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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user