feat: note history modal with restore, diff comparison, and dynamic UI updates
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m14s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m14s
- 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
This commit is contained in:
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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') => {
|
||||
|
||||
@@ -181,20 +181,14 @@ export function MasonryGrid({
|
||||
|
||||
// Local notes state for optimistic size/order updates
|
||||
const [localNotes, setLocalNotes] = useState<Note[]>(notes);
|
||||
const prevNotesRef = useRef<Note[]>(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]);
|
||||
|
||||
@@ -535,7 +535,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
)}
|
||||
|
||||
{/* Fusion Badge */}
|
||||
{optimisticNote.aiProvider === 'fusion' && (
|
||||
{note.aiProvider === 'fusion' && optimisticNote.autoGenerated !== null && (
|
||||
<div className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 flex items-center gap-1 group/badge relative mb-2 w-fit">
|
||||
<Link2 className="h-2.5 w-2.5" />
|
||||
{t('memoryEcho.fused')}
|
||||
@@ -550,28 +550,28 @@ export const NoteCard = memo(function NoteCard({
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{optimisticNote.title && (
|
||||
{note.title && (
|
||||
<h3 dir="auto" className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight flex items-center gap-2">
|
||||
{(() => {
|
||||
const TypeIcon = NOTE_TYPE_ICONS[optimisticNote.type] || AlignLeft
|
||||
const TypeIcon = NOTE_TYPE_ICONS[note.type] || AlignLeft
|
||||
return <TypeIcon className="h-4 w-4 shrink-0 text-muted-foreground/50" />
|
||||
})()}
|
||||
<span className="min-w-0 truncate">{optimisticNote.title}</span>
|
||||
<span className="min-w-0 truncate">{note.title}</span>
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* Search Match Type Badge */}
|
||||
{optimisticNote.matchType && (
|
||||
{note.matchType && (
|
||||
<Badge
|
||||
variant={optimisticNote.matchType === 'exact' ? 'default' : 'secondary'}
|
||||
variant={note.matchType === 'exact' ? 'default' : 'secondary'}
|
||||
className={cn(
|
||||
'mb-2 text-xs',
|
||||
optimisticNote.matchType === 'exact'
|
||||
note.matchType === 'exact'
|
||||
? 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800'
|
||||
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
{t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
|
||||
{t(`semanticSearch.${note.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -597,12 +597,12 @@ export const NoteCard = memo(function NoteCard({
|
||||
)}
|
||||
|
||||
{/* Images Component */}
|
||||
<NoteImages images={optimisticNote.images || []} title={optimisticNote.title} />
|
||||
<NoteImages images={note.images || []} title={note.title} />
|
||||
|
||||
{/* Link Previews */}
|
||||
{Array.isArray(optimisticNote.links) && optimisticNote.links.length > 0 && (
|
||||
{Array.isArray(note.links) && note.links.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
{optimisticNote.links.map((link, idx) => (
|
||||
{note.links.map((link: any, idx: number) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={link.url}
|
||||
@@ -627,26 +627,26 @@ export const NoteCard = memo(function NoteCard({
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{optimisticNote.type === 'checklist' ? (
|
||||
{note.type === 'checklist' ? (
|
||||
<NoteChecklist
|
||||
items={localCheckItems || optimisticNote.checkItems || []}
|
||||
items={localCheckItems || optimisticNote.checkItems || note.checkItems || []}
|
||||
onToggleItem={handleCheckItem}
|
||||
/>
|
||||
) : optimisticNote.type === 'richtext' ? (
|
||||
<div className="text-sm text-foreground line-clamp-10 rt-preview" dangerouslySetInnerHTML={{ __html: optimisticNote.content || '' }} />
|
||||
) : note.type === 'richtext' ? (
|
||||
<div className="text-sm text-foreground line-clamp-10 rt-preview" dangerouslySetInnerHTML={{ __html: note.content || '' }} />
|
||||
) : (
|
||||
<div className="text-sm text-foreground line-clamp-10">
|
||||
<MarkdownContent
|
||||
content={optimisticNote.content}
|
||||
content={note.content}
|
||||
className="prose-h1:text-xl prose-h1:font-semibold prose-h1:leading-snug prose-h1:mt-1 prose-h1:mb-2 prose-h2:text-lg prose-h2:font-medium prose-h3:text-base prose-p:text-sm prose-p:leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{optimisticNote.labels.map((label) => (
|
||||
{note.labels.map((label: string) => (
|
||||
<LabelBadge key={label} label={label} />
|
||||
))}
|
||||
</div>
|
||||
@@ -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}
|
||||
|
||||
@@ -92,6 +92,24 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
|
||||
const fileInputRef = useRef<HTMLInputElement>(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)
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
||||
{language === 'fr' ? 'Titre' : 'Title'}
|
||||
</p>
|
||||
<p className="text-sm text-foreground font-medium">
|
||||
{entry.title || (language === 'fr' ? 'Sans titre' : 'Untitled')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
||||
{language === 'fr' ? 'Contenu' : 'Content'}
|
||||
</p>
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 p-3 text-sm text-foreground overflow-auto max-h-[48vh]">
|
||||
{isCl && entry.checkItems ? (
|
||||
<div className="space-y-1">
|
||||
{entry.checkItems.map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border text-[10px]',
|
||||
item.checked ? 'border-primary bg-primary/20 text-primary' : 'border-border'
|
||||
)}>{item.checked && '✓'}</span>
|
||||
<span className={cn(item.checked && 'line-through text-muted-foreground')}>{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : isMd ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<MarkdownContent content={entry.content || ''} />
|
||||
</div>
|
||||
) : isRt ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: entry.content || '' }} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap font-sans">{entry.content || ''}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLDivElement | null>
|
||||
onScroll: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground mb-1.5 shrink-0">{label}</p>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
className="rounded-md border border-border/70 bg-muted/30 overflow-auto max-h-[48vh] text-xs font-mono leading-relaxed flex-1"
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'px-2.5 py-px min-h-[1.4em]',
|
||||
line.type === 'added' && 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
|
||||
line.type === 'removed' && 'bg-red-500/15 text-red-700 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{line.value || '\u00A0'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteHistoryModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
note,
|
||||
enabled,
|
||||
onEnableHistory,
|
||||
onRestored,
|
||||
open, onOpenChange, note, enabled, onEnableHistory, onRestored,
|
||||
}: NoteHistoryModalProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [entries, setEntries] = useState<NoteHistoryEntry[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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<ViewMode>('preview')
|
||||
const [diffLeftId, setDiffLeftId] = useState<string | null>(null)
|
||||
const [diffRightId, setDiffRightId] = useState<string | null>(null)
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
|
||||
const [currentEntryId, setCurrentEntryId] = useState<string | null>(null)
|
||||
const leftScrollRef = useRef<HTMLDivElement>(null)
|
||||
const rightScrollRef = useRef<HTMLDivElement>(null)
|
||||
const isScrollSyncing = useRef(false)
|
||||
|
||||
// Reset state when modal closes or note changes
|
||||
const prevNoteId = useRef<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl p-0">
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-4">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'p-0 gap-0 sm:!max-w-none',
|
||||
viewMode === 'diff' && isDiffReady ? 'w-[92vw] max-w-[1500px]' : 'w-auto min-w-[500px] max-w-[800px]'
|
||||
)}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<div className="border-b border-border/60 px-5 py-3 pr-10">
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<History className="h-4 w-4 text-primary" />
|
||||
{t('notes.history') || 'Historique'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{note?.title || t('notes.untitled') || 'Sans titre'}
|
||||
<DialogDescription className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{noteTitle}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
{!enabled || justEnabled ? (
|
||||
{/* ── Body ── */}
|
||||
{!enabled ? (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||
{justEnabled ? (
|
||||
<>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10 mb-5 animate-in zoom-in-50 duration-300">
|
||||
<Check className="h-8 w-8 text-emerald-500" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground mb-1.5 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
{t('notes.historyEnabledTitle') || 'Historique activé !'}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-xs animate-in fade-in slide-in-from-bottom-3 duration-500 leading-relaxed">
|
||||
{t('notes.historyEnabledDesc') || "Les versions de cette note seront désormais enregistrées."}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 mb-5">
|
||||
<GitBranchPlus className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground mb-1.5">
|
||||
{t('notes.historyDisabledTitle') || 'Historique des versions'}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mb-6 leading-relaxed">
|
||||
{t('notes.historyDisabledDesc') || "Suivez les modifications de cette note au fil du temps. Activez l'historique pour commencer à enregistrer des versions."}
|
||||
</p>
|
||||
<Button onClick={handleEnable} disabled={isEnabling} size="lg" className="rounded-full px-8">
|
||||
{isEnabling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('notes.enableHistory') || "Activer l'historique"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 mb-5">
|
||||
<GitBranchPlus className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground mb-1.5">
|
||||
{t('notes.historyDisabledTitle') || 'Historique des versions'}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mb-6 leading-relaxed">
|
||||
{t('notes.historyDisabledDesc') || "Activez l'historique pour enregistrer les versions."}
|
||||
</p>
|
||||
<Button onClick={handleEnable} disabled={isEnabling} size="lg" className="rounded-full px-8">
|
||||
{isEnabling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('notes.enableHistory') || "Activer l'historique"}
|
||||
</Button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('general.loading')}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<History className="h-8 w-8 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{t('notes.historyEmpty') || 'Aucune version disponible'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[260px_1fr] gap-0">
|
||||
<div className="max-h-[60vh] overflow-y-auto border-r border-border/60 p-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('general.loading')}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">
|
||||
{t('notes.historyEmpty') || 'Aucune version disponible'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
'group/entry relative w-full rounded-md border px-2.5 py-2 text-left transition-colors',
|
||||
selectedId === entry.id
|
||||
? 'border-primary/40 bg-primary/8'
|
||||
: 'border-border/70 hover:bg-muted/60'
|
||||
<div className="grid grid-cols-[210px_1fr]">
|
||||
{/* ── Left: Version list ── */}
|
||||
<div className="max-h-[65vh] overflow-y-auto border-r border-border/60 p-2 space-y-1">
|
||||
{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 (
|
||||
<div
|
||||
key={entry.id}
|
||||
onClick={() => {
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 pr-10">
|
||||
<span className="text-xs font-semibold text-foreground">v{entry.version}</span>
|
||||
{isCurrent && (
|
||||
<span className="rounded bg-primary/15 px-1.5 py-px text-[10px] font-medium text-primary whitespace-nowrap">
|
||||
{t('notes.currentVersion') || 'actuelle'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground" suppressHydrationWarning>
|
||||
{fmtDate(entry.createdAt, language)}
|
||||
</p>
|
||||
|
||||
<div className={cn(
|
||||
'absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5',
|
||||
isSelected || isDiffSel ? 'opacity-100' : 'opacity-0 group-hover/entry:opacity-100'
|
||||
)}>
|
||||
{viewMode === 'preview' && (
|
||||
<button
|
||||
type="button" onClick={(e) => { e.stopPropagation(); handleRestore(entry.id) }}
|
||||
disabled={isRestoring}
|
||||
className="rounded p-1 text-muted-foreground/60 hover:text-primary hover:bg-primary/10"
|
||||
title={t('notes.restore') || 'Restaurer'}
|
||||
>
|
||||
{isRestoring ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(entry.id)}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
v{entry.version}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground" suppressHydrationWarning>
|
||||
{formatDistanceToNow(new Date(entry.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: getDateLocale(language),
|
||||
})}
|
||||
</p>
|
||||
{entry.reason && (
|
||||
<p className="mt-1 line-clamp-1 text-[11px] text-muted-foreground">
|
||||
{entry.reason}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteEntry(entry.id) }}
|
||||
className="absolute right-1.5 top-1.5 rounded p-0.5 text-muted-foreground/40 opacity-0 transition-opacity hover:text-red-500 group-hover/entry:opacity-100"
|
||||
type="button" onClick={(e) => { e.stopPropagation(); handleDelete(entry.id) }}
|
||||
className="rounded p-1 text-muted-foreground/60 hover:text-red-500 hover:bg-red-500/10"
|
||||
title={t('notes.deleteVersion') || 'Supprimer'}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{viewMode === 'diff' && entries.length >= 2 && (
|
||||
<div className="mt-2 border-t border-border/40 pt-2 px-1">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5">
|
||||
{t('notes.diffSelectHint') || 'Cliquez sur 2 versions pour comparer'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-red-400 inline-block" />
|
||||
{diffLeftEntry ? `v${diffLeftEntry.version}` : '—'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400 inline-block" />
|
||||
{diffRightEntry ? `v${diffRightEntry.version}` : '—'}
|
||||
</span>
|
||||
{isDiffReady && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDiffLeftId(null); setDiffRightId(null) }}
|
||||
className="ml-auto p-0.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto px-6 py-4">
|
||||
{selectedEntry ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t('notes.title') || 'Titre'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-foreground">
|
||||
{selectedEntry.title || t('notes.untitled') || 'Sans titre'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t('notes.content') || 'Contenu'}
|
||||
</p>
|
||||
<pre className="mt-1 whitespace-pre-wrap rounded-md border border-border/70 bg-muted/30 p-3 text-sm text-foreground">
|
||||
{selectedEntry.content || ''}
|
||||
</pre>
|
||||
{/* ── Right: Preview / Diff ── */}
|
||||
<div className="max-h-[65vh] overflow-y-auto px-5 py-4">
|
||||
{viewMode === 'preview' ? (
|
||||
selectedEntry ? (
|
||||
<VersionPreview entry={selectedEntry} language={language} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
{t('notes.historySelectVersion') || 'Sélectionnez une version pour la prévisualiser'}
|
||||
</p>
|
||||
)
|
||||
) : isDiffReady && diffResult ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold text-muted-foreground">
|
||||
{t('notes.diffTitle') || 'Comparaison'} : v{diffLeftEntry!.version} → v{diffRightEntry!.version}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<DiffPanel
|
||||
lines={diffResult.left}
|
||||
label={`v${diffLeftEntry!.version} — ${fmtDate(diffLeftEntry!.createdAt, language)}`}
|
||||
scrollRef={leftScrollRef}
|
||||
onScroll={() => handleSyncScroll('left')}
|
||||
/>
|
||||
<DiffPanel
|
||||
lines={diffResult.right}
|
||||
label={`v${diffRightEntry!.version} — ${fmtDate(diffRightEntry!.createdAt, language)}`}
|
||||
scrollRef={rightScrollRef}
|
||||
onScroll={() => handleSyncScroll('right')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('notes.historySelectVersion') || 'Sélectionnez une version pour prévisualiser son contenu'}
|
||||
</p>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<GitCompare className="h-8 w-8 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('notes.diffSelectHint') || 'Cliquez sur 2 versions pour comparer'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="border-t border-border/60 px-6 py-3">
|
||||
{enabled && selectedEntry && (
|
||||
<Button onClick={handleRestore} disabled={isRestoring}>
|
||||
{isRestoring
|
||||
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
: <RotateCcw className="mr-2 h-4 w-4" />}
|
||||
{t('notes.restore') || 'Restaurer'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
{/* ── Footer: actions ── */}
|
||||
{enabled && !isLoading && entries.length >= 2 && (
|
||||
<div className="flex items-center justify-end border-t border-border/60 px-5 py-2.5">
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border/60 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setViewMode('preview'); setDiffLeftId(null); setDiffRightId(null) }}
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
viewMode === 'preview' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t('notes.history') || 'Historique'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewMode('diff')
|
||||
if (!diffLeftId && entries.length >= 2) {
|
||||
setDiffLeftId(entries[1].id)
|
||||
setDiffRightId(entries[0].id)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors inline-flex items-center gap-1',
|
||||
viewMode === 'diff' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<GitCompare className="h-3 w-3" />
|
||||
{t('notes.compareVersions') || 'Comparer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<AlertDialog open={!!deleteTargetId} onOpenChange={(open) => { if (!open) setDeleteTargetId(null) }}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('notes.deleteVersionConfirm') || 'Supprimer cette version définitivement ?'}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('notes.deleteVersionDesc') || 'Cette action est irréversible. La version sera définitivement supprimée de l\'historique.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('general.cancel') || 'Annuler'}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={confirmDelete}>
|
||||
{t('general.delete') || 'Supprimer'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,6 +139,22 @@ export function NoteInlineEditor({
|
||||
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
||||
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
|
||||
|
||||
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 }))
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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<SortOrder>('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<Note[]>(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({
|
||||
)}
|
||||
>
|
||||
<NoteInlineEditor
|
||||
key={selected.id}
|
||||
key={`${selected.id}-${String(selected.updatedAt)}`}
|
||||
note={selected}
|
||||
noteHistoryMode={noteHistoryMode}
|
||||
onOpenHistory={onOpenHistory}
|
||||
|
||||
@@ -66,8 +66,10 @@ export async function createNoteHistorySnapshot({
|
||||
|
||||
if (!note || !note.userId) return
|
||||
|
||||
const ownerId = note.userId
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
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 },
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
453
memento-note/package-lock.json
generated
453
memento-note/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user