Files
Momento/memento-note/components/dashboard-view.tsx
Antigravity 30da592ba2
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m3s
CI / Deploy production (on server) (push) Successful in 23s
feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
Briefing granulaire, pistes rapides puis enrichissement async, layout persisté v5,
suggestions agents, intégration Gmail et navigation sidebar alignée sur /home.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:50:53 +00:00

1053 lines
38 KiB
TypeScript

'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<string, number>
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<string, { Icon: LucideIcon; color: string }> = {
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 | number>) => string,
): string {
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (mins < 1) return t('time.justNow')
if (mins < 60) return t('time.minutesAgo', { count: mins })
if (hours < 24) return t('time.hoursAgo', { count: hours })
return t('time.daysAgo', { count: days })
}
function localeForLanguage(language: string): string {
const map: Record<string, string> = {
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(/&nbsp;/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<DashboardPath[]>([])
const [openLoops, setOpenLoops] = useState<DashboardOpenLoopItem[]>([])
const [pathsEnriching, setPathsEnriching] = useState(false)
const [flashcardStats, setFlashcardStats] = useState<{
retentionRate: number
streak: number
totalCards: number
} | null>(null)
const [sentiment, setSentiment] = useState<SentimentData | null>(null)
const [mindMap, setMindMap] = useState<MindMapData | null>(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<string | null>(null)
const [echoRefreshing, setEchoRefreshing] = useState(false)
const [dismissingInsightId, setDismissingInsightId] = useState<string | null>(null)
const [actingBridgeSuggestionKey, setActingBridgeSuggestionKey] = useState<string | null>(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<typeof data>) => {
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: `<p>${text.replace(/</g, '&lt;')}</p>` })
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<HTMLTextAreaElement>) => {
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 = `<p>${s.suggestedContent.replace(/</g, '&lt;')}</p>`
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) => (
<div id={`dashboard-widget-${id}`}>{node}</div>
)
switch (id) {
case 'capture':
return wrap(
<div className="relative rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm shadow-sm h-full">
<div className="flex items-center justify-between gap-2 px-3.5 pt-2.5">
<div className="flex items-center gap-2 min-w-0">
<Inbox size={11} className="text-brand-accent shrink-0" />
<span className="text-[8px] font-mono font-bold uppercase tracking-widest text-concrete">
{t('homeDashboard.quickCapture')}
</span>
</div>
<DashboardWidgetHelp widgetId="capture" />
</div>
<textarea
value={captureText}
onChange={e => setCaptureText(e.target.value)}
onKeyDown={handleCaptureKeyDown}
placeholder={t('homeDashboard.quickCapturePlaceholder')}
rows={2}
className="w-full text-sm px-3.5 pb-3 pt-1.5 pe-12 bg-transparent outline-none text-ink dark:text-dark-ink resize-none leading-relaxed placeholder:text-concrete/45"
/>
<button
type="button"
onClick={handleCapture}
disabled={!captureText.trim() || capturing}
className="absolute bottom-2.5 end-2.5 p-2 bg-ink text-white dark:bg-white dark:text-black rounded-lg disabled:opacity-25 hover:scale-105 active:scale-95 transition-all shadow-sm"
>
<Send size={12} />
</button>
</div>,
)
case 'next-paths':
return wrap(
<DashboardNextPaths
paths={pathsList}
loading={pathsLoading}
enriching={pathsEnriching}
focusNoteTitle={recentNotes[0]?.title}
onAction={handlePathAction}
prefersReducedMotion={!!prefersReducedMotion}
/>,
)
case 'daily-review':
return wrap(
<DashboardDailyReview items={dailyReviewItems} loading={briefingLoading} />,
)
case 'open-loops':
return wrap(
<DashboardOpenLoops loops={openLoopsList} loading={pathsDetailLoading && openLoopsList.length === 0} onSelect={onNoteSelect} />,
)
case 'daily-note':
return wrap(
<DashboardDailyNoteWidget loading={briefingLoading} onOpen={handleOpenDailyNote} />,
)
case 'link-suggestions':
return wrap(
<DashboardLinkSuggestions
paths={linkSuggestionPaths}
loading={pathsDetailLoading && linkSuggestionPaths.length === 0}
onAction={(id) => {
const p = pathsList.find(x => x.id === id)
if (p) handlePathAction(p)
}}
/>,
)
case 'bridges':
return wrap(
<DashboardBridgesWidget
suggestions={(bridgeSuggestions ?? []).map(s => ({
clusterAId: s.clusterAId,
clusterBId: s.clusterBId,
clusterAName: s.clusterAName,
clusterBName: s.clusterBName,
suggestedTitle: s.suggestedTitle,
justification: s.justification,
}))}
loading={briefingLoading}
actingKey={actingBridgeSuggestionKey}
onCreate={handleCreateBridgeSuggestion}
onDismiss={(s) => handleDismissBridgeSuggestion(
bridgeSuggestions.find(b => b.clusterAId === s.clusterAId && b.clusterBId === s.clusterBId)!,
)}
/>,
)
case 'flashcards-progress':
return wrap(
<DashboardFlashcardsProgressWidget
retentionRate={flashcardStats?.retentionRate ?? 0}
streak={flashcardStats?.streak ?? 0}
totalCards={flashcardStats?.totalCards ?? 0}
loading={!flashcardStats}
onOpen={() => router.push('/revision')}
/>,
)
case 'resume':
return wrap(
<DashboardResumeHero
notes={resumeNotes}
loading={briefingLoading}
onSelect={onNoteSelect}
formatRelativeTime={relTime}
prefersReducedMotion={!!prefersReducedMotion}
/>,
)
case 'intelligence':
return wrap(
<IntelligenceHub
loading={briefingLoading}
aiActive={aiActive}
hasAiConsent={hasAiConsent}
providerReady={aiStatus?.providerReady ?? false}
memoryEchoEnabled={aiStatus?.memoryEchoEnabled ?? true}
insights={insights}
bridgeNotes={topBridgeNotes}
bridgeSuggestions={bridgeSuggestions}
agentActions={agentActions}
onNoteSelect={(nid) => onNoteSelect(nid, null)}
onRefreshEcho={handleRefreshEcho}
onEnableAi={handleEnableAi}
echoRefreshing={echoRefreshing}
onDismissInsight={handleDismissInsight}
onDismissBridgeSuggestion={handleDismissBridgeSuggestion}
onCreateBridgeSuggestion={handleCreateBridgeSuggestion}
onOpenInsightNote={handleOpenFromInsight}
dismissingInsightId={dismissingInsightId}
actingBridgeSuggestionKey={actingBridgeSuggestionKey}
prefersReducedMotion={!!prefersReducedMotion}
/>,
)
case 'reminders':
return wrap(
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
<DashboardWidgetTitleRow
widgetId="reminders"
icon={<Bell size={12} className="text-brand-accent" />}
title={t('homeDashboard.reminders')}
/>
{briefingLoading ? (
<div className="space-y-2">
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
</div>
) : reminders.length === 0 ? (
<p className="text-[11px] text-concrete italic py-1">{t('homeDashboard.allCaughtUp')}</p>
) : (
<div className="space-y-1.5">
{reminders.slice(0, 4).map(r => (
<button
key={r.id}
type="button"
onClick={() => onNoteSelect(r.id, r.notebookId)}
className="w-full flex items-center justify-between gap-2 p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/25 hover:bg-brand-accent/[0.03] transition-all text-start group"
>
<span className="text-[11px] text-ink dark:text-dark-ink group-hover:text-brand-accent transition-colors truncate flex-1">
{r.title || t('homeDashboard.untitled')}
</span>
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded shrink-0">
{r.reminder ? new Date(r.reminder).toLocaleDateString(dateLocale, { day: 'numeric', month: 'short' }) : ''}
</span>
</button>
))}
</div>
)}
{!briefingLoading && (
gmail?.connected ? (
<button
type="button"
onClick={() => router.push('/settings/integrations')}
className="w-full flex items-center justify-between gap-2 p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/25 transition-all text-start mt-2"
>
<div className="flex items-center gap-2 min-w-0">
<Mail size={11} className="text-concrete" />
<span className="text-[10px] text-ink dark:text-dark-ink truncate">{t('homeDashboard.gmailCaptures')}</span>
</div>
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">
{t('homeDashboard.gmailRecent', { count: gmail.recentCaptures })}
</span>
</button>
) : (
<button
type="button"
onClick={() => router.push('/settings/integrations')}
className="w-full text-[9px] font-mono uppercase tracking-wider text-concrete hover:text-brand-accent transition-colors text-start py-2 mt-2"
>
{t('homeDashboard.gmailConnect')}
</button>
)
)}
</div>,
)
case 'mind-map':
return wrap(
<DashboardMindOrbit
clusters={mindMap?.clusters ?? []}
bridgeNotes={mindMap?.bridgeNotes ?? []}
loading={mindMapLoading}
onOpenInsights={() => router.push('/insights')}
onNoteSelect={(nid) => onNoteSelect(nid, null)}
prefersReducedMotion={!!prefersReducedMotion}
/>,
)
case 'agents':
return wrap(
<DashboardAgentCarousel
suggestions={agentSuggestions}
loading={briefingLoading}
actingId={actingSuggestionId}
formatFrequency={formatAgentFrequency}
onAccept={handleAcceptSuggestion}
onDismiss={handleDismissSuggestion}
prefersReducedMotion={!!prefersReducedMotion}
/>,
)
case 'sentiment':
return wrap(
<DashboardSentimentChip
available={!!sentiment?.available}
loading={sentimentLoading}
dominantEmotion={sentiment?.dominantEmotion}
summary={sentiment?.summary}
emotions={sentiment?.emotions}
emotionMeta={EMOTION_META}
/>,
)
case 'inbox':
return wrap(
<DashboardInboxWidget
count={inboxCount}
loading={briefingLoading}
onOpen={() => router.push('/home?forceList=1')}
/>,
)
case 'revision':
return wrap(
<DashboardRevisionWidget
dueCount={dueFlashcards}
loading={briefingLoading}
onOpen={() => router.push('/revision')}
/>,
)
case 'stats':
return wrap(
<DashboardStatsWidget
clusterCount={themeCount}
bridgeCount={mindMap?.bridgeNotes.length ?? 0}
noteCount={mindMap?.totalNotes ?? 0}
loading={mindMapLoading}
onOpen={() => router.push('/insights')}
/>,
)
case 'agent-activity':
return wrap(
<DashboardAgentActivityWidget
actions={agentActions}
loading={briefingLoading}
onOpen={() => router.push('/agents')}
/>,
)
case 'gmail':
return wrap(
<DashboardGmailWidget
connected={!!gmail?.connected}
recentCaptures={gmail?.recentCaptures ?? 0}
loading={briefingLoading}
onOpen={() => router.push('/settings/integrations')}
/>,
)
case 'activity':
return wrap(
<DashboardActivityWidget
data={writingActivity}
loading={briefingLoading}
/>,
)
case 'pinned':
return wrap(
<DashboardPinnedWidget
notes={pinnedNotes}
loading={briefingLoading}
onSelect={onNoteSelect}
/>,
)
case 'usage':
return wrap(
<DashboardUsageWidget />,
)
default:
return null
}
}, [
t, captureText, capturing, handleCaptureKeyDown, handleCapture, resumeNotes, briefingLoading, pathsLoading, pathsEnriching, sentimentLoading,
onNoteSelect, relTime, prefersReducedMotion, mindMapLoading, aiActive, hasAiConsent,
aiStatus, insights, topBridgeNotes, bridgeSuggestions, agentActions, handleRefreshEcho,
handleEnableAi, echoRefreshing, handleDismissInsight, handleDismissBridgeSuggestion,
handleCreateBridgeSuggestion, handleOpenFromInsight, dismissingInsightId,
actingBridgeSuggestionKey, reminders, gmail, dateLocale, router, mindMap,
agentSuggestions, actingSuggestionId, formatAgentFrequency, handleAcceptSuggestion,
handleDismissSuggestion, sentiment, pinnedNotes, writingActivity, themeCount,
pathsList, openLoopsList, handlePathAction, dailyReviewItems, linkSuggestionPaths,
handleOpenDailyNote, flashcardStats,
])
return (
<div className="h-full w-full bg-[#F9F8F6] dark:bg-[#0D0D0D] overflow-y-auto custom-scrollbar relative">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808005_1px,transparent_1px),linear-gradient(to_bottom,#80808005_1px,transparent_1px)] bg-[size:28px_28px] pointer-events-none z-0" />
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-10 py-6 sm:py-8 relative z-10">
{/* ── En-tête : orientation en 2 secondes ── */}
<header className="mb-5">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-2 pb-4 border-b border-border/20">
<div>
<h1 className="font-serif text-2xl sm:text-3xl font-medium text-ink dark:text-dark-ink tracking-tight">
{t('homeDashboard.title')}
</h1>
<p className="text-[10px] font-mono uppercase tracking-[0.2em] text-concrete font-bold mt-1">
{new Date().toLocaleDateString(dateLocale, { weekday: 'long', day: 'numeric', month: 'long' })}
</p>
</div>
{!briefingLoading && briefingSubtitle && (
<p className="text-[11px] text-concrete leading-relaxed max-w-md sm:text-end">
{briefingSubtitle}
</p>
)}
</div>
{/* Pulse : file d'attente cognitive en un coup d'œil */}
<div className="mt-4">
<DashboardActionStrip
inboxCount={inboxCount}
dueFlashcards={dueFlashcards}
reminderCount={reminders.length}
discoveryCount={discoveryCount}
themeCount={themeCount}
inboxPulse={inboxPulse}
onInbox={() => router.push('/home?forceList=1')}
onReview={() => router.push('/revision')}
onReminders={() => scrollToWidget('reminders')}
onDiscoveries={() => scrollToWidget('intelligence')}
onThemes={() => router.push('/insights')}
prefersReducedMotion={!!prefersReducedMotion}
/>
</div>
</header>
<div className="pb-24">
<DashboardWidgetGrid renderWidget={renderWidget} />
</div>
</div>
</div>
)
}