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:
258
memento-note/components/dashboard-path-widgets.tsx
Normal file
258
memento-note/components/dashboard-path-widgets.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
'use client'
|
||||
|
||||
import { CheckCircle2, Circle } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { DashboardWidgetShell } from '@/components/dashboard-catalog-widgets'
|
||||
|
||||
export interface DailyReviewItem {
|
||||
key: string
|
||||
label: string
|
||||
done: boolean
|
||||
count?: number
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export function DashboardDailyReview({
|
||||
items,
|
||||
loading,
|
||||
}: {
|
||||
items: DailyReviewItem[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="daily-review"
|
||||
icon={<CheckCircle2 size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.widgets.daily-review')}
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map(i => <div key={i} className="h-8 rounded-lg bg-stone-50 animate-pulse" />)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{items.map(item => (
|
||||
<li key={item.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={item.onClick}
|
||||
className="w-full flex items-center gap-2.5 p-2 rounded-xl hover:bg-brand-accent/[0.04] transition-colors text-start"
|
||||
>
|
||||
{item.done ? (
|
||||
<CheckCircle2 size={14} className="text-brand-accent shrink-0" />
|
||||
) : (
|
||||
<Circle size={14} className="text-concrete/50 shrink-0" />
|
||||
)}
|
||||
<span className={`text-[10px] flex-1 ${item.done ? 'text-concrete line-through' : 'text-ink dark:text-dark-ink'}`}>
|
||||
{item.label}
|
||||
{item.count !== undefined && item.count > 0 ? ` (${item.count})` : ''}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-[9px] text-concrete mt-2 leading-relaxed">{t('homeDashboard.dailyReviewHint')}</p>
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardOpenLoops({
|
||||
loops,
|
||||
loading,
|
||||
onSelect,
|
||||
}: {
|
||||
loops: Array<{ id: string; title: string | null; notebookId: string | null; daysStale: number }>
|
||||
loading?: boolean
|
||||
onSelect: (id: string, notebookId: string | null) => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="open-loops"
|
||||
icon={<Circle size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.widgets.open-loops')}
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-16 rounded-lg bg-stone-50 animate-pulse" />
|
||||
) : loops.length === 0 ? (
|
||||
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.openLoopsEmpty')}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{loops.map(loop => (
|
||||
<button
|
||||
key={loop.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(loop.id, loop.notebookId)}
|
||||
className="w-full flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-brand-accent/[0.04] text-start transition-colors"
|
||||
>
|
||||
<span className="text-[10px] text-ink dark:text-dark-ink truncate flex-1">
|
||||
{loop.title || t('homeDashboard.untitled')}
|
||||
</span>
|
||||
<span className="text-[8px] font-mono text-concrete shrink-0">
|
||||
{t('homeDashboard.openLoopsStale', { days: loop.daysStale })}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardDailyNoteWidget({
|
||||
loading,
|
||||
onOpen,
|
||||
}: {
|
||||
loading?: boolean
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="daily-note"
|
||||
icon={<CheckCircle2 size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.widgets.daily-note')}
|
||||
compact
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 text-start transition-all"
|
||||
>
|
||||
<p className="text-[11px] font-serif font-semibold text-ink dark:text-dark-ink">
|
||||
{new Date().toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</p>
|
||||
<p className="text-[9px] font-mono uppercase text-brand-accent mt-1">{t('homeDashboard.dailyNoteOpen')} →</p>
|
||||
</button>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardLinkSuggestions({
|
||||
paths,
|
||||
loading,
|
||||
onAction,
|
||||
}: {
|
||||
paths: Array<{ id: string; title: string; description: string; score?: number }>
|
||||
loading?: boolean
|
||||
onAction: (id: string) => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="link-suggestions"
|
||||
icon={<Circle size={12} className="text-emerald-600" />}
|
||||
title={t('homeDashboard.widgets.link-suggestions')}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-12 rounded-lg bg-stone-50 animate-pulse" />
|
||||
</div>
|
||||
) : paths.length === 0 ? (
|
||||
<p className="text-[10px] text-concrete italic">{t('homeDashboard.linkSuggestionsEmpty')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{paths.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => onAction(p.id)}
|
||||
className="w-full p-3 rounded-xl border border-border/20 hover:border-emerald-500/30 text-start transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<p className="text-[11px] font-mono font-bold text-ink dark:text-dark-ink truncate">{p.title}</p>
|
||||
{p.score ? (
|
||||
<span className="text-[8px] font-mono text-emerald-600 shrink-0">{p.score}%</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-[10px] text-concrete line-clamp-2">{p.description}</p>
|
||||
<p className="text-[8px] font-mono uppercase text-emerald-600 mt-2">{t('homeDashboard.pathActions.addLink')} →</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardBridgesWidget({
|
||||
suggestions,
|
||||
loading,
|
||||
actingKey,
|
||||
onCreate,
|
||||
onDismiss,
|
||||
}: {
|
||||
suggestions: Array<{
|
||||
clusterAId: number
|
||||
clusterBId: number
|
||||
clusterAName: string
|
||||
clusterBName: string
|
||||
suggestedTitle: string
|
||||
justification: string
|
||||
}>
|
||||
loading?: boolean
|
||||
actingKey?: string | null
|
||||
onCreate: (s: { clusterAId: number; clusterBId: number; clusterAName: string; clusterBName: string; suggestedTitle: string; suggestedContent: string; justification: string }) => void
|
||||
onDismiss: (s: { clusterAId: number; clusterBId: number }) => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="bridges"
|
||||
icon={<Circle size={12} className="text-violet-600" />}
|
||||
title={t('homeDashboard.widgets.bridges')}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-20 rounded-lg bg-stone-50 animate-pulse" />
|
||||
) : suggestions.length === 0 ? (
|
||||
<p className="text-[10px] text-concrete italic">{t('homeDashboard.bridgesEmpty')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{suggestions.slice(0, 3).map(s => {
|
||||
const key = `${s.clusterAId}-${s.clusterBId}`
|
||||
return (
|
||||
<div key={key} className="p-3 rounded-xl border border-border/20 bg-stone-50/40 dark:bg-zinc-950/30">
|
||||
<p className="text-[10px] font-mono font-bold text-ink dark:text-dark-ink">{s.suggestedTitle}</p>
|
||||
<p className="text-[9px] text-concrete mt-1">
|
||||
{t('homeDashboard.suggestedBridge', { clusterA: s.clusterAName, clusterB: s.clusterBName })}
|
||||
</p>
|
||||
<p className="text-[10px] text-concrete mt-1 line-clamp-2">{s.justification}</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={actingKey === key}
|
||||
onClick={() => onCreate({ ...s, suggestedContent: s.justification })}
|
||||
className="text-[8px] font-mono uppercase font-bold text-brand-accent hover:underline disabled:opacity-50"
|
||||
>
|
||||
{t('homeDashboard.createBridgeNote')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actingKey === key}
|
||||
onClick={() => onDismiss(s)}
|
||||
className="text-[8px] font-mono uppercase text-concrete hover:text-rose-500 disabled:opacity-50"
|
||||
>
|
||||
{t('homeDashboard.dismissConnection')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user