feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m3s
CI / Deploy production (on server) (push) Successful in 23s

Briefing granulaire, pistes rapides puis enrichissement async, layout persisté v5,
suggestions agents, intégration Gmail et navigation sidebar alignée sur /home.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-07-14 16:50:53 +00:00
parent d38a99586b
commit 30da592ba2
62 changed files with 7741 additions and 335 deletions

View File

@@ -24,14 +24,14 @@ const PROVIDER_INFO: Record<string, { name: string; hint: string }> = {
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' },
openrouter: { name: 'OpenRouter', hint: 'Accès multi-fournisseurs' },
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
openrouter: { name: 'OpenRouter', hint: 'Multi-provider access' },
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
zai: { name: 'Zuki Journey', hint: 'Proxy OpenAI/Anthropic' },
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Proxy compatible Anthropic' },
custom_openai: { name: 'Compatible OpenAI', hint: 'Tout proxy compatible OpenAI' },
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Tout proxy compatible Anthropic' },
custom: { name: 'API personnalisée', hint: 'Votre propre endpoint' },
zai: { name: 'Zuki Journey', hint: 'OpenAI/Anthropic proxy' },
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Anthropic-compatible proxy' },
custom_openai: { name: 'Compatible OpenAI', hint: 'Any OpenAI-compatible proxy' },
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Any Anthropic-compatible proxy' },
custom: { name: 'Custom API', hint: 'Your own endpoint' },
}
function displayName(provider: string): string {
@@ -59,7 +59,7 @@ type SavedKey = {
async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> {
const res = await fetch('/api/user/api-keys')
if (!res.ok) throw new Error('Erreur de chargement')
if (!res.ok) throw new Error('Failed to load')
return res.json()
}
@@ -92,6 +92,8 @@ function EditKeyForm({
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null)
const { t } = useLanguage()
const needsUrl = NEEDS_BASE_URL.has(savedKey.provider)
const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider)
@@ -118,10 +120,10 @@ function EditKeyForm({
body: JSON.stringify(payload),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
if (!res.ok) throw new Error(body.message ?? body.error ?? t('common.error'))
},
onSuccess: () => {
toast.success(`${displayName(savedKey.provider)} mis à jour ✓`)
toast.success(t('byok.updated', { name: displayName(savedKey.provider) }))
onInvalidate()
onDone()
},
@@ -132,7 +134,7 @@ function EditKeyForm({
const keyToUse = newKey.length >= 8 ? newKey : ''
if (!keyToUse && !model) return
if (!keyToUse) {
toast.info('Pour tester, entrez la clé API dans le champ "Nouvelle clé"')
toast.info(t('byok.testHint'))
return
}
setTesting(true)
@@ -143,7 +145,7 @@ function EditKeyForm({
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
setTestResult(await res.json())
} catch {
setTestResult({ ok: false, error: 'Impossible de contacter le serveur' })
setTestResult({ ok: false, error: t('byok.contactError') })
} finally {
setTesting(false)
}
@@ -155,18 +157,18 @@ function EditKeyForm({
return (
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-violet-600 dark:text-violet-400">
Modifier {displayName(savedKey.provider)}
{t('byok.editLabel', { name: displayName(savedKey.provider) })}
</p>
{/* Alias */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Alias</Label>
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="ex. Ma clé pro" />
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')}</Label>
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} />
</div>
{needsUrl && (
<div className="space-y-1">
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">URL de l'API</Label>
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
<Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
</div>
)}
@@ -174,21 +176,21 @@ function EditKeyForm({
{/* Model */}
<div className="space-y-1">
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
{showModelDropdown ? (
<Select value={model} onValueChange={setModel}>
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder="Choisir..." /></SelectTrigger>
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder={t('byok.choose')} /></SelectTrigger>
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
) : showModelInput ? (
<Input id={`edit-model-${savedKey.provider}`} value={model} onChange={(e) => setModel(e.target.value)} placeholder="ex. MiniMax-M2.7" />
) : <div className="flex items-center gap-2 text-xs text-concrete py-1"><Loader2 className="h-3 w-3 animate-spin" />Chargement...</div>}
) : <div className="flex items-center gap-2 text-xs text-concrete py-1"><Loader2 className="h-3 w-3 animate-spin" />{t('common.loading')}</div>}
</div>
{/* Key rotation (optional) */}
<div className="space-y-1">
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">
Nouvelle clé API <span className="normal-case font-normal text-concrete">(laisser vide pour conserver l'actuelle)</span>
{t('byok.newKey')} <span className="normal-case font-normal text-concrete">{t('byok.newKeyHint')}</span>
</Label>
<Input
id={`edit-key-${savedKey.provider}`}
@@ -196,7 +198,7 @@ function EditKeyForm({
autoComplete="off"
value={newKey}
onChange={(e) => { setNewKey(e.target.value); setTestResult(null) }}
placeholder="sk-... (optionnel)"
placeholder={t('byok.keyPlaceholder')}
/>
</div>
@@ -209,8 +211,8 @@ function EditKeyForm({
{testResult.ok ? <CheckCircle2 size={12} className="shrink-0 mt-0.5" /> : <XCircle size={12} className="shrink-0 mt-0.5" />}
<span>
{testResult.ok
? `✓ Opérationnel${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
: testResult.error ?? 'Échec'
? `${t('byok.operational')}${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
: testResult.error ?? t('common.error')
}
</span>
</div>
@@ -225,7 +227,7 @@ function EditKeyForm({
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-violet-400 transition-colors disabled:opacity-40 disabled:pointer-events-none"
>
{testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
Tester
{t('byok.test')}
</button>
<Button
type="button"
@@ -233,7 +235,7 @@ function EditKeyForm({
onClick={() => saveMutation.mutate()}
className="flex-1 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.12em] bg-ink text-paper shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
>
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Enregistrer les modifications'}
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('byok.saveChanges')}
</Button>
<button
type="button"
@@ -305,7 +307,7 @@ export function ByokSettingsPanel() {
async function verifyKey() {
if (!provider || apiKey.length < 8) return
if (NEEDS_BASE_URL.has(provider) && !baseUrl) { toast.error("Veuillez renseigner l'URL de l'API"); return }
if (NEEDS_BASE_URL.has(provider) && !baseUrl) { toast.error(t('byok.baseUrlRequired')); return }
setVerifying(true)
setTestResult(null)
try {
@@ -316,12 +318,12 @@ export function ByokSettingsPanel() {
if (res.ok && body.valid) {
setKeyOk(true)
if (Array.isArray(body.models) && body.models.length > 0) { setModels(body.models); if (!model) setModel(body.models[0]) }
toast.success('Clé API valide ✓')
toast.success(t('byok.keyValid'))
} else {
setKeyOk(false)
toast.error(body.message ?? 'Clé API invalide')
toast.error(body.message ?? t('byok.keyInvalid'))
}
} catch { setKeyOk(false); toast.error('Erreur de vérification') }
} catch { setKeyOk(false); toast.error(t('byok.verifyError')) }
finally { setVerifying(false) }
}
@@ -334,7 +336,7 @@ export function ByokSettingsPanel() {
if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl)
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
setTestResult(await res.json())
} catch { setTestResult({ ok: false, error: 'Impossible de contacter le serveur' }) }
} catch { setTestResult({ ok: false, error: t('byok.contactError') }) }
finally { setTesting(false) }
}
@@ -352,10 +354,10 @@ export function ByokSettingsPanel() {
body: JSON.stringify(payload),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
if (!res.ok) throw new Error(body.message ?? body.error ?? t('common.error'))
},
onSuccess: () => {
toast.success(`Clé ${displayName(provider)} enregistrée ✓`)
toast.success(t('byok.keySaved', { name: displayName(provider) }))
setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('')
setModel(''); setModels([]); setKeyOk(false); setTestResult(null)
invalidate()
@@ -369,7 +371,7 @@ export function ByokSettingsPanel() {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive }),
})
if (!res.ok) throw new Error('Erreur')
if (!res.ok) throw new Error(t('common.error'))
},
onSuccess: invalidate,
onError: () => toast.error(t('byokSettings.error')),
@@ -378,9 +380,9 @@ export function ByokSettingsPanel() {
const deleteMutation = useMutation({
mutationFn: async (p: string) => {
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Erreur')
if (!res.ok) throw new Error(t('common.error'))
},
onSuccess: () => { toast.success('Clé supprimée'); invalidate() },
onSuccess: () => { toast.success(t('byok.keyDeleted')); invalidate() },
onError: () => toast.error(t('byokSettings.error')),
})
@@ -417,9 +419,9 @@ export function ByokSettingsPanel() {
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
)}>
{activeKey ? (
<><Zap size={14} className="shrink-0" /><span>BYOK actif · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
<><Zap size={14} className="shrink-0" /><span>{t('byok.byokActive')} · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
) : (
<><Shield size={14} className="shrink-0" /><span>Aucune clé active utilisation des quotas Memento</span></>
<><Shield size={14} className="shrink-0" /><span>{t('byok.noActiveKey')}</span></>
)}
</div>
)}
@@ -431,7 +433,7 @@ export function ByokSettingsPanel() {
{/* Saved keys */}
{keys.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">Clés enregistrées</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byok.savedKeys')}</p>
<ul className="space-y-1">
{keys.map((key) => (
<li key={key.provider}>
@@ -448,7 +450,7 @@ export function ByokSettingsPanel() {
{key.model && <code className="text-[9px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
{key.alias && <span className="text-[9px] text-concrete">{key.alias}</span>}
<span className={cn('text-[9px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
{key.isActive ? '● Actif' : '○ Inactif'}
{key.isActive ? t('byok.activeStatus') : t('byok.inactiveStatus')}
</span>
</div>
</div>
@@ -481,7 +483,7 @@ export function ByokSettingsPanel() {
type="button"
className="h-7 w-7 rounded-lg flex items-center justify-center text-destructive/50 hover:text-destructive hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors"
disabled={deleteMutation.isPending}
onClick={() => { if (confirm(`Supprimer la clé ${displayName(key.provider)} ?`)) deleteMutation.mutate(key.provider) }}
onClick={() => { if (confirm(t('byok.confirmDelete', { name: displayName(key.provider) }))) deleteMutation.mutate(key.provider) }}
>
<Trash2 size={12} />
</button>
@@ -507,14 +509,14 @@ export function ByokSettingsPanel() {
{/* Add key form */}
<div className="space-y-4 border border-border/60 rounded-2xl p-5">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
{keys.length > 0 ? 'Ajouter / remplacer une clé' : 'Connecter votre fournisseur IA'}
{keys.length > 0 ? t('byok.addOrReplace') : t('byok.connectProvider')}
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Fournisseur</Label>
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
<Select value={provider} onValueChange={onProviderChange} disabled={saveMutation.isPending}>
<SelectTrigger id="byok-provider"><SelectValue placeholder="Choisir un fournisseur..." /></SelectTrigger>
<SelectTrigger id="byok-provider"><SelectValue placeholder={t('byok.chooseProvider')} /></SelectTrigger>
<SelectContent>
{allowed.map((p) => (
<SelectItem key={p} value={p}>
@@ -526,20 +528,20 @@ export function ByokSettingsPanel() {
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Alias <span className="normal-case font-normal">(optionnel)</span></Label>
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="ex. Ma clé pro" disabled={saveMutation.isPending} />
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')} <span className="normal-case font-normal">{t('byok.optional')}</span></Label>
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} disabled={saveMutation.isPending} />
</div>
</div>
{NEEDS_BASE_URL.has(provider) && (
<div className="space-y-1.5">
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">URL de l'API</Label>
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
<Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Clé API</Label>
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiKey')}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
@@ -555,19 +557,19 @@ export function ByokSettingsPanel() {
className={cn('shrink-0 px-4 py-2 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-violet-400')}
>
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : 'Vérifier'}
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : t('byok.verify')}
</button>
</div>
</div>
{provider && (
<div className="space-y-1.5">
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
{loadingModels ? (
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />Récupération des modèles...</div>
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />{t('byok.fetchingModels')}</div>
) : showModelDropdown ? (
<Select value={model} onValueChange={(v) => { setModel(v); setTestResult(null) }} disabled={saveMutation.isPending}>
<SelectTrigger id="byok-model"><SelectValue placeholder="Choisir un modèle..." /></SelectTrigger>
<SelectTrigger id="byok-model"><SelectValue placeholder={t('byok.chooseModel')} /></SelectTrigger>
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
) : showModelInput ? (
@@ -582,8 +584,8 @@ export function ByokSettingsPanel() {
{testResult.ok ? <CheckCircle2 size={14} className="shrink-0 mt-0.5" /> : <XCircle size={14} className="shrink-0 mt-0.5" />}
<div className="space-y-0.5">
{testResult.ok ? (
<><p className="font-semibold">Modèle opérationnel ✓{testResult.latency && <span className="font-normal ml-1">({testResult.latency}ms)</span>}</p>{testResult.reply && <p className="opacity-70 font-mono text-[10px]">Réponse : {testResult.reply}</p>}</>
) : <p className="font-semibold">{testResult.error ?? 'Échec du test'}</p>}
<><p className="font-semibold">{t('byok.modelOperational')}{testResult.latency && <span className="font-normal ml-1">({testResult.latency}ms)</span>}</p>{testResult.reply && <p className="opacity-70 font-mono text-[10px]">{t('byok.reply')} {testResult.reply}</p>}</>
) : <p className="font-semibold">{testResult.error ?? t('byok.testFailed')}</p>}
</div>
</div>
)}
@@ -593,7 +595,7 @@ export function ByokSettingsPanel() {
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-violet-400 disabled:opacity-40 disabled:pointer-events-none"
>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}Tester
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}{t('byok.test')}
</button>
<Button
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
@@ -601,7 +603,7 @@ export function ByokSettingsPanel() {
>
<div className="flex items-center justify-center gap-2">
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{provider ? `Enregistrer — ${displayName(provider)}` : 'Enregistrer'}
{provider ? t('byok.saveWithName', { name: displayName(provider) }) : t('byok.save')}
</div>
</Button>
</div>

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Search, Sparkles, Link2, X, Folder } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
export interface BlockSuggestion {
blockId: string
@@ -22,6 +23,7 @@ interface BlockPickerProps {
}
export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: BlockPickerProps) {
const { t } = useLanguage()
const [activeTab, setActiveTab] = useState<'suggestions' | 'search'>('suggestions')
const [searchQuery, setSearchQuery] = useState('')
const [suggestions, setSuggestions] = useState<BlockSuggestion[]>([])
@@ -134,7 +136,7 @@ export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: B
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Rechercher un extrait de note..."
placeholder={t('blockPicker.searchPlaceholder')}
className="w-full bg-white dark:bg-zinc-850 border border-[#D5D2CD] dark:border-neutral-800 rounded-xl pl-9 pr-4 py-2 text-xs outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-all font-sans"
autoFocus
/>

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useLanguage } from '@/lib/i18n'
import { motion } from 'motion/react'
import { Zap, Lightbulb, Trophy } from 'lucide-react'
@@ -32,6 +33,7 @@ interface BridgeNotesDashboardProps {
}
export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashboardProps) {
const { t } = useLanguage()
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
const [loading, setLoading] = useState(true)
@@ -109,14 +111,14 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
<div className="flex items-center gap-2 text-indigo-500 mb-2">
<Trophy size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">Bridges</span>
<span className="text-[10px] font-bold uppercase tracking-widest">{t('bridgeNotes.bridges')}</span>
</div>
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
</div>
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
<div className="flex items-center gap-2 text-ochre mb-2">
<Lightbulb size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">Suggestions</span>
<span className="text-[10px] font-bold uppercase tracking-widest">{t('bridgeNotes.suggestions')}</span>
</div>
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{suggestions.length}</div>
</div>
@@ -126,7 +128,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<section>
<div className="flex items-center gap-2 mb-6 px-1">
<Zap size={16} className="text-ochre" />
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Powerful Bridge Notes</h3>
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">{t('bridgeNotes.powerfulTitle')}</h3>
</div>
<div className="space-y-3">
{bridgeNotes.map((bridge) => (
@@ -138,10 +140,10 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">
{bridge.note?.title || 'Untitled'}
{bridge.note?.title || t('common.untitled')}
</h4>
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
Score: {(bridge.bridgeScore * 100).toFixed(0)}%
{t('bridgeNotes.score')}: {(bridge.bridgeScore * 100).toFixed(0)}%
</span>
</div>
<div className="flex items-center gap-2">
@@ -158,7 +160,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
</motion.div>
))}
{bridgeNotes.length === 0 && (
<div className="text-xs text-concrete italic p-4">No significant bridge notes found yet. Deepen your research to find new connections.</div>
<div className="text-xs text-concrete italic p-4">{t('bridgeNotes.empty')}</div>
)}
</div>
</section>
@@ -167,7 +169,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<section>
<div className="flex items-center gap-2 mb-6 px-1">
<Lightbulb size={16} className="text-indigo-500" />
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Missing Links (AI Generated)</h3>
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">{t('bridgeNotes.missingLinks')}</h3>
</div>
<div className="space-y-4">
{suggestions.map((s, idx) => (
@@ -180,7 +182,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<div className="w-6 h-6 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[10px] text-white">B</div>
</div>
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">
Bridging {s.clusterAName} & {s.clusterBName}
{t('bridgeNotes.bridging')} {s.clusterAName} & {s.clusterBName}
</span>
</div>
<h4 className="text-base font-memento-serif font-medium text-ink dark:text-dark-ink mb-2">{s.suggestedTitle}</h4>
@@ -193,7 +195,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<button
onClick={() => dismissSuggestion(s.clusterAId, s.clusterBId)}
className="ml-4 p-2 text-concrete hover:text-ink dark:hover:text-dark-ink hover:bg-concrete/10 rounded-lg transition-colors"
title="Dismiss suggestion"
title={t('bridgeNotes.dismiss')}
>
×
</button>
@@ -203,13 +205,13 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
{suggestions.length === 0 && !loading && (
<div className="text-center py-8 text-concrete">
<Lightbulb size={24} className="mx-auto mb-3 opacity-50" />
<p className="text-sm">No connection suggestions yet</p>
<p className="text-xs mt-1">All your clusters may already be connected!</p>
<p className="text-sm">{t('bridgeNotes.noSuggestions')}</p>
<p className="text-xs mt-1">{t('bridgeNotes.allConnected')}</p>
<button
onClick={generateNewSuggestions}
className="mt-4 px-4 py-2 bg-indigo-500 text-white text-xs rounded-lg hover:bg-indigo-600 transition-colors"
>
Generate Suggestions
{t('bridgeNotes.generate')}
</button>
</div>
)}

View File

@@ -6,6 +6,7 @@ import { NoteChart } from './note-chart'
import { suggestCharts, chartSuggestionToMarkdown, type ChartSuggestion, type SuggestChartsResponse } from '@/lib/ai/services/chart-suggestion.service'
import { BarChart3, X, Search, AlertCircle, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
// Chart type to color mapping for visual variety
const CHART_TYPE_COLORS: Record<string, string> = {
@@ -36,6 +37,7 @@ export function ChartSuggestionsDialog({
onClose,
onSelectChart,
}: ChartSuggestionsDialogProps) {
const { t } = useLanguage()
const [response, setResponse] = useState<SuggestChartsResponse | null>(null)
const [loading, setLoading] = useState(false)
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
@@ -127,12 +129,12 @@ export function ChartSuggestionsDialog({
{loading ? (
<span className="flex items-center gap-2">
<Search className="w-3 h-3 animate-spin" />
Analyzing {isAnalyzingSelection ? 'selection' : 'note'} ({wordCount} words)...
{isAnalyzingSelection ? t('chart.analyzingSelection') : t('chart.analyzingNote')} ({wordCount} {t('chart.words')})...
</span>
) : response?.hasData ? (
`Found ${response?.detectedData || 'data'}`
`${t('chart.found')} ${response?.detectedData || ''}`
) : (
'No data detected'
t('chart.noDataDetected')
)}
</p>
</div>
@@ -140,7 +142,7 @@ export function ChartSuggestionsDialog({
<button
onClick={onClose}
className="p-2 hover:bg-muted rounded-lg transition-colors"
aria-label="Close"
aria-label={t('common.close')}
>
<X className="w-5 h-5" />
</button>
@@ -152,7 +154,7 @@ export function ChartSuggestionsDialog({
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Search className="w-12 h-12 mx-auto mb-4 text-muted-foreground animate-pulse" />
<p className="text-muted-foreground">Analyzing your content for chart data...</p>
<p className="text-muted-foreground">{t('chart.analyzing')}</p>
</div>
</div>
) : response?.quotaExceeded ? (
@@ -167,7 +169,7 @@ export function ChartSuggestionsDialog({
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onClick={() => (window.location.href = '/settings/billing')}
>
Upgrade Plan
{t('ai.featureLocked')}
</button>
</div>
</div>
@@ -175,10 +177,10 @@ export function ChartSuggestionsDialog({
<div className="flex items-center justify-center py-12">
<div className="text-center max-w-md">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
<h3 className="text-lg font-semibold mb-2">Error</h3>
<h3 className="text-lg font-semibold mb-2">{t('common.error')}</h3>
<p className="text-sm text-muted-foreground mb-2">{response.error}</p>
<details className="text-left text-xs text-muted-foreground mt-4">
<summary className="cursor-pointer hover:text-foreground">Debug info</summary>
<summary className="cursor-pointer hover:text-foreground">{t('chart.debugInfo')}</summary>
<pre className="mt-2 bg-muted p-2 rounded overflow-auto max-h-32">
analyzedText: {response.analyzedText}
{'\n'}detectedData: {response.detectedData}
@@ -190,20 +192,20 @@ export function ChartSuggestionsDialog({
<div className="flex items-center justify-center py-12">
<div className="text-center max-w-md">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-semibold mb-2">No Data Detected</h3>
<h3 className="text-lg font-semibold mb-2">{t('chart.noDataDetected')}</h3>
<p className="text-muted-foreground mb-4">
Try adding numerical data to your note. Charts work best with:
{t('chart.noDataHint')}
</p>
<ul className="text-sm text-muted-foreground text-left inline-block">
<li> Sales figures, metrics, or measurements</li>
<li> Lists with values (e.g., "Jan: 5000, Feb: 7500")</li>
<li> Percentages or proportions</li>
<li> Time-series data</li>
<li>{t('chart.dataHint1')}</li>
<li>{t('chart.dataHint2')}</li>
<li>{t('chart.dataHint3')}</li>
<li>{t('chart.dataHint4')}</li>
</ul>
<div className="mt-4 pt-4 border-t border-border/50">
<details className="text-left">
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
Debug: Show what was analyzed
{t('chart.debugShowAnalyzed')}
</summary>
<pre className="mt-2 text-xs bg-muted p-2 rounded overflow-auto max-h-32">
{textToAnalyze.substring(0, 500)}
@@ -276,7 +278,7 @@ export function ChartSuggestionsDialog({
{/* Data preview */}
<div className="mt-2 pt-2 border-t border-border/50">
<p className="text-xs text-muted-foreground">
{suggestion.data.length} data points
{suggestion.data.length} {t('chart.dataPoints')}
</p>
</div>
</button>
@@ -286,9 +288,9 @@ export function ChartSuggestionsDialog({
{/* Keyboard hint */}
<div className="flex items-center justify-center gap-4 pt-4 text-xs text-muted-foreground">
<span> Navigate</span>
<span> Select</span>
<span>Esc Cancel</span>
<span>{t('chart.navigateHint')}</span>
<span>{t('chart.selectHint')}</span>
<span>{t('chart.escCancel')}</span>
</div>
</div>
)}

View File

@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useLanguage } from '@/lib/i18n'
import ReactFlow, {
Node,
Edge,
@@ -47,6 +48,7 @@ export function ClusterVisualization({
bridgeNotes,
onNodeClick
}: ClusterVisualizationProps) {
const { t } = useLanguage()
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [selectedCluster, setSelectedCluster] = useState<number | null>(null)
@@ -79,8 +81,8 @@ export function ClusterVisualization({
data: {
label: (
<div className="px-3 py-1 rounded-full text-sm font-medium" style={{ backgroundColor: color }}>
{cluster.name || `Cluster ${cluster.clusterId}`}
<span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} notes)</span>
{cluster.name || t('clusters.clusterLabel', { id: cluster.clusterId })}
<span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} {t('clusters.notes')})</span>
</div>
)
},
@@ -174,8 +176,8 @@ export function ClusterVisualization({
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p>No clusters to display</p>
<p className="text-sm mt-2">Create more notes to generate clusters</p>
<p>{t('clusters.empty')}</p>
<p className="text-sm mt-2">{t('clusters.emptyHint')}</p>
</div>
</div>
)
@@ -200,10 +202,10 @@ export function ClusterVisualization({
{selectedCluster !== null && (
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-4 max-w-xs">
<h3 className="font-semibold mb-2">
{clusters.find(c => c.clusterId === selectedCluster)?.name || `Cluster ${selectedCluster}`}
{clusters.find(c => c.clusterId === selectedCluster)?.name || t('clusters.clusterLabel', { id: selectedCluster })}
</h3>
<p className="text-sm text-gray-600">
{clusters.find(c => c.clusterId === selectedCluster)?.noteIds.length || 0} notes
{clusters.find(c => c.clusterId === selectedCluster)?.noteIds.length || 0} {t('clusters.notes')}
</p>
</div>
)}
@@ -212,11 +214,11 @@ export function ClusterVisualization({
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-yellow-400 border-2 border-yellow-600"></div>
<span>Bridge note</span>
<span>{t('clusters.bridgeNote')}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-blue-500"></div>
<span>Regular note</span>
<span>{t('clusters.regularNote')}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,126 @@
'use client'
import { motion } from 'motion/react'
import { Inbox, GraduationCap, Bell, Sparkles, Layers } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
export interface DashboardActionStripProps {
inboxCount: number
dueFlashcards: number
reminderCount: number
discoveryCount: number
themeCount: number
inboxPulse?: number
onInbox: () => void
onReview: () => void
onReminders: () => void
onDiscoveries: () => void
onThemes: () => void
prefersReducedMotion?: boolean
}
export function DashboardActionStrip({
inboxCount,
dueFlashcards,
reminderCount,
discoveryCount,
themeCount,
inboxPulse = 0,
onInbox,
onReview,
onReminders,
onDiscoveries,
onThemes,
prefersReducedMotion,
}: DashboardActionStripProps) {
const { t } = useLanguage()
const items = [
{
key: 'inbox',
icon: Inbox,
label: t('homeDashboard.inbox'),
value: inboxCount,
onClick: onInbox,
accent: inboxCount > 0,
pulse: inboxPulse > 0,
},
{
key: 'review',
icon: GraduationCap,
label: t('homeDashboard.review'),
value: dueFlashcards,
onClick: onReview,
accent: dueFlashcards > 0,
},
{
key: 'reminders',
icon: Bell,
label: t('homeDashboard.reminders'),
value: reminderCount,
onClick: onReminders,
accent: reminderCount > 0,
},
{
key: 'discoveries',
icon: Sparkles,
label: t('homeDashboard.aiFound'),
value: discoveryCount,
onClick: onDiscoveries,
accent: discoveryCount > 0,
},
{
key: 'themes',
icon: Layers,
label: t('homeDashboard.themes'),
value: themeCount,
onClick: onThemes,
accent: themeCount > 0,
},
]
return (
<div className="flex gap-2 overflow-x-auto custom-scrollbar pb-0.5 -mx-0.5 px-0.5">
{items.map(item => {
const Icon = item.icon
const Wrapper = item.pulse && !prefersReducedMotion ? motion.button : 'button'
const motionProps = item.pulse && !prefersReducedMotion
? {
key: `inbox-pulse-${inboxPulse}`,
animate: { scale: [1, 1.03, 1] },
transition: { duration: 0.45 },
}
: {}
return (
<Wrapper
key={item.key}
type="button"
onClick={item.onClick}
{...motionProps}
className={`shrink-0 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border transition-all text-start min-w-[108px] ${
item.accent
? 'border-brand-accent/30 bg-brand-accent/[0.06] hover:border-brand-accent/50 hover:bg-brand-accent/10 shadow-sm'
: 'border-border/25 bg-white/60 dark:bg-zinc-900/40 hover:border-border/50'
}`}
>
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${
item.accent ? 'bg-brand-accent/15 text-brand-accent' : 'bg-stone-100 dark:bg-zinc-800 text-concrete'
}`}>
<Icon size={13} strokeWidth={2} />
</div>
<div className="min-w-0">
<p className={`text-lg font-serif font-bold leading-none ${
item.accent ? 'text-ink dark:text-dark-ink' : 'text-concrete/80'
}`}>
{item.value}
</p>
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete truncate mt-0.5">
{item.label}
</p>
</div>
</Wrapper>
)
})}
</div>
)
}

View File

@@ -0,0 +1,158 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
export interface AgentSuggestion {
id: string
topic: string
reason: string
relatedNoteCount: number
suggestedFrequency: string
}
export interface DashboardAgentCarouselProps {
suggestions: AgentSuggestion[]
loading?: boolean
actingId: string | null
formatFrequency: (f: string) => string
onAccept: (id: string) => void
onDismiss: (id: string) => void
prefersReducedMotion?: boolean
}
export function DashboardAgentCarousel({
suggestions,
loading,
actingId,
formatFrequency,
onAccept,
onDismiss,
prefersReducedMotion,
}: DashboardAgentCarouselProps) {
const { t } = useLanguage()
const [idx, setIdx] = useState(0)
const navActions = suggestions.length > 1 ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setIdx(i => Math.max(0, i - 1))}
disabled={idx === 0}
className="p-1 rounded border border-border/30 disabled:opacity-25"
aria-label={t('homeDashboard.intelPrev')}
>
<ChevronLeft size={12} />
</button>
<span className="text-[8px] font-mono text-concrete px-1">
{idx + 1}/{suggestions.length}
</span>
<button
type="button"
onClick={() => setIdx(i => Math.min(suggestions.length - 1, i + 1))}
disabled={idx >= suggestions.length - 1}
className="p-1 rounded border border-border/30 disabled:opacity-25"
aria-label={t('homeDashboard.intelNext')}
>
<ChevronRight size={12} />
</button>
</div>
) : null
if (loading) {
return <div className="h-[140px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
}
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
<DashboardWidgetTitleRow
widgetId="agents"
icon={<Bot size={12} className="text-brand-accent" />}
title={t('homeDashboard.suggestedResearch')}
actions={navActions}
/>
{suggestions.length === 0 ? (
<p className="text-[11px] text-concrete italic leading-relaxed py-2">
{t('homeDashboard.agentsEmpty')}
</p>
) : (
<AgentSlide
current={suggestions[idx]}
actingId={actingId}
formatFrequency={formatFrequency}
onAccept={onAccept}
onDismiss={onDismiss}
prefersReducedMotion={prefersReducedMotion}
t={t}
/>
)}
</div>
)
}
function AgentSlide({
current,
actingId,
formatFrequency,
onAccept,
onDismiss,
prefersReducedMotion,
t,
}: {
current: AgentSuggestion
actingId: string | null
formatFrequency: (f: string) => string
onAccept: (id: string) => void
onDismiss: (id: string) => void
prefersReducedMotion?: boolean
t: (key: string) => string
}) {
const slide = prefersReducedMotion
? { initial: {}, animate: {}, exit: {} }
: { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -8 } }
return (
<AnimatePresence mode="wait">
<motion.div
key={current.id}
initial={slide.initial}
animate={slide.animate}
exit={slide.exit}
transition={{ duration: prefersReducedMotion ? 0 : 0.18 }}
className="p-3.5 rounded-xl border border-border/25 bg-stone-50/60 dark:bg-zinc-950/40"
>
<div className="flex items-start gap-2 mb-2">
<Search size={12} className="text-brand-accent shrink-0 mt-0.5" />
<p className="text-sm font-semibold text-ink dark:text-dark-ink leading-snug">{current.topic}</p>
</div>
<p className="text-[11px] text-concrete leading-relaxed line-clamp-2 mb-3">{current.reason}</p>
<p className="text-[8px] font-mono uppercase text-concrete mb-3">
{current.relatedNoteCount} {t('homeDashboard.notes')} · {formatFrequency(current.suggestedFrequency)}
</p>
<div className="flex gap-2">
<button
type="button"
disabled={actingId === current.id}
onClick={() => onDismiss(current.id)}
className="text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg border border-border/40 text-concrete hover:text-ink transition-colors disabled:opacity-40"
>
{t('homeDashboard.dismiss')}
</button>
<button
type="button"
disabled={actingId === current.id}
onClick={() => onAccept(current.id)}
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black font-bold hover:opacity-90 disabled:opacity-40"
>
{actingId === current.id ? <Loader2 size={10} className="animate-spin" /> : null}
{t('homeDashboard.createAgent')}
</button>
</div>
</motion.div>
</AnimatePresence>
)
}

View File

@@ -0,0 +1,371 @@
'use client'
import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
import { UsageMeter } from '@/components/usage-meter'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
interface DashboardWidgetShellProps {
widgetId: DashboardWidgetId
icon: React.ReactNode
title: string
children: React.ReactNode
compact?: boolean
headerActions?: React.ReactNode
}
export function DashboardWidgetShell({
widgetId,
icon,
title,
children,
compact,
headerActions,
}: DashboardWidgetShellProps) {
return (
<div className={`rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 h-full ${
compact ? 'p-3' : 'p-4'
}`}>
<DashboardWidgetTitleRow
widgetId={widgetId}
icon={icon}
title={title}
actions={headerActions}
className={compact ? 'mb-2' : 'mb-3'}
/>
{children}
</div>
)
}
export function DashboardInboxWidget({
count,
loading,
onOpen,
}: {
count: number
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="inbox"
icon={<Inbox size={12} className="text-brand-accent" />}
title={t('homeDashboard.inbox')}
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
) : (
<button
type="button"
onClick={onOpen}
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
>
<div>
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{count}</p>
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.toOrganize')}</p>
</div>
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} </span>
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardRevisionWidget({
dueCount,
loading,
onOpen,
}: {
dueCount: number
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="revision"
icon={<GraduationCap size={12} className="text-brand-accent" />}
title={t('homeDashboard.review')}
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
) : (
<button
type="button"
onClick={onOpen}
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
>
<div>
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{dueCount}</p>
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.cardsDue')}</p>
</div>
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} </span>
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardStatsWidget({
clusterCount,
bridgeCount,
noteCount,
loading,
onOpen,
}: {
clusterCount: number
bridgeCount: number
noteCount: number
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="stats"
icon={<BarChart3 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.stats')}
compact
>
{loading ? (
<div className="grid grid-cols-3 gap-2">
{[0, 1, 2].map(i => <div key={i} className="h-12 rounded-lg bg-stone-50 animate-pulse" />)}
</div>
) : (
<button type="button" onClick={onOpen} className="w-full text-start">
<div className="grid grid-cols-3 gap-2">
{[
{ label: t('homeDashboard.themes'), value: clusterCount },
{ label: t('homeDashboard.widgetStatsBridges'), value: bridgeCount },
{ label: t('homeDashboard.widgetStatsNotes'), value: noteCount },
].map(item => (
<div key={item.label} className="p-2 rounded-xl border border-border/20 bg-stone-50/50 dark:bg-zinc-950/30 text-center">
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink leading-none">{item.value}</p>
<p className="text-[8px] font-mono uppercase text-concrete mt-1 truncate">{item.label}</p>
</div>
))}
</div>
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardAgentActivityWidget({
actions,
loading,
onOpen,
}: {
actions: { id: string; agentName: string; result: string | null; createdAt: string }[]
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="agent-activity"
icon={<Bot size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.agent-activity')}
compact
>
{loading ? (
<div className="space-y-2">
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
</div>
) : actions.length === 0 ? (
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetAgentActivityEmpty')}</p>
) : (
<div className="space-y-1.5">
{actions.slice(0, 3).map(a => (
<button
key={a.id}
type="button"
onClick={onOpen}
className="w-full p-2 rounded-xl border border-border/20 hover:border-brand-accent/25 text-start transition-all"
>
<p className="text-[10px] font-mono font-bold text-ink dark:text-dark-ink truncate">{a.agentName}</p>
<p className="text-[9px] text-concrete truncate mt-0.5">
{(a.result || t('homeDashboard.intelAgentNoResult')).slice(0, 80)}
</p>
</button>
))}
</div>
)}
</DashboardWidgetShell>
)
}
export function DashboardGmailWidget({
connected,
recentCaptures,
loading,
onOpen,
}: {
connected: boolean
recentCaptures: number
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="gmail"
icon={<Mail size={12} className="text-brand-accent" />}
title={t('homeDashboard.gmailCaptures')}
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
) : connected ? (
<button
type="button"
onClick={onOpen}
className="w-full flex items-center justify-between gap-2 p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/25 transition-all text-start"
>
<span className="text-[10px] text-ink dark:text-dark-ink">{t('homeDashboard.gmailRecent', { count: recentCaptures })}</span>
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded shrink-0">Gmail</span>
</button>
) : (
<button
type="button"
onClick={onOpen}
className="w-full text-[10px] font-mono uppercase tracking-wider text-concrete hover:text-brand-accent transition-colors text-start py-2"
>
{t('homeDashboard.gmailConnect')}
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardPinnedWidget({
notes,
loading,
onSelect,
}: {
notes: { id: string; title: string | null; notebookId: string | null }[]
loading: boolean
onSelect: (id: string, notebookId: string | null) => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="pinned"
icon={<Pin size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.pinned')}
compact
>
{loading ? (
<div className="space-y-2">
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
</div>
) : notes.length === 0 ? (
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetPinnedEmpty')}</p>
) : (
<div className="space-y-1">
{notes.map(n => (
<button
key={n.id}
type="button"
onClick={() => onSelect(n.id, n.notebookId)}
className="w-full text-[10px] text-ink dark:text-dark-ink truncate text-start p-2 rounded-lg hover:bg-brand-accent/[0.04] transition-colors"
>
{n.title || t('homeDashboard.untitled')}
</button>
))}
</div>
)}
</DashboardWidgetShell>
)
}
export function DashboardActivityWidget({
data,
loading,
}: {
data: { date: string; count: number }[]
loading: boolean
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="activity"
icon={<BarChart3 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.activity')}
>
{loading ? (
<div className="h-24 rounded-lg bg-stone-50 animate-pulse" />
) : (
<RevisionHeatmap data={data} />
)}
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetActivityHint')}</p>
</DashboardWidgetShell>
)
}
export function DashboardUsageWidget() {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="usage"
icon={<BarChart3 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.usage')}
compact
>
<UsageMeter className="px-0 py-0" />
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetUsageHint')}</p>
</DashboardWidgetShell>
)
}
export function DashboardFlashcardsProgressWidget({
retentionRate,
streak,
totalCards,
loading,
onOpen,
}: {
retentionRate: number
streak: number
totalCards: number
loading?: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="flashcards-progress"
icon={<GraduationCap size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.flashcards-progress')}
compact
>
{loading ? (
<div className="h-14 rounded-lg bg-stone-50 animate-pulse" />
) : (
<button type="button" onClick={onOpen} className="w-full text-start">
<div className="grid grid-cols-3 gap-2">
<div className="text-center p-2 rounded-xl border border-border/20">
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{retentionRate}%</p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashRetention')}</p>
</div>
<div className="text-center p-2 rounded-xl border border-border/20">
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{streak}</p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashStreak')}</p>
</div>
<div className="text-center p-2 rounded-xl border border-border/20">
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{totalCards}</p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashTotal')}</p>
</div>
</div>
</button>
)}
</DashboardWidgetShell>
)
}

View File

@@ -0,0 +1,139 @@
'use client'
import { motion } from 'motion/react'
import { Layers, Zap, ArrowUpRight } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
interface Cluster {
clusterId: number
name?: string
noteIds: string[]
}
interface BridgeNote {
noteId: string
bridgeScore: number
clusterNames?: string[]
note?: { id: string; title: string | null }
}
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
export interface DashboardMindOrbitProps {
clusters: Cluster[]
bridgeNotes: BridgeNote[]
loading?: boolean
onOpenInsights: () => void
onNoteSelect: (id: string) => void
prefersReducedMotion?: boolean
}
export function DashboardMindOrbit({
clusters,
bridgeNotes,
loading,
onOpenInsights,
onNoteSelect,
prefersReducedMotion,
}: DashboardMindOrbitProps) {
const { t } = useLanguage()
const topClusters = [...clusters]
.sort((a, b) => b.noteIds.length - a.noteIds.length)
.slice(0, 5)
const maxCount = topClusters[0]?.noteIds.length || 1
const topBridge = bridgeNotes[0]
if (loading) {
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
}
if (topClusters.length === 0) {
return (
<button
type="button"
onClick={onOpenInsights}
className="w-full h-[180px] rounded-2xl border border-dashed border-border/35 bg-white/50 dark:bg-zinc-900/50 flex flex-col items-center justify-center gap-2 p-6 text-center hover:border-brand-accent/30 transition-all"
>
<Layers size={22} className="text-concrete/35" />
<p className="text-xs text-concrete italic max-w-[220px]">{t('homeDashboard.mindMapEmpty')}</p>
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.mindMapOpen')}</span>
</button>
)
}
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
<DashboardWidgetTitleRow
widgetId="mind-map"
icon={<Layers size={12} className="text-brand-accent" />}
title={t('homeDashboard.mindMap')}
actions={(
<button
type="button"
onClick={onOpenInsights}
className="inline-flex items-center gap-0.5 text-[8px] font-mono uppercase font-bold text-brand-accent hover:underline"
>
{t('homeDashboard.fullMap')}
<ArrowUpRight size={10} />
</button>
)}
/>
<div className="flex flex-wrap items-center justify-center gap-3 min-h-[100px] py-2">
{topClusters.map((cluster, idx) => {
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
const size = Math.round(56 * scale)
const label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}`
return (
<motion.button
key={cluster.clusterId}
type="button"
whileHover={prefersReducedMotion ? undefined : { scale: 1.06 }}
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
onClick={onOpenInsights}
className="relative flex flex-col items-center gap-1.5 group"
style={{ width: size + 16 }}
>
<div
className="rounded-full border-2 flex items-center justify-center font-mono font-bold text-white shadow-sm group-hover:shadow-md transition-shadow"
style={{
width: size,
height: size,
backgroundColor: `${color}cc`,
borderColor: `${color}40`,
fontSize: Math.max(9, size * 0.22),
}}
>
{cluster.noteIds.length}
</div>
<span className="text-[8px] font-medium text-ink/80 dark:text-dark-ink/80 text-center line-clamp-2 leading-tight max-w-[72px] group-hover:text-brand-accent transition-colors">
{label}
</span>
</motion.button>
)
})}
</div>
{topBridge?.note && (
<button
type="button"
onClick={() => onNoteSelect(topBridge.noteId)}
className="w-full mt-2 p-2.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] hover:bg-brand-accent/[0.08] transition-all text-start flex items-center gap-2 group"
>
<Zap size={11} className="text-brand-accent shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
{topBridge.note.title || t('homeDashboard.untitled')}
</p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.bridgeNote')}</p>
</div>
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
{Math.round(topBridge.bridgeScore * 100)}%
</span>
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,165 @@
'use client'
import { motion } from 'motion/react'
import { Loader2 } from 'lucide-react'
import {
ArrowRight, GitBranch, Lightbulb, Link2, BookOpen, Inbox,
GraduationCap, Compass, Plus, Sparkles, PenLine,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
import type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
const TYPE_META: Record<DashboardPathType, { Icon: LucideIcon; accent: string }> = {
continue: { Icon: PenLine, accent: 'text-ink' },
connect: { Icon: Link2, accent: 'text-brand-accent' },
'add-link': { Icon: Plus, accent: 'text-emerald-600' },
bridge: { Icon: GitBranch, accent: 'text-violet-600' },
research: { Icon: Sparkles, accent: 'text-sky-600' },
explore: { Icon: Compass, accent: 'text-amber-600' },
organize: { Icon: Inbox, accent: 'text-brand-accent' },
review: { Icon: GraduationCap, accent: 'text-brand-accent' },
resurface: { Icon: Lightbulb, accent: 'text-brand-accent' },
daily: { Icon: BookOpen, accent: 'text-concrete' },
}
export interface DashboardNextPathsProps {
paths: DashboardPath[]
loading?: boolean
enriching?: boolean
focusNoteTitle?: string | null
onAction: (path: DashboardPath) => void
prefersReducedMotion?: boolean
}
export function DashboardNextPaths({
paths,
loading,
enriching,
focusNoteTitle,
onAction,
prefersReducedMotion,
}: DashboardNextPathsProps) {
const { t } = useLanguage()
if (loading) {
return (
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
<div className="px-5 pt-4 pb-3 border-b border-border/15">
<DashboardWidgetTitleRow
widgetId="next-paths"
title={t('homeDashboard.pathsTitle')}
className="mb-0"
/>
{focusNoteTitle && (
<p className="text-[11px] text-concrete mt-1">
{t('homeDashboard.pathsFromNote', { title: focusNoteTitle })}
</p>
)}
</div>
<div className="flex flex-col items-center justify-center gap-3 px-5 py-10">
<Loader2 size={22} className="text-brand-accent animate-spin" strokeWidth={2} />
<p className="text-[11px] text-concrete text-center leading-relaxed max-w-[240px]">
{t('homeDashboard.pathsLoading')}
</p>
</div>
</div>
)
}
if (paths.length === 0) {
return (
<div className="rounded-2xl border border-border/25 bg-white/60 dark:bg-zinc-900/60 p-5">
<DashboardWidgetTitleRow
widgetId="next-paths"
title={t('homeDashboard.pathsTitle')}
className="mb-2"
/>
<p className="text-[10px] text-concrete italic">{t('homeDashboard.pathsEmpty')}</p>
</div>
)
}
const hero = paths[0]
const rest = paths.slice(1, 5)
const HeroIcon = TYPE_META[hero.type].Icon
return (
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
<div className="px-5 pt-4 pb-3 border-b border-border/15">
<DashboardWidgetTitleRow
widgetId="next-paths"
title={t('homeDashboard.pathsTitle')}
className="mb-0"
actions={enriching ? (
<span className="inline-flex items-center gap-1.5 text-[8px] font-mono uppercase text-concrete">
<Loader2 size={10} className="text-brand-accent animate-spin" />
{t('homeDashboard.pathsEnriching')}
</span>
) : undefined}
/>
{focusNoteTitle && (
<p className="text-[11px] text-concrete mt-1">
{t('homeDashboard.pathsFromNote', { title: focusNoteTitle })}
</p>
)}
</div>
<motion.button
type="button"
onClick={() => onAction(hero)}
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
className="w-full text-start px-5 py-4 hover:bg-brand-accent/[0.03] transition-colors border-b border-border/10"
>
<div className="flex items-start gap-3">
<div className={`w-9 h-9 rounded-xl bg-brand-accent/10 flex items-center justify-center shrink-0 ${TYPE_META[hero.type].accent}`}>
<HeroIcon size={16} strokeWidth={1.75} />
</div>
<div className="min-w-0 flex-1">
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete mb-0.5">
{t(`homeDashboard.pathTypes.${hero.type}`)}
{hero.score ? ` · ${hero.score}%` : ''}
</p>
<p className="text-sm font-serif font-semibold text-ink dark:text-dark-ink leading-snug">
{hero.title}
</p>
<p className="text-[11px] text-concrete leading-relaxed mt-1 line-clamp-2">
{hero.description}
</p>
</div>
<span className="shrink-0 inline-flex items-center gap-1 text-[9px] font-mono font-bold uppercase text-brand-accent mt-1">
{t(`homeDashboard.pathActions.${hero.actionKey}`)}
<ArrowRight size={10} />
</span>
</div>
</motion.button>
{rest.length > 0 && (
<div className="divide-y divide-border/10">
{rest.map(path => {
const meta = TYPE_META[path.type]
const Icon = meta.Icon
return (
<button
key={path.id}
type="button"
onClick={() => onAction(path)}
className="w-full flex items-center gap-3 px-5 py-3 text-start hover:bg-stone-50/80 dark:hover:bg-zinc-950/40 transition-colors"
>
<Icon size={13} className={`shrink-0 ${meta.accent}`} />
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-ink dark:text-dark-ink truncate">{path.title}</p>
<p className="text-[9px] text-concrete truncate">{path.description}</p>
</div>
<span className="text-[8px] font-mono uppercase text-brand-accent shrink-0">
{t(`homeDashboard.pathActions.${path.actionKey}`)}
</span>
</button>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,258 @@
'use client'
import { CheckCircle2, Circle } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetShell } from '@/components/dashboard-catalog-widgets'
export interface DailyReviewItem {
key: string
label: string
done: boolean
count?: number
onClick: () => void
}
export function DashboardDailyReview({
items,
loading,
}: {
items: DailyReviewItem[]
loading?: boolean
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="daily-review"
icon={<CheckCircle2 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.daily-review')}
compact
>
{loading ? (
<div className="space-y-2">
{[0, 1, 2].map(i => <div key={i} className="h-8 rounded-lg bg-stone-50 animate-pulse" />)}
</div>
) : (
<ul className="space-y-1.5">
{items.map(item => (
<li key={item.key}>
<button
type="button"
onClick={item.onClick}
className="w-full flex items-center gap-2.5 p-2 rounded-xl hover:bg-brand-accent/[0.04] transition-colors text-start"
>
{item.done ? (
<CheckCircle2 size={14} className="text-brand-accent shrink-0" />
) : (
<Circle size={14} className="text-concrete/50 shrink-0" />
)}
<span className={`text-[10px] flex-1 ${item.done ? 'text-concrete line-through' : 'text-ink dark:text-dark-ink'}`}>
{item.label}
{item.count !== undefined && item.count > 0 ? ` (${item.count})` : ''}
</span>
</button>
</li>
))}
</ul>
)}
<p className="text-[9px] text-concrete mt-2 leading-relaxed">{t('homeDashboard.dailyReviewHint')}</p>
</DashboardWidgetShell>
)
}
export function DashboardOpenLoops({
loops,
loading,
onSelect,
}: {
loops: Array<{ id: string; title: string | null; notebookId: string | null; daysStale: number }>
loading?: boolean
onSelect: (id: string, notebookId: string | null) => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="open-loops"
icon={<Circle size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.open-loops')}
compact
>
{loading ? (
<div className="h-16 rounded-lg bg-stone-50 animate-pulse" />
) : loops.length === 0 ? (
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.openLoopsEmpty')}</p>
) : (
<div className="space-y-1">
{loops.map(loop => (
<button
key={loop.id}
type="button"
onClick={() => onSelect(loop.id, loop.notebookId)}
className="w-full flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-brand-accent/[0.04] text-start transition-colors"
>
<span className="text-[10px] text-ink dark:text-dark-ink truncate flex-1">
{loop.title || t('homeDashboard.untitled')}
</span>
<span className="text-[8px] font-mono text-concrete shrink-0">
{t('homeDashboard.openLoopsStale', { days: loop.daysStale })}
</span>
</button>
))}
</div>
)}
</DashboardWidgetShell>
)
}
export function DashboardDailyNoteWidget({
loading,
onOpen,
}: {
loading?: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="daily-note"
icon={<CheckCircle2 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.daily-note')}
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
) : (
<button
type="button"
onClick={onOpen}
className="w-full p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 text-start transition-all"
>
<p className="text-[11px] font-serif font-semibold text-ink dark:text-dark-ink">
{new Date().toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}
</p>
<p className="text-[9px] font-mono uppercase text-brand-accent mt-1">{t('homeDashboard.dailyNoteOpen')} </p>
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardLinkSuggestions({
paths,
loading,
onAction,
}: {
paths: Array<{ id: string; title: string; description: string; score?: number }>
loading?: boolean
onAction: (id: string) => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="link-suggestions"
icon={<Circle size={12} className="text-emerald-600" />}
title={t('homeDashboard.widgets.link-suggestions')}
>
{loading ? (
<div className="space-y-2">
<div className="h-12 rounded-lg bg-stone-50 animate-pulse" />
</div>
) : paths.length === 0 ? (
<p className="text-[10px] text-concrete italic">{t('homeDashboard.linkSuggestionsEmpty')}</p>
) : (
<div className="space-y-2">
{paths.map(p => (
<button
key={p.id}
type="button"
onClick={() => onAction(p.id)}
className="w-full p-3 rounded-xl border border-border/20 hover:border-emerald-500/30 text-start transition-all"
>
<div className="flex items-center justify-between gap-2 mb-1">
<p className="text-[11px] font-mono font-bold text-ink dark:text-dark-ink truncate">{p.title}</p>
{p.score ? (
<span className="text-[8px] font-mono text-emerald-600 shrink-0">{p.score}%</span>
) : null}
</div>
<p className="text-[10px] text-concrete line-clamp-2">{p.description}</p>
<p className="text-[8px] font-mono uppercase text-emerald-600 mt-2">{t('homeDashboard.pathActions.addLink')} </p>
</button>
))}
</div>
)}
</DashboardWidgetShell>
)
}
export function DashboardBridgesWidget({
suggestions,
loading,
actingKey,
onCreate,
onDismiss,
}: {
suggestions: Array<{
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
justification: string
}>
loading?: boolean
actingKey?: string | null
onCreate: (s: { clusterAId: number; clusterBId: number; clusterAName: string; clusterBName: string; suggestedTitle: string; suggestedContent: string; justification: string }) => void
onDismiss: (s: { clusterAId: number; clusterBId: number }) => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="bridges"
icon={<Circle size={12} className="text-violet-600" />}
title={t('homeDashboard.widgets.bridges')}
>
{loading ? (
<div className="h-20 rounded-lg bg-stone-50 animate-pulse" />
) : suggestions.length === 0 ? (
<p className="text-[10px] text-concrete italic">{t('homeDashboard.bridgesEmpty')}</p>
) : (
<div className="space-y-2">
{suggestions.slice(0, 3).map(s => {
const key = `${s.clusterAId}-${s.clusterBId}`
return (
<div key={key} className="p-3 rounded-xl border border-border/20 bg-stone-50/40 dark:bg-zinc-950/30">
<p className="text-[10px] font-mono font-bold text-ink dark:text-dark-ink">{s.suggestedTitle}</p>
<p className="text-[9px] text-concrete mt-1">
{t('homeDashboard.suggestedBridge', { clusterA: s.clusterAName, clusterB: s.clusterBName })}
</p>
<p className="text-[10px] text-concrete mt-1 line-clamp-2">{s.justification}</p>
<div className="flex gap-2 mt-2">
<button
type="button"
disabled={actingKey === key}
onClick={() => onCreate({ ...s, suggestedContent: s.justification })}
className="text-[8px] font-mono uppercase font-bold text-brand-accent hover:underline disabled:opacity-50"
>
{t('homeDashboard.createBridgeNote')}
</button>
<button
type="button"
disabled={actingKey === key}
onClick={() => onDismiss(s)}
className="text-[8px] font-mono uppercase text-concrete hover:text-rose-500 disabled:opacity-50"
>
{t('homeDashboard.dismissConnection')}
</button>
</div>
</div>
)
})}
</div>
)}
</DashboardWidgetShell>
)
}

View File

@@ -0,0 +1,123 @@
'use client'
import { motion } from 'motion/react'
import { Clock, ChevronRight, Play } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
export interface ResumeNote {
id: string
title: string | null
excerpt: string
notebookName: string
notebookColor: string
updatedAt: string
notebookId: string | null
}
export interface DashboardResumeHeroProps {
notes: ResumeNote[]
loading?: boolean
onSelect: (id: string, notebookId: string | null) => void
formatRelativeTime: (date: string) => string
prefersReducedMotion?: boolean
}
export function DashboardResumeHero({
notes,
loading,
onSelect,
formatRelativeTime,
prefersReducedMotion,
}: DashboardResumeHeroProps) {
const { t } = useLanguage()
const hero = notes[0]
const rest = notes.slice(1, 4)
if (loading) {
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-5 min-h-[200px] animate-pulse">
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded mb-4" />
<div className="h-6 w-3/4 bg-stone-100 dark:bg-zinc-800 rounded mb-3" />
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
</div>
)
}
if (!hero) {
return (
<div className="rounded-2xl border border-dashed border-border/40 bg-white/50 dark:bg-zinc-900/50 p-8 text-center">
<Clock size={24} className="mx-auto text-concrete/30 mb-3" strokeWidth={1.25} />
<p className="text-xs text-concrete italic">{t('homeDashboard.noRecentNotes')}</p>
</div>
)
}
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 overflow-hidden">
<div className="px-5 pt-4">
<DashboardWidgetTitleRow
widgetId="resume"
icon={<Play size={11} className="text-brand-accent" fill="currentColor" />}
title={t('homeDashboard.continue')}
className="mb-2"
/>
</div>
<motion.button
type="button"
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
onClick={() => onSelect(hero.id, hero.notebookId)}
className="w-full text-start px-5 pb-5 group cursor-pointer"
>
<div
className="p-4 rounded-xl border border-border/25 bg-gradient-to-br from-stone-50/80 to-white dark:from-zinc-950/50 dark:to-zinc-900/80 group-hover:border-brand-accent/35 group-hover:shadow-md transition-all"
>
<div className="flex items-start justify-between gap-3 mb-2">
<span
className="text-[8px] font-mono font-bold uppercase px-2 py-0.5 rounded text-white shrink-0"
style={{ backgroundColor: hero.notebookColor }}
>
{hero.notebookName}
</span>
<span className="text-[9px] font-mono text-concrete/70 shrink-0">
{formatRelativeTime(hero.updatedAt)}
</span>
</div>
<h3 className="text-base sm:text-lg font-serif font-semibold text-ink dark:text-dark-ink group-hover:text-brand-accent transition-colors leading-snug mb-2 line-clamp-2">
{hero.title || t('homeDashboard.untitled')}
</h3>
{hero.excerpt && (
<p className="text-[11px] text-concrete leading-relaxed line-clamp-3">
{hero.excerpt}
</p>
)}
<div className="flex items-center gap-1 mt-3 text-[9px] font-mono font-bold uppercase text-brand-accent opacity-0 group-hover:opacity-100 transition-opacity">
{t('homeDashboard.resumeOpen')}
<ChevronRight size={12} />
</div>
</div>
</motion.button>
{rest.length > 0 && (
<div className="flex gap-2 px-5 pb-4 overflow-x-auto custom-scrollbar">
{rest.map(note => (
<button
key={note.id}
type="button"
onClick={() => onSelect(note.id, note.notebookId)}
className="shrink-0 max-w-[200px] p-2.5 rounded-xl border border-border/20 bg-stone-50/50 dark:bg-zinc-950/30 hover:border-brand-accent/30 transition-all text-start group"
>
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
{note.title || t('homeDashboard.untitled')}
</p>
<p className="text-[8px] font-mono text-concrete/60 mt-0.5">
{formatRelativeTime(note.updatedAt)}
</p>
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,113 @@
'use client'
import { Heart } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
interface EmotionMeta {
Icon: LucideIcon
color: string
}
export interface DashboardSentimentChipProps {
available: boolean
loading?: boolean
dominantEmotion?: string
summary?: string
emotions?: Record<string, number>
emotionMeta: Record<string, EmotionMeta>
}
export function DashboardSentimentChip({
available,
loading,
dominantEmotion,
summary,
emotions,
emotionMeta,
}: DashboardSentimentChipProps) {
const { t } = useLanguage()
if (loading) {
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
<div className="h-24 rounded-xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
</div>
)
}
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4 min-h-[140px]">
<DashboardWidgetTitleRow
widgetId="sentiment"
icon={<Heart size={12} className="text-brand-accent" />}
title={t('homeDashboard.sentiment')}
/>
{!available || !dominantEmotion ? (
<p className="text-[11px] text-concrete italic leading-relaxed">
{t('homeDashboard.notEnoughNotes')}
</p>
) : (
<div className="space-y-3">
<div className="flex items-center gap-3">
{(() => {
const meta = emotionMeta[dominantEmotion] || emotionMeta.reflective
const Icon = meta?.Icon
return (
<div
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
style={{ backgroundColor: `${meta.color}18`, color: meta.color }}
>
{Icon && <Icon size={18} strokeWidth={1.75} />}
</div>
)
})()}
<div className="min-w-0">
<p className="text-lg font-serif font-semibold text-ink dark:text-dark-ink">
{t(`homeDashboard.emotions.${dominantEmotion}`)}
</p>
<p className="text-[9px] font-mono uppercase tracking-wider text-concrete">
{t('homeDashboard.sentimentDominant')}
</p>
</div>
</div>
{summary && (
<p className="text-[11px] text-concrete leading-relaxed border-s-2 border-brand-accent/30 ps-3">
{summary}
</p>
)}
{emotions && (
<div className="space-y-2 pt-1">
{Object.entries(emotions)
.sort(([, a], [, b]) => b - a)
.slice(0, 4)
.map(([key, val]) => {
const em = emotionMeta[key]
return (
<div key={key} className="flex items-center gap-2">
<span className="text-[8px] font-mono uppercase text-concrete w-16 truncate">
{t(`homeDashboard.emotions.${key}`)}
</span>
<div className="flex-1 h-1.5 rounded-full bg-stone-100 dark:bg-zinc-800 overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: `${Math.min(val * 100, 100)}%`,
backgroundColor: em?.color || 'var(--color-brand-accent)',
}}
/>
</div>
</div>
)
})}
</div>
)}
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,402 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
DndContext,
closestCenter,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
rectSortingStrategy,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical, X, Plus, RotateCcw, Check, LayoutGrid, Eye, EyeOff } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import {
getDefaultDashboardLayout,
resetDashboardLayout,
DASHBOARD_CATEGORY_ORDER,
DASHBOARD_WIDGET_META,
catalogByCategory,
hiddenWidgetIds,
isWidgetVisible,
normalizeDashboardLayout,
reorderWidgets,
setWidgetVisibility,
visibleWidgetsInZone,
type DashboardLayout,
type DashboardWidgetId,
type DashboardWidgetPlacement,
type DashboardWidgetZone,
} from '@/lib/dashboard/layout'
import { toast } from 'sonner'
interface DashboardWidgetGridProps {
renderWidget: (id: DashboardWidgetId) => React.ReactNode
}
function SortableWidget({
placement,
editMode,
onHide,
children,
}: {
placement: DashboardWidgetPlacement
editMode: boolean
onHide: (id: DashboardWidgetId) => void
children: React.ReactNode
}) {
const { t } = useLanguage()
const meta = DASHBOARD_WIDGET_META[placement.id]
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: placement.id, disabled: !editMode })
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 20 : undefined,
}
return (
<div
ref={setNodeRef}
style={style}
className={`relative w-full ${
isDragging ? 'opacity-90 scale-[1.01]' : ''
} ${editMode ? 'ring-2 ring-brand-accent/25 ring-offset-2 ring-offset-[#F9F8F6] dark:ring-offset-[#0D0D0D] rounded-2xl' : ''}`}
>
{editMode && (
<div className="absolute top-2 end-2 z-20 flex items-center gap-1">
<button
type="button"
className="p-1.5 rounded-lg bg-white/95 dark:bg-zinc-900/95 border border-border/40 shadow-sm cursor-grab active:cursor-grabbing text-concrete hover:text-ink"
aria-label={t('homeDashboard.widgetDrag')}
{...attributes}
{...listeners}
>
<GripVertical size={12} />
</button>
{!meta.required && (
<button
type="button"
onClick={() => onHide(placement.id)}
className="p-1.5 rounded-lg bg-white/95 dark:bg-zinc-900/95 border border-border/40 shadow-sm text-concrete hover:text-rose-500"
aria-label={t('homeDashboard.widgetHide')}
>
<X size={12} />
</button>
)}
</div>
)}
{children}
</div>
)
}
function ZoneColumn({
zone,
placements,
editMode,
onHide,
renderWidget,
}: {
zone: DashboardWidgetZone
placements: DashboardWidgetPlacement[]
editMode: boolean
onHide: (id: DashboardWidgetId) => void
renderWidget: (id: DashboardWidgetId) => React.ReactNode
}) {
if (placements.length === 0) return null
return (
<SortableContext items={placements.map(w => w.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-4">
{placements.map(placement => (
<SortableWidget
key={placement.id}
placement={placement}
editMode={editMode}
onHide={onHide}
>
{renderWidget(placement.id)}
</SortableWidget>
))}
</div>
</SortableContext>
)
}
export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) {
const { t } = useLanguage()
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
const [editMode, setEditMode] = useState(false)
const [catalogOpen, setCatalogOpen] = useState(false)
const [loaded, setLoaded] = useState(false)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
fetch('/api/dashboard/layout', { cache: 'no-store' })
.then(r => r.ok ? r.json() : null)
.then(json => {
if (json?.layout) setLayout(normalizeDashboardLayout(json.layout))
})
.catch(() => {})
.finally(() => setLoaded(true))
}, [])
const persistLayout = useCallback((next: DashboardLayout, immediate = false) => {
if (saveTimer.current) clearTimeout(saveTimer.current)
const save = () => {
fetch('/api/dashboard/layout', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: next }),
}).catch(() => {})
}
if (immediate) save()
else saveTimer.current = setTimeout(save, 600)
}, [])
const applyLayout = useCallback((next: DashboardLayout, immediate = false) => {
const normalized = normalizeDashboardLayout(next)
setLayout(normalized)
persistLayout(normalized, immediate)
}, [persistLayout])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
)
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
applyLayout(reorderWidgets(layout, active.id as DashboardWidgetId, over.id as DashboardWidgetId))
}, [applyLayout, layout])
const handleHide = useCallback((id: DashboardWidgetId) => {
applyLayout(setWidgetVisibility(layout, id, false))
}, [applyLayout, layout])
const handleToggle = useCallback((id: DashboardWidgetId) => {
const visible = isWidgetVisible(layout, id)
if (visible) {
applyLayout(setWidgetVisibility(layout, id, false))
} else {
const maxOrder = Math.max(...layout.widgets.map(w => w.order), 0)
applyLayout({
...layout,
widgets: layout.widgets.map(w =>
w.id === id ? { ...w, visible: true, order: maxOrder + 1 } : w,
),
})
}
}, [applyLayout, layout])
const handleReset = useCallback(() => {
const fresh = resetDashboardLayout()
setLayout(fresh)
persistLayout(fresh, true)
setCatalogOpen(false)
toast.success(t('homeDashboard.widgetResetDone'))
}, [persistLayout, t])
const fullWidgets = visibleWidgetsInZone(layout, 'full')
const mainWidgets = visibleWidgetsInZone(layout, 'main')
const sideWidgets = visibleWidgetsInZone(layout, 'side')
const hidden = hiddenWidgetIds(layout)
const catalog = catalogByCategory(layout)
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0
return (
<>
<div className="space-y-4">
{loaded ? (
hasVisibleWidgets ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{fullWidgets.length > 0 && (
<SortableContext items={fullWidgets.map(w => w.id)} strategy={rectSortingStrategy}>
<div className="flex flex-col gap-4">
{fullWidgets.map(placement => (
<SortableWidget
key={placement.id}
placement={placement}
editMode={editMode}
onHide={handleHide}
>
{renderWidget(placement.id)}
</SortableWidget>
))}
</div>
</SortableContext>
)}
{(mainWidgets.length > 0 || sideWidgets.length > 0) && (
<div className="grid grid-cols-12 gap-4 items-start">
{mainWidgets.length > 0 && (
<div className="col-span-12 lg:col-span-8">
<ZoneColumn
zone="main"
placements={mainWidgets}
editMode={editMode}
onHide={handleHide}
renderWidget={renderWidget}
/>
</div>
)}
{sideWidgets.length > 0 && (
<div className="col-span-12 lg:col-span-4">
<ZoneColumn
zone="side"
placements={sideWidgets}
editMode={editMode}
onHide={handleHide}
renderWidget={renderWidget}
/>
</div>
)}
</div>
)}
</DndContext>
) : (
<div className="rounded-2xl border border-dashed border-border/40 bg-white/60 dark:bg-zinc-900/60 p-8 text-center">
<p className="text-sm font-serif text-ink dark:text-dark-ink mb-2">{t('homeDashboard.layoutEmptyTitle')}</p>
<p className="text-[11px] text-concrete mb-4 max-w-md mx-auto">{t('homeDashboard.layoutEmptyHint')}</p>
<button
type="button"
onClick={handleReset}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-brand-accent text-white text-[10px] font-mono uppercase font-bold hover:bg-brand-accent/90 transition-colors"
>
<RotateCcw size={12} />
{t('homeDashboard.widgetReset')}
</button>
</div>
)
) : (
<div className="grid grid-cols-12 gap-5">
<div className="col-span-12 h-20 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
<div className="col-span-12 lg:col-span-8 h-48 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
<div className="col-span-12 lg:col-span-4 h-48 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
</div>
)}
</div>
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-2 px-3 py-2 rounded-2xl bg-ink/92 dark:bg-zinc-900/95 text-white shadow-xl border border-white/10 backdrop-blur-md">
{editMode ? (
<>
<button
type="button"
onClick={() => setCatalogOpen(v => !v)}
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
>
<Plus size={12} />
{t('homeDashboard.widgetCatalog')}
{hidden.length > 0 && (
<span className="px-1.5 py-0.5 rounded-md bg-brand-accent/90 text-[9px]">{hidden.length}</span>
)}
</button>
<button
type="button"
onClick={handleReset}
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
>
<RotateCcw size={12} />
{t('homeDashboard.widgetReset')}
</button>
<button
type="button"
onClick={() => { setEditMode(false); setCatalogOpen(false) }}
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors"
>
<Check size={12} />
{t('homeDashboard.widgetDone')}
</button>
</>
) : (
<button
type="button"
onClick={() => setEditMode(true)}
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
>
<LayoutGrid size={12} />
{t('homeDashboard.widgetCustomize')}
</button>
)}
</div>
{editMode && catalogOpen && (
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-40 w-[min(520px,calc(100vw-2rem))] max-h-[min(60vh,480px)] overflow-y-auto custom-scrollbar p-4 rounded-2xl bg-white dark:bg-zinc-900 border border-border/40 shadow-2xl">
<p className="text-[9px] font-mono font-bold uppercase tracking-wider text-concrete mb-3">
{t('homeDashboard.widgetCatalogTitle')}
</p>
<div className="space-y-4">
{DASHBOARD_CATEGORY_ORDER.map(category => {
const ids = catalog[category]
if (ids.length === 0) return null
return (
<div key={category}>
<p className="text-[8px] font-mono font-bold uppercase tracking-[0.15em] text-brand-accent mb-2">
{t(`homeDashboard.widgetCategories.${category}`)}
</p>
<div className="space-y-1.5">
{ids.map(id => {
const meta = DASHBOARD_WIDGET_META[id]
const active = isWidgetVisible(layout, id)
return (
<button
key={id}
type="button"
onClick={() => !meta.required && handleToggle(id)}
disabled={meta.required}
className={`w-full flex items-start gap-3 p-2.5 rounded-xl border text-start transition-colors ${
active
? 'border-brand-accent/30 bg-brand-accent/[0.04]'
: 'border-border/25 bg-stone-50/80 dark:bg-zinc-950/40 hover:border-brand-accent/25'
} ${meta.required ? 'opacity-80 cursor-default' : 'cursor-pointer'}`}
>
<div className={`mt-0.5 shrink-0 w-6 h-6 rounded-lg flex items-center justify-center ${
active ? 'bg-brand-accent/15 text-brand-accent' : 'bg-stone-200/60 dark:bg-zinc-800 text-concrete'
}`}>
{active ? <Eye size={11} /> : <EyeOff size={11} />}
</div>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-mono font-bold uppercase tracking-wide text-ink dark:text-dark-ink">
{t(`homeDashboard.widgets.${id}`)}
</p>
<p className="text-[10px] text-concrete leading-snug mt-0.5">
{t(`homeDashboard.widgetDescriptions.${id}`)}
</p>
</div>
{!meta.required && (
<span className={`shrink-0 text-[9px] font-mono font-bold uppercase px-2 py-1 rounded-lg ${
active ? 'text-concrete' : 'text-brand-accent bg-brand-accent/10'
}`}>
{active ? t('homeDashboard.widgetActive') : t('homeDashboard.widgetAddShort')}
</span>
)}
</button>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import { useState } from 'react'
import { HelpCircle, X } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
interface DashboardWidgetHelpProps {
widgetId: DashboardWidgetId
className?: string
}
export function DashboardWidgetHelp({ widgetId, className = '' }: DashboardWidgetHelpProps) {
const { t } = useLanguage()
const [open, setOpen] = useState(false)
const helpKey = `homeDashboard.widgetHelp.${widgetId}`
const text = t(helpKey)
if (text === helpKey) return null
return (
<div className={`relative ${className}`}>
<button
type="button"
onClick={() => setOpen(v => !v)}
className={`p-1 rounded-full border transition-colors ${
open
? 'border-brand-accent/40 bg-brand-accent/10 text-brand-accent'
: 'border-border/30 bg-white/90 dark:bg-zinc-900/90 text-concrete hover:text-brand-accent hover:border-brand-accent/30'
}`}
aria-label={t('homeDashboard.widgetHelpLabel')}
aria-expanded={open}
>
<HelpCircle size={12} strokeWidth={2} />
</button>
{open && (
<div className="absolute top-full end-0 mt-1.5 z-30 w-[min(280px,calc(100vw-3rem))] p-3 rounded-xl border border-brand-accent/20 bg-white dark:bg-zinc-900 shadow-lg">
<div className="flex items-start justify-between gap-2 mb-1">
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-brand-accent">
{t(`homeDashboard.widgets.${widgetId}`)}
</p>
<button
type="button"
onClick={() => setOpen(false)}
className="text-concrete hover:text-ink shrink-0"
aria-label={t('homeDashboard.widgetHelpClose')}
>
<X size={10} />
</button>
</div>
<p className="text-[10px] text-concrete leading-relaxed">{text}</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
import type { ReactNode } from 'react'
import { DashboardWidgetHelp } from '@/components/dashboard-widget-help'
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
interface DashboardWidgetTitleRowProps {
widgetId: DashboardWidgetId
icon?: ReactNode
title: string
actions?: ReactNode
className?: string
}
/** Titre widget : actions dabord, aide « ? » en dernier — ne masque jamais la navigation. */
export function DashboardWidgetTitleRow({
widgetId,
icon,
title,
actions,
className = 'mb-3',
}: DashboardWidgetTitleRowProps) {
return (
<div className={`flex items-center justify-between gap-2 ${className}`}>
<div className="flex items-center gap-2 min-w-0 flex-1">
{icon}
<h2 className="text-[10px] font-mono font-bold uppercase tracking-widest text-ink dark:text-dark-ink truncate">
{title}
</h2>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{actions}
<DashboardWidgetHelp widgetId={widgetId} />
</div>
</div>
)
}

View File

@@ -34,6 +34,7 @@ import { StudyPlannerDialog } from '@/components/wizard/study-planner-dialog'
import { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-dialog'
import { toast } from 'sonner'
import { AnimatePresence, motion } from 'motion/react'
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
@@ -58,6 +59,10 @@ const OrganizeNotebookDialog = dynamic(
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
{ ssr: false }
)
const DashboardView = dynamic(
() => import('@/components/dashboard-view').then(m => ({ default: m.DashboardView })),
{ ssr: false }
)
const NotebookSiteDialog = dynamic(
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
{ ssr: false }
@@ -225,15 +230,12 @@ export function HomeClient({
}, [])
// Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid)
// On GARDE forceList dans l'URL pour distinguer "liste" du "dashboard"
useEffect(() => {
if (searchParams.get('forceList') === '1') {
setEditingNote(null)
const params = new URLSearchParams(searchParams.toString())
params.delete('forceList')
const newUrl = params.toString() ? `/home?${params.toString()}` : '/home'
router.replace(newUrl, { scroll: false })
}
}, [searchParams, router])
}, [searchParams])
const fetchNotesForCurrentView = useCallback(
async (options?: { silent?: boolean }) => {
@@ -821,6 +823,16 @@ export function HomeClient({
emitNoteChange({ type: 'updated', note: savedNote })
}, [])
// Show dashboard when no active filter/view params
const showDashboard = !editingNote && isDashboardHomeRoute('/home', searchParams)
const handleDashboardNoteSelect = useCallback((noteId: string, notebookId: string | null) => {
const params = new URLSearchParams()
params.set('openNote', noteId)
if (notebookId) params.set('notebook', notebookId)
router.push(`/home?${params.toString()}`)
}, [router])
return (
<div
className={cn(
@@ -838,6 +850,8 @@ export function HomeClient({
fullPage
/>
</div>
) : showDashboard ? (
<DashboardView onNoteSelect={handleDashboardNoteSelect} />
) : (
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
<div

View File

@@ -0,0 +1,729 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'motion/react'
import {
Sparkles, Zap, Lightbulb, Bot, ChevronLeft, ChevronRight,
ExternalLink, GitCompare, X, RefreshCw, Loader2, Link2, Brain, ArrowRight,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
import { MEMORY_ECHO_LEGACY_EN_FALLBACKS } from '@/lib/ai/memory-echo-i18n'
// ─── Types ─────────────────────────────────────────────
export interface IntelBriefingInsight {
id: string
insight: string
score: number
date: string
viewed: boolean
note1: { id: string; title: string | null }
note2: { id: string; title: string | null }
note1Excerpt?: string
note2Excerpt?: string
}
export interface IntelBridgeNote {
noteId: string
bridgeScore: number
clusterNames?: string[]
note?: { id: string; title: string | null; content?: string }
}
export interface IntelBridgeSuggestion {
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}
export interface IntelAgentAction {
id: string
agentName: string
result: string | null
createdAt: string
}
type IntelFilter = 'all' | 'connections' | 'bridges' | 'ideas' | 'agents'
type IntelItem =
| { kind: 'insight'; id: string; priority: number; insight: IntelBriefingInsight }
| { kind: 'bridge'; id: string; priority: number; bridge: IntelBridgeNote }
| { kind: 'suggestion'; id: string; priority: number; suggestion: IntelBridgeSuggestion }
| { kind: 'agent'; id: string; priority: number; agent: IntelAgentAction }
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
const FILTER_KINDS: Record<IntelFilter, IntelItem['kind'][] | null> = {
all: null,
connections: ['insight'],
bridges: ['bridge'],
ideas: ['suggestion'],
agents: ['agent'],
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
}
function formatRelativeTime(
dateStr: string,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (mins < 1) return t('time.justNow')
if (mins < 60) return t('time.minutesAgo', { count: mins })
if (hours < 24) return t('time.hoursAgo', { count: hours })
return t('time.daysAgo', { count: days })
}
// ─── Visual: lien entre deux notes ─────────────────────
function ConnectionDiagram({
note1Title,
note2Title,
score,
color = '#6366F1',
}: {
note1Title: string
note2Title: string
score: number
color?: string
}) {
return (
<div className="flex items-center gap-2 py-3">
<div
className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start"
style={{ borderColor: `${color}30` }}
>
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate leading-tight">
{note1Title}
</p>
</div>
<div className="shrink-0 flex flex-col items-center gap-0.5 px-1">
<div className="w-8 h-px" style={{ background: `linear-gradient(90deg, transparent, ${color}, transparent)` }} />
<span
className="text-[9px] font-mono font-bold px-2 py-0.5 rounded-full"
style={{ color, backgroundColor: `${color}14`, border: `1px solid ${color}25` }}
>
{Math.round(score * 100)}%
</span>
<div className="w-8 h-px" style={{ background: `linear-gradient(90deg, transparent, ${color}, transparent)` }} />
</div>
<div
className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start"
style={{ borderColor: `${color}30` }}
>
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate leading-tight">
{note2Title}
</p>
</div>
</div>
)
}
// ─── Props ─────────────────────────────────────────────
export interface IntelligenceHubProps {
loading: boolean
aiActive: boolean
hasAiConsent: boolean
providerReady: boolean
memoryEchoEnabled: boolean
insights: IntelBriefingInsight[]
bridgeNotes: IntelBridgeNote[]
bridgeSuggestions: IntelBridgeSuggestion[]
agentActions: IntelAgentAction[]
onNoteSelect: (noteId: string) => void
onRefreshEcho: () => void
onEnableAi: () => void
echoRefreshing: boolean
onDismissInsight: (insight: IntelBriefingInsight) => void
onDismissBridgeSuggestion: (s: IntelBridgeSuggestion) => void
onCreateBridgeSuggestion: (s: IntelBridgeSuggestion) => void
onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string) => void
dismissingInsightId: string | null
actingBridgeSuggestionKey: string | null
prefersReducedMotion: boolean
}
// ─── Component ─────────────────────────────────────────
export function IntelligenceHub({
loading,
aiActive,
hasAiConsent,
providerReady,
memoryEchoEnabled,
insights,
bridgeNotes,
bridgeSuggestions,
agentActions,
onNoteSelect,
onRefreshEcho,
onEnableAi,
echoRefreshing,
onDismissInsight,
onDismissBridgeSuggestion,
onCreateBridgeSuggestion,
onOpenInsightNote,
dismissingInsightId,
actingBridgeSuggestionKey,
prefersReducedMotion,
}: IntelligenceHubProps) {
const router = useRouter()
const { t } = useLanguage()
const [filter, setFilter] = useState<IntelFilter>('all')
const [activeIndex, setActiveIndex] = useState(0)
const localizeInsightText = useCallback((insight: IntelBriefingInsight) => {
const text = insight.insight.trim()
if (MEMORY_ECHO_LEGACY_EN_FALLBACKS.has(text)) {
if (insight.note1Excerpt && insight.note2Excerpt) {
return t('homeDashboard.connectionBetween', {
note1: insight.note1.title || insight.note1Excerpt.slice(0, 50),
note2: insight.note2.title || insight.note2Excerpt.slice(0, 50),
})
}
return t('memoryEcho.defaultInsight')
}
return text
}, [t])
const allItems = useMemo(() => {
const insightNoteIds = new Set<string>()
for (const i of insights) {
insightNoteIds.add(i.note1.id)
insightNoteIds.add(i.note2.id)
}
const items: IntelItem[] = []
for (const insight of insights) {
items.push({
kind: 'insight',
id: `insight-${insight.id}`,
priority: (insight.viewed ? 40 : 100) + insight.score * 10,
insight,
})
}
for (const bridge of bridgeNotes) {
if (insightNoteIds.has(bridge.noteId)) continue
items.push({
kind: 'bridge',
id: `bridge-${bridge.noteId}`,
priority: 60 + bridge.bridgeScore * 10,
bridge,
})
}
for (const suggestion of bridgeSuggestions) {
items.push({
kind: 'suggestion',
id: `suggestion-${suggestion.clusterAId}-${suggestion.clusterBId}`,
priority: 80,
suggestion,
})
}
for (const agent of agentActions) {
items.push({
kind: 'agent',
id: `agent-${agent.id}`,
priority: 30,
agent,
})
}
return items.sort((a, b) => b.priority - a.priority).slice(0, 10)
}, [insights, bridgeNotes, bridgeSuggestions, agentActions])
const counts = useMemo(() => ({
all: allItems.length,
connections: allItems.filter(i => i.kind === 'insight').length,
bridges: allItems.filter(i => i.kind === 'bridge').length,
ideas: allItems.filter(i => i.kind === 'suggestion').length,
agents: allItems.filter(i => i.kind === 'agent').length,
}), [allItems])
const filteredItems = useMemo(() => {
const kinds = FILTER_KINDS[filter]
if (!kinds) return allItems
return allItems.filter(i => kinds.includes(i.kind))
}, [allItems, filter])
useEffect(() => {
setActiveIndex(0)
}, [filter])
useEffect(() => {
if (activeIndex >= filteredItems.length && filteredItems.length > 0) {
setActiveIndex(filteredItems.length - 1)
}
}, [activeIndex, filteredItems.length])
const activeItem = filteredItems[activeIndex] ?? null
const newCount = insights.filter(i => !i.viewed).length
const goPrev = () => setActiveIndex(i => Math.max(0, i - 1))
const goNext = () => setActiveIndex(i => Math.min(filteredItems.length - 1, i + 1))
const filters: { key: IntelFilter; label: string }[] = [
{ key: 'all', label: t('homeDashboard.intelFilterAll') },
{ key: 'connections', label: t('homeDashboard.intelFilterConnections') },
{ key: 'bridges', label: t('homeDashboard.intelFilterBridges') },
{ key: 'ideas', label: t('homeDashboard.intelFilterIdeas') },
{ key: 'agents', label: t('homeDashboard.intelFilterAgents') },
]
const slideVariants = prefersReducedMotion
? { initial: {}, animate: {}, exit: {} }
: {
initial: { opacity: 0, x: 24, scale: 0.98 },
animate: { opacity: 1, x: 0, scale: 1 },
exit: { opacity: 0, x: -24, scale: 0.98 },
}
const renderSpotlight = (item: IntelItem) => {
switch (item.kind) {
case 'insight': {
const { insight } = item
const text = localizeInsightText(insight)
const excerpt = insight.note1Excerpt || insight.note2Excerpt
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3">
<Sparkles size={12} className="text-indigo-500" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
{t('homeDashboard.semanticConnection')}
</span>
{!insight.viewed && (
<span className="w-1.5 h-1.5 rounded-full bg-ochre animate-pulse" />
)}
</div>
<ConnectionDiagram
note1Title={insight.note1.title || t('homeDashboard.untitled')}
note2Title={insight.note2.title || t('homeDashboard.untitled')}
score={insight.score}
/>
<p className="text-[11px] text-ink/75 dark:text-dark-ink/75 font-serif italic leading-relaxed line-clamp-3 px-1">
« {text} »
</p>
{excerpt && (
<p className="text-[9px] text-concrete/80 line-clamp-2 mt-2 px-1 border-s-2 border-indigo-500/20 ps-2">
{excerpt}
</p>
)}
<div className="flex items-center gap-1.5 mt-auto pt-3 flex-wrap">
<button
type="button"
onClick={() => onOpenInsightNote(insight, insight.note1.id)}
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
>
<ExternalLink size={9} />
{t('homeDashboard.intelOpenNote')}
</button>
<button
type="button"
onClick={() => onOpenInsightNote(insight, insight.note2.id)}
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-border/40 hover:border-indigo-400/40 transition-colors"
>
<GitCompare size={9} />
{t('homeDashboard.intelCompare')}
</button>
<button
type="button"
disabled={dismissingInsightId === insight.id}
onClick={() => onDismissInsight(insight)}
className="p-1.5 rounded-lg border border-border/30 text-concrete hover:text-rose-500 ms-auto disabled:opacity-40"
title={t('homeDashboard.dismissConnection')}
>
{dismissingInsightId === insight.id
? <Loader2 size={10} className="animate-spin" />
: <X size={10} />}
</button>
</div>
</div>
)
}
case 'bridge': {
const { bridge } = item
const title = bridge.note?.title || t('homeDashboard.untitled')
const excerpt = bridge.note?.content ? stripHtml(bridge.note.content).slice(0, 160) : ''
const names = bridge.clusterNames ?? []
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2">
<Zap size={12} className="text-ochre" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre">
{t('homeDashboard.bridgeNote')}
</span>
</div>
<span className="text-[9px] font-mono font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
{Math.round(bridge.bridgeScore * 100)}%
</span>
</div>
<button
type="button"
onClick={() => onNoteSelect(bridge.noteId)}
className="text-start group flex-1"
>
<p className="text-sm font-semibold text-ink dark:text-dark-ink group-hover:text-ochre transition-colors mb-2 leading-snug">
{title}
</p>
{excerpt && (
<p className="text-[10px] text-concrete leading-relaxed line-clamp-3 mb-3">{excerpt}</p>
)}
{names.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{names.map((name, i) => (
<span
key={`${bridge.noteId}-${name}`}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full border border-border/25 bg-white/70 dark:bg-zinc-900/50"
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: CLUSTER_COLORS[i % CLUSTER_COLORS.length] }}
/>
<span className="text-[8px] font-mono uppercase text-concrete">{name}</span>
</span>
))}
</div>
)}
</button>
<button
type="button"
onClick={() => onNoteSelect(bridge.noteId)}
className="mt-3 inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-ochre/90 text-white hover:bg-ochre transition-colors w-fit"
>
<ExternalLink size={9} />
{t('homeDashboard.intelOpenNote')}
</button>
</div>
)
}
case 'suggestion': {
const { suggestion } = item
const key = `${suggestion.clusterAId}-${suggestion.clusterBId}`
const busy = actingBridgeSuggestionKey === key
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3">
<Lightbulb size={12} className="text-violet-500" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-violet-600 dark:text-violet-400 truncate">
{t('homeDashboard.intelMissingLink')}
</span>
</div>
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-1 rounded-lg bg-violet-500/10 text-[9px] font-mono font-bold text-violet-700 dark:text-violet-300 truncate max-w-[45%]">
{suggestion.clusterAName}
</span>
<div className="flex-1 h-px bg-gradient-to-r from-violet-400/40 via-ochre/60 to-violet-400/40" />
<span className="px-2 py-1 rounded-lg bg-ochre/10 text-[9px] font-mono font-bold text-ochre truncate max-w-[45%]">
{suggestion.clusterBName}
</span>
</div>
<p className="text-sm font-semibold text-ink dark:text-dark-ink mb-1.5">{suggestion.suggestedTitle}</p>
<p className="text-[10px] text-concrete leading-relaxed line-clamp-2 flex-1">{suggestion.suggestedContent}</p>
<div className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border/15">
<button
type="button"
disabled={busy}
onClick={() => onCreateBridgeSuggestion(suggestion)}
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-violet-600 text-white hover:bg-violet-700 transition-colors disabled:opacity-40"
>
{busy ? <Loader2 size={9} className="animate-spin" /> : <Link2 size={9} />}
{t('homeDashboard.createBridgeNote')}
</button>
<button
type="button"
disabled={busy}
onClick={() => onDismissBridgeSuggestion(suggestion)}
className="text-[8.5px] font-mono uppercase px-2 py-1.5 rounded-lg border border-border/30 text-concrete hover:text-rose-500 disabled:opacity-40"
>
{t('homeDashboard.dismiss')}
</button>
</div>
</div>
)
}
case 'agent': {
const { agent } = item
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3">
<Bot size={12} className="text-ochre" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre">
{agent.agentName}
</span>
<span className="text-[8px] font-mono text-concrete/60 ms-auto">
{formatRelativeTime(agent.createdAt, t)}
</span>
</div>
{agent.result ? (
<p className="text-[11px] text-concrete leading-relaxed line-clamp-4 flex-1">
{agent.result}
</p>
) : (
<p className="text-[11px] text-concrete/60 italic flex-1">{t('homeDashboard.intelAgentNoResult')}</p>
)}
<button
type="button"
onClick={() => router.push('/agents')}
className="mt-3 inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-ochre/30 text-ochre hover:bg-ochre/10 transition-colors w-fit"
>
{t('homeDashboard.intelViewAgent')}
<ArrowRight size={9} />
</button>
</div>
)
}
}
}
const spotlightAccent = (item: IntelItem | null) => {
if (!item) return 'from-stone-100/50 to-transparent border-border/30'
switch (item.kind) {
case 'insight': return 'from-indigo-500/[0.06] via-transparent to-transparent border-indigo-400/25'
case 'bridge': return 'from-ochre/[0.06] via-transparent to-transparent border-ochre/25'
case 'suggestion': return 'from-violet-500/[0.06] via-transparent to-transparent border-violet-400/25'
case 'agent': return 'from-ochre/[0.04] via-transparent to-transparent border-border/30'
}
}
return (
<section className="p-5 rounded-2xl bg-white dark:bg-zinc-900 border border-border/30 flex flex-col">
{/* Header */}
<DashboardWidgetTitleRow
widgetId="intelligence"
icon={(
<div className="w-5 h-5 rounded bg-brand-accent/10 flex items-center justify-center text-brand-accent shrink-0">
<Sparkles size={12} />
</div>
)}
title={t('homeDashboard.aiFound')}
actions={(
<>
{newCount > 0 && (
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-2 py-0.5 rounded uppercase">
{newCount} {t('homeDashboard.new')}
</span>
)}
{aiActive && (
<button
type="button"
onClick={onRefreshEcho}
disabled={echoRefreshing}
title={t('homeDashboard.analyzeNotes')}
className="p-1.5 rounded-lg border border-border/30 hover:border-brand-accent/40 hover:bg-brand-accent/5 transition-all disabled:opacity-40"
>
{echoRefreshing
? <Loader2 size={11} className="animate-spin text-brand-accent" />
: <RefreshCw size={11} className="text-concrete hover:text-brand-accent" />}
</button>
)}
</>
)}
/>
{loading ? (
<div className="h-[220px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
) : !aiActive ? (
<div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center">
<p className="text-xs text-concrete leading-relaxed">
{!hasAiConsent
? t('homeDashboard.aiConsentRequired')
: !providerReady
? t('homeDashboard.aiProviderUnavailable')
: t('homeDashboard.memoryEchoDisabled')}
</p>
{!hasAiConsent && (
<button
type="button"
onClick={onEnableAi}
className="text-[9px] font-mono uppercase font-bold px-3 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black hover:opacity-90 transition-opacity mx-auto"
>
{t('homeDashboard.enableAi')}
</button>
)}
</div>
) : allItems.length === 0 ? (
<div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center">
<Brain size={22} className="mx-auto text-concrete/40" strokeWidth={1.25} />
<p className="text-xs text-concrete leading-relaxed">{t('homeDashboard.noConnections')}</p>
<button
type="button"
onClick={onRefreshEcho}
disabled={echoRefreshing}
className="inline-flex items-center gap-1.5 text-[9px] font-mono uppercase font-bold px-3 py-1.5 rounded-lg border border-ochre/30 text-ochre hover:bg-ochre/10 transition-all disabled:opacity-40 mx-auto"
>
{echoRefreshing ? <Loader2 size={11} className="animate-spin" /> : <Sparkles size={11} />}
{t('homeDashboard.analyzeNotes')}
</button>
</div>
) : (
<>
{/* Filtres */}
<div className="flex gap-1 overflow-x-auto custom-scrollbar pb-1 -mx-0.5 px-0.5 mb-3">
{filters.map(f => {
const count = counts[f.key]
if (f.key !== 'all' && count === 0) return null
const active = filter === f.key
return (
<button
key={f.key}
type="button"
onClick={() => setFilter(f.key)}
className={`shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[8.5px] font-mono font-bold uppercase tracking-wider transition-all ${
active
? 'bg-ink text-white dark:bg-white dark:text-black shadow-sm'
: 'bg-stone-100 dark:bg-zinc-800 text-concrete hover:text-ink dark:hover:text-dark-ink'
}`}
>
{f.label}
{count > 0 && (
<span className={`text-[7px] px-1 py-px rounded-full ${active ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
{count}
</span>
)}
</button>
)
})}
</div>
{filteredItems.length === 0 ? (
<p className="text-xs text-concrete italic text-center py-8">{t('homeDashboard.intelFilterEmpty')}</p>
) : (
<>
{/* Spotlight carousel */}
<div className="relative">
{filteredItems.length > 1 && (
<>
<button
type="button"
onClick={goPrev}
disabled={activeIndex === 0}
className="absolute start-0 top-1/2 -translate-y-1/2 -translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-ochre/40 transition-all"
aria-label={t('homeDashboard.intelPrev')}
>
<ChevronLeft size={14} />
</button>
<button
type="button"
onClick={goNext}
disabled={activeIndex >= filteredItems.length - 1}
className="absolute end-0 top-1/2 -translate-y-1/2 translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-ochre/40 transition-all"
aria-label={t('homeDashboard.intelNext')}
>
<ChevronRight size={14} />
</button>
</>
)}
<div
className={`min-h-[200px] mx-5 p-4 rounded-2xl border bg-gradient-to-br ${spotlightAccent(activeItem)} transition-colors`}
>
<AnimatePresence mode="wait">
{activeItem && (
<motion.div
key={activeItem.id}
initial={slideVariants.initial}
animate={slideVariants.animate}
exit={slideVariants.exit}
transition={{ duration: prefersReducedMotion ? 0 : 0.22, ease: 'easeOut' }}
className="min-h-[180px] flex flex-col"
>
{renderSpotlight(activeItem)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Navigation dots + position */}
{filteredItems.length > 1 && (
<div className="flex items-center justify-center gap-3 mt-3">
<div className="flex items-center gap-1.5">
{filteredItems.map((item, idx) => (
<button
key={item.id}
type="button"
onClick={() => setActiveIndex(idx)}
className={`rounded-full transition-all ${
idx === activeIndex
? 'w-5 h-1.5 bg-ochre'
: 'w-1.5 h-1.5 bg-concrete/25 hover:bg-concrete/50'
}`}
aria-label={t('homeDashboard.intelGoTo', { index: idx + 1 })}
/>
))}
</div>
<span className="text-[8px] font-mono text-concrete/60 uppercase">
{t('homeDashboard.intelPosition', { current: activeIndex + 1, total: filteredItems.length })}
</span>
</div>
)}
{/* File d'attente compacte */}
{filteredItems.length > 1 && (
<div className="flex gap-1.5 mt-3 overflow-x-auto custom-scrollbar pt-1">
{filteredItems.map((item, idx) => {
const isActive = idx === activeIndex
let label = ''
let Icon = Sparkles
let accent = 'text-indigo-500'
if (item.kind === 'insight') {
label = item.insight.note1.title?.slice(0, 28) || t('homeDashboard.untitled')
Icon = Sparkles
accent = 'text-indigo-500'
} else if (item.kind === 'bridge') {
label = item.bridge.note?.title?.slice(0, 28) || t('homeDashboard.untitled')
Icon = Zap
accent = 'text-ochre'
} else if (item.kind === 'suggestion') {
label = item.suggestion.suggestedTitle.slice(0, 28)
Icon = Lightbulb
accent = 'text-violet-500'
} else {
label = item.agent.agentName
Icon = Bot
accent = 'text-ochre'
}
return (
<button
key={item.id}
type="button"
onClick={() => setActiveIndex(idx)}
className={`shrink-0 flex items-center gap-1.5 px-2 py-1.5 rounded-lg border text-start max-w-[130px] transition-all ${
isActive
? 'border-ochre/40 bg-ochre/5 shadow-sm'
: 'border-border/25 bg-stone-50/50 dark:bg-zinc-950/30 hover:border-border/50'
}`}
>
<Icon size={9} className={`shrink-0 ${accent}`} />
<span className="text-[8px] font-medium text-ink dark:text-dark-ink truncate">{label}</span>
</button>
)
})}
</div>
)}
</>
)}
</>
)}
</section>
)
}

View File

@@ -10,6 +10,12 @@ import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
import '@excalidraw/excalidraw/index.css'
import type { PresentationSpec } from '@/lib/types/presentation'
import { useLanguage } from '@/lib/i18n'
function SlidesLoadingFallback() {
const { t } = useLanguage()
return <div className="absolute inset-0 flex items-center justify-center bg-zinc-950 text-white/40 text-sm">{t('lab.loadingSlides')}</div>
}
const Excalidraw = dynamic(
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
@@ -18,7 +24,7 @@ const Excalidraw = dynamic(
const SlidesRenderer = dynamic(
() => import('./slides-renderer').then(m => ({ default: m.SlidesRenderer })),
{ ssr: false, loading: () => <div className="absolute inset-0 flex items-center justify-center bg-zinc-950 text-white/40 text-sm">Chargement des slides</div> }
{ ssr: false, loading: () => <SlidesLoadingFallback /> }
)
interface CanvasBoardProps {
@@ -62,6 +68,7 @@ function parseCanvasScene(initialData?: string): {
}
function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
const { t } = useLanguage()
const handleDownload = () => {
try {
const byteCharacters = atob(data.base64)
@@ -83,7 +90,7 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
URL.revokeObjectURL(url)
} catch (e) {
console.error('[PptxViewer] Download failed:', e)
toast.error('Failed to download presentation')
toast.error(t('lab.downloadFailed'))
}
}
@@ -106,7 +113,7 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
className="flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 transition-colors font-medium shadow-sm"
>
<Download className="w-5 h-5" />
Download .pptx
{t('lab.exportPpt')}
</button>
<p className="text-xs text-muted-foreground max-w-sm text-center">
This file can be opened in Microsoft PowerPoint, Google Slides, or Keynote.
@@ -117,6 +124,7 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
}
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
const { t } = useLanguage()
const iframeRef = useRef<HTMLIFrameElement>(null)
const [currentSlide, setCurrentSlide] = useState(1)
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
@@ -194,27 +202,27 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
href={`/api/canvas/slides/pptx?id=${canvasId}`}
download
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Exporter en PowerPoint"
title={t('lab.exportPpt')}
>
<Download className="w-3.5 h-3.5" />
Export PPTX
{t('lab.exportPpt')}
</a>
)}
<button
onClick={downloadHtml}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Télécharger le HTML"
title={t('lab.downloadHtml')}
>
<Download className="w-3.5 h-3.5" />
Export HTML
{t('lab.downloadHtml')}
</button>
<button
onClick={openFullscreen}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Ouvrir en plein écran"
title={t('lab.openFullscreen')}
>
<Maximize2 className="w-3.5 h-3.5" />
Plein écran
{t('lab.openFullscreen')}
</button>
</div>
</div>
@@ -228,7 +236,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
{!isLoaded && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3">
<div className="w-8 h-8 rounded-full border-2 border-white/10 border-t-white/60 animate-spin" />
<span className="text-xs text-white/30">Chargement de la présentation</span>
<span className="text-xs text-white/30">{t('lab.loadingPresentation')}</span>
</div>
)}
<iframe
@@ -244,7 +252,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
<button
onClick={() => navigate(-1)}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
aria-label="Slide précédent"
aria-label={t('lab.previousSlide')}
>
<ChevronLeft className="w-5 h-5" />
</button>
@@ -252,7 +260,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
<button
onClick={() => navigate(1)}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
aria-label="Slide suivant"
aria-label={t('lab.nextSlide')}
>
<ChevronRight className="w-5 h-5" />
</button>

View File

@@ -11,10 +11,16 @@ import {
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
FunnelChart, Funnel, LabelList,
} from 'recharts'
import { useLanguage } from '@/lib/i18n'
function MermaidLoadingFallback() {
const { t } = useLanguage()
return <div style={{ color: 'rgba(255,255,255,0.3)', padding: 24 }}>{t('common.loading')}</div>
}
const MermaidDiagram = dynamic(
() => import('./mermaid-diagram').then(m => ({ default: m.MermaidDiagram })),
{ ssr: false, loading: () => <div style={{ color: 'rgba(255,255,255,0.3)', padding: 24 }}>Chargement</div> }
{ ssr: false, loading: () => <MermaidLoadingFallback /> }
)
// ── Constants ──────────────────────────────────────────────────────────────────
@@ -688,6 +694,7 @@ export interface SlidesRendererProps {
}
export function SlidesRenderer({ spec }: SlidesRendererProps) {
const { t } = useLanguage()
const [current, setCurrent] = useState(0)
const [showNotes, setShowNotes] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
@@ -795,12 +802,12 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
{/* Navigation buttons */}
{current > 0 && (
<button style={navBtn(true)} onClick={prev} aria-label="Précédent">
<button style={navBtn(true)} onClick={prev} aria-label={t('lab.previous')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
</button>
)}
{current < total - 1 && (
<button style={navBtn(false)} onClick={next} aria-label="Suivant">
<button style={navBtn(false)} onClick={next} aria-label={t('lab.next')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 6 15 12 9 18" /></svg>
</button>
)}
@@ -830,12 +837,12 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: palette.accent }}>
Notes de présentation
{t('lab.notes')}
</span>
<button
onClick={() => setShowNotes(false)}
style={{ background: 'none', border: 'none', color: isDark ? '#94a3b8' : '#64748b', cursor: 'pointer', fontSize: 13, padding: 4 }}
title="Masquer les notes"
title={t('lab.hideNotes')}
>
</button>
@@ -860,10 +867,10 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
display: 'flex', alignItems: 'center', gap: 6, transition: 'all 0.2s',
backdropFilter: 'blur(4px)',
}}
title="Bascule des notes de présentation (Raccourci: N)"
title={t('lab.toggleNotes')}
>
<span>📝</span>
<span>Notes</span>
<span>{t('lab.notes')}</span>
</button>
)}
<div style={{ fontSize: 12, fontWeight: 600, color: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.35)', background: isDark ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.8)', padding: '3px 10px', borderRadius: 100, backdropFilter: 'blur(4px)', border: `1px solid ${isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>

View File

@@ -47,7 +47,7 @@ export function MobileActionSheet({
const end = $pos.end(1)
editor.chain().focus().setTextSelection({ from: start, to: end }).run()
toast.success(t('richTextEditor.blockSelected') || 'Bloc sélectionné en entier')
toast.success(t('richTextEditor.blockSelected'))
onClose()
}
@@ -62,7 +62,7 @@ export function MobileActionSheet({
const nodeText = editor.state.doc.slice(start, end)
editor.chain().focus().insertContentAt(end, nodeText.content.toJSON()).run()
toast.success(t('richTextEditor.blockDuplicated') || 'Bloc dupliqué')
toast.success(t('richTextEditor.blockDuplicated'))
onClose()
}
@@ -75,7 +75,7 @@ export function MobileActionSheet({
const start = $pos.before(1)
const end = $pos.after(1)
editor.chain().focus().deleteRange({ from: start, to: end }).run()
toast.success(t('richTextEditor.blockDeleted') || 'Bloc supprimé')
toast.success(t('richTextEditor.blockDeleted'))
onClose()
}
@@ -85,7 +85,7 @@ export function MobileActionSheet({
onClose()
const tab = action === 'improve' ? 'actions' : 'chat'
window.dispatchEvent(new CustomEvent('memento-open-ai', { detail: { tab, scroll: action } }))
toast.info(t('richTextEditor.aiActionStarted') || 'IA Note sollicitée...')
toast.info(t('richTextEditor.aiActionStarted'))
}
return createPortal(
@@ -101,26 +101,26 @@ export function MobileActionSheet({
<div className="mobile-action-sheet-body">
{/* Section 1 : Actions de bloc */}
<div className="mobile-action-sheet-section">
<h4 className="section-title">{t('mobile.blockActions') || 'Actions sur le bloc'}</h4>
<h4 className="section-title">{t('mobile.blockActions')}</h4>
<div className="grid grid-cols-3 gap-2">
<button type="button" className="action-tile-btn" onClick={handleSelectAllBlock}>
<FileText size={20} className="text-blue-500" />
<span>{t('mobile.selectAll') || 'Sélectionner tout'}</span>
<span>{t('mobile.selectAll')}</span>
</button>
<button type="button" className="action-tile-btn" onClick={handleDuplicateBlock}>
<Copy size={20} className="text-emerald-500" />
<span>{t('mobile.duplicate') || 'Dupliquer'}</span>
<span>{t('mobile.duplicate')}</span>
</button>
<button type="button" className="action-tile-btn text-rose-500" onClick={handleDeleteBlock}>
<Trash2 size={20} className="text-rose-500" />
<span>{t('mobile.delete') || 'Supprimer'}</span>
<span>{t('mobile.delete')}</span>
</button>
</div>
</div>
{/* Section 2 : IA Note */}
<div className="mobile-action-sheet-section">
<h4 className="section-title">{t('mobile.aiNote') || 'IA Note'}</h4>
<h4 className="section-title">{t('mobile.aiNote')}</h4>
<div className="grid grid-cols-4 gap-2">
<button type="button" className="action-tile-btn" onClick={() => handleAiAction('improve')}>
<Wand2 size={18} className="text-purple-500 animate-pulse" />
@@ -143,42 +143,42 @@ export function MobileActionSheet({
{/* Section 3 : Format de bloc */}
<div className="mobile-action-sheet-section">
<h4 className="section-title">{t('mobile.convertFormat') || 'Convertir le format'}</h4>
<h4 className="section-title">{t('mobile.convertFormat')}</h4>
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none">
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().setParagraph().run(); onClose() }}
>
{t('mobile.paragraph') || 'Paragraphe'}
{t('mobile.paragraph')}
</button>
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleHeading({ level: 1 }).run(); onClose() }}
>
<Heading1 size={14} className="inline mr-1" /> {t('notes.heading1') || 'Titre 1'}
<Heading1 size={14} className="inline mr-1" /> {t('notes.heading1')}
</button>
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleHeading({ level: 2 }).run(); onClose() }}
>
<Heading2 size={14} className="inline mr-1" /> {t('notes.heading2') || 'Titre 2'}
<Heading2 size={14} className="inline mr-1" /> {t('notes.heading2')}
</button>
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleHeading({ level: 3 }).run(); onClose() }}
>
<Heading3 size={14} className="inline mr-1" /> {t('notes.heading3') || 'Titre 3'}
<Heading3 size={14} className="inline mr-1" /> {t('notes.heading3')}
</button>
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleBlockquote().run(); onClose() }}
>
<Quote size={14} className="inline mr-1" /> {t('mobile.quote') || 'Citation'}
<Quote size={14} className="inline mr-1" /> {t('mobile.quote')}
</button>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import {
Heading, Code, Sparkles, MessageSquare, Quote, AlignLeft
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
export type MobileEditorToolbarProps = {
editor: Editor | null
@@ -19,6 +20,7 @@ export function MobileEditorToolbar({
onOpenActionSheet,
onInsertImage,
}: MobileEditorToolbarProps) {
const { t } = useLanguage()
if (!editor) return null
// Format states
@@ -61,7 +63,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isBold && 'active')}
onClick={() => editor.chain().focus().toggleBold().run()}
aria-label="Bold"
aria-label={t('editor.bold')}
>
<Bold size={18} />
</button>
@@ -70,7 +72,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isItalic && 'active')}
onClick={() => editor.chain().focus().toggleItalic().run()}
aria-label="Italic"
aria-label={t('editor.italic')}
>
<Italic size={18} />
</button>
@@ -79,7 +81,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isHighlight && 'active')}
onClick={() => editor.chain().focus().toggleHighlight().run()}
aria-label="Highlight"
aria-label={t('editor.highlight')}
>
<Highlighter size={18} />
</button>
@@ -88,7 +90,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isLink && 'active')}
onClick={handleLinkPress}
aria-label="Link"
aria-label={t('editor.link')}
>
<Link2 size={18} />
</button>
@@ -97,7 +99,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isBulletList && 'active')}
onClick={() => editor.chain().focus().toggleBulletList().run()}
aria-label="Bullet List"
aria-label={t('editor.bulletList')}
>
<List size={18} />
</button>
@@ -106,7 +108,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isTaskList && 'active')}
onClick={() => editor.chain().focus().toggleTaskList().run()}
aria-label="Task List"
aria-label={t('editor.taskList')}
>
<CheckSquare size={18} />
</button>
@@ -115,7 +117,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isHeading && 'active')}
onClick={toggleHeadingCycle}
aria-label="Heading"
aria-label={t('editor.heading')}
>
<Heading size={18} />
</button>
@@ -124,7 +126,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isCodeBlock && 'active')}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
aria-label="Code Block"
aria-label={t('editor.codeBlock')}
>
<Code size={18} />
</button>
@@ -133,7 +135,7 @@ export function MobileEditorToolbar({
type="button"
className="mobile-toolbar-btn highlight-btn"
onClick={onOpenActionSheet}
aria-label="Plus"
aria-label={t('editor.plus')}
>
<Sparkles size={18} className="text-amber-500 animate-pulse" />
</button>

View File

@@ -144,7 +144,7 @@ export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
useEffect(() => {
setLoading(true)
fetch('/api/graph')
.then(r => { if (!r.ok) throw new Error('Erreur réseau'); return r.json() })
.then(r => { if (!r.ok) throw new Error(t('errors.network')); return r.json() })
.then(d => setRawData(d))
.catch(e => setError(e.message))
.finally(() => setLoading(false))

View File

@@ -189,7 +189,7 @@ function NoteGridThumbnail({
await updateNote(note.id, { illustrationSvg: null })
await onNoteIllustrationDeleted?.(note.id)
} catch {
toast.error('Erreur lors de la suppression')
toast.error(t('notes.deleteError'))
} finally {
setBusy(false)
}

View File

@@ -42,7 +42,7 @@ export function OrganizeNotebookDialog({
const handleAnalyze = useCallback(async () => {
setStep('analyzing'); setError(null); setPlan(null)
const res = await analyzeNotebookForOrganization(notebookId, language)
if (!res.success || !res.plan) { setError(res.error ?? 'Erreur'); setStep('idle'); return }
if (!res.success || !res.plan) { setError(res.error ?? t('common.error')); setStep('idle'); return }
setPlan(res.plan)
setEditableGroups(res.plan.groups.map(g => ({ ...g, notes: [...g.notes] })))
setExpandedGroups(new Set(res.plan.groups.map((_, i) => i)))
@@ -74,7 +74,7 @@ export function OrganizeNotebookDialog({
groups: editableGroups.filter(g => g.notes.length > 0 && g.name.trim()),
}
const res = await executeNotebookOrganization(finalPlan)
if (!res.success) { setError(res.error ?? 'Erreur'); setStep('preview'); return }
if (!res.success) { setError(res.error ?? t('common.error')); setStep('preview'); return }
setResult({ created: res.created, moved: res.moved })
setStep('done')
toast.success(t('organizeNotebook.toastSuccess', { created: res.created, moved: res.moved }))

View File

@@ -9,6 +9,7 @@ import {
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/markdown-content'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { useLanguage } from '@/lib/i18n'
// ── Personas definition ──────────────────────────────────────────────────────
@@ -87,6 +88,7 @@ interface PersonasPanelProps {
export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
const { requestAiConsent } = useAiConsent()
const { t } = useLanguage()
const [loadingId, setLoadingId] = useState<PersonaId | null>(null)
const [results, setResults] = useState<Map<PersonaId, PersonaResult>>(new Map())
const [expanded, setExpanded] = useState<PersonaId | null>(null)
@@ -122,7 +124,7 @@ export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
body: JSON.stringify({ content: noteContent, title: noteTitle, personaId }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Erreur')
if (!res.ok) throw new Error(data.error || t('common.error'))
setResults(prev => new Map(prev).set(personaId, data))
setExpanded(personaId)
} catch {

View File

@@ -37,9 +37,9 @@ export function ProvidersWrapper({
return (
<QueryProvider>
<NoteRefreshProvider>
<NotebooksProvider>
<EditorUIProvider>
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
<NotebooksProvider>
<EditorUIProvider>
<AiConsentProvider initialPersistentConsent={initialAiProcessingConsent}>
<DirWrapper>
<SearchModalProvider>
@@ -49,9 +49,9 @@ export function ProvidersWrapper({
</SearchModalProvider>
</DirWrapper>
</AiConsentProvider>
</LanguageProvider>
</EditorUIProvider>
</NotebooksProvider>
</EditorUIProvider>
</NotebooksProvider>
</LanguageProvider>
</NoteRefreshProvider>
</QueryProvider>
)

View File

@@ -35,12 +35,13 @@ import {
FileText,
Folder,
FolderOpen,
LayoutGrid,
} from 'lucide-react'
import { useSearchModal } from '@/context/search-modal-context'
import { useLanguage } from '@/lib/i18n'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { applyDocumentTheme } from '@/lib/apply-document-theme'
import { getAllNotes, getTrashCount, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
import { getAllNotes, getTrashCount, getNotesWithReminders, toggleReminderDone, getInboxCount } from '@/app/actions/notes'
import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
import { useNotebooks } from '@/context/notebooks-context'
import { Notebook, Note } from '@/lib/types'
@@ -62,9 +63,10 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { performSignOut } from '@/lib/auth-client'
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
type NavigationView = 'dashboard' | 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
const NOTEBOOKS_PANEL_HEIGHT_KEY = 'memento-sidebar-notebooks-height'
@@ -639,6 +641,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const [showSortMenu, setShowSortMenu] = useState(false)
const [notebookSearchQuery, setNotebookSearchQuery] = useState('')
const [trashCount, setTrashCount] = useState(0)
const [inboxCount, setInboxCount] = useState(0)
const notebooksContainerRef = useRef<HTMLDivElement>(null)
const notebooksPanelRef = useRef<HTMLDivElement>(null)
@@ -804,8 +807,11 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
})
}, [currentNotebookId, notebooks])
const isDashboardRoute = isDashboardHomeRoute(pathname, searchParams)
const isInboxActive =
pathname === '/home' &&
searchParams.get('forceList') === '1' &&
!searchParams.get('notebook') &&
!searchParams.get('labels') &&
!searchParams.get('archived') &&
@@ -817,8 +823,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
else if (pathname === '/insights') setActiveView('insights')
else if (pathname.startsWith('/revision')) setActiveView('revision')
else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders')
else if (isDashboardRoute) setActiveView('dashboard')
else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks')
}, [pathname, searchParams])
}, [pathname, searchParams, isDashboardRoute])
const isRemindersRoute = pathname === '/home' && searchParams.get('reminders') === '1'
const isSharedRoute = pathname === '/home' && searchParams.get('shared') === '1'
@@ -848,7 +855,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
(pathname === '/home' || pathname.startsWith('/notes')) &&
!pathname.startsWith('/settings') &&
!isRemindersRoute &&
!isSharedRoute
!isSharedRoute &&
!isDashboardRoute
const displayName = user?.name || user?.email || ''
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
@@ -872,6 +880,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
useEffect(() => {
let cancelled = false
getTrashCount().then(count => { if (!cancelled) setTrashCount(count) })
getInboxCount().then((count: number) => { if (!cancelled) setInboxCount(count) })
return () => { cancelled = true }
}, [])
@@ -902,6 +911,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
useEffect(() => {
const onNoteChange = (e: Event) => {
const detail = (e as CustomEvent<NoteChangeEvent>).detail
getInboxCount().then((count: number) => setInboxCount(count))
if (detail.type === 'deleted') {
setNotebookNotes((prev) => {
const next = { ...prev }
@@ -1340,6 +1350,16 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{/* Boutons de navigation principaux */}
<div className="flex flex-col gap-2 w-full px-1.5">
{([
{
id: 'dashboard',
icon: LayoutGrid,
label: t('nav.dashboard') || 'Dashboard',
onClick: () => {
setActiveView('dashboard')
router.push('/home')
},
isActive: isDashboardRoute,
},
{
id: 'notebooks',
icon: BookOpen,
@@ -1517,7 +1537,78 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
>
<AnimatePresence mode="wait">
{activeView === 'notebooks' ? (
{activeView === 'dashboard' ? (
<motion.div
key="dashboard"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
className="px-4 pt-4 space-y-4"
>
<div className="flex items-center gap-1.5">
<LayoutGrid size={14} className="text-brand-accent" />
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
{t('nav.dashboard')}
</h3>
</div>
<p className="text-[12px] leading-relaxed text-muted-foreground">
{t('sidebar.dashboardPanelBody')}
</p>
<div className="space-y-1.5">
<button
type="button"
onClick={handleInboxClick}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<span className="flex items-center gap-2 min-w-0">
<Inbox size={14} className="text-brand-accent shrink-0" />
{t('homeDashboard.inbox')}
</span>
{inboxCount > 0 && (
<span className="text-[10px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded shrink-0">
{inboxCount}
</span>
)}
</button>
<button
type="button"
onClick={() => router.push('/revision')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<GraduationCap size={14} className="text-brand-accent shrink-0" />
{t('homeDashboard.review')}
</button>
<button
type="button"
onClick={handleRemindersClick}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<Bell size={14} className="text-brand-accent shrink-0" />
{t('homeDashboard.reminders')}
</button>
<button
type="button"
onClick={() => router.push('/insights')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<Sparkles size={14} className="text-brand-accent shrink-0" />
{t('homeDashboard.themes')}
</button>
<button
type="button"
onClick={() => {
setActiveView('notebooks')
router.push('/home?forceList=1')
}}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<BookOpen size={14} className="text-brand-accent shrink-0" />
{t('sidebar.backToNotebooks')}
</button>
</div>
</motion.div>
) : activeView === 'notebooks' ? (
<motion.div
key="notebooks"
ref={notebooksContainerRef}
@@ -1641,12 +1732,22 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</div>
<span
className={cn(
'text-[13px] font-medium truncate',
'text-[13px] font-medium truncate flex-1',
isInboxActive ? 'text-ink' : 'text-muted-ink',
)}
>
{t('sidebar.inbox')}
</span>
{inboxCount > 0 && (
<span className={cn(
'text-[10px] font-bold min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full transition-colors',
isInboxActive
? 'bg-brand-accent text-white'
: 'bg-brand-accent/10 text-brand-accent',
)}>
{inboxCount > 99 ? '99+' : inboxCount}
</span>
)}
</button>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import { NoteChartFromCode } from './note-chart'
import { useState } from 'react'
import { Code, BarChart3, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
/**
* ChartExtension - TipTap Node extension for rendering chart blocks
@@ -107,6 +108,7 @@ export const ChartExtension = Node.create({
* - Error handling for invalid chart data
*/
function ChartBlockView(props: any) {
const { t } = useLanguage()
const [isEditing, setIsEditing] = useState(false)
const [parseError, setParseError] = useState(false)
@@ -120,7 +122,7 @@ function ChartBlockView(props: any) {
<NodeViewWrapper className="notion-chart-code-block my-4">
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border-b border-border">
<Code className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Chart Code</span>
<span className="text-sm text-muted-foreground">{t('chart.code')}</span>
<button
onClick={() => setIsEditing(false)}
className="ml-auto text-xs px-2 py-1 bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
@@ -144,8 +146,8 @@ function ChartBlockView(props: any) {
<div className="flex items-center gap-3 text-muted-foreground">
<AlertCircle className="w-5 h-5" />
<div>
<p className="font-medium">Invalid Chart</p>
<p className="text-sm">This chart block contains invalid or empty data.</p>
<p className="font-medium">{t('chart.invalid')}</p>
<p className="text-sm">{t('chart.invalidDescription')}</p>
</div>
</div>
</div>
@@ -158,7 +160,7 @@ function ChartBlockView(props: any) {
'absolute top-2 right-2 p-2 rounded-lg bg-background/80 backdrop-blur border border-border shadow-sm opacity-0 group-hover:opacity-100 transition-opacity',
'hover:bg-background hover:shadow-md'
)}
title="Edit chart source"
title={t('chart.editSource')}
>
<Code className="w-4 h-4 text-muted-foreground" />
</button>