feat: add setup wizard components (SourcePicker, ConnectFlow, IngestDashboard, ReadyScreen)

SourcePicker renders a grouped card grid for selecting data sources; SourceConnectFlow
provides per-source auth panels (OAuth, filesystem, local); IngestDashboard polls sync
status every 2s with progress bars; ReadyScreen shows a celebration screen with
context-aware starter queries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
krypticmouse
2026-03-26 18:08:37 +00:00
co-authored by Claude Sonnet 4.6
parent 53233f94ae
commit 1b9cdc44c2
4 changed files with 941 additions and 0 deletions
@@ -0,0 +1,179 @@
import { useState, useEffect, useCallback } from 'react';
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
import { getSyncStatus } from '../../lib/connectors-api';
import { SOURCE_CATALOG } from '../../types/connectors';
import type { SyncStatus } from '../../types/connectors';
// ---------------------------------------------------------------------------
// ProgressRow
// ---------------------------------------------------------------------------
function ProgressRow({
displayName,
status,
}: {
displayName: string;
status: SyncStatus | null;
}) {
const isDone = status?.state === 'idle' && (status?.items_synced ?? 0) > 0;
const pct =
status && status.items_total > 0
? Math.min(100, Math.round((status.items_synced / status.items_total) * 100))
: isDone
? 100
: 0;
return (
<div className="flex flex-col gap-1.5 p-4 rounded-xl"
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
}}>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
{displayName}
</span>
<div className="flex items-center gap-1.5 shrink-0">
{!status || status.state === 'idle' ? (
isDone ? (
<CheckCircle2 size={14} style={{ color: 'var(--color-accent)' }} />
) : (
<Loader2 size={14} className="animate-spin" style={{ color: 'var(--color-text-tertiary)' }} />
)
) : status.state === 'syncing' ? (
<Loader2 size={14} className="animate-spin" style={{ color: 'var(--color-accent)' }} />
) : status.state === 'paused' ? (
<Loader2 size={14} style={{ color: 'var(--color-text-tertiary)' }} />
) : (
<AlertCircle size={14} style={{ color: '#ef4444' }} />
)}
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
{status?.state === 'syncing'
? `${status.items_synced} / ${status.items_total}`
: isDone
? `${status!.items_synced} items`
: status?.state === 'paused'
? 'Paused'
: status?.state === 'error'
? 'Error'
: 'Starting...'}
</span>
</div>
</div>
<div
className="h-1.5 rounded-full overflow-hidden"
style={{ background: 'var(--color-bg-tertiary)' }}
>
<div
className="h-full rounded-full transition-all duration-500"
style={{
background:
status?.state === 'error' ? '#ef4444' : 'var(--color-accent)',
width: `${pct}%`,
}}
/>
</div>
{status?.error && (
<p className="text-xs" style={{ color: '#ef4444' }}>
{status.error}
</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// IngestDashboard
// ---------------------------------------------------------------------------
export function IngestDashboard({
connectedIds,
onReady,
}: {
connectedIds: string[];
onReady: () => void;
}) {
const [statuses, setStatuses] = useState<Record<string, SyncStatus | null>>(() =>
Object.fromEntries(connectedIds.map((id) => [id, null])),
);
const poll = useCallback(async () => {
const updates = await Promise.all(
connectedIds.map(async (id) => {
try {
const s = await getSyncStatus(id);
return [id, s] as [string, SyncStatus];
} catch {
return [id, null] as [string, null];
}
}),
);
setStatuses(Object.fromEntries(updates));
}, [connectedIds]);
useEffect(() => {
poll();
const interval = setInterval(poll, 2000);
return () => clearInterval(interval);
}, [poll]);
const allDone = connectedIds.every(
(id) => statuses[id]?.state === 'error' ||
(statuses[id]?.state === 'idle' && (statuses[id]?.items_synced ?? 0) > 0),
);
const totalSynced = Object.values(statuses).reduce(
(sum, s) => sum + (s?.items_synced ?? 0),
0,
);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="mb-6">
<h2 className="text-xl font-bold mb-1" style={{ color: 'var(--color-text)' }}>
{allDone ? 'Sync complete' : 'Syncing your data...'}
</h2>
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
{allDone
? `Indexed ${totalSynced} items across ${connectedIds.length} source${connectedIds.length !== 1 ? 's' : ''}.`
: 'This may take a few minutes depending on your data volume.'}
</p>
</div>
{/* Progress rows */}
<div className="flex flex-col gap-3 flex-1 overflow-y-auto">
{connectedIds.map((id) => {
const card = SOURCE_CATALOG.find((c) => c.connector_id === id);
return (
<ProgressRow
key={id}
displayName={card?.display_name ?? id}
status={statuses[id] ?? null}
/>
);
})}
</div>
{/* Footer */}
<div className="pt-4 border-t" style={{ borderColor: 'var(--color-border)' }}>
<button
onClick={onReady}
className="w-full py-3 px-4 rounded-xl font-semibold text-sm flex items-center justify-center gap-2 transition-all"
style={{
background: 'var(--color-accent)',
color: 'white',
}}
>
{!allDone && <Loader2 size={16} className="animate-spin" />}
Start Researching
</button>
{!allDone && (
<p className="text-center text-xs mt-2" style={{ color: 'var(--color-text-tertiary)' }}>
Sync will continue in the background
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,142 @@
import { Sparkles, MessageSquare, ArrowRight } from 'lucide-react';
import { SOURCE_CATALOG } from '../../types/connectors';
// ---------------------------------------------------------------------------
// Starter queries
// ---------------------------------------------------------------------------
function getStarterQueries(connectedSources: string[]): string[] {
const queries: string[] = [];
const has = (id: string) => connectedSources.includes(id);
if (has('gmail') || has('gmail_imap')) {
queries.push('What emails need my attention today?');
}
if (has('gcalendar')) {
queries.push("What's on my calendar this week?");
}
if (has('slack')) {
queries.push('Summarize important Slack messages from yesterday');
}
if (has('gdrive') || has('notion') || has('obsidian')) {
queries.push('Find my notes about project planning');
}
if (has('imessage')) {
queries.push('What have I been texting about lately?');
}
if (has('gcontacts')) {
queries.push('Who are my most frequent collaborators?');
}
if (has('granola')) {
queries.push('Summarize my recent meeting notes');
}
// Fallback defaults
if (queries.length === 0) {
return [
'What should I focus on today?',
'Summarize my recent activity',
'Help me draft a quick update',
];
}
return queries.slice(0, 3);
}
// ---------------------------------------------------------------------------
// StarterCard
// ---------------------------------------------------------------------------
function StarterCard({
query,
onSelect,
}: {
query: string;
onSelect: (q: string) => void;
}) {
return (
<button
onClick={() => onSelect(query)}
className="flex items-center justify-between gap-3 w-full px-4 py-3 rounded-xl text-left transition-all group"
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
}}
>
<div className="flex items-center gap-3">
<MessageSquare size={16} style={{ color: 'var(--color-accent)', flexShrink: 0 }} />
<span className="text-sm" style={{ color: 'var(--color-text)' }}>
{query}
</span>
</div>
<ArrowRight
size={14}
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ color: 'var(--color-text-tertiary)' }}
/>
</button>
);
}
// ---------------------------------------------------------------------------
// ReadyScreen
// ---------------------------------------------------------------------------
export function ReadyScreen({
connectedSources,
onStart,
}: {
connectedSources: string[];
onStart: (query?: string) => void;
}) {
const starters = getStarterQueries(connectedSources);
const connectedCards = connectedSources
.map((id) => SOURCE_CATALOG.find((c) => c.connector_id === id))
.filter(Boolean);
return (
<div className="flex flex-col items-center text-center h-full justify-center gap-6">
{/* Icon */}
<div
className="w-20 h-20 rounded-2xl flex items-center justify-center"
style={{ background: 'var(--color-accent-subtle)', color: 'var(--color-accent)' }}
>
<Sparkles size={36} />
</div>
{/* Headline */}
<div>
<h2 className="text-2xl font-bold mb-2" style={{ color: 'var(--color-text)' }}>
You're all set!
</h2>
<p className="text-sm max-w-sm" style={{ color: 'var(--color-text-secondary)' }}>
{connectedCards.length > 0
? `Connected ${connectedCards.length} source${connectedCards.length !== 1 ? 's' : ''}: ${connectedCards.map((c) => c!.display_name).join(', ')}.`
: 'Your personal AI is ready to help.'}
{' '}Ask anything about your work and life.
</p>
</div>
{/* Starter queries */}
<div className="w-full max-w-sm flex flex-col gap-2">
<p className="text-xs font-semibold uppercase tracking-wider mb-1 text-left"
style={{ color: 'var(--color-text-tertiary)' }}>
Try asking
</p>
{starters.map((q) => (
<StarterCard key={q} query={q} onSelect={onStart} />
))}
</div>
{/* Open Chat button */}
<button
onClick={() => onStart()}
className="px-8 py-3 rounded-xl font-semibold text-sm transition-all"
style={{ background: 'var(--color-accent)', color: 'white' }}
>
Open Chat
</button>
</div>
);
}
@@ -0,0 +1,442 @@
import { useState } from 'react';
import {
CheckCircle2,
Circle,
SkipForward,
ExternalLink,
FolderOpen,
Loader2,
} from 'lucide-react';
import { SOURCE_CATALOG } from '../../types/connectors';
import { connectSource } from '../../lib/connectors-api';
import type { ConnectRequest } from '../../types/connectors';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type SourceState = 'pending' | 'connecting' | 'connected' | 'skipped' | 'error';
interface SourceEntry {
id: string;
state: SourceState;
error?: string;
}
// ---------------------------------------------------------------------------
// Sidebar item
// ---------------------------------------------------------------------------
function SidebarItem({
label,
state,
active,
onClick,
}: {
label: string;
state: SourceState;
active: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-left transition-all"
style={{
background: active ? 'var(--color-accent-subtle)' : 'transparent',
border: active ? '1px solid var(--color-accent)' : '1px solid transparent',
}}
>
<div className="shrink-0">
{state === 'connected' ? (
<CheckCircle2 size={16} style={{ color: 'var(--color-accent)' }} />
) : state === 'connecting' ? (
<Loader2 size={16} className="animate-spin" style={{ color: 'var(--color-accent)' }} />
) : state === 'skipped' ? (
<SkipForward size={16} style={{ color: 'var(--color-text-tertiary)' }} />
) : state === 'error' ? (
<Circle size={16} style={{ color: '#ef4444' }} />
) : (
<Circle size={16} style={{ color: 'var(--color-text-tertiary)' }} />
)}
</div>
<span
className="text-sm truncate"
style={{
color:
state === 'skipped'
? 'var(--color-text-tertiary)'
: 'var(--color-text)',
textDecoration: state === 'skipped' ? 'line-through' : 'none',
}}
>
{label}
</span>
</button>
);
}
// ---------------------------------------------------------------------------
// Auth panels
// ---------------------------------------------------------------------------
function FilesystemPanel({
displayName,
onConnect,
onSkip,
isConnecting,
}: {
displayName: string;
onConnect: (req: ConnectRequest) => void;
onSkip: () => void;
isConnecting: boolean;
}) {
const [path, setPath] = useState('');
return (
<div className="flex flex-col gap-4">
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Enter the path to your local {displayName} folder.
</p>
<div className="flex gap-2">
<input
type="text"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/Users/you/Documents/..."
className="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
}}
/>
<button
onClick={() => onConnect({ path })}
disabled={!path.trim() || isConnecting}
className="px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-all"
style={{
background: path.trim() ? 'var(--color-accent)' : 'var(--color-bg-tertiary)',
color: path.trim() ? 'white' : 'var(--color-text-tertiary)',
cursor: path.trim() && !isConnecting ? 'pointer' : 'not-allowed',
}}
>
{isConnecting ? <Loader2 size={14} className="animate-spin" /> : <FolderOpen size={14} />}
Connect
</button>
</div>
<button
onClick={onSkip}
className="text-xs self-start"
style={{ color: 'var(--color-text-tertiary)' }}
>
Skip for now
</button>
</div>
);
}
function OAuthPanel({
displayName,
authUrl,
onConnect,
onSkip,
isConnecting,
}: {
displayName: string;
authUrl?: string;
onConnect: (req: ConnectRequest) => void;
onSkip: () => void;
isConnecting: boolean;
}) {
const [token, setToken] = useState('');
const [phase, setPhase] = useState<'start' | 'paste'>('start');
const openBrowser = () => {
if (authUrl) {
window.open(authUrl, '_blank');
}
setPhase('paste');
};
return (
<div className="flex flex-col gap-4">
{phase === 'start' ? (
<>
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Authorize OpenJarvis to access your {displayName} account.
</p>
<button
onClick={openBrowser}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium self-start transition-all"
style={{
background: 'var(--color-accent)',
color: 'white',
}}
>
<ExternalLink size={14} />
Open in browser
</button>
</>
) : (
<>
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
After authorizing, paste the token or code below.
</p>
<textarea
value={token}
onChange={(e) => setToken(e.target.value)}
rows={3}
placeholder="Paste auth token or code here..."
className="w-full px-3 py-2 rounded-lg text-sm outline-none resize-none font-mono"
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
}}
/>
<div className="flex gap-2">
<button
onClick={() => onConnect({ token, code: token })}
disabled={!token.trim() || isConnecting}
className="px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-all"
style={{
background: token.trim() ? 'var(--color-accent)' : 'var(--color-bg-tertiary)',
color: token.trim() ? 'white' : 'var(--color-text-tertiary)',
cursor: token.trim() && !isConnecting ? 'pointer' : 'not-allowed',
}}
>
{isConnecting && <Loader2 size={14} className="animate-spin" />}
Confirm
</button>
<button
onClick={() => setPhase('start')}
className="px-4 py-2 rounded-lg text-sm font-medium transition-all"
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
Back
</button>
</div>
</>
)}
<button
onClick={onSkip}
className="text-xs self-start"
style={{ color: 'var(--color-text-tertiary)' }}
>
Skip for now
</button>
</div>
);
}
function LocalPanel({
displayName,
onConnect,
onSkip,
isConnecting,
}: {
displayName: string;
onConnect: (req: ConnectRequest) => void;
onSkip: () => void;
isConnecting: boolean;
}) {
return (
<div className="flex flex-col gap-4">
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
{displayName} reads data directly from your Mac. Make sure the app is installed and
Full Disk Access is granted to OpenJarvis in System Settings.
</p>
<div
className="px-4 py-3 rounded-lg text-sm"
style={{
background: 'var(--color-bg-tertiary)',
color: 'var(--color-text-secondary)',
}}
>
<strong>System Settings</strong> Privacy &amp; Security Full Disk Access
enable OpenJarvis
</div>
<button
onClick={() => onConnect({})}
disabled={isConnecting}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium self-start transition-all"
style={{
background: 'var(--color-accent)',
color: 'white',
cursor: isConnecting ? 'not-allowed' : 'pointer',
opacity: isConnecting ? 0.7 : 1,
}}
>
{isConnecting && <Loader2 size={14} className="animate-spin" />}
Check Access
</button>
<button
onClick={onSkip}
className="text-xs self-start"
style={{ color: 'var(--color-text-tertiary)' }}
>
Skip for now
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// SourceConnectFlow
// ---------------------------------------------------------------------------
export function SourceConnectFlow({
selectedIds,
onComplete,
}: {
selectedIds: string[];
onComplete: () => void;
}) {
const [entries, setEntries] = useState<SourceEntry[]>(() =>
selectedIds.map((id) => ({ id, state: 'pending' as SourceState })),
);
const [activeIndex, setActiveIndex] = useState(0);
const updateEntry = (id: string, patch: Partial<SourceEntry>) => {
setEntries((prev) => prev.map((e) => (e.id === id ? { ...e, ...patch } : e)));
};
const advanceToNext = (currentIndex: number) => {
const next = entries.findIndex((e, i) => i > currentIndex && e.state === 'pending');
if (next !== -1) {
setActiveIndex(next);
} else {
onComplete();
}
};
const handleConnect = async (id: string, req: ConnectRequest) => {
updateEntry(id, { state: 'connecting', error: undefined });
try {
await connectSource(id, req);
updateEntry(id, { state: 'connected' });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
updateEntry(id, { state: 'error', error: msg });
return;
}
advanceToNext(activeIndex);
};
const handleSkip = (id: string) => {
updateEntry(id, { state: 'skipped' });
advanceToNext(activeIndex);
};
const activeEntry = entries[activeIndex];
const activeCard = activeEntry
? SOURCE_CATALOG.find((c) => c.connector_id === activeEntry.id)
: null;
const allDone = entries.every((e) => e.state === 'connected' || e.state === 'skipped');
return (
<div className="flex h-full gap-6">
{/* Sidebar */}
<div className="w-48 shrink-0 flex flex-col gap-1 py-1">
<p className="text-xs font-semibold uppercase tracking-wider mb-2"
style={{ color: 'var(--color-text-tertiary)' }}>
Sources
</p>
{entries.map((entry, idx) => {
const card = SOURCE_CATALOG.find((c) => c.connector_id === entry.id);
return (
<SidebarItem
key={entry.id}
label={card?.display_name ?? entry.id}
state={entry.state}
active={idx === activeIndex}
onClick={() => setActiveIndex(idx)}
/>
);
})}
</div>
{/* Main content */}
<div className="flex-1 flex flex-col">
{activeCard && activeEntry ? (
<>
<div className="mb-6">
<h2 className="text-xl font-bold mb-1" style={{ color: 'var(--color-text)' }}>
{activeCard.display_name}
</h2>
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
{activeCard.description}
</p>
{activeEntry.state === 'error' && activeEntry.error && (
<div
className="mt-3 px-4 py-3 rounded-lg text-sm"
style={{
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.2)',
color: '#ef4444',
}}
>
{activeEntry.error}
</div>
)}
</div>
{activeEntry.state === 'connected' ? (
<div className="flex items-center gap-2 text-sm"
style={{ color: 'var(--color-accent)' }}>
<CheckCircle2 size={18} />
Connected
</div>
) : activeCard.auth_type === 'filesystem' ? (
<FilesystemPanel
displayName={activeCard.display_name}
onConnect={(req) => handleConnect(activeEntry.id, req)}
onSkip={() => handleSkip(activeEntry.id)}
isConnecting={activeEntry.state === 'connecting'}
/>
) : activeCard.auth_type === 'local' ? (
<LocalPanel
displayName={activeCard.display_name}
onConnect={(req) => handleConnect(activeEntry.id, req)}
onSkip={() => handleSkip(activeEntry.id)}
isConnecting={activeEntry.state === 'connecting'}
/>
) : (
<OAuthPanel
displayName={activeCard.display_name}
authUrl={undefined}
onConnect={(req) => handleConnect(activeEntry.id, req)}
onSkip={() => handleSkip(activeEntry.id)}
isConnecting={activeEntry.state === 'connecting'}
/>
)}
</>
) : (
<div className="flex flex-col items-center justify-center flex-1 gap-3">
<CheckCircle2 size={32} style={{ color: 'var(--color-accent)' }} />
<p className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
All sources configured
</p>
</div>
)}
{allDone && (
<div className="mt-auto pt-4">
<button
onClick={onComplete}
className="w-full py-3 px-4 rounded-xl font-semibold text-sm transition-all"
style={{ background: 'var(--color-accent)', color: 'white' }}
>
Continue
</button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,178 @@
import { useState } from 'react';
import {
Mail,
Hash,
MessageSquare,
FolderOpen,
FileText,
Diamond,
Mic,
Calendar,
Users,
CheckCircle2,
} from 'lucide-react';
import { SOURCE_CATALOG, type SourceCard } from '../../types/connectors';
// ---------------------------------------------------------------------------
// Icon map
// ---------------------------------------------------------------------------
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
Mail,
Hash,
MessageSquare,
FolderOpen,
FileText,
Diamond,
Mic,
Calendar,
Users,
};
// ---------------------------------------------------------------------------
// CategorySection
// ---------------------------------------------------------------------------
function CategorySection({
category,
label,
cards,
selected,
onToggle,
}: {
category: string;
label: string;
cards: SourceCard[];
selected: Set<string>;
onToggle: (id: string) => void;
}) {
return (
<div className="mb-6">
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--color-text-tertiary)' }}>
{label}
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{cards.map((card) => {
const Icon = ICON_MAP[card.icon] ?? Mail;
const isSelected = selected.has(card.connector_id);
return (
<button
key={card.connector_id}
onClick={() => onToggle(card.connector_id)}
className="relative flex flex-col items-start gap-2 p-4 rounded-xl text-left transition-all"
style={{
background: isSelected
? 'var(--color-accent-subtle)'
: 'var(--color-surface)',
border: isSelected
? '1.5px solid var(--color-accent)'
: '1.5px solid var(--color-border)',
}}
>
{isSelected && (
<CheckCircle2
size={16}
className="absolute top-3 right-3"
style={{ color: 'var(--color-accent)' }}
/>
)}
<div
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ background: 'var(--color-bg-tertiary)' }}
>
<Icon size={18} className={card.color} />
</div>
<div>
<div
className="text-sm font-semibold leading-snug"
style={{ color: 'var(--color-text)' }}
>
{card.display_name}
</div>
<div
className="text-xs leading-snug mt-0.5"
style={{ color: 'var(--color-text-tertiary)' }}
>
{card.description}
</div>
</div>
</button>
);
})}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// SourcePicker
// ---------------------------------------------------------------------------
const CATEGORIES: { key: 'communication' | 'documents' | 'pim'; label: string }[] = [
{ key: 'communication', label: 'Communication' },
{ key: 'documents', label: 'Documents' },
{ key: 'pim', label: 'Personal Info' },
];
export function SourcePicker({ onContinue }: { onContinue: (selectedIds: string[]) => void }) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="mb-6">
<h2 className="text-xl font-bold mb-1" style={{ color: 'var(--color-text)' }}>
Connect your sources
</h2>
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Choose which data sources to include in your personal knowledge base.
</p>
</div>
{/* Cards by category */}
<div className="flex-1 overflow-y-auto">
{CATEGORIES.map(({ key, label }) => (
<CategorySection
key={key}
category={key}
label={label}
cards={SOURCE_CATALOG.filter((s) => s.category === key)}
selected={selected}
onToggle={toggle}
/>
))}
</div>
{/* Footer */}
<div className="pt-4 border-t" style={{ borderColor: 'var(--color-border)' }}>
<button
onClick={() => onContinue(Array.from(selected))}
disabled={selected.size === 0}
className="w-full py-3 px-4 rounded-xl font-semibold text-sm transition-all"
style={{
background: selected.size > 0 ? 'var(--color-accent)' : 'var(--color-bg-tertiary)',
color: selected.size > 0 ? 'white' : 'var(--color-text-tertiary)',
cursor: selected.size > 0 ? 'pointer' : 'not-allowed',
}}
>
{selected.size === 0
? 'Select sources to continue'
: `Connect ${selected.size} source${selected.size !== 1 ? 's' : ''}`}
</button>
</div>
</div>
);
}