Files
Momento/memento-note/components/dashboard-widget-grid.tsx
Antigravity 30da592ba2
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m3s
CI / Deploy production (on server) (push) Successful in 23s
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>
2026-07-14 16:50:53 +00:00

403 lines
15 KiB
TypeScript

'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>
)}
</>
)
}