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>
This commit is contained in:
941
memento-note/components/notes-list-views.tsx
Normal file
941
memento-note/components/notes-list-views.tsx
Normal file
@@ -0,0 +1,941 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user