Add trust features: history, trash, export, and collection sharing
release gate / check (push) Failing after 6m36s

Implement Notion-like upgrade phase 4: append-only page revisions with restore-as-a-new-version; recoverable bookmark/page/collection deletions whose restores never resurrect public flags or shares; owner-scoped JSON export; and explicit, revocable read-only collection sharing with owner-only mutations and no transitive third-party bookmark leaks. Add migration 009, API/docs, integration coverage, and page/collections/trash UI.
This commit is contained in:
Andrew S Erwin
2026-10-19 23:07:38 -05:00
parent 9db69efe4b
commit 5eab58f99c
22 changed files with 1496 additions and 76 deletions
+109 -4
View File
@@ -8,12 +8,13 @@ import remarkGfm from 'remark-gfm'
import { toast } from 'sonner'
import {
ChevronLeft, ChevronRight, Code, CornerUpLeft, Download, Eye, FileText, GripVertical,
Heading1, Heading2, Heading3, Layers, Link2, List, ListOrdered, ListTodo, Minus, Pencil, Pin, PinOff,
Plus, Quote, Search, Trash2, Type, X,
Heading1, Heading2, Heading3, History, Layers, Link2, List, ListOrdered, ListTodo, Minus, Pencil,
Pin, PinOff, Plus, Quote, RotateCcw, Search, Trash2, Type, X,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { PageAutosave, PageConflictError, type PageSaveState } from '@/lib/page-autosave'
@@ -65,6 +66,8 @@ type PageDetail = PageSummary & {
links: { id: string; title: string }[]
embeds: PageEmbed[]
}
type PageRevisionSummary = { id: string; version: number; title: string; createdAt: string; length: number }
type PageRevisionDetail = PageRevisionSummary & { content: string; icon: string | null }
const notify = notifyPagesChanged
const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u } }
@@ -192,6 +195,10 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
const [pageLinkQuery, setPageLinkQuery] = useState('')
const [embedOpen, setEmbedOpen] = useState(false)
const [collections, setCollections] = useState<{ id: string; name: string; icon: string | null; views?: { id: string; name: string }[] }[]>([])
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<PageRevisionSummary[]>([])
const [revision, setRevision] = useState<PageRevisionDetail | null>(null)
const [historyBusy, setHistoryBusy] = useState<string | null>(null)
const [slash, setSlash] = useState<{ query: string; start: number } | null>(null)
const [slashIndex, setSlashIndex] = useState(0)
const [tabDrop, setTabDrop] = useState<{ id: string; after: boolean } | null>(null)
@@ -388,6 +395,60 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
}
}
const openHistory = async () => {
if (!page) return
setHistoryOpen(true)
setRevision(null)
try {
const res = await fetch(`/api/pages/${page.id}/revisions`, { cache: 'no-store' })
setRevisions(res.ok ? await res.json() : [])
if (!res.ok) toast.error('Could not load page history')
} catch { toast.error('Could not load page history') }
}
const viewRevision = async (id: string) => {
if (!page) return
setHistoryBusy(id)
try {
const res = await fetch(`/api/pages/${page.id}/revisions/${id}`, { cache: 'no-store' })
if (res.ok) setRevision(await res.json())
else toast.error('Could not load this version')
} catch { toast.error('Could not load this version') }
finally { setHistoryBusy(null) }
}
const restoreRevision = async (rev: PageRevisionSummary) => {
if (!page) return
if (!confirm(`Restore version ${rev.version}? The current content is kept in history.`)) return
setHistoryBusy(rev.id)
try {
if (!await autosave.flush(page.id)) { toast.error('Resolve the unsaved draft before restoring history'); return }
const latest = await fetch(`/api/pages/${page.id}`, { cache: 'no-store' })
if (!latest.ok) { toast.error('Could not reload the page'); return }
const current: PageDetail = await latest.json()
const res = await fetch(`/api/pages/${page.id}/revisions/${rev.id}/restore`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: current.version }),
})
if (res.status === 409) {
toast.error('The page changed elsewhere. Reload it before restoring history.')
await loadPage(page.id)
return
}
if (!res.ok) { toast.error((await res.json().catch(() => null))?.error ?? 'Could not restore this version'); return }
const restored: PageDetail = await res.json()
autosave.discard(page.id)
autosave.setVersion(page.id, restored.version)
setPage(restored)
setTitle(restored.title)
setContent(restored.content)
setMode('preview')
notifyActivePage(restored.title)
setHistoryOpen(false)
toast.success(`Restored version ${rev.version} as version ${restored.version}`)
await loadPages(); notify()
} catch { toast.error('Could not restore this version') }
finally { setHistoryBusy(null) }
}
// Show the block menu only while the caret sits on a bare "/token" line.
const updateSlash = (text: string, caret: number) => {
const lineStart = text.lastIndexOf('\n', caret - 1) + 1
@@ -504,7 +565,7 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
}
const remove = async () => {
if (!page || !confirm(`Delete page "${page.title}"? Bookmarks are kept.`)) return
if (!page || !confirm(`Move page "${page.title}" to the trash? Bookmarks are kept and it can be restored.`)) return
if (!await autosave.flush(page.id)) return
const res = await fetch(`/api/pages/${page.id}`, { method: 'DELETE' })
if (!res.ok) { toast.error('Could not delete page'); return }
@@ -694,7 +755,8 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
<Button variant="ghost" size="icon-sm" title="Move tab left" onClick={() => move(-1)}><ChevronLeft className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Move tab right" onClick={() => move(1)}><ChevronRight className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Download .md" onClick={download}><Download className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Delete page" onClick={remove}><Trash2 className="h-4 w-4 text-destructive" /></Button>
<Button variant="ghost" size="icon-sm" title="Version history" onClick={() => void openHistory()}><History className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Move to trash" onClick={remove}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</div>
<div className="mb-4 flex flex-wrap items-center gap-2 text-sm">
@@ -934,6 +996,49 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
)}
</div>
)}
<Dialog open={historyOpen} onOpenChange={(open) => { setHistoryOpen(open); if (!open) setRevision(null) }}>
<DialogContent className="max-w-3xl">
<DialogHeader><DialogTitle>Version history</DialogTitle></DialogHeader>
<p className="text-xs text-muted-foreground">
Every saved content version is kept. Restoring writes a new version, so nothing in history is ever rewritten.
</p>
<div className="grid gap-3 md:grid-cols-[15rem_1fr]">
<div className="max-h-[24rem] space-y-1 overflow-y-auto rounded-md border p-1">
{revisions.map((rev) => (
<div key={rev.id} className={cn('rounded-md px-2 py-1.5', revision?.id === rev.id ? 'bg-accent' : 'hover:bg-muted')}>
<button type="button" className="flex w-full items-center justify-between gap-2 text-left" onClick={() => void viewRevision(rev.id)}>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">Version {rev.version}</span>
<span className="block text-xs text-muted-foreground">{new Date(rev.createdAt).toLocaleString()}</span>
</span>
{revisions[0]?.id === rev.id && <span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">Current</span>}
</button>
<button
type="button"
disabled={historyBusy === rev.id}
onClick={() => void restoreRevision(rev)}
className="mt-1 flex items-center gap-1 text-xs text-primary hover:underline disabled:opacity-50"
>
<RotateCcw className="h-3 w-3" /> Restore
</button>
</div>
))}
{revisions.length === 0 && <p className="px-2 py-6 text-center text-xs text-muted-foreground">No saved versions yet.</p>}
</div>
<div className="max-h-[24rem] overflow-y-auto rounded-md border bg-muted/20 p-3">
{revision ? (
<>
<p className="mb-2 text-sm font-medium">{revision.title}</p>
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-muted-foreground">{revision.content || '(empty page)'}</pre>
</>
) : (
<p className="py-10 text-center text-sm text-muted-foreground">Select a version to preview it.</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}