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.
902 lines
32 KiB
TypeScript
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>
|
|
)
|
|
})
|