Files
Momento/memento-note/components/note-document-info-panel.tsx
Antigravity 0784c94242
Some checks failed
CI / Lint, Test & Build (push) Failing after 57s
CI / Deploy production (on server) (push) Has been skipped
feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note)
avec activation guidée, tableau éditable, kanban et suppression de colonnes.
Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN.
Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la
robustesse du serveur MCP (config, validation, rate-limit, métriques).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:03:16 +00:00

494 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 { 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)
}}
>
{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)
}}
onRestored={(restored) => {
setShowHistory(false)
onNoteRestored?.(restored)
}}
/>
)}
</>
)
}