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>
372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
'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>
|
|
)
|
|
}
|