Files
petal/web/src/components/History/HistoryPanel.tsx
prosolis 8e1111d768 Phase 8: version history + export (trust foundation)
Version history: new document_versions table (migration 0003) holding
full-body snapshots that cascade with the doc. Throttled auto-snapshots
on save (>=3min apart, max 40/doc, pruned), explicit manual restore
points, and a pre_restore safety copy taken before each restore so
restoring is itself undoable. Endpoints under /api/docs/:id/versions,
all owner-scoped. Empties and bare renames never snapshot.

Export: pure-Go Tiptap-JSON -> Markdown / HTML / plain-text / docx
(no cgo/pandoc, single-binary intact), CJK-safe with RFC 5987
filenames. docx is a hand-built OOXML zip. PDF is handled client-side
via the browser print dialog + an @media print stylesheet so CJK
renders with the reader's own fonts.

Frontend: ExportMenu (downloads + Print/PDF) and HistoryPanel
(snapshot list, preview, restore) wired into the title row; bilingual
zh-first to match chrome. Restore remounts the editor via editorEpoch.

Stop saving empty docs: blank Untitled drafts now reuse-on-create and
self-discard when navigated away from (refs avoid stale closures).

Tests: versions_test.go, export_test.go (incl. valid-zip docx).
go build/vet/test, tsc, vite build all clean; live end-to-end smoke
verified snapshot/throttle/restore/export.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 23:44:15 -07:00

231 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from 'react'
import { api, type Document, type DocumentVersion } from '../../api/client'
// HistoryPanel is the "time machine" drawer: every snapshot Petal kept of this
// document, newest first, with a one-click preview and restore. It's the safety
// net that makes trusting Petal with real writing reasonable — a bad edit or a
// regretted rewrite is always recoverable, and restoring is itself undoable
// (the server keeps a pre_restore copy). Bilingual, zh-first, to match chrome.
interface Props {
docId: string
onClose: () => void
// Called after a successful restore with the freshly-restored document so the
// editor can reload it.
onRestored: (doc: Document) => void
}
// kindLabel maps a snapshot kind to its bilingual badge + accent color.
const KIND: Record<DocumentVersion['kind'], { zh: string; en: string; color: string }> = {
manual: { zh: '保存点', en: 'Saved point', color: 'var(--color-accent)' },
auto: { zh: '自动', en: 'Auto', color: 'var(--color-muted)' },
pre_restore: { zh: '恢复前', en: 'Before restore', color: 'var(--color-lavender)' },
}
// relativeTime renders a UTC timestamp as a gentle "x minutes ago" string.
function relativeTime(iso: string): string {
const then = new Date(iso).getTime()
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
if (secs < 60) return 'just now · 刚刚'
const mins = Math.round(secs / 60)
if (mins < 60) return `${mins} min ago · ${mins} 分钟前`
const hrs = Math.round(mins / 60)
if (hrs < 24) return `${hrs} hr ago · ${hrs} 小时前`
const days = Math.round(hrs / 24)
return `${days} day${days > 1 ? 's' : ''} ago · ${days} 天前`
}
export function HistoryPanel({ docId, onClose, onRestored }: Props) {
const [versions, setVersions] = useState<DocumentVersion[] | null>(null)
const [error, setError] = useState(false)
const [selected, setSelected] = useState<DocumentVersion | null>(null)
const [preview, setPreview] = useState<DocumentVersion | null>(null)
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
setError(false)
try {
setVersions(await api.listVersions(docId))
} catch {
setError(true)
}
}, [docId])
useEffect(() => {
void load()
}, [load])
// Escape closes the drawer.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const choose = useCallback(
async (v: DocumentVersion) => {
setSelected(v)
setPreview(null)
try {
setPreview(await api.getVersion(docId, v.id))
} catch {
setPreview(null)
}
},
[docId],
)
const restore = useCallback(async () => {
if (!selected) return
setBusy(true)
try {
const doc = await api.restoreVersion(docId, selected.id)
onRestored(doc)
onClose()
} catch {
setBusy(false)
}
}, [docId, selected, onRestored, onClose])
return (
<div className="petal-no-print fixed inset-0 z-40 flex justify-end">
{/* Backdrop */}
<div
className="absolute inset-0"
style={{ background: 'rgba(61, 46, 57, 0.18)' }}
onClick={onClose}
/>
<aside
className="relative flex h-full w-full max-w-[380px] flex-col"
style={{
background: 'var(--color-surface)',
borderLeft: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-soft)',
}}
>
<header
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<div>
<div className="text-base font-extrabold text-plum"> · History</div>
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
Every saved moment nothing is ever lost 🌸
</div>
</div>
<button
type="button"
aria-label="Close history"
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-full text-lg"
style={{ color: 'var(--color-muted)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
{error ? (
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
Couldnt load history just now.
<button onClick={load} className="ml-1 font-bold text-plum underline">
Try again
</button>
</div>
) : versions === null ? (
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
Loading
</div>
) : versions.length === 0 ? (
<div className="px-2 py-8 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
No snapshots yet. Keep writing Petal saves restore points as you go.
</div>
) : (
<ul className="flex flex-col gap-1.5">
{versions.map((v) => {
const k = KIND[v.kind]
const active = selected?.id === v.id
return (
<li key={v.id}>
<button
type="button"
onClick={() => choose(v)}
className="flex w-full flex-col gap-1 rounded-2xl px-3 py-2.5 text-left"
style={{
background: active ? 'var(--color-surface-alt)' : 'transparent',
border: `1px solid ${active ? 'var(--color-border)' : 'transparent'}`,
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = 'var(--color-surface-alt)')
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = active
? 'var(--color-surface-alt)'
: 'transparent')
}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-bold text-plum">{v.title || 'Untitled'}</span>
<span
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
style={{ background: k.color, color: '#fff' }}
>
{k.zh} · {k.en}
</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs" style={{ color: 'var(--color-muted)' }}>
<span>{relativeTime(v.created_at)}</span>
<span>{v.word_count} words</span>
</div>
</button>
</li>
)
})}
</ul>
)}
</div>
{selected && (
<div
className="shrink-0 px-4 py-3"
style={{ borderTop: '1px solid var(--color-border)', background: 'var(--color-surface-alt)' }}
>
<div className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
· Preview
</div>
<div
className="mb-3 max-h-32 overflow-y-auto whitespace-pre-wrap rounded-xl px-3 py-2 text-sm"
style={{
background: 'var(--color-surface)',
color: 'var(--color-plum)',
fontFamily: 'var(--font-body)',
}}
>
{preview ? preview.content_text || '(empty)' : 'Loading…'}
</div>
<button
type="button"
onClick={restore}
disabled={busy}
className="w-full rounded-full py-2.5 text-sm font-extrabold text-white disabled:opacity-60"
style={{ background: 'var(--color-accent)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
{busy ? 'Restoring…' : '恢复这个版本 · Restore this version'}
</button>
<div className="mt-1.5 text-center text-[11px]" style={{ color: 'var(--color-muted)' }}>
Your current draft is saved first, so this is undoable.
</div>
</div>
)}
</aside>
</div>
)
}