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

470 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useTransition, useEffect, useRef } from 'react'
import type { Note } from '@/lib/types'
import { getNoteFeedImage, getNotePlainExcerpt, getNoteDisplayTitle } from '@/lib/note-preview'
import { useLanguage } from '@/lib/i18n'
import { emitNoteChange, type NoteCollectionActions } from '@/lib/note-change-sync'
import { motion, AnimatePresence } from 'motion/react'
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2, Bell, FolderOpen, FileText } from 'lucide-react'
import { useLabelsQuery } from '@/lib/query-hooks'
import { useSession } from 'next-auth/react'
import { getAISettings } from '@/app/actions/ai-settings'
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoveToNotebookPickerPortal } from '@/components/move-to-notebook-picker'
import { deleteNote, toggleArchive, togglePin, updateNote } from '@/app/actions/notes'
import { ReminderDialog } from '@/components/reminder-dialog'
import { useNotebooks } from '@/context/notebooks-context'
import { toast } from 'sonner'
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
import { NoteSelectCheckbox } from '@/components/note-select-checkbox'
import { dateTextDirection, formatNoteCalendarDate } from '@/lib/utils/format-localized-date'
import { sanitizeIllustrationSvg } from '@/lib/sanitize-content'
import { cn } from '@/lib/utils'
import { useHydrated } from '@/lib/use-hydrated'
type NotesEditorialViewProps = {
notes: Note[]
onOpen: (note: Note, readOnly?: boolean) => void
notebookName?: string
onOpenHistory?: (note: Note) => void
selection?: {
selectedIds: ReadonlySet<string>
onToggle: (id: string) => void
}
} & NoteCollectionActions
function formatNoteDate(date: Date | string, language: string): string {
return formatNoteCalendarDate(date, language)
}
export function EditorialNoteMenu({
note,
onOpen,
onOpenHistory,
onTogglePin,
onDeleteNote,
onArchiveNote,
onMoveToNotebook,
onNotePatch,
}: {
note: Note
onOpen: (note: Note) => void
onOpenHistory?: (note: Note) => void
} & NoteCollectionActions) {
const { t } = useLanguage()
const { notebooks } = useNotebooks()
const [, startTransition] = useTransition()
const [showReminder, setShowReminder] = useState(false)
const [movePickerOpen, setMovePickerOpen] = useState(false)
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation()
if (onDeleteNote) {
onDeleteNote(note)
return
}
setConfirmDeleteOpen(true)
}
const confirmFallbackDelete = () => {
startTransition(async () => {
try {
await deleteNote(note.id, { skipRevalidation: true })
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
showNoteTrashedToast(note, t)
} catch {
toast.error(t('general.error'))
}
})
}
const handleArchive = (e: React.MouseEvent) => {
e.stopPropagation()
if (onArchiveNote) {
onArchiveNote(note)
return
}
startTransition(async () => {
try {
await toggleArchive(note.id, !note.isArchived, { skipRevalidation: true })
emitNoteChange({ type: 'updated', note: { ...note, isArchived: !note.isArchived } })
toast.success(note.isArchived ? (t('notes.unarchived') || 'Désarchivée') : (t('notes.archived') || 'Archivée'))
} catch {
toast.error(t('general.error'))
}
})
}
const handlePin = (e: React.MouseEvent) => {
e.stopPropagation()
if (onTogglePin) {
onTogglePin(note)
return
}
startTransition(async () => {
try {
const nextPinned = !note.isPinned
await togglePin(note.id, nextPinned, { skipRevalidation: true })
emitNoteChange({ type: 'updated', note: { ...note, isPinned: nextPinned } })
} catch {
toast.error(t('general.error'))
}
})
}
const handleMoveToNotebook = (notebookId: string | null) => {
if (onMoveToNotebook) {
onMoveToNotebook(note, notebookId)
return
}
startTransition(async () => {
try {
await updateNote(note.id, { notebookId }, { skipRevalidation: true })
emitNoteChange({ type: 'updated', note: { ...note, notebookId } })
toast.success(t('notebookSuggestion.movedToNotebook') || 'Note déplacée')
} catch {
toast.error(t('general.error'))
}
})
}
const patchReminder = (reminder: Date | null) => {
startTransition(async () => {
try {
await updateNote(note.id, { reminder }, { skipRevalidation: true })
const patch = { reminder: (reminder?.toISOString() ?? null) as any }
onNotePatch?.(note.id, patch)
emitNoteChange({ type: 'updated', note: { ...note, reminder: patch.reminder } as any })
setShowReminder(false)
} catch {
toast.error(t('general.error'))
}
})
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={e => e.stopPropagation()}>
<button
ref={menuTriggerRef}
className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-muted/60 text-muted-foreground hover:text-foreground cursor-pointer"
aria-label={t('notes.moreOptions') || 'Options'}
>
<MoreHorizontal size={15} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={e => { e.stopPropagation(); onOpen(note) }}>
<Pencil className="h-4 w-4 me-2 text-foreground/50" />
{t('notes.open') || 'Ouvrir'}
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePin}>
<Pin className="h-4 w-4 me-2 text-foreground/50" />
{note.isPinned ? (t('notes.unpin') || 'Désépingler') : (t('notes.pin') || 'Épingler')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleArchive}>
<Archive className="h-4 w-4 me-2 text-foreground/50" />
{note.isArchived ? (t('notes.unarchive') || 'Désarchiver') : (t('notes.archive') || 'Archiver')}
</DropdownMenuItem>
{onOpenHistory && (
<DropdownMenuItem onClick={e => { e.stopPropagation(); onOpenHistory(note) }}>
<History className="h-4 w-4 me-2 text-foreground/50" />
{t('notes.history') || 'Historique'}
</DropdownMenuItem>
)}
{/* Rappel */}
<DropdownMenuItem onClick={e => { e.stopPropagation(); setShowReminder(true) }}>
<Bell className="h-4 w-4 me-2 text-foreground/50" />
{note.reminder
? (t('reminder.changeReminder') || 'Modifier le rappel')
: (t('reminder.setReminder') || 'Définir un rappel')}
</DropdownMenuItem>
{/* Déplacer vers un carnet */}
<DropdownMenuItem
onClick={e => {
e.stopPropagation()
setMovePickerOpen(true)
}}
>
<FolderOpen className="h-4 w-4 me-2 text-foreground/50" />
{t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive focus:bg-destructive/10">
<Trash2 className="h-4 w-4 me-2" />
{t('notes.delete') || 'Supprimer'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<MoveToNotebookPickerPortal
open={movePickerOpen}
onOpenChange={setMovePickerOpen}
anchorRef={menuTriggerRef}
notebooks={notebooks}
currentNotebookId={note.notebookId}
onSelect={handleMoveToNotebook}
align="end"
preferDropUp
/>
{/* ReminderDialog hors du DropdownMenu pour éviter les conflits de portail */}
<ReminderDialog
open={showReminder}
onOpenChange={setShowReminder}
currentReminder={note.reminder ? new Date(note.reminder) : null}
onSave={(date) => patchReminder(date)}
onRemove={() => patchReminder(null)}
/>
<ConfirmDeleteNoteDialog
open={confirmDeleteOpen}
onOpenChange={setConfirmDeleteOpen}
onConfirm={confirmFallbackDelete}
/>
</>
)
}
function EditorialThumbnail({
note,
title,
aiIllustrationEnabled,
onNoteIllustrationGenerated,
}: {
note: Note
title: string
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)
}
}
return (
<div className={cn('relative shrink-0', img || note.illustrationSvg ? 'w-24 sm:w-32 aspect-[4/3] overflow-hidden rounded-md bg-muted/30' : 'flex w-11 flex-col items-center gap-1')}>
{img ? (
<img
src={img}
alt=""
className="w-full h-full object-cover"
/>
) : note.illustrationSvg ? (
<div
className="w-full h-full flex items-center justify-center bg-muted/30 p-2 [&_svg]:max-w-full [&_svg]:max-h-full [&_svg]:w-auto [&_svg]:h-auto"
// SVG déjà sanitisé côté serveur (note-illustration.ts)
dangerouslySetInnerHTML={{ __html: sanitizeIllustrationSvg(note.illustrationSvg) }}
aria-hidden
/>
) : (
<>
<span className="flex h-11 w-11 items-center justify-center rounded-md bg-brand-accent/10 text-brand-accent" aria-hidden>
<FileText size={22} strokeWidth={1.5} />
</span>
{aiIllustrationEnabled && (
<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="flex h-11 w-11 items-center justify-center rounded-md text-brand-accent transition-colors hover:bg-brand-accent/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent"
onClick={handleGenerateSvg}
disabled={busy}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4 text-primary" />}
</button>
)}
</>
)}
</div>
)
}
function NoteTag({ labelName, allLabels }: { labelName: string; allLabels: any[] }) {
const labelDef = allLabels?.find(l => l.name === labelName)
const isAI = labelDef?.type === 'ai'
return (
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-paper dark:bg-white/5 text-[9px] font-bold uppercase tracking-[0.15em] text-muted-foreground border border-border/40">
{isAI && <Sparkles size={8} className="text-brand-accent" />}
{labelName}
</div>
)
}
export function NotesEditorialView({
notes,
onOpen,
notebookName,
onOpenHistory,
onTogglePin,
onDeleteNote,
onArchiveNote,
onMoveToNotebook,
onNotePatch,
onNoteIllustrationGenerated,
selection,
}: NotesEditorialViewProps) {
const { t, language } = useLanguage()
const { data: session } = useSession()
const { data: allLabels } = useLabelsQuery()
const hydrated = useHydrated()
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])
return (
<div className="mx-auto w-full min-w-0 max-w-3xl divide-y divide-border/50">
<AnimatePresence>
{notes.map((note: Note, index: number) => {
const title = getNoteDisplayTitle(note, t('notes.untitled') || 'Untitled')
const excerpt = getNotePlainExcerpt(note)
const dateStr = formatNoteDate(note.createdAt, language)
const editorialRtl = language === 'fa' || language === 'ar'
const dateDir = dateTextDirection(language)
const dateLang = language === 'fa' ? 'fa' : language === 'ar' ? 'ar' : undefined
return (
<motion.article
key={note.id}
initial={hydrated ? { opacity: 0, y: 20 } : false}
animate={{ opacity: 1, y: 0 }}
transition={hydrated ? { delay: 0.05 * index, duration: 0.6 } : { duration: 0 }}
className="min-w-0 space-y-3 group cursor-pointer relative py-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent"
onClick={() => onOpen(note)}
onKeyDown={(e) => { if (e.key === 'Enter') onOpen(note) }}
tabIndex={0}
role="button"
>
{/* Date / breadcrumb — isolated bidi so Latin notebook name + Jalali date dont reorder wrongly */}
<div
className="pe-12 text-xs font-medium text-muted-foreground"
dir={editorialRtl ? 'rtl' : 'ltr'}
>
{notebookName ? (
<>
<bdi className={cn(editorialRtl && 'uppercase tracking-[0.2em]')} dir="auto">{notebookName}</bdi>
<span className="mx-1.5 select-none text-muted-foreground/80" aria-hidden>
</span>
<bdi dir={dateDir} lang={dateLang}>
{dateStr}
</bdi>
</>
) : (
<bdi dir={dateDir} lang={dateLang}>
{dateStr}
</bdi>
)}
</div>
{/* Actions menu — absolutely positioned at top-right */}
<div
className="absolute top-3 end-0 opacity-100 md:opacity-0 md:group-hover:opacity-100 focus-within:opacity-100 transition-opacity"
onClick={e => e.stopPropagation()}
>
<EditorialNoteMenu
note={note}
onOpen={onOpen}
onOpenHistory={onOpenHistory}
onTogglePin={onTogglePin}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onMoveToNotebook={onMoveToNotebook}
onNotePatch={onNotePatch}
/>
</div>
<h2 className="font-memento-serif text-lg sm:text-xl leading-snug font-medium text-foreground flex items-start gap-2">
<span className="inline-flex items-start gap-2 min-w-0 flex-1">
{selection && (
<NoteSelectCheckbox
checked={selection.selectedIds.has(note.id)}
onToggle={() => selection.onToggle(note.id)}
label={t('notes.selectNote')}
/>
)}
{note.isPinned && <Pin size={14} className="text-amber-500 fill-amber-500 shrink-0" />}
{note.historyEnabled && <History size={14} className="text-emerald-500 shrink-0" />}
<span className="min-w-0 break-words [overflow-wrap:anywhere]">{title}</span>
</span>
<span className="opacity-0 group-hover:opacity-30 transition-opacity shrink-0">
<ChevronRight size={20} />
</span>
</h2>
<div className="flex gap-3 sm:gap-4 items-start min-w-0">
<EditorialThumbnail
note={note}
title={title}
aiIllustrationEnabled={aiIllustrationEnabled}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
/>
<div className="min-w-0 space-y-2 flex-1">
{note.labels && note.labels.length > 0 && (
<div className="flex flex-wrap gap-2">
{note.labels.slice(0, 2).map((labelName) => (
<NoteTag key={labelName} labelName={labelName} allLabels={allLabels || []} />
))}
</div>
)}
{excerpt ? (
<p className="text-sm leading-relaxed text-foreground/80 line-clamp-2 break-words [overflow-wrap:anywhere]">
{excerpt}
</p>
) : (
<p className="text-sm italic text-muted-foreground">{t('notes.noContent')}</p>
)}
<span className="text-xs text-brand-accent font-medium inline-flex items-center gap-1">
{t('notes.readMore') || 'Read more'}
<ChevronRight size={10} />
</span>
</div>
</div>
</motion.article>
)
})}
</AnimatePresence>
</div>
)
}