Harmonise la liste des notes et l’éditeur (titres, dates, retour), rend la saisie rapide et les raccourcis de l’accueil utilisables sur téléphone, et conserve le panneau latéral repliable.
1199 lines
44 KiB
TypeScript
1199 lines
44 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback, useMemo, useRef, type ReactNode } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useReducedMotion } from 'motion/react'
|
|
import { Send, Bell, Loader2, PenLine } from 'lucide-react'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
|
import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect'
|
|
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 { 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 { pathNoteTitle, pickFocusNote } from '@/lib/dashboard/path-title'
|
|
import { emitAiUsageChanged } from '@/lib/ai-usage-sync'
|
|
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
|
|
excerpt?: string
|
|
notebookId: string | null
|
|
updatedAt: string
|
|
notebook?: { id: string; name: string; color: string | null; icon: string | null } | null
|
|
}
|
|
|
|
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
|
|
relatedNotes?: Array<{ id: string; title: string | null; notebookId: string | null }>
|
|
}
|
|
|
|
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, peekNoteId?: 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 | null | undefined): string {
|
|
if (!html) return ''
|
|
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
|
|
inboxPreview?: Array<{
|
|
id: string
|
|
title: string | null
|
|
excerpt?: string
|
|
notebookId: string | null
|
|
updatedAt?: string
|
|
}>
|
|
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 [captureError, setCaptureError] = useState(false)
|
|
const capturingLockRef = useRef(false)
|
|
const [inboxPulse, setInboxPulse] = useState(0)
|
|
const [actingSuggestionId, setActingSuggestionId] = useState<string | null>(null)
|
|
const [createdAgent, setCreatedAgent] = useState<{ id: string | null; topic: string } | null>(null)
|
|
const [echoRefreshing, setEchoRefreshing] = useState(false)
|
|
const [dismissingInsightId, setDismissingInsightId] = useState<string | null>(null)
|
|
const [actingBridgeSuggestionKey, setActingBridgeSuggestionKey] = useState<string | null>(null)
|
|
const [briefingError, setBriefingError] = useState(false)
|
|
|
|
const loadBriefing = useCallback(async () => {
|
|
try {
|
|
const res = await fetch('/api/briefing', { cache: 'no-store' })
|
|
if (res.ok) {
|
|
setData(await res.json())
|
|
setBriefingError(false)
|
|
} else {
|
|
setBriefingError(true)
|
|
}
|
|
} catch {
|
|
setBriefingError(true)
|
|
}
|
|
}, [])
|
|
|
|
const loadPaths = useCallback(async (briefing: NonNullable<typeof data>) => {
|
|
setPathsEnriching(true)
|
|
try {
|
|
const focus = pickFocusNote(briefing.recentNotes)
|
|
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)
|
|
}
|
|
}, [])
|
|
|
|
/** Recharge briefing puis enrichit les pistes (évite loadPaths() sans argument). */
|
|
const reloadBriefingAndPaths = useCallback(async () => {
|
|
try {
|
|
const res = await fetch('/api/briefing', { cache: 'no-store' })
|
|
if (!res.ok) {
|
|
setBriefingError(true)
|
|
return
|
|
}
|
|
const briefing = await res.json()
|
|
setData(briefing)
|
|
setBriefingError(false)
|
|
setPaths(buildFastPathsFromBriefing({
|
|
recentNotes: (briefing.recentNotes ?? []).map((n: BriefingNote) => ({
|
|
id: n.id,
|
|
title: n.title,
|
|
content: n.content,
|
|
notebookId: n.notebookId,
|
|
})),
|
|
inboxCount: briefing.inboxCount ?? 0,
|
|
dueFlashcards: briefing.dueFlashcards ?? 0,
|
|
insights: briefing.insights ?? [],
|
|
bridgeSuggestions: briefing.bridgeSuggestions ?? [],
|
|
agentSuggestions: briefing.agentSuggestions ?? [],
|
|
}))
|
|
await loadPaths(briefing)
|
|
} catch {
|
|
setBriefingError(true)
|
|
}
|
|
}, [loadPaths])
|
|
|
|
const loadSentiment = useCallback(async () => {
|
|
try {
|
|
const res = await fetch('/api/briefing/sentiment', { cache: 'no-store' })
|
|
if (res.ok) {
|
|
setSentiment(await res.json())
|
|
emitAiUsageChanged()
|
|
}
|
|
} 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 => {
|
|
setFlashcardStats({
|
|
retentionRate: json?.retentionRate ?? 0,
|
|
streak: json?.streak ?? 0,
|
|
totalCards: json?.totalCards ?? 0,
|
|
})
|
|
})
|
|
.catch(() => {
|
|
setFlashcardStats({ retentionRate: 0, streak: 0, totalCards: 0 })
|
|
})
|
|
}, [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 recentNotes = data?.recentNotes ?? []
|
|
const pathsList = useMemo(
|
|
() => paths.filter(p => p.type !== 'organize' && p.type !== 'review'),
|
|
[paths],
|
|
)
|
|
const pathBridgeKeys = useMemo(
|
|
() => pathsList
|
|
.filter(p => p.type === 'bridge' && p.clusterAId != null && p.clusterBId != null)
|
|
.map(p => `${p.clusterAId}-${p.clusterBId}`),
|
|
[pathsList],
|
|
)
|
|
const focusNote = useMemo(() => pickFocusNote(recentNotes), [recentNotes])
|
|
const openLoopsList = openLoops
|
|
const briefingLoading = data === null
|
|
const pathsLoading = data === null
|
|
const pathsDetailLoading = pathsEnriching
|
|
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.flatMap(n => {
|
|
const displayTitle = pathNoteTitle(n.title, n.content)
|
|
if (!displayTitle) return []
|
|
return [{
|
|
id: n.id,
|
|
title: displayTitle,
|
|
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 handleCapture = useCallback(async () => {
|
|
const text = captureText.trim()
|
|
if (!text || capturingLockRef.current) return
|
|
capturingLockRef.current = true
|
|
setCapturing(true)
|
|
setCaptureError(false)
|
|
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 })
|
|
} else {
|
|
setCaptureError(true)
|
|
toast.error(t('homeDashboard.captureError'))
|
|
}
|
|
} catch {
|
|
setCaptureError(true)
|
|
toast.error(t('homeDashboard.captureError'))
|
|
} finally {
|
|
capturingLockRef.current = false
|
|
setCapturing(false)
|
|
}
|
|
}, [captureText, t])
|
|
|
|
const handleCaptureKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.nativeEvent.isComposing || e.key === 'Process') return
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
void 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,
|
|
peekNoteId?: string | null,
|
|
) => {
|
|
await markInsightViewed(insight.id)
|
|
const peek = peekNoteId && peekNoteId !== noteId ? peekNoteId : undefined
|
|
onNoteSelect(noteId, null, peek)
|
|
}, [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.code === 'ai_consent_required') {
|
|
redirectToAiConsentSettings(router)
|
|
return
|
|
}
|
|
if (!res.ok) throw new Error(json.error)
|
|
if (json.insight) {
|
|
await reloadBriefingAndPaths()
|
|
emitAiUsageChanged()
|
|
toast.success(t('homeDashboard.echoFound'))
|
|
} else {
|
|
toast.info(t('homeDashboard.echoNone'))
|
|
}
|
|
} catch {
|
|
toast.error(t('homeDashboard.echoFailed'))
|
|
} finally {
|
|
setEchoRefreshing(false)
|
|
}
|
|
}, [requestAiConsent, reloadBriefingAndPaths, t, router])
|
|
|
|
const handleEnableAi = useCallback(async () => {
|
|
await requestAiConsent()
|
|
await reloadBriefingAndPaths()
|
|
}, [requestAiConsent, reloadBriefingAndPaths])
|
|
|
|
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)
|
|
const topic = data?.agentSuggestions?.find(s => s.id === id)?.topic ?? ''
|
|
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)
|
|
setCreatedAgent({ id: json.agentId ?? null, topic })
|
|
toast.success(t('homeDashboard.agentCreated'))
|
|
} catch {
|
|
toast.error(t('homeDashboard.agentFailed'))
|
|
} finally {
|
|
setActingSuggestionId(null)
|
|
}
|
|
}, [t, data?.agentSuggestions])
|
|
|
|
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, path.note2Id)
|
|
break
|
|
case 'addLink':
|
|
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id)
|
|
break
|
|
case 'openInsight': {
|
|
const insight = insights.find(i => i.id === path.insightId)
|
|
if (insight && path.noteId) handleOpenFromInsight(insight, path.noteId, path.note2Id)
|
|
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'),
|
|
// Coché quand il n'y a plus de piste de connexion à traiter
|
|
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={`rounded-xl border bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm shadow-sm h-full flex flex-col ${
|
|
captureError
|
|
? 'border-red-400/70 dark:border-red-500/60'
|
|
: 'border-border/30 focus-within:border-brand-accent/50'
|
|
} focus-within:ring-2 focus-within:ring-brand-accent focus-within:ring-offset-2 focus-within:ring-offset-background`}
|
|
>
|
|
<DashboardWidgetTitleRow
|
|
widgetId="capture"
|
|
icon={<PenLine size={16} className="text-brand-accent" />}
|
|
title={t('homeDashboard.quickCapture')}
|
|
className="mb-0 px-3 pt-3"
|
|
wrapTitle
|
|
/>
|
|
<p id="dashboard-capture-hint" className="px-3 pt-1 text-sm text-muted-foreground leading-snug">
|
|
{t('homeDashboard.captureGoesToFile')}
|
|
</p>
|
|
<div className="flex flex-col min-[360px]:flex-row items-stretch min-[360px]:items-end gap-2 px-3 pt-2 pb-3">
|
|
<textarea
|
|
value={captureText}
|
|
onChange={e => {
|
|
setCaptureText(e.target.value)
|
|
if (captureError) setCaptureError(false)
|
|
}}
|
|
onKeyDown={handleCaptureKeyDown}
|
|
placeholder={t('homeDashboard.quickCapturePlaceholder')}
|
|
rows={3}
|
|
dir="auto"
|
|
readOnly={capturing}
|
|
aria-busy={capturing}
|
|
aria-invalid={captureError}
|
|
aria-describedby={captureError ? 'dashboard-capture-status dashboard-capture-hint' : 'dashboard-capture-hint'}
|
|
className="min-h-16 min-w-0 flex-1 resize-none rounded-md bg-transparent px-1 py-2 text-base leading-snug text-foreground outline-none ring-0 shadow-none placeholder:text-foreground/55 read-only:opacity-70"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleCapture()}
|
|
disabled={!captureText.trim() || capturing}
|
|
className={`flex h-11 w-11 shrink-0 self-end items-center justify-center rounded-lg bg-brand-accent text-white shadow-sm hover:bg-brand-accent/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent focus-visible:ring-offset-2 disabled:opacity-40 disabled:hover:bg-brand-accent ${
|
|
prefersReducedMotion ? '' : 'active:scale-95'
|
|
}`}
|
|
aria-busy={capturing}
|
|
aria-label={t('homeDashboard.captureSend')}
|
|
title={t('homeDashboard.captureSend')}
|
|
>
|
|
{capturing
|
|
? <Loader2 size={18} className="animate-spin" />
|
|
: <Send size={18} />}
|
|
</button>
|
|
</div>
|
|
<p
|
|
id="dashboard-capture-status"
|
|
role="status"
|
|
aria-live="polite"
|
|
className={`px-3 pb-3 text-sm leading-snug ${
|
|
captureError
|
|
? 'text-red-700 dark:text-red-400'
|
|
: capturing
|
|
? 'text-muted-foreground'
|
|
: 'sr-only'
|
|
}`}
|
|
>
|
|
{captureError
|
|
? t('homeDashboard.captureError')
|
|
: capturing
|
|
? t('homeDashboard.captureSending')
|
|
: ''}
|
|
</p>
|
|
</div>,
|
|
)
|
|
case 'next-paths':
|
|
return wrap(
|
|
<DashboardNextPaths
|
|
paths={pathsList}
|
|
loading={pathsLoading}
|
|
enriching={pathsEnriching}
|
|
focusNoteTitle={focusNote ? pathNoteTitle(focusNote.title, focusNote.content) : null}
|
|
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}
|
|
dueCount={dueFlashcards}
|
|
loading={!flashcardStats}
|
|
onOpen={() => router.push('/revision')}
|
|
/>,
|
|
)
|
|
case 'resume':
|
|
return wrap(
|
|
<DashboardResumeHero
|
|
notes={resumeNotes}
|
|
loading={briefingLoading}
|
|
onSelect={onNoteSelect}
|
|
onCaptureFocus={() => {
|
|
document.getElementById('dashboard-widget-capture')?.scrollIntoView({
|
|
behavior: prefersReducedMotion ? 'auto' : 'smooth',
|
|
block: 'center',
|
|
})
|
|
const ta = document.querySelector<HTMLTextAreaElement>('#dashboard-widget-capture textarea')
|
|
ta?.focus()
|
|
}}
|
|
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}
|
|
excludeBridgeSuggestionKeys={pathBridgeKeys}
|
|
/>,
|
|
)
|
|
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 dark:bg-zinc-950/40 animate-pulse" />
|
|
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
|
</div>
|
|
) : reminders.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3">
|
|
<p className="text-[11px] text-concrete leading-relaxed mb-2">
|
|
{t('homeDashboard.remindersEmpty')}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push('/home?reminders=1&forceList=1')}
|
|
className="text-[9px] font-mono font-bold uppercase text-brand-accent hover:underline"
|
|
>
|
|
{t('homeDashboard.remindersOpenAll')} →
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<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>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push('/home?reminders=1&forceList=1')}
|
|
className="w-full text-start px-1 pt-1 text-[9px] font-mono uppercase font-bold text-brand-accent hover:underline"
|
|
>
|
|
{t('homeDashboard.remindersOpenAll')} →
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>,
|
|
)
|
|
case 'mind-map':
|
|
return wrap(
|
|
<DashboardMindOrbit
|
|
clusters={mindMap?.clusters ?? []}
|
|
bridgeNotes={mindMap?.bridgeNotes ?? []}
|
|
loading={mindMapLoading}
|
|
onOpenInsights={() => router.push('/insights')}
|
|
onOpenCluster={(clusterId) => router.push(`/insights?cluster=${clusterId}`)}
|
|
onNoteSelect={(nid) => onNoteSelect(nid, null)}
|
|
prefersReducedMotion={!!prefersReducedMotion}
|
|
/>,
|
|
)
|
|
case 'agents':
|
|
return wrap(
|
|
<DashboardAgentCarousel
|
|
suggestions={agentSuggestions}
|
|
loading={briefingLoading}
|
|
actingId={actingSuggestionId}
|
|
formatFrequency={formatAgentFrequency}
|
|
onAccept={handleAcceptSuggestion}
|
|
onDismiss={handleDismissSuggestion}
|
|
createdAgent={createdAgent}
|
|
onOpenCreated={() => router.push(createdAgent?.id ? `/agents?id=${createdAgent.id}` : '/agents')}
|
|
onClearCreated={() => setCreatedAgent(null)}
|
|
prefersReducedMotion={!!prefersReducedMotion}
|
|
/>,
|
|
)
|
|
case 'sentiment':
|
|
return wrap(
|
|
<DashboardSentimentChip
|
|
available={!!sentiment?.available}
|
|
loading={sentimentLoading}
|
|
dominantEmotion={sentiment?.dominantEmotion}
|
|
summary={sentiment?.summary}
|
|
emotions={sentiment?.emotions}
|
|
emotionMeta={EMOTION_META}
|
|
relatedNotes={sentiment?.relatedNotes}
|
|
onSelectNote={onNoteSelect}
|
|
/>,
|
|
)
|
|
case 'inbox':
|
|
return wrap(
|
|
<DashboardInboxWidget
|
|
count={inboxCount}
|
|
notes={data?.inboxPreview ?? []}
|
|
loading={briefingLoading}
|
|
onOpen={() => router.push('/home?forceList=1')}
|
|
onSelect={onNoteSelect}
|
|
formatRelativeTime={relTime}
|
|
/>,
|
|
)
|
|
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}
|
|
formatRelativeTime={relTime}
|
|
/>,
|
|
)
|
|
case 'usage':
|
|
return wrap(
|
|
<DashboardUsageWidget />,
|
|
)
|
|
default:
|
|
return null
|
|
}
|
|
}, [
|
|
t, captureText, capturing, captureError, 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, pathBridgeKeys, reminders, gmail, dateLocale, router, mindMap,
|
|
agentSuggestions, actingSuggestionId, createdAgent, formatAgentFrequency, handleAcceptSuggestion,
|
|
handleDismissSuggestion, sentiment, pinnedNotes, writingActivity, themeCount,
|
|
pathsList, openLoopsList, handlePathAction, dailyReviewItems, linkSuggestionPaths,
|
|
handleOpenDailyNote, flashcardStats, focusNote,
|
|
])
|
|
|
|
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="w-full px-4 sm:px-6 lg:px-8 xl:px-10 2xl:px-12 py-6 sm:py-8 relative z-10">
|
|
{briefingError && !data && (
|
|
<div className="mb-4 rounded-xl border border-rose-500/30 bg-rose-500/5 px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<p className="text-sm text-ink dark:text-dark-ink">
|
|
{t('homeDashboard.briefingLoadError')}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => { void reloadBriefingAndPaths() }}
|
|
className="shrink-0 text-[10px] font-mono font-bold uppercase tracking-wider px-3 py-2 rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90"
|
|
>
|
|
{t('homeDashboard.briefingRetry')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{/* ── En-tête : orientation en 2 secondes ── */}
|
|
<header className="mb-5">
|
|
<div className="pb-4 border-b border-border/20">
|
|
<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>
|
|
|
|
{/* 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-8">
|
|
<DashboardWidgetGrid
|
|
renderWidget={renderWidget}
|
|
isWidgetEmpty={(id) => {
|
|
if (briefingLoading) return false
|
|
if (id === 'revision') return dueFlashcards === 0
|
|
return false
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|