'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 { 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 (
{labelNames.slice(0, max).map((labelName) => {
const def = allLabels.find((l) => l.name === labelName)
return (
)
})}
{labelNames.length > max && (
+{labelNames.length - max}
)}
)
}
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 (
{hasIllustration && onDelete && (
)}
)
}
function NoteGridThumbnail({
note,
aiIllustrationEnabled,
onNoteIllustrationGenerated,
onNoteIllustrationDeleted,
}: {
note: Note
aiIllustrationEnabled?: boolean
onNoteIllustrationGenerated?: (noteId: string) => void | Promise
onNoteIllustrationDeleted?: (noteId: string) => void | Promise
}) {
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('Erreur lors de la suppression')
} finally {
setBusy(false)
}
}
const aiButtonClass = 'opacity-0 group-hover/card:opacity-100 focus-visible:opacity-100 transition-opacity duration-200'
if (img) {
return (
)
}
if (note.illustrationSvg) {
return (
<>
{aiIllustrationEnabled && (
)}
>
)
}
return (
<>
{aiIllustrationEnabled && (
)}
>
)
}
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
} & Partial
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' ? :
) : null
if (layoutMode === 'grid') {
return (
!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 (
| handleSort('title')}
>
{t('notes.tableTitle')}
|
handleSort('notebook')}
>
{t('notes.tableNotebook')}
|
{t('notes.tableLabels')}
|
handleSort('modified')}
>
{t('notes.tableModified')}
|
{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 (
onOpen(note)}
className="h-11 hover:bg-foreground/[0.02] cursor-pointer transition-colors group"
>
|
{note.isPinned && }
{title}
|
{nb ? (
{nb.name}
) : (
—
)}
|
|
{formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}
|
)
})}
)
}
return (
{pinnedNotes.length > 0 && (
{t('notes.pinned')}
)}
{notes.filter((n) => !n.isPinned).length > 0 && (
!n.isPinned)}
onOpen={onOpen}
notebookName={notebookName}
onOpenHistory={onOpenHistory}
onTogglePin={onTogglePin}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onMoveToNotebook={onMoveToNotebook}
onNotePatch={onNotePatch}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
/>
)}
)
}
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
onDeleteNote?: (note: Note) => void | Promise
onMoveToNotebook?: (note: Note, notebookId: string | null) => void | Promise
onNoteIllustrationGenerated?: (noteId: string) => void | Promise
onNoteIllustrationDeleted?: (noteId: string) => void | Promise
isOverlay?: boolean
}
function NotesMasonryGrid({
pinnedNotes,
unpinnedNotes,
pinnedLabel,
onGridReorder,
...cardProps
}: {
pinnedNotes: Note[]
unpinnedNotes: Note[]
pinnedLabel: string
onGridReorder?: (orderedIds: string[]) => void | Promise
} & Omit) {
const [activeId, setActiveId] = useState(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 (
{pinnedNotes.length > 0 && (
{pinnedLabel}
)}
{unpinnedNotes.length > 0 && (
)}
{activeNote ? (
) : null}
)
}
function NotesGridSection({
notes,
sortEnabled,
indexOffset = 0,
untitled,
allLabels,
notebooks,
aiIllustrationEnabled,
onOpen,
onTogglePin,
onDeleteNote,
onMoveToNotebook,
onNoteIllustrationGenerated,
className,
}: Omit & {
notes: Note[]
sortEnabled?: boolean
indexOffset?: number
className?: string
}) {
const ids = useMemo(() => notes.map((n) => n.id), [notes])
const grid = (
{notes.map((note, index) =>
sortEnabled ? (
) : (
),
)}
)
if (!sortEnabled) return grid
return (
{grid}
)
}
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 (
)
})
const GridCard = memo(function GridCard({
note,
index,
untitled,
allLabels,
notebooks,
aiIllustrationEnabled,
onOpen,
onTogglePin,
onDeleteNote,
onMoveToNotebook,
onNoteIllustrationGenerated,
onNoteIllustrationDeleted,
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 (
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"
>
{title}
{excerpt || '\u00A0'}
{formattedDate}
{onMoveToNotebook && (
onMoveToNotebook(note, notebookId)}
align="end"
preferDropUp
>
)}
)
})