Files
Momento/memento-note/components/notes-editorial-view.tsx
Antigravity 1fcea6ed7d
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export
- Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG)
- Add document Q&A overlay with streaming chat and PDF preview
- Add note attachments UI with status polling, grid layout, and auto-scroll
- Add task extraction AI tool and agent executor improvements
- Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings
- Fix brainstorm 'Create Note' button: add success toast and redirect to created note
- Fix memory echo notification infinite polling
- Fix chat route to always include document_search tool
- Add brainstorm i18n keys across all 14 locales
- Add socket server for real-time brainstorm collaboration
- Add hierarchical notebook selector and organize notebook dialog improvements
- Add sidebar brainstorm section with session management
- Update prisma schema with brainstorm tables, attachments, and document chunks
2026-05-14 17:43:21 +00:00

443 lines
17 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 } 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 } 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,
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'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { cn } from '@/lib/utils'
type NotesEditorialViewProps = {
notes: Note[]
onOpen: (note: Note, readOnly?: boolean) => void
notebookName?: string
onOpenHistory?: (note: Note) => void
}
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}`
}
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 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.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 */}
<DropdownMenuSub>
<DropdownMenuSubTrigger onClick={e => e.stopPropagation()}>
<FolderOpen className="h-4 w-4 me-2 text-foreground/50" />
{t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
<DropdownMenuItem onClick={e => { e.stopPropagation(); handleMoveToNotebook(null) }}>
<span className="w-4 h-4 rounded-full bg-foreground text-background flex items-center justify-center text-[9px] font-semibold me-2 shrink-0">N</span>
{t('notebookSuggestion.generalNotes') || 'Notes générales'}
</DropdownMenuItem>
{notebooks.map((nb: any) => (
<DropdownMenuItem key={nb.id} onClick={e => { e.stopPropagation(); handleMoveToNotebook(nb.id) }}>
<span className="w-4 h-4 rounded-full bg-foreground text-background flex items-center justify-center text-[9px] font-semibold me-2 shrink-0">{nb.name.charAt(0).toUpperCase()}</span>
{nb.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<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>
{/* 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) => {
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)
})
}}
/>
</>
)
}
/** 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 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 */
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% var(--thumb-lightness-1, 94%)) 0%, hsl(${hue} 18% var(--thumb-lightness-2, 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>
)
}
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-blueprint" />}
{labelName}
</div>
)
}
export function NotesEditorialView({
notes,
onOpen,
notebookName,
onOpenHistory,
}: NotesEditorialViewProps) {
const { t, language } = useLanguage()
const { data: session } = useSession()
const { data: allLabels } = useLabelsQuery()
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={{ 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 — 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} />
</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">
{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>
)
}