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:
120
web/src/components/Export/ExportMenu.tsx
Normal file
120
web/src/components/Export/ExportMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
230
web/src/components/History/HistoryPanel.tsx
Normal file
230
web/src/components/History/HistoryPanel.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user