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

@@ -1,11 +1,13 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays } from 'lucide-react' import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays, Mail } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { SettingsHelpBox } from '@/components/settings/settings-help-box' import { SettingsHelpBox } from '@/components/settings/settings-help-box'
import { useLanguage } from '@/lib/i18n'
export default function IntegrationsPage() { export default function IntegrationsPage() {
const { t } = useLanguage()
// ── Readwise ─────────────────────────────────────────────────────────── // ── Readwise ───────────────────────────────────────────────────────────
const [rwToken, setRwToken] = useState('') const [rwToken, setRwToken] = useState('')
const [rwConnected, setRwConnected] = useState(false) const [rwConnected, setRwConnected] = useState(false)
@@ -19,32 +21,47 @@ export default function IntegrationsPage() {
const [calEvents, setCalEvents] = useState<any[]>([]) const [calEvents, setCalEvents] = useState<any[]>([])
const [calFetching, setCalFetching] = useState(false) const [calFetching, setCalFetching] = useState(false)
// ── Gmail ────────────────────────────────────────────────────────────
const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(true)
const [gmailRecent, setGmailRecent] = useState(0)
const [gmailScanning, setGmailScanning] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
fetch('/api/integrations/readwise').then((r) => r.json()), fetch('/api/integrations/readwise').then((r) => r.json()),
fetch('/api/integrations/calendar').then((r) => r.json()), fetch('/api/integrations/calendar').then((r) => r.json()),
]).then(([rw, cal]) => { fetch('/api/integrations/gmail').then((r) => r.json()),
]).then(([rw, cal, gmail]) => {
setRwConnected(rw.connected) setRwConnected(rw.connected)
setCalConnected(cal.connected) setCalConnected(cal.connected)
setGmailConnected(gmail.connected)
setGmailRecent(gmail.recentCaptures ?? 0)
}).finally(() => { }).finally(() => {
setLoading(false) setLoading(false)
setCalLoading(false) setCalLoading(false)
setGmailLoading(false)
}) })
// Handle redirect params // Handle redirect params
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
if (params.get('connected') === 'calendar') { if (params.get('connected') === 'calendar') {
setCalConnected(true) setCalConnected(true)
toast.success('Google Calendar connecté !') toast.success(t('integrations.calendarConnected'))
window.history.replaceState({}, '', '/settings/integrations')
}
if (params.get('connected') === 'gmail') {
setGmailConnected(true)
toast.success(t('integrations.gmail.connected'))
window.history.replaceState({}, '', '/settings/integrations') window.history.replaceState({}, '', '/settings/integrations')
} }
if (params.get('error')) { if (params.get('error')) {
toast.error(`Erreur: ${params.get('error')}`) toast.error(t('integrations.errorWith', { error: params.get('error') || '' }))
window.history.replaceState({}, '', '/settings/integrations') window.history.replaceState({}, '', '/settings/integrations')
} }
}, []) }, [t])
// ── Readwise handlers ────────────────────────────────────────────────── // ── Readwise handlers ──────────────────────────────────────────────────
const handleRwConnect = async () => { const handleRwConnect = async () => {
@@ -57,12 +74,12 @@ export default function IntegrationsPage() {
body: JSON.stringify({ token: rwToken.trim() }), body: JSON.stringify({ token: rwToken.trim() }),
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur Readwise'); return } if (!res.ok) { toast.error(data.error || t('integrations.readwiseError')); return }
setRwConnected(true) setRwConnected(true)
setRwToken('') setRwToken('')
setRwLastSync({ created: data.created, updated: data.updated }) setRwLastSync({ created: data.created, updated: data.updated })
toast.success(`Readwise connecté — ${data.created} notes créées, ${data.updated} mises à jour`) toast.success(t('integrations.readwiseConnected', { created: data.created, updated: data.updated }))
} catch { toast.error('Erreur de connexion Readwise') } finally { setRwConnecting(false) } } catch { toast.error(t('integrations.readwiseConnectError')) } finally { setRwConnecting(false) }
} }
const handleRwSync = async () => { const handleRwSync = async () => {
@@ -74,16 +91,16 @@ export default function IntegrationsPage() {
body: JSON.stringify({}), body: JSON.stringify({}),
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur de sync'); return } if (!res.ok) { toast.error(data.error || t('integrations.syncError')); return }
setRwLastSync({ created: data.created, updated: data.updated }) setRwLastSync({ created: data.created, updated: data.updated })
toast.success(`Sync Readwise — ${data.created} créées, ${data.updated} mises à jour`) toast.success(t('integrations.readwiseSynced', { created: data.created, updated: data.updated }))
} catch { toast.error('Erreur de synchronisation') } finally { setRwSyncing(false) } } catch { toast.error(t('integrations.syncFailed')) } finally { setRwSyncing(false) }
} }
const handleRwDisconnect = async () => { const handleRwDisconnect = async () => {
await fetch('/api/integrations/readwise', { method: 'DELETE' }) await fetch('/api/integrations/readwise', { method: 'DELETE' })
setRwConnected(false) setRwConnected(false)
toast.success('Readwiseconnecté') toast.success(t('integrations.readwiseDisconnected'))
} }
// ── Calendar handlers ────────────────────────────────────────────────── // ── Calendar handlers ──────────────────────────────────────────────────
@@ -96,10 +113,10 @@ export default function IntegrationsPage() {
try { try {
const res = await fetch('/api/integrations/calendar?events=1') const res = await fetch('/api/integrations/calendar?events=1')
const data = await res.json() const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur'); return } if (!res.ok) { toast.error(data.error || t('common.error')); return }
setCalEvents(data.events ?? []) setCalEvents(data.events ?? [])
if (data.events.length === 0) toast.info('Aucun événement aujourd\'hui') if (data.events.length === 0) toast.info(t('integrations.noEvents'))
} catch { toast.error('Erreur de chargement des événements') } finally { setCalFetching(false) } } catch { toast.error(t('integrations.eventsLoadError')) } finally { setCalFetching(false) }
} }
const handleCreateMeetingNote = async (event: any) => { const handleCreateMeetingNote = async (event: any) => {
@@ -110,11 +127,11 @@ export default function IntegrationsPage() {
}) })
const data = await res.json() const data = await res.json()
if (data.success) { if (data.success) {
toast.success(`Note de réunion créée : ${event.summary}`, { toast.success(t('integrations.meetingNoteCreated', { summary: event.summary }), {
action: { label: 'Ouvrir', onClick: () => window.location.href = `/home?openNote=${data.note.id}` }, action: { label: t('integrations.open'), onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
}) })
} else { } else {
toast.error('Erreur lors de la création de la note') toast.error(t('integrations.createNoteError'))
} }
} }
@@ -122,25 +139,114 @@ export default function IntegrationsPage() {
await fetch('/api/integrations/calendar', { method: 'DELETE' }) await fetch('/api/integrations/calendar', { method: 'DELETE' })
setCalConnected(false) setCalConnected(false)
setCalEvents([]) setCalEvents([])
toast.success('Google Calendarconnecté') toast.success(t('integrations.calendarDisconnected'))
}
// ── Gmail handlers ─────────────────────────────────────────────────────
const handleGmailConnect = () => {
window.location.href = '/api/integrations/gmail?connect=1'
}
const handleGmailScan = async () => {
setGmailScanning(true)
try {
const res = await fetch('/api/integrations/gmail?scan=1')
const data = await res.json()
if (!res.ok) { toast.error(data.error || t('common.error')); return }
setGmailRecent(prev => prev + (data.created ?? 0))
toast.success(t('integrations.gmail.scanDone', { created: data.created ?? 0 }))
} catch {
toast.error(t('integrations.gmailScanError'))
} finally {
setGmailScanning(false)
}
}
const handleGmailDisconnect = async () => {
await fetch('/api/integrations/gmail', { method: 'DELETE' })
setGmailConnected(false)
setGmailRecent(0)
toast.success(t('integrations.gmail.disconnected'))
} }
const StatusBadge = ({ connected }: { connected: boolean }) => const StatusBadge = ({ connected }: { connected: boolean }) =>
connected ? ( connected ? (
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800/50 rounded-full px-2.5 py-1"> <span className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800/50 rounded-full px-2.5 py-1">
<Check size={11} /> Connecté <Check size={11} /> {t('integrations.connected')}
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-concrete bg-border/20 border border-border/40 rounded-full px-2.5 py-1"> <span className="flex items-center gap-1.5 text-[11px] font-semibold text-concrete bg-border/20 border border-border/40 rounded-full px-2.5 py-1">
<X size={11} /> Non connecté <X size={11} /> {t('integrations.notConnected')}
</span> </span>
) )
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">Intégrations</h2> <h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">{t('settings.integrationsTitle')}</h2>
<p className="text-sm text-concrete mt-1">Connectez des services externes à Memento.</p> <p className="text-sm text-concrete mt-1">{t('settings.integrationsDesc')}</p>
</div>
{/* ── Gmail ──────────────────────────────────────────────────────── */}
<div className="border border-border/40 rounded-2xl p-6 bg-paper space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Mail size={20} className="text-red-600 dark:text-red-400" />
</div>
<div>
<h3 className="font-semibold text-ink text-sm">{t('integrations.gmail.title')}</h3>
<p className="text-[12px] text-concrete">{t('integrations.gmail.description')}</p>
</div>
</div>
{gmailLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={gmailConnected} />}
</div>
{!gmailConnected ? (
<div className="space-y-3">
<SettingsHelpBox
title={t('integrations.gmail.helpTitle')}
steps={[
{ text: t('integrations.gmail.helpStep1') },
{ text: t('integrations.gmail.helpStep2') },
{ text: t('integrations.gmail.helpStep3') },
{ text: t('integrations.gmail.helpStep4') },
]}
/>
<button
onClick={handleGmailConnect}
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all flex items-center gap-2"
>
<Mail size={14} />
{t('integrations.gmail.connect')}
</button>
</div>
) : (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<button
onClick={handleGmailScan}
disabled={gmailScanning}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{gmailScanning ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{t('integrations.gmail.scanNow')}
</button>
<button
onClick={handleGmailDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
{t('integrations.gmail.disconnect')}
</button>
</div>
{gmailRecent > 0 && (
<p className="text-[11px] text-concrete">
{t('integrations.gmail.recentCaptures', { count: gmailRecent })}
</p>
)}
</div>
)}
</div> </div>
{/* ── Google Calendar ────────────────────────────────────────────── */} {/* ── Google Calendar ────────────────────────────────────────────── */}
@@ -152,7 +258,7 @@ export default function IntegrationsPage() {
</div> </div>
<div> <div>
<h3 className="font-semibold text-ink text-sm">Google Calendar</h3> <h3 className="font-semibold text-ink text-sm">Google Calendar</h3>
<p className="text-[12px] text-concrete">Accédez à vos événements et créez des notes de réunion</p> <p className="text-[12px] text-concrete">{t('integrations.calendarDesc')}</p>
</div> </div>
</div> </div>
{calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />} {calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />}
@@ -161,12 +267,12 @@ export default function IntegrationsPage() {
{!calConnected ? ( {!calConnected ? (
<div className="space-y-3"> <div className="space-y-3">
<SettingsHelpBox <SettingsHelpBox
title="Comment fonctionne Google Calendar ?" title={t('integrations.calendarInfo')}
steps={[ steps={[
{ text: 'Cliquez "Connecter Google Calendar" — vous serez redirigé vers Google pour autoriser l\'accès.' }, { text: t('integrations.calendarHelpStep1') },
{ text: 'Une fois connecté, revenez ici et cliquez "Événements aujourd\'hui" pour voir votre agenda.' }, { text: t('integrations.calendarHelpStep2') },
{ text: 'Sur chaque événement, cliquez "+ Note" pour créer automatiquement une note de réunion avec template (Ordre du jour / Notes / Actions).' }, { text: t('integrations.calendarHelpStep3') },
{ text: 'La note s\'ouvre directement dans Memento — ajoutez vos notes en temps réel pendant la réunion.' }, { text: t('integrations.calendarHelpStep4') },
]} ]}
/> />
<button <button
@@ -174,7 +280,7 @@ export default function IntegrationsPage() {
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all flex items-center gap-2" className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all flex items-center gap-2"
> >
<CalendarDays size={14} /> <CalendarDays size={14} />
Connecter Google Calendar {t('integrations.connectCalendar')}
</button> </button>
</div> </div>
) : ( ) : (
@@ -186,14 +292,14 @@ export default function IntegrationsPage() {
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50" className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
> >
{calFetching ? <Loader2 size={14} className="animate-spin" /> : <CalendarDays size={14} />} {calFetching ? <Loader2 size={14} className="animate-spin" /> : <CalendarDays size={14} />}
Événements aujourd&apos;hui {t('integrations.todayEvents')}
</button> </button>
<button <button
onClick={handleCalDisconnect} onClick={handleCalDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all" className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
> >
<Trash2 size={14} /> <Trash2 size={14} />
Déconnecter {t('integrations.disconnect')}
</button> </button>
</div> </div>
@@ -211,7 +317,7 @@ export default function IntegrationsPage() {
onClick={() => handleCreateMeetingNote(ev)} onClick={() => handleCreateMeetingNote(ev)}
className="shrink-0 text-[11px] font-semibold px-3 py-1.5 border border-border/40 rounded-full hover:bg-ink/5 transition-all" className="shrink-0 text-[11px] font-semibold px-3 py-1.5 border border-border/40 rounded-full hover:bg-ink/5 transition-all"
> >
+ Note {t('integrations.addNote')}
</button> </button>
</div> </div>
))} ))}
@@ -230,20 +336,20 @@ export default function IntegrationsPage() {
</div> </div>
<div> <div>
<h3 className="font-semibold text-ink text-sm">Readwise</h3> <h3 className="font-semibold text-ink text-sm">Readwise</h3>
<p className="text-[12px] text-concrete">Importez vos surlignages de livres, articles et Kindle</p> <p className="text-[12px] text-concrete">{t('integrations.readwiseDesc')}</p>
</div> </div>
</div> </div>
{loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />} {loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />}
</div> </div>
<SettingsHelpBox <SettingsHelpBox
title="Comment fonctionne Readwise ?" title={t('integrations.readwiseInfo')}
steps={[ steps={[
{ text: 'Copiez votre token d\'accès Readwise.', link: { label: 'readwise.io/access_token', href: 'https://readwise.io/access_token' } }, { text: t('integrations.readwiseHelpStep1'), link: { label: 'readwise.io/access_token', href: 'https://readwise.io/access_token' } },
{ text: 'Collez-le dans le champ ci-dessous et cliquez "Connecter". La première synchronisation importe tous vos livres et articles.' }, { text: t('integrations.readwiseHelpStep2') },
{ text: 'Chaque livre devient une note dans un carnet "Readwise 📚" — avec tous vos surlignages organisés.' }, { text: t('integrations.readwiseHelpStep3') },
{ text: 'Pour mettre à jour avec de nouveaux surlignages, revenez ici et cliquez "Synchroniser maintenant".' }, { text: t('integrations.readwiseHelpStep4') },
{ icon: '💡', text: 'Astuce : créez des flashcards IA depuis une note Readwise (bouton 🎓 dans l\'éditeur) pour réviser vos lectures.' }, { icon: '💡', text: t('integrations.readwiseHelpStep5') },
]} ]}
/> />
@@ -254,7 +360,7 @@ export default function IntegrationsPage() {
type="password" type="password"
value={rwToken} value={rwToken}
onChange={(e) => setRwToken(e.target.value)} onChange={(e) => setRwToken(e.target.value)}
placeholder="Token Readwise…" placeholder={t('integrations.readwiseTokenPlaceholder')}
className="flex-1 text-sm border border-border/40 rounded-xl px-3 py-2 bg-paper text-ink placeholder:text-concrete/50 focus:outline-none focus:ring-1 focus:ring-brand-accent/40" className="flex-1 text-sm border border-border/40 rounded-xl px-3 py-2 bg-paper text-ink placeholder:text-concrete/50 focus:outline-none focus:ring-1 focus:ring-brand-accent/40"
onKeyDown={(e) => e.key === 'Enter' && handleRwConnect()} onKeyDown={(e) => e.key === 'Enter' && handleRwConnect()}
/> />
@@ -264,7 +370,7 @@ export default function IntegrationsPage() {
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all disabled:opacity-50 flex items-center gap-2" className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all disabled:opacity-50 flex items-center gap-2"
> >
{rwConnecting ? <Loader2 size={14} className="animate-spin" /> : null} {rwConnecting ? <Loader2 size={14} className="animate-spin" /> : null}
Connecter {t('integrations.connect')}
</button> </button>
</div> </div>
</div> </div>
@@ -278,28 +384,28 @@ export default function IntegrationsPage() {
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50" className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
> >
{rwSyncing ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />} {rwSyncing ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
Synchroniser maintenant {t('integrations.syncNow')}
</button> </button>
<button <button
onClick={handleRwDisconnect} onClick={handleRwDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all" className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
> >
<Trash2 size={14} /> <Trash2 size={14} />
Déconnecter {t('integrations.disconnect')}
</button> </button>
</div> </div>
)} )}
{rwLastSync && ( {rwLastSync && (
<p className="text-[11px] text-concrete"> <p className="text-[11px] text-concrete">
Dernière sync : <strong>{rwLastSync.created}</strong> notes créées, <strong>{rwLastSync.updated}</strong> mises à jour {t('integrations.lastSync')} <strong>{rwLastSync.created}</strong> {t('integrations.createdLabel')}, <strong>{rwLastSync.updated}</strong> {t('integrations.updatedLabel')}
</p> </p>
)} )}
</div> </div>
{/* Placeholder */} {/* Placeholder */}
<div className="border border-dashed border-border/40 rounded-2xl p-6 text-center"> <div className="border border-dashed border-border/40 rounded-2xl p-6 text-center">
<p className="text-sm text-concrete italic">D'autres intégrations arrivent bientôt Zapier, GitHub, Notion import</p> <p className="text-sm text-concrete italic">{t('integrations.moreComing')}</p>
</div> </div>
</div> </div>
) )

View File

@@ -1082,6 +1082,27 @@ export async function getRecentNotes(limit: number = 3) {
} }
} }
// Get count of unassigned notes (Inbox count) — notes without a notebook
export async function getInboxCount() {
const session = await auth()
if (!session?.user?.id) return 0
try {
const count = await prisma.note.count({
where: {
userId: session.user.id,
notebookId: null,
isArchived: false,
trashedAt: null,
},
})
return count
} catch (error) {
console.error('Error counting inbox notes:', error)
return 0
}
}
// Dismiss a note from Recent section // Dismiss a note from Recent section
export async function dismissFromRecent(id: string) { export async function dismissFromRecent(id: string) {
const session = await auth(); const session = await auth();

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const result = await agentSuggestionService.accept(session.user.id, id)
if (!result) {
return NextResponse.json({ error: 'Suggestion introuvable' }, { status: 404 })
}
return NextResponse.json({ success: true, agentId: result.agentId })
}

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const result = await agentSuggestionService.dismiss(session.user.id, id)
if (!result) {
return NextResponse.json({ error: 'Suggestion introuvable' }, { status: 404 })
}
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const suggestions = await agentSuggestionService.getPending(session.user.id, 3)
return NextResponse.json({
suggestions: suggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
suggestedType: s.suggestedType,
suggestedFrequency: s.suggestedFrequency,
relatedNoteCount: (() => {
try {
return JSON.parse(s.relatedNoteIds).length
} catch {
return 0
}
})(),
clusterId: s.clusterId,
createdAt: s.createdAt.toISOString(),
})),
})
}

View File

@@ -0,0 +1,204 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
import { buildDashboardPaths, buildOpenLoops } from '@/lib/dashboard/paths.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
function excerptNoteContent(content: string | null | undefined, max = 200): string {
if (!content) return ''
const plain = content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
interface PathsBriefingSnapshot {
recentNote?: {
id: string
title: string | null
content: string
notebookId: string | null
} | null
inboxCount?: number
dueFlashcards?: number
insights?: Array<{
id: string
insight: string
score: number
viewed: boolean
note1: { id: string; title: string | null }
note2: { id: string; title: string | null }
note1Excerpt?: string
note2Excerpt?: string
}>
bridgeSuggestions?: Array<{
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}>
agentSuggestions?: Array<{
id: string
topic: string
reason: string
clusterId: number | null
}>
includeOpenLoops?: boolean
}
async function resolvePathsPayload(userId: string, snapshot?: PathsBriefingSnapshot | null) {
const now = new Date()
const hasSnapshot = !!snapshot?.recentNote
const needsAgents = !snapshot?.agentSuggestions
const needsBridges = !snapshot?.bridgeSuggestions
const needsInsights = !snapshot?.insights
const needsCounts = snapshot?.inboxCount === undefined || snapshot?.dueFlashcards === undefined
const needsFocusNote = !snapshot?.recentNote
const [
aiConsent,
aiSettings,
systemConfig,
recentNotes,
inboxCount,
dueFlashcards,
unviewedInsights,
agentSuggestions,
bridgeSuggestions,
] = await Promise.all([
hasUserAiConsent(),
prisma.userAISettings.findUnique({
where: { userId },
select: { memoryEcho: true },
}),
getSystemConfig(),
needsFocusNote
? prisma.note.findMany({
where: { userId, trashedAt: null, isArchived: false },
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 1,
})
: Promise.resolve([]),
needsCounts
? prisma.note.count({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
})
: Promise.resolve(snapshot!.inboxCount!),
needsCounts
? prisma.flashcard.count({
where: { deck: { userId }, nextReviewAt: { lte: now } },
})
: Promise.resolve(snapshot!.dueFlashcards!),
needsInsights
? prisma.memoryEchoInsight.findMany({
where: { userId, dismissed: false },
select: {
id: true, insight: true, similarityScore: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5,
}).catch(() => [])
: Promise.resolve([]),
needsAgents
? agentSuggestionService.getPending(userId, 3).catch(() => [])
: Promise.resolve([]),
needsBridges
? bridgeNotesService.getBridgeSuggestions(userId).catch(() => [])
: Promise.resolve([]),
])
const aiProviderReady = !!getChatProvider(systemConfig)
const memoryEchoEnabled = aiSettings?.memoryEcho ?? true
const aiActive = aiConsent && aiProviderReady && memoryEchoEnabled
const focusNotes = hasSnapshot && snapshot?.recentNote
? [{
...snapshot.recentNote,
updatedAt: now,
}]
: recentNotes
const insights = snapshot?.insights ?? unviewedInsights.map(i => ({
id: i.id,
insight: i.insight,
score: i.similarityScore,
viewed: i.viewed,
note1: { id: i.note1.id, title: i.note1.title },
note2: { id: i.note2.id, title: i.note2.title },
note1Excerpt: excerptNoteContent(i.note1.content),
note2Excerpt: excerptNoteContent(i.note2.content),
}))
const resolvedBridges = snapshot?.bridgeSuggestions
?? bridgeSuggestions.slice(0, 3).map(s => ({
clusterAId: s.clusterAId,
clusterBId: s.clusterBId,
clusterAName: s.clusterAName,
clusterBName: s.clusterBName,
suggestedTitle: s.suggestedTitle,
suggestedContent: s.suggestedContent,
justification: s.justification,
}))
const resolvedAgents = snapshot?.agentSuggestions
?? agentSuggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
clusterId: s.clusterId,
}))
const paths = await buildDashboardPaths({
userId,
aiActive,
recentNotes: focusNotes,
inboxCount: needsCounts ? inboxCount : snapshot!.inboxCount!,
dueFlashcards: needsCounts ? dueFlashcards : snapshot!.dueFlashcards!,
insights,
bridgeSuggestions: resolvedBridges,
agentSuggestions: resolvedAgents,
}).catch(() => [])
const openLoops = snapshot?.includeOpenLoops === false
? []
: await buildOpenLoops(userId).catch(() => [])
return { paths, openLoops }
}
/**
* GET /api/briefing/paths — pistes enrichies (fallback sans snapshot client).
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const payload = await resolvePathsPayload(session.user.id)
return NextResponse.json(payload)
}
/**
* POST /api/briefing/paths — enrichissement avec snapshot briefing (évite re-fetch agents/ponts).
*/
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const snapshot = await request.json().catch(() => null) as PathsBriefingSnapshot | null
const payload = await resolvePathsPayload(session.user.id, snapshot)
return NextResponse.json(payload)
}

View File

@@ -0,0 +1,240 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
function excerptNoteContent(content: string | null | undefined, max = 200): string {
if (!content) return ''
const plain = content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
async function fetchWritingActivity(userId: string, since: Date) {
const rows = await prisma.$queryRaw<Array<{ date: string; count: number }>>`
SELECT to_char("updatedAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date,
COUNT(*)::int AS count
FROM "Note"
WHERE "userId" = ${userId}
AND "trashedAt" IS NULL
AND "updatedAt" >= ${since}
GROUP BY 1
ORDER BY 1 ASC
`
return rows.map(r => ({ date: r.date, count: Number(r.count) }))
}
/**
* GET /api/briefing — agrégat rapide pour le tableau de bord.
* Pas de génération IA synchrone ; les pistes sont dans GET /api/briefing/paths.
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const now = new Date()
const ninetyDaysAgo = new Date(now)
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
const [aiConsent, aiSettings, systemConfig] = await Promise.all([
hasUserAiConsent(),
prisma.userAISettings.findUnique({
where: { userId },
select: { memoryEcho: true },
}),
getSystemConfig(),
])
const aiProviderReady = !!getChatProvider(systemConfig)
const memoryEchoEnabled = aiSettings?.memoryEcho ?? true
const aiActive = aiConsent && aiProviderReady && memoryEchoEnabled
const [
recentNotes,
inboxCount,
dueFlashcards,
upcomingReminders,
unviewedInsights,
recentAgentActions,
pinnedNotes,
writingActivity,
gmailStatus,
agentSuggestions,
bridgeSuggestions,
] = await Promise.all([
prisma.note.findMany({
where: { userId, trashedAt: null, isArchived: false },
select: {
id: true, title: true, content: true, color: true,
notebookId: true, updatedAt: true, createdAt: true,
},
orderBy: { updatedAt: 'desc' },
take: 6,
}),
prisma.note.count({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
}),
prisma.flashcard.count({
where: { deck: { userId }, nextReviewAt: { lte: now } },
}),
prisma.note.findMany({
where: {
userId, reminder: { gte: now }, isReminderDone: false, trashedAt: null,
},
select: { id: true, title: true, reminder: true, notebookId: true },
orderBy: { reminder: 'asc' },
take: 5,
}),
prisma.memoryEchoInsight.findMany({
where: { userId, viewed: false, dismissed: false },
select: {
id: true, insight: true, similarityScore: true,
insightDate: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5,
}).catch(() => []),
prisma.agentAction.findMany({
where: {
agent: { userId },
status: 'completed',
createdAt: { gte: new Date(now.getTime() - 48 * 60 * 60 * 1000) },
},
select: {
id: true, result: true, createdAt: true,
agent: { select: { id: true, name: true, type: true } },
},
orderBy: { createdAt: 'desc' },
take: 3,
}).catch(() => []),
prisma.note.findMany({
where: { userId, isPinned: true, trashedAt: null, isArchived: false },
select: { id: true, title: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 5,
}),
fetchWritingActivity(userId, ninetyDaysAgo).catch(() => []),
gmailScannerService.getStatus(userId).catch(() => ({
connected: false,
recentCaptures: 0,
})),
agentSuggestionService.getPending(userId, 3).catch(() => []),
bridgeNotesService.getBridgeSuggestions(userId).catch(() => []),
])
let insightRows = unviewedInsights
if (insightRows.length < 5) {
const viewedRecent = await prisma.memoryEchoInsight.findMany({
where: {
userId,
dismissed: false,
viewed: true,
id: { notIn: insightRows.map(i => i.id) },
},
select: {
id: true, insight: true, similarityScore: true,
insightDate: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5 - insightRows.length,
}).catch(() => [])
insightRows = [...insightRows, ...viewedRecent]
}
const notebookIds = [...new Set(recentNotes.map(n => n.notebookId).filter(Boolean))] as string[]
const notebooks = notebookIds.length > 0
? await prisma.notebook.findMany({
where: { id: { in: notebookIds } },
select: { id: true, name: true, color: true, icon: true },
})
: []
const notebookMap = new Map(notebooks.map(n => [n.id, n]))
return NextResponse.json({
recentNotes: recentNotes.map(n => ({
...n,
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
})),
inboxCount,
dueFlashcards,
upcomingReminders: upcomingReminders.map(r => ({
id: r.id,
title: r.title,
reminder: r.reminder?.toISOString() || null,
notebookId: r.notebookId,
})),
insights: insightRows.map(i => ({
id: i.id,
insight: i.insight,
score: i.similarityScore,
date: i.insightDate.toISOString(),
viewed: i.viewed,
note1: { id: i.note1.id, title: i.note1.title },
note2: { id: i.note2.id, title: i.note2.title },
note1Excerpt: excerptNoteContent(i.note1.content),
note2Excerpt: excerptNoteContent(i.note2.content),
})),
bridgeSuggestions: bridgeSuggestions.slice(0, 3).map(s => ({
clusterAId: s.clusterAId,
clusterBId: s.clusterBId,
clusterAName: s.clusterAName,
clusterBName: s.clusterBName,
suggestedTitle: s.suggestedTitle,
suggestedContent: s.suggestedContent,
justification: s.justification,
})),
ai: {
consent: aiConsent,
memoryEchoEnabled,
providerReady: aiProviderReady,
active: aiActive,
},
agentActions: recentAgentActions.map(a => ({
id: a.id,
agentName: a.agent?.name || 'Agent',
agentType: a.agent?.type || 'custom',
result: a.result ? String(a.result).slice(0, 200) : null,
createdAt: a.createdAt.toISOString(),
})),
gmail: gmailStatus,
pinnedNotes: pinnedNotes.map(n => ({
id: n.id,
title: n.title,
notebookId: n.notebookId,
updatedAt: n.updatedAt.toISOString(),
})),
writingActivity,
agentSuggestions: agentSuggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
suggestedType: s.suggestedType,
suggestedFrequency: s.suggestedFrequency,
relatedNoteCount: (() => {
try { return JSON.parse(s.relatedNoteIds).length } catch { return 0 }
})(),
clusterId: s.clusterId,
})),
})
}

View File

@@ -0,0 +1,87 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { redis } from '@/lib/redis'
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
const CACHE_TTL_SEC = 3600
/**
* GET /api/briefing/sentiment
* Analyzes the emotional tone of recent notes (last 7 days) using LLM.
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const locale = await detectUserLanguage()
const cacheKey = `briefing:sentiment:${userId}:${locale}`
try {
const cached = await redis.get(cacheKey)
if (cached) return NextResponse.json(JSON.parse(cached))
} catch {}
const weekAgo = new Date()
weekAgo.setDate(weekAgo.getDate() - 7)
const recentNotes = await prisma.note.findMany({
where: {
userId,
trashedAt: null,
isArchived: false,
updatedAt: { gte: weekAgo },
},
select: { title: true, content: true },
take: 20,
orderBy: { updatedAt: 'desc' },
})
if (recentNotes.length < 3) {
return NextResponse.json({
available: false,
reason: 'Not enough notes this week',
})
}
const snippets = recentNotes.map(n => {
const text = n.content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
return `${n.title || ''}: ${text.slice(0, 200)}`
}).join('\n---\n')
try {
const config = await getSystemConfig()
const provider = getChatProvider(config)
if (!provider) {
return NextResponse.json({ available: false, reason: 'No AI provider' })
}
const localeLabel = locale === 'fr' ? 'French' : locale === 'en' ? 'English' : locale
const prompt = `Analyze the emotional patterns in these notes from the past week. Return ONLY valid JSON (no markdown, no code fences).
Write "summary" and "topTopic" in ${localeLabel} (locale code: ${locale}). Keep dominantEmotion keys in English as listed.
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence describing the emotional pattern","topTopic":"most discussed topic"}
Notes:
${snippets.slice(0, 3000)}`
const result = await provider.generateText(prompt)
const jsonMatch = result.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
return NextResponse.json({ available: false, reason: 'Parse error' })
}
const parsed = JSON.parse(jsonMatch[0])
const payload = { available: true, ...parsed }
try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {}
return NextResponse.json(payload)
} catch (error) {
console.error('[briefing/sentiment]', error)
return NextResponse.json({ available: false, reason: 'Analysis failed' })
}
}

View File

@@ -20,8 +20,54 @@ export async function GET(request: NextRequest) {
// Check for stored results (even if stale/périmés) // Check for stored results (even if stale/périmés)
const stored = await clusteringService.getStoredClusters(userId) const stored = await clusteringService.getStoredClusters(userId)
const lite = request.nextUrl.searchParams.get('lite') === '1'
if (stored) { if (stored) {
const cached = stored.clusters const cached = stored.clusters
if (lite) {
const [bridgeNotesData, totalNotes] = await Promise.all([
prisma.bridgeNote.findMany({
where: { userId },
orderBy: { bridgeScore: 'desc' },
take: 5,
select: { noteId: true, bridgeScore: true, clustersConnected: true },
}),
prisma.note.count({ where: { userId, trashedAt: null } }),
])
let enrichedBridgeNotes: object[] = []
if (bridgeNotesData.length > 0) {
const bridgeNoteDetails = await prisma.note.findMany({
where: { id: { in: bridgeNotesData.map(b => b.noteId) } },
select: { id: true, title: true },
})
const bridgeNoteDetailsMap = new Map(bridgeNoteDetails.map(n => [n.id, n]))
enrichedBridgeNotes = bridgeNotesData.map(b => {
const clustersConnected = JSON.parse(b.clustersConnected) as number[]
return {
noteId: b.noteId,
bridgeScore: b.bridgeScore,
clustersConnected,
clusterNames: clustersConnected.map(
cid => cached.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`,
),
note: bridgeNoteDetailsMap.get(b.noteId),
}
})
}
return NextResponse.json({
clusters: cached,
bridgeNotes: enrichedBridgeNotes,
cached: true,
stale: stored.stale,
lastCalculated: stored.lastCalculated,
totalNotes,
})
}
// Fetch notes with their cluster assignments // Fetch notes with their cluster assignments
const notes = await prisma.note.findMany({ const notes = await prisma.note.findMany({
where: { userId, trashedAt: null }, where: { userId, trashedAt: null },

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export const dynamic = 'force-dynamic'
function authorizeCron(request: NextRequest): boolean {
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) return false
const authHeader = request.headers.get('authorization')
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const querySecret = new URL(request.url).searchParams.get('secret')
return bearer === cronSecret || querySecret === cronSecret
}
/**
* POST /api/cron/agent-suggestions
* Génère des suggestions d'agents à partir des clusters sémantiques existants.
*/
export async function POST(request: NextRequest) {
if (!authorizeCron(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const users = await prisma.user.findMany({
where: { noteClusters: { some: {} } },
select: { id: true },
take: 30,
})
let total = 0
for (const user of users) {
try {
total += await agentSuggestionService.generateForUser(user.id)
} catch (err) {
console.error('[CronAgentSuggestions] user', user.id, err)
}
}
return NextResponse.json({ success: true, users: users.length, suggestionsUpserted: total })
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
export const dynamic = 'force-dynamic'
function authorize(request: NextRequest): boolean {
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) return false
const authHeader = request.headers.get('authorization')
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
return bearer === cronSecret
}
/** POST /api/cron/gmail — scan Gmail for connected users (1×/jour en prod via entrypoint) */
export async function POST(request: NextRequest) {
if (!authorize(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const users = await prisma.userAISettings.findMany({
select: { userId: true, integrationTokens: true },
take: 200,
})
const connected = users.filter(u => {
if (!u.integrationTokens) return false
try {
const meta = typeof u.integrationTokens === 'string'
? JSON.parse(u.integrationTokens)
: u.integrationTokens
return !!(meta?.gmailAccessToken && meta?.gmailRefreshToken)
} catch {
return false
}
})
let totalCreated = 0
for (const row of connected) {
try {
const { created } = await gmailScannerService.scanUser(row.userId)
totalCreated += created
} catch (err) {
console.error('[CronGmail]', row.userId, err)
}
}
return NextResponse.json({ success: true, users: connected.length, notesCreated: totalCreated })
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import {
getDefaultDashboardLayout,
normalizeDashboardLayout,
type DashboardLayout,
} from '@/lib/dashboard/layout'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const settings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id },
select: { dashboardLayout: true },
})
const layout = normalizeDashboardLayout(settings?.dashboardLayout ?? getDefaultDashboardLayout())
return NextResponse.json({ layout })
}
export async function PUT(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let body: { layout?: DashboardLayout }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const layout = normalizeDashboardLayout(body.layout ?? getDefaultDashboardLayout())
const layoutJson = layout as unknown as Prisma.InputJsonValue
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: { userId: session.user.id, dashboardLayout: layoutJson },
update: { dashboardLayout: layoutJson },
})
return NextResponse.json({ layout, ok: true })
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { getGoogleOAuthCredentials, readIntegrationMeta, writeIntegrationMeta } from '@/lib/integrations/google-integration-tokens'
export async function GET(req: NextRequest) {
const url = new URL(req.url)
const code = url.searchParams.get('code')
const userId = url.searchParams.get('state')
const error = url.searchParams.get('error')
if (error || !code || !userId) {
return NextResponse.redirect(`${url.origin}/settings/integrations?error=gmail_auth_failed`)
}
const { clientId, clientSecret } = getGoogleOAuthCredentials()
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: `${url.origin}/api/integrations/gmail/callback`,
grant_type: 'authorization_code',
}),
})
const tokenData = await tokenRes.json()
if (!tokenData.access_token) {
return NextResponse.redirect(`${url.origin}/settings/integrations?error=gmail_token_failed`)
}
const meta = await readIntegrationMeta(userId)
meta.gmailAccessToken = tokenData.access_token
if (tokenData.refresh_token) meta.gmailRefreshToken = tokenData.refresh_token
await writeIntegrationMeta(userId, meta)
return NextResponse.redirect(`${url.origin}/settings/integrations?connected=gmail`)
}

View File

@@ -0,0 +1,67 @@
/**
* Google Gmail Integration (OAuth + scan trigger)
*/
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import {
getGoogleOAuthCredentials,
getGoogleTokens,
readIntegrationMeta,
writeIntegrationMeta,
} from '@/lib/integrations/google-integration-tokens'
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
const GMAIL_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
function redirectUri(origin: string) {
return `${origin}/api/integrations/gmail/callback`
}
export async function GET(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const url = new URL(req.url)
const userId = session.user.id
if (url.searchParams.get('connect') === '1') {
const { clientId } = getGoogleOAuthCredentials()
if (!clientId) {
return NextResponse.json({ error: 'Google OAuth not configured' }, { status: 503 })
}
const authUrl = new URL(GOOGLE_AUTH_URL)
authUrl.searchParams.set('client_id', clientId)
authUrl.searchParams.set('redirect_uri', redirectUri(url.origin))
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('scope', GMAIL_SCOPE)
authUrl.searchParams.set('access_type', 'offline')
authUrl.searchParams.set('prompt', 'consent')
authUrl.searchParams.set('state', userId)
return NextResponse.redirect(authUrl.toString())
}
if (url.searchParams.get('scan') === '1') {
const result = await gmailScannerService.scanUser(userId)
return NextResponse.json(result)
}
const status = await gmailScannerService.getStatus(userId)
return NextResponse.json(status)
}
export async function DELETE() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const meta = await readIntegrationMeta(session.user.id)
delete meta.gmailAccessToken
delete meta.gmailRefreshToken
await writeIntegrationMeta(session.user.id, meta)
return NextResponse.json({ success: true })
}

View File

@@ -24,14 +24,14 @@ const PROVIDER_INFO: Record<string, { name: string; hint: string }> = {
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' }, minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' }, google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' }, deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' },
openrouter: { name: 'OpenRouter', hint: 'Accès multi-fournisseurs' }, openrouter: { name: 'OpenRouter', hint: 'Multi-provider access' },
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' }, mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' }, glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
zai: { name: 'Zuki Journey', hint: 'Proxy OpenAI/Anthropic' }, zai: { name: 'Zuki Journey', hint: 'OpenAI/Anthropic proxy' },
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Proxy compatible Anthropic' }, anthropic_custom: { name: 'Anthropic (custom)', hint: 'Anthropic-compatible proxy' },
custom_openai: { name: 'Compatible OpenAI', hint: 'Tout proxy compatible OpenAI' }, custom_openai: { name: 'Compatible OpenAI', hint: 'Any OpenAI-compatible proxy' },
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Tout proxy compatible Anthropic' }, custom_anthropic: { name: 'Compatible Anthropic', hint: 'Any Anthropic-compatible proxy' },
custom: { name: 'API personnalisée', hint: 'Votre propre endpoint' }, custom: { name: 'Custom API', hint: 'Your own endpoint' },
} }
function displayName(provider: string): string { function displayName(provider: string): string {
@@ -59,7 +59,7 @@ type SavedKey = {
async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> { async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> {
const res = await fetch('/api/user/api-keys') 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() return res.json()
} }
@@ -92,6 +92,8 @@ function EditKeyForm({
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null) 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 needsUrl = NEEDS_BASE_URL.has(savedKey.provider)
const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider) const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider)
@@ -118,10 +120,10 @@ function EditKeyForm({
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
const body = await res.json().catch(() => ({})) 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: () => { onSuccess: () => {
toast.success(`${displayName(savedKey.provider)} mis à jour ✓`) toast.success(t('byok.updated', { name: displayName(savedKey.provider) }))
onInvalidate() onInvalidate()
onDone() onDone()
}, },
@@ -132,7 +134,7 @@ function EditKeyForm({
const keyToUse = newKey.length >= 8 ? newKey : '' const keyToUse = newKey.length >= 8 ? newKey : ''
if (!keyToUse && !model) return if (!keyToUse && !model) return
if (!keyToUse) { if (!keyToUse) {
toast.info('Pour tester, entrez la clé API dans le champ "Nouvelle clé"') toast.info(t('byok.testHint'))
return return
} }
setTesting(true) setTesting(true)
@@ -143,7 +145,7 @@ function EditKeyForm({
const res = await fetch(`/api/user/api-keys/test-model?${q}`) const res = await fetch(`/api/user/api-keys/test-model?${q}`)
setTestResult(await res.json()) setTestResult(await res.json())
} catch { } catch {
setTestResult({ ok: false, error: 'Impossible de contacter le serveur' }) setTestResult({ ok: false, error: t('byok.contactError') })
} finally { } finally {
setTesting(false) setTesting(false)
} }
@@ -155,18 +157,18 @@ function EditKeyForm({
return ( return (
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3"> <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"> <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> </p>
{/* Alias */} {/* Alias */}
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Alias</Label> <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="ex. Ma clé pro" /> <Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} />
</div> </div>
{needsUrl && ( {needsUrl && (
<div className="space-y-1"> <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" /> <Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
</div> </div>
)} )}
@@ -174,21 +176,21 @@ function EditKeyForm({
{/* Model */} {/* Model */}
<div className="space-y-1"> <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 ? ( {showModelDropdown ? (
<Select value={model} onValueChange={setModel}> <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> <SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select> </Select>
) : showModelInput ? ( ) : showModelInput ? (
<Input id={`edit-model-${savedKey.provider}`} value={model} onChange={(e) => setModel(e.target.value)} placeholder="ex. MiniMax-M2.7" /> <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> </div>
{/* Key rotation (optional) */} {/* Key rotation (optional) */}
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete"> <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> </Label>
<Input <Input
id={`edit-key-${savedKey.provider}`} id={`edit-key-${savedKey.provider}`}
@@ -196,7 +198,7 @@ function EditKeyForm({
autoComplete="off" autoComplete="off"
value={newKey} value={newKey}
onChange={(e) => { setNewKey(e.target.value); setTestResult(null) }} onChange={(e) => { setNewKey(e.target.value); setTestResult(null) }}
placeholder="sk-... (optionnel)" placeholder={t('byok.keyPlaceholder')}
/> />
</div> </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" />} {testResult.ok ? <CheckCircle2 size={12} className="shrink-0 mt-0.5" /> : <XCircle size={12} className="shrink-0 mt-0.5" />}
<span> <span>
{testResult.ok {testResult.ok
? `✓ Opérationnel${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}` ? `${t('byok.operational')}${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
: testResult.error ?? 'Échec' : testResult.error ?? t('common.error')
} }
</span> </span>
</div> </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" 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} />} {testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
Tester {t('byok.test')}
</button> </button>
<Button <Button
type="button" type="button"
@@ -233,7 +235,7 @@ function EditKeyForm({
onClick={() => saveMutation.mutate()} 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" 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>
<button <button
type="button" type="button"
@@ -305,7 +307,7 @@ export function ByokSettingsPanel() {
async function verifyKey() { async function verifyKey() {
if (!provider || apiKey.length < 8) return 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) setVerifying(true)
setTestResult(null) setTestResult(null)
try { try {
@@ -316,12 +318,12 @@ export function ByokSettingsPanel() {
if (res.ok && body.valid) { if (res.ok && body.valid) {
setKeyOk(true) setKeyOk(true)
if (Array.isArray(body.models) && body.models.length > 0) { setModels(body.models); if (!model) setModel(body.models[0]) } 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 { } else {
setKeyOk(false) 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) } finally { setVerifying(false) }
} }
@@ -334,7 +336,7 @@ export function ByokSettingsPanel() {
if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl) if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl)
const res = await fetch(`/api/user/api-keys/test-model?${q}`) const res = await fetch(`/api/user/api-keys/test-model?${q}`)
setTestResult(await res.json()) 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) } finally { setTesting(false) }
} }
@@ -352,10 +354,10 @@ export function ByokSettingsPanel() {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
const body = await res.json().catch(() => ({})) 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: () => { onSuccess: () => {
toast.success(`Clé ${displayName(provider)} enregistrée ✓`) toast.success(t('byok.keySaved', { name: displayName(provider) }))
setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('') setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('')
setModel(''); setModels([]); setKeyOk(false); setTestResult(null) setModel(''); setModels([]); setKeyOk(false); setTestResult(null)
invalidate() invalidate()
@@ -369,7 +371,7 @@ export function ByokSettingsPanel() {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive }), body: JSON.stringify({ isActive }),
}) })
if (!res.ok) throw new Error('Erreur') if (!res.ok) throw new Error(t('common.error'))
}, },
onSuccess: invalidate, onSuccess: invalidate,
onError: () => toast.error(t('byokSettings.error')), onError: () => toast.error(t('byokSettings.error')),
@@ -378,9 +380,9 @@ export function ByokSettingsPanel() {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: async (p: string) => { mutationFn: async (p: string) => {
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { method: 'DELETE' }) 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')), 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' : 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
)}> )}>
{activeKey ? ( {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> </div>
)} )}
@@ -431,7 +433,7 @@ export function ByokSettingsPanel() {
{/* Saved keys */} {/* Saved keys */}
{keys.length > 0 && ( {keys.length > 0 && (
<div className="space-y-2"> <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"> <ul className="space-y-1">
{keys.map((key) => ( {keys.map((key) => (
<li key={key.provider}> <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.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>} {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')}> <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> </span>
</div> </div>
</div> </div>
@@ -481,7 +483,7 @@ export function ByokSettingsPanel() {
type="button" 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" 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} 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} /> <Trash2 size={12} />
</button> </button>
@@ -507,14 +509,14 @@ export function ByokSettingsPanel() {
{/* Add key form */} {/* Add key form */}
<div className="space-y-4 border border-border/60 rounded-2xl p-5"> <div className="space-y-4 border border-border/60 rounded-2xl p-5">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete"> <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> </p>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <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}> <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> <SelectContent>
{allowed.map((p) => ( {allowed.map((p) => (
<SelectItem key={p} value={p}> <SelectItem key={p} value={p}>
@@ -526,20 +528,20 @@ export function ByokSettingsPanel() {
</Select> </Select>
</div> </div>
<div className="space-y-1.5"> <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> <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="ex. Ma clé pro" disabled={saveMutation.isPending} /> <Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} disabled={saveMutation.isPending} />
</div> </div>
</div> </div>
{NEEDS_BASE_URL.has(provider) && ( {NEEDS_BASE_URL.has(provider) && (
<div className="space-y-1.5"> <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} /> <Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
</div> </div>
)} )}
<div className="space-y-1.5"> <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="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Input <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', 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')} 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> </button>
</div> </div>
</div> </div>
{provider && ( {provider && (
<div className="space-y-1.5"> <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 ? ( {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 ? ( ) : showModelDropdown ? (
<Select value={model} onValueChange={(v) => { setModel(v); setTestResult(null) }} disabled={saveMutation.isPending}> <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> <SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select> </Select>
) : showModelInput ? ( ) : 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" />} {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"> <div className="space-y-0.5">
{testResult.ok ? ( {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">{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 ?? 'Échec du test'}</p>} ) : <p className="font-semibold">{testResult.error ?? t('byok.testFailed')}</p>}
</div> </div>
</div> </div>
)} )}
@@ -593,7 +595,7 @@ export function ByokSettingsPanel() {
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel} 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" 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>
<Button <Button
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()} type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
@@ -601,7 +603,7 @@ export function ByokSettingsPanel() {
> >
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />} {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> </div>
</Button> </Button>
</div> </div>

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { Search, Sparkles, Link2, X, Folder } from 'lucide-react' import { Search, Sparkles, Link2, X, Folder } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
export interface BlockSuggestion { export interface BlockSuggestion {
blockId: string blockId: string
@@ -22,6 +23,7 @@ interface BlockPickerProps {
} }
export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: BlockPickerProps) { export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: BlockPickerProps) {
const { t } = useLanguage()
const [activeTab, setActiveTab] = useState<'suggestions' | 'search'>('suggestions') const [activeTab, setActiveTab] = useState<'suggestions' | 'search'>('suggestions')
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [suggestions, setSuggestions] = useState<BlockSuggestion[]>([]) const [suggestions, setSuggestions] = useState<BlockSuggestion[]>([])
@@ -134,7 +136,7 @@ export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: B
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} 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" 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 autoFocus
/> />

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useLanguage } from '@/lib/i18n'
import { motion } from 'motion/react' import { motion } from 'motion/react'
import { Zap, Lightbulb, Trophy } from 'lucide-react' import { Zap, Lightbulb, Trophy } from 'lucide-react'
@@ -32,6 +33,7 @@ interface BridgeNotesDashboardProps {
} }
export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashboardProps) { export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashboardProps) {
const { t } = useLanguage()
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([]) const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([]) const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
const [loading, setLoading] = useState(true) 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="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"> <div className="flex items-center gap-2 text-indigo-500 mb-2">
<Trophy size={14} /> <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>
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div> <div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
</div> </div>
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm"> <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"> <div className="flex items-center gap-2 text-ochre mb-2">
<Lightbulb size={14} /> <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>
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{suggestions.length}</div> <div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{suggestions.length}</div>
</div> </div>
@@ -126,7 +128,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<section> <section>
<div className="flex items-center gap-2 mb-6 px-1"> <div className="flex items-center gap-2 mb-6 px-1">
<Zap size={16} className="text-ochre" /> <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>
<div className="space-y-3"> <div className="space-y-3">
{bridgeNotes.map((bridge) => ( {bridgeNotes.map((bridge) => (
@@ -138,10 +140,10 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
> >
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1"> <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> </h4>
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full"> <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> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -158,7 +160,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
</motion.div> </motion.div>
))} ))}
{bridgeNotes.length === 0 && ( {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> </div>
</section> </section>
@@ -167,7 +169,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<section> <section>
<div className="flex items-center gap-2 mb-6 px-1"> <div className="flex items-center gap-2 mb-6 px-1">
<Lightbulb size={16} className="text-indigo-500" /> <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>
<div className="space-y-4"> <div className="space-y-4">
{suggestions.map((s, idx) => ( {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 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> </div>
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60"> <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> </span>
</div> </div>
<h4 className="text-base font-memento-serif font-medium text-ink dark:text-dark-ink mb-2">{s.suggestedTitle}</h4> <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 <button
onClick={() => dismissSuggestion(s.clusterAId, s.clusterBId)} 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" 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> </button>
@@ -203,13 +205,13 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
{suggestions.length === 0 && !loading && ( {suggestions.length === 0 && !loading && (
<div className="text-center py-8 text-concrete"> <div className="text-center py-8 text-concrete">
<Lightbulb size={24} className="mx-auto mb-3 opacity-50" /> <Lightbulb size={24} className="mx-auto mb-3 opacity-50" />
<p className="text-sm">No connection suggestions yet</p> <p className="text-sm">{t('bridgeNotes.noSuggestions')}</p>
<p className="text-xs mt-1">All your clusters may already be connected!</p> <p className="text-xs mt-1">{t('bridgeNotes.allConnected')}</p>
<button <button
onClick={generateNewSuggestions} onClick={generateNewSuggestions}
className="mt-4 px-4 py-2 bg-indigo-500 text-white text-xs rounded-lg hover:bg-indigo-600 transition-colors" 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> </button>
</div> </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 { suggestCharts, chartSuggestionToMarkdown, type ChartSuggestion, type SuggestChartsResponse } from '@/lib/ai/services/chart-suggestion.service'
import { BarChart3, X, Search, AlertCircle, Sparkles } from 'lucide-react' import { BarChart3, X, Search, AlertCircle, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
// Chart type to color mapping for visual variety // Chart type to color mapping for visual variety
const CHART_TYPE_COLORS: Record<string, string> = { const CHART_TYPE_COLORS: Record<string, string> = {
@@ -36,6 +37,7 @@ export function ChartSuggestionsDialog({
onClose, onClose,
onSelectChart, onSelectChart,
}: ChartSuggestionsDialogProps) { }: ChartSuggestionsDialogProps) {
const { t } = useLanguage()
const [response, setResponse] = useState<SuggestChartsResponse | null>(null) const [response, setResponse] = useState<SuggestChartsResponse | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [selectedIndex, setSelectedIndex] = useState<number | null>(null) const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
@@ -127,12 +129,12 @@ export function ChartSuggestionsDialog({
{loading ? ( {loading ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Search className="w-3 h-3 animate-spin" /> <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> </span>
) : response?.hasData ? ( ) : response?.hasData ? (
`Found ${response?.detectedData || 'data'}` `${t('chart.found')} ${response?.detectedData || ''}`
) : ( ) : (
'No data detected' t('chart.noDataDetected')
)} )}
</p> </p>
</div> </div>
@@ -140,7 +142,7 @@ export function ChartSuggestionsDialog({
<button <button
onClick={onClose} onClick={onClose}
className="p-2 hover:bg-muted rounded-lg transition-colors" className="p-2 hover:bg-muted rounded-lg transition-colors"
aria-label="Close" aria-label={t('common.close')}
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
@@ -152,7 +154,7 @@ export function ChartSuggestionsDialog({
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-center"> <div className="text-center">
<Search className="w-12 h-12 mx-auto mb-4 text-muted-foreground animate-pulse" /> <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>
</div> </div>
) : response?.quotaExceeded ? ( ) : 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" className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onClick={() => (window.location.href = '/settings/billing')} onClick={() => (window.location.href = '/settings/billing')}
> >
Upgrade Plan {t('ai.featureLocked')}
</button> </button>
</div> </div>
</div> </div>
@@ -175,10 +177,10 @@ export function ChartSuggestionsDialog({
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-center max-w-md"> <div className="text-center max-w-md">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" /> <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> <p className="text-sm text-muted-foreground mb-2">{response.error}</p>
<details className="text-left text-xs text-muted-foreground mt-4"> <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"> <pre className="mt-2 bg-muted p-2 rounded overflow-auto max-h-32">
analyzedText: {response.analyzedText} analyzedText: {response.analyzedText}
{'\n'}detectedData: {response.detectedData} {'\n'}detectedData: {response.detectedData}
@@ -190,20 +192,20 @@ export function ChartSuggestionsDialog({
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-center max-w-md"> <div className="text-center max-w-md">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground" /> <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"> <p className="text-muted-foreground mb-4">
Try adding numerical data to your note. Charts work best with: {t('chart.noDataHint')}
</p> </p>
<ul className="text-sm text-muted-foreground text-left inline-block"> <ul className="text-sm text-muted-foreground text-left inline-block">
<li> Sales figures, metrics, or measurements</li> <li>{t('chart.dataHint1')}</li>
<li> Lists with values (e.g., "Jan: 5000, Feb: 7500")</li> <li>{t('chart.dataHint2')}</li>
<li> Percentages or proportions</li> <li>{t('chart.dataHint3')}</li>
<li> Time-series data</li> <li>{t('chart.dataHint4')}</li>
</ul> </ul>
<div className="mt-4 pt-4 border-t border-border/50"> <div className="mt-4 pt-4 border-t border-border/50">
<details className="text-left"> <details className="text-left">
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground"> <summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
Debug: Show what was analyzed {t('chart.debugShowAnalyzed')}
</summary> </summary>
<pre className="mt-2 text-xs bg-muted p-2 rounded overflow-auto max-h-32"> <pre className="mt-2 text-xs bg-muted p-2 rounded overflow-auto max-h-32">
{textToAnalyze.substring(0, 500)} {textToAnalyze.substring(0, 500)}
@@ -276,7 +278,7 @@ export function ChartSuggestionsDialog({
{/* Data preview */} {/* Data preview */}
<div className="mt-2 pt-2 border-t border-border/50"> <div className="mt-2 pt-2 border-t border-border/50">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{suggestion.data.length} data points {suggestion.data.length} {t('chart.dataPoints')}
</p> </p>
</div> </div>
</button> </button>
@@ -286,9 +288,9 @@ export function ChartSuggestionsDialog({
{/* Keyboard hint */} {/* Keyboard hint */}
<div className="flex items-center justify-center gap-4 pt-4 text-xs text-muted-foreground"> <div className="flex items-center justify-center gap-4 pt-4 text-xs text-muted-foreground">
<span> Navigate</span> <span>{t('chart.navigateHint')}</span>
<span> Select</span> <span>{t('chart.selectHint')}</span>
<span>Esc Cancel</span> <span>{t('chart.escCancel')}</span>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { useLanguage } from '@/lib/i18n'
import ReactFlow, { import ReactFlow, {
Node, Node,
Edge, Edge,
@@ -47,6 +48,7 @@ export function ClusterVisualization({
bridgeNotes, bridgeNotes,
onNodeClick onNodeClick
}: ClusterVisualizationProps) { }: ClusterVisualizationProps) {
const { t } = useLanguage()
const [nodes, setNodes, onNodesChange] = useNodesState([]) const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([]) const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [selectedCluster, setSelectedCluster] = useState<number | null>(null) const [selectedCluster, setSelectedCluster] = useState<number | null>(null)
@@ -79,8 +81,8 @@ export function ClusterVisualization({
data: { data: {
label: ( label: (
<div className="px-3 py-1 rounded-full text-sm font-medium" style={{ backgroundColor: color }}> <div className="px-3 py-1 rounded-full text-sm font-medium" style={{ backgroundColor: color }}>
{cluster.name || `Cluster ${cluster.clusterId}`} {cluster.name || t('clusters.clusterLabel', { id: cluster.clusterId })}
<span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} notes)</span> <span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} {t('clusters.notes')})</span>
</div> </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"> <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" /> <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> </svg>
<p>No clusters to display</p> <p>{t('clusters.empty')}</p>
<p className="text-sm mt-2">Create more notes to generate clusters</p> <p className="text-sm mt-2">{t('clusters.emptyHint')}</p>
</div> </div>
</div> </div>
) )
@@ -200,10 +202,10 @@ export function ClusterVisualization({
{selectedCluster !== null && ( {selectedCluster !== null && (
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-4 max-w-xs"> <div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-4 max-w-xs">
<h3 className="font-semibold mb-2"> <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> </h3>
<p className="text-sm text-gray-600"> <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> </p>
</div> </div>
)} )}
@@ -212,11 +214,11 @@ export function ClusterVisualization({
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-yellow-400 border-2 border-yellow-600"></div> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-blue-500"></div> <div className="w-4 h-4 rounded-full bg-blue-500"></div>
<span>Regular note</span> <span>{t('clusters.regularNote')}</span>
</div> </div>
</div> </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 { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-dialog'
import { toast } from 'sonner' import { toast } from 'sonner'
import { AnimatePresence, motion } from 'motion/react' import { AnimatePresence, motion } from 'motion/react'
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual' type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
@@ -58,6 +59,10 @@ const OrganizeNotebookDialog = dynamic(
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })), () => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
{ ssr: false } { ssr: false }
) )
const DashboardView = dynamic(
() => import('@/components/dashboard-view').then(m => ({ default: m.DashboardView })),
{ ssr: false }
)
const NotebookSiteDialog = dynamic( const NotebookSiteDialog = dynamic(
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })), () => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
{ ssr: false } { ssr: false }
@@ -225,15 +230,12 @@ export function HomeClient({
}, []) }, [])
// Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid) // 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(() => { useEffect(() => {
if (searchParams.get('forceList') === '1') { if (searchParams.get('forceList') === '1') {
setEditingNote(null) 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( const fetchNotesForCurrentView = useCallback(
async (options?: { silent?: boolean }) => { async (options?: { silent?: boolean }) => {
@@ -821,6 +823,16 @@ export function HomeClient({
emitNoteChange({ type: 'updated', note: savedNote }) 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 ( return (
<div <div
className={cn( className={cn(
@@ -838,6 +850,8 @@ export function HomeClient({
fullPage fullPage
/> />
</div> </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 className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
<div <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 type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
import '@excalidraw/excalidraw/index.css' import '@excalidraw/excalidraw/index.css'
import type { PresentationSpec } from '@/lib/types/presentation' 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( const Excalidraw = dynamic(
async () => (await import('@excalidraw/excalidraw')).Excalidraw, async () => (await import('@excalidraw/excalidraw')).Excalidraw,
@@ -18,7 +24,7 @@ const Excalidraw = dynamic(
const SlidesRenderer = dynamic( const SlidesRenderer = dynamic(
() => import('./slides-renderer').then(m => ({ default: m.SlidesRenderer })), () => 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 { interface CanvasBoardProps {
@@ -62,6 +68,7 @@ function parseCanvasScene(initialData?: string): {
} }
function PptxViewer({ data, name }: { data: PptxPayload; name: string }) { function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
const { t } = useLanguage()
const handleDownload = () => { const handleDownload = () => {
try { try {
const byteCharacters = atob(data.base64) const byteCharacters = atob(data.base64)
@@ -83,7 +90,7 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} catch (e) { } catch (e) {
console.error('[PptxViewer] Download failed:', 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" 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 className="w-5 h-5" />
Download .pptx {t('lab.exportPpt')}
</button> </button>
<p className="text-xs text-muted-foreground max-w-sm text-center"> <p className="text-xs text-muted-foreground max-w-sm text-center">
This file can be opened in Microsoft PowerPoint, Google Slides, or Keynote. 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 }) { function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
const { t } = useLanguage()
const iframeRef = useRef<HTMLIFrameElement>(null) const iframeRef = useRef<HTMLIFrameElement>(null)
const [currentSlide, setCurrentSlide] = useState(1) const [currentSlide, setCurrentSlide] = useState(1)
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading 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}`} href={`/api/canvas/slides/pptx?id=${canvasId}`}
download 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" 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" /> <Download className="w-3.5 h-3.5" />
Export PPTX {t('lab.exportPpt')}
</a> </a>
)} )}
<button <button
onClick={downloadHtml} 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" 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" /> <Download className="w-3.5 h-3.5" />
Export HTML {t('lab.downloadHtml')}
</button> </button>
<button <button
onClick={openFullscreen} 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" 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" /> <Maximize2 className="w-3.5 h-3.5" />
Plein écran {t('lab.openFullscreen')}
</button> </button>
</div> </div>
</div> </div>
@@ -228,7 +236,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
{!isLoaded && ( {!isLoaded && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3"> <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" /> <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> </div>
)} )}
<iframe <iframe
@@ -244,7 +252,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
<button <button
onClick={() => navigate(-1)} 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" 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" /> <ChevronLeft className="w-5 h-5" />
</button> </button>
@@ -252,7 +260,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
<button <button
onClick={() => navigate(1)} 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" 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" /> <ChevronRight className="w-5 h-5" />
</button> </button>

View File

@@ -11,10 +11,16 @@ import {
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
FunnelChart, Funnel, LabelList, FunnelChart, Funnel, LabelList,
} from 'recharts' } 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( const MermaidDiagram = dynamic(
() => import('./mermaid-diagram').then(m => ({ default: m.MermaidDiagram })), () => 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 ────────────────────────────────────────────────────────────────── // ── Constants ──────────────────────────────────────────────────────────────────
@@ -688,6 +694,7 @@ export interface SlidesRendererProps {
} }
export function SlidesRenderer({ spec }: SlidesRendererProps) { export function SlidesRenderer({ spec }: SlidesRendererProps) {
const { t } = useLanguage()
const [current, setCurrent] = useState(0) const [current, setCurrent] = useState(0)
const [showNotes, setShowNotes] = useState(false) const [showNotes, setShowNotes] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@@ -795,12 +802,12 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
{/* Navigation buttons */} {/* Navigation buttons */}
{current > 0 && ( {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> <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> </button>
)} )}
{current < total - 1 && ( {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> <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> </button>
)} )}
@@ -830,12 +837,12 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}> <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 }}> <span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: palette.accent }}>
Notes de présentation {t('lab.notes')}
</span> </span>
<button <button
onClick={() => setShowNotes(false)} onClick={() => setShowNotes(false)}
style={{ background: 'none', border: 'none', color: isDark ? '#94a3b8' : '#64748b', cursor: 'pointer', fontSize: 13, padding: 4 }} style={{ background: 'none', border: 'none', color: isDark ? '#94a3b8' : '#64748b', cursor: 'pointer', fontSize: 13, padding: 4 }}
title="Masquer les notes" title={t('lab.hideNotes')}
> >
</button> </button>
@@ -860,10 +867,10 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
display: 'flex', alignItems: 'center', gap: 6, transition: 'all 0.2s', display: 'flex', alignItems: 'center', gap: 6, transition: 'all 0.2s',
backdropFilter: 'blur(4px)', backdropFilter: 'blur(4px)',
}} }}
title="Bascule des notes de présentation (Raccourci: N)" title={t('lab.toggleNotes')}
> >
<span>📝</span> <span>📝</span>
<span>Notes</span> <span>{t('lab.notes')}</span>
</button> </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)'}` }}> <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) const end = $pos.end(1)
editor.chain().focus().setTextSelection({ from: start, to: end }).run() 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() onClose()
} }
@@ -62,7 +62,7 @@ export function MobileActionSheet({
const nodeText = editor.state.doc.slice(start, end) const nodeText = editor.state.doc.slice(start, end)
editor.chain().focus().insertContentAt(end, nodeText.content.toJSON()).run() editor.chain().focus().insertContentAt(end, nodeText.content.toJSON()).run()
toast.success(t('richTextEditor.blockDuplicated') || 'Bloc dupliqué') toast.success(t('richTextEditor.blockDuplicated'))
onClose() onClose()
} }
@@ -75,7 +75,7 @@ export function MobileActionSheet({
const start = $pos.before(1) const start = $pos.before(1)
const end = $pos.after(1) const end = $pos.after(1)
editor.chain().focus().deleteRange({ from: start, to: end }).run() editor.chain().focus().deleteRange({ from: start, to: end }).run()
toast.success(t('richTextEditor.blockDeleted') || 'Bloc supprimé') toast.success(t('richTextEditor.blockDeleted'))
onClose() onClose()
} }
@@ -85,7 +85,7 @@ export function MobileActionSheet({
onClose() onClose()
const tab = action === 'improve' ? 'actions' : 'chat' const tab = action === 'improve' ? 'actions' : 'chat'
window.dispatchEvent(new CustomEvent('memento-open-ai', { detail: { tab, scroll: action } })) 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( return createPortal(
@@ -101,26 +101,26 @@ export function MobileActionSheet({
<div className="mobile-action-sheet-body"> <div className="mobile-action-sheet-body">
{/* Section 1 : Actions de bloc */} {/* Section 1 : Actions de bloc */}
<div className="mobile-action-sheet-section"> <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"> <div className="grid grid-cols-3 gap-2">
<button type="button" className="action-tile-btn" onClick={handleSelectAllBlock}> <button type="button" className="action-tile-btn" onClick={handleSelectAllBlock}>
<FileText size={20} className="text-blue-500" /> <FileText size={20} className="text-blue-500" />
<span>{t('mobile.selectAll') || 'Sélectionner tout'}</span> <span>{t('mobile.selectAll')}</span>
</button> </button>
<button type="button" className="action-tile-btn" onClick={handleDuplicateBlock}> <button type="button" className="action-tile-btn" onClick={handleDuplicateBlock}>
<Copy size={20} className="text-emerald-500" /> <Copy size={20} className="text-emerald-500" />
<span>{t('mobile.duplicate') || 'Dupliquer'}</span> <span>{t('mobile.duplicate')}</span>
</button> </button>
<button type="button" className="action-tile-btn text-rose-500" onClick={handleDeleteBlock}> <button type="button" className="action-tile-btn text-rose-500" onClick={handleDeleteBlock}>
<Trash2 size={20} className="text-rose-500" /> <Trash2 size={20} className="text-rose-500" />
<span>{t('mobile.delete') || 'Supprimer'}</span> <span>{t('mobile.delete')}</span>
</button> </button>
</div> </div>
</div> </div>
{/* Section 2 : IA Note */} {/* Section 2 : IA Note */}
<div className="mobile-action-sheet-section"> <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"> <div className="grid grid-cols-4 gap-2">
<button type="button" className="action-tile-btn" onClick={() => handleAiAction('improve')}> <button type="button" className="action-tile-btn" onClick={() => handleAiAction('improve')}>
<Wand2 size={18} className="text-purple-500 animate-pulse" /> <Wand2 size={18} className="text-purple-500 animate-pulse" />
@@ -143,42 +143,42 @@ export function MobileActionSheet({
{/* Section 3 : Format de bloc */} {/* Section 3 : Format de bloc */}
<div className="mobile-action-sheet-section"> <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"> <div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none">
<button <button
type="button" type="button"
className="format-pill-btn" className="format-pill-btn"
onClick={() => { editor.chain().focus().setParagraph().run(); onClose() }} onClick={() => { editor.chain().focus().setParagraph().run(); onClose() }}
> >
{t('mobile.paragraph') || 'Paragraphe'} {t('mobile.paragraph')}
</button> </button>
<button <button
type="button" type="button"
className="format-pill-btn" className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleHeading({ level: 1 }).run(); onClose() }} 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>
<button <button
type="button" type="button"
className="format-pill-btn" className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleHeading({ level: 2 }).run(); onClose() }} 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>
<button <button
type="button" type="button"
className="format-pill-btn" className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleHeading({ level: 3 }).run(); onClose() }} 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>
<button <button
type="button" type="button"
className="format-pill-btn" className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleBlockquote().run(); onClose() }} 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> </button>
</div> </div>
</div> </div>

View File

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

View File

@@ -144,7 +144,7 @@ export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
fetch('/api/graph') 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)) .then(d => setRawData(d))
.catch(e => setError(e.message)) .catch(e => setError(e.message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,12 +35,13 @@ import {
FileText, FileText,
Folder, Folder,
FolderOpen, FolderOpen,
LayoutGrid,
} from 'lucide-react' } from 'lucide-react'
import { useSearchModal } from '@/context/search-modal-context' import { useSearchModal } from '@/context/search-modal-context'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { applyDocumentTheme } from '@/lib/apply-document-theme' 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 { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
import { useNotebooks } from '@/context/notebooks-context' import { useNotebooks } from '@/context/notebooks-context'
import { Notebook, Note } from '@/lib/types' import { Notebook, Note } from '@/lib/types'
@@ -62,9 +63,10 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { performSignOut } from '@/lib/auth-client' import { performSignOut } from '@/lib/auth-client'
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm' 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' type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
const NOTEBOOKS_PANEL_HEIGHT_KEY = 'memento-sidebar-notebooks-height' 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 [showSortMenu, setShowSortMenu] = useState(false)
const [notebookSearchQuery, setNotebookSearchQuery] = useState('') const [notebookSearchQuery, setNotebookSearchQuery] = useState('')
const [trashCount, setTrashCount] = useState(0) const [trashCount, setTrashCount] = useState(0)
const [inboxCount, setInboxCount] = useState(0)
const notebooksContainerRef = useRef<HTMLDivElement>(null) const notebooksContainerRef = useRef<HTMLDivElement>(null)
const notebooksPanelRef = useRef<HTMLDivElement>(null) const notebooksPanelRef = useRef<HTMLDivElement>(null)
@@ -804,8 +807,11 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
}) })
}, [currentNotebookId, notebooks]) }, [currentNotebookId, notebooks])
const isDashboardRoute = isDashboardHomeRoute(pathname, searchParams)
const isInboxActive = const isInboxActive =
pathname === '/home' && pathname === '/home' &&
searchParams.get('forceList') === '1' &&
!searchParams.get('notebook') && !searchParams.get('notebook') &&
!searchParams.get('labels') && !searchParams.get('labels') &&
!searchParams.get('archived') && !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 === '/insights') setActiveView('insights')
else if (pathname.startsWith('/revision')) setActiveView('revision') else if (pathname.startsWith('/revision')) setActiveView('revision')
else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders') else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders')
else if (isDashboardRoute) setActiveView('dashboard')
else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks') else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks')
}, [pathname, searchParams]) }, [pathname, searchParams, isDashboardRoute])
const isRemindersRoute = pathname === '/home' && searchParams.get('reminders') === '1' const isRemindersRoute = pathname === '/home' && searchParams.get('reminders') === '1'
const isSharedRoute = pathname === '/home' && searchParams.get('shared') === '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 === '/home' || pathname.startsWith('/notes')) &&
!pathname.startsWith('/settings') && !pathname.startsWith('/settings') &&
!isRemindersRoute && !isRemindersRoute &&
!isSharedRoute !isSharedRoute &&
!isDashboardRoute
const displayName = user?.name || user?.email || '' const displayName = user?.name || user?.email || ''
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?' const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
@@ -872,6 +880,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
getTrashCount().then(count => { if (!cancelled) setTrashCount(count) }) getTrashCount().then(count => { if (!cancelled) setTrashCount(count) })
getInboxCount().then((count: number) => { if (!cancelled) setInboxCount(count) })
return () => { cancelled = true } return () => { cancelled = true }
}, []) }, [])
@@ -902,6 +911,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
useEffect(() => { useEffect(() => {
const onNoteChange = (e: Event) => { const onNoteChange = (e: Event) => {
const detail = (e as CustomEvent<NoteChangeEvent>).detail const detail = (e as CustomEvent<NoteChangeEvent>).detail
getInboxCount().then((count: number) => setInboxCount(count))
if (detail.type === 'deleted') { if (detail.type === 'deleted') {
setNotebookNotes((prev) => { setNotebookNotes((prev) => {
const next = { ...prev } const next = { ...prev }
@@ -1340,6 +1350,16 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{/* Boutons de navigation principaux */} {/* Boutons de navigation principaux */}
<div className="flex flex-col gap-2 w-full px-1.5"> <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', id: 'notebooks',
icon: BookOpen, icon: BookOpen,
@@ -1517,7 +1537,78 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
> >
<AnimatePresence mode="wait"> <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 <motion.div
key="notebooks" key="notebooks"
ref={notebooksContainerRef} ref={notebooksContainerRef}
@@ -1641,12 +1732,22 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</div> </div>
<span <span
className={cn( className={cn(
'text-[13px] font-medium truncate', 'text-[13px] font-medium truncate flex-1',
isInboxActive ? 'text-ink' : 'text-muted-ink', isInboxActive ? 'text-ink' : 'text-muted-ink',
)} )}
> >
{t('sidebar.inbox')} {t('sidebar.inbox')}
</span> </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> </button>
</div> </div>
</div> </div>

View File

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

View File

@@ -10,6 +10,7 @@ import { getNoteById } from '@/app/actions/notes'
import { useNoteRefresh } from './NoteRefreshContext' import { useNoteRefresh } from './NoteRefreshContext'
import { toast } from 'sonner' import { toast } from 'sonner'
import { queryKeys } from '@/lib/query-keys' import { queryKeys } from '@/lib/query-keys'
import { useLanguage } from '@/lib/i18n'
// ===== INPUT TYPES ===== // ===== INPUT TYPES =====
export interface CreateNotebookInput { export interface CreateNotebookInput {
@@ -101,6 +102,7 @@ interface NotebooksProviderProps {
export function NotebooksProvider({ children, initialNotebooks = [] }: NotebooksProviderProps) { export function NotebooksProvider({ children, initialNotebooks = [] }: NotebooksProviderProps) {
// ===== BASE STATE ===== // ===== BASE STATE =====
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { t } = useLanguage()
const [notebooks, setNotebooks] = useState<Notebook[]>(initialNotebooks) const [notebooks, setNotebooks] = useState<Notebook[]>(initialNotebooks)
const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null) const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -401,14 +403,12 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
emitNoteChange({ type: 'updated', note: movedNote }) emitNoteChange({ type: 'updated', note: movedNote })
} }
} catch (error) { } catch (error) {
toast.error('Failed to move note. Please try again.') toast.error(t('notes.moveFailed'))
throw error throw error
} finally { } finally {
setIsMovingNote(false) setIsMovingNote(false)
} }
}, [queryClient, triggerNotebooksRefresh]) }, [queryClient, triggerNotebooksRefresh, t])
// ===== ACTIONS: AI (STUBS) =====
const suggestNotebookForNote = useCallback(async (_noteContent: string) => { const suggestNotebookForNote = useCallback(async (_noteContent: string) => {
// Stub pour l'instant - retourne null // Stub pour l'instant - retourne null
return null return null

View File

@@ -117,17 +117,48 @@ fi
# --- Background cron scheduler --- # --- Background cron scheduler ---
( (
sleep 60 sleep 60
CRON_SECRET="${CRON_SECRET:-}"
tick=0
while true; do while true; do
node -e " if [ -n "$CRON_SECRET" ]; then
CRON_TICK="$tick" node -e "
const http = require('http'); const http = require('http');
const req = http.request('http://localhost:3000/api/cron/agents', { method: 'POST' }, (res) => { const secret = process.env.CRON_SECRET || '';
const tick = Number(process.env.CRON_TICK || '0');
const call = (path, method) => new Promise((resolve) => {
const req = http.request({
hostname: 'localhost',
port: 3000,
path,
method: method || 'POST',
headers: { Authorization: 'Bearer ' + secret },
}, (res) => {
let body = ''; let body = '';
res.on('data', (d) => body += d); res.on('data', (d) => body += d);
res.on('end', () => console.log('[Scheduler] Cron response:', res.statusCode, body)); res.on('end', () => { console.log('[Scheduler]', path, res.statusCode, body.slice(0, 200)); resolve(); });
}); });
req.on('error', (e) => console.error('[Scheduler] Cron error:', e.message)); req.on('error', (e) => { console.error('[Scheduler]', path, e.message); resolve(); });
req.end(); req.end();
});
(async () => {
await call('/api/cron/agents');
await call('/api/cron/agent-suggestions');
if (tick % 12 === 0) {
await call('/api/cron/reminders');
await call('/api/cron/sync-usage');
}
if (tick % 72 === 0) {
await call('/api/cron/clusters', 'GET');
}
if (tick % 288 === 0) {
await call('/api/cron/gmail');
}
})();
" 2>&1 || true " 2>&1 || true
else
warn "CRON_SECRET unset — scheduled crons disabled"
fi
tick=$((tick + 1))
sleep 300 sleep 300
done done
) & ) &

View File

@@ -0,0 +1,116 @@
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
/** Texte de secours quand le LLM ne renvoie rien — une entrée par locale supportée. */
export const MEMORY_ECHO_INSIGHT_FALLBACKS: Record<SupportedLanguage, string> = {
en: 'These notes appear to be semantically related.',
fr: 'Ces notes semblent sémantiquement liées.',
es: 'Estas notas parecen estar relacionadas semánticamente.',
de: 'Diese Notizen scheinen semantisch miteinander verbunden zu sein.',
it: 'Queste note sembrano semanticamente collegate.',
pt: 'Estas notas parecem estar semanticamente relacionadas.',
nl: 'Deze notities lijken semantisch met elkaar verbonden.',
pl: 'Te notatki wydają się semantycznie powiązane.',
ru: 'Эти заметки кажутся семантическement связанными.',
zh: '这些笔记在语义上似乎相关。',
ja: 'これらのノートは意味的に関連しているようです。',
ko: '이 노트들은 의미적으로 관련된 것으로 보입니다.',
ar: 'يبدو أن هذه الملاحظات مرتبطة دلاليًا.',
fa: 'این یادداشت‌ها از نظر معنایی به هم مرتبط به نظر می‌رسند.',
hi: 'ये नोट्स अर्थ के हिसाब से जुड़े हुए लगते हैं।',
}
/** Anciens insights stockés en anglais — à remapper côté UI. */
export const MEMORY_ECHO_LEGACY_EN_FALLBACKS = new Set([
'These notes appear to be semantically related.',
])
const PROMPT_LANGUAGE_LABEL: Record<SupportedLanguage, string> = {
en: 'English',
fr: 'French',
es: 'Spanish',
de: 'German',
it: 'Italian',
pt: 'Portuguese',
nl: 'Dutch',
pl: 'Polish',
ru: 'Russian',
zh: 'Chinese',
ja: 'Japanese',
ko: 'Korean',
ar: 'Arabic',
fa: 'Persian',
hi: 'Hindi',
}
const UNTITLED_NOTE: Record<SupportedLanguage, string> = {
en: 'Untitled note',
fr: 'Note sans titre',
es: 'Nota sin título',
de: 'Unbenannte Notiz',
it: 'Nota senza titolo',
pt: 'Nota sem título',
nl: 'Naamloze notitie',
pl: 'Notatka bez tytułu',
ru: 'Без названия',
zh: '无标题笔记',
ja: '無題のノート',
ko: '제목 없는 노트',
ar: 'ملاحظة بدون عنوان',
fa: 'یادداشت بدون عنوان',
hi: 'शीर्षक रहित नोट',
}
export function getMemoryEchoInsightFallback(language: SupportedLanguage): string {
return MEMORY_ECHO_INSIGHT_FALLBACKS[language] ?? MEMORY_ECHO_INSIGHT_FALLBACKS.en
}
export function getUntitledNoteLabel(language: SupportedLanguage): string {
return UNTITLED_NOTE[language] ?? UNTITLED_NOTE.en
}
export function buildMemoryEchoInsightPrompt(
language: SupportedLanguage,
note1Title: string | null,
excerpt1: string,
note2Title: string | null,
excerpt2: string,
): string {
const untitled = getUntitledNoteLabel(language)
const note1Desc = note1Title || untitled
const note2Desc = note2Title || untitled
const langLabel = PROMPT_LANGUAGE_LABEL[language] ?? 'English'
if (language === 'fa' || language === 'ar') {
return `تو یک دستیار هستی که ارتباط بین یادداشت‌ها را تحلیل می‌کنی.
یادداشت ۱: «${note1Desc}»
متن: ${excerpt1}
یادداشت ۲: «${note2Desc}»
متن: ${excerpt2}
در یک جمله کوتاه (حداکثر ۱۵ کلمه) به ${language === 'fa' ? 'فارسی' : 'العربية'} توضیح بده چرا این دو یادداشت به هم مرتبط‌اند. فقط رابطه معنایی را بگو.`
}
if (language === 'fr') {
return `Tu analyses les connexions entre notes.
Note 1 : « ${note1Desc} »
Contenu : ${excerpt1}
Note 2 : « ${note2Desc} »
Contenu : ${excerpt2}
En une phrase courte (15 mots max), en français, explique pourquoi ces notes sont liées. Concentre-toi sur le lien sémantique.`
}
return `You analyze connections between notes.
Note 1: "${note1Desc}"
Content: ${excerpt1}
Note 2: "${note2Desc}"
Content: ${excerpt2}
Reply in ${langLabel} only. One brief sentence (max 15 words) explaining the semantic relationship between these notes.`
}

View File

@@ -0,0 +1,216 @@
/**
* Agent Suggestion Service
*
* Proposes research/monitor agents from existing semantic clusters (no duplicate DBSCAN).
* Runs after clustering; compares cluster themes vs existing agents.
*/
import prisma from '@/lib/prisma'
import { clusteringService } from './clustering.service'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { calculateNextRun } from '@/lib/agents/schedule'
const MIN_CLUSTER_NOTES = 3
const MAX_SUGGESTIONS_PER_RUN = 3
export interface AgentSuggestionPayload {
topic: string
reason: string
suggestedRole: string
suggestedType: string
suggestedFrequency: string
relatedNoteIds: string[]
clusterId: number
}
function parseJsonArray(raw: string | null | undefined): string[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []
} catch {
return []
}
}
function clusterCoveredByAgent(
topic: string,
noteIds: string[],
agent: { name: string; role: string; description: string | null; sourceNoteIds: string | null },
): boolean {
const topicLower = topic.toLowerCase()
const haystack = `${agent.name} ${agent.role} ${agent.description || ''}`.toLowerCase()
const topicWords = topicLower.split(/\s+/).filter(w => w.length > 3)
if (topicWords.some(w => haystack.includes(w))) return true
const agentNoteIds = parseJsonArray(agent.sourceNoteIds)
if (agentNoteIds.length === 0 || noteIds.length === 0) return false
const overlap = agentNoteIds.filter(id => noteIds.includes(id)).length
return overlap / noteIds.length >= 0.4
}
async function buildSuggestionWithLlm(
topic: string,
noteTitles: string[],
noteCount: number,
): Promise<Pick<AgentSuggestionPayload, 'reason' | 'suggestedRole' | 'suggestedType' | 'suggestedFrequency'>> {
const fallback = {
reason: `${noteCount} notes récentes sur ce thème — aucun agent ne le couvre encore.`,
suggestedRole: `Recherche et synthétise les dernières informations sur « ${topic} ». Croise avec les notes existantes de l'utilisateur et produis un résumé actionnable.`,
suggestedType: 'researcher',
suggestedFrequency: 'weekly',
}
try {
const config = await getSystemConfig()
const provider = getChatProvider(config)
if (!provider) return fallback
const prompt = `Tu proposes un agent IA pour un second cerveau (prise de notes).
Thème détecté: "${topic}"
Notes liées (titres): ${noteTitles.slice(0, 5).join(' | ') || 'sans titre'}
Nombre de notes: ${noteCount}
Retourne UNIQUEMENT du JSON valide:
{"reason":"1 phrase pourquoi un agent est utile","suggestedRole":"prompt système de l'agent (2-3 phrases, français)","suggestedType":"researcher|monitor","suggestedFrequency":"daily|weekly"}`
const raw = await provider.generateText(prompt)
const match = raw.match(/\{[\s\S]*\}/)
if (!match) return fallback
const parsed = JSON.parse(match[0])
return {
reason: String(parsed.reason || fallback.reason).slice(0, 500),
suggestedRole: String(parsed.suggestedRole || fallback.suggestedRole).slice(0, 2000),
suggestedType: parsed.suggestedType === 'monitor' ? 'monitor' : 'researcher',
suggestedFrequency: ['daily', 'weekly'].includes(parsed.suggestedFrequency) ? parsed.suggestedFrequency : 'weekly',
}
} catch {
return fallback
}
}
export class AgentSuggestionService {
async generateForUser(userId: string): Promise<number> {
const stored = await clusteringService.getStoredClusters(userId)
if (!stored || stored.clusters.length === 0) return 0
const agents = await prisma.agent.findMany({
where: { userId, isEnabled: true },
select: { name: true, role: true, description: true, sourceNoteIds: true },
})
const candidates = stored.clusters
.filter(c => c.noteIds.length >= MIN_CLUSTER_NOTES)
.sort((a, b) => b.noteIds.length - a.noteIds.length)
let created = 0
for (const cluster of candidates) {
if (created >= MAX_SUGGESTIONS_PER_RUN) break
const topic = cluster.name || `Thème ${cluster.clusterId + 1}`
const covered = agents.some(a => clusterCoveredByAgent(topic, cluster.noteIds, a))
if (covered) continue
const existing = await prisma.agentSuggestion.findUnique({
where: { userId_clusterId: { userId, clusterId: cluster.clusterId } },
})
if (existing && existing.status !== 'pending') continue
const notes = await prisma.note.findMany({
where: { id: { in: cluster.noteIds.slice(0, 8) }, userId },
select: { title: true },
})
const llm = await buildSuggestionWithLlm(
topic,
notes.map(n => n.title || 'Sans titre'),
cluster.noteIds.length,
)
const payload = {
topic,
reason: llm.reason,
suggestedRole: llm.suggestedRole,
suggestedType: llm.suggestedType,
suggestedFrequency: llm.suggestedFrequency,
relatedNoteIds: JSON.stringify(cluster.noteIds.slice(0, 20)),
status: 'pending' as const,
}
if (existing) {
await prisma.agentSuggestion.update({
where: { id: existing.id },
data: payload,
})
} else {
await prisma.agentSuggestion.create({
data: { userId, clusterId: cluster.clusterId, ...payload },
})
}
created++
}
return created
}
async getPending(userId: string, limit = 3) {
return prisma.agentSuggestion.findMany({
where: { userId, status: 'pending' },
orderBy: { updatedAt: 'desc' },
take: limit,
})
}
async dismiss(userId: string, id: string) {
const row = await prisma.agentSuggestion.findFirst({ where: { id, userId } })
if (!row) return null
return prisma.agentSuggestion.update({
where: { id },
data: { status: 'dismissed' },
})
}
async accept(userId: string, id: string): Promise<{ agentId: string } | null> {
const row = await prisma.agentSuggestion.findFirst({ where: { id, userId, status: 'pending' } })
if (!row) return null
const noteIds = parseJsonArray(row.relatedNoteIds)
const agent = await prisma.agent.create({
data: {
userId,
name: `Recherche — ${row.topic}`.slice(0, 80),
description: row.reason,
type: row.suggestedType,
role: row.suggestedRole,
frequency: row.suggestedFrequency,
sourceNoteIds: noteIds.length > 0 ? JSON.stringify(noteIds) : null,
tools: JSON.stringify(['web_search', 'read_url']),
isEnabled: true,
scheduledTime: '08:00',
},
})
if (row.suggestedFrequency !== 'manual') {
const nextRun = calculateNextRun({
frequency: row.suggestedFrequency,
scheduledTime: '08:00',
})
if (nextRun) {
await prisma.agent.update({ where: { id: agent.id }, data: { nextRun } })
}
}
await prisma.agentSuggestion.update({
where: { id: row.id },
data: { status: 'accepted' },
})
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(agent.id, userId))
.catch(err => console.error('[AgentSuggestion] execute failed:', err))
return { agentId: agent.id }
}
}
export const agentSuggestionService = new AgentSuggestionService()

View File

@@ -10,6 +10,12 @@ import {
prepareNoteTextForEmbedding, prepareNoteTextForEmbedding,
} from '@/lib/text/plain-text' } from '@/lib/text/plain-text'
import { detectTextDirection } from '@/lib/clip/rtl-content' import { detectTextDirection } from '@/lib/clip/rtl-content'
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
import {
buildMemoryEchoInsightPrompt,
getMemoryEchoInsightFallback,
} from '@/lib/ai/memory-echo-i18n'
import { import {
SEMANTIC_SIMILARITY_FLOOR_CLIP, SEMANTIC_SIMILARITY_FLOOR_CLIP,
SEMANTIC_SIMILARITY_FLOOR_DEMO, SEMANTIC_SIMILARITY_FLOOR_DEMO,
@@ -334,38 +340,16 @@ export class MemoryEchoService {
note1Title: string | null, note1Title: string | null,
note1Content: string, note1Content: string,
note2Title: string | null, note2Title: string | null,
note2Content: string note2Content: string,
language: SupportedLanguage = 'en',
): Promise<string> { ): Promise<string> {
try { try {
const config = await getSystemConfig() const config = await getSystemConfig()
const provider = getChatProvider(config) const provider = getChatProvider(config)
const note1Desc = note1Title || 'Untitled note'
const note2Desc = note2Title || 'Untitled note'
const excerpt1 = excerptPlainNoteContent(note1Title, note1Content, 1200) const excerpt1 = excerptPlainNoteContent(note1Title, note1Content, 1200)
const excerpt2 = excerptPlainNoteContent(note2Title, note2Content, 1200) const excerpt2 = excerptPlainNoteContent(note2Title, note2Content, 1200)
const directionSample = `${note1Desc}\n${excerpt1}\n${note2Desc}\n${excerpt2}` const prompt = buildMemoryEchoInsightPrompt(language, note1Title, excerpt1, note2Title, excerpt2)
const isRtl = detectTextDirection(directionSample) === 'rtl'
const prompt = isRtl
? `تو یک دستیار هستی که ارتباط بین یادداشت‌ها را تحلیل می‌کنی.
یادداشت ۱: «${note1Desc}»
متن: ${excerpt1}
یادداشت ۲: «${note2Desc}»
متن: ${excerpt2}
در یک جمله کوتاه (حداکثر ۱۵ کلمه) به فارسی توضیح بده چرا این دو یادداشت به هم مرتبط‌اند. فقط رابطه معنایی را بگو.`
: `You are a helpful assistant analyzing connections between notes.
Note 1: "${note1Desc}"
Content: ${excerpt1}
Note 2: "${note2Desc}"
Content: ${excerpt2}
Explain in one brief sentence (max 15 words) why these notes are connected. Focus on the semantic relationship.`
const response = await provider.generateText(prompt) const response = await provider.generateText(prompt)
@@ -375,20 +359,15 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
.trim() .trim()
.substring(0, 150) .substring(0, 150)
const fallback = isRtl return insight || getMemoryEchoInsightFallback(language)
? 'این یادداشت‌ها از نظر معنایی به هم مرتبط به نظر می‌رسند.'
: 'These notes appear to be semantically related.'
return insight || fallback
} catch (error) { } catch (error) {
console.error('[MemoryEcho] Failed to generate insight:', error) console.error('[MemoryEcho] Failed to generate insight:', error)
const sample = excerptPlainNoteContent(note1Title, note1Content, 200) const sample = excerptPlainNoteContent(note1Title, note1Content, 200)
+ excerptPlainNoteContent(note2Title, note2Content, 200) + excerptPlainNoteContent(note2Title, note2Content, 200)
if (detectTextDirection(sample) === 'rtl') { const rtl = detectTextDirection(sample) === 'rtl'
return 'این یادداشت‌ها از نظر معنایی به هم مرتبط به نظر می‌رسند.' if (rtl) return getMemoryEchoInsightFallback(language === 'fa' || language === 'ar' ? language : 'fa')
} return getMemoryEchoInsightFallback(language)
return 'These notes appear to be semantically related.'
} }
} }
@@ -484,12 +463,14 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
return null // All connections already shown return null // All connections already shown
} }
// Generate AI insight // Generate AI insight in user's language
const userLanguage = await detectUserLanguage()
const insightText = await this.generateInsight( const insightText = await this.generateInsight(
newConnection.note1.title, newConnection.note1.title,
newConnection.note1.content, newConnection.note1.content,
newConnection.note2.title, newConnection.note2.title,
newConnection.note2.content || '' newConnection.note2.content || '',
userLanguage,
) )
// Store insight in database // Store insight in database

View File

@@ -0,0 +1,17 @@
/** True when /home shows the Second Brain dashboard (not list, editor, or filters). */
export function isDashboardHomeRoute(
pathname: string,
searchParams: Pick<URLSearchParams, 'get'>,
): boolean {
if (pathname !== '/home') return false
return (
searchParams.get('forceList') !== '1' &&
!searchParams.get('notebook') &&
!searchParams.get('search') &&
!searchParams.get('labels') &&
!searchParams.get('shared') &&
!searchParams.get('reminders') &&
!searchParams.get('color') &&
!searchParams.get('openNote')
)
}

View File

@@ -0,0 +1,297 @@
export const DASHBOARD_LAYOUT_VERSION = 5 as const
/** Widgets visibles dans la mise en page par défaut (réf. prototype / capture utilisateur). */
export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
'capture',
'next-paths',
'intelligence',
'resume',
'mind-map',
'sentiment',
'inbox',
'revision',
'stats',
'reminders',
'flashcards-progress',
'agents',
] as const
export type DashboardWidgetId =
| 'capture'
| 'next-paths'
| 'resume'
| 'intelligence'
| 'reminders'
| 'mind-map'
| 'agents'
| 'sentiment'
| 'inbox'
| 'revision'
| 'stats'
| 'agent-activity'
| 'gmail'
| 'activity'
| 'pinned'
| 'usage'
| 'daily-review'
| 'open-loops'
| 'daily-note'
| 'link-suggestions'
| 'bridges'
| 'flashcards-progress'
export type DashboardWidgetZone = 'full' | 'main' | 'side'
export type DashboardWidgetCategory =
| 'essential'
| 'paths'
| 'cognition'
| 'productivity'
| 'agents'
export interface DashboardWidgetPlacement {
id: DashboardWidgetId
visible: boolean
order: number
zone: DashboardWidgetZone
}
export interface DashboardLayout {
version: typeof DASHBOARD_LAYOUT_VERSION
widgets: DashboardWidgetPlacement[]
}
export const DASHBOARD_WIDGET_META: Record<DashboardWidgetId, {
required: boolean
zone: DashboardWidgetZone
category: DashboardWidgetCategory
defaultVisible: boolean
}> = {
capture: { required: true, zone: 'full', category: 'essential', defaultVisible: true },
'next-paths': { required: false, zone: 'main', category: 'paths', defaultVisible: true },
resume: { required: true, zone: 'main', category: 'essential', defaultVisible: true },
intelligence: { required: true, zone: 'main', category: 'essential', defaultVisible: true },
reminders: { required: false, zone: 'side', category: 'productivity', defaultVisible: true },
'mind-map': { required: false, zone: 'main', category: 'cognition', defaultVisible: true },
agents: { required: false, zone: 'side', category: 'agents', defaultVisible: true },
sentiment: { required: false, zone: 'side', category: 'agents', defaultVisible: true },
inbox: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
revision: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
stats: { required: false, zone: 'side', category: 'cognition', defaultVisible: false },
'agent-activity': { required: false, zone: 'side', category: 'agents', defaultVisible: false },
gmail: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
activity: { required: false, zone: 'main', category: 'cognition', defaultVisible: false },
pinned: { required: false, zone: 'side', category: 'cognition', defaultVisible: false },
usage: { required: false, zone: 'side', category: 'agents', defaultVisible: false },
'daily-review': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
'open-loops': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
'daily-note': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
'link-suggestions': { required: false, zone: 'main', category: 'paths', defaultVisible: false },
bridges: { required: false, zone: 'main', category: 'cognition', defaultVisible: false },
'flashcards-progress': { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
}
export const DASHBOARD_CATEGORY_ORDER: DashboardWidgetCategory[] = [
'essential',
'paths',
'cognition',
'productivity',
'agents',
]
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
version: DASHBOARD_LAYOUT_VERSION,
widgets: [
// Pleine largeur
{ id: 'capture', visible: true, order: 0, zone: 'full' },
// Colonne principale (gauche) — ordre vertical
{ id: 'next-paths', visible: true, order: 1, zone: 'main' },
{ id: 'intelligence', visible: true, order: 2, zone: 'main' },
{ id: 'resume', visible: true, order: 3, zone: 'main' },
{ id: 'mind-map', visible: true, order: 4, zone: 'main' },
// Colonne latérale (droite) — cartes compactes + widgets IA
{ id: 'sentiment', visible: true, order: 5, zone: 'side' },
{ id: 'inbox', visible: true, order: 6, zone: 'side' },
{ id: 'revision', visible: true, order: 7, zone: 'side' },
{ id: 'stats', visible: true, order: 8, zone: 'side' },
{ id: 'reminders', visible: true, order: 9, zone: 'side' },
{ id: 'flashcards-progress', visible: true, order: 10, zone: 'side' },
{ id: 'agents', visible: true, order: 11, zone: 'side' },
// Catalogue — masqués par défaut, ajoutables via « Personnaliser »
{ id: 'daily-review', visible: false, order: 12, zone: 'side' },
{ id: 'agent-activity', visible: false, order: 13, zone: 'side' },
{ id: 'gmail', visible: false, order: 14, zone: 'side' },
{ id: 'activity', visible: false, order: 15, zone: 'main' },
{ id: 'pinned', visible: false, order: 16, zone: 'side' },
{ id: 'usage', visible: false, order: 17, zone: 'side' },
{ id: 'open-loops', visible: false, order: 18, zone: 'side' },
{ id: 'daily-note', visible: false, order: 19, zone: 'side' },
{ id: 'link-suggestions', visible: false, order: 20, zone: 'main' },
{ id: 'bridges', visible: false, order: 21, zone: 'main' },
],
}
/** Clone frais — évite de muter le constant partagé via useState. */
export function getDefaultDashboardLayout(): DashboardLayout {
return structuredClone(DEFAULT_DASHBOARD_LAYOUT)
}
export function resetDashboardLayout(): DashboardLayout {
return getDefaultDashboardLayout()
}
const ALL_WIDGET_IDS = Object.keys(DASHBOARD_WIDGET_META) as DashboardWidgetId[]
const LEGACY_SPAN_ZONE: Record<string, DashboardWidgetZone> = {
full: 'full',
large: 'main',
medium: 'main',
small: 'side',
compact: 'side',
}
function isValidZone(zone: unknown): zone is DashboardWidgetZone {
return zone === 'full' || zone === 'main' || zone === 'side'
}
function legacyZoneFromItem(item: Record<string, unknown>, id: DashboardWidgetId): DashboardWidgetZone {
if (isValidZone(item.zone)) return item.zone
const span = item.span
if (typeof span === 'string' && LEGACY_SPAN_ZONE[span]) return LEGACY_SPAN_ZONE[span]
return DASHBOARD_WIDGET_META[id].zone
}
export function normalizeDashboardLayout(raw: unknown): DashboardLayout {
if (!raw || typeof raw !== 'object') return getDefaultDashboardLayout()
const data = raw as Partial<DashboardLayout> & { version?: number }
if (!Array.isArray(data.widgets) || data.widgets.length === 0) return getDefaultDashboardLayout()
// Layout périmé ou vide → preset canonique
const incomingVersion = typeof data.version === 'number' ? data.version : 0
if (incomingVersion < DASHBOARD_LAYOUT_VERSION) {
return getDefaultDashboardLayout()
}
const byId = new Map<DashboardWidgetId, DashboardWidgetPlacement>()
for (const item of data.widgets) {
if (!item || typeof item !== 'object') continue
const id = (item as DashboardWidgetPlacement).id
if (!ALL_WIDGET_IDS.includes(id)) continue
const meta = DASHBOARD_WIDGET_META[id]
byId.set(id, {
id,
visible: meta.required ? true : Boolean((item as DashboardWidgetPlacement).visible),
order: Number.isFinite((item as DashboardWidgetPlacement).order)
? Number((item as DashboardWidgetPlacement).order)
: 0,
zone: legacyZoneFromItem(item as unknown as Record<string, unknown>, id),
})
}
for (const id of ALL_WIDGET_IDS) {
if (!byId.has(id)) {
const meta = DASHBOARD_WIDGET_META[id]
const fallback = DEFAULT_DASHBOARD_LAYOUT.widgets.find(w => w.id === id)
byId.set(id, {
id,
visible: meta.defaultVisible,
order: fallback?.order ?? 99,
zone: meta.zone,
})
}
}
const widgets = [...byId.values()]
.sort((a, b) => a.order - b.order)
.map((w, index) => ({ ...w, order: index }))
const layout = { version: DASHBOARD_LAYOUT_VERSION, widgets }
const visibleCount = layout.widgets.filter(w => w.visible).length
if (visibleCount === 0) return getDefaultDashboardLayout()
return layout
}
export function reorderWidgets(
layout: DashboardLayout,
activeId: DashboardWidgetId,
overId: DashboardWidgetId,
): DashboardLayout {
const activeMeta = DASHBOARD_WIDGET_META[activeId]
const overMeta = DASHBOARD_WIDGET_META[overId]
if (activeMeta.zone !== overMeta.zone) return layout
const visible = layout.widgets
.filter(w => w.visible && w.zone === activeMeta.zone)
.sort((a, b) => a.order - b.order)
const activeIdx = visible.findIndex(w => w.id === activeId)
const overIdx = visible.findIndex(w => w.id === overId)
if (activeIdx === -1 || overIdx === -1 || activeIdx === overIdx) return layout
const next = [...visible]
const [moved] = next.splice(activeIdx, 1)
next.splice(overIdx, 0, moved)
const orderMap = new Map(next.map((w, i) => [w.id, i]))
return {
...layout,
widgets: layout.widgets.map(w => {
if (w.zone !== activeMeta.zone || !w.visible) return w
return { ...w, order: orderMap.has(w.id) ? orderMap.get(w.id)! : w.order + next.length }
}),
}
}
export function setWidgetVisibility(
layout: DashboardLayout,
id: DashboardWidgetId,
visible: boolean,
): DashboardLayout {
if (DASHBOARD_WIDGET_META[id].required && !visible) return layout
return {
...layout,
widgets: layout.widgets.map(w => w.id === id ? { ...w, visible } : w),
}
}
export function visibleWidgets(layout: DashboardLayout): DashboardWidgetPlacement[] {
return layout.widgets.filter(w => w.visible).sort((a, b) => a.order - b.order)
}
export function visibleWidgetsInZone(
layout: DashboardLayout,
zone: DashboardWidgetZone,
): DashboardWidgetPlacement[] {
return visibleWidgets(layout).filter(w => w.zone === zone)
}
export function hiddenWidgetIds(layout: DashboardLayout): DashboardWidgetId[] {
return layout.widgets.filter(w => !w.visible).map(w => w.id)
}
export function catalogByCategory(layout: DashboardLayout): Record<DashboardWidgetCategory, DashboardWidgetId[]> {
const result = Object.fromEntries(
DASHBOARD_CATEGORY_ORDER.map(c => [c, [] as DashboardWidgetId[]]),
) as Record<DashboardWidgetCategory, DashboardWidgetId[]>
for (const id of ALL_WIDGET_IDS) {
result[DASHBOARD_WIDGET_META[id].category].push(id)
}
for (const cat of DASHBOARD_CATEGORY_ORDER) {
result[cat].sort((a, b) => {
const aVis = layout.widgets.find(w => w.id === a)?.visible ? 0 : 1
const bVis = layout.widgets.find(w => w.id === b)?.visible ? 0 : 1
if (aVis !== bVis) return aVis - bVis
return a.localeCompare(b)
})
}
return result
}
export function isWidgetVisible(layout: DashboardLayout, id: DashboardWidgetId): boolean {
return layout.widgets.find(w => w.id === id)?.visible ?? DASHBOARD_WIDGET_META[id].defaultVisible
}

View File

@@ -0,0 +1,36 @@
export type DashboardPathType =
| 'continue'
| 'connect'
| 'bridge'
| 'research'
| 'organize'
| 'explore'
| 'review'
| 'resurface'
| 'daily'
| 'add-link'
export interface DashboardPath {
id: string
type: DashboardPathType
priority: number
title: string
description: string
actionKey: string
noteId?: string
note2Id?: string
notebookId?: string
clusterAId?: number
clusterBId?: number
agentSuggestionId?: string
insightId?: string
snippet?: string
score?: number
}
export interface DashboardOpenLoop {
id: string
title: string | null
notebookId: string | null
daysStale: number
}

View File

@@ -0,0 +1,156 @@
import type { DashboardPath } from '@/lib/dashboard/path-types'
function excerpt(text: string, max = 120): string {
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
export interface BriefingPathsInput {
recentNotes: Array<{
id: string
title: string | null
content: string
notebookId: string | null
}>
inboxCount: number
dueFlashcards: number
insights: Array<{
id: string
insight: string
score: number
viewed: boolean
note1: { id: string; title: string | null }
note2: { id: string; title: string | null }
note1Excerpt?: string
note2Excerpt?: string
}>
bridgeSuggestions: Array<{
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}>
agentSuggestions: Array<{
id: string
topic: string
reason: string
clusterId: number | null
}>
}
/** Pistes instantanées à partir du briefing déjà chargé — sans requête lourde. */
export function buildFastPathsFromBriefing(input: BriefingPathsInput): DashboardPath[] {
const paths: DashboardPath[] = []
const focus = input.recentNotes[0]
if (focus) {
paths.push({
id: `continue-${focus.id}`,
type: 'continue',
priority: 100,
title: focus.title || 'Untitled',
description: excerpt(focus.content, 140),
actionKey: 'continue',
noteId: focus.id,
notebookId: focus.notebookId ?? undefined,
})
for (const ins of input.insights
.filter(i => i.note1.id === focus.id || i.note2.id === focus.id)
.slice(0, 2)) {
const other = ins.note1.id === focus.id ? ins.note2 : ins.note1
paths.push({
id: `connect-${focus.id}-${other.id}`,
type: 'connect',
priority: 88,
title: other.title || 'Untitled',
description: ins.insight,
actionKey: 'compare',
noteId: focus.id,
note2Id: other.id,
notebookId: focus.notebookId ?? undefined,
score: Math.round(ins.score * 100),
})
}
}
const freshInsight = input.insights.find(i => !i.viewed)
if (freshInsight) {
paths.push({
id: `resurface-${freshInsight.id}`,
type: 'resurface',
priority: 85,
title: `${freshInsight.note1.title || '…'}${freshInsight.note2.title || '…'}`,
description: freshInsight.insight,
actionKey: 'openInsight',
insightId: freshInsight.id,
noteId: freshInsight.note1.id,
note2Id: freshInsight.note2.id,
score: Math.round(freshInsight.score * 100),
})
}
for (const bridge of input.bridgeSuggestions.slice(0, 2)) {
paths.push({
id: `bridge-${bridge.clusterAId}-${bridge.clusterBId}`,
type: 'bridge',
priority: 70,
title: bridge.suggestedTitle,
description: bridge.justification || bridge.suggestedContent.slice(0, 140),
actionKey: 'createBridge',
clusterAId: bridge.clusterAId,
clusterBId: bridge.clusterBId,
notebookId: focus?.notebookId ?? undefined,
})
}
for (const agent of input.agentSuggestions.slice(0, 2)) {
paths.push({
id: `research-${agent.id}`,
type: 'research',
priority: 65,
title: agent.topic,
description: agent.reason,
actionKey: 'createAgent',
agentSuggestionId: agent.id,
notebookId: focus?.notebookId ?? undefined,
})
}
if (input.inboxCount > 0) {
paths.push({
id: 'organize-inbox',
type: 'organize',
priority: 60,
title: `${input.inboxCount} notes`,
description: 'inbox',
actionKey: 'organizeInbox',
})
}
if (input.dueFlashcards > 0) {
paths.push({
id: 'review-flashcards',
type: 'review',
priority: 55,
title: `${input.dueFlashcards}`,
description: 'flashcards',
actionKey: 'reviewCards',
})
}
paths.push({
id: 'daily-journal',
type: 'daily',
priority: 40,
title: new Date().toISOString().slice(0, 10),
description: 'daily',
actionKey: 'openDaily',
})
return paths.sort((a, b) => b.priority - a.priority).slice(0, 8)
}

View File

@@ -0,0 +1,297 @@
import 'server-only'
import prisma from '@/lib/prisma'
import type { DashboardPath } from '@/lib/dashboard/path-types'
export type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
interface BuildPathsInput {
userId: string
aiActive: boolean
recentNotes: Array<{
id: string
title: string | null
notebookId: string | null
updatedAt: Date
content: string
}>
inboxCount: number
dueFlashcards: number
insights: Array<{
id: string
insight: string
score: number
viewed: boolean
note1: { id: string; title: string | null }
note2: { id: string; title: string | null }
note1Excerpt?: string
note2Excerpt?: string
}>
bridgeSuggestions: Array<{
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}>
agentSuggestions: Array<{
id: string
topic: string
reason: string
clusterId: number | null
}>
}
function excerpt(text: string, max = 120): string {
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
function jaccardSimilarity(a: string, b: string): number {
const tokenize = (s: string) =>
new Set(s.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 3))
const A = tokenize(a)
const B = tokenize(b)
if (A.size === 0 || B.size === 0) return 0
let intersection = 0
A.forEach(w => { if (B.has(w)) intersection++ })
return intersection / (A.size + B.size - intersection)
}
async function findLinkSuggestions(
userId: string,
sourceNote: { id: string; title: string | null; content: string },
limit = 2,
): Promise<Array<{ noteId: string; noteTitle: string; snippet: string; score: number }>> {
const sourcePlain = excerpt(sourceNote.content, 400)
const sourceText = `${sourceNote.title || ''} ${sourcePlain}`.toLowerCase()
const candidates = await prisma.note.findMany({
where: { userId, id: { not: sourceNote.id }, isArchived: false, trashedAt: null },
select: { id: true, title: true, content: true },
take: 12,
orderBy: { updatedAt: 'desc' },
})
const scored: Array<{ noteId: string; noteTitle: string; snippet: string; score: number }> = []
for (const note of candidates) {
const notePlain = excerpt(note.content, 300)
const sim = jaccardSimilarity(sourceText, `${note.title || ''} ${notePlain}`)
if (sim < 0.06) continue
scored.push({
noteId: note.id,
noteTitle: note.title || '',
snippet: notePlain.slice(0, 100),
score: Math.round(sim * 100),
})
}
return scored.sort((a, b) => b.score - a.score).slice(0, limit)
}
function pathsFromInsights(
focus: BuildPathsInput['recentNotes'][0],
insights: BuildPathsInput['insights'],
paths: DashboardPath[],
): void {
for (const ins of insights
.filter(i => i.note1.id === focus.id || i.note2.id === focus.id)
.slice(0, 2)) {
const other = ins.note1.id === focus.id ? ins.note2 : ins.note1
if (paths.some(p => p.note2Id === other.id && p.type === 'connect')) continue
paths.push({
id: `connect-${focus.id}-${other.id}`,
type: 'connect',
priority: 90 - paths.length,
title: other.title || 'Untitled',
description: ins.insight || ins.note2Excerpt || ins.note1Excerpt || '',
actionKey: 'compare',
noteId: focus.id,
note2Id: other.id,
notebookId: focus.notebookId ?? undefined,
score: Math.round(ins.score * 100),
})
}
}
async function findOpenLoops(userId: string, limit = 3): Promise<Array<{ id: string; title: string | null; notebookId: string | null; daysStale: number }>> {
const threeDaysAgo = new Date()
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const stale = await prisma.note.findMany({
where: {
userId,
trashedAt: null,
isArchived: false,
updatedAt: { lt: threeDaysAgo, gte: thirtyDaysAgo },
OR: [
{ notebookId: null },
{ isPinned: true },
{ reminder: { not: null }, isReminderDone: false },
],
},
select: { id: true, title: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: limit,
})
const now = Date.now()
return stale.map(n => ({
id: n.id,
title: n.title,
notebookId: n.notebookId,
daysStale: Math.floor((now - n.updatedAt.getTime()) / (1000 * 60 * 60 * 24)),
}))
}
export async function buildDashboardPaths(input: BuildPathsInput): Promise<DashboardPath[]> {
const paths: DashboardPath[] = []
const focus = input.recentNotes[0]
let focusClusterId: number | null = null
if (focus) {
const member = await prisma.clusterMember.findFirst({
where: { userId: input.userId, noteId: focus.id },
select: { clusterId: true },
})
focusClusterId = member?.clusterId ?? null
paths.push({
id: `continue-${focus.id}`,
type: 'continue',
priority: 100,
title: focus.title || 'Untitled',
description: excerpt(focus.content, 140),
actionKey: 'continue',
noteId: focus.id,
notebookId: focus.notebookId ?? undefined,
})
pathsFromInsights(focus, input.insights, paths)
const linkSuggestions = await findLinkSuggestions(input.userId, focus, 2)
for (const link of linkSuggestions) {
paths.push({
id: `add-link-${focus.id}-${link.noteId}`,
type: 'add-link',
priority: 75,
title: link.noteTitle || 'Untitled',
description: link.snippet,
actionKey: 'addLink',
noteId: focus.id,
note2Id: link.noteId,
notebookId: focus.notebookId ?? undefined,
score: link.score,
})
}
}
const freshInsight = input.insights.find(i => !i.viewed)
if (freshInsight) {
paths.push({
id: `resurface-${freshInsight.id}`,
type: 'resurface',
priority: 85,
title: `${freshInsight.note1.title || '…'}${freshInsight.note2.title || '…'}`,
description: freshInsight.insight,
actionKey: 'openInsight',
insightId: freshInsight.id,
noteId: freshInsight.note1.id,
note2Id: freshInsight.note2.id,
score: Math.round(freshInsight.score * 100),
})
}
for (const bridge of input.bridgeSuggestions.slice(0, 2)) {
const relevant = focusClusterId === null
|| bridge.clusterAId === focusClusterId
|| bridge.clusterBId === focusClusterId
paths.push({
id: `bridge-${bridge.clusterAId}-${bridge.clusterBId}`,
type: 'bridge',
priority: relevant ? 80 : 55,
title: bridge.suggestedTitle,
description: bridge.justification || bridge.suggestedContent.slice(0, 140),
actionKey: 'createBridge',
clusterAId: bridge.clusterAId,
clusterBId: bridge.clusterBId,
notebookId: focus?.notebookId ?? undefined,
})
}
for (const agent of input.agentSuggestions.slice(0, 2)) {
const relevant = focusClusterId !== null && agent.clusterId === focusClusterId
paths.push({
id: `research-${agent.id}`,
type: 'research',
priority: relevant ? 78 : 50,
title: agent.topic,
description: agent.reason,
actionKey: 'createAgent',
agentSuggestionId: agent.id,
notebookId: focus?.notebookId ?? undefined,
})
}
if (focusClusterId !== null) {
const cluster = await prisma.noteCluster.findFirst({
where: { userId: input.userId, clusterId: focusClusterId },
select: { name: true, noteCount: true },
})
if (cluster) {
paths.push({
id: `explore-cluster-${focusClusterId}`,
type: 'explore',
priority: 60,
title: cluster.name || `Cluster ${focusClusterId}`,
description: `${cluster.noteCount} notes in this theme`,
actionKey: 'exploreTheme',
clusterAId: focusClusterId,
})
}
}
if (input.inboxCount > 0) {
paths.push({
id: 'organize-inbox',
type: 'organize',
priority: 70,
title: `${input.inboxCount} notes`,
description: 'inbox',
actionKey: 'organizeInbox',
})
}
if (input.dueFlashcards > 0) {
paths.push({
id: 'review-flashcards',
type: 'review',
priority: 65,
title: `${input.dueFlashcards}`,
description: 'flashcards',
actionKey: 'reviewCards',
})
}
paths.push({
id: 'daily-journal',
type: 'daily',
priority: 40,
title: new Date().toISOString().slice(0, 10),
description: 'daily',
actionKey: 'openDaily',
})
return paths
.sort((a, b) => b.priority - a.priority)
.slice(0, 8)
}
export async function buildOpenLoops(userId: string) {
return findOpenLoops(userId, 5)
}

View File

@@ -0,0 +1,204 @@
/**
* Gmail Scanner — extrait vols, colis, abonnements → notes + rappels
*/
import prisma from '@/lib/prisma'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { getGoogleTokens, refreshGoogleAccessToken } from '@/lib/integrations/google-integration-tokens'
const GMAIL_API = 'https://gmail.googleapis.com/gmail/v1/users/me'
const SCAN_QUERIES: { category: string; q: string }[] = [
{ category: 'flight', q: 'subject:(flight OR boarding OR vol OR "boarding pass") newer_than:7d' },
{ category: 'package', q: 'from:(amazon OR ups OR fedex OR dhl OR laposte OR colissimo) newer_than:7d' },
{ category: 'subscription', q: 'subject:(renewal OR reconduction OR renouvellement OR abonnement) newer_than:30d' },
]
function decodeBase64Url(data: string): string {
const normalized = data.replace(/-/g, '+').replace(/_/g, '/')
return Buffer.from(normalized, 'base64').toString('utf-8')
}
function extractBody(payload: any): string {
if (!payload) return ''
if (payload.body?.data) {
const raw = decodeBase64Url(payload.body.data)
return raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 4000)
}
if (Array.isArray(payload.parts)) {
for (const part of payload.parts) {
if (part.mimeType === 'text/plain' && part.body?.data) {
return decodeBase64Url(part.body.data).slice(0, 4000)
}
}
for (const part of payload.parts) {
const nested = extractBody(part)
if (nested) return nested
}
}
return ''
}
async function gmailFetch(userId: string, path: string, accessToken: string, refreshToken: string) {
let token = accessToken
let res = await fetch(`${GMAIL_API}${path}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.status === 401 && refreshToken) {
const refreshed = await refreshGoogleAccessToken(userId, 'gmail', refreshToken)
if (refreshed) {
token = refreshed
res = await fetch(`${GMAIL_API}${path}`, {
headers: { Authorization: `Bearer ${token}` },
})
}
}
return res
}
async function extractStructured(
category: string,
subject: string,
body: string,
): Promise<Record<string, unknown> | null> {
const config = await getSystemConfig()
const provider = getChatProvider(config)
if (!provider) return null
const schemas: Record<string, string> = {
flight: '{"airline":"","flightNumber":"","departureDate":"ISO8601","departureAirport":"","arrivalAirport":"","confirmationCode":""}',
package: '{"carrier":"","trackingNumber":"","expectedDelivery":"ISO8601","status":""}',
subscription: '{"serviceName":"","renewalDate":"ISO8601","amount":0,"currency":"EUR"}',
}
const prompt = `Extrais les données structurées de cet email (${category}). Retourne UNIQUEMENT du JSON valide selon ce schéma: ${schemas[category] || '{}'}
Sujet: ${subject}
Corps: ${body.slice(0, 2500)}`
try {
const raw = await provider.generateText(prompt)
const match = raw.match(/\{[\s\S]*\}/)
if (!match) return null
return JSON.parse(match[0])
} catch {
return null
}
}
function buildNoteFromExtraction(category: string, subject: string, data: Record<string, unknown>) {
if (category === 'flight') {
const title = `Vol ${data.flightNumber || data.airline || ''}`.trim() || subject.slice(0, 80)
const dep = data.departureDate ? new Date(String(data.departureDate)) : null
const reminder = dep && !Number.isNaN(dep.getTime())
? new Date(dep.getTime() - 3 * 60 * 60 * 1000)
: null
const html = `<p><strong>${title}</strong></p><p>${data.departureAirport || ''}${data.arrivalAirport || ''}</p><p>Confirmation: ${data.confirmationCode || '—'}</p>`
return { title, content: html, reminder }
}
if (category === 'package') {
const title = `Colis ${data.trackingNumber || data.carrier || ''}`.trim() || subject.slice(0, 80)
const del = data.expectedDelivery ? new Date(String(data.expectedDelivery)) : null
const reminder = del && !Number.isNaN(del.getTime()) ? del : null
const html = `<p><strong>${title}</strong></p><p>Transporteur: ${data.carrier || '—'}</p><p>Suivi: ${data.trackingNumber || '—'}</p><p>Statut: ${data.status || '—'}</p>`
return { title, content: html, reminder }
}
const title = `Abonnement ${data.serviceName || ''}`.trim() || subject.slice(0, 80)
const ren = data.renewalDate ? new Date(String(data.renewalDate)) : null
const reminder = ren && !Number.isNaN(ren.getTime())
? new Date(ren.getTime() - 7 * 24 * 60 * 60 * 1000)
: null
const html = `<p><strong>${title}</strong></p><p>Renouvellement: ${data.renewalDate || '—'}</p><p>Montant: ${data.amount ?? '—'} ${data.currency || ''}</p>`
return { title, content: html, reminder }
}
export class GmailScannerService {
async scanUser(userId: string): Promise<{ processed: number; created: number }> {
const tokens = await getGoogleTokens(userId, 'gmail')
if (!tokens) return { processed: 0, created: 0 }
let processed = 0
let created = 0
for (const { category, q } of SCAN_QUERIES) {
const listRes = await gmailFetch(
userId,
`/messages?maxResults=5&q=${encodeURIComponent(q)}`,
tokens.accessToken,
tokens.refreshToken,
)
if (!listRes.ok) continue
const listJson = await listRes.json()
const ids: string[] = (listJson.messages || []).map((m: { id: string }) => m.id)
for (const messageId of ids) {
const exists = await prisma.gmailScanHistory.findUnique({
where: { userId_messageId: { userId, messageId } },
})
if (exists) continue
const msgRes = await gmailFetch(
userId,
`/messages/${messageId}?format=full`,
tokens.accessToken,
tokens.refreshToken,
)
if (!msgRes.ok) continue
processed++
const msg = await msgRes.json()
const headers: { name: string; value: string }[] = msg.payload?.headers || []
const subject = headers.find(h => h.name.toLowerCase() === 'subject')?.value || '(Sans objet)'
const body = extractBody(msg.payload)
const extracted = await extractStructured(category, subject, body)
if (!extracted) {
await prisma.gmailScanHistory.create({
data: { userId, messageId, category, data: { subject, skipped: true } },
})
continue
}
const built = buildNoteFromExtraction(category, subject, extracted)
const note = await prisma.note.create({
data: {
userId,
title: built.title,
content: built.content,
type: 'richtext',
labels: JSON.stringify(['gmail', category]),
reminder: built.reminder,
autoGenerated: true,
},
})
await prisma.gmailScanHistory.create({
data: {
userId,
messageId,
category,
data: extracted as object,
noteId: note.id,
},
})
created++
}
}
return { processed, created }
}
async getStatus(userId: string) {
const tokens = await getGoogleTokens(userId, 'gmail')
const recent = await prisma.gmailScanHistory.count({
where: {
userId,
scannedAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
noteId: { not: null },
},
})
return { connected: !!tokens, recentCaptures: recent }
}
}
export const gmailScannerService = new GmailScannerService()

View File

@@ -0,0 +1,66 @@
import prisma from '@/lib/prisma'
export function getGoogleOAuthCredentials() {
return {
clientId: process.env.GOOGLE_CALENDAR_CLIENT_ID || process.env.AUTH_GOOGLE_ID || '',
clientSecret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET || process.env.AUTH_GOOGLE_SECRET || '',
}
}
export async function readIntegrationMeta(userId: string): Promise<Record<string, unknown>> {
const aiSettings = await prisma.userAISettings.findUnique({
where: { userId },
select: { integrationTokens: true },
})
try {
const raw = aiSettings?.integrationTokens
if (!raw) return {}
return typeof raw === 'string' ? JSON.parse(raw) : (raw as Record<string, unknown>)
} catch {
return {}
}
}
export async function writeIntegrationMeta(userId: string, meta: Record<string, unknown>) {
await prisma.userAISettings.upsert({
where: { userId },
update: { integrationTokens: JSON.stringify(meta) },
create: { userId, integrationTokens: JSON.stringify(meta) },
})
}
export async function getGoogleTokens(
userId: string,
prefix: 'calendar' | 'gmail',
): Promise<{ accessToken: string; refreshToken: string } | null> {
const meta = await readIntegrationMeta(userId)
const accessToken = meta[`${prefix}AccessToken`] as string | undefined
const refreshToken = meta[`${prefix}RefreshToken`] as string | undefined
if (accessToken && refreshToken) return { accessToken, refreshToken }
return null
}
export async function refreshGoogleAccessToken(
userId: string,
prefix: 'calendar' | 'gmail',
refreshToken: string,
): Promise<string | null> {
const { clientId, clientSecret } = getGoogleOAuthCredentials()
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
})
const data = await res.json()
if (!data.access_token) return null
const meta = await readIntegrationMeta(userId)
meta[`${prefix}AccessToken`] = data.access_token
await writeIntegrationMeta(userId, meta)
return data.access_token as string
}

View File

@@ -73,6 +73,7 @@
"dropToRoot": "Drop here to move to root", "dropToRoot": "Drop here to move to root",
"noReminders": "No active reminders.", "noReminders": "No active reminders.",
"documents": "Documents", "documents": "Documents",
"dashboardPanelBody": "Your second brain at a glance: AI suggestions, quick capture, and next steps. Use the shortcuts below to jump into action.",
"searchNotebooksPlaceholder": "Search notebooks…", "searchNotebooksPlaceholder": "Search notebooks…",
"clearSearch": "Clear search", "clearSearch": "Clear search",
"insightsPanelBody": "Semantic map of your notes: thematic clusters, bridge notes, and connection suggestions.", "insightsPanelBody": "Semantic map of your notes: thematic clusters, bridge notes, and connection suggestions.",
@@ -82,7 +83,10 @@
"resizeSidebar": "Resize sidebar width", "resizeSidebar": "Resize sidebar width",
"dailyNote": "Daily Note", "dailyNote": "Daily Note",
"dailyNoteError": "Could not open today's note", "dailyNoteError": "Could not open today's note",
"dailyNoteTooltip": "Open or create your personal daily journal note for today" "dailyNoteTooltip": "Open or create your personal daily journal note for today",
"lightMode": "Light mode",
"darkMode": "Dark mode",
"searchShortcut": "Search (Ctrl+K)"
}, },
"notes": { "notes": {
"title": "Notes", "title": "Notes",
@@ -318,7 +322,12 @@
"unarchived": "Unarchived", "unarchived": "Unarchived",
"savedJustNow": "Saved", "savedJustNow": "Saved",
"unsaved": "Unsaved changes", "unsaved": "Unsaved changes",
"historyEnabledTooltip": "Version history enabled — changes are tracked" "historyEnabledTooltip": "Version history enabled — changes are tracked",
"heading1": "Heading 1",
"heading2": "Heading 2",
"heading3": "Heading 3",
"importSuccess": "{{count}} notes imported!",
"unpublished": "Note unpublished"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",
@@ -360,7 +369,9 @@
"confirmDeleteShort": "Confirm?", "confirmDeleteShort": "Confirm?",
"labelRemoved": "Label \"{label}\" removed", "labelRemoved": "Label \"{label}\" removed",
"filterByTags": "Filter by tags", "filterByTags": "Filter by tags",
"searchTags": "Search tags" "searchTags": "Search tags",
"activeCount": "{{count}} active",
"showMoreCount": "+ {{count}} more"
}, },
"search": { "search": {
"placeholder": "Search", "placeholder": "Search",
@@ -769,6 +780,7 @@
"match": "{percentage}% match", "match": "{percentage}% match",
"fused": "Fused", "fused": "Fused",
"clickToView": "Click to view note →", "clickToView": "Click to view note →",
"defaultInsight": "These notes appear to be semantically related.",
"overlay": { "overlay": {
"title": "Connected Notes", "title": "Connected Notes",
"searchPlaceholder": "Search connections...", "searchPlaceholder": "Search connections...",
@@ -938,6 +950,8 @@
"graphView": "Link map", "graphView": "Link map",
"insights": "Semantic themes", "insights": "Semantic themes",
"revision": "Review", "revision": "Review",
"dailyNotes": "Daily Notes",
"dashboard": "Dashboard",
"userManagement": "User Management", "userManagement": "User Management",
"accountSettings": "Account Settings", "accountSettings": "Account Settings",
"manageAISettings": "Manage AI Settings", "manageAISettings": "Manage AI Settings",
@@ -1017,7 +1031,19 @@
"autoSave": "Auto-save", "autoSave": "Auto-save",
"autoSaveDesc": "Automatically save changes while typing", "autoSaveDesc": "Automatically save changes while typing",
"autoSaveEnabled": "Auto-save enabled!", "autoSaveEnabled": "Auto-save enabled!",
"autoSaveDisabled": "Auto-save disabled" "autoSaveDisabled": "Auto-save disabled",
"publishedTitle": "My Published Pages",
"publishedDesc": "Manage notes you have published publicly.",
"publishedEmpty": "No published notes yet.",
"notesViewLabel": "Notes View",
"notesViewDescription": "Choose how notes are displayed in the list.",
"notesViewList": "List",
"notesViewMasonry": "Masonry",
"notesViewTable": "Table",
"importExportHelp": "Import / Export — how does it work?",
"aiConfigHelp": "How to configure your AI?",
"integrationsTitle": "Integrations",
"integrationsDesc": "Connect external services to Memento."
}, },
"profile": { "profile": {
"title": "Profile", "title": "Profile",
@@ -1224,7 +1250,8 @@
"dismissIn": "Dismiss (closes in {timeLeft}s)", "dismissIn": "Dismiss (closes in {timeLeft}s)",
"moveToNotebook": "Move to notebook", "moveToNotebook": "Move to notebook",
"generalNotes": "General Notes", "generalNotes": "General Notes",
"movedToNotebook": "Moved to notebook" "movedToNotebook": "Moved to notebook",
"filterNotebooks": "Filter notebooks…"
}, },
"admin": { "admin": {
"title": "Admin Dashboard", "title": "Admin Dashboard",
@@ -1956,7 +1983,7 @@
"off": "Off", "off": "Off",
"unknown": "Unknown", "unknown": "Unknown",
"notAvailable": "N/A", "notAvailable": "N/A",
"loading": "Loading...", "loading": "Loading",
"error": "Error", "error": "Error",
"success": "Success", "success": "Success",
"confirm": "Confirm", "confirm": "Confirm",
@@ -1970,7 +1997,14 @@
"search": "Search...", "search": "Search...",
"noResults": "No notes found", "noResults": "No notes found",
"required": "Required", "required": "Required",
"optional": "Optional" "optional": "Optional",
"notes": "notes",
"openMenu": "Open menu",
"openNav": "Open navigation",
"linkCopied": "Link copied!",
"copyLink": "Copy link",
"untitled": "Untitled",
"selectPlaceholder": "Select…"
}, },
"time": { "time": {
"justNow": "just now", "justNow": "just now",
@@ -2391,7 +2425,20 @@
}, },
"lab": { "lab": {
"initializing": "Initializing workspace", "initializing": "Initializing workspace",
"loadingIdeas": "Loading your ideas..." "loadingIdeas": "Loading your ideas...",
"loadingSlides": "Loading slides…",
"downloadFailed": "Failed to download presentation",
"exportPpt": "Export to PowerPoint",
"downloadHtml": "Download HTML",
"openFullscreen": "Open in fullscreen",
"loadingPresentation": "Loading presentation…",
"previousSlide": "Previous slide",
"nextSlide": "Next slide",
"hideNotes": "Hide notes",
"toggleNotes": "Toggle presentation notes (Shortcut: N)",
"notes": "Notes",
"previous": "Previous",
"next": "Next"
}, },
"richTextEditor": { "richTextEditor": {
"slashHint": "↑↓ navigate · Enter insert · Tab switch section", "slashHint": "↑↓ navigate · Enter insert · Tab switch section",
@@ -2690,7 +2737,15 @@
"previewDiagramTip": "Sketch concepts or generate diagrams via AI.", "previewDiagramTip": "Sketch concepts or generate diagrams via AI.",
"previewLivingBlockTip": "Sync content between multiple notes.", "previewLivingBlockTip": "Sync content between multiple notes.",
"previewSlidesTip": "Create interactive exportable presentations.", "previewSlidesTip": "Create interactive exportable presentations.",
"previewTableTip": "Organize your data in rows and columns." "previewTableTip": "Organize your data in rows and columns.",
"aiActionStarted": "AI Note invoked…",
"blockDeleted": "Block deleted",
"blockDuplicated": "Block duplicated",
"blockSelected": "Entire block selected",
"slashVideo": "Video",
"startVoice": "Start voice input",
"stopVoice": "Stop voice input",
"collapsibleSection": "Collapsible section"
}, },
"flashcards": { "flashcards": {
"generateTitle": "Generate flashcards", "generateTitle": "Generate flashcards",
@@ -2784,7 +2839,8 @@
"cardSaved": "Card saved", "cardSaved": "Card saved",
"cardTypeBadge": "{type}", "cardTypeBadge": "{type}",
"reviewNow": "Review now", "reviewNow": "Review now",
"deleteFailed": "Could not delete" "deleteFailed": "Could not delete",
"toolbarGenerateHint": "SM-2 Spaced Repetition"
}, },
"structuredViews": { "structuredViews": {
"enableTitle": "Organize this notebook (table, kanban, gallery…)", "enableTitle": "Organize this notebook (table, kanban, gallery…)",
@@ -2916,7 +2972,14 @@
"tagApplied": "applied", "tagApplied": "applied",
"filterDone": "Done", "filterDone": "Done",
"filterTodo": "To do", "filterTodo": "To do",
"propertyStatus": "Status" "propertyStatus": "Status",
"exportCsv": "Export CSV",
"importCsv": "Import CSV",
"relationEmpty": "Link a note…",
"relationNoResults": "No note found",
"relationSearch": "Search a note…",
"semanticResonances": "Semantic resonances",
"insertLink": "Insert link in editor"
}, },
"brainstorm": { "brainstorm": {
"title": "Waves of Thought", "title": "Waves of Thought",
@@ -3200,7 +3263,19 @@
"instantActivation": "Instant activation", "instantActivation": "Instant activation",
"secureDesc": "Secure payments by Stripe", "secureDesc": "Secure payments by Stripe",
"secureTransactions": "Secure transactions", "secureTransactions": "Secure transactions",
"satisfactionGuarantee": "30-day satisfaction guarantee" "satisfactionGuarantee": "30-day satisfaction guarantee",
"cancelConfirm": "Are you sure you want to cancel your subscription? You will keep your Pro/Business access until the end of the current billing period.",
"cancelSuccess": "Your subscription has been successfully cancelled. It will end at the end of the current billing period.",
"invoices": "Invoices & Receipts",
"invoiceAmount": "Amount",
"invoiceDate": "Date",
"invoiceNumber": "Number",
"invoiceStatus": "Status",
"noInvoices": "No invoices available.",
"checkoutFailed": "Failed to start checkout. Please try again.",
"portalFailed": "Failed to open billing portal.",
"cancelFailed": "Failed to cancel subscription.",
"downloadPdf": "Download PDF"
}, },
"quotaPaywall": { "quotaPaywall": {
"title": "Monthly limit reached", "title": "Monthly limit reached",
@@ -3689,7 +3764,9 @@
"openFully": "Open full screen", "openFully": "Open full screen",
"openFullyHelp": "Replace the current note with this one", "openFullyHelp": "Replace the current note with this one",
"readOnlyHint": "Read-only preview — open full screen to edit.", "readOnlyHint": "Read-only preview — open full screen to edit.",
"loadFailed": "Could not open the linked note preview." "loadFailed": "Could not open the linked note preview.",
"loading": "Loading…",
"untitled": "Untitled"
}, },
"structuredViewBlock": { "structuredViewBlock": {
"insertLabel": "Structured View", "insertLabel": "Structured View",
@@ -3856,11 +3933,317 @@
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content." "hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
}, },
"integrations": { "integrations": {
"title": "Integrations" "title": "Integrations",
"gmail": {
"title": "Gmail",
"description": "Automatically capture flights, packages and subscriptions from your emails",
"connect": "Connect Gmail",
"disconnect": "Disconnect",
"scanNow": "Scan now",
"connected": "Gmail connected!",
"disconnected": "Gmail disconnected",
"scanDone": "{created} note(s) created from emails",
"recentCaptures": "{count} capture(s) this week",
"helpTitle": "How does Gmail work?",
"helpStep1": "Connect Gmail — you'll be redirected to Google to authorize read-only access.",
"helpStep2": "Memento scans recent emails for flights, packages and subscription renewals.",
"helpStep3": "Each match becomes a note with an automatic reminder when relevant.",
"helpStep4": "Scan runs daily in the background; you can also trigger a manual scan here."
},
"calendarConnected": "Google Calendar connected!",
"errorWith": "Error: {{error}}",
"readwiseError": "Readwise error",
"readwiseConnected": "Readwise connected — {{created}} notes created, {{updated}} updated",
"readwiseConnectError": "Readwise connection error",
"syncError": "Sync error",
"readwiseSynced": "Readwise sync — {{created}} created, {{updated}} updated",
"syncFailed": "Synchronization failed",
"readwiseDisconnected": "Readwise disconnected",
"noEvents": "No events today",
"eventsLoadError": "Failed to load events",
"meetingNoteCreated": "Meeting note created: {{summary}}",
"createNoteError": "Failed to create note",
"calendarDisconnected": "Google Calendar disconnected",
"gmailScanError": "Gmail scan error",
"calendarInfo": "How does Google Calendar work?",
"readwiseInfo": "How does Readwise work?",
"readwiseTokenPlaceholder": "Readwise token…",
"connected": "Connected",
"notConnected": "Not connected",
"connectCalendar": "Connect Google Calendar",
"todayEvents": "Today's events",
"disconnect": "Disconnect",
"connect": "Connect",
"syncNow": "Sync now",
"addNote": "+ Note",
"open": "Open",
"moreComing": "More integrations coming soon — Zapier, GitHub, Notion import…",
"calendarDesc": "Access your events and create meeting notes",
"readwiseDesc": "Import highlights from books, articles, and Kindle",
"lastSync": "Last sync:",
"createdLabel": "notes created",
"updatedLabel": "updated",
"calendarHelpStep1": "Click \"Connect Google Calendar\" — you will be redirected to Google to authorize access.",
"calendarHelpStep2": "Once connected, come back here and click \"Today's events\" to see your agenda.",
"calendarHelpStep3": "On each event, click \"+ Note\" to automatically create a meeting note with a template (Agenda / Notes / Actions).",
"calendarHelpStep4": "The note opens directly in Memento — add your notes in real time during the meeting.",
"readwiseHelpStep1": "Copy your Readwise access token.",
"readwiseHelpStep2": "Paste it in the field below and click \"Connect\". The first sync imports all your books and articles.",
"readwiseHelpStep3": "Each book becomes a note in a \"Readwise 📚\" notebook — with all your highlights organized.",
"readwiseHelpStep4": "To update with new highlights, come back here and click \"Sync now\".",
"readwiseHelpStep5": "💡 Tip: create AI flashcards from a Readwise note (🎓 button in the editor) to review your readings."
},
"homeDashboard": {
"title": "Dashboard",
"quickCapture": "Quick Capture",
"quickCapturePlaceholder": "An idea, a thought… Enter to capture to Inbox.",
"captured": "Captured to Inbox",
"captureError": "Failed",
"mindMap": "Mind map",
"fullMap": "Full map →",
"mindMapEmpty": "No themes detected yet. Semantic analysis groups your notes by topic.",
"mindMapOpen": "Open insights map →",
"mindMapUnavailable": "Mind map unavailable.",
"themes": "themes",
"bridges": "bridges",
"aiEchoes": "AI echoes",
"bridgeNote": "Bridge note",
"theme": "Theme",
"suggestedResearch": "Suggested research",
"dismiss": "Dismiss",
"createAgent": "Create agent",
"agentsEmpty": "No research agents suggested yet. Keep writing — Memento will propose synthesis topics when your notes cluster.",
"agentCreated": "Agent created and started",
"agentFailed": "Failed to create agent",
"notes": "notes",
"continue": "Continue",
"noRecentNotes": "No recent notes.",
"untitled": "Untitled",
"inbox": "Inbox",
"sentiment": "Sentiment",
"analyzing": "Analyzing…",
"notEnoughNotes": "Not enough notes this week.",
"toReview": "To review",
"allCaughtUp": "All caught up.",
"toOrganize": "to organize",
"review": "Review",
"cardsDue": "cards due",
"reminders": "Reminders",
"aiFound": "AI found",
"new": "new",
"noConnections": "No connections yet. AI analyzes your notes.",
"match": "Match",
"semanticConnection": "Semantic affinity",
"suggestedBridge": "Link {clusterA} & {clusterB}",
"createBridgeNote": "Create bridge note",
"bridgeNoteCreated": "Bridge note created",
"agentDiscovery": "Agent",
"connectionBetween": "Link between \"{note1}\" and \"{note2}\"",
"intelFilterAll": "All",
"intelFilterConnections": "Links",
"intelFilterBridges": "Bridges",
"intelFilterIdeas": "Ideas",
"intelFilterAgents": "Agents",
"intelFilterEmpty": "Nothing in this category yet.",
"intelOpenNote": "Open",
"intelCompare": "Compare",
"intelMissingLink": "Missing link",
"intelViewAgent": "View agent",
"intelAgentNoResult": "Research complete — see details.",
"intelPrev": "Previous",
"intelNext": "Next",
"intelGoTo": "Discovery {index}",
"intelPosition": "{current} / {total}",
"resumeOpen": "Resume",
"pulseInbox": "{count} to organize",
"pulseReview": "{count} to review",
"pulseDiscoveries": "{count} discoveries",
"pulseClear": "All caught up — enjoy your session.",
"widgetCustomize": "Customize",
"widgetDone": "Done",
"widgetAdd": "Add widget",
"widgetReset": "Reset layout",
"widgetResetDone": "Default dashboard restored.",
"layoutEmptyTitle": "Your dashboard is empty",
"layoutEmptyHint": "Restore the recommended layout to get started with your second brain.",
"widgetCatalog": "Widget catalog",
"widgetCatalogTitle": "Customize your dashboard",
"widgetActive": "Visible",
"widgetAddShort": "Add",
"widgetOpen": "Open",
"widgetDrag": "Drag widget",
"widgetHide": "Hide widget",
"widgetStatsBridges": "Bridges",
"widgetStatsNotes": "Notes",
"widgetAgentActivityEmpty": "No agent activity in the last 48 hours.",
"widgetPinnedEmpty": "No pinned notes yet.",
"widgetActivityHint": "Notes edited over the last 90 days.",
"widgetUsageHint": "Monthly AI usage by feature.",
"widgetCategories": {
"essential": "Essentials",
"paths": "Paths & review",
"cognition": "Knowledge map",
"productivity": "Productivity",
"agents": "AI & agents"
},
"widgets": {
"capture": "Quick capture",
"resume": "Resume here",
"intelligence": "AI found",
"reminders": "Reminders",
"mind-map": "Mind map",
"agents": "Suggested research",
"sentiment": "Sentiment",
"inbox": "Inbox",
"revision": "Flashcards",
"stats": "Semantic stats",
"agent-activity": "Agent activity",
"gmail": "Gmail captures",
"activity": "Writing activity",
"pinned": "Pinned notes",
"usage": "AI quota",
"next-paths": "Next paths",
"daily-review": "Daily review",
"open-loops": "Open loops",
"daily-note": "Daily journal",
"link-suggestions": "Link suggestions",
"bridges": "Bridge opportunities",
"flashcards-progress": "Learning progress"
},
"widgetDescriptions": {
"capture": "Capture a thought instantly into your inbox.",
"resume": "Pick up your most recent notes where you left off.",
"intelligence": "Semantic links, bridge ideas, and agent discoveries.",
"reminders": "Upcoming note reminders at a glance.",
"mind-map": "Theme clusters sized by note volume.",
"agents": "AI-suggested research agents for your topics.",
"sentiment": "Emotional tone of your notes this week.",
"inbox": "Notes waiting to be filed into notebooks.",
"revision": "Flashcards due for spaced repetition review.",
"stats": "Clusters, bridge notes, and total indexed notes.",
"agent-activity": "Latest completed agent runs.",
"gmail": "Email captures synced from Gmail.",
"activity": "GitHub-style heatmap of your writing rhythm.",
"pinned": "Quick access to your pinned notes.",
"usage": "Remaining AI credits and monthly limits.",
"next-paths": "AI-suggested next steps from your latest work.",
"daily-review": "10-minute morning checklist (inbox, discoveries, one link).",
"open-loops": "Notes started but left unfinished.",
"daily-note": "Today's journal entry.",
"link-suggestions": "Passages to link into your current note.",
"bridges": "Missing links between your knowledge themes.",
"flashcards-progress": "Retention, streak, and total cards."
},
"pathsTitle": "Your next paths",
"pathsFromNote": "Based on \"{title}\"",
"pathsLoading": "Analyzing your recent work…",
"pathsEnriching": "Refining suggestions…",
"pathsEmpty": "Work on a note to unlock personalized paths.",
"pathCompareHint": "Also related: {title} — open Memory Echo to compare.",
"pathAddLinkHint": "Consider linking to \"{link}\" in your note.",
"dailyReviewHint": "10 min each morning — the habit that makes your second brain compound.",
"dailyReviewInbox": "Process inbox",
"dailyReviewDiscoveries": "Review AI discoveries",
"dailyReviewConnection": "Explore one connection",
"dailyReviewCards": "Review flashcards",
"openLoopsEmpty": "No stalled notes — you're on track.",
"openLoopsStale": "{days}d ago",
"dailyNoteOpen": "Open journal",
"linkSuggestionsEmpty": "Edit a note to get link suggestions.",
"bridgesEmpty": "No bridge opportunities right now.",
"flashRetention": "Mastered",
"flashStreak": "Streak",
"flashTotal": "Cards",
"pathTypes": {
"continue": "Continue",
"connect": "Semantic link",
"add-link": "Add to note",
"bridge": "Bridge idea",
"research": "Research agent",
"explore": "Explore theme",
"organize": "Organize",
"review": "Review",
"resurface": "Resurface",
"daily": "Journal"
},
"widgetHelpLabel": "Widget help",
"widgetHelpClose": "Close",
"sentimentDominant": "Dominant tone this week",
"widgetHelp": {
"capture": "Jot down a thought in one line. It lands in your Inbox — classify it during your daily review.",
"next-paths": "Suggested next steps based on your latest edited note: resume, link, bridge, or research.",
"resume": "Your most recently updated notes. Pick up where you left off.",
"intelligence": "AI discoveries: semantic links between notes, bridge ideas, and agent findings.",
"reminders": "Upcoming note reminders. Empty when everything is on schedule.",
"mind-map": "Theme clusters sized by note volume. Click to explore in Insights.",
"agents": "AI-suggested research agents for topics you write about often.",
"sentiment": "Emotional tone of notes edited in the last 7 days. Requires at least 3 recent notes and AI enabled.",
"inbox": "Notes without a notebook yet. File them to keep your second brain tidy.",
"revision": "Flashcards due for spaced-repetition review today.",
"stats": "Semantic index stats: active themes, bridge notes, total indexed notes.",
"agent-activity": "Agents that completed a run in the last 48 hours.",
"gmail": "Email captures synced from Gmail integration.",
"activity": "Heatmap of notes you edited over the last 90 days.",
"pinned": "Quick access to notes you pinned.",
"usage": "Monthly AI credit usage by feature.",
"daily-review": "10-minute morning checklist: inbox, discoveries, one connection, flashcards.",
"open-loops": "Notes you started but haven't touched in 3+ days.",
"daily-note": "Today's journal entry — one note per day.",
"link-suggestions": "Passages from other notes worth linking into your current work.",
"bridges": "AI suggestions to connect two separate knowledge themes with a bridge note.",
"flashcards-progress": "Learning retention rate, review streak, and total cards."
},
"pathActions": {
"continue": "Resume",
"compare": "Compare",
"addLink": "Open & link",
"openInsight": "Explore",
"createBridge": "Create bridge",
"createAgent": "Launch agent",
"exploreTheme": "View themes",
"organizeInbox": "Open inbox",
"reviewCards": "Review",
"openDaily": "Open journal"
},
"gmailCaptures": "Gmail captures",
"gmailConnect": "Connect Gmail",
"gmailRecent": "{count} this week",
"aiConsentRequired": "Enable AI to discover connections between your notes.",
"aiProviderUnavailable": "AI is temporarily unavailable. Check your provider settings.",
"memoryEchoDisabled": "Memory Echo is disabled in your AI settings.",
"enableAi": "Enable AI",
"analyzeNotes": "Analyze my notes",
"echoFound": "New connection found!",
"echoNone": "No new connections right now. Try again later.",
"echoFailed": "Analysis failed",
"alreadySeen": "seen",
"dismissConnection": "Dismiss this connection",
"loading": "…",
"emotions": {
"focused": "Focused",
"curious": "Curious",
"enthusiastic": "Enthusiastic",
"frustrated": "Frustrated",
"calm": "Calm",
"anxious": "Anxious",
"creative": "Creative",
"reflective": "Reflective"
}
}, },
"editor": { "editor": {
"voiceStart": "Dictate text (microphone)", "voiceStart": "Dictate text (microphone)",
"voiceStop": "Stop dictation" "voiceStop": "Stop dictation",
"backlinks": "Backlinks",
"clickToEdit": "Click to edit",
"bold": "Bold",
"italic": "Italic",
"highlight": "Highlight",
"link": "Link",
"bulletList": "Bullet List",
"taskList": "Task List",
"heading": "Heading",
"codeBlock": "Code Block",
"plus": "Plus"
}, },
"wizard": { "wizard": {
"title": "Create a notebook with AI", "title": "Create a notebook with AI",
@@ -3933,28 +4316,148 @@
"published": "Site published!", "published": "Site published!",
"openSite": "Open site", "openSite": "Open site",
"updateSite": "Update site", "updateSite": "Update site",
"unpublish": "Unpublish site" "unpublish": "Unpublish site",
"selectAtLeastOne": "Select at least one note",
"unpublished": "Site unpublished",
"online": "Site online",
"unpublishing": "Unpublishing…"
}, },
"searchModal": { "searchModal": {
"search_ariaLabel": "Search", "ariaLabel": "Search",
"search_placeholder": "Search across all your notes…", "placeholder": "Search all notes…",
"search_caseSensitive": "Case sensitive", "caseSensitive": "Case sensitive",
"search_regexMode": "Regex mode", "regexMode": "Regex mode",
"search_trashIncluded": "Trash included", "trashIncluded": "Trash included",
"search_openInEditor": "Open in editor", "openInEditor": "Open in editor",
"search_title": "Memento Search", "title": "Memento Search",
"search_favorites": "Favorites:", "favorites": "Favorites:",
"search_searching": "Searching…", "searching": "Searching…",
"search_noResults": "No results", "noResults": "No results",
"search_typeToSearch": "Type to search", "typeToSearch": "Type to search",
"search_aiAnalysis": "Analyzing…", "aiAnalysis": "Analyzing…",
"search_aiResponse": "AI Response", "aiResponse": "AI Response",
"search_resultsSummary": "Results summary…", "resultsSummary": "Results summary…",
"search_documentPreview": "Document preview", "documentPreview": "Document preview",
"search_noMatch": "No note matches.", "noMatch": "No note matches your search.",
"search_typeForResults": "Type to get instant results.", "typeForResults": "Type for instant results.",
"search_hintNavigate": "navigate", "hintNavigate": "navigate",
"search_hintOpen": "open", "hintOpen": "open",
"search_hintClose": "close" "hintClose": "close"
},
"mobile": {
"aiNote": "AI Note",
"blockActions": "Block Actions",
"convertFormat": "Convert Format",
"delete": "Delete",
"duplicate": "Duplicate",
"paragraph": "Paragraph",
"quote": "Quote",
"selectAll": "Select All"
},
"clusters": {
"empty": "No clusters to display",
"emptyHint": "Create more notes to generate clusters",
"bridgeNote": "Bridge note",
"regularNote": "Regular note",
"notes": "notes"
},
"bridgeNotes": {
"bridges": "Bridges",
"suggestions": "Suggestions",
"powerfulTitle": "Powerful Bridge Notes",
"score": "Score",
"empty": "No significant bridge notes found yet. Deepen your research to find new connections.",
"missingLinks": "Missing Links (AI Generated)",
"bridging": "Bridging",
"dismiss": "Dismiss suggestion",
"noSuggestions": "No connection suggestions yet",
"allConnected": "All your clusters may already be connected!",
"generate": "Generate Suggestions"
},
"chart": {
"code": "Chart Code",
"invalid": "Invalid Chart",
"invalidDescription": "This chart block contains invalid or empty data.",
"editSource": "Edit chart source",
"suggestions": "Chart Suggestions",
"analyzing": "Analyzing your content for chart data…",
"debugInfo": "Debug info",
"escCancel": "Esc Cancel",
"insertFailed": "Failed to insert chart: ",
"noDataDetected": "No Data Detected",
"noDataHint": "Try adding numerical data to your note. Charts work best with:",
"dataHint1": "• Sales figures, metrics, or measurements",
"dataHint2": "• Lists with values (e.g., \"Jan: 5000, Feb: 7500\")",
"dataHint3": "• Percentages or proportions",
"dataHint4": "• Time-series data",
"debugShowAnalyzed": "Debug: Show what was analyzed",
"navigateHint": "← → Navigate",
"selectHint": "↵ Select",
"dataPoints": "data points",
"analyzingSelection": "selection",
"analyzingNote": "note",
"words": "words",
"found": "Found"
},
"byok": {
"loadError": "Loading error",
"updated": "{{name}} updated ✓",
"testHint": "To test, enter your API key…",
"aliasPlaceholder": "e.g. My pro key",
"choose": "Choose…",
"keyPlaceholder": "sk-… (optional)",
"keyValid": "Valid API key ✓",
"verifyError": "Verification error",
"keySaved": "Key {{name}} saved ✓",
"keyDeleted": "Key deleted",
"noActiveKey": "No active key — using Memento quotas",
"savedKeys": "Saved keys",
"chooseProvider": "Choose a provider…",
"apiKey": "API Key",
"fetchingModels": "Fetching models…",
"chooseModel": "Choose a model…",
"editLabel": "Edit — {{name}}",
"aliasLabel": "Alias",
"apiUrl": "API URL",
"model": "Model",
"newKey": "New API key",
"newKeyHint": "(leave empty to keep current)",
"operational": "✓ Operational",
"test": "Test",
"saveChanges": "Save changes",
"contactError": "Could not contact server",
"baseUrlRequired": "Please provide the API URL",
"keyInvalid": "Invalid API key",
"byokActive": "BYOK active",
"activeStatus": "● Active",
"inactiveStatus": "○ Inactive",
"confirmDelete": "Delete the {{name}} key?",
"addOrReplace": "Add / replace a key",
"connectProvider": "Connect your AI provider",
"optional": "(optional)",
"verify": "Verify",
"modelOperational": "Model operational ✓",
"reply": "Reply:",
"testFailed": "Test failed",
"save": "Save",
"saveWithName": "Save — {{name}}"
},
"blockPicker": {
"searchPlaceholder": "Search for a note excerpt…"
},
"errors": {
"network": "Network error"
},
"pdf": {
"exportError": "PDF export error"
},
"report": {
"explainProblem": "Explain the problem…"
},
"publish": {
"defaultTitle": "Published note",
"tagline": "Your memory, augmented by AI",
"aiSheet": "AI-generated sheet",
"publishedOn": "Published on"
} }
} }

View File

@@ -73,6 +73,7 @@
"dropToRoot": "Déposez ici pour déplacer à la racine", "dropToRoot": "Déposez ici pour déplacer à la racine",
"noReminders": "Aucun rappel actif.", "noReminders": "Aucun rappel actif.",
"documents": "Documents", "documents": "Documents",
"dashboardPanelBody": "Votre second brain en un coup d'œil : suggestions IA, capture rapide et prochaines pistes. Raccourcis pour agir tout de suite.",
"searchNotebooksPlaceholder": "Rechercher un carnet…", "searchNotebooksPlaceholder": "Rechercher un carnet…",
"clearSearch": "Effacer la recherche", "clearSearch": "Effacer la recherche",
"insightsPanelBody": "Cartographie sémantique de vos notes : clusters thématiques, notes-ponts et suggestions de connexion.", "insightsPanelBody": "Cartographie sémantique de vos notes : clusters thématiques, notes-ponts et suggestions de connexion.",
@@ -82,7 +83,10 @@
"resizeSidebar": "Redimensionner la largeur de la barre latérale", "resizeSidebar": "Redimensionner la largeur de la barre latérale",
"dailyNote": "Note du jour", "dailyNote": "Note du jour",
"dailyNoteError": "Impossible d'ouvrir la note du jour", "dailyNoteError": "Impossible d'ouvrir la note du jour",
"dailyNoteTooltip": "Ouvrir ou créer la note personnelle pour le journal d'aujourd'hui" "dailyNoteTooltip": "Ouvrir ou créer la note personnelle pour le journal d'aujourd'hui",
"lightMode": "Mode clair",
"darkMode": "Mode sombre",
"searchShortcut": "Recherche (Ctrl+K)"
}, },
"notes": { "notes": {
"title": "Notes", "title": "Notes",
@@ -324,7 +328,12 @@
"unarchived": "Désarchivée", "unarchived": "Désarchivée",
"savedJustNow": "Sauvegardé", "savedJustNow": "Sauvegardé",
"unsaved": "Non sauvegardé", "unsaved": "Non sauvegardé",
"historyEnabledTooltip": "Historique des versions activé — les modifications sont suivies" "historyEnabledTooltip": "Historique des versions activé — les modifications sont suivies",
"heading1": "Titre 1",
"heading2": "Titre 2",
"heading3": "Titre 3",
"importSuccess": "{{count}} notes importées !",
"unpublished": "Note dépubliée"
}, },
"pagination": { "pagination": {
"previous": "←", "previous": "←",
@@ -366,7 +375,9 @@
"confirmDeleteShort": "Confirmer ?", "confirmDeleteShort": "Confirmer ?",
"labelRemoved": "Étiquette \"{label}\" supprimée", "labelRemoved": "Étiquette \"{label}\" supprimée",
"filterByTags": "Filtrer par étiquettes", "filterByTags": "Filtrer par étiquettes",
"searchTags": "Rechercher des étiquettes" "searchTags": "Rechercher des étiquettes",
"activeCount": "{{count}} actifs",
"showMoreCount": "+ {{count}} de plus"
}, },
"search": { "search": {
"placeholder": "Rechercher", "placeholder": "Rechercher",
@@ -775,6 +786,7 @@
"match": "{percentage}% correspondance", "match": "{percentage}% correspondance",
"fused": "Fusionné", "fused": "Fusionné",
"clickToView": "Cliquer pour voir la note →", "clickToView": "Cliquer pour voir la note →",
"defaultInsight": "Ces notes semblent sémantiquement liées.",
"overlay": { "overlay": {
"title": "Notes connectées", "title": "Notes connectées",
"searchPlaceholder": "Rechercher des connexions...", "searchPlaceholder": "Rechercher des connexions...",
@@ -944,6 +956,8 @@
"graphView": "Carte des liens", "graphView": "Carte des liens",
"insights": "Thèmes sémantiques", "insights": "Thèmes sémantiques",
"revision": "Révisions", "revision": "Révisions",
"dailyNotes": "Notes du jour",
"dashboard": "Tableau de bord",
"userManagement": "Gestion des utilisateurs", "userManagement": "Gestion des utilisateurs",
"accountSettings": "Paramètres du compte", "accountSettings": "Paramètres du compte",
"manageAISettings": "Gérer les paramètres IA", "manageAISettings": "Gérer les paramètres IA",
@@ -1023,7 +1037,19 @@
"autoSave": "Auto-enregistrement", "autoSave": "Auto-enregistrement",
"autoSaveDesc": "Enregistrer automatiquement les modifications pendant la frappe", "autoSaveDesc": "Enregistrer automatiquement les modifications pendant la frappe",
"autoSaveEnabled": "Auto-enregistrement activé !", "autoSaveEnabled": "Auto-enregistrement activé !",
"autoSaveDisabled": "Auto-enregistrement désactivé" "autoSaveDisabled": "Auto-enregistrement désactivé",
"publishedTitle": "Mes pages publiées",
"publishedDesc": "Gérez les notes que vous avez publiées publiquement.",
"publishedEmpty": "Aucune note publiée pour le moment.",
"notesViewLabel": "Affichage des notes",
"notesViewDescription": "Choisissez comment les notes sont affichées dans la liste.",
"notesViewList": "Liste",
"notesViewMasonry": "Mosaïque",
"notesViewTable": "Tableau",
"importExportHelp": "Import / Export — comment ça fonctionne ?",
"aiConfigHelp": "Comment configurer votre IA ?",
"integrationsTitle": "Intégrations",
"integrationsDesc": "Connectez des services externes à Memento."
}, },
"profile": { "profile": {
"title": "Profil", "title": "Profil",
@@ -1230,7 +1256,8 @@
"dismissIn": "Rejeter (ferme dans {timeLeft}s)", "dismissIn": "Rejeter (ferme dans {timeLeft}s)",
"moveToNotebook": "Déplacer vers un carnet", "moveToNotebook": "Déplacer vers un carnet",
"generalNotes": "Notes générales", "generalNotes": "Notes générales",
"movedToNotebook": "Déplacé vers le carnet" "movedToNotebook": "Déplacé vers le carnet",
"filterNotebooks": "Filtrer les carnets…"
}, },
"admin": { "admin": {
"title": "Tableau de bord Admin", "title": "Tableau de bord Admin",
@@ -1922,7 +1949,9 @@
"outboundList": "Depuis cette note ({count})", "outboundList": "Depuis cette note ({count})",
"unlinkedList": "Mentions non reliées ({count})", "unlinkedList": "Mentions non reliées ({count})",
"refBadge": "Entrant", "refBadge": "Entrant",
"toBadge": "Sortant" "toBadge": "Sortant",
"noInbound": "Aucun lien wiki ne pointe vers cette note.",
"noOutbound": "Cette note ne contient pas encore de liens vers d'autres notes."
} }
}, },
"languages": { "languages": {
@@ -1960,7 +1989,7 @@
"off": "Désactivé", "off": "Désactivé",
"unknown": "Inconnu", "unknown": "Inconnu",
"notAvailable": "N/D", "notAvailable": "N/D",
"loading": "Chargement...", "loading": "Chargement",
"error": "Erreur", "error": "Erreur",
"success": "Succès", "success": "Succès",
"confirm": "Confirmer", "confirm": "Confirmer",
@@ -1974,7 +2003,14 @@
"search": "Rechercher...", "search": "Rechercher...",
"noResults": "Aucune note trouvée", "noResults": "Aucune note trouvée",
"required": "Requis", "required": "Requis",
"optional": "Optionnel" "optional": "Optionnel",
"notes": "notes",
"openMenu": "Ouvrir le menu",
"openNav": "Ouvrir la navigation",
"linkCopied": "Lien copié !",
"copyLink": "Copier le lien",
"untitled": "Sans titre",
"selectPlaceholder": "Sélectionner…"
}, },
"time": { "time": {
"justNow": "à l'instant", "justNow": "à l'instant",
@@ -2395,7 +2431,20 @@
}, },
"lab": { "lab": {
"initializing": "Initialisation de l'espace de travail", "initializing": "Initialisation de l'espace de travail",
"loadingIdeas": "Chargement de vos idées..." "loadingIdeas": "Chargement de vos idées...",
"loadingSlides": "Chargement des slides…",
"downloadFailed": "Échec du téléchargement de la présentation",
"exportPpt": "Exporter en PowerPoint",
"downloadHtml": "Télécharger le HTML",
"openFullscreen": "Ouvrir en plein écran",
"loadingPresentation": "Chargement de la présentation…",
"previousSlide": "Slide précédent",
"nextSlide": "Slide suivant",
"hideNotes": "Masquer les notes",
"toggleNotes": "Bascule des notes de présentation (Raccourci : N)",
"notes": "Notes",
"previous": "Précédent",
"next": "Suivant"
}, },
"richTextEditor": { "richTextEditor": {
"slashHint": "↑↓ naviguer · Entrée insérer · Tab changer de section", "slashHint": "↑↓ naviguer · Entrée insérer · Tab changer de section",
@@ -2694,7 +2743,15 @@
"previewDiagramTip": "Esquissez des concepts ou générez des diagrammes via IA.", "previewDiagramTip": "Esquissez des concepts ou générez des diagrammes via IA.",
"previewLivingBlockTip": "Synchronisez du contenu entre plusieurs notes.", "previewLivingBlockTip": "Synchronisez du contenu entre plusieurs notes.",
"previewSlidesTip": "Créez des présentations interactives exportables.", "previewSlidesTip": "Créez des présentations interactives exportables.",
"previewTableTip": "Organisez vos données en lignes et colonnes." "previewTableTip": "Organisez vos données en lignes et colonnes.",
"aiActionStarted": "IA Note sollicitée…",
"blockDeleted": "Bloc supprimé",
"blockDuplicated": "Bloc dupliqué",
"blockSelected": "Bloc sélectionné en entier",
"slashVideo": "Vidéo",
"startVoice": "Démarrer la dictée vocale",
"stopVoice": "Arrêter la dictée vocale",
"collapsibleSection": "Section repliable"
}, },
"flashcards": { "flashcards": {
"generateTitle": "Générer des flashcards", "generateTitle": "Générer des flashcards",
@@ -2788,7 +2845,8 @@
"cardSaved": "Carte enregistrée", "cardSaved": "Carte enregistrée",
"cardTypeBadge": "{type}", "cardTypeBadge": "{type}",
"reviewNow": "Réviser maintenant", "reviewNow": "Réviser maintenant",
"deleteFailed": "Impossible de supprimer" "deleteFailed": "Impossible de supprimer",
"toolbarGenerateHint": "Révision espacée SM-2"
}, },
"structuredViews": { "structuredViews": {
"enableTitle": "Organiser ce carnet (tableau, kanban, galerie…)", "enableTitle": "Organiser ce carnet (tableau, kanban, galerie…)",
@@ -2920,7 +2978,14 @@
"tagApplied": "appliqué", "tagApplied": "appliqué",
"filterDone": "Fait", "filterDone": "Fait",
"filterTodo": "À faire", "filterTodo": "À faire",
"propertyStatus": "Statut" "propertyStatus": "Statut",
"exportCsv": "Exporter CSV",
"importCsv": "Importer CSV",
"relationEmpty": "Lier une note…",
"relationNoResults": "Aucune note trouvée",
"relationSearch": "Rechercher une note…",
"semanticResonances": "Résonances sémantiques",
"insertLink": "Insérer le lien dans l'éditeur"
}, },
"brainstorm": { "brainstorm": {
"title": "Vagues de pensée", "title": "Vagues de pensée",
@@ -3167,8 +3232,8 @@
"planSince": "Membre depuis", "planSince": "Membre depuis",
"checkoutSuccessTitle": "Abonnement activé !", "checkoutSuccessTitle": "Abonnement activé !",
"checkoutSuccessBody": "Bienvenue sur {tier}. Vos fonctionnalités sont maintenant débloquées.", "checkoutSuccessBody": "Bienvenue sur {tier}. Vos fonctionnalités sont maintenant débloquées.",
"subscriptionType": "subscriptionType", "subscriptionType": "Type d'abonnement",
"renewalDate": "renewalDate", "renewalDate": "Date de renouvellement",
"noRenewalDate": "—", "noRenewalDate": "—",
"tab": "Facturation", "tab": "Facturation",
"currentUsage": "Utilisation actuelle", "currentUsage": "Utilisation actuelle",
@@ -3204,7 +3269,19 @@
"instantActivation": "Activation instantanée", "instantActivation": "Activation instantanée",
"secureDesc": "Paiements sécurisés par Stripe", "secureDesc": "Paiements sécurisés par Stripe",
"secureTransactions": "Transactions sécurisées", "secureTransactions": "Transactions sécurisées",
"satisfactionGuarantee": "Satisfait ou remboursé 30 jours" "satisfactionGuarantee": "Satisfait ou remboursé 30 jours",
"cancelConfirm": "Êtes-vous sûr de vouloir résilier votre abonnement ? Vous conserverez vos accès Pro/Business jusqu'à la fin de la période en cours.",
"cancelSuccess": "Votre abonnement a été résilié avec succès. Il prendra fin à la fin de la période de facturation en cours.",
"invoices": "Factures & reçus",
"invoiceAmount": "Montant",
"invoiceDate": "Date",
"invoiceNumber": "Numéro",
"invoiceStatus": "Statut",
"noInvoices": "Aucune facture disponible.",
"checkoutFailed": "Échec du démarrage du paiement. Veuillez réessayer.",
"portalFailed": "Échec de l'ouverture du portail de facturation.",
"cancelFailed": "Échec de l'annulation de l'abonnement.",
"downloadPdf": "Télécharger le PDF"
}, },
"quotaPaywall": { "quotaPaywall": {
"title": "Limite mensuelle atteinte", "title": "Limite mensuelle atteinte",
@@ -3693,7 +3770,9 @@
"openFully": "Ouvrir en plein écran", "openFully": "Ouvrir en plein écran",
"openFullyHelp": "Remplacer la note courante par cette note", "openFullyHelp": "Remplacer la note courante par cette note",
"readOnlyHint": "Aperçu en lecture seule — ouvrez en plein écran pour modifier.", "readOnlyHint": "Aperçu en lecture seule — ouvrez en plein écran pour modifier.",
"loadFailed": "Impossible d'ouvrir l'aperçu de la note liée." "loadFailed": "Impossible d'ouvrir l'aperçu de la note liée.",
"loading": "Chargement…",
"untitled": "Sans titre"
}, },
"structuredViewBlock": { "structuredViewBlock": {
"insertLabel": "Vue structurée", "insertLabel": "Vue structurée",
@@ -3860,11 +3939,317 @@
"hint_insights_refresh_desc": "Si vous avez ajouté de nouvelles notes, cliquez sur le bouton de rafraîchissement pour recalculer les clusters avec le contenu le plus récent." "hint_insights_refresh_desc": "Si vous avez ajouté de nouvelles notes, cliquez sur le bouton de rafraîchissement pour recalculer les clusters avec le contenu le plus récent."
}, },
"integrations": { "integrations": {
"title": "Intégrations" "title": "Intégrations",
"gmail": {
"title": "Gmail",
"description": "Capture automatique des vols, colis et abonnements depuis vos e-mails",
"connect": "Connecter Gmail",
"disconnect": "Déconnecter",
"scanNow": "Scanner maintenant",
"connected": "Gmail connecté !",
"disconnected": "Gmail déconnecté",
"scanDone": "{created} note(s) créée(s) depuis les e-mails",
"recentCaptures": "{count} capture(s) cette semaine",
"helpTitle": "Comment fonctionne Gmail ?",
"helpStep1": "Connectez Gmail — vous serez redirigé vers Google pour autoriser un accès en lecture seule.",
"helpStep2": "Memento analyse vos e-mails récents : vols, colis, renouvellements d'abonnement.",
"helpStep3": "Chaque correspondance devient une note, avec rappel automatique si pertinent.",
"helpStep4": "Le scan tourne chaque jour en arrière-plan ; vous pouvez aussi lancer un scan manuel ici."
},
"calendarConnected": "Google Calendar connecté !",
"errorWith": "Erreur : {{error}}",
"readwiseError": "Erreur Readwise",
"readwiseConnected": "Readwise connecté — {{created}} notes créées, {{updated}} mises à jour",
"readwiseConnectError": "Erreur de connexion Readwise",
"syncError": "Erreur de synchronisation",
"readwiseSynced": "Sync Readwise — {{created}} créées, {{updated}} mises à jour",
"syncFailed": "Erreur de synchronisation",
"readwiseDisconnected": "Readwise déconnecté",
"noEvents": "Aucun événement aujourd'hui",
"eventsLoadError": "Erreur de chargement des événements",
"meetingNoteCreated": "Note de réunion créée : {{summary}}",
"createNoteError": "Erreur lors de la création de la note",
"calendarDisconnected": "Google Calendar déconnecté",
"gmailScanError": "Erreur de scan Gmail",
"calendarInfo": "Comment fonctionne Google Calendar ?",
"readwiseInfo": "Comment fonctionne Readwise ?",
"readwiseTokenPlaceholder": "Token Readwise…",
"connected": "Connecté",
"notConnected": "Non connecté",
"connectCalendar": "Connecter Google Calendar",
"todayEvents": "Événements aujourd'hui",
"disconnect": "Déconnecter",
"connect": "Connecter",
"syncNow": "Synchroniser maintenant",
"addNote": "+ Note",
"open": "Ouvrir",
"moreComing": "D'autres intégrations arrivent bientôt — Zapier, GitHub, Notion import…",
"calendarDesc": "Accédez à vos événements et créez des notes de réunion",
"readwiseDesc": "Importez vos surlignages de livres, articles et Kindle",
"lastSync": "Dernière sync :",
"createdLabel": "notes créées",
"updatedLabel": "mises à jour",
"calendarHelpStep1": "Cliquez « Connecter Google Calendar » — vous serez redirigé vers Google pour autoriser l'accès.",
"calendarHelpStep2": "Une fois connecté, revenez ici et cliquez « Événements aujourd'hui » pour voir votre agenda.",
"calendarHelpStep3": "Sur chaque événement, cliquez « + Note » pour créer automatiquement une note de réunion avec template (Ordre du jour / Notes / Actions).",
"calendarHelpStep4": "La note s'ouvre directement dans Memento — ajoutez vos notes en temps réel pendant la réunion.",
"readwiseHelpStep1": "Copiez votre token d'accès Readwise.",
"readwiseHelpStep2": "Collez-le dans le champ ci-dessous et cliquez « Connecter ». La première synchronisation importe tous vos livres et articles.",
"readwiseHelpStep3": "Chaque livre devient une note dans un carnet « Readwise 📚 » — avec tous vos surlignages organisés.",
"readwiseHelpStep4": "Pour mettre à jour avec de nouveaux surlignages, revenez ici et cliquez « Synchroniser maintenant ».",
"readwiseHelpStep5": "💡 Astuce : créez des flashcards IA depuis une note Readwise (bouton 🎓 dans l'éditeur) pour réviser vos lectures."
},
"homeDashboard": {
"title": "Tableau de bord",
"quickCapture": "Capture rapide",
"quickCapturePlaceholder": "Une idée, une pensée… Entrée pour capturer dans l'Inbox.",
"captured": "Capturé dans l'Inbox",
"captureError": "Erreur",
"mindMap": "Carte mentale",
"fullMap": "Vue complète →",
"mindMapEmpty": "Pas encore de thèmes détectés. L'analyse sémantique regroupe vos notes par sujets.",
"mindMapOpen": "Ouvrir la cartographie →",
"mindMapUnavailable": "Cartographie indisponible.",
"themes": "thèmes",
"bridges": "ponts",
"aiEchoes": "échos IA",
"bridgeNote": "Note-pont",
"theme": "Thème",
"suggestedResearch": "Recherches suggérées",
"dismiss": "Ignorer",
"createAgent": "Créer l'agent",
"agentsEmpty": "Aucune recherche suggérée pour l'instant. Continuez à écrire — Memento proposera des synthèses quand vos notes formeront des thèmes.",
"agentCreated": "Agent créé et lancé",
"agentFailed": "Échec de la création",
"notes": "notes",
"continue": "Reprendre ici",
"noRecentNotes": "Aucune note récente.",
"untitled": "Sans titre",
"inbox": "Inbox",
"sentiment": "Analyse émotionnelle",
"analyzing": "Analyse en cours…",
"notEnoughNotes": "Pas assez de notes cette semaine.",
"toReview": "À traiter",
"allCaughtUp": "Tout est à jour.",
"toOrganize": "à organiser",
"review": "Révisions",
"cardsDue": "cartes dues",
"reminders": "Rappels",
"aiFound": "L'IA a trouvé",
"new": "nouveaux",
"noConnections": "Aucune connexion trouvée. L'IA analyse vos notes.",
"match": "Connexion",
"semanticConnection": "Affinité sémantique",
"suggestedBridge": "Relier {clusterA} et {clusterB}",
"createBridgeNote": "Créer la note-pont",
"bridgeNoteCreated": "Note-pont créée",
"agentDiscovery": "Agent",
"connectionBetween": "Lien entre « {note1} » et « {note2} »",
"intelFilterAll": "Tout",
"intelFilterConnections": "Liens",
"intelFilterBridges": "Ponts",
"intelFilterIdeas": "Idées",
"intelFilterAgents": "Agents",
"intelFilterEmpty": "Rien dans cette catégorie pour l'instant.",
"intelOpenNote": "Ouvrir",
"intelCompare": "Comparer",
"intelMissingLink": "Lien manquant",
"intelViewAgent": "Voir l'agent",
"intelAgentNoResult": "Recherche terminée — voir le détail.",
"intelPrev": "Précédent",
"intelNext": "Suivant",
"intelGoTo": "Découverte {index}",
"intelPosition": "{current} / {total}",
"resumeOpen": "Reprendre",
"pulseInbox": "{count} à classer",
"pulseReview": "{count} à réviser",
"pulseDiscoveries": "{count} découvertes",
"pulseClear": "Tout est à jour — bonne session.",
"widgetCustomize": "Personnaliser",
"widgetDone": "Terminé",
"widgetAdd": "Ajouter",
"widgetReset": "Réinitialiser",
"widgetResetDone": "Tableau de bord par défaut restauré.",
"layoutEmptyTitle": "Votre tableau de bord est vide",
"layoutEmptyHint": "Restaurez la mise en page recommandée pour démarrer votre second brain.",
"widgetCatalog": "Catalogue",
"widgetCatalogTitle": "Personnaliser le tableau de bord",
"widgetActive": "Visible",
"widgetAddShort": "Ajouter",
"widgetOpen": "Ouvrir",
"widgetDrag": "Déplacer le widget",
"widgetHide": "Masquer le widget",
"widgetStatsBridges": "Ponts",
"widgetStatsNotes": "Notes",
"widgetAgentActivityEmpty": "Aucune activité d'agent sur les 48 dernières heures.",
"widgetPinnedEmpty": "Aucune note épinglée pour l'instant.",
"widgetActivityHint": "Notes modifiées sur les 90 derniers jours.",
"widgetUsageHint": "Consommation IA mensuelle par fonctionnalité.",
"widgetCategories": {
"essential": "Essentiels",
"paths": "Pistes & revue",
"cognition": "Carte de connaissances",
"productivity": "Productivité",
"agents": "IA & agents"
},
"widgets": {
"capture": "Capture rapide",
"resume": "Reprendre ici",
"intelligence": "L'IA a trouvé",
"reminders": "Rappels",
"mind-map": "Carte mentale",
"agents": "Recherches suggérées",
"sentiment": "Analyse émotionnelle",
"inbox": "Inbox",
"revision": "Flashcards",
"stats": "Statistiques sémantiques",
"agent-activity": "Activité agents",
"gmail": "Captures Gmail",
"activity": "Activité d'écriture",
"pinned": "Notes épinglées",
"usage": "Quota IA",
"next-paths": "Prochaines pistes",
"daily-review": "Revue quotidienne",
"open-loops": "Boucles ouvertes",
"daily-note": "Journal du jour",
"link-suggestions": "Liens à ajouter",
"bridges": "Opportunités de pont",
"flashcards-progress": "Progression apprentissage"
},
"widgetDescriptions": {
"capture": "Saisir une pensée immédiatement dans l'inbox.",
"resume": "Reprendre vos notes les plus récentes.",
"intelligence": "Liens sémantiques, idées-ponts et découvertes d'agents.",
"reminders": "Rappels de notes à venir en un coup d'œil.",
"mind-map": "Thèmes regroupés, taille proportionnelle au volume.",
"agents": "Agents de recherche suggérés par l'IA.",
"sentiment": "Tonalité émotionnelle de vos notes cette semaine.",
"inbox": "Notes en attente de classement dans un carnet.",
"revision": "Cartes à réviser (répétition espacée).",
"stats": "Clusters, notes-ponts et total de notes indexées.",
"agent-activity": "Dernières exécutions d'agents terminées.",
"gmail": "Captures e-mail synchronisées depuis Gmail.",
"activity": "Heatmap de votre rythme d'écriture.",
"pinned": "Accès rapide à vos notes épinglées.",
"usage": "Crédits IA restants et limites mensuelles.",
"next-paths": "Prochaines étapes suggérées à partir de votre travail récent.",
"daily-review": "Checklist matinale 10 min (inbox, découvertes, un lien).",
"open-loops": "Notes commencées mais laissées en suspens.",
"daily-note": "Entrée de journal du jour.",
"link-suggestions": "Passages à lier dans votre note en cours.",
"bridges": "Liens manquants entre vos thèmes de connaissance.",
"flashcards-progress": "Rétention, série et total de cartes."
},
"pathsTitle": "Vos prochaines pistes",
"pathsFromNote": "À partir de « {title} »",
"pathsLoading": "Analyse de votre activité récente…",
"pathsEnriching": "Affinage des suggestions…",
"pathsEmpty": "Travaillez une note pour débloquer des pistes personnalisées.",
"pathCompareHint": "Également lié : {title} — ouvrez Memory Echo pour comparer.",
"pathAddLinkHint": "Pensez à lier « {link} » dans votre note.",
"dailyReviewHint": "10 min chaque matin — l'habitude qui fait composer votre second brain.",
"dailyReviewInbox": "Traiter l'inbox",
"dailyReviewDiscoveries": "Revoir les découvertes IA",
"dailyReviewConnection": "Explorer une connexion",
"dailyReviewCards": "Réviser les flashcards",
"openLoopsEmpty": "Aucune note en suspens — vous êtes à jour.",
"openLoopsStale": "il y a {days} j",
"dailyNoteOpen": "Ouvrir le journal",
"linkSuggestionsEmpty": "Modifiez une note pour obtenir des suggestions de liens.",
"bridgesEmpty": "Pas d'opportunité de pont pour l'instant.",
"flashRetention": "Maîtrisées",
"flashStreak": "Série",
"flashTotal": "Cartes",
"pathTypes": {
"continue": "Continuer",
"connect": "Lien sémantique",
"add-link": "Ajouter à la note",
"bridge": "Idée-pont",
"research": "Agent de recherche",
"explore": "Explorer le thème",
"organize": "Organiser",
"review": "Réviser",
"resurface": "Resurfacer",
"daily": "Journal"
},
"widgetHelpLabel": "Aide du widget",
"widgetHelpClose": "Fermer",
"sentimentDominant": "Tonalité dominante cette semaine",
"widgetHelp": {
"capture": "Saisissez une pensée en une ligne. Elle arrive dans l'Inbox — classez-la lors de votre revue quotidienne.",
"next-paths": "Prochaines étapes suggérées à partir de votre dernière note : reprendre, lier, créer un pont ou lancer une recherche.",
"resume": "Vos notes les plus récemment modifiées. Reprenez là où vous vous êtes arrêté.",
"intelligence": "Découvertes IA : liens sémantiques, idées-ponts et résultats d'agents.",
"reminders": "Rappels de notes à venir. Vide quand tout est à jour.",
"mind-map": "Thèmes regroupés, taille proportionnelle au volume de notes. Cliquez pour explorer dans Insights.",
"agents": "Agents de recherche suggérés par l'IA pour vos sujets récurrents.",
"sentiment": "Tonalité émotionnelle des notes modifiées sur les 7 derniers jours. Nécessite au moins 3 notes récentes et l'IA activée.",
"inbox": "Notes sans carnet. Classez-les pour garder un second brain ordonné.",
"revision": "Cartes flashcards à réviser aujourd'hui (répétition espacée).",
"stats": "Statistiques de l'index sémantique : thèmes, notes-ponts, total indexé.",
"agent-activity": "Agents ayant terminé une exécution dans les 48 dernières heures.",
"gmail": "Captures e-mail synchronisées depuis l'intégration Gmail.",
"activity": "Heatmap des notes modifiées sur les 90 derniers jours.",
"pinned": "Accès rapide à vos notes épinglées.",
"usage": "Consommation mensuelle des crédits IA par fonctionnalité.",
"daily-review": "Checklist matinale 10 min : inbox, découvertes, une connexion, flashcards.",
"open-loops": "Notes commencées mais sans modification depuis 3+ jours.",
"daily-note": "Entrée de journal du jour — une note par jour.",
"link-suggestions": "Passages d'autres notes à lier dans votre travail en cours.",
"bridges": "Suggestions IA pour relier deux thèmes distincts par une note-pont.",
"flashcards-progress": "Taux de rétention, série de révisions et total de cartes."
},
"pathActions": {
"continue": "Reprendre",
"compare": "Comparer",
"addLink": "Ouvrir & lier",
"openInsight": "Explorer",
"createBridge": "Créer le pont",
"createAgent": "Lancer l'agent",
"exploreTheme": "Voir les thèmes",
"organizeInbox": "Ouvrir l'inbox",
"reviewCards": "Réviser",
"openDaily": "Ouvrir le journal"
},
"gmailCaptures": "Captures Gmail",
"gmailConnect": "Connecter Gmail",
"gmailRecent": "{count} cette semaine",
"aiConsentRequired": "Activez l'IA pour découvrir les connexions entre vos notes.",
"aiProviderUnavailable": "L'IA est temporairement indisponible. Vérifiez la configuration du fournisseur.",
"memoryEchoDisabled": "Memory Echo est désactivé dans vos paramètres IA.",
"enableAi": "Activer l'IA",
"analyzeNotes": "Analyser mes notes",
"echoFound": "Nouvelle connexion trouvée !",
"echoNone": "Pas de nouvelle connexion pour l'instant. Réessayez plus tard.",
"echoFailed": "Échec de l'analyse",
"alreadySeen": "vu",
"dismissConnection": "Ignorer cette connexion",
"loading": "…",
"emotions": {
"focused": "Concentré",
"curious": "Curieux",
"enthusiastic": "Enthousiaste",
"frustrated": "Frustré",
"calm": "Calme",
"anxious": "Anxieux",
"creative": "Créatif",
"reflective": "Réfléchi"
}
}, },
"editor": { "editor": {
"voiceStart": "Dicter du texte (microphone)", "voiceStart": "Dicter du texte (microphone)",
"voiceStop": "Arrêter la dictée" "voiceStop": "Arrêter la dictée",
"backlinks": "Liens entrants",
"clickToEdit": "Cliquez pour éditer",
"bold": "Gras",
"italic": "Italique",
"highlight": "Surligner",
"link": "Lien",
"bulletList": "Liste à puces",
"taskList": "Liste de tâches",
"heading": "Titre",
"codeBlock": "Bloc de code",
"plus": "Plus"
}, },
"wizard": { "wizard": {
"title": "Créer un carnet avec l'IA", "title": "Créer un carnet avec l'IA",
@@ -3937,28 +4322,148 @@
"published": "Site publié !", "published": "Site publié !",
"openSite": "Ouvrir le site", "openSite": "Ouvrir le site",
"updateSite": "Mettre à jour le site", "updateSite": "Mettre à jour le site",
"unpublish": "Dépublier le site" "unpublish": "Dépublier le site",
"selectAtLeastOne": "Sélectionne au moins une note",
"unpublished": "Site dépublié",
"online": "Site en ligne",
"unpublishing": "Dépublication en cours…"
}, },
"searchModal": { "searchModal": {
"search_ariaLabel": "Recherche", "ariaLabel": "Recherche",
"search_placeholder": "Rechercher dans toutes vos notes…", "placeholder": "Rechercher dans toutes les notes…",
"search_caseSensitive": "Respecter la casse", "caseSensitive": "Sensible à la casse",
"search_regexMode": "Mode regex", "regexMode": "Mode regex",
"search_trashIncluded": "Corbeille incluse", "trashIncluded": "Corbeille incluse",
"search_openInEditor": "Ouvrir dans l'éditeur", "openInEditor": "Ouvrir dans l'éditeur",
"search_title": "Memento Search", "title": "Recherche Memento",
"search_favorites": "Favoris :", "favorites": "Favoris :",
"search_searching": "Recherche en cours…", "searching": "Recherche en cours…",
"search_noResults": "Aucun résultat", "noResults": "Aucun résultat",
"search_typeToSearch": "Tapez pour rechercher", "typeToSearch": "Tapez pour rechercher",
"search_aiAnalysis": "Analyse...", "aiAnalysis": "Analyse en cours…",
"search_aiResponse": "Réponse IA", "aiResponse": "Réponse IA",
"search_resultsSummary": "Synthèse des résultats...", "resultsSummary": "Synthèse des résultats",
"search_documentPreview": "Aperçu du document", "documentPreview": "Aperçu du document",
"search_noMatch": "Aucune note ne correspond.", "noMatch": "Aucune note ne correspond à votre recherche.",
"search_typeForResults": "Tapez pour obtenir des résultats instantanés.", "typeForResults": "Tapez pour des résultats instantanés.",
"search_hintNavigate": "naviguer", "hintNavigate": "naviguer",
"search_hintOpen": "ouvrir", "hintOpen": "ouvrir",
"search_hintClose": "fermer" "hintClose": "fermer"
},
"mobile": {
"aiNote": "IA Note",
"blockActions": "Actions sur le bloc",
"convertFormat": "Convertir le format",
"delete": "Supprimer",
"duplicate": "Dupliquer",
"paragraph": "Paragraphe",
"quote": "Citation",
"selectAll": "Sélectionner tout"
},
"clusters": {
"empty": "Aucun groupe à afficher",
"emptyHint": "Créez plus de notes pour générer des groupes",
"bridgeNote": "Note pont",
"regularNote": "Note standard",
"notes": "notes"
},
"bridgeNotes": {
"bridges": "Ponts",
"suggestions": "Suggestions",
"powerfulTitle": "Notes pont puissantes",
"score": "Score",
"empty": "Aucune note pont significative trouvée. Approfondissez vos recherches pour découvrir de nouvelles connexions.",
"missingLinks": "Liens manquants (générés par l'IA)",
"bridging": "Relie",
"dismiss": "Rejeter la suggestion",
"noSuggestions": "Aucune suggestion de connexion",
"allConnected": "Tous vos groupes sont peut-être déjà connectés !",
"generate": "Générer des suggestions"
},
"chart": {
"code": "Code du graphique",
"invalid": "Graphique invalide",
"invalidDescription": "Ce bloc de graphique contient des données invalides ou vides.",
"editSource": "Modifier le code source",
"suggestions": "Suggestions de graphiques",
"analyzing": "Analyse de votre contenu pour des données graphiques…",
"debugInfo": "Infos de débogage",
"escCancel": "Échap Annuler",
"insertFailed": "Échec de l'insertion du graphique : ",
"noDataDetected": "Aucune donnée détectée",
"noDataHint": "Essayez d'ajouter des données numériques à votre note. Les graphiques fonctionnent mieux avec :",
"dataHint1": "• Chiffres de vente, métriques ou mesures",
"dataHint2": "• Listes avec valeurs (ex. « Jan : 5000, Fév : 7500 »)",
"dataHint3": "• Pourcentages ou proportions",
"dataHint4": "• Données temporelles",
"debugShowAnalyzed": "Débogage : voir ce qui a été analysé",
"navigateHint": "← → Naviguer",
"selectHint": "↵ Sélectionner",
"dataPoints": "points de données",
"analyzingSelection": "sélection",
"analyzingNote": "note",
"words": "mots",
"found": "Trouvé"
},
"byok": {
"loadError": "Erreur de chargement",
"updated": "{{name}} mis à jour ✓",
"testHint": "Pour tester, entrez la clé API…",
"aliasPlaceholder": "ex. Ma clé pro",
"choose": "Choisir…",
"keyPlaceholder": "sk-… (optionnel)",
"keyValid": "Clé API valide ✓",
"verifyError": "Erreur de vérification",
"keySaved": "Clé {{name}} enregistrée ✓",
"keyDeleted": "Clé supprimée",
"noActiveKey": "Aucune clé active — utilisation des quotas Memento",
"savedKeys": "Clés enregistrées",
"chooseProvider": "Choisir un fournisseur…",
"apiKey": "Clé API",
"fetchingModels": "Récupération des modèles…",
"chooseModel": "Choisir un modèle…",
"editLabel": "Modifier — {{name}}",
"aliasLabel": "Alias",
"apiUrl": "URL de l'API",
"model": "Modèle",
"newKey": "Nouvelle clé API",
"newKeyHint": "(laisser vide pour conserver l'actuelle)",
"operational": "✓ Opérationnel",
"test": "Tester",
"saveChanges": "Enregistrer les modifications",
"contactError": "Impossible de contacter le serveur",
"baseUrlRequired": "Veuillez renseigner l'URL de l'API",
"keyInvalid": "Clé API invalide",
"byokActive": "BYOK actif",
"activeStatus": "● Actif",
"inactiveStatus": "○ Inactif",
"confirmDelete": "Supprimer la clé {{name}} ?",
"addOrReplace": "Ajouter / remplacer une clé",
"connectProvider": "Connecter votre fournisseur IA",
"optional": "(optionnel)",
"verify": "Vérifier",
"modelOperational": "Modèle opérationnel ✓",
"reply": "Réponse :",
"testFailed": "Échec du test",
"save": "Enregistrer",
"saveWithName": "Enregistrer — {{name}}"
},
"blockPicker": {
"searchPlaceholder": "Rechercher un extrait de note…"
},
"errors": {
"network": "Erreur réseau"
},
"pdf": {
"exportError": "Erreur export PDF"
},
"report": {
"explainProblem": "Expliquez le problème…"
},
"publish": {
"defaultTitle": "Note publiée",
"tagline": "Votre mémoire augmentée par l'IA",
"aiSheet": "Fiche générée par IA",
"publishedOn": "Publié sur"
} }
} }

View File

@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "AgentSuggestion" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"topic" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"suggestedRole" TEXT NOT NULL,
"suggestedType" TEXT NOT NULL DEFAULT 'researcher',
"suggestedFrequency" TEXT NOT NULL DEFAULT 'weekly',
"relatedNoteIds" TEXT NOT NULL,
"clusterId" INTEGER,
"status" TEXT NOT NULL DEFAULT 'pending',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AgentSuggestion_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AgentSuggestion_userId_status_idx" ON "AgentSuggestion"("userId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "AgentSuggestion_userId_clusterId_key" ON "AgentSuggestion"("userId", "clusterId");
-- AddForeignKey
ALTER TABLE "AgentSuggestion" ADD CONSTRAINT "AgentSuggestion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "GmailScanHistory" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"messageId" TEXT NOT NULL,
"category" TEXT NOT NULL,
"data" JSONB,
"noteId" TEXT,
"scannedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GmailScanHistory_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "GmailScanHistory_userId_scannedAt_idx" ON "GmailScanHistory"("userId", "scannedAt");
-- CreateIndex
CREATE UNIQUE INDEX "GmailScanHistory_userId_messageId_key" ON "GmailScanHistory"("userId", "messageId");
-- AddForeignKey
ALTER TABLE "GmailScanHistory" ADD CONSTRAINT "GmailScanHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "UserAISettings" ADD COLUMN IF NOT EXISTS "dashboardLayout" JSONB;

View File

@@ -30,6 +30,7 @@ model User {
onboardingStep Int @default(0) onboardingStep Int @default(0)
accounts Account[] accounts Account[]
agents Agent[] agents Agent[]
agentSuggestions AgentSuggestion[]
aiFeedback AiFeedback[] aiFeedback AiFeedback[]
brainstormActivities BrainstormActivity[] brainstormActivities BrainstormActivity[]
brainstormParticipant BrainstormParticipant[] brainstormParticipant BrainstormParticipant[]
@@ -58,6 +59,7 @@ model User {
bridgeSuggestions BridgeSuggestion[] bridgeSuggestions BridgeSuggestion[]
flashcardDecks FlashcardDeck[] flashcardDecks FlashcardDeck[]
errorLogs ErrorLog[] errorLogs ErrorLog[]
gmailScanHistory GmailScanHistory[]
} }
model Account { model Account {
@@ -370,6 +372,7 @@ model UserAISettings {
autoSave Boolean @default(true) autoSave Boolean @default(true)
aiProcessingConsent Boolean @default(false) aiProcessingConsent Boolean @default(false)
svgComplexity String @default("simple") svgComplexity String @default("simple")
dashboardLayout Json?
integrationTokens Json? // Stores third-party integration tokens (Readwise, Calendar, etc.) integrationTokens Json? // Stores third-party integration tokens (Readwise, Calendar, etc.)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -480,6 +483,39 @@ model AgentAction {
@@index([agentId]) @@index([agentId])
} }
model AgentSuggestion {
id String @id @default(cuid())
userId String
topic String
reason String
suggestedRole String @db.Text
suggestedType String @default("researcher")
suggestedFrequency String @default("weekly")
relatedNoteIds String // JSON string[]
clusterId Int?
status String @default("pending") // pending | accepted | dismissed
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status])
@@unique([userId, clusterId])
}
model GmailScanHistory {
id String @id @default(cuid())
userId String
messageId String
category String // flight | package | subscription
data Json?
noteId String?
scannedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, messageId])
@@index([userId, scannedAt])
}
model Conversation { model Conversation {
id String @id @default(cuid()) id String @id @default(cuid())
title String? title String?