401 lines
18 KiB
TypeScript
401 lines
18 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo } from 'react'
|
|
import { Note } from '@/lib/types'
|
|
import { format, formatDistanceToNow } from 'date-fns'
|
|
import { fr } from 'date-fns/locale/fr'
|
|
import { enUS } from 'date-fns/locale/en-US'
|
|
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon } 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 { enableNoteHistory, commitNoteHistory, getNoteHistory, deleteNoteHistoryEntry, restoreNoteVersion } from '@/app/actions/notes'
|
|
import { useEffect } from 'react'
|
|
|
|
type Tab = 'info' | 'versions'
|
|
|
|
interface NoteDocumentInfoPanelProps {
|
|
note: Note
|
|
content: string
|
|
onClose: () => void
|
|
onNoteRestored?: (note: Note) => void
|
|
}
|
|
|
|
function getLocale(lang: string) {
|
|
return lang === 'fr' ? fr : enUS
|
|
}
|
|
|
|
function wordCount(text: string) {
|
|
return text.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).filter(Boolean).length
|
|
}
|
|
|
|
function charCount(text: string) {
|
|
return text.replace(/<[^>]+>/g, '').length
|
|
}
|
|
|
|
const noteTypeLabel: Record<string, string> = {
|
|
richtext: 'Rich Text',
|
|
markdown: 'Markdown',
|
|
text: 'Texte',
|
|
checklist: 'Liste de tâches',
|
|
}
|
|
|
|
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 locale = getLocale(language)
|
|
|
|
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('Supprimer cette version ?')) 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 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'] 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 === 'info' ? 'Info' : 'Versions'}
|
|
</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">mots</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">caractères</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">Carnet</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">Type</p>
|
|
<p className="text-sm font-medium">{noteTypeLabel[note.type] || note.type}</p>
|
|
</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">Créée le</p>
|
|
<p className="text-sm font-medium">{format(createdAt, '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">Modifiée</p>
|
|
<p className="text-sm font-medium">{format(updatedAt, '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">Labels</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">
|
|
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">ID</p>
|
|
<p className="text-[11px] text-muted-foreground font-mono truncate">{note.id}</p>
|
|
</div>
|
|
</div>
|
|
</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">L'historique n'est pas activé pour cette note.</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)
|
|
}}
|
|
>
|
|
Activer l'historique
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">Versions sauvegardées</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" />Sauvegarde…</>
|
|
) : versionSaved ? (
|
|
<><Check className="h-3.5 w-3.5" /> Version sauvegardée !</>
|
|
) : (
|
|
<>Sauvegarder cette version</>
|
|
)}
|
|
</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">Chargement...</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">Aucune version</p>
|
|
</div>
|
|
) : (
|
|
<div className="relative pl-6 space-y-6 before:absolute before:left-[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 -left-[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">Latest</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="Restaurer"
|
|
>
|
|
{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="Supprimer"
|
|
>
|
|
{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">
|
|
{format(new Date(entry.createdAt), '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-left 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">Mode Comparaison</p>
|
|
<p className="text-[10px] text-muted-foreground">Comparer les versions côte à côte</p>
|
|
</div>
|
|
</div>
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</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)
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|