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.
121 lines
5.1 KiB
TypeScript
121 lines
5.1 KiB
TypeScript
'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>
|
|
)
|
|
}
|