Files
Momento/memento-note/components/note-document-info-panel.tsx
Antigravity 89d2ffad46
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m21s
CI / Deploy production (on server) (push) Failing after 4s
fix: emitNoteChange après enableNoteHistory — carte se met à jour
enableNoteHistory() mettait à jour la DB mais ne notifiait pas la home page.
L'éditeur fetch la note à jour (historyEnabled=true) → icône visible.
La home gardait les données en cache (historyEnabled=false) → pas d'icône.

Fix: emitNoteChange({ type: 'updated', note: { ...note, historyEnabled: true } })
dispatché après les 2 points d'appel dans note-document-info-panel.tsx.
2026-06-28 10:02:33 +00:00

497 lines
23 KiB
TypeScript

'use client'
import { useState, useMemo } from 'react'
import { Note } from '@/lib/types'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { faIR } from 'date-fns/locale/fa-IR'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon, Network, Copy, ExternalLink } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { LabelBadge } from './label-badge'
import { NoteHistoryModal } from './note-history-modal'
import { NoteNetworkTab } from './note-network-tab'
import { NoteEditorPropertiesPanel } from './structured-views/note-editor-properties-panel'
import { enableNoteHistory, commitNoteHistory, getNoteHistory, deleteNoteHistoryEntry, restoreNoteVersion } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync'
import { useEffect } from 'react'
type Tab = 'info' | 'versions' | 'network'
interface NoteDocumentInfoPanelProps {
note: Note
content: string
onClose: () => void
onNoteRestored?: (note: Note) => void
}
function getLocale(lang: string) {
if (lang === 'fr') return fr
if (lang === 'fa') return faIR
return enUS
}
function wordCount(text: string) {
return text.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).filter(Boolean).length
}
function charCount(text: string) {
return text.replace(/<[^>]+>/g, '').length
}
function lineCount(text: string) {
const plain = text.replace(/<[^>]+>/g, '\n')
return plain.trim() ? plain.split('\n').length : 0
}
function equationCount(text: string) {
const block = (text.match(/\$\$[\s\S]+?\$\$/g) || []).length
const inline = (text.match(/\$[^$\n]+?\$/g) || []).length
return block + inline
}
function imageCount(text: string) {
const md = (text.match(/!\[[^\]]*\]\([^)]+\)/g) || []).length
const html = (text.match(/<img\s/gi) || []).length
return md + html
}
export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }: NoteDocumentInfoPanelProps) {
const { t, language } = useLanguage()
const { notebooks } = useNotebooks()
const [activeTab, setActiveTab] = useState<Tab>('info')
const [showHistory, setShowHistory] = useState(false)
const [historyEnabled, setHistoryEnabled] = useState(note.historyEnabled ?? false)
const [isSavingVersion, setIsSavingVersion] = useState(false)
const [versionSaved, setVersionSaved] = useState(false)
const [historyEntries, setHistoryEntries] = useState<any[]>([])
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
const [isDeleting, setIsDeleting] = useState<string | null>(null)
const [isRestoring, setIsRestoring] = useState<string | null>(null)
const [copiedId, setCopiedId] = useState(false)
const locale = getLocale(language)
const displayNoteType = useMemo(() => {
if (note.sourceUrl) return t('notes.noteTypes.clip')
const map: Record<string, string> = {
richtext: t('notes.noteTypes.richtext'),
markdown: t('notes.noteTypes.markdown'),
text: t('notes.noteTypes.text'),
checklist: t('notes.noteTypes.checklist'),
}
return map[note.type] || note.type
}, [t, note.type, note.sourceUrl])
useEffect(() => {
if (activeTab === 'versions' && historyEnabled) {
loadHistory()
}
}, [activeTab, historyEnabled, note.id])
const loadHistory = async () => {
setIsLoadingHistory(true)
try {
const entries = await getNoteHistory(note.id, 50)
setHistoryEntries(entries)
} catch (e) {
console.error(e)
} finally {
setIsLoadingHistory(false)
}
}
const handleDeleteVersion = async (entryId: string) => {
if (!confirm(t('documentInfo.deleteVersionConfirm'))) return
setIsDeleting(entryId)
try {
await deleteNoteHistoryEntry(note.id, entryId)
setHistoryEntries(prev => prev.filter(e => e.id !== entryId))
} catch (e) {
console.error(e)
} finally {
setIsDeleting(null)
}
}
const handleRestoreVersion = async (entryId: string) => {
setIsRestoring(entryId)
try {
const restored = await restoreNoteVersion(note.id, entryId)
onNoteRestored?.(restored)
loadHistory()
} catch (e) {
console.error(e)
} finally {
setIsRestoring(null)
}
}
const notebook = useMemo(
() => notebooks.find(nb => nb.id === note.notebookId),
[notebooks, note.notebookId]
)
const words = useMemo(() => wordCount(content), [content])
const chars = useMemo(() => charCount(content), [content])
const lines = useMemo(() => lineCount(content), [content])
const equations = useMemo(() => equationCount(content), [content])
const images = useMemo(() => imageCount(content), [content])
const createdAt = note.createdAt ? new Date(note.createdAt as unknown as string) : null
const updatedAt = note.contentUpdatedAt ? new Date(note.contentUpdatedAt as unknown as string) : null
return (
<>
<div className="flex w-full h-full flex-col bg-background overflow-hidden">
{/* Header tabs */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/40">
<div className="flex gap-1">
{(['info', 'versions', 'network'] as Tab[]).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold transition-all',
activeTab === tab
? 'bg-foreground text-background'
: 'text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
{tab === 'info' && <Info className="h-3 w-3" />}
{tab === 'versions' && <Clock className="h-3 w-3" />}
{tab === 'network' && <Network className="h-3 w-3" />}
{tab === 'info'
? t('documentInfo.tabInfo')
: tab === 'versions'
? t('documentInfo.tabVersions')
: t('documentInfo.tabNetwork')}
</button>
))}
</div>
<button
onClick={onClose}
className="p-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto">
{/* ── INFO TAB ── */}
{activeTab === 'info' && (
<div>
{/* Stats — grand display numbers */}
<div className="grid grid-cols-2 border-b border-border/30">
<div className="flex flex-col items-center gap-1 py-6 border-r border-border/30">
<span className="text-4xl font-bold font-memento-serif tabular-nums tracking-tight">{words}</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-semibold">{t('documentInfo.wordsLabel')}</span>
</div>
<div className="flex flex-col items-center gap-1 py-6">
<span className="text-4xl font-bold font-memento-serif tabular-nums tracking-tight">{chars}</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-semibold">{t('documentInfo.charactersLabel')}</span>
</div>
</div>
<div className="grid grid-cols-3 border-b border-border/30 divide-x divide-border/30">
{[
{ value: lines, label: t('documentInfo.linesLabel') },
{ value: equations, label: t('documentInfo.equationsLabel') },
{ value: images, label: t('documentInfo.imagesLabel') },
].map(({ value, label }) => (
<div key={label} className="flex flex-col items-center gap-0.5 py-3">
<span className="text-lg font-bold font-memento-serif tabular-nums">{value}</span>
<span className="text-[8px] uppercase tracking-widest text-muted-foreground font-semibold">{label}</span>
</div>
))}
</div>
<div className="divide-y divide-border/30">
{notebook && (
<div className="flex items-start gap-3 px-4 py-3">
<Book className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.notebookLabel')}</p>
<p className="text-sm font-medium">{notebook.name}</p>
</div>
</div>
)}
<div className="flex items-start gap-3 px-4 py-3">
<FileText className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.typeLabel')}</p>
<p className="text-sm font-medium">{displayNoteType}</p>
</div>
</div>
{note.sourceUrl && (
<div className="flex items-start gap-3 px-4 py-3">
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.sourceWebLabel')}</p>
<a
href={note.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-brand-accent hover:underline break-all"
>
{note.sourceUrl}
</a>
</div>
</div>
)}
{createdAt && (
<div className="flex items-start gap-3 px-4 py-3">
<Calendar className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.createdLabel')}</p>
<p className="text-sm font-medium">{formatAbsoluteDateLocalized(createdAt, language, 'd MMM yyyy', locale)}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
{formatDistanceToNow(createdAt, { addSuffix: true, locale })}
</p>
</div>
</div>
)}
{updatedAt && (
<div className="flex items-start gap-3 px-4 py-3">
<Clock className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.modifiedLabel')}</p>
<p className="text-sm font-medium">{formatAbsoluteDateLocalized(updatedAt, language, 'd MMM yyyy · HH:mm', locale)}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
{formatDistanceToNow(updatedAt, { addSuffix: true, locale })}
</p>
</div>
</div>
)}
{(note.labels ?? []).length > 0 && (
<div className="flex items-start gap-3 px-4 py-3">
<Tag className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-1.5">{t('documentInfo.labelsSection')}</p>
<div className="flex flex-wrap gap-1">
{(note.labels ?? []).map(label => (
<LabelBadge key={label} label={label} />
))}
</div>
</div>
</div>
)}
<div className="flex items-start gap-3 px-4 py-3">
<Hash className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.idLabel')}</p>
<div className="flex items-center gap-1.5 min-w-0">
<p className="text-[11px] text-muted-foreground font-mono truncate">{note.id}</p>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(note.id).then(() => {
setCopiedId(true)
setTimeout(() => setCopiedId(false), 2000)
})
}}
className="p-1 rounded hover:bg-muted text-muted-foreground shrink-0"
title={t('documentInfo.copyId')}
>
{copiedId ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
</button>
</div>
</div>
</div>
</div>
<div className="px-4 pb-4">
<NoteEditorPropertiesPanel
noteId={note.id}
notebookId={note.notebookId}
/>
</div>
</div>
)}
{/* ── VERSIONS TAB ── */}
{activeTab === 'versions' && (
<div className="p-4 space-y-4">
{!historyEnabled ? (
<div className="text-center py-6 space-y-3">
<Clock className="h-8 w-8 text-muted-foreground/30 mx-auto" />
<p className="text-sm text-muted-foreground">{t('documentInfo.historyDisabled')}</p>
<button
className="text-xs px-4 py-2 rounded-lg bg-foreground text-background font-medium hover:opacity-80 transition-opacity"
onClick={async () => {
await enableNoteHistory(note.id)
setHistoryEnabled(true)
setShowHistory(true)
emitNoteChange({ type: 'updated', note: { ...note, historyEnabled: true } })
}}
>
{t('documentInfo.enableHistory')}
</button>
</div>
) : (
<div className="space-y-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('documentInfo.savedVersions')}</p>
{/* Save version button */}
<button
disabled={isSavingVersion}
className={cn(
'w-full flex items-center justify-center gap-2 p-3 rounded-xl border transition-all text-xs font-bold uppercase tracking-widest',
versionSaved
? 'border-emerald-500/40 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-400'
: 'border-foreground/10 bg-foreground text-background hover:opacity-90 shadow-sm',
isSavingVersion && 'opacity-50 cursor-not-allowed'
)}
onClick={async () => {
setIsSavingVersion(true)
try {
await commitNoteHistory(note.id)
setVersionSaved(true)
loadHistory()
setTimeout(() => setVersionSaved(false), 3000)
} catch (e) {
console.error(e)
} finally {
setIsSavingVersion(false)
}
}}
>
{isSavingVersion ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" />{t('documentInfo.savingEllipsis')}</>
) : versionSaved ? (
<><Check className="h-3.5 w-3.5" /> {t('documentInfo.versionSaved')}</>
) : (
<>{t('documentInfo.saveThisVersion')}</>
)}
</button>
<div className="h-px bg-border/30 my-2" />
{/* Timeline */}
{isLoadingHistory && historyEntries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 opacity-40">
<Loader2 className="h-6 w-6 animate-spin mb-2" />
<p className="text-[10px] uppercase tracking-widest">{t('documentInfo.loading')}</p>
</div>
) : historyEntries.length === 0 ? (
<div className="text-center py-8 opacity-40 border border-dashed rounded-xl">
<Clock className="h-6 w-6 mx-auto mb-2" />
<p className="text-[10px] uppercase tracking-widest">{t('documentInfo.noVersion')}</p>
</div>
) : (
<div className="relative ps-6 space-y-6 before:absolute before:start-[11px] before:top-2 before:bottom-2 before:w-px before:bg-border/40">
{historyEntries.map((entry, idx) => {
const colors = ['#E2E8F0', '#ACB995', '#E9ECEF']
const dotColor = colors[idx % colors.length]
const isLatest = idx === 0
return (
<div key={entry.id} className="relative group">
{/* Dot */}
<div
className="absolute -start-[19px] top-1.5 h-3 w-3 rounded-full border-2 border-background z-10 shadow-sm"
style={{ backgroundColor: dotColor }}
/>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-bold font-mono">v{entry.version}</span>
{isLatest && (
<span className="text-[9px] px-1.5 py-0.5 rounded-md bg-primary/10 text-primary font-bold uppercase tracking-widest">{t('documentInfo.latestBadge')}</span>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleRestoreVersion(entry.id)}
disabled={!!isRestoring || !!isDeleting}
className="p-1.5 rounded-lg hover:bg-primary/10 text-muted-foreground hover:text-primary transition-colors"
title={t('documentInfo.restoreTooltip')}
>
{isRestoring === entry.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />}
</button>
<button
onClick={() => handleDeleteVersion(entry.id)}
disabled={!!isRestoring || !!isDeleting}
className="p-1.5 rounded-lg hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-colors"
title={t('documentInfo.deleteTooltip')}
>
{isDeleting === entry.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
</button>
</div>
</div>
<p className="text-[10px] text-muted-foreground font-medium">
{formatAbsoluteDateLocalized(new Date(entry.createdAt), language, 'd MMM · HH:mm', locale)}
<span className="mx-1.5 opacity-30">·</span>
{formatDistanceToNow(new Date(entry.createdAt), { addSuffix: true, locale })}
</p>
</div>
</div>
)
})}
</div>
)}
{/* Button to open the full modal (optional, but good to keep if user wants diff) */}
<button
className="w-full flex items-center justify-between p-3 rounded-xl border border-border/40 hover:bg-muted/50 transition-colors text-start group mt-4"
onClick={() => setShowHistory(true)}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/5 flex items-center justify-center text-primary group-hover:bg-primary/10 transition-colors">
<HistoryIcon className="h-4 w-4" />
</div>
<div>
<p className="text-xs font-bold uppercase tracking-wider">{t('documentInfo.comparisonMode')}</p>
<p className="text-[10px] text-muted-foreground">{t('documentInfo.comparisonSubtitle')}</p>
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground rtl:scale-x-[-1]" />
</button>
</div>
)}
</div>
)}
{/* ── NETWORK TAB ── */}
{activeTab === 'network' && (
<NoteNetworkTab noteId={note.id} noteTitle={note.title || ''} />
)}
</div>
</div>
{/* NoteHistoryModal with correct props */}
{showHistory && (
<NoteHistoryModal
open={showHistory}
onOpenChange={(v) => setShowHistory(v)}
note={note}
enabled={historyEnabled}
onEnableHistory={async () => {
await enableNoteHistory(note.id)
setHistoryEnabled(true)
emitNoteChange({ type: 'updated', note: { ...note, historyEnabled: true } })
}}
onRestored={(restored) => {
setShowHistory(false)
onNoteRestored?.(restored)
}}
/>
)}
</>
)
}