mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-31 03:12:16 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
53233f94ae
commit
1b9cdc44c2
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user