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>
This commit is contained in:
371
memento-note/components/dashboard-catalog-widgets.tsx
Normal file
371
memento-note/components/dashboard-catalog-widgets.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
'use client'
|
||||
|
||||
import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
|
||||
import { UsageMeter } from '@/components/usage-meter'
|
||||
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
|
||||
|
||||
interface DashboardWidgetShellProps {
|
||||
widgetId: DashboardWidgetId
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
compact?: boolean
|
||||
headerActions?: React.ReactNode
|
||||
}
|
||||
|
||||
export function DashboardWidgetShell({
|
||||
widgetId,
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
compact,
|
||||
headerActions,
|
||||
}: DashboardWidgetShellProps) {
|
||||
return (
|
||||
<div className={`rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 h-full ${
|
||||
compact ? 'p-3' : 'p-4'
|
||||
}`}>
|
||||
<DashboardWidgetTitleRow
|
||||
widgetId={widgetId}
|
||||
icon={icon}
|
||||
title={title}
|
||||
actions={headerActions}
|
||||
className={compact ? 'mb-2' : 'mb-3'}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardInboxWidget({
|
||||
count,
|
||||
loading,
|
||||
onOpen,
|
||||
}: {
|
||||
count: number
|
||||
loading: boolean
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="inbox"
|
||||
icon={<Inbox size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.inbox')}
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
|
||||
>
|
||||
<div>
|
||||
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{count}</p>
|
||||
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.toOrganize')}</p>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} →</span>
|
||||
</button>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardRevisionWidget({
|
||||
dueCount,
|
||||
loading,
|
||||
onOpen,
|
||||
}: {
|
||||
dueCount: number
|
||||
loading: boolean
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="revision"
|
||||
icon={<GraduationCap size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.review')}
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
|
||||
>
|
||||
<div>
|
||||
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{dueCount}</p>
|
||||
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.cardsDue')}</p>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} →</span>
|
||||
</button>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardStatsWidget({
|
||||
clusterCount,
|
||||
bridgeCount,
|
||||
noteCount,
|
||||
loading,
|
||||
onOpen,
|
||||
}: {
|
||||
clusterCount: number
|
||||
bridgeCount: number
|
||||
noteCount: number
|
||||
loading: boolean
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="stats"
|
||||
icon={<BarChart3 size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.widgets.stats')}
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[0, 1, 2].map(i => <div key={i} className="h-12 rounded-lg bg-stone-50 animate-pulse" />)}
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={onOpen} className="w-full text-start">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ label: t('homeDashboard.themes'), value: clusterCount },
|
||||
{ label: t('homeDashboard.widgetStatsBridges'), value: bridgeCount },
|
||||
{ label: t('homeDashboard.widgetStatsNotes'), value: noteCount },
|
||||
].map(item => (
|
||||
<div key={item.label} className="p-2 rounded-xl border border-border/20 bg-stone-50/50 dark:bg-zinc-950/30 text-center">
|
||||
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink leading-none">{item.value}</p>
|
||||
<p className="text-[8px] font-mono uppercase text-concrete mt-1 truncate">{item.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardAgentActivityWidget({
|
||||
actions,
|
||||
loading,
|
||||
onOpen,
|
||||
}: {
|
||||
actions: { id: string; agentName: string; result: string | null; createdAt: string }[]
|
||||
loading: boolean
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="agent-activity"
|
||||
icon={<Bot size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.widgets.agent-activity')}
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
||||
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
||||
</div>
|
||||
) : actions.length === 0 ? (
|
||||
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetAgentActivityEmpty')}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{actions.slice(0, 3).map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full p-2 rounded-xl border border-border/20 hover:border-brand-accent/25 text-start transition-all"
|
||||
>
|
||||
<p className="text-[10px] font-mono font-bold text-ink dark:text-dark-ink truncate">{a.agentName}</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||
{(a.result || t('homeDashboard.intelAgentNoResult')).slice(0, 80)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardGmailWidget({
|
||||
connected,
|
||||
recentCaptures,
|
||||
loading,
|
||||
onOpen,
|
||||
}: {
|
||||
connected: boolean
|
||||
recentCaptures: number
|
||||
loading: boolean
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="gmail"
|
||||
icon={<Mail size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.gmailCaptures')}
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||
) : connected ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full flex items-center justify-between gap-2 p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/25 transition-all text-start"
|
||||
>
|
||||
<span className="text-[10px] text-ink dark:text-dark-ink">{t('homeDashboard.gmailRecent', { count: recentCaptures })}</span>
|
||||
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded shrink-0">Gmail</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full text-[10px] font-mono uppercase tracking-wider text-concrete hover:text-brand-accent transition-colors text-start py-2"
|
||||
>
|
||||
{t('homeDashboard.gmailConnect')} →
|
||||
</button>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardPinnedWidget({
|
||||
notes,
|
||||
loading,
|
||||
onSelect,
|
||||
}: {
|
||||
notes: { id: string; title: string | null; notebookId: string | null }[]
|
||||
loading: boolean
|
||||
onSelect: (id: string, notebookId: string | null) => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="pinned"
|
||||
icon={<Pin size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.widgets.pinned')}
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetPinnedEmpty')}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{notes.map(n => (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(n.id, n.notebookId)}
|
||||
className="w-full text-[10px] text-ink dark:text-dark-ink truncate text-start p-2 rounded-lg hover:bg-brand-accent/[0.04] transition-colors"
|
||||
>
|
||||
{n.title || t('homeDashboard.untitled')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardActivityWidget({
|
||||
data,
|
||||
loading,
|
||||
}: {
|
||||
data: { date: string; count: number }[]
|
||||
loading: boolean
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="activity"
|
||||
icon={<BarChart3 size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.widgets.activity')}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-24 rounded-lg bg-stone-50 animate-pulse" />
|
||||
) : (
|
||||
<RevisionHeatmap data={data} />
|
||||
)}
|
||||
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetActivityHint')}</p>
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardUsageWidget() {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="usage"
|
||||
icon={<BarChart3 size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.widgets.usage')}
|
||||
compact
|
||||
>
|
||||
<UsageMeter className="px-0 py-0" />
|
||||
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetUsageHint')}</p>
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardFlashcardsProgressWidget({
|
||||
retentionRate,
|
||||
streak,
|
||||
totalCards,
|
||||
loading,
|
||||
onOpen,
|
||||
}: {
|
||||
retentionRate: number
|
||||
streak: number
|
||||
totalCards: number
|
||||
loading?: boolean
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="flashcards-progress"
|
||||
icon={<GraduationCap size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.widgets.flashcards-progress')}
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-14 rounded-lg bg-stone-50 animate-pulse" />
|
||||
) : (
|
||||
<button type="button" onClick={onOpen} className="w-full text-start">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="text-center p-2 rounded-xl border border-border/20">
|
||||
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{retentionRate}%</p>
|
||||
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashRetention')}</p>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-xl border border-border/20">
|
||||
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{streak}</p>
|
||||
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashStreak')}</p>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-xl border border-border/20">
|
||||
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{totalCards}</p>
|
||||
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashTotal')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user