From 30da592ba2f1ae059a06ae0fc6a45a4befed26d3 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Tue, 14 Jul 2026 16:50:53 +0000 Subject: [PATCH] feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/(main)/settings/integrations/page.tsx | 198 +++- memento-note/app/actions/notes.ts | 21 + .../agents/suggestions/[id]/accept/route.ts | 21 + .../agents/suggestions/[id]/dismiss/route.ts | 21 + .../app/api/agents/suggestions/route.ts | 30 + memento-note/app/api/briefing/paths/route.ts | 204 ++++ memento-note/app/api/briefing/route.ts | 240 ++++ .../app/api/briefing/sentiment/route.ts | 87 ++ memento-note/app/api/clusters/route.ts | 46 + .../app/api/cron/agent-suggestions/route.ts | 41 + memento-note/app/api/cron/gmail/route.ts | 49 + .../app/api/dashboard/layout/route.ts | 49 + .../api/integrations/gmail/callback/route.ts | 38 + .../app/api/integrations/gmail/route.ts | 67 ++ .../components/ai/byok-settings-panel.tsx | 112 +- memento-note/components/block-picker.tsx | 4 +- .../components/bridge-notes-dashboard.tsx | 26 +- .../components/chart-suggestions-dialog.tsx | 40 +- .../components/cluster-visualization.tsx | 18 +- .../components/dashboard-action-strip.tsx | 126 ++ .../components/dashboard-agent-carousel.tsx | 158 +++ .../components/dashboard-catalog-widgets.tsx | 371 ++++++ .../components/dashboard-mind-orbit.tsx | 139 +++ .../components/dashboard-next-paths.tsx | 165 +++ .../components/dashboard-path-widgets.tsx | 258 ++++ .../components/dashboard-resume-hero.tsx | 123 ++ .../components/dashboard-sentiment-chip.tsx | 113 ++ memento-note/components/dashboard-view.tsx | 1052 +++++++++++++++++ .../components/dashboard-widget-grid.tsx | 402 +++++++ .../components/dashboard-widget-help.tsx | 57 + .../components/dashboard-widget-title-row.tsx | 37 + memento-note/components/home-client.tsx | 24 +- memento-note/components/intelligence-hub.tsx | 729 ++++++++++++ memento-note/components/lab/canvas-board.tsx | 32 +- .../components/lab/slides-renderer.tsx | 21 +- .../components/mobile-action-sheet.tsx | 30 +- .../components/mobile-editor-toolbar.tsx | 20 +- memento-note/components/note-graph-view.tsx | 2 +- memento-note/components/notes-list-views.tsx | 2 +- .../components/organize-notebook-dialog.tsx | 4 +- memento-note/components/personas-panel.tsx | 4 +- memento-note/components/providers-wrapper.tsx | 12 +- memento-note/components/sidebar.tsx | 113 +- .../components/tiptap-chart-extension.tsx | 10 +- memento-note/context/notebooks-context.tsx | 8 +- memento-note/docker-entrypoint.sh | 51 +- memento-note/lib/ai/memory-echo-i18n.ts | 116 ++ .../ai/services/agent-suggestion.service.ts | 216 ++++ .../lib/ai/services/memory-echo.service.ts | 53 +- memento-note/lib/dashboard/home-route.ts | 17 + memento-note/lib/dashboard/layout.ts | 297 +++++ memento-note/lib/dashboard/path-types.ts | 36 + memento-note/lib/dashboard/paths-fast.ts | 156 +++ memento-note/lib/dashboard/paths.service.ts | 297 +++++ .../lib/integrations/gmail-scanner.service.ts | 204 ++++ .../integrations/google-integration-tokens.ts | 66 ++ memento-note/locales/en.json | 575 ++++++++- memento-note/locales/fr.json | 583 ++++++++- .../migration.sql | 26 + .../migration.sql | 21 + .../migration.sql | 2 + memento-note/prisma/schema.prisma | 36 + 62 files changed, 7741 insertions(+), 335 deletions(-) create mode 100644 memento-note/app/api/agents/suggestions/[id]/accept/route.ts create mode 100644 memento-note/app/api/agents/suggestions/[id]/dismiss/route.ts create mode 100644 memento-note/app/api/agents/suggestions/route.ts create mode 100644 memento-note/app/api/briefing/paths/route.ts create mode 100644 memento-note/app/api/briefing/route.ts create mode 100644 memento-note/app/api/briefing/sentiment/route.ts create mode 100644 memento-note/app/api/cron/agent-suggestions/route.ts create mode 100644 memento-note/app/api/cron/gmail/route.ts create mode 100644 memento-note/app/api/dashboard/layout/route.ts create mode 100644 memento-note/app/api/integrations/gmail/callback/route.ts create mode 100644 memento-note/app/api/integrations/gmail/route.ts create mode 100644 memento-note/components/dashboard-action-strip.tsx create mode 100644 memento-note/components/dashboard-agent-carousel.tsx create mode 100644 memento-note/components/dashboard-catalog-widgets.tsx create mode 100644 memento-note/components/dashboard-mind-orbit.tsx create mode 100644 memento-note/components/dashboard-next-paths.tsx create mode 100644 memento-note/components/dashboard-path-widgets.tsx create mode 100644 memento-note/components/dashboard-resume-hero.tsx create mode 100644 memento-note/components/dashboard-sentiment-chip.tsx create mode 100644 memento-note/components/dashboard-view.tsx create mode 100644 memento-note/components/dashboard-widget-grid.tsx create mode 100644 memento-note/components/dashboard-widget-help.tsx create mode 100644 memento-note/components/dashboard-widget-title-row.tsx create mode 100644 memento-note/components/intelligence-hub.tsx create mode 100644 memento-note/lib/ai/memory-echo-i18n.ts create mode 100644 memento-note/lib/ai/services/agent-suggestion.service.ts create mode 100644 memento-note/lib/dashboard/home-route.ts create mode 100644 memento-note/lib/dashboard/layout.ts create mode 100644 memento-note/lib/dashboard/path-types.ts create mode 100644 memento-note/lib/dashboard/paths-fast.ts create mode 100644 memento-note/lib/dashboard/paths.service.ts create mode 100644 memento-note/lib/integrations/gmail-scanner.service.ts create mode 100644 memento-note/lib/integrations/google-integration-tokens.ts create mode 100644 memento-note/prisma/migrations/20260714160000_add_agent_suggestions/migration.sql create mode 100644 memento-note/prisma/migrations/20260714170000_add_gmail_scan_history/migration.sql create mode 100644 memento-note/prisma/migrations/20260714180000_add_dashboard_layout/migration.sql diff --git a/memento-note/app/(main)/settings/integrations/page.tsx b/memento-note/app/(main)/settings/integrations/page.tsx index bdb38f9..326334c 100644 --- a/memento-note/app/(main)/settings/integrations/page.tsx +++ b/memento-note/app/(main)/settings/integrations/page.tsx @@ -1,11 +1,13 @@ 'use client' 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 { SettingsHelpBox } from '@/components/settings/settings-help-box' +import { useLanguage } from '@/lib/i18n' export default function IntegrationsPage() { + const { t } = useLanguage() // ── Readwise ─────────────────────────────────────────────────────────── const [rwToken, setRwToken] = useState('') const [rwConnected, setRwConnected] = useState(false) @@ -19,32 +21,47 @@ export default function IntegrationsPage() { const [calEvents, setCalEvents] = useState([]) 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) useEffect(() => { Promise.all([ fetch('/api/integrations/readwise').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) setCalConnected(cal.connected) + setGmailConnected(gmail.connected) + setGmailRecent(gmail.recentCaptures ?? 0) }).finally(() => { setLoading(false) setCalLoading(false) + setGmailLoading(false) }) // Handle redirect params const params = new URLSearchParams(window.location.search) if (params.get('connected') === 'calendar') { 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') } if (params.get('error')) { - toast.error(`Erreur: ${params.get('error')}`) + toast.error(t('integrations.errorWith', { error: params.get('error') || '' })) window.history.replaceState({}, '', '/settings/integrations') } - }, []) + }, [t]) // ── Readwise handlers ────────────────────────────────────────────────── const handleRwConnect = async () => { @@ -57,12 +74,12 @@ export default function IntegrationsPage() { body: JSON.stringify({ token: rwToken.trim() }), }) 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) setRwToken('') setRwLastSync({ created: data.created, updated: data.updated }) - toast.success(`Readwise connecté — ${data.created} notes créées, ${data.updated} mises à jour`) - } catch { toast.error('Erreur de connexion Readwise') } finally { setRwConnecting(false) } + toast.success(t('integrations.readwiseConnected', { created: data.created, updated: data.updated })) + } catch { toast.error(t('integrations.readwiseConnectError')) } finally { setRwConnecting(false) } } const handleRwSync = async () => { @@ -74,16 +91,16 @@ export default function IntegrationsPage() { body: JSON.stringify({}), }) 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 }) - toast.success(`Sync Readwise — ${data.created} créées, ${data.updated} mises à jour`) - } catch { toast.error('Erreur de synchronisation') } finally { setRwSyncing(false) } + toast.success(t('integrations.readwiseSynced', { created: data.created, updated: data.updated })) + } catch { toast.error(t('integrations.syncFailed')) } finally { setRwSyncing(false) } } const handleRwDisconnect = async () => { await fetch('/api/integrations/readwise', { method: 'DELETE' }) setRwConnected(false) - toast.success('Readwise déconnecté') + toast.success(t('integrations.readwiseDisconnected')) } // ── Calendar handlers ────────────────────────────────────────────────── @@ -96,10 +113,10 @@ export default function IntegrationsPage() { try { const res = await fetch('/api/integrations/calendar?events=1') 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 ?? []) - if (data.events.length === 0) toast.info('Aucun événement aujourd\'hui') - } catch { toast.error('Erreur de chargement des événements') } finally { setCalFetching(false) } + if (data.events.length === 0) toast.info(t('integrations.noEvents')) + } catch { toast.error(t('integrations.eventsLoadError')) } finally { setCalFetching(false) } } const handleCreateMeetingNote = async (event: any) => { @@ -110,11 +127,11 @@ export default function IntegrationsPage() { }) const data = await res.json() if (data.success) { - toast.success(`Note de réunion créée : ${event.summary}`, { - action: { label: 'Ouvrir', onClick: () => window.location.href = `/home?openNote=${data.note.id}` }, + toast.success(t('integrations.meetingNoteCreated', { summary: event.summary }), { + action: { label: t('integrations.open'), onClick: () => window.location.href = `/home?openNote=${data.note.id}` }, }) } 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' }) setCalConnected(false) setCalEvents([]) - toast.success('Google Calendar déconnecté') + 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 }) => connected ? ( - Connecté + {t('integrations.connected')} ) : ( - Non connecté + {t('integrations.notConnected')} ) return (
-

Intégrations

-

Connectez des services externes à Memento.

+

{t('settings.integrationsTitle')}

+

{t('settings.integrationsDesc')}

+
+ + {/* ── Gmail ──────────────────────────────────────────────────────── */} +
+
+
+
+ +
+
+

{t('integrations.gmail.title')}

+

{t('integrations.gmail.description')}

+
+
+ {gmailLoading ? : } +
+ + {!gmailConnected ? ( +
+ + +
+ ) : ( +
+
+ + +
+ {gmailRecent > 0 && ( +

+ {t('integrations.gmail.recentCaptures', { count: gmailRecent })} +

+ )} +
+ )}
{/* ── Google Calendar ────────────────────────────────────────────── */} @@ -152,7 +258,7 @@ export default function IntegrationsPage() {

Google Calendar

-

Accédez à vos événements et créez des notes de réunion

+

{t('integrations.calendarDesc')}

{calLoading ? : } @@ -161,12 +267,12 @@ export default function IntegrationsPage() { {!calConnected ? (
) : ( @@ -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" > {calFetching ? : } - Événements aujourd'hui + {t('integrations.todayEvents')} @@ -211,7 +317,7 @@ export default function IntegrationsPage() { 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" > - + Note + {t('integrations.addNote')} ))} @@ -230,20 +336,20 @@ export default function IntegrationsPage() {

Readwise

-

Importez vos surlignages de livres, articles et Kindle

+

{t('integrations.readwiseDesc')}

{loading ? : } @@ -254,7 +360,7 @@ export default function IntegrationsPage() { type="password" value={rwToken} 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" 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" > {rwConnecting ? : null} - Connecter + {t('integrations.connect')} @@ -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" > {rwSyncing ? : } - Synchroniser maintenant + {t('integrations.syncNow')} )} {rwLastSync && (

- Dernière sync : {rwLastSync.created} notes créées, {rwLastSync.updated} mises à jour + {t('integrations.lastSync')} {rwLastSync.created} {t('integrations.createdLabel')}, {rwLastSync.updated} {t('integrations.updatedLabel')}

)} {/* Placeholder */}
-

D'autres intégrations arrivent bientôt — Zapier, GitHub, Notion import…

+

{t('integrations.moreComing')}

) diff --git a/memento-note/app/actions/notes.ts b/memento-note/app/actions/notes.ts index 76dac81..5e2d743 100644 --- a/memento-note/app/actions/notes.ts +++ b/memento-note/app/actions/notes.ts @@ -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 export async function dismissFromRecent(id: string) { const session = await auth(); diff --git a/memento-note/app/api/agents/suggestions/[id]/accept/route.ts b/memento-note/app/api/agents/suggestions/[id]/accept/route.ts new file mode 100644 index 0000000..2b0dee4 --- /dev/null +++ b/memento-note/app/api/agents/suggestions/[id]/accept/route.ts @@ -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 }) +} diff --git a/memento-note/app/api/agents/suggestions/[id]/dismiss/route.ts b/memento-note/app/api/agents/suggestions/[id]/dismiss/route.ts new file mode 100644 index 0000000..890a4fb --- /dev/null +++ b/memento-note/app/api/agents/suggestions/[id]/dismiss/route.ts @@ -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 }) +} diff --git a/memento-note/app/api/agents/suggestions/route.ts b/memento-note/app/api/agents/suggestions/route.ts new file mode 100644 index 0000000..583cd8e --- /dev/null +++ b/memento-note/app/api/agents/suggestions/route.ts @@ -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(), + })), + }) +} diff --git a/memento-note/app/api/briefing/paths/route.ts b/memento-note/app/api/briefing/paths/route.ts new file mode 100644 index 0000000..026b27b --- /dev/null +++ b/memento-note/app/api/briefing/paths/route.ts @@ -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(/ /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) +} diff --git a/memento-note/app/api/briefing/route.ts b/memento-note/app/api/briefing/route.ts new file mode 100644 index 0000000..5936087 --- /dev/null +++ b/memento-note/app/api/briefing/route.ts @@ -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(/ /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>` + 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, + })), + }) +} diff --git a/memento-note/app/api/briefing/sentiment/route.ts b/memento-note/app/api/briefing/sentiment/route.ts new file mode 100644 index 0000000..60751be --- /dev/null +++ b/memento-note/app/api/briefing/sentiment/route.ts @@ -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(/ /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' }) + } +} diff --git a/memento-note/app/api/clusters/route.ts b/memento-note/app/api/clusters/route.ts index 137988a..b272798 100644 --- a/memento-note/app/api/clusters/route.ts +++ b/memento-note/app/api/clusters/route.ts @@ -20,8 +20,54 @@ export async function GET(request: NextRequest) { // Check for stored results (even if stale/périmés) const stored = await clusteringService.getStoredClusters(userId) + const lite = request.nextUrl.searchParams.get('lite') === '1' + if (stored) { 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 const notes = await prisma.note.findMany({ where: { userId, trashedAt: null }, diff --git a/memento-note/app/api/cron/agent-suggestions/route.ts b/memento-note/app/api/cron/agent-suggestions/route.ts new file mode 100644 index 0000000..903291e --- /dev/null +++ b/memento-note/app/api/cron/agent-suggestions/route.ts @@ -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 }) +} diff --git a/memento-note/app/api/cron/gmail/route.ts b/memento-note/app/api/cron/gmail/route.ts new file mode 100644 index 0000000..6569c97 --- /dev/null +++ b/memento-note/app/api/cron/gmail/route.ts @@ -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 }) +} diff --git a/memento-note/app/api/dashboard/layout/route.ts b/memento-note/app/api/dashboard/layout/route.ts new file mode 100644 index 0000000..d606db6 --- /dev/null +++ b/memento-note/app/api/dashboard/layout/route.ts @@ -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 }) +} diff --git a/memento-note/app/api/integrations/gmail/callback/route.ts b/memento-note/app/api/integrations/gmail/callback/route.ts new file mode 100644 index 0000000..7499796 --- /dev/null +++ b/memento-note/app/api/integrations/gmail/callback/route.ts @@ -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`) +} diff --git a/memento-note/app/api/integrations/gmail/route.ts b/memento-note/app/api/integrations/gmail/route.ts new file mode 100644 index 0000000..bbb0c34 --- /dev/null +++ b/memento-note/app/api/integrations/gmail/route.ts @@ -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 }) +} diff --git a/memento-note/components/ai/byok-settings-panel.tsx b/memento-note/components/ai/byok-settings-panel.tsx index abe91f4..9351341 100644 --- a/memento-note/components/ai/byok-settings-panel.tsx +++ b/memento-note/components/ai/byok-settings-panel.tsx @@ -24,14 +24,14 @@ const PROVIDER_INFO: Record = { minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' }, google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' }, deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' }, - openrouter: { name: 'OpenRouter', hint: 'Accès multi-fournisseurs' }, - mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' }, + openrouter: { name: 'OpenRouter', hint: 'Multi-provider access' }, + mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' }, glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' }, - zai: { name: 'Zuki Journey', hint: 'Proxy OpenAI/Anthropic' }, - anthropic_custom: { name: 'Anthropic (custom)', hint: 'Proxy compatible Anthropic' }, - custom_openai: { name: 'Compatible OpenAI', hint: 'Tout proxy compatible OpenAI' }, - custom_anthropic: { name: 'Compatible Anthropic', hint: 'Tout proxy compatible Anthropic' }, - custom: { name: 'API personnalisée', hint: 'Votre propre endpoint' }, + zai: { name: 'Zuki Journey', hint: 'OpenAI/Anthropic proxy' }, + anthropic_custom: { name: 'Anthropic (custom)', hint: 'Anthropic-compatible proxy' }, + custom_openai: { name: 'Compatible OpenAI', hint: 'Any OpenAI-compatible proxy' }, + custom_anthropic: { name: 'Compatible Anthropic', hint: 'Any Anthropic-compatible proxy' }, + custom: { name: 'Custom API', hint: 'Your own endpoint' }, } function displayName(provider: string): string { @@ -59,7 +59,7 @@ type SavedKey = { async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> { const res = await fetch('/api/user/api-keys') - if (!res.ok) throw new Error('Erreur de chargement') + if (!res.ok) throw new Error('Failed to load') return res.json() } @@ -92,6 +92,8 @@ function EditKeyForm({ const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null) + const { t } = useLanguage() + const needsUrl = NEEDS_BASE_URL.has(savedKey.provider) const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider) @@ -118,10 +120,10 @@ function EditKeyForm({ body: JSON.stringify(payload), }) const body = await res.json().catch(() => ({})) - if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec') + if (!res.ok) throw new Error(body.message ?? body.error ?? t('common.error')) }, onSuccess: () => { - toast.success(`${displayName(savedKey.provider)} mis à jour ✓`) + toast.success(t('byok.updated', { name: displayName(savedKey.provider) })) onInvalidate() onDone() }, @@ -132,7 +134,7 @@ function EditKeyForm({ const keyToUse = newKey.length >= 8 ? newKey : '' if (!keyToUse && !model) return if (!keyToUse) { - toast.info('Pour tester, entrez la clé API dans le champ "Nouvelle clé"') + toast.info(t('byok.testHint')) return } setTesting(true) @@ -143,7 +145,7 @@ function EditKeyForm({ const res = await fetch(`/api/user/api-keys/test-model?${q}`) setTestResult(await res.json()) } catch { - setTestResult({ ok: false, error: 'Impossible de contacter le serveur' }) + setTestResult({ ok: false, error: t('byok.contactError') }) } finally { setTesting(false) } @@ -155,18 +157,18 @@ function EditKeyForm({ return (

- Modifier — {displayName(savedKey.provider)} + {t('byok.editLabel', { name: displayName(savedKey.provider) })}

{/* Alias */}
- - setAlias(e.target.value)} placeholder="ex. Ma clé pro" /> + + setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} />
{needsUrl && (
- + setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
)} @@ -174,21 +176,21 @@ function EditKeyForm({ {/* Model */}
- + {showModelDropdown ? ( ) : showModelInput ? ( setModel(e.target.value)} placeholder="ex. MiniMax-M2.7" /> - ) :
Chargement...
} + ) :
{t('common.loading')}
}
{/* Key rotation (optional) */}
{ setNewKey(e.target.value); setTestResult(null) }} - placeholder="sk-... (optionnel)" + placeholder={t('byok.keyPlaceholder')} />
@@ -209,8 +211,8 @@ function EditKeyForm({ {testResult.ok ? : } {testResult.ok - ? `✓ Opérationnel${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}` - : testResult.error ?? 'Échec' + ? `${t('byok.operational')}${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}` + : testResult.error ?? t('common.error') }
@@ -225,7 +227,7 @@ function EditKeyForm({ className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-violet-400 transition-colors disabled:opacity-40 disabled:pointer-events-none" > {testing ? : } - Tester + {t('byok.test')}
)} @@ -431,7 +433,7 @@ export function ByokSettingsPanel() { {/* Saved keys */} {keys.length > 0 && (
-

Clés enregistrées

+

{t('byok.savedKeys')}

    {keys.map((key) => (
  • @@ -448,7 +450,7 @@ export function ByokSettingsPanel() { {key.model && {key.model}} {key.alias && {key.alias}} - {key.isActive ? '● Actif' : '○ Inactif'} + {key.isActive ? t('byok.activeStatus') : t('byok.inactiveStatus')}
@@ -481,7 +483,7 @@ export function ByokSettingsPanel() { type="button" className="h-7 w-7 rounded-lg flex items-center justify-center text-destructive/50 hover:text-destructive hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors" disabled={deleteMutation.isPending} - onClick={() => { if (confirm(`Supprimer la clé ${displayName(key.provider)} ?`)) deleteMutation.mutate(key.provider) }} + onClick={() => { if (confirm(t('byok.confirmDelete', { name: displayName(key.provider) }))) deleteMutation.mutate(key.provider) }} > @@ -507,14 +509,14 @@ export function ByokSettingsPanel() { {/* Add key form */}

- {keys.length > 0 ? 'Ajouter / remplacer une clé' : 'Connecter votre fournisseur IA'} + {keys.length > 0 ? t('byok.addOrReplace') : t('byok.connectProvider')}

- +
- - setAlias(e.target.value)} placeholder="ex. Ma clé pro" disabled={saveMutation.isPending} /> + + setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} disabled={saveMutation.isPending} />
{NEEDS_BASE_URL.has(provider) && (
- + setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
)}
- +
- {verifying ? : keyOk ? : 'Vérifier'} + {verifying ? : keyOk ? : t('byok.verify')}
{provider && (
- + {loadingModels ? ( -
Récupération des modèles...
+
{t('byok.fetchingModels')}
) : showModelDropdown ? ( ) : showModelInput ? ( @@ -582,8 +584,8 @@ export function ByokSettingsPanel() { {testResult.ok ? : }
{testResult.ok ? ( - <>

Modèle opérationnel ✓{testResult.latency && ({testResult.latency}ms)}

{testResult.reply &&

Réponse : {testResult.reply}

} - ) :

{testResult.error ?? 'Échec du test'}

} + <>

{t('byok.modelOperational')}{testResult.latency && ({testResult.latency}ms)}

{testResult.reply &&

{t('byok.reply')} {testResult.reply}

} + ) :

{testResult.error ?? t('byok.testFailed')}

}
)} @@ -593,7 +595,7 @@ export function ByokSettingsPanel() { type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel} className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-violet-400 disabled:opacity-40 disabled:pointer-events-none" > - {testing ? : }Tester + {testing ? : }{t('byok.test')}
diff --git a/memento-note/components/block-picker.tsx b/memento-note/components/block-picker.tsx index bfac8ac..4d41660 100644 --- a/memento-note/components/block-picker.tsx +++ b/memento-note/components/block-picker.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useMemo } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { Search, Sparkles, Link2, X, Folder } from 'lucide-react' +import { useLanguage } from '@/lib/i18n' export interface BlockSuggestion { blockId: string @@ -22,6 +23,7 @@ interface BlockPickerProps { } export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: BlockPickerProps) { + const { t } = useLanguage() const [activeTab, setActiveTab] = useState<'suggestions' | 'search'>('suggestions') const [searchQuery, setSearchQuery] = useState('') const [suggestions, setSuggestions] = useState([]) @@ -134,7 +136,7 @@ export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: B type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} - placeholder="Rechercher un extrait de note..." + placeholder={t('blockPicker.searchPlaceholder')} className="w-full bg-white dark:bg-zinc-850 border border-[#D5D2CD] dark:border-neutral-800 rounded-xl pl-9 pr-4 py-2 text-xs outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-all font-sans" autoFocus /> diff --git a/memento-note/components/bridge-notes-dashboard.tsx b/memento-note/components/bridge-notes-dashboard.tsx index 9c6358f..d21d45e 100644 --- a/memento-note/components/bridge-notes-dashboard.tsx +++ b/memento-note/components/bridge-notes-dashboard.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect } from 'react' +import { useLanguage } from '@/lib/i18n' import { motion } from 'motion/react' import { Zap, Lightbulb, Trophy } from 'lucide-react' @@ -32,6 +33,7 @@ interface BridgeNotesDashboardProps { } export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashboardProps) { + const { t } = useLanguage() const [bridgeNotes, setBridgeNotes] = useState([]) const [suggestions, setSuggestions] = useState([]) const [loading, setLoading] = useState(true) @@ -109,14 +111,14 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
- Bridges + {t('bridgeNotes.bridges')}
{bridgeNotes.length}
- Suggestions + {t('bridgeNotes.suggestions')}
{suggestions.length}
@@ -126,7 +128,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
-

Powerful Bridge Notes

+

{t('bridgeNotes.powerfulTitle')}

{bridgeNotes.map((bridge) => ( @@ -138,10 +140,10 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb >

- {bridge.note?.title || 'Untitled'} + {bridge.note?.title || t('common.untitled')}

- Score: {(bridge.bridgeScore * 100).toFixed(0)}% + {t('bridgeNotes.score')}: {(bridge.bridgeScore * 100).toFixed(0)}%
@@ -158,7 +160,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb ))} {bridgeNotes.length === 0 && ( -
No significant bridge notes found yet. Deepen your research to find new connections.
+
{t('bridgeNotes.empty')}
)}
@@ -167,7 +169,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
-

Missing Links (AI Generated)

+

{t('bridgeNotes.missingLinks')}

{suggestions.map((s, idx) => ( @@ -180,7 +182,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
B
- Bridging {s.clusterAName} & {s.clusterBName} + {t('bridgeNotes.bridging')} {s.clusterAName} & {s.clusterBName}

{s.suggestedTitle}

@@ -193,7 +195,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb @@ -203,13 +205,13 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb {suggestions.length === 0 && !loading && (
-

No connection suggestions yet

-

All your clusters may already be connected!

+

{t('bridgeNotes.noSuggestions')}

+

{t('bridgeNotes.allConnected')}

)} diff --git a/memento-note/components/chart-suggestions-dialog.tsx b/memento-note/components/chart-suggestions-dialog.tsx index bcec087..d55e3b7 100644 --- a/memento-note/components/chart-suggestions-dialog.tsx +++ b/memento-note/components/chart-suggestions-dialog.tsx @@ -6,6 +6,7 @@ import { NoteChart } from './note-chart' import { suggestCharts, chartSuggestionToMarkdown, type ChartSuggestion, type SuggestChartsResponse } from '@/lib/ai/services/chart-suggestion.service' import { BarChart3, X, Search, AlertCircle, Sparkles } from 'lucide-react' import { cn } from '@/lib/utils' +import { useLanguage } from '@/lib/i18n' // Chart type to color mapping for visual variety const CHART_TYPE_COLORS: Record = { @@ -36,6 +37,7 @@ export function ChartSuggestionsDialog({ onClose, onSelectChart, }: ChartSuggestionsDialogProps) { + const { t } = useLanguage() const [response, setResponse] = useState(null) const [loading, setLoading] = useState(false) const [selectedIndex, setSelectedIndex] = useState(null) @@ -127,12 +129,12 @@ export function ChartSuggestionsDialog({ {loading ? ( - Analyzing {isAnalyzingSelection ? 'selection' : 'note'} ({wordCount} words)... + {isAnalyzingSelection ? t('chart.analyzingSelection') : t('chart.analyzingNote')} ({wordCount} {t('chart.words')})... ) : response?.hasData ? ( - `Found ${response?.detectedData || 'data'}` + `${t('chart.found')} ${response?.detectedData || ''}` ) : ( - 'No data detected' + t('chart.noDataDetected') )}

@@ -140,7 +142,7 @@ export function ChartSuggestionsDialog({ @@ -152,7 +154,7 @@ export function ChartSuggestionsDialog({
-

Analyzing your content for chart data...

+

{t('chart.analyzing')}

) : response?.quotaExceeded ? ( @@ -167,7 +169,7 @@ export function ChartSuggestionsDialog({ className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors" onClick={() => (window.location.href = '/settings/billing')} > - Upgrade Plan + {t('ai.featureLocked')} @@ -175,10 +177,10 @@ export function ChartSuggestionsDialog({
-

Error

+

{t('common.error')}

{response.error}

- Debug info + {t('chart.debugInfo')}
                     analyzedText: {response.analyzedText}
                     {'\n'}detectedData: {response.detectedData}
@@ -190,20 +192,20 @@ export function ChartSuggestionsDialog({
             
-

No Data Detected

+

{t('chart.noDataDetected')}

- Try adding numerical data to your note. Charts work best with: + {t('chart.noDataHint')}

    -
  • • Sales figures, metrics, or measurements
  • -
  • • Lists with values (e.g., "Jan: 5000, Feb: 7500")
  • -
  • • Percentages or proportions
  • -
  • • Time-series data
  • +
  • {t('chart.dataHint1')}
  • +
  • {t('chart.dataHint2')}
  • +
  • {t('chart.dataHint3')}
  • +
  • {t('chart.dataHint4')}
- Debug: Show what was analyzed + {t('chart.debugShowAnalyzed')}
                       {textToAnalyze.substring(0, 500)}
@@ -276,7 +278,7 @@ export function ChartSuggestionsDialog({
                       {/* Data preview */}
                       

- {suggestion.data.length} data points + {suggestion.data.length} {t('chart.dataPoints')}

@@ -286,9 +288,9 @@ export function ChartSuggestionsDialog({ {/* Keyboard hint */}
- ← → Navigate - ↵ Select - Esc Cancel + {t('chart.navigateHint')} + {t('chart.selectHint')} + {t('chart.escCancel')}
)} diff --git a/memento-note/components/cluster-visualization.tsx b/memento-note/components/cluster-visualization.tsx index 3959bd0..af06b9f 100644 --- a/memento-note/components/cluster-visualization.tsx +++ b/memento-note/components/cluster-visualization.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useState, useCallback } from 'react' +import { useLanguage } from '@/lib/i18n' import ReactFlow, { Node, Edge, @@ -47,6 +48,7 @@ export function ClusterVisualization({ bridgeNotes, onNodeClick }: ClusterVisualizationProps) { + const { t } = useLanguage() const [nodes, setNodes, onNodesChange] = useNodesState([]) const [edges, setEdges, onEdgesChange] = useEdgesState([]) const [selectedCluster, setSelectedCluster] = useState(null) @@ -79,8 +81,8 @@ export function ClusterVisualization({ data: { label: (
- {cluster.name || `Cluster ${cluster.clusterId}`} - ({cluster.noteIds.length} notes) + {cluster.name || t('clusters.clusterLabel', { id: cluster.clusterId })} + ({cluster.noteIds.length} {t('clusters.notes')})
) }, @@ -174,8 +176,8 @@ export function ClusterVisualization({ -

No clusters to display

-

Create more notes to generate clusters

+

{t('clusters.empty')}

+

{t('clusters.emptyHint')}

) @@ -200,10 +202,10 @@ export function ClusterVisualization({ {selectedCluster !== null && (

- {clusters.find(c => c.clusterId === selectedCluster)?.name || `Cluster ${selectedCluster}`} + {clusters.find(c => c.clusterId === selectedCluster)?.name || t('clusters.clusterLabel', { id: selectedCluster })}

- {clusters.find(c => c.clusterId === selectedCluster)?.noteIds.length || 0} notes + {clusters.find(c => c.clusterId === selectedCluster)?.noteIds.length || 0} {t('clusters.notes')}

)} @@ -212,11 +214,11 @@ export function ClusterVisualization({
- Bridge note + {t('clusters.bridgeNote')}
- Regular note + {t('clusters.regularNote')}
diff --git a/memento-note/components/dashboard-action-strip.tsx b/memento-note/components/dashboard-action-strip.tsx new file mode 100644 index 0000000..82a405b --- /dev/null +++ b/memento-note/components/dashboard-action-strip.tsx @@ -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 ( +
+ {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 ( + +
+ +
+
+

+ {item.value} +

+

+ {item.label} +

+
+
+ ) + })} +
+ ) +} diff --git a/memento-note/components/dashboard-agent-carousel.tsx b/memento-note/components/dashboard-agent-carousel.tsx new file mode 100644 index 0000000..3621084 --- /dev/null +++ b/memento-note/components/dashboard-agent-carousel.tsx @@ -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 ? ( +
+ + + {idx + 1}/{suggestions.length} + + +
+ ) : null + + if (loading) { + return
+ } + + return ( +
+ } + title={t('homeDashboard.suggestedResearch')} + actions={navActions} + /> + + {suggestions.length === 0 ? ( +

+ {t('homeDashboard.agentsEmpty')} +

+ ) : ( + + )} +
+ ) +} + +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 ( + + +
+ +

{current.topic}

+
+

{current.reason}

+

+ {current.relatedNoteCount} {t('homeDashboard.notes')} · {formatFrequency(current.suggestedFrequency)} +

+
+ + +
+
+
+ ) +} diff --git a/memento-note/components/dashboard-catalog-widgets.tsx b/memento-note/components/dashboard-catalog-widgets.tsx new file mode 100644 index 0000000..807b692 --- /dev/null +++ b/memento-note/components/dashboard-catalog-widgets.tsx @@ -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 ( +
+ + {children} +
+ ) +} + +export function DashboardInboxWidget({ + count, + loading, + onOpen, +}: { + count: number + loading: boolean + onOpen: () => void +}) { + const { t } = useLanguage() + return ( + } + title={t('homeDashboard.inbox')} + compact + > + {loading ? ( +
+ ) : ( + + )} + + ) +} + +export function DashboardRevisionWidget({ + dueCount, + loading, + onOpen, +}: { + dueCount: number + loading: boolean + onOpen: () => void +}) { + const { t } = useLanguage() + return ( + } + title={t('homeDashboard.review')} + compact + > + {loading ? ( +
+ ) : ( + + )} + + ) +} + +export function DashboardStatsWidget({ + clusterCount, + bridgeCount, + noteCount, + loading, + onOpen, +}: { + clusterCount: number + bridgeCount: number + noteCount: number + loading: boolean + onOpen: () => void +}) { + const { t } = useLanguage() + return ( + } + title={t('homeDashboard.widgets.stats')} + compact + > + {loading ? ( +
+ {[0, 1, 2].map(i =>
)} +
+ ) : ( + + )} + + ) +} + +export function DashboardAgentActivityWidget({ + actions, + loading, + onOpen, +}: { + actions: { id: string; agentName: string; result: string | null; createdAt: string }[] + loading: boolean + onOpen: () => void +}) { + const { t } = useLanguage() + return ( + } + title={t('homeDashboard.widgets.agent-activity')} + compact + > + {loading ? ( +
+
+
+
+ ) : actions.length === 0 ? ( +

{t('homeDashboard.widgetAgentActivityEmpty')}

+ ) : ( +
+ {actions.slice(0, 3).map(a => ( + + ))} +
+ )} + + ) +} + +export function DashboardGmailWidget({ + connected, + recentCaptures, + loading, + onOpen, +}: { + connected: boolean + recentCaptures: number + loading: boolean + onOpen: () => void +}) { + const { t } = useLanguage() + return ( + } + title={t('homeDashboard.gmailCaptures')} + compact + > + {loading ? ( +
+ ) : connected ? ( + + ) : ( + + )} + + ) +} + +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 ( + } + title={t('homeDashboard.widgets.pinned')} + compact + > + {loading ? ( +
+
+
+ ) : notes.length === 0 ? ( +

{t('homeDashboard.widgetPinnedEmpty')}

+ ) : ( +
+ {notes.map(n => ( + + ))} +
+ )} + + ) +} + +export function DashboardActivityWidget({ + data, + loading, +}: { + data: { date: string; count: number }[] + loading: boolean +}) { + const { t } = useLanguage() + return ( + } + title={t('homeDashboard.widgets.activity')} + > + {loading ? ( +
+ ) : ( + + )} +

{t('homeDashboard.widgetActivityHint')}

+ + ) +} + +export function DashboardUsageWidget() { + const { t } = useLanguage() + return ( + } + title={t('homeDashboard.widgets.usage')} + compact + > + +

{t('homeDashboard.widgetUsageHint')}

+
+ ) +} + +export function DashboardFlashcardsProgressWidget({ + retentionRate, + streak, + totalCards, + loading, + onOpen, +}: { + retentionRate: number + streak: number + totalCards: number + loading?: boolean + onOpen: () => void +}) { + const { t } = useLanguage() + return ( + } + title={t('homeDashboard.widgets.flashcards-progress')} + compact + > + {loading ? ( +
+ ) : ( + + )} + + ) +} diff --git a/memento-note/components/dashboard-mind-orbit.tsx b/memento-note/components/dashboard-mind-orbit.tsx new file mode 100644 index 0000000..4211fe7 --- /dev/null +++ b/memento-note/components/dashboard-mind-orbit.tsx @@ -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
+ } + + if (topClusters.length === 0) { + return ( + + ) + } + + return ( +
+ } + title={t('homeDashboard.mindMap')} + actions={( + + )} + /> + +
+ {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 ( + +
+ {cluster.noteIds.length} +
+ + {label} + +
+ ) + })} +
+ + {topBridge?.note && ( + + )} +
+ ) +} diff --git a/memento-note/components/dashboard-next-paths.tsx b/memento-note/components/dashboard-next-paths.tsx new file mode 100644 index 0000000..2cdf49a --- /dev/null +++ b/memento-note/components/dashboard-next-paths.tsx @@ -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 = { + 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 ( +
+
+ + {focusNoteTitle && ( +

+ {t('homeDashboard.pathsFromNote', { title: focusNoteTitle })} +

+ )} +
+
+ +

+ {t('homeDashboard.pathsLoading')} +

+
+
+ ) + } + + if (paths.length === 0) { + return ( +
+ +

{t('homeDashboard.pathsEmpty')}

+
+ ) + } + + const hero = paths[0] + const rest = paths.slice(1, 5) + const HeroIcon = TYPE_META[hero.type].Icon + + return ( +
+
+ + + {t('homeDashboard.pathsEnriching')} + + ) : undefined} + /> + {focusNoteTitle && ( +

+ {t('homeDashboard.pathsFromNote', { title: focusNoteTitle })} +

+ )} +
+ + 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" + > +
+
+ +
+
+

+ {t(`homeDashboard.pathTypes.${hero.type}`)} + {hero.score ? ` · ${hero.score}%` : ''} +

+

+ {hero.title} +

+

+ {hero.description} +

+
+ + {t(`homeDashboard.pathActions.${hero.actionKey}`)} + + +
+
+ + {rest.length > 0 && ( +
+ {rest.map(path => { + const meta = TYPE_META[path.type] + const Icon = meta.Icon + return ( + + ) + })} +
+ )} +
+ ) +} diff --git a/memento-note/components/dashboard-path-widgets.tsx b/memento-note/components/dashboard-path-widgets.tsx new file mode 100644 index 0000000..faba6d9 --- /dev/null +++ b/memento-note/components/dashboard-path-widgets.tsx @@ -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 ( + } + title={t('homeDashboard.widgets.daily-review')} + compact + > + {loading ? ( +
+ {[0, 1, 2].map(i =>
)} +
+ ) : ( +
    + {items.map(item => ( +
  • + +
  • + ))} +
+ )} +

{t('homeDashboard.dailyReviewHint')}

+ + ) +} + +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 ( + } + title={t('homeDashboard.widgets.open-loops')} + compact + > + {loading ? ( +
+ ) : loops.length === 0 ? ( +

{t('homeDashboard.openLoopsEmpty')}

+ ) : ( +
+ {loops.map(loop => ( + + ))} +
+ )} + + ) +} + +export function DashboardDailyNoteWidget({ + loading, + onOpen, +}: { + loading?: boolean + onOpen: () => void +}) { + const { t } = useLanguage() + + return ( + } + title={t('homeDashboard.widgets.daily-note')} + compact + > + {loading ? ( +
+ ) : ( + + )} + + ) +} + +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 ( + } + title={t('homeDashboard.widgets.link-suggestions')} + > + {loading ? ( +
+
+
+ ) : paths.length === 0 ? ( +

{t('homeDashboard.linkSuggestionsEmpty')}

+ ) : ( +
+ {paths.map(p => ( + + ))} +
+ )} + + ) +} + +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 ( + } + title={t('homeDashboard.widgets.bridges')} + > + {loading ? ( +
+ ) : suggestions.length === 0 ? ( +

{t('homeDashboard.bridgesEmpty')}

+ ) : ( +
+ {suggestions.slice(0, 3).map(s => { + const key = `${s.clusterAId}-${s.clusterBId}` + return ( +
+

{s.suggestedTitle}

+

+ {t('homeDashboard.suggestedBridge', { clusterA: s.clusterAName, clusterB: s.clusterBName })} +

+

{s.justification}

+
+ + +
+
+ ) + })} +
+ )} + + ) +} diff --git a/memento-note/components/dashboard-resume-hero.tsx b/memento-note/components/dashboard-resume-hero.tsx new file mode 100644 index 0000000..c04ef3f --- /dev/null +++ b/memento-note/components/dashboard-resume-hero.tsx @@ -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 ( +
+
+
+
+
+ ) + } + + if (!hero) { + return ( +
+ +

{t('homeDashboard.noRecentNotes')}

+
+ ) + } + + return ( +
+
+ } + title={t('homeDashboard.continue')} + className="mb-2" + /> +
+ + onSelect(hero.id, hero.notebookId)} + className="w-full text-start px-5 pb-5 group cursor-pointer" + > +
+
+ + {hero.notebookName} + + + {formatRelativeTime(hero.updatedAt)} + +
+

+ {hero.title || t('homeDashboard.untitled')} +

+ {hero.excerpt && ( +

+ {hero.excerpt} +

+ )} +
+ {t('homeDashboard.resumeOpen')} + +
+
+
+ + {rest.length > 0 && ( +
+ {rest.map(note => ( + + ))} +
+ )} +
+ ) +} diff --git a/memento-note/components/dashboard-sentiment-chip.tsx b/memento-note/components/dashboard-sentiment-chip.tsx new file mode 100644 index 0000000..eb414b4 --- /dev/null +++ b/memento-note/components/dashboard-sentiment-chip.tsx @@ -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 + emotionMeta: Record +} + +export function DashboardSentimentChip({ + available, + loading, + dominantEmotion, + summary, + emotions, + emotionMeta, +}: DashboardSentimentChipProps) { + const { t } = useLanguage() + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+ } + title={t('homeDashboard.sentiment')} + /> + + {!available || !dominantEmotion ? ( +

+ {t('homeDashboard.notEnoughNotes')} +

+ ) : ( +
+
+ {(() => { + const meta = emotionMeta[dominantEmotion] || emotionMeta.reflective + const Icon = meta?.Icon + return ( +
+ {Icon && } +
+ ) + })()} +
+

+ {t(`homeDashboard.emotions.${dominantEmotion}`)} +

+

+ {t('homeDashboard.sentimentDominant')} +

+
+
+ + {summary && ( +

+ {summary} +

+ )} + + {emotions && ( +
+ {Object.entries(emotions) + .sort(([, a], [, b]) => b - a) + .slice(0, 4) + .map(([key, val]) => { + const em = emotionMeta[key] + return ( +
+ + {t(`homeDashboard.emotions.${key}`)} + +
+
+
+
+ ) + })} +
+ )} +
+ )} +
+ ) +} diff --git a/memento-note/components/dashboard-view.tsx b/memento-note/components/dashboard-view.tsx new file mode 100644 index 0000000..872742b --- /dev/null +++ b/memento-note/components/dashboard-view.tsx @@ -0,0 +1,1052 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react' +import { useRouter } from 'next/navigation' +import { useReducedMotion } from 'motion/react' +import { Inbox, Send, Bell, Mail } from 'lucide-react' +import { useLanguage } from '@/lib/i18n' +import { useAiConsent } from '@/components/legal/ai-consent-provider' +import { createNote } from '@/app/actions/notes' +import { emitNoteChange } from '@/lib/note-change-sync' +import { toast } from 'sonner' +import { IntelligenceHub } from '@/components/intelligence-hub' +import { DashboardWidgetGrid } from '@/components/dashboard-widget-grid' +import type { DashboardWidgetId } from '@/lib/dashboard/layout' +import { DashboardWidgetHelp } from '@/components/dashboard-widget-help' +import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row' +import { DashboardActionStrip } from '@/components/dashboard-action-strip' +import { DashboardResumeHero } from '@/components/dashboard-resume-hero' +import { DashboardMindOrbit } from '@/components/dashboard-mind-orbit' +import { DashboardAgentCarousel } from '@/components/dashboard-agent-carousel' +import { DashboardSentimentChip } from '@/components/dashboard-sentiment-chip' +import { DashboardNextPaths } from '@/components/dashboard-next-paths' +import { + DashboardDailyReview, + DashboardOpenLoops, + DashboardDailyNoteWidget, + DashboardLinkSuggestions, + DashboardBridgesWidget, +} from '@/components/dashboard-path-widgets' +import type { DashboardPath } from '@/lib/dashboard/path-types' +import { buildFastPathsFromBriefing } from '@/lib/dashboard/paths-fast' +import { + DashboardInboxWidget, + DashboardRevisionWidget, + DashboardStatsWidget, + DashboardAgentActivityWidget, + DashboardGmailWidget, + DashboardPinnedWidget, + DashboardActivityWidget, + DashboardUsageWidget, + DashboardFlashcardsProgressWidget, +} from '@/components/dashboard-catalog-widgets' +import { + ScanEye, Search, Zap, CloudRain, Waves, HeartPulse, Lightbulb, Brain, +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' + +interface BriefingPinnedNote { + id: string + title: string | null + notebookId: string | null + updatedAt: string +} + +interface ActivityDay { + date: string + count: number +} + +interface DashboardOpenLoopItem { + id: string + title: string | null + notebookId: string | null + daysStale: number +} + +interface BriefingNote { + id: string + title: string | null + content: string + color: string + notebookId: string | null + updatedAt: string + notebook: { id: string; name: string; color: string | null; icon: string | null } | null +} + +interface BriefingInsight { + 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 +} + +interface BridgeSuggestionItem { + clusterAId: number + clusterBId: number + clusterAName: string + clusterBName: string + suggestedTitle: string + suggestedContent: string + justification: string +} + +interface BriefingAiStatus { + consent: boolean + memoryEchoEnabled: boolean + providerReady: boolean + active: boolean +} + +interface BriefingAgentAction { + id: string + agentName: string + agentType: string + result: string | null + createdAt: string +} + +interface AgentSuggestionItem { + id: string + topic: string + reason: string + suggestedType: string + suggestedFrequency: string + relatedNoteCount: number + clusterId: number | null +} + +interface BriefingReminder { + id: string + title: string + reminder: string | null + notebookId: string | null +} + +interface SentimentData { + available: boolean + dominantEmotion?: string + sentimentScore?: number + emotions?: Record + summary?: string + topTopic?: string +} + +interface MindMapData { + clusters: { clusterId: number; name?: string; noteIds: string[] }[] + bridgeNotes: { + noteId: string + bridgeScore: number + clusterNames?: string[] + note?: { id: string; title: string | null; content?: string } + }[] + cached: boolean + totalNotes: number +} + +interface DashboardViewProps { + onNoteSelect: (noteId: string, notebookId: string | null) => void +} + +interface GmailStatus { + connected: boolean + recentCaptures: number +} + +const EMOTION_META: Record = { + focused: { Icon: ScanEye, color: '#D97706' }, + curious: { Icon: Search, color: '#0891B2' }, + enthusiastic: { Icon: Zap, color: '#EA580C' }, + frustrated: { Icon: CloudRain, color: '#DC2626' }, + calm: { Icon: Waves, color: '#059669' }, + anxious: { Icon: HeartPulse, color: '#7C3AED' }, + creative: { Icon: Lightbulb, color: '#DB2777' }, + reflective: { Icon: Brain, color: '#4F46E5' }, +} + +function formatRelativeTime( + dateStr: string, + t: (key: string, params?: Record) => 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 }) +} + +function localeForLanguage(language: string): string { + const map: Record = { + en: 'en-US', fr: 'fr-FR', es: 'es-ES', de: 'de-DE', it: 'it-IT', + pt: 'pt-PT', nl: 'nl-NL', pl: 'pl-PL', ru: 'ru-RU', zh: 'zh-CN', + ja: 'ja-JP', ko: 'ko-KR', ar: 'ar-SA', fa: 'fa-IR', hi: 'hi-IN', + } + return map[language] || language +} + +function stripHtml(html: string): string { + return html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim() +} + +// ─── Main ───────────────────────────────────────────── + +export function DashboardView({ onNoteSelect }: DashboardViewProps) { + const router = useRouter() + const { t, language } = useLanguage() + const { hasAiConsent, requestAiConsent } = useAiConsent() + const prefersReducedMotion = useReducedMotion() + + const scrollToWidget = (id: string) => { + document.getElementById(`dashboard-widget-${id}`)?.scrollIntoView({ + behavior: prefersReducedMotion ? 'auto' : 'smooth', + block: 'start', + }) + } + + const [data, setData] = useState<{ + recentNotes: BriefingNote[] + inboxCount: number + dueFlashcards: number + upcomingReminders: BriefingReminder[] + insights: BriefingInsight[] + agentActions: BriefingAgentAction[] + agentSuggestions?: AgentSuggestionItem[] + bridgeSuggestions?: BridgeSuggestionItem[] + gmail?: GmailStatus + ai?: BriefingAiStatus + pinnedNotes?: BriefingPinnedNote[] + writingActivity?: ActivityDay[] + } | null>(null) + const [paths, setPaths] = useState([]) + const [openLoops, setOpenLoops] = useState([]) + const [pathsEnriching, setPathsEnriching] = useState(false) + const [flashcardStats, setFlashcardStats] = useState<{ + retentionRate: number + streak: number + totalCards: number + } | null>(null) + const [sentiment, setSentiment] = useState(null) + const [mindMap, setMindMap] = useState(null) + const [mindMapLoading, setMindMapLoading] = useState(true) + const [sentimentLoading, setSentimentLoading] = useState(true) + const [captureText, setCaptureText] = useState('') + const [capturing, setCapturing] = useState(false) + const [inboxPulse, setInboxPulse] = useState(0) + const [actingSuggestionId, setActingSuggestionId] = useState(null) + const [echoRefreshing, setEchoRefreshing] = useState(false) + const [dismissingInsightId, setDismissingInsightId] = useState(null) + const [actingBridgeSuggestionKey, setActingBridgeSuggestionKey] = useState(null) + + const loadBriefing = useCallback(async () => { + try { + const res = await fetch('/api/briefing', { cache: 'no-store' }) + if (res.ok) setData(await res.json()) + } catch { + /* état dégradé */ + } + }, []) + + const loadPaths = useCallback(async (briefing: NonNullable) => { + setPathsEnriching(true) + try { + const focus = briefing.recentNotes[0] + const res = await fetch('/api/briefing/paths', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recentNote: focus + ? { + id: focus.id, + title: focus.title, + content: focus.content, + notebookId: focus.notebookId, + } + : null, + inboxCount: briefing.inboxCount, + dueFlashcards: briefing.dueFlashcards, + insights: briefing.insights, + bridgeSuggestions: briefing.bridgeSuggestions ?? [], + agentSuggestions: briefing.agentSuggestions ?? [], + }), + cache: 'no-store', + }) + if (res.ok) { + const json = await res.json() + setPaths(json.paths ?? []) + setOpenLoops(json.openLoops ?? []) + } + } catch { + /* pistes enrichies optionnelles — les pistes rapides restent affichées */ + } finally { + setPathsEnriching(false) + } + }, []) + + const loadSentiment = useCallback(async () => { + try { + const res = await fetch('/api/briefing/sentiment', { cache: 'no-store' }) + if (res.ok) setSentiment(await res.json()) + } catch { + /* widget sentiment en état vide */ + } finally { + setSentimentLoading(false) + } + }, []) + + const loadMindMap = useCallback(async () => { + try { + const res = await fetch('/api/clusters?lite=1', { cache: 'no-store' }) + if (res.ok) { + const json = await res.json() + setMindMap({ + clusters: json.clusters || [], + bridgeNotes: json.bridgeNotes || [], + cached: !!json.cached, + totalNotes: json.totalNotes || 0, + }) + } + } catch {} finally { setMindMapLoading(false) } + }, []) + + useEffect(() => { + loadBriefing() + loadMindMap() + fetch('/api/flashcards/stats', { cache: 'no-store' }) + .then(r => r.ok ? r.json() : null) + .then(json => { + if (json) { + setFlashcardStats({ + retentionRate: json.retentionRate ?? 0, + streak: json.streak ?? 0, + totalCards: json.totalCards ?? 0, + }) + } + }) + .catch(() => {}) + }, [loadBriefing, loadMindMap]) + + // Pistes rapides dès le briefing, puis enrichissement en arrière-plan + useEffect(() => { + if (!data) return + setPaths(buildFastPathsFromBriefing({ + recentNotes: data.recentNotes.map(n => ({ + id: n.id, + title: n.title, + content: n.content, + notebookId: n.notebookId, + })), + inboxCount: data.inboxCount, + dueFlashcards: data.dueFlashcards, + insights: data.insights, + bridgeSuggestions: data.bridgeSuggestions ?? [], + agentSuggestions: data.agentSuggestions ?? [], + })) + void loadPaths(data) + }, [data, loadPaths]) + + // Sentiment (LLM) en arrière-plan — ne bloque pas le reste du tableau de bord + useEffect(() => { + const run = () => { void loadSentiment() } + if (typeof requestIdleCallback === 'function') { + const id = requestIdleCallback(run, { timeout: 2500 }) + return () => cancelIdleCallback(id) + } + const t = setTimeout(run, 400) + return () => clearTimeout(t) + }, [loadSentiment]) + + const aiStatus = data?.ai + const aiActive = aiStatus?.active ?? false + const inboxCount = data?.inboxCount ?? 0 + const dueFlashcards = data?.dueFlashcards ?? 0 + const reminders = data?.upcomingReminders ?? [] + const insights = data?.insights ?? [] + const agentActions = data?.agentActions ?? [] + const agentSuggestions = data?.agentSuggestions ?? [] + const bridgeSuggestions = data?.bridgeSuggestions ?? [] + const gmail = data?.gmail + const pinnedNotes = data?.pinnedNotes ?? [] + const writingActivity = data?.writingActivity ?? [] + const pathsList = paths + const openLoopsList = openLoops + const briefingLoading = data === null + const pathsLoading = data === null + const pathsDetailLoading = pathsEnriching + const recentNotes = data?.recentNotes ?? [] + const topBridgeNotes = useMemo(() => (mindMap?.bridgeNotes ?? []).slice(0, 3), [mindMap?.bridgeNotes]) + const dateLocale = localeForLanguage(language) + + const discoveryCount = useMemo(() => { + const fresh = insights.filter(i => !i.viewed).length + return fresh + bridgeSuggestions.length + agentActions.length + }, [insights, bridgeSuggestions, agentActions]) + + const themeCount = mindMap?.clusters.length ?? 0 + + const resumeNotes = useMemo(() => recentNotes.map(n => ({ + id: n.id, + title: n.title, + excerpt: stripHtml(n.content).slice(0, 180), + notebookName: n.notebook?.name || t('homeDashboard.inbox'), + notebookColor: n.notebook?.color || '#8B5CF6', + updatedAt: n.updatedAt, + notebookId: n.notebookId, + })), [recentNotes, t]) + + const briefingSubtitle = useMemo(() => { + if (briefingLoading) return '' + const parts: string[] = [] + if (inboxCount > 0) parts.push(t('homeDashboard.pulseInbox', { count: inboxCount })) + if (dueFlashcards > 0) parts.push(t('homeDashboard.pulseReview', { count: dueFlashcards })) + if (discoveryCount > 0) parts.push(t('homeDashboard.pulseDiscoveries', { count: discoveryCount })) + if (parts.length === 0) return t('homeDashboard.pulseClear') + return parts.join(' · ') + }, [briefingLoading, inboxCount, dueFlashcards, discoveryCount, t]) + + const handleCapture = useCallback(async () => { + const text = captureText.trim() + if (!text) return + setCapturing(true) + try { + const words = text.split(/\s+/) + const title = words.slice(0, 5).join(' ') + (words.length > 5 ? '…' : '') + const note = await createNote({ title, content: `

${text.replace(/` }) + if (note) { + emitNoteChange({ type: 'created', note }) + setCaptureText('') + setInboxPulse(p => p + 1) + setData(prev => prev ? { ...prev, inboxCount: prev.inboxCount + 1 } : prev) + toast.success(t('homeDashboard.captured'), { duration: 2000 }) + } + } catch { + toast.error(t('homeDashboard.captureError')) + } finally { + setCapturing(false) + } + }, [captureText, t]) + + const handleCaptureKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCapture() } + } + + const markInsightViewed = useCallback(async (insightId: string) => { + await fetch('/api/ai/echo', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'view', insightId }), + }).catch(() => {}) + setData(prev => prev ? { + ...prev, + insights: prev.insights.map(i => i.id === insightId ? { ...i, viewed: true } : i), + } : prev) + }, []) + + const handleOpenFromInsight = useCallback(async (insight: BriefingInsight, noteId: string) => { + await markInsightViewed(insight.id) + onNoteSelect(noteId, null) + }, [markInsightViewed, onNoteSelect]) + + const handleDismissInsight = useCallback(async (insight: BriefingInsight) => { + setDismissingInsightId(insight.id) + try { + const res = await fetch('/api/ai/echo/dismiss', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ noteId: insight.note1.id, connectedNoteId: insight.note2.id }), + }) + if (!res.ok) throw new Error() + setData(prev => prev ? { ...prev, insights: prev.insights.filter(i => i.id !== insight.id) } : prev) + } catch { + toast.error(t('homeDashboard.captureError')) + } finally { + setDismissingInsightId(null) + } + }, [t]) + + const handleRefreshEcho = useCallback(async () => { + const consented = await requestAiConsent() + if (!consented) return + setEchoRefreshing(true) + try { + const res = await fetch('/api/ai/echo') + const json = await res.json() + if (res.status === 403 && json.error === 'ai_consent_required') { await requestAiConsent(); return } + if (!res.ok) throw new Error(json.error) + if (json.insight) { + await Promise.all([loadBriefing(), loadPaths()]) + toast.success(t('homeDashboard.echoFound')) + } else { + toast.info(t('homeDashboard.echoNone')) + } + } catch { + toast.error(t('homeDashboard.echoFailed')) + } finally { + setEchoRefreshing(false) + } + }, [requestAiConsent, loadBriefing, loadPaths, t]) + + const handleEnableAi = useCallback(async () => { + await requestAiConsent() + await Promise.all([loadBriefing(), loadPaths()]) + }, [requestAiConsent, loadBriefing, loadPaths]) + + const handleDismissBridgeSuggestion = useCallback(async (s: BridgeSuggestionItem) => { + const key = `${s.clusterAId}-${s.clusterBId}` + setActingBridgeSuggestionKey(key) + try { + const res = await fetch('/api/bridge-notes', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clusterAId: s.clusterAId, clusterBId: s.clusterBId }), + }) + if (!res.ok) throw new Error() + setData(prev => prev ? { + ...prev, + bridgeSuggestions: prev.bridgeSuggestions?.filter( + x => !(x.clusterAId === s.clusterAId && x.clusterBId === s.clusterBId), + ) ?? [], + } : prev) + } catch { + toast.error(t('homeDashboard.captureError')) + } finally { + setActingBridgeSuggestionKey(null) + } + }, [t]) + + const handleCreateBridgeSuggestion = useCallback(async (s: BridgeSuggestionItem) => { + const key = `${s.clusterAId}-${s.clusterBId}` + setActingBridgeSuggestionKey(key) + try { + const html = `

${s.suggestedContent.replace(/` + const note = await createNote({ title: s.suggestedTitle, content: html }) + if (!note) throw new Error() + emitNoteChange({ type: 'created', note }) + await fetch('/api/bridge-notes', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clusterAId: s.clusterAId, clusterBId: s.clusterBId }), + }).catch(() => {}) + setData(prev => prev ? { + ...prev, + bridgeSuggestions: prev.bridgeSuggestions?.filter( + x => !(x.clusterAId === s.clusterAId && x.clusterBId === s.clusterBId), + ) ?? [], + } : prev) + toast.success(t('homeDashboard.bridgeNoteCreated')) + onNoteSelect(note.id, note.notebookId ?? null) + } catch { + toast.error(t('homeDashboard.captureError')) + } finally { + setActingBridgeSuggestionKey(null) + } + }, [onNoteSelect, t]) + + const formatAgentFrequency = useCallback((frequency: string) => { + const key = `agents.frequencies.${frequency.toLowerCase()}` + const label = t(key) + return label !== key ? label : frequency + }, [t]) + + const handleAcceptSuggestion = useCallback(async (id: string) => { + setActingSuggestionId(id) + try { + const res = await fetch(`/api/agents/suggestions/${id}/accept`, { method: 'POST' }) + const json = await res.json() + if (!res.ok) throw new Error(json.error) + setData(prev => prev ? { ...prev, agentSuggestions: prev.agentSuggestions?.filter(s => s.id !== id) ?? [] } : prev) + toast.success(t('homeDashboard.agentCreated')) + if (json.agentId) router.push(`/agents?id=${json.agentId}`) + } catch { + toast.error(t('homeDashboard.agentFailed')) + } finally { + setActingSuggestionId(null) + } + }, [t, router]) + + const handleDismissSuggestion = useCallback(async (id: string) => { + setActingSuggestionId(id) + try { + const res = await fetch(`/api/agents/suggestions/${id}/dismiss`, { method: 'POST' }) + if (!res.ok) throw new Error() + setData(prev => prev ? { ...prev, agentSuggestions: prev.agentSuggestions?.filter(s => s.id !== id) ?? [] } : prev) + } catch { + toast.error(t('homeDashboard.captureError')) + } finally { + setActingSuggestionId(null) + } + }, [t]) + + const handlePathAction = useCallback((path: DashboardPath) => { + switch (path.actionKey) { + case 'continue': + if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null) + break + case 'compare': + if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null) + if (path.note2Id) toast.info(t('homeDashboard.pathCompareHint', { title: path.title })) + break + case 'addLink': + if (path.noteId) { + onNoteSelect(path.noteId, path.notebookId ?? null) + toast.info(t('homeDashboard.pathAddLinkHint', { link: path.title })) + } + break + case 'openInsight': { + const insight = insights.find(i => i.id === path.insightId) + if (insight && path.noteId) handleOpenFromInsight(insight, path.noteId) + break + } + case 'createBridge': { + const bridge = bridgeSuggestions.find( + b => b.clusterAId === path.clusterAId && b.clusterBId === path.clusterBId, + ) + if (bridge) handleCreateBridgeSuggestion(bridge) + break + } + case 'createAgent': + if (path.agentSuggestionId) handleAcceptSuggestion(path.agentSuggestionId) + break + case 'exploreTheme': + router.push('/insights') + break + case 'organizeInbox': + router.push('/home?forceList=1') + break + case 'reviewCards': + router.push('/revision') + break + case 'openDaily': + fetch('/api/notes/daily') + .then(r => r.ok ? r.json() : null) + .then(json => { + if (json?.note?.id) onNoteSelect(json.note.id, json.note.notebookId ?? null) + }) + .catch(() => toast.error(t('homeDashboard.captureError'))) + break + default: + break + } + }, [ + onNoteSelect, t, insights, handleOpenFromInsight, bridgeSuggestions, + handleCreateBridgeSuggestion, handleAcceptSuggestion, router, + ]) + + const handleOpenDailyNote = useCallback(() => { + fetch('/api/notes/daily') + .then(r => r.ok ? r.json() : null) + .then(json => { + if (json?.note?.id) onNoteSelect(json.note.id, json.note.notebookId ?? null) + }) + .catch(() => toast.error(t('homeDashboard.captureError'))) + }, [onNoteSelect, t]) + + const dailyReviewItems = useMemo(() => [ + { + key: 'inbox', + label: t('homeDashboard.dailyReviewInbox'), + done: inboxCount === 0, + count: inboxCount, + onClick: () => router.push('/home?forceList=1'), + }, + { + key: 'discoveries', + label: t('homeDashboard.dailyReviewDiscoveries'), + done: discoveryCount === 0, + count: discoveryCount, + onClick: () => scrollToWidget('intelligence'), + }, + { + key: 'connection', + label: t('homeDashboard.dailyReviewConnection'), + done: pathsList.some(p => p.type === 'connect' || p.type === 'resurface'), + onClick: () => { + const p = pathsList.find(x => x.type === 'connect' || x.type === 'resurface') + if (p) handlePathAction(p) + else scrollToWidget('next-paths') + }, + }, + { + key: 'review', + label: t('homeDashboard.dailyReviewCards'), + done: dueFlashcards === 0, + count: dueFlashcards, + onClick: () => router.push('/revision'), + }, + ], [t, inboxCount, discoveryCount, dueFlashcards, pathsList, router, handlePathAction]) + + const linkSuggestionPaths = useMemo( + () => pathsList.filter(p => p.type === 'add-link').map(p => ({ + id: p.id, + title: p.title, + description: p.description, + score: p.score, + })), + [pathsList], + ) + + const relTime = useCallback((d: string) => formatRelativeTime(d, t), [t]) + + const renderWidget = useCallback((id: DashboardWidgetId) => { + const wrap = (node: ReactNode) => ( +

{node}
+ ) + + switch (id) { + case 'capture': + return wrap( +
+
+
+ + + {t('homeDashboard.quickCapture')} + +
+ +
+