Files
Momento/memento-note/components/notes-list-views.tsx
Antigravity e2672cd2c2
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m19s
CI / Deploy production (on server) (push) Has been skipped
feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 14:27:29 +00:00

942 lines
34 KiB
TypeScript

'use client'
import { useMemo, useState, useTransition, useEffect, useCallback } from 'react'
import {
DndContext,
DragOverlay,
PointerSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
rectSortingStrategy,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import type { Note } from '@/lib/types'
import { NotesEditorialView } from '@/components/notes-editorial-view'
import type { NoteCollectionActions } from '@/lib/note-change-sync'
import { getNoteDisplayTitle, getNoteFeedImage, getNotePlainExcerpt, prepareNoteIllustrationForGrid } from '@/lib/note-preview'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { useLabelsQuery } from '@/lib/query-hooks'
import { updateNote } from '@/app/actions/notes'
import { getAISettings } from '@/app/actions/ai-settings'
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
import { LabelBadge } from '@/components/label-badge'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { motion } from 'motion/react'
import { MoveToNotebookPicker } from '@/components/move-to-notebook-picker'
import { toast } from 'sonner'
import {
Pin,
FileText,
Link2,
CheckSquare,
ChevronUp,
ChevronDown,
Wind,
Trash2,
FolderOpen,
Sparkles,
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
export type NotesLayoutMode = 'grid' | 'list' | 'table'
export type NotesViewType = 'notes' | 'tasks'
type TaskItem = {
id: string
noteId: string
noteTitle: string
text: string
completed: boolean
lineIndex: number
}
function getNoteTasksStats(content: string) {
const lines = (content || '').split('\n')
let total = 0
let completed = 0
for (const line of lines) {
const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/)
if (match) {
total++
if (match[1].toLowerCase() === 'x') completed++
}
}
return { completed, total }
}
function extractTasksFromNotes(notes: Note[]): TaskItem[] {
const tasks: TaskItem[] = []
for (const note of notes) {
const title = note.title?.trim() || 'Sans titre'
const lines = (note.content || '').split('\n')
lines.forEach((line, idx) => {
const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/)
if (match) {
tasks.push({
id: `${note.id}-${idx}`,
noteId: note.id,
noteTitle: title,
text: match[2].trim(),
completed: match[1].toLowerCase() === 'x',
lineIndex: idx,
})
}
})
}
return tasks
}
function getNotebookColor(notebookId: string | null | undefined, name?: string) {
const colors = [
{ bg: 'bg-[#A47148]/5 dark:bg-[#A47148]/10', border: 'border-[#A47148]/20', text: 'text-[#A47148]' },
{ bg: 'bg-emerald-500/5 dark:bg-emerald-500/10', border: 'border-emerald-500/15', text: 'text-emerald-600 dark:text-emerald-400' },
{ bg: 'bg-indigo-500/5 dark:bg-indigo-500/10', border: 'border-indigo-500/15', text: 'text-indigo-600 dark:text-indigo-400' },
{ bg: 'bg-blue-500/5 dark:bg-blue-500/10', border: 'border-blue-500/15', text: 'text-blue-600 dark:text-blue-400' },
{ bg: 'bg-amber-500/5 dark:bg-amber-500/10', border: 'border-amber-500/15', text: 'text-amber-600 dark:text-amber-400' },
{ bg: 'bg-rose-500/5 dark:bg-rose-500/10', border: 'border-rose-500/15', text: 'text-rose-600 dark:text-rose-400' },
]
const key = name || notebookId || ''
const idx = Math.abs(key.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)) % colors.length
return colors[idx]
}
function NoteLabelsRow({
labelNames,
allLabels,
max = 3,
}: {
labelNames: string[] | null | undefined
allLabels: { name: string; type?: 'ai' | 'user' }[]
max?: number
}) {
if (!labelNames?.length) return null
return (
<div className="flex items-center gap-1 flex-wrap">
{labelNames.slice(0, max).map((labelName) => {
const def = allLabels.find((l) => l.name === labelName)
return (
<LabelBadge key={labelName} label={labelName} type={def?.type} variant="default" />
)
})}
{labelNames.length > max && (
<span className="text-[8.5px] font-mono text-muted-foreground font-bold shrink-0 bg-muted/50 px-1 py-0.5 rounded">
+{labelNames.length - max}
</span>
)}
</div>
)
}
function NoteGridIllustrationButton({
busy,
onClick,
className,
}: {
busy: boolean
onClick: (e: React.MouseEvent) => void
className?: string
}) {
const { t } = useLanguage()
return (
<button
type="button"
aria-label={t('notes.generateIllustration') || 'Générer une illustration IA'}
title={t('notes.generateIllustration') || 'Générer une illustration IA'}
className={cn(
'absolute bottom-2 end-2 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background/95 text-foreground shadow-card-rest backdrop-blur-sm transition-colors hover:bg-accent z-10',
className,
)}
onClick={onClick}
disabled={busy}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4 text-primary" />}
</button>
)
}
function NoteGridThumbnail({
note,
aiIllustrationEnabled,
onNoteIllustrationGenerated,
}: {
note: Note
aiIllustrationEnabled?: boolean
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
}) {
const { t } = useLanguage()
const [busy, setBusy] = useState(false)
const img = getNoteFeedImage(note)
const handleGenerateSvg = async (e: React.MouseEvent) => {
e.stopPropagation()
if (!aiIllustrationEnabled || busy || img) return
setBusy(true)
try {
const res = await generateNoteIllustrationSvg(note.id, { skipRevalidation: true })
if (!res.ok) {
toast.error(res.error)
} else {
toast.success(t('notes.illustrationGenerated') || 'Illustration générée')
await onNoteIllustrationGenerated?.(note.id)
}
} finally {
setBusy(false)
}
}
const aiButtonClass = 'opacity-0 group-hover/card:opacity-100 focus-visible:opacity-100'
if (img) {
return (
<img
src={img}
alt=""
className="w-full h-full object-cover mix-blend-multiply dark:mix-blend-normal opacity-85 grayscale contrast-115 group-hover/card:scale-105 group-hover/card:grayscale-0 group-hover/card:opacity-100 transition-all duration-500"
/>
)
}
if (note.illustrationSvg) {
return (
<>
<div
className="absolute inset-0 w-full h-full overflow-hidden bg-[#F5F0E8] dark:bg-muted/30"
dangerouslySetInnerHTML={{ __html: prepareNoteIllustrationForGrid(note.illustrationSvg) }}
aria-hidden
/>
{aiIllustrationEnabled && (
<NoteGridIllustrationButton busy={busy} onClick={handleGenerateSvg} className={aiButtonClass} />
)}
</>
)
}
return (
<>
<div className="w-full h-full flex items-center justify-center bg-muted/40 text-muted-foreground/50">
<FileText size={28} strokeWidth={1.25} />
</div>
{aiIllustrationEnabled && (
<NoteGridIllustrationButton busy={busy} onClick={handleGenerateSvg} className={aiButtonClass} />
)}
</>
)
}
export type { NoteCollectionActions } from '@/lib/note-change-sync'
type NotesListViewsProps = {
notes: Note[]
pinnedNotes?: Note[]
viewType: NotesViewType
layoutMode: NotesLayoutMode
onOpen: (note: Note, readOnly?: boolean) => void
onOpenHistory?: (note: Note) => void
notebookName?: string
onGridReorder?: (orderedIds: string[]) => void | Promise<void>
} & Partial<NoteCollectionActions>
export function NotesListViews({
notes,
pinnedNotes = [],
viewType,
layoutMode,
onOpen,
onOpenHistory,
notebookName,
onTogglePin,
onDeleteNote,
onArchiveNote,
onMoveToNotebook,
onNotePatch,
onNoteIllustrationGenerated,
onGridReorder,
}: NotesListViewsProps) {
const { t, language } = useLanguage()
const { data: session } = useSession()
const { notebooks } = useNotebooks()
const { data: allLabels = [] } = useLabelsQuery()
const [, startTransition] = useTransition()
const [sortColumn, setSortColumn] = useState<'title' | 'notebook' | 'tasks' | 'modified' | null>(null)
const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(null)
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
useEffect(() => {
if (!session?.user?.id) {
setAiIllustrationEnabled(false)
return
}
getAISettings(session.user.id)
.then((s) => setAiIllustrationEnabled(s.paragraphRefactor !== false))
.catch(() => setAiIllustrationEnabled(false))
}, [session?.user?.id])
const untitled = t('notes.untitled')
const dateLocale = language === 'fr' ? fr : enUS
const allDisplayNotes = useMemo(() => {
const unpinned = notes.filter((n) => !n.isPinned)
return [...pinnedNotes, ...unpinned]
}, [notes, pinnedNotes])
const extractTasks = useMemo(() => extractTasksFromNotes(allDisplayNotes), [allDisplayNotes])
const completedTasksCount = extractTasks.filter((task) => task.completed).length
const handleToggleTask = (task: TaskItem) => {
const note = allDisplayNotes.find((n) => n.id === task.noteId)
if (!note) return
const lines = (note.content || '').split('\n')
const line = lines[task.lineIndex]
if (!line) return
const nextChar = task.completed ? ' ' : 'x'
lines[task.lineIndex] = line.replace(/\[([ xX])\]/, `[${nextChar}]`)
startTransition(async () => {
await updateNote(note.id, { content: lines.join('\n') }, { skipRevalidation: true })
onNotePatch?.(note.id, { content: lines.join('\n') })
})
}
const handleSort = (field: 'title' | 'notebook' | 'tasks' | 'modified') => {
if (sortColumn !== field) {
setSortColumn(field)
setSortDirection('asc')
} else if (sortDirection === 'asc') {
setSortDirection('desc')
} else {
setSortColumn(null)
setSortDirection(null)
}
}
const sortedNotes = useMemo(() => {
if (!sortColumn || !sortDirection) return allDisplayNotes
const copy = [...allDisplayNotes]
return copy.sort((a, b) => {
let valA: string | number = ''
let valB: string | number = ''
if (sortColumn === 'title') {
valA = getNoteDisplayTitle(a, untitled).toLowerCase()
valB = getNoteDisplayTitle(b, untitled).toLowerCase()
} else if (sortColumn === 'notebook') {
valA = notebooks.find((nb) => nb.id === a.notebookId)?.name?.toLowerCase() || ''
valB = notebooks.find((nb) => nb.id === b.notebookId)?.name?.toLowerCase() || ''
} else if (sortColumn === 'tasks') {
valA = getNoteTasksStats(a.content || '').completed
valB = getNoteTasksStats(b.content || '').completed
} else {
valA = new Date(a.updatedAt).getTime()
valB = new Date(b.updatedAt).getTime()
}
if (valA < valB) return sortDirection === 'asc' ? -1 : 1
if (valA > valB) return sortDirection === 'asc' ? 1 : -1
return 0
})
}, [allDisplayNotes, sortColumn, sortDirection, notebooks, untitled])
const SortIcon = ({ field }: { field: typeof sortColumn }) =>
sortColumn === field ? (
sortDirection === 'asc' ? <ChevronUp size={12} /> : <ChevronDown size={12} />
) : null
if (viewType === 'tasks') {
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between pb-3 border-b border-foreground/5">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-[10px] uppercase font-bold tracking-[0.2em] text-muted-foreground">
{t('notes.tasksHeader')}
</span>
</div>
<div className="text-[11px] font-mono font-bold text-foreground bg-foreground/[0.03] dark:bg-white/5 py-1 px-3 rounded-full">
{t('notes.tasksSummary')
.replace('{count}', String(extractTasks.length))
.replace('{completed}', String(completedTasksCount))}
</div>
</div>
{extractTasks.length > 0 ? (
<div className="overflow-hidden border border-border/40 rounded-2xl bg-card/30 shadow-sm">
<div className="divide-y divide-foreground/[0.04]">
{extractTasks.map((task) => (
<div
key={task.id}
className="p-4 flex items-center justify-between gap-4 hover:bg-foreground/[0.01] transition-all group"
>
<div className="flex items-center gap-3.5 flex-grow min-w-0">
<button
type="button"
onClick={() => handleToggleTask(task)}
className={cn(
'w-5 h-5 rounded-md border flex items-center justify-center transition-all shrink-0',
task.completed
? 'bg-brand-accent border-brand-accent text-white'
: 'border-border hover:border-brand-accent/60 bg-transparent',
)}
>
{task.completed && <span className="text-xs font-bold"></span>}
</button>
<span
className={cn(
'text-[13px] font-light leading-relaxed truncate',
task.completed && 'line-through text-muted-foreground',
)}
>
{task.text}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[9.5px] uppercase font-mono tracking-wider text-muted-foreground max-w-[140px] truncate">
{t('notes.taskFromNote').replace('{title}', task.noteTitle)}
</span>
<button
type="button"
onClick={() => {
const note = allDisplayNotes.find((n) => n.id === task.noteId)
if (note) onOpen(note)
}}
className="p-1.5 rounded-full hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-all"
title={t('notes.openSourceNote')}
>
<Link2 size={13} />
</button>
</div>
</div>
))}
</div>
</div>
) : (
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
<div className="w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center border border-border/40">
<CheckSquare size={18} className="text-muted-foreground/60" />
</div>
<p className="font-memento-serif text-lg italic text-muted-foreground">{t('notes.tasksEmptyTitle')}</p>
<p className="text-xs text-muted-foreground/70 max-w-sm leading-relaxed">{t('notes.tasksEmptyHint')}</p>
</div>
)}
</div>
)
}
if (layoutMode === 'grid') {
return (
<NotesMasonryGrid
pinnedNotes={pinnedNotes}
unpinnedNotes={notes.filter((n) => !n.isPinned)}
untitled={untitled}
allLabels={allLabels}
notebooks={notebooks}
aiIllustrationEnabled={aiIllustrationEnabled}
onOpen={onOpen}
onTogglePin={onTogglePin}
onDeleteNote={onDeleteNote}
onMoveToNotebook={onMoveToNotebook}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
onGridReorder={onGridReorder}
pinnedLabel={t('notes.pinned')}
/>
)
}
if (layoutMode === 'table') {
return (
<div className="max-w-6xl mx-auto">
<div className="overflow-x-auto border border-border/40 rounded-2xl bg-card/30 shadow-sm">
<table className="w-full text-left border-collapse min-w-[720px]">
<thead>
<tr className="border-b border-border/30">
<th
className="w-[32%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => handleSort('title')}
>
<span className="inline-flex items-center gap-1">
{t('notes.tableTitle')} <SortIcon field="title" />
</span>
</th>
<th
className="w-[15%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => handleSort('notebook')}
>
<span className="inline-flex items-center gap-1">
{t('notes.tableNotebook')} <SortIcon field="notebook" />
</span>
</th>
<th className="w-[22%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground">
{t('notes.tableLabels')}
</th>
<th
className="w-[12%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => handleSort('tasks')}
>
<span className="inline-flex items-center gap-1">
{t('notes.tableTasks')} <SortIcon field="tasks" />
</span>
</th>
<th
className="w-[13%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => handleSort('modified')}
>
<span className="inline-flex items-center gap-1">
{t('notes.tableModified')} <SortIcon field="modified" />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-foreground/[0.03]">
{sortedNotes.map((note) => {
const title = getNoteDisplayTitle(note, untitled)
const nb = notebooks.find((n) => n.id === note.notebookId)
const nbColor = getNotebookColor(note.notebookId, nb?.name)
const stats = getNoteTasksStats(note.content || '')
return (
<tr
key={note.id}
onClick={() => onOpen(note)}
className="h-11 hover:bg-foreground/[0.02] cursor-pointer transition-colors group"
>
<td className="px-4 py-2 font-memento-serif text-[13px] font-medium truncate max-w-[280px]">
<span className="inline-flex items-center gap-2 truncate group-hover:text-brand-accent transition-colors">
{note.isPinned && <Pin size={11} className="text-amber-500 fill-amber-500 shrink-0" />}
{title}
</span>
</td>
<td className="px-4 py-2">
{nb ? (
<span
className={cn(
'inline-block px-2 py-0.5 rounded-full text-[9px] font-bold tracking-wide border truncate max-w-[125px]',
nbColor.bg,
nbColor.border,
nbColor.text,
)}
>
{nb.name}
</span>
) : (
<span className="text-muted-foreground text-[10px]"></span>
)}
</td>
<td className="px-4 py-2">
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={3} />
</td>
<td className="px-4 py-2 font-mono text-[10.5px] font-bold text-foreground/80">
{stats.total > 0 ? (
<span className={stats.completed === stats.total ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}>
{stats.completed}/{stats.total} <span className="text-[9px] font-sans"></span>
</span>
) : (
<span className="text-muted-foreground/40"></span>
)}
</td>
<td className="px-4 py-2 text-[10.5px] font-mono text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
return (
<div className="max-w-3xl space-y-16">
{pinnedNotes.length > 0 && (
<div className="mb-6">
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-2">
{t('notes.pinned')}
</h2>
<NotesEditorialView
notes={pinnedNotes}
onOpen={onOpen}
notebookName={notebookName}
onOpenHistory={onOpenHistory}
onTogglePin={onTogglePin}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onMoveToNotebook={onMoveToNotebook}
onNotePatch={onNotePatch}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
/>
</div>
)}
{notes.filter((n) => !n.isPinned).length > 0 && (
<NotesEditorialView
notes={notes.filter((n) => !n.isPinned)}
onOpen={onOpen}
notebookName={notebookName}
onOpenHistory={onOpenHistory}
onTogglePin={onTogglePin}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onMoveToNotebook={onMoveToNotebook}
onNotePatch={onNotePatch}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
/>
)}
</div>
)
}
function formatGridCardDate(date: Date | string, language: string): string {
const d = typeof date === 'string' ? new Date(date) : date
const locale = language === 'fr' ? fr : enUS
if (language === 'fa') {
return formatAbsoluteDateLocalized(d, language, 'd MMM yyyy', locale)
}
const month = d.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' })
const day = d.getUTCDate()
const year = d.getUTCFullYear()
return `${month.toUpperCase()} ${day}, ${year}`
}
type GridCardSharedProps = {
note: Note
index: number
untitled: string
allLabels: { name: string; type?: 'ai' | 'user' }[]
notebooks: { id: string; name: string }[]
aiIllustrationEnabled?: boolean
onOpen: (note: Note) => void
onTogglePin?: (note: Note) => void | Promise<void>
onDeleteNote?: (note: Note) => void | Promise<void>
onMoveToNotebook?: (note: Note, notebookId: string | null) => void | Promise<void>
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
isOverlay?: boolean
}
function NotesMasonryGrid({
pinnedNotes,
unpinnedNotes,
pinnedLabel,
onGridReorder,
...cardProps
}: {
pinnedNotes: Note[]
unpinnedNotes: Note[]
pinnedLabel: string
onGridReorder?: (orderedIds: string[]) => void | Promise<void>
} & Omit<GridCardSharedProps, 'note' | 'index' | 'isOverlay'>) {
const [activeId, setActiveId] = useState<string | null>(null)
const displayNotes = useMemo(
() => [...pinnedNotes, ...unpinnedNotes],
[pinnedNotes, unpinnedNotes],
)
const activeNote = useMemo(
() => displayNotes.find((n) => n.id === activeId) ?? null,
[displayNotes, activeId],
)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }),
)
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string)
}, [])
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
setActiveId(null)
if (!over || active.id === over.id || !onGridReorder) return
const activeIdx = displayNotes.findIndex((n) => n.id === active.id)
const overIdx = displayNotes.findIndex((n) => n.id === over.id)
if (activeIdx === -1 || overIdx === -1) return
const activeNoteItem = displayNotes[activeIdx]
const overNoteItem = displayNotes[overIdx]
if (activeNoteItem.isPinned !== overNoteItem.isPinned) return
const reordered = arrayMove(displayNotes, activeIdx, overIdx)
onGridReorder(reordered.map((n) => n.id))
},
[displayNotes, onGridReorder],
)
const sortEnabled = Boolean(onGridReorder)
return (
<DndContext
id="notes-grid-masonry"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="max-w-6xl mx-auto space-y-8">
{pinnedNotes.length > 0 && (
<div>
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-1">
{pinnedLabel}
</h2>
<NotesGridSection
notes={pinnedNotes}
sortEnabled={sortEnabled}
{...cardProps}
/>
</div>
)}
{unpinnedNotes.length > 0 && (
<NotesGridSection
notes={unpinnedNotes}
sortEnabled={sortEnabled}
indexOffset={pinnedNotes.length}
{...cardProps}
/>
)}
</div>
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease' }}>
{activeNote ? (
<div className="cursor-grabbing shadow-2xl rounded-2xl opacity-95">
<GridCard note={activeNote} index={0} isOverlay {...cardProps} />
</div>
) : null}
</DragOverlay>
</DndContext>
)
}
function NotesGridSection({
notes,
sortEnabled,
indexOffset = 0,
untitled,
allLabels,
notebooks,
aiIllustrationEnabled,
onOpen,
onTogglePin,
onDeleteNote,
onMoveToNotebook,
onNoteIllustrationGenerated,
className,
}: Omit<GridCardSharedProps, 'note' | 'index' | 'isOverlay'> & {
notes: Note[]
sortEnabled?: boolean
indexOffset?: number
className?: string
}) {
const ids = useMemo(() => notes.map((n) => n.id), [notes])
const grid = (
<div className={cn('grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6', className)}>
{notes.map((note, index) =>
sortEnabled ? (
<SortableGridCard
key={note.id}
note={note}
index={indexOffset + index}
untitled={untitled}
allLabels={allLabels}
notebooks={notebooks}
aiIllustrationEnabled={aiIllustrationEnabled}
onOpen={onOpen}
onTogglePin={onTogglePin}
onDeleteNote={onDeleteNote}
onMoveToNotebook={onMoveToNotebook}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
/>
) : (
<GridCard
key={note.id}
note={note}
index={indexOffset + index}
untitled={untitled}
allLabels={allLabels}
notebooks={notebooks}
aiIllustrationEnabled={aiIllustrationEnabled}
onOpen={onOpen}
onTogglePin={onTogglePin}
onDeleteNote={onDeleteNote}
onMoveToNotebook={onMoveToNotebook}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
/>
),
)}
</div>
)
if (!sortEnabled) return grid
return (
<SortableContext items={ids} strategy={rectSortingStrategy}>
{grid}
</SortableContext>
)
}
function SortableGridCard(props: GridCardSharedProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.note.id,
})
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
'touch-none cursor-grab active:cursor-grabbing',
isDragging && 'opacity-40',
)}
>
<GridCard {...props} />
</div>
)
}
function GridCard({
note,
index,
untitled,
allLabels,
notebooks,
aiIllustrationEnabled,
onOpen,
onTogglePin,
onDeleteNote,
onMoveToNotebook,
onNoteIllustrationGenerated,
isOverlay = false,
}: GridCardSharedProps) {
const router = useRouter()
const { t, language } = useLanguage()
const title = getNoteDisplayTitle(note, untitled)
const excerpt = getNotePlainExcerpt(note, 110)
const stats = getNoteTasksStats(note.content || '')
const formattedDate = formatGridCardDate(note.updatedAt, language)
const handlePinClick = (e: React.MouseEvent) => {
e.stopPropagation()
onTogglePin?.(note)
}
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
onDeleteNote?.(note)
}
const handleBrainstormClick = (e: React.MouseEvent) => {
e.stopPropagation()
const seed = `${title}\n\n${excerpt}`.trim() || title
router.push(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`)
}
return (
<motion.div
initial={isOverlay ? false : { opacity: 0, y: 15 }}
animate={isOverlay ? undefined : { opacity: 1, y: 0 }}
transition={isOverlay ? undefined : { delay: 0.04 * index, duration: 0.5 }}
onClick={() => onOpen(note)}
className="bg-card/60 border border-border/40 rounded-2xl overflow-hidden hover:shadow-md hover:border-brand-accent/30 transition-all duration-300 group/card cursor-pointer flex flex-col relative"
>
<div className="aspect-[16/10] bg-muted/30 border-b border-border/20 overflow-hidden relative">
<NoteGridThumbnail
note={note}
aiIllustrationEnabled={aiIllustrationEnabled}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
/>
{note.isPinned && (
<div className="absolute top-3 start-3 bg-background/90 backdrop-blur-sm p-1.5 rounded-full shadow-sm border border-border/40 text-amber-500">
<Pin size={11} className="fill-amber-500" />
</div>
)}
{stats.total > 0 && (
<div className="absolute top-3 end-3 bg-background/90 backdrop-blur-sm py-1 px-2.5 rounded-full shadow-sm border border-border/40 text-[9.5px] font-mono font-bold text-muted-foreground">
{stats.completed}/{stats.total}
</div>
)}
</div>
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
<div className="space-y-2.5">
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={2} />
<h3 className="font-memento-serif text-base font-semibold text-foreground leading-snug line-clamp-2 group-hover/card:text-brand-accent transition-colors">
{title}
</h3>
{excerpt && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3 font-light">{excerpt}</p>
)}
</div>
<div className="flex items-center justify-between pt-3 border-t border-foreground/[0.03] dark:border-white/[0.03] text-[9.5px] text-muted-foreground font-medium uppercase tracking-wider">
<span>{formattedDate}</span>
<div className="flex items-center gap-1 opacity-0 group-hover/card:opacity-100 transition-opacity">
<button
type="button"
onClick={handleBrainstormClick}
className="p-1 px-2 rounded-full hover:bg-brand-accent/10 text-brand-accent transition-all flex items-center gap-1 cursor-pointer"
title={t('notes.brainstormThisIdea') || 'Brainstormer cette idée'}
aria-label={t('notes.brainstormThisIdeaAria') || t('notes.brainstormThisIdea') || 'Brainstormer cette idée'}
>
<Wind size={11} />
</button>
<button
type="button"
onClick={handlePinClick}
className="p-1 px-2 rounded-full hover:bg-muted text-foreground transition-all cursor-pointer"
title={note.isPinned ? (t('notes.unpin') || 'Désépingler') : (t('notes.pin') || 'Épingler')}
aria-label={note.isPinned ? (t('notes.unpin') || 'Désépingler') : (t('notes.pin') || 'Épingler')}
>
<Pin size={11} className={note.isPinned ? 'fill-amber-500 text-amber-500' : ''} />
</button>
{onMoveToNotebook && (
<MoveToNotebookPicker
notebooks={notebooks}
currentNotebookId={note.notebookId}
onSelect={(notebookId) => onMoveToNotebook(note, notebookId)}
align="end"
preferDropUp
>
<button
type="button"
className="p-1 px-2 rounded-full hover:bg-muted text-foreground transition-all cursor-pointer"
title={t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
aria-label={t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
>
<FolderOpen size={11} />
</button>
</MoveToNotebookPicker>
)}
<button
type="button"
onClick={handleDeleteClick}
className="p-1 px-2 rounded-full hover:bg-rose-50 dark:hover:bg-rose-500/10 text-rose-500 transition-all cursor-pointer"
title={t('notes.delete') || 'Supprimer'}
aria-label={t('notes.delete') || 'Supprimer'}
>
<Trash2 size={11} />
</button>
</div>
</div>
</div>
</motion.div>
)
}