- Add Bell/reminder item to EditorialNoteMenu (notes-editorial-view.tsx) - Add FolderOpen submenu for moving notes between notebooks - Import ReminderDialog, useNotebooks, DropdownMenuSub components - Fix settings/general/page.tsx to pass only required props to GeneralSettingsClient Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useTransition, useEffect } from 'react'
|
|
import type { Note } from '@/lib/types'
|
|
import { getNoteFeedImage, getNotePlainExcerpt, getNoteDisplayTitle } from '@/lib/note-preview'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { useRefresh } from '@/lib/use-refresh'
|
|
import { motion, AnimatePresence } from 'motion/react'
|
|
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2, Bell, FolderOpen, StickyNote } from 'lucide-react'
|
|
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,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
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'
|
|
|
|
type NotesEditorialViewProps = {
|
|
notes: Note[]
|
|
onOpen: (note: Note, readOnly?: boolean) => void
|
|
notebookName?: string
|
|
onOpenHistory?: (note: Note) => void
|
|
}
|
|
|
|
function formatNoteDate(date: Date | string): string {
|
|
const d = typeof date === 'string' ? new Date(date) : date
|
|
return d.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
}).toUpperCase()
|
|
}
|
|
|
|
function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
|
note: Note
|
|
onOpen: (note: Note) => void
|
|
onOpenHistory?: (note: Note) => void
|
|
}) {
|
|
const { t } = useLanguage()
|
|
const { refreshNotes } = useRefresh()
|
|
const { notebooks } = useNotebooks()
|
|
const [, startTransition] = useTransition()
|
|
const [showReminder, setShowReminder] = useState(false)
|
|
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
|
|
|
const handleDelete = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
startTransition(async () => {
|
|
try {
|
|
await deleteNote(note.id)
|
|
refreshNotes(note?.notebookId)
|
|
toast.success(t('notes.deleted') || 'Note supprimée')
|
|
} catch {
|
|
toast.error(t('general.error'))
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleArchive = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
startTransition(async () => {
|
|
try {
|
|
await toggleArchive(note.id, !note.isArchived)
|
|
refreshNotes(note?.notebookId)
|
|
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()
|
|
startTransition(async () => {
|
|
try {
|
|
await togglePin(note.id, !note.isPinned)
|
|
refreshNotes(note?.notebookId)
|
|
} catch {
|
|
toast.error(t('general.error'))
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleMoveToNotebook = (notebookId: string | null) => {
|
|
startTransition(async () => {
|
|
try {
|
|
await updateNote(note.id, { notebookId })
|
|
refreshNotes(note?.notebookId)
|
|
toast.success(t('notebookSuggestion.movedToNotebook') || 'Note déplacée')
|
|
} catch {
|
|
toast.error(t('general.error'))
|
|
}
|
|
})
|
|
}
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild onClick={e => e.stopPropagation()}>
|
|
<button className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground">
|
|
<MoreHorizontal size={16} />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-44">
|
|
<DropdownMenuItem onClick={e => { e.stopPropagation(); onOpen(note) }}>
|
|
<Pencil className="h-4 w-4 mr-2" />
|
|
{t('notes.open') || 'Ouvrir'}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handlePin}>
|
|
<Pin className="h-4 w-4 mr-2" />
|
|
{note.isPinned ? (t('notes.unpin') || 'Désépingler') : (t('notes.pin') || 'Épingler')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleArchive}>
|
|
<Archive className="h-4 w-4 mr-2" />
|
|
{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 mr-2" />
|
|
{t('notes.history') || 'Historique'}
|
|
</DropdownMenuItem>
|
|
)}
|
|
|
|
{/* Rappel */}
|
|
<DropdownMenuItem onClick={e => { e.stopPropagation(); setShowReminder(true) }}>
|
|
<Bell className="h-4 w-4 mr-2" />
|
|
{note.reminder ? (t('reminder.changeReminder') || 'Modifier le rappel') : (t('reminder.setReminder') || 'Définir un rappel')}
|
|
</DropdownMenuItem>
|
|
<ReminderDialog
|
|
open={showReminder}
|
|
onOpenChange={setShowReminder}
|
|
currentReminder={note.reminder ? new Date(note.reminder) : null}
|
|
onSave={(date) => {
|
|
startTransition(async () => {
|
|
await updateNote(note.id, { reminder: date })
|
|
refreshNotes(note?.notebookId)
|
|
setShowReminder(false)
|
|
})
|
|
}}
|
|
onRemove={() => {
|
|
startTransition(async () => {
|
|
await updateNote(note.id, { reminder: null })
|
|
refreshNotes(note?.notebookId)
|
|
setShowReminder(false)
|
|
})
|
|
}}
|
|
/>
|
|
|
|
{/* Déplacer vers notebook */}
|
|
<DropdownMenuSub open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
|
|
<DropdownMenuSubTrigger onClick={e => e.stopPropagation()}>
|
|
<FolderOpen className="h-4 w-4 mr-2" />
|
|
{t('notebookSuggestion.moveToNotebook') || 'Déplacer vers notebook'}
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent alignOffset={0} className="w-48">
|
|
<DropdownMenuItem onClick={e => { e.stopPropagation(); handleMoveToNotebook(null) }}>
|
|
<StickyNote className="h-4 w-4 mr-2" />
|
|
{t('notebookSuggestion.generalNotes') || 'Notes générales'}
|
|
</DropdownMenuItem>
|
|
{notebooks.map((nb: any) => (
|
|
<DropdownMenuItem key={nb.id} onClick={e => { e.stopPropagation(); handleMoveToNotebook(nb.id) }}>
|
|
<FolderOpen className="h-4 w-4 mr-2" />
|
|
{nb.name}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={handleDelete} className="text-red-600 dark:text-red-400 focus:text-red-600">
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
|
{t('notes.delete') || 'Supprimer'}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
/** 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,
|
|
}: {
|
|
note: Note
|
|
title: string
|
|
aiIllustrationEnabled: boolean
|
|
}) {
|
|
const { t } = useLanguage()
|
|
const { refreshNotes } = useRefresh()
|
|
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)
|
|
if (!res.ok) {
|
|
toast.error(res.error)
|
|
} else {
|
|
toast.success(t('notes.illustrationGenerated') || 'Illustration générée')
|
|
refreshNotes(note?.notebookId)
|
|
}
|
|
} 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: note.illustrationSvg }}
|
|
aria-hidden
|
|
/>
|
|
) : (
|
|
<>
|
|
<NoteThumbnailPlaceholder title={title} 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 right-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 */
|
|
function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: string }) {
|
|
// Try to extract the first emoji from the title
|
|
const emojiMatch = title.match(/\p{Emoji_Presentation}|\p{Emoji}\uFE0F/u)
|
|
const emoji = emojiMatch?.[0]
|
|
const letter = title.replace(/\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu, '').trim()[0]?.toUpperCase() || '?'
|
|
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% 94%) 0%, hsl(${hue} 18% 87%) 100%)` }}
|
|
>
|
|
{/* Decorative concentric circles */}
|
|
<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>
|
|
{emoji ? (
|
|
<span
|
|
className="relative text-5xl leading-none select-none"
|
|
style={{ filter: `drop-shadow(0 2px 8px hsl(${hue} 40% 40% / 0.2))` }}
|
|
>
|
|
{emoji}
|
|
</span>
|
|
) : (
|
|
<span
|
|
className="relative font-memento-serif font-bold select-none leading-none"
|
|
style={{
|
|
fontSize: '4.5rem',
|
|
color: `hsl(${hue} 35% 35%)`,
|
|
opacity: 0.35,
|
|
}}
|
|
>
|
|
{letter}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function NotesEditorialView({
|
|
notes,
|
|
onOpen,
|
|
notebookName,
|
|
onOpenHistory,
|
|
}: NotesEditorialViewProps) {
|
|
const { t } = useLanguage()
|
|
const { data: session } = useSession()
|
|
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)
|
|
|
|
return (
|
|
<motion.article
|
|
key={note.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.05 * index, duration: 0.6 }}
|
|
className="space-y-4 group cursor-pointer relative pb-8"
|
|
onClick={() => onOpen(note)}
|
|
>
|
|
{/* Date / breadcrumb */}
|
|
<div className="note-date-badge">
|
|
{notebookName ? `${notebookName} — ${dateStr}` : dateStr}
|
|
</div>
|
|
|
|
{/* Actions menu — absolutely positioned at top-right */}
|
|
<div
|
|
className="absolute top-0 right-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<EditorialNoteMenu note={note} onOpen={onOpen} onOpenHistory={onOpenHistory} />
|
|
</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} />
|
|
<div className="space-y-3 flex-1">
|
|
{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>
|
|
)
|
|
}
|