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>
1053 lines
38 KiB
TypeScript
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(/ /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, '<')}</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, '<')}</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>
|
|
)
|
|
}
|