feat: Phase 0 walking skeleton — monorepo, API, web, ML stub
Sets up the full Phase 0 foundation: - pnpm workspaces + turbo build graph; native module build approval - packages/shared-types: HTTP contracts (Tip, Auth, Integrations, User) - services/api: Express modular monolith with better-sqlite3/drizzle - auth: Google OAuth2 + PKCE via openid-client v6, cookie sessions - integrations: Todoist OAuth2 connect/disconnect, token vault - recommender: RandomPolicy over Todoist tasks, feedback sink - user: profile, consent capture, full account deletion (GDPR) - apps/web: Next.js 15, three pages (sign-in → connect → tip) - tip page: black canvas, hold-to-act gesture, action sheet - PWA manifest + theme - ml/serving: FastAPI stub implementing the POST /score contract - infra: docker-compose (core/full profiles), Dockerfiles, CI skeleton - .env.example with all required vars documented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
156
apps/web/src/app/connect/page.tsx
Normal file
156
apps/web/src/app/connect/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { getSession, getIntegrations, disconnectIntegration } from '@/lib/api';
|
||||
import type { Integration } from '@oo/shared-types';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
function ConnectPageInner() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [integrations, setIntegrations] = useState<Integration[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [disconnecting, setDisconnecting] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const { user } = await getSession();
|
||||
if (!user) { router.replace('/sign-in'); return; }
|
||||
const { integrations: list } = await getIntegrations();
|
||||
setIntegrations(list);
|
||||
setLoading(false);
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Show banner if just connected
|
||||
const justConnected = searchParams.get('connected');
|
||||
|
||||
const isConnected = (provider: string) =>
|
||||
integrations.some((i) => i.provider === provider && i.status === 'connected');
|
||||
|
||||
const handleDisconnect = async (provider: string) => {
|
||||
setDisconnecting(provider);
|
||||
await disconnectIntegration(provider);
|
||||
await load();
|
||||
setDisconnecting(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ color: 'var(--gray)', fontSize: '0.875rem' }}>Loading…</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const todoistConnected = isConnected('todoist');
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', padding: '4rem 2rem', maxWidth: '480px', margin: '0 auto' }}>
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 300, marginBottom: '0.5rem', letterSpacing: '-0.02em' }}>
|
||||
Connect your apps
|
||||
</h2>
|
||||
<p style={{ color: 'var(--gray)', fontSize: '0.875rem', marginBottom: '3rem' }}>
|
||||
oO reads what you need, when you need it.
|
||||
</p>
|
||||
|
||||
{justConnected && (
|
||||
<div style={{
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
marginBottom: '1.5rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--white)',
|
||||
}}>
|
||||
{justConnected} connected.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Todoist card */}
|
||||
<div style={{
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1.25rem 1.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '1rem',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.875rem' }}>
|
||||
{/* Todoist logomark */}
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-label="Todoist">
|
||||
<rect width="24" height="24" rx="6" fill="#DB4035"/>
|
||||
<path d="M6 8.5L11 13l7-7" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: '0.9rem' }}>Todoist</div>
|
||||
<div style={{ color: 'var(--gray)', fontSize: '0.75rem', marginTop: '0.1rem' }}>
|
||||
{todoistConnected ? 'Connected' : 'Tasks & to-dos'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{todoistConnected ? (
|
||||
<button
|
||||
onClick={() => handleDisconnect('todoist')}
|
||||
disabled={disconnecting === 'todoist'}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
color: 'var(--gray)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.375rem 0.875rem',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
{disconnecting === 'todoist' ? '…' : 'Disconnect'}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="/api/integrations/todoist/connect?redirectTo=/connect"
|
||||
style={{
|
||||
background: 'var(--white)',
|
||||
color: 'var(--black)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.375rem 0.875rem',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{todoistConnected && (
|
||||
<div style={{ marginTop: '3rem' }}>
|
||||
<a
|
||||
href="/tip"
|
||||
style={{
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
background: 'var(--white)',
|
||||
color: 'var(--black)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.875rem',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
See my tip →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConnectPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ConnectPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user