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
231 lines
8.8 KiB
TypeScript
231 lines
8.8 KiB
TypeScript
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)' }}>
|
||
Couldn’t 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>
|
||
)
|
||
}
|