feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition, useEffect } from 'react'
|
||||
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 { useRefresh } from '@/lib/use-refresh'
|
||||
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 } from 'lucide-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'
|
||||
@@ -17,10 +17,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} 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'
|
||||
@@ -35,7 +33,7 @@ type NotesEditorialViewProps = {
|
||||
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
|
||||
@@ -49,23 +47,37 @@ function formatNoteDate(date: Date | string, language: string): string {
|
||||
return `${month.toUpperCase()} ${day}, ${year}`
|
||||
}
|
||||
|
||||
function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
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 { refreshNotes } = useRefresh()
|
||||
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)
|
||||
refreshNotes(note?.notebookId)
|
||||
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'))
|
||||
@@ -75,10 +87,14 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
|
||||
const handleArchive = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (onArchiveNote) {
|
||||
onArchiveNote(note)
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await toggleArchive(note.id, !note.isArchived)
|
||||
refreshNotes(note?.notebookId)
|
||||
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'))
|
||||
@@ -88,10 +104,15 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
|
||||
const handlePin = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (onTogglePin) {
|
||||
onTogglePin(note)
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await togglePin(note.id, !note.isPinned)
|
||||
refreshNotes(note?.notebookId)
|
||||
const nextPinned = !note.isPinned
|
||||
await togglePin(note.id, nextPinned, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'updated', note: { ...note, isPinned: nextPinned } })
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
@@ -99,10 +120,14 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
}
|
||||
|
||||
const handleMoveToNotebook = (notebookId: string | null) => {
|
||||
if (onMoveToNotebook) {
|
||||
onMoveToNotebook(note, notebookId)
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateNote(note.id, { notebookId })
|
||||
refreshNotes(note?.notebookId)
|
||||
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'))
|
||||
@@ -110,11 +135,28 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
})
|
||||
}
|
||||
|
||||
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 className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-muted/60 text-muted-foreground hover:text-foreground">
|
||||
<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>
|
||||
@@ -147,24 +189,15 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
</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>
|
||||
<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">
|
||||
@@ -174,25 +207,24 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
</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) => {
|
||||
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)
|
||||
})
|
||||
}}
|
||||
onSave={(date) => patchReminder(date)}
|
||||
onRemove={() => patchReminder(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
@@ -209,13 +241,14 @@ function EditorialThumbnail({
|
||||
note,
|
||||
title,
|
||||
aiIllustrationEnabled,
|
||||
onNoteIllustrationGenerated,
|
||||
}: {
|
||||
note: Note
|
||||
title: string
|
||||
aiIllustrationEnabled: boolean
|
||||
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const { refreshNotes } = useRefresh()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const img = getNoteFeedImage(note)
|
||||
|
||||
@@ -224,12 +257,12 @@ function EditorialThumbnail({
|
||||
if (!aiIllustrationEnabled || busy || img) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await generateNoteIllustrationSvg(note.id)
|
||||
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')
|
||||
refreshNotes(note?.notebookId)
|
||||
await onNoteIllustrationGenerated?.(note.id)
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
@@ -253,7 +286,7 @@ function EditorialThumbnail({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<NoteThumbnailPlaceholder title={title} noteId={note.id} />
|
||||
<NoteThumbnailPlaceholder noteId={note.id} />
|
||||
{aiIllustrationEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -272,12 +305,8 @@ function EditorialThumbnail({
|
||||
)
|
||||
}
|
||||
|
||||
/** 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() || '?'
|
||||
/** 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 (
|
||||
@@ -285,7 +314,6 @@ function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: st
|
||||
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"
|
||||
@@ -299,25 +327,12 @@ function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: st
|
||||
<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>
|
||||
)}
|
||||
<FileText
|
||||
size={40}
|
||||
strokeWidth={1.25}
|
||||
className="relative text-muted-foreground/45"
|
||||
style={{ color: `hsl(${hue} 25% 45%)` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -339,6 +354,12 @@ export function NotesEditorialView({
|
||||
onOpen,
|
||||
notebookName,
|
||||
onOpenHistory,
|
||||
onTogglePin,
|
||||
onDeleteNote,
|
||||
onArchiveNote,
|
||||
onMoveToNotebook,
|
||||
onNotePatch,
|
||||
onNoteIllustrationGenerated,
|
||||
}: NotesEditorialViewProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { data: session } = useSession()
|
||||
@@ -400,7 +421,16 @@ export function NotesEditorialView({
|
||||
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} />
|
||||
<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">
|
||||
@@ -411,7 +441,12 @@ export function NotesEditorialView({
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 items-start">
|
||||
<EditorialThumbnail note={note} title={title} aiIllustrationEnabled={aiIllustrationEnabled} />
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user