'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([]) const [sharedBookmarks, setSharedBookmarks] = useState([]) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') const [selected, setSelected] = useState(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. Depend on // `selected` instead of writing a ref during render. const refreshSelected = useCallback(async () => { const id = selected?.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 */ } }, [selected]) 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(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=. 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' ? : } return (

All Bookmarks

Every saved link. Use the sidebar to filter by calendar, tags, board, or contacts.

) => setSearch(e.target.value)} className="pl-9" />
{loading ? ( ) : sorted.length === 0 ? ( ) : sorted.map((b: Bookmark) => ( setSelected(b)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelected(b) } }}> ))}
toggleSort('title')}> Title {sortIcon('title')} URL Tags toggleSort('kanbanStatus')}> Status {sortIcon('kanbanStatus')} toggleSort('updatedAt')}> Updated {sortIcon('updatedAt')} Actions
Loading...
No bookmarks found
{b.favicon && { e.target.style.display = 'none' }} />} {b.title ?? 'Untitled'}
e.stopPropagation()} className="text-primary hover:underline truncate max-w-[200px] block"> {(() => { try { return new URL(b.url).hostname } catch { return b.url } })()}
{(b.tags ?? []).slice(0, 2).map((bt: any) => ( {bt?.tag?.name} ))}
{b.kanbanStatus?.replace('_', ' ')} {new Date(b.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}
e.stopPropagation()}> {tab === 'mine' && ( )}
setSelected(null)} onUpdate={() => { load(); void refreshSelected() }} />
) }