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.
470 lines
18 KiB
TypeScript
470 lines
18 KiB
TypeScript
'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 don’t 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>
|
||
)
|
||
}
|