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:
111
web/src/App.tsx
111
web/src/App.tsx
@@ -6,6 +6,8 @@ import { useSpellChecker } from './hooks/useSpellChecker'
|
||||
import { DocList } from './components/DocList/DocList'
|
||||
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||
import { ToneSelect } from './components/Editor/ToneSelect'
|
||||
import { ExportMenu } from './components/Export/ExportMenu'
|
||||
import { HistoryPanel } from './components/History/HistoryPanel'
|
||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||
@@ -29,6 +31,28 @@ export default function App() {
|
||||
// Monotonic counters the companion watches to react to writing + accepts.
|
||||
const [editTick, setEditTick] = useState(0)
|
||||
const [acceptTick, setAcceptTick] = useState(0)
|
||||
// History drawer visibility, and an epoch bumped on restore to force the
|
||||
// editor to remount with the restored content (its initialContent is read
|
||||
// only on mount).
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [editorEpoch, setEditorEpoch] = useState(0)
|
||||
|
||||
// Live mirrors of the current doc's editable fields so the "discard blank
|
||||
// drafts on navigation" logic can read the latest values without rebuilding
|
||||
// callbacks on every keystroke.
|
||||
const currentDocRef = useRef(currentDoc)
|
||||
const titleRef = useRef(title)
|
||||
const wordCountRef = useRef(wordCount)
|
||||
currentDocRef.current = currentDoc
|
||||
titleRef.current = title
|
||||
wordCountRef.current = wordCount
|
||||
|
||||
// A throwaway blank draft: no words and still the default/empty title. We
|
||||
// delete these on navigation rather than leave orphan "Untitled" docs behind.
|
||||
const isBlankDraft = useCallback(() => {
|
||||
const t = titleRef.current.trim()
|
||||
return wordCountRef.current === 0 && (t === '' || t === 'Untitled')
|
||||
}, [])
|
||||
|
||||
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
|
||||
const {
|
||||
@@ -49,6 +73,9 @@ export default function App() {
|
||||
|
||||
const openDoc = useCallback(
|
||||
async (id: string) => {
|
||||
// Decide the leaving doc's fate before any await mutates state.
|
||||
const leaving = currentDocRef.current
|
||||
const leavingBlank = isBlankDraft()
|
||||
await saveNow() // flush any pending edits to the doc we're leaving
|
||||
const doc = await api.getDoc(id)
|
||||
setCurrentDoc(doc)
|
||||
@@ -56,8 +83,14 @@ export default function App() {
|
||||
setWordCount(doc.word_count)
|
||||
setTone(doc.tone || 'general')
|
||||
setDocText(doc.content_text)
|
||||
// Leaving an untouched blank draft for a different doc? Drop it silently
|
||||
// (best-effort cleanup — a 404 just means it's already gone).
|
||||
if (leaving && leaving.id !== id && leavingBlank) {
|
||||
api.deleteDoc(leaving.id).catch(() => {})
|
||||
setDocs((prev) => prev.filter((d) => d.id !== leaving.id))
|
||||
}
|
||||
},
|
||||
[saveNow],
|
||||
[saveNow, isBlankDraft],
|
||||
)
|
||||
|
||||
// Initial load: fetch the list, opening the first doc (or creating one).
|
||||
@@ -90,6 +123,9 @@ export default function App() {
|
||||
}, [openDoc])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
// Already sitting on a fresh blank draft? Reuse it instead of stacking
|
||||
// another empty "Untitled" on top.
|
||||
if (currentDocRef.current && isBlankDraft()) return
|
||||
await saveNow()
|
||||
const fresh = await api.createDoc()
|
||||
setDocs((prev) => [
|
||||
@@ -101,7 +137,7 @@ export default function App() {
|
||||
setWordCount(0)
|
||||
setTone(fresh.tone || 'general')
|
||||
setDocText('')
|
||||
}, [saveNow])
|
||||
}, [saveNow, isBlankDraft])
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
@@ -179,6 +215,21 @@ export default function App() {
|
||||
[removeSuggestion],
|
||||
)
|
||||
|
||||
// After restoring a version, swap the restored doc into the editor. Bumping
|
||||
// editorEpoch remounts EditorCore so it picks up the restored content.
|
||||
const handleRestored = useCallback(
|
||||
(doc: Document) => {
|
||||
setCurrentDoc(doc)
|
||||
setTitle(doc.title)
|
||||
setWordCount(doc.word_count)
|
||||
setTone(doc.tone || 'general')
|
||||
setDocText(doc.content_text)
|
||||
patchSummary(doc.id, { title: doc.title, word_count: doc.word_count })
|
||||
setEditorEpoch((n) => n + 1)
|
||||
},
|
||||
[patchSummary],
|
||||
)
|
||||
|
||||
// Escape always restores the sidebar while in distraction-free mode.
|
||||
useEffect(() => {
|
||||
if (!focusMode) return
|
||||
@@ -211,7 +262,7 @@ export default function App() {
|
||||
<div className="flex h-full flex-col">
|
||||
<header
|
||||
onMouseDown={handleChromeDown}
|
||||
className="flex h-12 shrink-0 items-center gap-2 px-5"
|
||||
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"
|
||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<span className="text-xl">🌸</span>
|
||||
@@ -219,7 +270,9 @@ export default function App() {
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className={`petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}`}>
|
||||
<div
|
||||
className={`petal-no-print petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}`}
|
||||
>
|
||||
<DocList
|
||||
docs={docs}
|
||||
selectedId={currentDoc?.id ?? null}
|
||||
@@ -246,10 +299,32 @@ export default function App() {
|
||||
className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
|
||||
style={{ fontFamily: 'var(--font-ui)' }}
|
||||
/>
|
||||
<ToneSelect value={tone} onChange={handleToneChange} />
|
||||
<div className="petal-no-print shrink-0">
|
||||
<ToneSelect value={tone} onChange={handleToneChange} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistoryOpen(true)}
|
||||
aria-label="Version history"
|
||||
title="Browse and restore earlier versions"
|
||||
className="petal-no-print inline-flex h-9 shrink-0 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)',
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>🕘</span>
|
||||
<span>历史</span>
|
||||
<span style={{ color: 'var(--color-muted)' }}>· History</span>
|
||||
</button>
|
||||
<div className="petal-no-print">
|
||||
<ExportMenu docId={currentDoc.id} />
|
||||
</div>
|
||||
</div>
|
||||
<EditorCore
|
||||
key={currentDoc.id}
|
||||
key={`${currentDoc.id}:${editorEpoch}`}
|
||||
docId={currentDoc.id}
|
||||
initialContent={currentDoc.content}
|
||||
onChange={handleEditorChange}
|
||||
@@ -264,7 +339,7 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div onMouseDown={handleChromeDown}>
|
||||
<div className="petal-no-print" onMouseDown={handleChromeDown}>
|
||||
<StatusBar
|
||||
wordCount={wordCount}
|
||||
text={docText}
|
||||
@@ -285,14 +360,24 @@ export default function App() {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{historyOpen && currentDoc && (
|
||||
<HistoryPanel
|
||||
docId={currentDoc.id}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
onRestored={handleRestored}
|
||||
/>
|
||||
)}
|
||||
|
||||
{updateAvailable && <UpdateBanner />}
|
||||
|
||||
<PetalCompanion
|
||||
wordCount={wordCount}
|
||||
saveStatus={status}
|
||||
editTick={editTick}
|
||||
acceptTick={acceptTick}
|
||||
/>
|
||||
<div className="petal-no-print">
|
||||
<PetalCompanion
|
||||
wordCount={wordCount}
|
||||
saveStatus={status}
|
||||
editTick={editTick}
|
||||
acceptTick={acceptTick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user