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>
494 lines
23 KiB
TypeScript
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)
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|