From 3818eb8237eafabebfccbc4052ba1340e7595bfe Mon Sep 17 00:00:00 2001 From: sepehr Date: Sat, 2 May 2026 16:51:12 +0200 Subject: [PATCH] feat: note history modal with restore, diff comparison, and dynamic UI updates - Complete rewrite of note-history-modal: version list with inline restore/delete, split diff view with synced scrolling, rich preview for markdown/richtext/checklist - Fix restore not updating editor: use key={id-updatedAt} on NoteInlineEditor to force remount on restore, and add sync useEffect to reset local state on prop changes - Add sync useEffect in NoteEditor for external note updates - Add historyEnabled to NOTE_LIST_SELECT (no more 'Activer' on every modal open) - Replace browser confirm() with shadcn AlertDialog for delete confirmation - Add i18n keys: currentVersion, compareVersions, diffTitle, diffSelectHint, deleteVersionDesc - Install diff + @types/diff for visual line diffing - Fix MasonryGrid render-phase sync to preserve local sizes - Update notes-tabs-view merge logic for restored note propagation - Remove debug console.log from restoreNoteVersion --- memento-note/app/actions/notes.ts | 30 +- memento-note/components/home-client.tsx | 1 + memento-note/components/masonry-grid.tsx | 20 +- memento-note/components/note-card.tsx | 38 +- memento-note/components/note-editor.tsx | 18 + .../components/note-history-modal.tsx | 640 ++++++++++++------ .../components/note-inline-editor.tsx | 18 +- memento-note/components/notes-tabs-view.tsx | 70 +- memento-note/lib/note-history.ts | 10 +- memento-note/locales/en.json | 7 +- memento-note/locales/fr.json | 7 +- memento-note/package-lock.json | 453 ++++++++++++- memento-note/package.json | 2 + 13 files changed, 1012 insertions(+), 302 deletions(-) diff --git a/memento-note/app/actions/notes.ts b/memento-note/app/actions/notes.ts index 8da91fd..431bf0a 100644 --- a/memento-note/app/actions/notes.ts +++ b/memento-note/app/actions/notes.ts @@ -57,7 +57,7 @@ const NOTE_LIST_SELECT = { language: true, languageConfidence: true, lastAiAnalysis: true, - // embedding: false — volontairement omis (économise ~6KB JSON/note) + historyEnabled: true, } as const // Wrapper for parseNote (embedding validation removed - embeddings are now in NoteEmbedding table) @@ -382,7 +382,7 @@ export async function getNoteHistory(noteId: string, limit = 30) { }) if (!note) return [] - const entries = await (prisma as any).noteHistory.findMany({ + const entries = await prisma.noteHistory.findMany({ where: { noteId: note.id, userId: session.user.id }, orderBy: { createdAt: 'desc' }, take: clampedLimit, @@ -403,7 +403,7 @@ export async function restoreNoteVersion(noteId: string, historyEntryId: string) where: { id: noteId, userId: session.user.id }, select: { id: true, notebookId: true }, }), - (prisma as any).noteHistory.findFirst({ + prisma.noteHistory.findFirst({ where: { id: historyEntryId, noteId, @@ -416,8 +416,10 @@ export async function restoreNoteVersion(noteId: string, historyEntryId: string) throw new Error('History entry not found') } + const userId = session.user.id + const restored = await prisma.note.update({ - where: { id: note.id, userId: session.user.id }, + where: { id: note.id, userId }, data: { title: historyEntry.title, content: historyEntry.content, @@ -436,23 +438,7 @@ export async function restoreNoteVersion(noteId: string, historyEntryId: string) }, }) - try { - await createNoteHistorySnapshot({ - noteId: note.id, - userId: session.user.id, - reason: `restore:v${historyEntry.version}`, - }) - } catch (snapshotError) { - console.error('[HISTORY] Failed to create snapshot after restore:', snapshotError) - } - revalidatePath('/') - revalidatePath(`/note/${note.id}`) - revalidatePath('/archive') - if (note.notebookId) revalidatePath(`/notebook/${note.notebookId}`) - if (historyEntry.notebookId && historyEntry.notebookId !== note.notebookId) { - revalidatePath(`/notebook/${historyEntry.notebookId}`) - } return parseNote(restored) } @@ -479,12 +465,12 @@ export async function deleteNoteHistoryEntry(noteId: string, historyEntryId: str const session = await auth() if (!session?.user?.id) throw new Error('Unauthorized') - const entry = await (prisma as any).noteHistory.findFirst({ + const entry = await prisma.noteHistory.findFirst({ where: { id: historyEntryId, noteId, userId: session.user.id }, }) if (!entry) throw new Error('History entry not found') - await (prisma as any).noteHistory.delete({ + await prisma.noteHistory.delete({ where: { id: historyEntryId }, }) } diff --git a/memento-note/components/home-client.tsx b/memento-note/components/home-client.tsx index d62de5f..e9de50a 100644 --- a/memento-note/components/home-client.tsx +++ b/memento-note/components/home-client.tsx @@ -156,6 +156,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { setNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n))) setPinnedNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n))) setEditingNote((prev) => (prev?.note.id === restored.id ? { ...prev, note: restored } : prev)) + setHistoryNote((prev) => (prev?.id === restored.id ? { ...prev, ...restored } : prev)) }, []) const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => { diff --git a/memento-note/components/masonry-grid.tsx b/memento-note/components/masonry-grid.tsx index 9679c80..465512c 100644 --- a/memento-note/components/masonry-grid.tsx +++ b/memento-note/components/masonry-grid.tsx @@ -181,20 +181,14 @@ export function MasonryGrid({ // Local notes state for optimistic size/order updates const [localNotes, setLocalNotes] = useState(notes); + const prevNotesRef = useRef(notes); - useEffect(() => { - setLocalNotes(prev => { - const prevIds = prev.map(n => n.id).join(',') - const incomingIds = notes.map(n => n.id).join(',') - if (prevIds === incomingIds) { - const localSizeMap = new Map(prev.map(n => [n.id, n.size])) - return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size })) - } - // Notes added/removed: full sync but preserve local sizes - const localSizeMap = new Map(prev.map(n => [n.id, n.size])) - return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size })) - }) - }, [notes]); + if (notes !== prevNotesRef.current) { + const localSizeMap = new Map(localNotes.map(n => [n.id, n.size])); + const newLocalNotes = notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size })); + setLocalNotes(newLocalNotes); + prevNotesRef.current = notes; + } const pinnedNotes = useMemo(() => localNotes.filter(n => n.isPinned), [localNotes]); const othersNotes = useMemo(() => localNotes.filter(n => !n.isPinned), [localNotes]); diff --git a/memento-note/components/note-card.tsx b/memento-note/components/note-card.tsx index dda8a22..47ae273 100644 --- a/memento-note/components/note-card.tsx +++ b/memento-note/components/note-card.tsx @@ -535,7 +535,7 @@ export const NoteCard = memo(function NoteCard({ )} {/* Fusion Badge */} - {optimisticNote.aiProvider === 'fusion' && ( + {note.aiProvider === 'fusion' && optimisticNote.autoGenerated !== null && (
{t('memoryEcho.fused')} @@ -550,28 +550,28 @@ export const NoteCard = memo(function NoteCard({ )} {/* Title */} - {optimisticNote.title && ( + {note.title && (

{(() => { - const TypeIcon = NOTE_TYPE_ICONS[optimisticNote.type] || AlignLeft + const TypeIcon = NOTE_TYPE_ICONS[note.type] || AlignLeft return })()} - {optimisticNote.title} + {note.title}

)} {/* Search Match Type Badge */} - {optimisticNote.matchType && ( + {note.matchType && ( - {t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)} + {t(`semanticSearch.${note.matchType === 'exact' ? 'exactMatch' : 'related'}`)} )} @@ -597,12 +597,12 @@ export const NoteCard = memo(function NoteCard({ )} {/* Images Component */} - + {/* Link Previews */} - {Array.isArray(optimisticNote.links) && optimisticNote.links.length > 0 && ( + {Array.isArray(note.links) && note.links.length > 0 && (
- {optimisticNote.links.map((link, idx) => ( + {note.links.map((link: any, idx: number) => ( - ) : optimisticNote.type === 'richtext' ? ( -
+ ) : note.type === 'richtext' ? ( +
) : (
)} {/* Labels - using shared LabelBadge component */} - {optimisticNote.notebookId && Array.isArray(optimisticNote.labels) && optimisticNote.labels.length > 0 && ( + {note.notebookId && Array.isArray(note.labels) && note.labels.length > 0 && (
- {optimisticNote.labels.map((label) => ( + {note.labels.map((label: string) => ( ))}
@@ -691,7 +691,7 @@ export const NoteCard = memo(function NoteCard({ onRestore={handleRestore} onPermanentDelete={handlePermanentDelete} onOpenHistory={() => onOpenHistory?.(note)} - historyEnabled={noteHistoryEnabled} + historyEnabled={!!note.historyEnabled} noteId={note.id} currentReminder={reminderDate} onUpdateReminder={handleUpdateReminder} diff --git a/memento-note/components/note-editor.tsx b/memento-note/components/note-editor.tsx index 92537f6..10a8ccc 100644 --- a/memento-note/components/note-editor.tsx +++ b/memento-note/components/note-editor.tsx @@ -92,6 +92,24 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown') const fileInputRef = useRef(null) + const prevNoteRef = useRef(note) + useEffect(() => { + if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) { + setTitle(note.title || '') + setContent(note.content) + setCheckItems(note.checkItems || []) + setLabels(note.labels || []) + setImages(note.images || []) + setLinks(note.links || []) + setColor(note.color) + setSize(note.size || 'small') + setNoteType(note.type) + setShowMarkdownPreview(note.type === 'markdown') + setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null) + } + prevNoteRef.current = note + }, [note]) + // Update context notebookId when note changes useEffect(() => { setContextNotebookId(note.notebookId || null) diff --git a/memento-note/components/note-history-modal.tsx b/memento-note/components/note-history-modal.tsx index d1601a6..6619afc 100644 --- a/memento-note/components/note-history-modal.tsx +++ b/memento-note/components/note-history-modal.tsx @@ -1,21 +1,25 @@ 'use client' -import { useEffect, useMemo, useState, useTransition } from 'react' -import { formatDistanceToNow } from 'date-fns' +import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react' +import { format } from 'date-fns' import { fr } from 'date-fns/locale/fr' import { enUS } from 'date-fns/locale/en-US' -import { History, Loader2, RotateCcw, Trash2, GitBranchPlus, Check } from 'lucide-react' +import * as Diff from 'diff' +import { + History, Loader2, RotateCcw, Trash2, GitBranchPlus, Check, GitCompare, X, +} from 'lucide-react' import { toast } from 'sonner' -import { getNoteHistory, restoreNoteVersion, deleteNoteHistoryEntry } from '@/app/actions/notes' +import { + getNoteHistory, restoreNoteVersion, deleteNoteHistoryEntry, +} from '@/app/actions/notes' import { Button } from '@/components/ui/button' import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { + Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog' +import { MarkdownContent } from '@/components/markdown-content' import { useLanguage } from '@/lib/i18n' import type { Note, NoteHistoryEntry } from '@/lib/types' import { cn } from '@/lib/utils' @@ -29,29 +33,147 @@ interface NoteHistoryModalProps { onRestored: (note: Note) => void } +type ViewMode = 'preview' | 'diff' + function getDateLocale(language: string) { - if (language === 'fr') return fr - return enUS + return language === 'fr' ? fr : enUS +} + +function fmtDate(date: Date | string, language: string): string { + const d = typeof date === 'string' ? new Date(date) : date + return format(d, 'd MMM yyyy HH:mm', { locale: getDateLocale(language) }) +} + +function VersionPreview({ entry, language }: { entry: NoteHistoryEntry; language: string }) { + const isMd = entry.type === 'markdown' || entry.isMarkdown + const isCl = entry.type === 'checklist' + const isRt = entry.type === 'richtext' + + return ( +
+
+

+ {language === 'fr' ? 'Titre' : 'Title'} +

+

+ {entry.title || (language === 'fr' ? 'Sans titre' : 'Untitled')} +

+
+
+

+ {language === 'fr' ? 'Contenu' : 'Content'} +

+
+ {isCl && entry.checkItems ? ( +
+ {entry.checkItems.map((item, i) => ( +
+ {item.checked && '✓'} + {item.text} +
+ ))} +
+ ) : isMd ? ( +
+ +
+ ) : isRt ? ( +
+ ) : ( +
{entry.content || ''}
+ )} +
+
+
+ ) +} + +interface DiffLine { type: 'added' | 'removed' | 'unchanged'; value: string } + +function computeLineDiff(oldText: string, newText: string) { + const changes = Diff.diffLines(oldText || '', newText || '') + const left: DiffLine[] = [] + const right: DiffLine[] = [] + for (const change of changes) { + const lines = change.value.replace(/\n$/, '').split('\n') + if (change.added) { + for (const line of lines) { right.push({ type: 'added', value: line }); left.push({ type: 'unchanged', value: '' }) } + } else if (change.removed) { + for (const line of lines) { left.push({ type: 'removed', value: line }); right.push({ type: 'unchanged', value: '' }) } + } else { + for (const line of lines) { left.push({ type: 'unchanged', value: line }); right.push({ type: 'unchanged', value: line }) } + } + } + return { left, right } +} + +function DiffPanel({ lines, label, scrollRef, onScroll }: { + lines: DiffLine[]; label: string + scrollRef: React.RefObject + onScroll: () => void +}) { + return ( +
+

{label}

+
+ {lines.map((line, i) => ( +
+ {line.value || '\u00A0'} +
+ ))} +
+
+ ) } export function NoteHistoryModal({ - open, - onOpenChange, - note, - enabled, - onEnableHistory, - onRestored, + open, onOpenChange, note, enabled, onEnableHistory, onRestored, }: NoteHistoryModalProps) { const { t, language } = useLanguage() const [entries, setEntries] = useState([]) const [selectedId, setSelectedId] = useState(null) const [isLoading, setIsLoading] = useState(false) - const [isRestoring, startRestoring] = useTransition() - const [isEnabling, startEnabling] = useTransition() - const [justEnabled, setJustEnabled] = useState(false) + const [isRestoring, setIsRestoring] = useState(false) + const [isEnabling, startEnableTx] = useTransition() + const [viewMode, setViewMode] = useState('preview') + const [diffLeftId, setDiffLeftId] = useState(null) + const [diffRightId, setDiffRightId] = useState(null) + const [deleteTargetId, setDeleteTargetId] = useState(null) + const [currentEntryId, setCurrentEntryId] = useState(null) + const leftScrollRef = useRef(null) + const rightScrollRef = useRef(null) + const isScrollSyncing = useRef(false) + // Reset state when modal closes or note changes + const prevNoteId = useRef(null) useEffect(() => { - if (!open || !note || !enabled) return + if (!open) { + setEntries([]) + setSelectedId(null) + setViewMode('preview') + setDiffLeftId(null) + setDiffRightId(null) + setCurrentEntryId(null) + setIsLoading(false) + return + } + if (!note) return + if (note.id === prevNoteId.current && entries.length > 0) return + prevNoteId.current = note.id let cancelled = false setIsLoading(true) @@ -60,220 +182,358 @@ export function NoteHistoryModal({ if (cancelled) return setEntries(result) setSelectedId(result[0]?.id ?? null) + setCurrentEntryId(result[0]?.id ?? null) }) - .catch((error) => { - console.error('Failed to load note history:', error) - toast.error(t('general.error')) - }) - .finally(() => { - if (!cancelled) setIsLoading(false) - }) + .catch(() => toast.error(t('general.error'))) + .finally(() => { if (!cancelled) setIsLoading(false) }) - return () => { - cancelled = true - } - }, [open, note, enabled, t]) + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, note?.id]) - const selectedEntry = useMemo( - () => entries.find((entry) => entry.id === selectedId) ?? null, - [entries, selectedId] + // Separate effect: fetch when enabled flips to true for this note + useEffect(() => { + if (!open || !note || !enabled) return + if (entries.length > 0) return + + let cancelled = false + setIsLoading(true) + getNoteHistory(note.id, 50) + .then((result) => { + if (cancelled) return + setEntries(result) + setSelectedId(result[0]?.id ?? null) + setCurrentEntryId(result[0]?.id ?? null) + }) + .catch(() => toast.error(t('general.error'))) + .finally(() => { if (!cancelled) setIsLoading(false) }) + + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled]) + + const currentVersion = useMemo( + () => entries.find((e) => e.id === currentEntryId) ?? null, + [entries, currentEntryId] ) - const handleRestore = () => { - if (!note || !selectedEntry) return - startRestoring(async () => { - try { - const restored = await restoreNoteVersion(note.id, selectedEntry.id) - onRestored(restored) - toast.success(t('notes.historyRestored') || 'Version restaurée') - } catch (error) { - console.error('Failed to restore history entry:', error) - toast.error(t('general.error')) - } - }) - } + const selectedEntry = useMemo( + () => entries.find((e) => e.id === selectedId) ?? null, + [entries, selectedId] + ) + const diffLeftEntry = useMemo(() => entries.find((e) => e.id === diffLeftId) ?? null, [entries, diffLeftId]) + const diffRightEntry = useMemo(() => entries.find((e) => e.id === diffRightId) ?? null, [entries, diffRightId]) + + const diffResult = useMemo(() => { + if (!diffLeftEntry || !diffRightEntry) return null + return computeLineDiff(diffLeftEntry.content, diffRightEntry.content) + }, [diffLeftEntry, diffRightEntry]) + + const isDiffReady = !!(diffLeftId && diffRightId && diffLeftId !== diffRightId) + + const handleRestore = useCallback(async (entryId: string) => { + if (!note) return + const entry = entries.find((e) => e.id === entryId) + if (!entry) return + setIsRestoring(true) + try { + const restored = await restoreNoteVersion(note.id, entryId) + setCurrentEntryId(entryId) + toast.success(t('notes.historyRestored') || 'Version restaurée') + onOpenChange(false) + onRestored(restored) + } catch { + toast.error(t('general.error')) + } finally { + setIsRestoring(false) + } + }, [note, entries, onOpenChange, t, onRestored]) + + const handleDelete = useCallback((entryId: string) => { + setDeleteTargetId(entryId) + }, []) + + const confirmDelete = useCallback(async () => { + if (!note || !deleteTargetId) return + const entryId = deleteTargetId + setDeleteTargetId(null) + setIsRestoring(true) + try { + await deleteNoteHistoryEntry(note.id, entryId) + setEntries((prev) => prev.filter((e) => e.id !== entryId)) + if (selectedId === entryId) setSelectedId(entries[0]?.id ?? null) + if (diffLeftId === entryId) setDiffLeftId(null) + if (diffRightId === entryId) setDiffRightId(null) + toast.success(t('notes.versionDeleted') || 'Version supprimée') + } catch { + toast.error(t('general.error')) + } finally { + setIsRestoring(false) + } + }, [note, entries, selectedId, diffLeftId, diffRightId, deleteTargetId, t]) const handleEnable = () => { - startEnabling(async () => { + startEnableTx(async () => { try { await onEnableHistory() - setJustEnabled(true) - toast.success(t('notes.historyEnabled') || 'History activé') - } catch (error) { - console.error('Failed to enable history:', error) + toast.success(t('notes.historyEnabled') || 'Historique activé') + } catch { toast.error(t('general.error')) } }) } - useEffect(() => { - if (!justEnabled) return - const timer = setTimeout(() => setJustEnabled(false), 1800) - return () => clearTimeout(timer) - }, [justEnabled]) - - const handleDeleteEntry = (entryId: string) => { - if (!note) return - if (!confirm(t('notes.deleteVersionConfirm') || 'Supprimer cette version définitivement ?')) return - startRestoring(async () => { - try { - await deleteNoteHistoryEntry(note.id, entryId) - setEntries((prev) => prev.filter((e) => e.id !== entryId)) - if (selectedId === entryId) { - setSelectedId(null) - } - toast.success(t('notes.versionDeleted') || 'Version supprimée') - } catch (error) { - console.error('Failed to delete history entry:', error) - toast.error(t('general.error')) - } - }) + const handleSyncScroll = (source: 'left' | 'right') => { + if (isScrollSyncing.current) return + isScrollSyncing.current = true + const src = source === 'left' ? leftScrollRef.current : rightScrollRef.current + const tgt = source === 'left' ? rightScrollRef.current : leftScrollRef.current + if (src && tgt) { + const ratio = src.scrollTop / (src.scrollHeight - src.clientHeight || 1) + tgt.scrollTop = ratio * (tgt.scrollHeight - tgt.clientHeight) + } + requestAnimationFrame(() => { isScrollSyncing.current = false }) } + const noteTitle = note?.title || t('notes.untitled') || 'Sans titre' + return ( - - - + + {/* ── Header ── */} +
+ {t('notes.history') || 'Historique'} - - {note?.title || t('notes.untitled') || 'Sans titre'} + + {noteTitle} - +
- {!enabled || justEnabled ? ( + {/* ── Body ── */} + {!enabled ? (
- {justEnabled ? ( - <> -
- -
-

- {t('notes.historyEnabledTitle') || 'Historique activé !'} -

-

- {t('notes.historyEnabledDesc') || "Les versions de cette note seront désormais enregistrées."} -

- - ) : ( - <> -
- -
-

- {t('notes.historyDisabledTitle') || 'Historique des versions'} -

-

- {t('notes.historyDisabledDesc') || "Suivez les modifications de cette note au fil du temps. Activez l'historique pour commencer à enregistrer des versions."} -

- - - )} +
+ +
+

+ {t('notes.historyDisabledTitle') || 'Historique des versions'} +

+

+ {t('notes.historyDisabledDesc') || "Activez l'historique pour enregistrer les versions."} +

+ +
+ ) : isLoading ? ( +
+ + {t('general.loading')} +
+ ) : entries.length === 0 ? ( +
+ +

{t('notes.historyEmpty') || 'Aucune version disponible'}

) : ( -
-
- {isLoading ? ( -
- - {t('general.loading')} -
- ) : entries.length === 0 ? ( -

- {t('notes.historyEmpty') || 'Aucune version disponible'} -

- ) : ( -
- {entries.map((entry) => ( -
+ {/* ── Left: Version list ── */} +
+ {entries.map((entry) => { + const isCurrent = entry.version === currentVersion?.version + const isSelected = viewMode === 'preview' && selectedId === entry.id + const isDL = entry.id === diffLeftId + const isDR = entry.id === diffRightId + const isDiffSel = viewMode === 'diff' && (isDL || isDR) + + return ( +
{ + if (viewMode === 'diff') { + if (!diffLeftId) { setDiffLeftId(entry.id) } + else if (!diffRightId && entry.id !== diffLeftId) { setDiffRightId(entry.id) } + else { setDiffLeftId(entry.id); setDiffRightId(null) } + } else { + setSelectedId(entry.id) + } + }} + className={cn( + 'group/entry relative rounded-md border px-2.5 py-1.5 cursor-pointer transition-colors', + isDiffSel + ? isDL ? 'border-red-400/50 bg-red-500/10' : 'border-emerald-400/50 bg-emerald-500/10' + : isSelected ? 'border-primary/40 bg-primary/8' : 'border-transparent hover:bg-muted/60' + )} + > +
+ v{entry.version} + {isCurrent && ( + + {t('notes.currentVersion') || 'actuelle'} + + )} +
+

+ {fmtDate(entry.createdAt, language)} +

+ +
+ {viewMode === 'preview' && ( + )} - > -
- ))} +
+ ) + })} + + {viewMode === 'diff' && entries.length >= 2 && ( +
+

+ {t('notes.diffSelectHint') || 'Cliquez sur 2 versions pour comparer'} +

+
+ + + {diffLeftEntry ? `v${diffLeftEntry.version}` : '—'} + + + + + {diffRightEntry ? `v${diffRightEntry.version}` : '—'} + + {isDiffReady && ( + + )} +
)}
-
- {selectedEntry ? ( -
-
-

- {t('notes.title') || 'Titre'} -

-

- {selectedEntry.title || t('notes.untitled') || 'Sans titre'} -

-
- -
-

- {t('notes.content') || 'Contenu'} -

-
-                      {selectedEntry.content || ''}
-                    
+ {/* ── Right: Preview / Diff ── */} +
+ {viewMode === 'preview' ? ( + selectedEntry ? ( + + ) : ( +

+ {t('notes.historySelectVersion') || 'Sélectionnez une version pour la prévisualiser'} +

+ ) + ) : isDiffReady && diffResult ? ( +
+

+ {t('notes.diffTitle') || 'Comparaison'} : v{diffLeftEntry!.version} → v{diffRightEntry!.version} +

+
+ handleSyncScroll('left')} + /> + handleSyncScroll('right')} + />
) : ( -

- {t('notes.historySelectVersion') || 'Sélectionnez une version pour prévisualiser son contenu'} -

+
+ +

+ {t('notes.diffSelectHint') || 'Cliquez sur 2 versions pour comparer'} +

+
)}
)} - - {enabled && selectedEntry && ( - - )} - + {/* ── Footer: actions ── */} + {enabled && !isLoading && entries.length >= 2 && ( +
+
+ + +
+
+ )} + + { if (!open) setDeleteTargetId(null) }}> + + + {t('notes.deleteVersionConfirm') || 'Supprimer cette version définitivement ?'} + + {t('notes.deleteVersionDesc') || 'Cette action est irréversible. La version sera définitivement supprimée de l\'historique.'} + + + + {t('general.cancel') || 'Annuler'} + + {t('general.delete') || 'Supprimer'} + + + +
) } diff --git a/memento-note/components/note-inline-editor.tsx b/memento-note/components/note-inline-editor.tsx index e5dcaa5..127c362 100644 --- a/memento-note/components/note-inline-editor.tsx +++ b/memento-note/components/note-inline-editor.tsx @@ -139,6 +139,22 @@ export function NoteInlineEditor({ const [fusionNotes, setFusionNotes] = useState>>([]) const [comparisonNotes, setComparisonNotes] = useState>>([]) + const noteContentRef = useRef(note.content) + const noteTitleRef = useRef(note.title) + useEffect(() => { + if (note.content !== noteContentRef.current || note.title !== noteTitleRef.current) { + clearTimeout(saveTimerRef.current) + setIsDirty(false) + setTitle(note.title || '') + setContent(note.content || '') + setCheckItems(note.checkItems || []) + setNoteType(note.type) + pendingRef.current = { title: note.title || '', content: note.content || '', checkItems: note.checkItems || [], isMarkdown: note.type === 'markdown', noteType: note.type } + noteContentRef.current = note.content + noteTitleRef.current = note.title + } + }, [note]) + const changeTitle = (t: string) => { setTitle(t); onChange?.(note.id, { title: t }) } const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) } const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) } @@ -639,7 +655,7 @@ export function NoteInlineEditor({ if (note.historyEnabled) { onOpenHistory(note) } else if (onEnableHistory) { - onEnableHistory(note.id).then(() => onOpenHistory(note)) + onEnableHistory(note.id).then(() => onOpenHistory({ ...note, historyEnabled: true })) } }} > diff --git a/memento-note/components/notes-tabs-view.tsx b/memento-note/components/notes-tabs-view.tsx index de038d3..6f6ab25 100644 --- a/memento-note/components/notes-tabs-view.tsx +++ b/memento-note/components/notes-tabs-view.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState, useTransition } from 'react' +import { useCallback, useEffect, useMemo, useState, useTransition, useRef } from 'react' import { useNoteRefreshOptional } from '@/context/NoteRefreshContext' import { DndContext, @@ -641,34 +641,48 @@ export function NotesTabsView({ const [sortOrder, setSortOrder] = useState('date-desc') const [sidebarOpen, setSidebarOpen] = useState(true) - useEffect(() => { - setItems((prev) => { - const prevIds = prev.map((n) => n.id).join(',') - const incomingIds = notes.map((n) => n.id).join(',') - const merge = (fresh: Note, local: Note) => { - const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(local.labels?.sort()) - return { - ...fresh, - title: local.title || fresh.title, - content: local.content, - checkItems: local.checkItems, - labels: labelsChanged ? fresh.labels : local.labels - } + const prevNotesRef = useRef(notes) + + if (notes !== prevNotesRef.current) { + const prevIds = items.map((n) => n.id).join(',') + const incomingIds = notes.map((n) => n.id).join(',') + + const merge = (fresh: Note, local: Note, oldFresh?: Note) => { + const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(local.labels?.sort()) + + const contentChangedOnServer = oldFresh && oldFresh.content !== fresh.content + const titleChangedOnServer = oldFresh && oldFresh.title !== fresh.title + const checkItemsChangedOnServer = oldFresh && JSON.stringify(oldFresh.checkItems) !== JSON.stringify(fresh.checkItems) + + return { + ...fresh, + title: titleChangedOnServer ? fresh.title : (local.title || fresh.title), + content: contentChangedOnServer ? fresh.content : local.content, + checkItems: checkItemsChangedOnServer ? fresh.checkItems : local.checkItems, + labels: labelsChanged ? fresh.labels : local.labels } - if (prevIds === incomingIds) { - return prev.map((p) => { - const fresh = notes.find((n) => n.id === p.id) - if (!fresh) return p - return merge(fresh, p) - }) - } - return notes.map((fresh) => { - const local = prev.find((p) => p.id === fresh.id) - if (!local) return fresh - return merge(fresh, local) + } + + let newItems: Note[] + if (prevIds === incomingIds) { + newItems = items.map((local) => { + const fresh = notes.find((n) => n.id === local.id) + if (!fresh) return local + const oldFresh = prevNotesRef.current.find((n) => n.id === local.id) + return merge(fresh, local, oldFresh) }) - }) - }, [notes]) + } else { + newItems = notes.map((fresh) => { + const local = items.find((p) => p.id === fresh.id) + if (!local) return fresh + const oldFresh = prevNotesRef.current.find((n) => n.id === fresh.id) + return merge(fresh, local, oldFresh) + }) + } + + setItems(newItems) + prevNotesRef.current = notes + } useEffect(() => { if (items.length === 0) { @@ -932,7 +946,7 @@ export function NotesTabsView({ )} > { - const lastVersionEntry = await (tx as any).noteHistory.findFirst({ + const lastVersionEntry = await tx.noteHistory.findFirst({ where: { noteId }, orderBy: { version: 'desc' }, select: { version: true }, @@ -75,10 +77,10 @@ export async function createNoteHistorySnapshot({ const nextVersion = ((lastVersionEntry?.version as number | undefined) ?? 0) + 1 - await (tx as any).noteHistory.create({ + await tx.noteHistory.create({ data: { noteId: note.id, - userId: note.userId, + userId: ownerId, version: nextVersion, reason: reason ?? null, title: note.title, @@ -157,7 +159,7 @@ export async function shouldCreateAutoSnapshot(params: { if (!contentChanged && !titleChanged) return false // Check cooldown: find the most recent snapshot for this note - const lastSnapshot = await (prisma as any).noteHistory.findFirst({ + const lastSnapshot = await prisma.noteHistory.findFirst({ where: { noteId }, orderBy: { createdAt: 'desc' }, select: { createdAt: true }, diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 951f554..3af4958 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -167,7 +167,8 @@ "versionSaved": "Version saved", "deleteVersion": "Delete this version", "versionDeleted": "Version deleted", - "deleteVersionConfirm": "Delete this version permanently?", + "deleteVersionConfirm": "Delete this version?", + "deleteVersionDesc": "This action cannot be undone. The version will be permanently deleted from the history.", "historyMode": "History mode", "historyModeManual": "Manual (commit button)", "historyModeAuto": "Automatic (smart)", @@ -183,6 +184,10 @@ "enableHistory": "Enable history", "historyEmpty": "No versions available", "historySelectVersion": "Select a version to preview its content", + "currentVersion": "current", + "compareVersions": "Compare", + "diffTitle": "Comparison", + "diffSelectHint": "Click on 2 versions in the list to compare them", "sortBy": "Sort by", "sortDateDesc": "Date (newest)", "sortDateAsc": "Date (oldest)", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index fc452d8..f658c62 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -167,7 +167,8 @@ "versionSaved": "Version enregistrée", "deleteVersion": "Supprimer cette version", "versionDeleted": "Version supprimée", - "deleteVersionConfirm": "Supprimer cette version définitivement ?", + "deleteVersionConfirm": "Supprimer cette version ?", + "deleteVersionDesc": "Cette action est irréversible. La version sera définitivement supprimée de l'historique.", "historyMode": "Mode d'historique", "historyModeManual": "Manuel (bouton commit)", "historyModeAuto": "Automatique (intelligent)", @@ -183,6 +184,10 @@ "enableHistory": "Activer l'historique", "historyEmpty": "Aucune version disponible", "historySelectVersion": "Sélectionnez une version pour prévisualiser son contenu", + "currentVersion": "actuelle", + "compareVersions": "Comparer", + "diffTitle": "Comparaison", + "diffSelectHint": "Cliquez sur 2 versions dans la liste pour les comparer", "sortBy": "Trier par", "sortDateDesc": "Date (récent)", "sortDateAsc": "Date (ancien)", diff --git a/memento-note/package-lock.json b/memento-note/package-lock.json index c0ea86e..93fcc61 100644 --- a/memento-note/package-lock.json +++ b/memento-note/package-lock.json @@ -53,6 +53,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "diff": "^9.0.0", "dotenv": "^17.2.3", "jsdom": "^29.0.2", "katex": "^0.16.27", @@ -84,6 +85,7 @@ "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", "@types/bcryptjs": "^2.4.6", + "@types/diff": "^7.0.2", "@types/node": "^20", "@types/nodemailer": "^7.0.4", "@types/react": "^19", @@ -501,6 +503,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -547,6 +550,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -574,6 +578,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -637,28 +642,6 @@ "integrity": "sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==", "license": "MIT" }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1620,6 +1603,7 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" @@ -2375,12 +2359,337 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@playwright/test": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.59.1" }, @@ -2407,6 +2716,7 @@ "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=16.13" }, @@ -5907,6 +6217,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz", "integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -6168,6 +6479,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz", "integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -6340,6 +6652,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.22.5.tgz", "integrity": "sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -6379,6 +6692,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz", "integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -6393,6 +6707,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz", "integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", @@ -6787,6 +7102,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/diff": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", + "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -6902,6 +7224,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6911,6 +7234,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6971,6 +7295,7 @@ "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.4", @@ -7316,6 +7641,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -7487,6 +7813,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -7747,6 +8074,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz", "integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -8156,6 +8484,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -8370,6 +8699,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -9250,6 +9588,7 @@ "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.20.0" }, @@ -10225,6 +10564,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", @@ -11026,6 +11366,14 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.37", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", @@ -11037,6 +11385,7 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", "license": "MIT-0", + "peer": true, "engines": { "node": ">=6.0.0" } @@ -11095,6 +11444,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -11191,6 +11541,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz", "integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -11510,6 +11861,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", "integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -11549,6 +11901,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -12022,6 +12375,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12056,6 +12410,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -12077,6 +12432,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -12094,6 +12450,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -12226,6 +12583,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -12255,6 +12613,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -12315,6 +12674,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -12515,6 +12875,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12534,6 +12895,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13251,7 +13613,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.3.tgz", "integrity": "sha512-fA/NX5gMf0ooCLISgB0wScaWgaj6rjTN2SVAwleURjiya7ITNkV+VMmoHtKkldP6CIZoYCZyxb8zP/e2TWoEtQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.2", @@ -13336,6 +13699,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13839,6 +14203,7 @@ "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.4", "@vitest/mocker": "4.1.4", @@ -13950,6 +14315,23 @@ } } }, + "node_modules/vitest/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/vitest/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -13965,6 +14347,14 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/vitest/node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -13978,12 +14368,28 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/vitest/node_modules/vite": { "version": "8.0.9", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -14258,6 +14664,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/memento-note/package.json b/memento-note/package.json index 023025f..55698bd 100644 --- a/memento-note/package.json +++ b/memento-note/package.json @@ -70,6 +70,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "diff": "^9.0.0", "dotenv": "^17.2.3", "jsdom": "^29.0.2", "katex": "^0.16.27", @@ -101,6 +102,7 @@ "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", "@types/bcryptjs": "^2.4.6", + "@types/diff": "^7.0.2", "@types/node": "^20", "@types/nodemailer": "^7.0.4", "@types/react": "^19",