initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet'
import { ExternalLink, Search, Trash2, ChevronUp, ChevronDown, Inbox, Share2 } from 'lucide-react'
import { toast } from 'sonner'
import type { Bookmark } from '@/components/app-shell'
import { fetchBookmarks } from '@/lib/bookmarks'
export function BookmarksClient({ initialSearch = null, initialOpen = null }: { initialSearch?: string | null; initialOpen?: string | null }) {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [sharedBookmarks, setSharedBookmarks] = useState<Bookmark[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<Bookmark | null>(null)
// Review CR-04: the sheet must receive the CURRENT row after mutations —
// its conflict banner ("Load latest version") and successful saves need
// the fresh version, or the next save would send a stale base.
const selectedRef = useRef<Bookmark | null>(null)
selectedRef.current = selected
const refreshSelected = useCallback(async () => {
const id = selectedRef.current?.id
if (!id) return
try {
const res = await fetch(`/api/bookmarks/${encodeURIComponent(id)}`, { cache: 'no-store' })
if (res.ok) setSelected(await res.json())
} catch { /* the list load surfaces errors */ }
}, [])
const [tab, setTab] = useState<'mine' | 'shared'>('mine')
const [sortField, setSortField] = useState<'title' | 'updatedAt' | 'kanbanStatus'>('updatedAt')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
// Debounced, abortable, sequence-guarded load: a slow response for an older
// query can never overwrite newer results (same discipline as pages client).
const loadSeq = useRef(0)
const abortRef = useRef<AbortController | null>(null)
const load = useCallback(() => {
abortRef.current?.abort()
const ac = new AbortController()
abortRef.current = ac
const seq = ++loadSeq.current
setLoading(true)
const params = new URLSearchParams()
if (search) params.set('search', search)
;(async () => {
try {
const mine = await fetchBookmarks(params, ac.signal)
if (seq !== loadSeq.current) return
setBookmarks(mine ?? [])
} catch {
if (seq === loadSeq.current && !ac.signal.aborted) toast.error('Could not load bookmarks')
} finally {
if (seq === loadSeq.current) setLoading(false)
}
})()
}, [search])
// The shared list is not search-filtered; load it once and on changes only.
const loadShared = useCallback(async () => {
try { setSharedBookmarks(await fetchBookmarks(new URLSearchParams({ shared: 'true' })) ?? []) }
catch { /* the mine-tab load surfaces errors */ }
}, [])
useEffect(() => {
const timer = setTimeout(load, search ? 250 : 0)
const reload = () => { load(); void loadShared() }
window.addEventListener('computer:bookmarks-changed', reload)
return () => { clearTimeout(timer); window.removeEventListener('computer:bookmarks-changed', reload); abortRef.current?.abort() }
}, [load, loadShared, search])
useEffect(() => { const timer = setTimeout(loadShared, 0); return () => clearTimeout(timer) }, [loadShared])
// Quick Find and the dashboard redirect land here with ?search=; the prop
// comes from the server component so query changes apply without a remount.
useEffect(() => {
const timer = setTimeout(() => setSearch(initialSearch || ''), 0)
return () => clearTimeout(timer)
}, [initialSearch])
// Quick Find deep-links straight to a bookmark's detail sheet via ?open=<id>.
useEffect(() => {
if (!initialOpen) return
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/bookmarks/${encodeURIComponent(initialOpen)}`)
if (res.ok) setSelected(await res.json())
} catch { /* stale or inaccessible id; the list still renders */ }
}, 0)
return () => clearTimeout(timer)
}, [initialOpen])
const displayBookmarks = tab === 'mine' ? bookmarks : sharedBookmarks
const sorted = [...(displayBookmarks ?? [])].sort((a: any, b: any) => {
const va = a?.[sortField] ?? ''
const vb = b?.[sortField] ?? ''
return sortDir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va))
})
const toggleSort = (field: typeof sortField) => {
if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSortField(field); setSortDir('asc') }
}
const sortIcon = (field: typeof sortField) => {
if (sortField !== field) return null
return sortDir === 'asc' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
}
return (
<div className="space-y-6">
<div>
<h1 className="font-display text-2xl font-bold tracking-tight">All Bookmarks</h1>
<p className="text-sm text-muted-foreground mt-1">Every saved link. Use the sidebar to filter by calendar, tags, board, or contacts.</p>
</div>
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Filter..." value={search} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)} className="pl-9" />
</div>
<div className="flex rounded-lg border overflow-hidden">
<button onClick={() => setTab('mine')} className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${tab === 'mine' ? 'bg-primary text-primary-foreground' : 'bg-card hover:bg-muted'}`}>
<Inbox className="h-3.5 w-3.5" /> Mine
</button>
<button onClick={() => setTab('shared')} className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${tab === 'shared' ? 'bg-primary text-primary-foreground' : 'bg-card hover:bg-muted'}`}>
<Share2 className="h-3.5 w-3.5" /> Shared
</button>
</div>
</div>
<div className="rounded-lg border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 text-left">
<th className="px-4 py-3 font-medium cursor-pointer" onClick={() => toggleSort('title')}>
<span className="flex items-center gap-1">Title {sortIcon('title')}</span>
</th>
<th className="px-4 py-3 font-medium">URL</th>
<th className="px-4 py-3 font-medium">Tags</th>
<th className="px-4 py-3 font-medium cursor-pointer" onClick={() => toggleSort('kanbanStatus')}>
<span className="flex items-center gap-1">Status {sortIcon('kanbanStatus')}</span>
</th>
<th className="px-4 py-3 font-medium cursor-pointer" onClick={() => toggleSort('updatedAt')}>
<span className="flex items-center gap-1">Updated {sortIcon('updatedAt')}</span>
</th>
<th className="px-4 py-3 font-medium w-20">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">Loading...</td></tr>
) : sorted.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">No bookmarks found</td></tr>
) : sorted.map((b: Bookmark) => (
<tr key={b.id} tabIndex={0} className="hover:bg-muted/30 cursor-pointer focus-visible:outline-2 focus-visible:outline-primary" onClick={() => setSelected(b)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelected(b) } }}>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{b.favicon && <img src={b.favicon} alt="" referrerPolicy="no-referrer" className="h-4 w-4 rounded" onError={(e: any) => { e.target.style.display = 'none' }} />}
<span className="truncate max-w-[200px]">{b.title ?? 'Untitled'}</span>
</div>
</td>
<td className="px-4 py-3">
<a href={b.url} target="_blank" rel="noopener noreferrer" onClick={(e: React.MouseEvent) => e.stopPropagation()} className="text-primary hover:underline truncate max-w-[200px] block">
{(() => { try { return new URL(b.url).hostname } catch { return b.url } })()}
</a>
</td>
<td className="px-4 py-3">
<div className="flex gap-1">
{(b.tags ?? []).slice(0, 2).map((bt: any) => (
<Badge key={bt?.tag?.id} variant="secondary" className="text-[10px] px-1.5 py-0" style={{ backgroundColor: `${bt?.tag?.color}20`, color: bt?.tag?.color }}>{bt?.tag?.name}</Badge>
))}
</div>
</td>
<td className="px-4 py-3"><Badge variant="outline" className="text-[10px]">{b.kanbanStatus?.replace('_', ' ')}</Badge></td>
<td className="px-4 py-3 text-xs text-muted-foreground">{new Date(b.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}</td>
<td className="px-4 py-3">
<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>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<BookmarkDetailSheet bookmark={selected} onClose={() => setSelected(null)} onUpdate={() => { load(); void refreshSelected() }} />
</div>
)
}