Files
Momento/memento-note/components/dashboard-mind-orbit.tsx
Antigravity a7b16870a4
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m17s
CI / Deploy production (on server) (push) Successful in 24s
fix(ui): sélection groupée dans la boîte de réception
Permet de cocher plusieurs notes, les déplacer vers un carnet ou les envoyer à la corbeille, et rend la carte mentale plus lisible.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 19:40:10 +00:00

208 lines
8.2 KiB
TypeScript

'use client'
import { motion } from 'motion/react'
import { Layers, Zap, ArrowUpRight } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
interface Cluster {
clusterId: number
name?: string
noteIds: string[]
}
interface BridgeNote {
noteId: string
bridgeScore: number
clusterNames?: string[]
note?: { id: string; title: string | null }
}
const CLUSTER_COLORS = ['#B91C1C', '#1D4ED8', '#047857', '#B45309', '#6D28D9', '#BE185D', '#0F766E']
const EASE = [0.16, 1, 0.3, 1] as const
function clusterInk(hex: string): string {
const n = hex.replace('#', '')
const r = parseInt(n.slice(0, 2), 16)
const g = parseInt(n.slice(2, 4), 16)
const b = parseInt(n.slice(4, 6), 16)
const y = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
return y > 0.45 ? '#1C1917' : '#FAFAF9'
}
function clusterPoint(index: number, count: number): { x: number; y: number } {
if (count === 1) return { x: 50, y: 42 }
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / count
const rx = count <= 3 ? 28 : 36
const ry = count <= 3 ? 24 : 30
return { x: 50 + Math.cos(angle) * rx, y: 44 + Math.sin(angle) * ry }
}
export interface DashboardMindOrbitProps {
clusters: Cluster[]
bridgeNotes: BridgeNote[]
loading?: boolean
onOpenInsights: () => void
onOpenCluster?: (clusterId: number) => void
onNoteSelect: (id: string) => void
prefersReducedMotion?: boolean
}
export function DashboardMindOrbit({
clusters,
bridgeNotes,
loading,
onOpenInsights,
onOpenCluster,
onNoteSelect,
prefersReducedMotion,
}: DashboardMindOrbitProps) {
const { t } = useLanguage()
const topClusters = [...clusters]
.sort((a, b) => b.noteIds.length - a.noteIds.length)
.slice(0, 5)
const maxCount = topClusters[0]?.noteIds.length || 1
const topBridge = bridgeNotes[0]
const reduced = !!prefersReducedMotion
const points = topClusters.map((_, idx) => clusterPoint(idx, topClusters.length))
if (loading) {
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
}
if (topClusters.length === 0) {
return (
<button
type="button"
onClick={onOpenInsights}
className="w-full h-[180px] rounded-2xl border border-dashed border-border/35 bg-white/50 dark:bg-zinc-900/50 flex flex-col items-center justify-center gap-2 p-6 text-center hover:border-brand-accent/30 transition-all"
>
<Layers size={22} className="text-concrete/35" />
<p className="text-[13px] text-ink/80 dark:text-dark-ink/80 italic max-w-[220px]">{t('homeDashboard.mindMapEmpty')}</p>
<span className="text-[13px] font-semibold uppercase tracking-wider text-brand-accent">{t('homeDashboard.mindMapOpen')}</span>
</button>
)
}
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
<DashboardWidgetTitleRow
widgetId="mind-map"
icon={<Layers size={12} className="text-brand-accent" />}
title={t('homeDashboard.mindMap')}
actions={(
<button
type="button"
onClick={onOpenInsights}
className="inline-flex items-center gap-0.5 text-[13px] font-semibold uppercase tracking-wider text-brand-accent hover:underline"
>
{t('homeDashboard.fullMap')}
<ArrowUpRight size={10} />
</button>
)}
/>
<motion.div className="relative h-[200px] overflow-visible">
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 w-full h-full pointer-events-none"
aria-hidden
>
{points.map((point, idx) => {
const color = CLUSTER_COLORS[topClusters[idx].clusterId % CLUSTER_COLORS.length]
return (
<motion.path
key={`spoke-${topClusters[idx].clusterId}`}
d={`M 50 44 L ${point.x} ${point.y}`}
fill="none"
stroke={color}
strokeWidth={1.2}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={reduced ? false : { pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 0.85 }}
transition={{ duration: reduced ? 0 : 0.45, delay: reduced ? 0 : 0.12 + idx * 0.05, ease: EASE }}
/>
)
})}
</svg>
<div
className="absolute left-1/2 top-[44%] w-2.5 h-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-accent shadow-[0_2px_8px_rgba(164,113,72,0.45)] pointer-events-none"
aria-hidden
/>
{topClusters.map((cluster, idx) => {
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
const size = Math.round(52 * scale)
const label = cluster.name?.trim() || t('homeDashboard.theme')
const point = points[idx]
return (
<motion.button
key={cluster.clusterId}
type="button"
whileHover={reduced ? undefined : { scale: 1.06 }}
whileTap={reduced ? undefined : { scale: 0.97 }}
onClick={() => (onOpenCluster ? onOpenCluster(cluster.clusterId) : onOpenInsights())}
className="absolute flex flex-col items-center gap-1 group z-[1] hover:z-10"
aria-label={label}
style={{
left: `${point.x}%`,
top: `${point.y}%`,
width: Math.max(size + 24, 128),
}}
initial={reduced ? { x: '-50%', y: '-50%' } : { x: '-50%', y: '-50%', scale: 0.82, opacity: 0.35 }}
animate={{ x: '-50%', y: '-50%', scale: 1, opacity: 1 }}
transition={{ duration: reduced ? 0 : 0.38, delay: reduced ? 0 : 0.18 + idx * 0.05, ease: EASE }}
>
<div
className="rounded-full border-2 flex items-center justify-center font-semibold shadow-sm group-hover:shadow-md transition-shadow"
style={{
width: size,
height: size,
backgroundColor: color,
borderColor: color,
color: clusterInk(color),
fontSize: Math.max(13, size * 0.28),
}}
>
{cluster.noteIds.length}
</div>
<span className="text-[13px] font-medium text-ink dark:text-dark-ink text-center line-clamp-2 leading-snug max-w-[128px] group-hover:text-brand-accent transition-colors">
{label}
</span>
<span className="pointer-events-none absolute top-full mt-1 max-w-[200px] px-2 py-1 rounded-md bg-ink text-white text-[13px] leading-snug text-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-20">
{label}
</span>
</motion.button>
)
})}
</motion.div>
{topBridge?.note && (
<motion.button
type="button"
onClick={() => onNoteSelect(topBridge.noteId)}
className="w-full mt-2 p-2.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] hover:bg-brand-accent/[0.08] transition-all text-start flex items-center gap-2 group"
initial={reduced ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduced ? 0 : 0.32, delay: reduced ? 0 : 0.48, ease: EASE }}
>
<Zap size={11} className="text-brand-accent shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
{topBridge.note.title || t('homeDashboard.untitled')}
</p>
<p className="text-[12px] text-ink/70 dark:text-dark-ink/70">{t('homeDashboard.bridgeNote')}</p>
</div>
<span className="text-[12px] font-semibold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
{Math.round(topBridge.bridgeScore * 100)}%
</span>
</motion.button>
)}
</div>
)
}