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
+120
View File
@@ -0,0 +1,120 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Download, FileText, Layers, Link2, RotateCcw, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { notifyPagesChanged } from '@/lib/pages'
type TrashEntry = { id: string; type: 'bookmark' | 'page' | 'collection'; originalId: string; title: string; deletedAt: string }
const TYPE_ICON = { bookmark: Link2, page: FileText, collection: Layers } as const
export function TrashClient() {
const [entries, setEntries] = useState<TrashEntry[]>([])
const [busy, setBusy] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const load = useCallback(async () => {
try {
const res = await fetch('/api/trash', { cache: 'no-store' })
if (!res.ok) { toast.error('Could not load the trash'); return }
setEntries(await res.json())
} catch { toast.error('Could not load the trash') }
}, [])
useEffect(() => {
const timer = setTimeout(() => { void load() }, 0)
return () => clearTimeout(timer)
}, [load])
const restore = async (entry: TrashEntry) => {
setBusy(entry.id)
try {
const res = await fetch(`/api/trash/${entry.id}/restore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
if (!res.ok) { toast.error((await res.json().catch(() => null))?.error ?? 'Could not restore'); return }
toast.success(`Restored “${entry.title}”`)
if (entry.type === 'page') notifyPagesChanged()
await load()
} catch { toast.error('Could not restore') }
finally { setBusy(null) }
}
const purge = async (entry: TrashEntry) => {
if (!confirm(`Delete “${entry.title}” permanently? This cannot be undone.`)) return
setBusy(entry.id)
try {
const res = await fetch(`/api/trash/${entry.id}`, { method: 'DELETE' })
if (!res.ok) { toast.error('Could not delete permanently'); return }
toast.success('Deleted permanently')
await load()
} catch { toast.error('Could not delete permanently') }
finally { setBusy(null) }
}
const exportData = async () => {
setExporting(true)
try {
const res = await fetch('/api/export')
if (!res.ok) { toast.error('Could not export your data'); return }
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `computer-export-${new Date().toISOString().slice(0, 10)}.json`
document.body.appendChild(a); a.click(); a.remove()
URL.revokeObjectURL(url)
toast.success('Export downloaded')
} catch { toast.error('Could not export your data') }
finally { setExporting(false) }
}
return (
<div className="mx-auto w-full max-w-3xl px-6 py-8">
<div className="mb-6 flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<h1 className="font-display text-2xl font-bold tracking-tight">Trash</h1>
<p className="mt-1 text-sm text-muted-foreground">
Deleted bookmarks, pages and collections stay recoverable here. Restoring brings back content and
owner-scoped links, but never a share or public link that was revoked in the meantime.
</p>
</div>
<Button variant="outline" onClick={() => void exportData()} disabled={exporting}>
<Download className="mr-1 h-4 w-4" />{exporting ? 'Exporting…' : 'Export all data'}
</Button>
</div>
{entries.length === 0 ? (
<div className="rounded-lg border border-dashed p-12 text-center">
<Trash2 className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">The trash is empty.</p>
</div>
) : (
<div className="space-y-2">
{entries.map((entry) => {
const Icon = TYPE_ICON[entry.type]
return (
<div key={entry.id} className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-3 py-2.5">
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{entry.title}</p>
<p className="text-xs text-muted-foreground">
{new Date(entry.deletedAt).toLocaleString()}
</p>
</div>
<Badge variant="secondary" className="capitalize">{entry.type}</Badge>
<Button size="sm" variant="outline" disabled={busy === entry.id} onClick={() => void restore(entry)}>
<RotateCcw className="mr-1 h-3.5 w-3.5" />Restore
</Button>
<Button size="sm" variant="ghost" disabled={busy === entry.id} onClick={() => void purge(entry)} aria-label={`Delete ${entry.title} permanently`}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
)
})}
</div>
)}
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { TrashClient } from './client'
export const dynamic = 'force-dynamic'
export default function TrashPage() {
return <TrashClient />
}