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>
403 lines
15 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|