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
This commit is contained in:
prosolis
2026-06-25 23:44:15 -07:00
parent cf7720ea77
commit 8e1111d768
13 changed files with 1703 additions and 14 deletions

View File

@@ -0,0 +1,120 @@
import { useEffect, useRef, useState } from 'react'
import { api, type ExportFormat } from '../../api/client'
// ExportMenu is the "get your writing out of Petal" dropdown. File formats are
// plain <a download> links to the server's export endpoint (which sets the
// Content-Disposition filename, CJK and all). "Print / Save as PDF" calls the
// browser print dialog against the print stylesheet, so PDF stays CJK-safe with
// no server-side font embedding. Bilingual zh·en labels match Petal's chrome.
interface FormatOption {
format: ExportFormat
emoji: string
zh: string
en: string
}
const FORMATS: FormatOption[] = [
{ format: 'docx', emoji: '📄', zh: 'Word 文档', en: 'Word (.docx)' },
{ format: 'md', emoji: '📝', zh: 'Markdown', en: 'Markdown (.md)' },
{ format: 'html', emoji: '🌐', zh: '网页', en: 'Web page (.html)' },
{ format: 'txt', emoji: '🧾', zh: '纯文本', en: 'Plain text (.txt)' },
]
interface Props {
docId: string
}
export function ExportMenu({ docId }: Props) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [open])
return (
<div ref={ref} className="relative shrink-0">
<button
type="button"
aria-label="Export document"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
style={{
borderRadius: 'var(--radius-pill)',
background: 'var(--color-surface)',
color: 'var(--color-plum)',
boxShadow: 'var(--shadow-soft)',
}}
title="Save or print your writing"
>
<span aria-hidden></span>
<span></span>
<span style={{ color: 'var(--color-muted)' }}>· Export</span>
</button>
{open && (
<div
role="menu"
className="petal-word-card absolute right-0 z-30 mt-1.5 p-1.5"
style={{
width: 220,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)',
}}
>
{FORMATS.map((f) => (
<a
key={f.format}
role="menuitem"
href={api.exportUrl(docId, f.format)}
download
onClick={() => setOpen(false)}
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold no-underline"
style={{ color: 'var(--color-plum)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span aria-hidden>{f.emoji}</span>
<span>{f.zh}</span>
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
{f.en}
</span>
</a>
))}
<div className="my-1 h-px" style={{ background: 'var(--color-border)' }} />
<button
type="button"
role="menuitem"
onClick={() => {
setOpen(false)
// Defer so the menu unmounts before the print dialog snapshots.
setTimeout(() => window.print(), 50)
}}
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold"
style={{ background: 'transparent', color: 'var(--color-plum)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span aria-hidden>🖨</span>
<span> / PDF</span>
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
Print / PDF
</span>
</button>
</div>
)}
</div>
)
}