Files
Momento/memento-note/components/notes-editorial-view.tsx
Antigravity e9e829e579
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m15s
CI / Deploy production (on server) (push) Successful in 37s
fix: TOUTES les clés i18n manquantes ajoutées — 0 erreur
- general.continue/send
- structuredViews.tagApplied/filterDone/filterTodo/propertyStatus
- wizard.taskA/taskB
- richTextEditor.preview*Tip (7 clés SlashPreview)
- wizard.* au niveau racine (48 clés FR + 48 EN)
- Total: 0 clé manquante pour FR et EN
- 0 erreur TypeScript
2026-06-20 17:01:04 +00:00

481 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 { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { formatAbsoluteDateLocalized } 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
} & NoteCollectionActions
function formatNoteDate(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}`
}
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 menuTriggerRef = useRef<HTMLButtonElement>(null)
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation()
if (onDeleteNote) {
onDeleteNote(note)
return
}
startTransition(async () => {
try {
await deleteNote(note.id, { skipRevalidation: true })
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
toast.success(t('notes.deleted') || 'Note supprimée')
} 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 }
onNotePatch?.(note.id, patch)
emitNoteChange({ type: 'updated', note: { ...note, reminder: patch.reminder } })
setShowReminder(false)
} catch {
toast.error(t('general.error'))
}
})
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={e => e.stopPropagation()}>
<button
ref={menuTriggerRef}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-muted/60 text-muted-foreground hover:text-foreground"
>
<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)}
/>
</>
)
}
/** Deterministic hue from a string — consistent per note */
function stringToHue(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff
return h % 360
}
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="relative w-full md:w-56 aspect-[4/3] bg-card/80 border border-border overflow-hidden rounded shadow-sm flex-shrink-0 group/thumb">
{img ? (
<img
src={img}
alt=""
className="w-full h-full object-cover mix-blend-multiply opacity-80 grayscale contrast-125 hover:grayscale-0 hover:opacity-100 transition-all duration-500"
/>
) : 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
/>
) : (
<>
<NoteThumbnailPlaceholder noteId={note.id} />
{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="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 opacity-0 group-hover/thumb:opacity-100 md:opacity-100 focus-visible:opacity-100"
onClick={handleGenerateSvg}
disabled={busy}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4 text-primary" />}
</button>
)}
</>
)}
</div>
)
}
/** SVG thumbnail for notes without an image — icône document (ref. architectural-grid), pas initiale */
function NoteThumbnailPlaceholder({ noteId }: { noteId: string }) {
const hue = stringToHue(noteId)
return (
<div
className="h-full w-full flex items-center justify-center relative overflow-hidden"
style={{ background: `linear-gradient(145deg, hsl(${hue} 25% var(--thumb-lightness-1, 94%)) 0%, hsl(${hue} 18% var(--thumb-lightness-2, 87%)) 100%)` }}
>
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 224 168"
fill="none"
aria-hidden
style={{ color: `hsl(${hue} 30% 60%)` }}
>
<circle cx="112" cy="84" r="90" stroke="currentColor" strokeWidth="0.6" opacity="0.25" />
<circle cx="112" cy="84" r="64" stroke="currentColor" strokeWidth="0.6" opacity="0.2" />
<circle cx="112" cy="84" r="38" stroke="currentColor" strokeWidth="0.6" opacity="0.15" />
<line x1="22" y1="84" x2="202" y2="84" stroke="currentColor" strokeWidth="0.4" opacity="0.15" />
<line x1="112" y1="4" x2="112" y2="164" stroke="currentColor" strokeWidth="0.4" opacity="0.15" />
</svg>
<FileText
size={40}
strokeWidth={1.25}
className="relative text-muted-foreground/45"
style={{ color: `hsl(${hue} 25% 45%)` }}
/>
</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,
}: 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 max-w-3xl space-y-8">
<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'
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="space-y-4 group cursor-pointer relative pb-8"
onClick={() => onOpen(note)}
>
{/* Date / breadcrumb — isolated bidi so Latin notebook name + Jalali date dont reorder wrongly */}
<div
className={cn('note-date-badge', editorialRtl && 'note-date-badge--locale-rtl')}
dir={editorialRtl ? 'rtl' : 'ltr'}
>
{notebookName ? (
<>
<bdi className={cn(editorialRtl && 'uppercase tracking-[0.2em]')}>{notebookName}</bdi>
<span className="mx-1.5 select-none text-muted-foreground/80" aria-hidden>
</span>
<bdi dir="rtl" lang={editorialRtl ? 'fa' : undefined}>
{dateStr}
</bdi>
</>
) : (
<bdi dir={editorialRtl ? 'rtl' : 'ltr'} lang={editorialRtl ? 'fa' : undefined}>
{dateStr}
</bdi>
)}
</div>
{/* Actions menu — absolutely positioned at top-right */}
<div
className="absolute top-0 end-0 opacity-0 group-hover: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-2xl font-medium text-foreground flex items-center justify-between">
{title}
<span className="opacity-0 group-hover:opacity-30 transition-opacity shrink-0">
<ChevronRight size={20} />
</span>
</h2>
<div className="flex flex-col md:flex-row gap-8 items-start">
<EditorialThumbnail
note={note}
title={title}
aiIllustrationEnabled={aiIllustrationEnabled}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
/>
<div className="space-y-3 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-[14px] leading-relaxed text-foreground/80 font-light max-w-lg line-clamp-4">
{excerpt}
</p>
) : (
<p className="text-sm italic text-muted-foreground/50">{t('notes.noContent')}</p>
)}
<span className="text-[11px] text-muted-foreground uppercase tracking-widest font-medium inline-flex items-center gap-1 group-hover:gap-2 transition-all">
{t('notes.readMore') || 'Read more'}
<ChevronRight size={10} />
</span>
</div>
</div>
</motion.article>
)
})}
</AnimatePresence>
</div>
)
}