Multiple feature additions and improvements across the application: - NextGen Editor: drag handles, smart paste, block actions - Structured views: Kanban and table layouts for notes - Architectural Grid: new brainstorming/agent interface prototype - Flashcards: SM-2 revision algorithm with AI generation - MCP server: robustness improvements - Graph/PDF chat: fix click propagation and copy behavior - Various UI/UX enhancements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
775 lines
27 KiB
TypeScript
775 lines
27 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo, useState, 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 { 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,
|
|
ChevronUp,
|
|
ChevronDown,
|
|
Wind,
|
|
Trash2,
|
|
FolderOpen,
|
|
Sparkles,
|
|
Loader2,
|
|
} 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'
|
|
|
|
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,
|
|
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[]
|
|
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 = [],
|
|
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 [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}
|
|
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-[18%] 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)
|
|
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 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 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}
|
|
/>
|
|
) : (
|
|
<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 h-full',
|
|
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 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}
|
|
/>
|
|
{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>
|
|
)}
|
|
</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-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>
|
|
)
|
|
}
|