Add trust features: history, trash, export, and collection sharing
release gate / check (push) Failing after 6m36s
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user