Files
Momento/memento-note/components/notes-list-views.tsx
Antigravity 3bdbec575a feat(ui): panneau repliable, carnets plus lisibles et accueil plus clair
Harmonise la liste des notes et l’éditeur (titres, dates, retour),
rend la saisie rapide et les raccourcis de l’accueil utilisables
sur téléphone, et conserve le panneau latéral repliable.
2026-09-05 20:53:48 +00:00

902 lines
32 KiB
TypeScript

'use client'
import { useMemo, useState, useEffect, useCallback, memo } 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 { sanitizeIllustrationSvg } from '@/lib/sanitize-content'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { useLabelsQuery } from '@/lib/query-hooks'
import { getAISettings } from '@/app/actions/ai-settings'
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
import { LabelBadge } from '@/components/label-badge'
import { formatNoteCalendarDate } 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,
ChevronUp,
ChevronDown,
Wind,
Trash2,
FolderOpen,
Sparkles,
Loader2,
History,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useHydrated } from '@/lib/use-hydrated'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { NoteSelectCheckbox } from '@/components/note-select-checkbox'
export type NotesLayoutMode = 'grid' | 'list' | 'table' | 'kanban' | 'gallery'
export type NotesClassicLayoutMode = 'grid' | 'list' | 'table'
export function isClassicLayoutMode(mode: NotesLayoutMode): mode is NotesClassicLayoutMode {
return mode === 'grid' || mode === 'list' || mode === 'table'
}
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,
onDelete,
hasIllustration,
className,
}: {
busy: boolean
onClick: (e: React.MouseEvent) => void
onDelete?: (e: React.MouseEvent) => void
hasIllustration?: boolean
className?: string
}) {
const { t } = useLanguage()
return (
<div className={cn('absolute bottom-2 end-2 flex items-center gap-1.5 z-10', className)}>
{hasIllustration && onDelete && (
<button
type="button"
aria-label={t('notes.deleteIllustration') || 'Supprimer l\'illustration'}
title={t('notes.deleteIllustration') || 'Supprimer l\'illustration'}
className="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background/95 text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10 shadow-card-rest backdrop-blur-sm transition-colors"
onClick={onDelete}
disabled={busy}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
aria-label={hasIllustration
? (t('notes.regenerateIllustration') || 'Regénérer l\'illustration')
: (t('notes.generateIllustration') || 'Générer une illustration IA')}
title={hasIllustration
? (t('notes.regenerateIllustration') || 'Regénérer')
: (t('notes.generateIllustration') || 'Générer une illustration IA')}
className="flex h-8 w-8 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"
onClick={onClick}
disabled={busy}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5 text-primary" />}
</button>
</div>
)
}
function NoteGridThumbnail({
note,
aiIllustrationEnabled,
onNoteIllustrationGenerated,
onNoteIllustrationDeleted,
}: {
note: Note
aiIllustrationEnabled?: boolean
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
onNoteIllustrationDeleted?: (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) 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 handleDeleteSvg = async (e: React.MouseEvent) => {
e.stopPropagation()
if (busy) return
setBusy(true)
try {
const { updateNote } = await import('@/app/actions/notes')
await updateNote(note.id, { illustrationSvg: null })
await onNoteIllustrationDeleted?.(note.id)
} catch {
toast.error(t('notes.deleteError'))
} finally {
setBusy(false)
}
}
const aiButtonClass = 'opacity-0 group-hover/card:opacity-100 focus-visible:opacity-100 transition-opacity duration-200'
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: sanitizeIllustrationSvg(prepareNoteIllustrationForGrid(note.illustrationSvg)) }}
aria-hidden
/>
{aiIllustrationEnabled && (
<NoteGridIllustrationButton
busy={busy}
onClick={handleGenerateSvg}
onDelete={handleDeleteSvg}
hasIllustration
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[]
layoutMode: NotesLayoutMode
onOpen: (note: Note, readOnly?: boolean) => void
onOpenHistory?: (note: Note) => void
notebookName?: string
onGridReorder?: (orderedIds: string[]) => void | Promise<void>
selection?: {
selectedIds: ReadonlySet<string>
onToggle: (id: string) => void
onToggleAll?: () => void
allSelected?: boolean
}
} & Partial<NoteCollectionActions>
export function NotesListViews({
notes,
pinnedNotes = [],
layoutMode,
onOpen,
onOpenHistory,
notebookName,
onTogglePin,
onDeleteNote,
onArchiveNote,
onMoveToNotebook,
onNotePatch,
onNoteIllustrationGenerated,
onGridReorder,
selection,
}: NotesListViewsProps) {
const { t, language } = useLanguage()
const { data: session } = useSession()
const { notebooks } = useNotebooks()
const { data: allLabels = [] } = useLabelsQuery()
const [sortColumn, setSortColumn] = useState<'title' | 'notebook' | '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 handleSort = (field: 'title' | 'notebook' | '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 {
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 (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}
onOpenHistory={onOpenHistory}
onGridReorder={onGridReorder}
pinnedLabel={t('notes.pinned')}
selection={selection}
/>
)
}
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">
{selection && (
<th className="w-10 px-3 py-3">
<NoteSelectCheckbox
checked={!!selection.allSelected}
onToggle={() => selection.onToggleAll?.()}
label={selection.allSelected ? t('notes.deselectAll') : t('notes.selectAll')}
/>
</th>
)}
<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-[18%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => handleSort('modified')}
aria-sort={sortColumn === 'modified' ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
>
<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)
return (
<tr
key={note.id}
onClick={() => onOpen(note)}
onKeyDown={(e) => { if (e.key === 'Enter') onOpen(note) }}
tabIndex={0}
role="button"
className="h-11 hover:bg-foreground/[0.02] cursor-pointer transition-colors group focus-visible:bg-foreground/[0.04]"
>
{selection && (
<td className="px-3 py-2 w-10">
<NoteSelectCheckbox
checked={selection.selectedIds.has(note.id)}
onToggle={() => selection.onToggle(note.id)}
label={t('notes.selectNote')}
/>
</td>
)}
<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" />}
{note.historyEnabled && <History size={11} className="text-emerald-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 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}
selection={selection}
/>
</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}
selection={selection}
/>
)}
</div>
)
}
function formatGridCardDate(date: Date | string, language: string): string {
return formatNoteCalendarDate(date, language)
}
type NotesSelection = {
selectedIds: ReadonlySet<string>
onToggle: (id: string) => void
onToggleAll?: () => void
allSelected?: boolean
}
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>
onNoteIllustrationDeleted?: (noteId: string) => void | Promise<void>
onOpenHistory?: (note: Note) => void
isOverlay?: boolean
selection?: NotesSelection
}
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,
selection,
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 items-stretch', 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}
selection={selection}
/>
) : (
<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}
selection={selection}
/>
),
)}
</div>
)
if (!sortEnabled) return grid
return (
<SortableContext items={ids} strategy={rectSortingStrategy}>
{grid}
</SortableContext>
)
}
const SortableGridCard = memo(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 h-full',
isDragging && 'opacity-40',
)}
>
<GridCard {...props} />
</div>
)
})
const GridCard = memo(function GridCard({
note,
index,
untitled,
allLabels,
notebooks,
aiIllustrationEnabled,
onOpen,
onTogglePin,
onDeleteNote,
onMoveToNotebook,
onNoteIllustrationGenerated,
onNoteIllustrationDeleted,
onOpenHistory,
isOverlay = false,
selection,
}: GridCardSharedProps) {
const router = useRouter()
const { t, language } = useLanguage()
const hydrated = useHydrated()
const title = getNoteDisplayTitle(note, untitled)
const excerpt = getNotePlainExcerpt(note, 110)
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 || !hydrated ? false : { opacity: 0, y: 15 }}
animate={isOverlay ? undefined : { opacity: 1, y: 0 }}
transition={isOverlay || !hydrated ? 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 h-full"
>
<div className="aspect-[16/10] shrink-0 bg-muted/30 border-b border-border/20 overflow-hidden relative">
<NoteGridThumbnail
note={note}
aiIllustrationEnabled={aiIllustrationEnabled}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
onNoteIllustrationDeleted={onNoteIllustrationDeleted}
/>
{selection && !isOverlay && (
<div className="absolute top-2 start-2 z-20">
<NoteSelectCheckbox
checked={selection.selectedIds.has(note.id)}
onToggle={() => selection.onToggle(note.id)}
label={t('notes.selectNote')}
className="shadow-sm"
/>
</div>
)}
{note.isPinned && (
<div className={cn(
'absolute top-3 bg-background/90 backdrop-blur-sm p-1.5 rounded-full shadow-sm border border-border/40 text-amber-500',
selection ? 'start-10' : 'start-3',
)}>
<Pin size={11} className="fill-amber-500" />
</div>
)}
{note.historyEnabled && (
<div className="absolute top-3 end-3 bg-emerald-500/15 dark:bg-emerald-500/25 border border-emerald-500/20 p-1 rounded-full shadow-sm backdrop-blur-sm text-emerald-600 dark:text-emerald-400">
<History size={11} />
</div>
)}
</div>
<div className="p-5 flex flex-col flex-1 min-h-[11.5rem]">
<div className="space-y-2.5 flex-1">
<div className="min-h-[1.125rem]">
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={2} />
</div>
<h3 className="font-memento-serif text-base font-semibold text-foreground leading-snug truncate group-hover/card:text-brand-accent transition-colors">
{title}
</h3>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3 font-light min-h-[3.75rem]">
{excerpt || '\u00A0'}
</p>
</div>
<div className="flex items-center justify-between pt-3 mt-auto 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-100 sm:opacity-0 sm: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>
)}
{onOpenHistory && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onOpenHistory(note) }}
className={cn(
"p-1 px-2 rounded-full transition-all cursor-pointer",
note.historyEnabled
? "text-emerald-500 hover:bg-emerald-500/10"
: "text-muted-foreground/60 hover:bg-muted hover:text-foreground"
)}
title={note.historyEnabled ? (t('notes.history') || 'Historique') : (t('notes.enableHistory') || "Activer l'historique")}
aria-label={note.historyEnabled ? (t('notes.history') || 'Historique') : (t('notes.enableHistory') || "Activer l'historique")}
>
<History size={11} />
</button>
)}
<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>
)
})