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:
402
memento-note/components/dashboard-widget-grid.tsx
Normal file
402
memento-note/components/dashboard-widget-grid.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
rectSortingStrategy,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVertical, X, Plus, RotateCcw, Check, LayoutGrid, Eye, EyeOff } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import {
|
||||
getDefaultDashboardLayout,
|
||||
resetDashboardLayout,
|
||||
DASHBOARD_CATEGORY_ORDER,
|
||||
DASHBOARD_WIDGET_META,
|
||||
catalogByCategory,
|
||||
hiddenWidgetIds,
|
||||
isWidgetVisible,
|
||||
normalizeDashboardLayout,
|
||||
reorderWidgets,
|
||||
setWidgetVisibility,
|
||||
visibleWidgetsInZone,
|
||||
type DashboardLayout,
|
||||
type DashboardWidgetId,
|
||||
type DashboardWidgetPlacement,
|
||||
type DashboardWidgetZone,
|
||||
} from '@/lib/dashboard/layout'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface DashboardWidgetGridProps {
|
||||
renderWidget: (id: DashboardWidgetId) => React.ReactNode
|
||||
}
|
||||
|
||||
function SortableWidget({
|
||||
placement,
|
||||
editMode,
|
||||
onHide,
|
||||
children,
|
||||
}: {
|
||||
placement: DashboardWidgetPlacement
|
||||
editMode: boolean
|
||||
onHide: (id: DashboardWidgetId) => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const meta = DASHBOARD_WIDGET_META[placement.id]
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: placement.id, disabled: !editMode })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 20 : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`relative w-full ${
|
||||
isDragging ? 'opacity-90 scale-[1.01]' : ''
|
||||
} ${editMode ? 'ring-2 ring-brand-accent/25 ring-offset-2 ring-offset-[#F9F8F6] dark:ring-offset-[#0D0D0D] rounded-2xl' : ''}`}
|
||||
>
|
||||
{editMode && (
|
||||
<div className="absolute top-2 end-2 z-20 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-lg bg-white/95 dark:bg-zinc-900/95 border border-border/40 shadow-sm cursor-grab active:cursor-grabbing text-concrete hover:text-ink"
|
||||
aria-label={t('homeDashboard.widgetDrag')}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={12} />
|
||||
</button>
|
||||
{!meta.required && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHide(placement.id)}
|
||||
className="p-1.5 rounded-lg bg-white/95 dark:bg-zinc-900/95 border border-border/40 shadow-sm text-concrete hover:text-rose-500"
|
||||
aria-label={t('homeDashboard.widgetHide')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ZoneColumn({
|
||||
zone,
|
||||
placements,
|
||||
editMode,
|
||||
onHide,
|
||||
renderWidget,
|
||||
}: {
|
||||
zone: DashboardWidgetZone
|
||||
placements: DashboardWidgetPlacement[]
|
||||
editMode: boolean
|
||||
onHide: (id: DashboardWidgetId) => void
|
||||
renderWidget: (id: DashboardWidgetId) => React.ReactNode
|
||||
}) {
|
||||
if (placements.length === 0) return null
|
||||
|
||||
return (
|
||||
<SortableContext items={placements.map(w => w.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{placements.map(placement => (
|
||||
<SortableWidget
|
||||
key={placement.id}
|
||||
placement={placement}
|
||||
editMode={editMode}
|
||||
onHide={onHide}
|
||||
>
|
||||
{renderWidget(placement.id)}
|
||||
</SortableWidget>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) {
|
||||
const { t } = useLanguage()
|
||||
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [catalogOpen, setCatalogOpen] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/dashboard/layout', { cache: 'no-store' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(json => {
|
||||
if (json?.layout) setLayout(normalizeDashboardLayout(json.layout))
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoaded(true))
|
||||
}, [])
|
||||
|
||||
const persistLayout = useCallback((next: DashboardLayout, immediate = false) => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current)
|
||||
const save = () => {
|
||||
fetch('/api/dashboard/layout', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ layout: next }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
if (immediate) save()
|
||||
else saveTimer.current = setTimeout(save, 600)
|
||||
}, [])
|
||||
|
||||
const applyLayout = useCallback((next: DashboardLayout, immediate = false) => {
|
||||
const normalized = normalizeDashboardLayout(next)
|
||||
setLayout(normalized)
|
||||
persistLayout(normalized, immediate)
|
||||
}, [persistLayout])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
applyLayout(reorderWidgets(layout, active.id as DashboardWidgetId, over.id as DashboardWidgetId))
|
||||
}, [applyLayout, layout])
|
||||
|
||||
const handleHide = useCallback((id: DashboardWidgetId) => {
|
||||
applyLayout(setWidgetVisibility(layout, id, false))
|
||||
}, [applyLayout, layout])
|
||||
|
||||
const handleToggle = useCallback((id: DashboardWidgetId) => {
|
||||
const visible = isWidgetVisible(layout, id)
|
||||
if (visible) {
|
||||
applyLayout(setWidgetVisibility(layout, id, false))
|
||||
} else {
|
||||
const maxOrder = Math.max(...layout.widgets.map(w => w.order), 0)
|
||||
applyLayout({
|
||||
...layout,
|
||||
widgets: layout.widgets.map(w =>
|
||||
w.id === id ? { ...w, visible: true, order: maxOrder + 1 } : w,
|
||||
),
|
||||
})
|
||||
}
|
||||
}, [applyLayout, layout])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
const fresh = resetDashboardLayout()
|
||||
setLayout(fresh)
|
||||
persistLayout(fresh, true)
|
||||
setCatalogOpen(false)
|
||||
toast.success(t('homeDashboard.widgetResetDone'))
|
||||
}, [persistLayout, t])
|
||||
|
||||
const fullWidgets = visibleWidgetsInZone(layout, 'full')
|
||||
const mainWidgets = visibleWidgetsInZone(layout, 'main')
|
||||
const sideWidgets = visibleWidgetsInZone(layout, 'side')
|
||||
const hidden = hiddenWidgetIds(layout)
|
||||
const catalog = catalogByCategory(layout)
|
||||
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{loaded ? (
|
||||
hasVisibleWidgets ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
{fullWidgets.length > 0 && (
|
||||
<SortableContext items={fullWidgets.map(w => w.id)} strategy={rectSortingStrategy}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{fullWidgets.map(placement => (
|
||||
<SortableWidget
|
||||
key={placement.id}
|
||||
placement={placement}
|
||||
editMode={editMode}
|
||||
onHide={handleHide}
|
||||
>
|
||||
{renderWidget(placement.id)}
|
||||
</SortableWidget>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
|
||||
{(mainWidgets.length > 0 || sideWidgets.length > 0) && (
|
||||
<div className="grid grid-cols-12 gap-4 items-start">
|
||||
{mainWidgets.length > 0 && (
|
||||
<div className="col-span-12 lg:col-span-8">
|
||||
<ZoneColumn
|
||||
zone="main"
|
||||
placements={mainWidgets}
|
||||
editMode={editMode}
|
||||
onHide={handleHide}
|
||||
renderWidget={renderWidget}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{sideWidgets.length > 0 && (
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<ZoneColumn
|
||||
zone="side"
|
||||
placements={sideWidgets}
|
||||
editMode={editMode}
|
||||
onHide={handleHide}
|
||||
renderWidget={renderWidget}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border/40 bg-white/60 dark:bg-zinc-900/60 p-8 text-center">
|
||||
<p className="text-sm font-serif text-ink dark:text-dark-ink mb-2">{t('homeDashboard.layoutEmptyTitle')}</p>
|
||||
<p className="text-[11px] text-concrete mb-4 max-w-md mx-auto">{t('homeDashboard.layoutEmptyHint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-brand-accent text-white text-[10px] font-mono uppercase font-bold hover:bg-brand-accent/90 transition-colors"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
{t('homeDashboard.widgetReset')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="grid grid-cols-12 gap-5">
|
||||
<div className="col-span-12 h-20 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||
<div className="col-span-12 lg:col-span-8 h-48 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||
<div className="col-span-12 lg:col-span-4 h-48 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-2 px-3 py-2 rounded-2xl bg-ink/92 dark:bg-zinc-900/95 text-white shadow-xl border border-white/10 backdrop-blur-md">
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCatalogOpen(v => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
|
||||
>
|
||||
<Plus size={12} />
|
||||
{t('homeDashboard.widgetCatalog')}
|
||||
{hidden.length > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-md bg-brand-accent/90 text-[9px]">{hidden.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
{t('homeDashboard.widgetReset')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditMode(false); setCatalogOpen(false) }}
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors"
|
||||
>
|
||||
<Check size={12} />
|
||||
{t('homeDashboard.widgetDone')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditMode(true)}
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
|
||||
>
|
||||
<LayoutGrid size={12} />
|
||||
{t('homeDashboard.widgetCustomize')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editMode && catalogOpen && (
|
||||
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-40 w-[min(520px,calc(100vw-2rem))] max-h-[min(60vh,480px)] overflow-y-auto custom-scrollbar p-4 rounded-2xl bg-white dark:bg-zinc-900 border border-border/40 shadow-2xl">
|
||||
<p className="text-[9px] font-mono font-bold uppercase tracking-wider text-concrete mb-3">
|
||||
{t('homeDashboard.widgetCatalogTitle')}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{DASHBOARD_CATEGORY_ORDER.map(category => {
|
||||
const ids = catalog[category]
|
||||
if (ids.length === 0) return null
|
||||
return (
|
||||
<div key={category}>
|
||||
<p className="text-[8px] font-mono font-bold uppercase tracking-[0.15em] text-brand-accent mb-2">
|
||||
{t(`homeDashboard.widgetCategories.${category}`)}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{ids.map(id => {
|
||||
const meta = DASHBOARD_WIDGET_META[id]
|
||||
const active = isWidgetVisible(layout, id)
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => !meta.required && handleToggle(id)}
|
||||
disabled={meta.required}
|
||||
className={`w-full flex items-start gap-3 p-2.5 rounded-xl border text-start transition-colors ${
|
||||
active
|
||||
? 'border-brand-accent/30 bg-brand-accent/[0.04]'
|
||||
: 'border-border/25 bg-stone-50/80 dark:bg-zinc-950/40 hover:border-brand-accent/25'
|
||||
} ${meta.required ? 'opacity-80 cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className={`mt-0.5 shrink-0 w-6 h-6 rounded-lg flex items-center justify-center ${
|
||||
active ? 'bg-brand-accent/15 text-brand-accent' : 'bg-stone-200/60 dark:bg-zinc-800 text-concrete'
|
||||
}`}>
|
||||
{active ? <Eye size={11} /> : <EyeOff size={11} />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-mono font-bold uppercase tracking-wide text-ink dark:text-dark-ink">
|
||||
{t(`homeDashboard.widgets.${id}`)}
|
||||
</p>
|
||||
<p className="text-[10px] text-concrete leading-snug mt-0.5">
|
||||
{t(`homeDashboard.widgetDescriptions.${id}`)}
|
||||
</p>
|
||||
</div>
|
||||
{!meta.required && (
|
||||
<span className={`shrink-0 text-[9px] font-mono font-bold uppercase px-2 py-1 rounded-lg ${
|
||||
active ? 'text-concrete' : 'text-brand-accent bg-brand-accent/10'
|
||||
}`}>
|
||||
{active ? t('homeDashboard.widgetActive') : t('homeDashboard.widgetAddShort')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user