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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,23 @@ export interface WordInfo {
|
||||
|
||||
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
|
||||
|
||||
// A point-in-time snapshot of a document. List responses omit the heavy
|
||||
// content/content_text fields; they arrive only on getVersion (preview/restore).
|
||||
export interface DocumentVersion {
|
||||
id: string
|
||||
doc_id: string
|
||||
title: string
|
||||
content?: string
|
||||
content_text?: string
|
||||
word_count: number
|
||||
kind: 'auto' | 'manual' | 'pre_restore'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Downloadable export formats. PDF is handled client-side via the browser's
|
||||
// print dialog (CJK-safe, no server-side font embedding).
|
||||
export type ExportFormat = 'md' | 'html' | 'txt' | 'docx'
|
||||
|
||||
// A single LLM-proposed edit. `original` is the source of truth for placement —
|
||||
// the editor re-anchors by matching this string in the live document (spec
|
||||
// Note #6); from_pos/to_pos are server-side advisory only. `replacement` is
|
||||
@@ -100,6 +117,23 @@ export const api = {
|
||||
dismissSuggestion: (id: string) =>
|
||||
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
||||
|
||||
// Version history. listVersions returns metadata only (no bodies); getVersion
|
||||
// loads one full snapshot for preview; snapshotDoc takes an explicit restore
|
||||
// point; restoreVersion copies a snapshot back onto the live doc (capturing a
|
||||
// pre_restore safety copy server-side first) and returns the restored doc.
|
||||
listVersions: (id: string) => req<DocumentVersion[]>(`/docs/${id}/versions`),
|
||||
getVersion: (id: string, vid: string) =>
|
||||
req<DocumentVersion>(`/docs/${id}/versions/${vid}`),
|
||||
snapshotDoc: (id: string) =>
|
||||
req<DocumentVersion>(`/docs/${id}/versions`, { method: 'POST' }),
|
||||
restoreVersion: (id: string, vid: string) =>
|
||||
req<Document>(`/docs/${id}/versions/${vid}/restore`, { method: 'POST' }),
|
||||
|
||||
// Download URL for an exported document (md/html/txt/docx). Used as an <a
|
||||
// href download> target so the browser handles the file save.
|
||||
exportUrl: (id: string, format: ExportFormat) =>
|
||||
`/api/docs/${id}/export?format=${format}`,
|
||||
|
||||
// Offline word lookup (definition + synonyms) for the right-click popover.
|
||||
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
|
||||
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -269,3 +269,32 @@ button, a, input {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Print / Save-as-PDF: strip every bit of app chrome and editing decoration so
|
||||
only the title and the writing itself reach the page. This is Petal's PDF
|
||||
path — it uses the browser's own fonts, so CJK renders correctly with no
|
||||
server-side font embedding. */
|
||||
@media print {
|
||||
.petal-no-print,
|
||||
.petal-sidebar,
|
||||
.petal-suggestion-card,
|
||||
.petal-misspell-card,
|
||||
.petal-confetti,
|
||||
.petal-companion {
|
||||
display: none !important;
|
||||
}
|
||||
html, body, #root {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
/* Drop the in-editor highlight underlines/tints — they're guidance, not ink. */
|
||||
.petal-suggestion,
|
||||
.petal-misspelling {
|
||||
background: none !important;
|
||||
text-decoration: none !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
/* Let the writing use the full page width. */
|
||||
.max-w-\[720px\] { max-width: none !important; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user