- 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
481 lines
18 KiB
TypeScript
481 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 { 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 don’t 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>
|
||
)
|
||
}
|