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:
@@ -176,7 +176,7 @@ export function BookmarksClient({ initialSearch = null, initialOpen = null }: {
|
||||
<div className="flex gap-1" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<a href={b.url} target="_blank" rel="noopener noreferrer"><ExternalLink className="h-4 w-4 text-muted-foreground hover:text-primary" /></a>
|
||||
{tab === 'mine' && (
|
||||
<button onClick={async () => { if (!confirm('Delete?')) return; try { const res = await fetch(`/api/bookmarks/${b.id}`, { method: 'DELETE' }); if (res.ok) { toast.success('Deleted'); load() } else toast.error('Could not delete bookmark') } catch { toast.error('Could not delete bookmark') } }}><Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" /></button>
|
||||
<button onClick={async () => { if (!confirm('Move this bookmark to the trash? It can be restored.')) return; try { const res = await fetch(`/api/bookmarks/${b.id}`, { method: 'DELETE' }); if (res.ok) { toast.success('Moved to trash'); load() } else toast.error('Could not move bookmark to trash') } catch { toast.error('Could not move bookmark to trash') } }}><Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" /></button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { CalendarDays, Columns3, Filter, GripVertical, Kanban, Layers, List, Plus, Table2, Trash2, X } from 'lucide-react'
|
||||
import { CalendarDays, Columns3, Filter, GripVertical, Kanban, Layers, List, Plus, Share2, Table2, Trash2, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Bookmark } from '@/components/app-shell'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -40,9 +40,13 @@ type CollectionItem = {
|
||||
propertyValues: Record<string, unknown>; position: number; version: number
|
||||
addedAt: string; updatedAt: string; bookmark: Bookmark
|
||||
}
|
||||
type CollectionShare = { userId: string; name: string | null; email: string | null; createdAt?: string }
|
||||
type CollectionSummary = {
|
||||
id: string; name: string; description: string | null; icon: string | null
|
||||
version: number; createdAt: string; updatedAt: string
|
||||
shared: boolean
|
||||
owner?: { id: string; name: string | null; email: string | null; image: string | null }
|
||||
sharedWith?: CollectionShare[]
|
||||
_count: { items: number; properties: number; views: number }
|
||||
}
|
||||
type CollectionDetail = CollectionSummary & { properties: CollectionProperty[]; views: CollectionView[] }
|
||||
@@ -95,10 +99,27 @@ function BookmarkCell({ bookmark }: { bookmark: Bookmark }) {
|
||||
|
||||
const asText = (v: unknown) => (typeof v === 'string' || typeof v === 'number' ? String(v) : '')
|
||||
|
||||
function plainValue(prop: CollectionProperty, value: unknown): string {
|
||||
if (prop.type === 'CHECKBOX') return value === true ? 'Yes' : 'No'
|
||||
if (prop.type === 'SELECT') return prop.config.options?.find((o) => o.id === value)?.label ?? '—'
|
||||
if (prop.type === 'MULTI_SELECT') {
|
||||
if (!Array.isArray(value)) return '—'
|
||||
return value.map((id) => prop.config.options?.find((o) => o.id === id)?.label).filter(Boolean).join(', ') || '—'
|
||||
}
|
||||
if (prop.type === 'DATE' && typeof value === 'string' && value) {
|
||||
if (prop.config.mode === 'date') return value.slice(0, 10)
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
|
||||
}
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// Per-type inline value editor. Saves immediately on commit gestures
|
||||
// (change/blur/Enter); failures toast and the prop value re-renders the
|
||||
// server state, so a rejected edit never sticks locally.
|
||||
function ValueEditor({ prop, value, onSave }: { prop: CollectionProperty; value: unknown; onSave: (v: unknown) => Promise<void> }) {
|
||||
// server state, so a rejected edit never sticks locally. Shared collections
|
||||
// are read-only: the same cell renders plain text.
|
||||
function ValueEditor({ prop, value, onSave, readOnly = false }: { prop: CollectionProperty; value: unknown; onSave: (v: unknown) => Promise<void>; readOnly?: boolean }) {
|
||||
const [text, setText] = useState(() => asText(value))
|
||||
const [seen, setSeen] = useState(value)
|
||||
// Adjusting state when a prop changes: the React-documented render-time
|
||||
@@ -108,6 +129,7 @@ function ValueEditor({ prop, value, onSave }: { prop: CollectionProperty; value:
|
||||
setText(asText(value))
|
||||
}
|
||||
const [open, setOpen] = useState(false)
|
||||
if (readOnly) return <span className="text-sm text-muted-foreground">{plainValue(prop, value)}</span>
|
||||
const commitText = async () => {
|
||||
const next = text.trim()
|
||||
const current = asText(value)
|
||||
@@ -220,6 +242,9 @@ export function CollectionsClient() {
|
||||
const [fProp, setFProp] = useState('')
|
||||
const [fOp, setFOp] = useState('eq')
|
||||
const [fValue, setFValue] = useState('')
|
||||
const [shareOpen, setShareOpen] = useState(false)
|
||||
const [shareQuery, setShareQuery] = useState('')
|
||||
const [shareResults, setShareResults] = useState<{ id: string; name: string | null; email: string }[]>([])
|
||||
|
||||
const loadCollections = useCallback(async () => {
|
||||
try {
|
||||
@@ -293,6 +318,41 @@ export function CollectionsClient() {
|
||||
|
||||
const activeView = detail?.views.find((v) => v.id === viewId) ?? null
|
||||
const groupProp = activeView?.groupByPropertyId ? detail?.properties.find((p) => p.id === activeView.groupByPropertyId) : null
|
||||
// Shared collections are explicitly read-only: hiding mutation controls is
|
||||
// the UI half of the server's owner-only mutation gate.
|
||||
const readOnly = detail?.shared === true
|
||||
|
||||
const shareCollection = async (userId: string) => {
|
||||
if (!detail) return
|
||||
const res = await fetch(`/api/collections/${detail.id}/share`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userIds: [userId] }),
|
||||
})
|
||||
if (!res.ok) { toast.error('Could not share collection'); return }
|
||||
toast.success('Collection shared')
|
||||
setShareQuery(''); setShareResults([])
|
||||
loadDetail(detail.id); loadCollections()
|
||||
}
|
||||
const unshareCollection = async (userId: string) => {
|
||||
if (!detail) return
|
||||
const res = await fetch(`/api/collections/${detail.id}/share/${userId}`, { method: 'DELETE' })
|
||||
if (!res.ok) { toast.error('Could not revoke collection access'); return }
|
||||
toast.success('Access revoked')
|
||||
loadDetail(detail.id); loadCollections()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!shareOpen) return
|
||||
const q = shareQuery.trim()
|
||||
if (q.length < 2) return
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`)
|
||||
if (res.ok) setShareResults(await res.json())
|
||||
} catch { /* transient */ }
|
||||
}, 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [shareOpen, shareQuery])
|
||||
|
||||
const createCollection = async () => {
|
||||
const name = newName.trim()
|
||||
@@ -307,7 +367,7 @@ export function CollectionsClient() {
|
||||
}
|
||||
|
||||
const saveName = async () => {
|
||||
if (!detail) return
|
||||
if (!detail || detail.shared) return
|
||||
const name = nameDraft.trim()
|
||||
if (!name || name === detail.name) { setNameDraft(detail.name); return }
|
||||
const res = await patch(`/api/collections/${detail.id}`, { version: detail.version, name })
|
||||
@@ -318,7 +378,7 @@ export function CollectionsClient() {
|
||||
}
|
||||
|
||||
const deleteCollection = async () => {
|
||||
if (!detail || !confirm(`Delete collection "${detail.name}"? Bookmarks are kept.`)) return
|
||||
if (!detail || detail.shared || !confirm(`Move collection "${detail.name}" to the trash? Bookmarks are kept and it can be restored.`)) return
|
||||
const res = await fetch(`/api/collections/${detail.id}`, { method: 'DELETE' })
|
||||
if (await mutate(res, loadCollections, 'Collection deleted')) {
|
||||
setActiveId(null)
|
||||
@@ -494,9 +554,9 @@ export function CollectionsClient() {
|
||||
<TableHead key={p.id} className="min-w-[11rem]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{p.name}
|
||||
<button type="button" aria-label={`Delete property ${p.name}`} className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100" onClick={() => deleteProperty(p)}>
|
||||
{!readOnly && <button type="button" aria-label={`Delete property ${p.name}`} className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100" onClick={() => deleteProperty(p)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</button>}
|
||||
</span>
|
||||
</TableHead>
|
||||
))}
|
||||
@@ -508,12 +568,12 @@ export function CollectionsClient() {
|
||||
<TableRow key={item.id} className="group">
|
||||
<TableCell><BookmarkCell bookmark={item.bookmark} /></TableCell>
|
||||
{(detail?.properties ?? []).map((p) => (
|
||||
<TableCell key={p.id}><ValueEditor prop={p} value={item.propertyValues?.[p.id]} onSave={(v) => saveValue(item, p.id, v)} /></TableCell>
|
||||
<TableCell key={p.id}><ValueEditor prop={p} value={item.propertyValues?.[p.id]} onSave={(v) => saveValue(item, p.id, v)} readOnly={readOnly} /></TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
<button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
|
||||
{!readOnly && <button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</button>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -535,7 +595,7 @@ export function CollectionsClient() {
|
||||
<div className="rounded-lg border border-dashed border-border p-8 text-center">
|
||||
<Kanban className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Board views group by a select property.</p>
|
||||
<div className="mx-auto mt-3 w-56">
|
||||
{!readOnly && <div className="mx-auto mt-3 w-56">
|
||||
<Select
|
||||
value={NONE}
|
||||
onValueChange={(id) => { if (id !== NONE) patchView({ groupByPropertyId: id }) }}
|
||||
@@ -547,7 +607,7 @@ export function CollectionsClient() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -602,7 +662,7 @@ export function CollectionsClient() {
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-1">
|
||||
<span
|
||||
{!readOnly && <span
|
||||
data-drag-handle
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
@@ -619,25 +679,29 @@ export function CollectionsClient() {
|
||||
aria-label={`Drag ${item.bookmark.title ?? 'bookmark'}`}
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</span>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<BookmarkCell bookmark={item.bookmark} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center justify-between gap-1">
|
||||
<Select
|
||||
value={typeof item.propertyValues?.[groupProp.id] === 'string' ? (item.propertyValues[groupProp.id] as string) : NONE}
|
||||
onValueChange={(id) => { void moveItemToGroup(item, id === NONE ? null : id) }}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-28 text-xs" aria-label={`Move ${item.bookmark.title ?? 'card'}`}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>—</SelectItem>
|
||||
{options.map((o) => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
|
||||
{readOnly ? (
|
||||
<span className="text-xs text-muted-foreground">{plainValue(groupProp, item.propertyValues?.[groupProp.id])}</span>
|
||||
) : (
|
||||
<Select
|
||||
value={typeof item.propertyValues?.[groupProp.id] === 'string' ? (item.propertyValues[groupProp.id] as string) : NONE}
|
||||
onValueChange={(id) => { void moveItemToGroup(item, id === NONE ? null : id) }}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-28 text-xs" aria-label={`Move ${item.bookmark.title ?? 'card'}`}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>—</SelectItem>
|
||||
{options.map((o) => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{!readOnly && <button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -674,7 +738,10 @@ export function CollectionsClient() {
|
||||
className={cn('flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors', c.id === activeId ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground')}
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{c.name}</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{c.name}
|
||||
{c.shared && <span className="ml-1 text-[10px] uppercase tracking-wide text-muted-foreground">shared</span>}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{c._count.items}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -699,13 +766,25 @@ export function CollectionsClient() {
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Input value={nameDraft} onChange={(e) => setNameDraft(e.target.value)} onBlur={saveName} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} aria-label="Collection name" className="w-72 bg-transparent font-display text-2xl font-bold tracking-tight" />
|
||||
{readOnly ? (
|
||||
<span className="w-72 truncate font-display text-2xl font-bold tracking-tight">{detail.name}</span>
|
||||
) : (
|
||||
<Input value={nameDraft} onChange={(e) => setNameDraft(e.target.value)} onBlur={saveName} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} aria-label="Collection name" className="w-72 bg-transparent font-display text-2xl font-bold tracking-tight" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">{detail._count?.items ?? 0} item{(detail._count?.items ?? 0) === 1 ? '' : 's'}</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}><Plus className="mr-1 h-4 w-4" />Add bookmarks</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setPropOpen(true)}><Plus className="mr-1 h-4 w-4" />Property</Button>
|
||||
<Button size="sm" variant="ghost" onClick={deleteCollection} aria-label="Delete collection" title="Delete collection"><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
||||
</div>
|
||||
{readOnly ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Share2 className="h-3 w-3" />
|
||||
Shared by {detail.owner?.name || detail.owner?.email || 'another user'} · read-only
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}><Plus className="mr-1 h-4 w-4" />Add bookmarks</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setPropOpen(true)}><Plus className="mr-1 h-4 w-4" />Property</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { setShareOpen(true); setShareResults([]); setShareQuery('') }}><Share2 className="mr-1 h-4 w-4" />Share</Button>
|
||||
<Button size="sm" variant="ghost" onClick={deleteCollection} aria-label="Move collection to trash" title="Move collection to trash"><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View tabs */}
|
||||
@@ -722,8 +801,8 @@ export function CollectionsClient() {
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => setViewOpen(true)} aria-label="Add view"><Plus className="h-4 w-4" /></Button>
|
||||
{activeView && (
|
||||
{!readOnly && <Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => setViewOpen(true)} aria-label="Add view"><Plus className="h-4 w-4" /></Button>}
|
||||
{activeView && !readOnly && (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Popover open={filterOpen} onOpenChange={(o) => { setFilterOpen(o); if (o) { setFProp(detail.properties[0]?.id ?? ''); setFOp(FILTER_OPS[detail.properties[0]?.type ?? 'TEXT']?.[0] ?? 'eq') } }}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -774,7 +853,7 @@ export function CollectionsClient() {
|
||||
{filterClauses.map((c, i) => (
|
||||
<Badge key={i} variant="secondary" className="gap-1">
|
||||
{clauseLabel(c)}
|
||||
<button type="button" aria-label="Remove filter" onClick={() => setClause(filterClauses.filter((_, j) => j !== i))}><X className="h-3 w-3" /></button>
|
||||
{!readOnly && <button type="button" aria-label="Remove filter" onClick={() => setClause(filterClauses.filter((_, j) => j !== i))}><X className="h-3 w-3" /></button>}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -878,6 +957,48 @@ export function CollectionsClient() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Share dialog */}
|
||||
<Dialog open={shareOpen} onOpenChange={(o) => { setShareOpen(o); if (!o) { setShareQuery(''); setShareResults([]) } }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader><DialogTitle>Share collection</DialogTitle></DialogHeader>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recipients get read-only access to this collection and the links you own inside it. Links you can
|
||||
only see through someone else's share are omitted, so sharing never passes a third-party grant on.
|
||||
They receive the same trimmed link view as other shared bookmarks; contacts, alerts and mailbox
|
||||
settings stay private. Revoking access takes effect immediately.
|
||||
</p>
|
||||
{detail && (
|
||||
<div className="space-y-1">
|
||||
{(detail.sharedWith ?? []).map((s) => (
|
||||
<div key={s.userId} className="flex items-center gap-2 rounded-md border px-2 py-1.5 text-sm">
|
||||
<span className="min-w-0 flex-1 truncate">{s.name || s.email || s.userId}</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => void unshareCollection(s.userId)}>Revoke</Button>
|
||||
</div>
|
||||
))}
|
||||
{(detail.sharedWith?.length ?? 0) === 0 && <p className="py-2 text-xs text-muted-foreground">Not shared with anyone yet.</p>}
|
||||
</div>
|
||||
)}
|
||||
<Input autoFocus value={shareQuery} onChange={(e) => setShareQuery(e.target.value)} placeholder="Search people by name or email" aria-label="Search people to share with" />
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto">
|
||||
{shareResults.map((u) => {
|
||||
const already = detail?.sharedWith?.some((s) => s.userId === u.id)
|
||||
return (
|
||||
<div key={u.id} className="flex items-center gap-2 rounded-md border px-2 py-1.5 text-sm">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate">{u.name || u.email}</p>
|
||||
{u.name && <p className="truncate text-xs text-muted-foreground">{u.email}</p>}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" disabled={already} onClick={() => void shareCollection(u.id)}>
|
||||
{already ? 'Shared' : 'Share'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{shareQuery.trim().length >= 2 && shareResults.length === 0 && <p className="py-4 text-center text-xs text-muted-foreground">No matching people</p>}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { TrashClient } from './client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function TrashPage() {
|
||||
return <TrashClient />
|
||||
}
|
||||
Reference in New Issue
Block a user