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
+157 -36
View File
@@ -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&apos;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>
)
}