import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; import { useClient } from 'hooks/useClient'; import { Input } from '@/components/ui/input'; import { ExternalLink, FileText, StickyNote, Image as ImageIcon } from 'lucide-react'; import { ALL } from './ListsPanel'; /** * Your saved bookmarks, as the calling Officer user. * * Every request goes through `/api/karakeep/api/*` — the authenticated door, which finds the caller's * mapped account and puts THEIR key on the request. This panel holds no credential and could not send * one if it wanted to, which is the property the whole plugin exists for. * * ── Search is Karakeep's, not ours ── * * `/bookmarks/search?q=` searches titles, notes, tags and the archived text. Re-implementing it here * would mean pulling every bookmark to the browser to filter it, and would still only see the fields we * happened to fetch. */ /** * A bookmark is a discriminated union on `content.type` — a link, a note, or an asset. * * Officer reads the minimum: enough to draw a row and open the thing. Everything else stays Karakeep's, * which is what keeps an upstream field addition from being a change here. */ type Bookmark = { id: string; title?: string | null; createdAt?: string; tags?: { id: string; name: string }[]; content?: | { type: 'link'; url: string; title?: string | null; description?: string | null } | { type: 'text'; text?: string } | { type: 'asset'; assetType?: string; fileName?: string | null }; }; /** Paginated, and the cursor is ignored for now — the first page is a screenful. */ type Page = { bookmarks?: Bookmark[]; nextCursor?: string | null }; export const BookmarksPanel = () => { const client = useClient(); // `useClient()` rebuilds its verbs every render, so they must never sit in a dependency list. const clientRef = useRef(client); clientRef.current = client; const [params] = useSearchParams(); const list = params.get('list') ?? ALL; const [query, setQuery] = useState(''); const [rows, setRows] = useState(null); const [error, setError] = useState(null); const load = useCallback(async (listId: string, search: string) => { /* Three different endpoints, because Karakeep models them as three questions rather than one with filters: everything, one list's contents, and a search. Search wins when there is one — narrowing a search to a list is a fourth thing the API does not offer, and pretending otherwise here would mean filtering results in the browser and calling it the same feature. */ const path = search.trim() ? `/karakeep/api/v1/bookmarks/search?q=${encodeURIComponent(search.trim())}` : listId !== ALL ? `/karakeep/api/v1/lists/${encodeURIComponent(listId)}/bookmarks` : '/karakeep/api/v1/bookmarks'; try { const body = await clientRef.current.get(path); setRows(body.bookmarks ?? []); setError(null); } catch (failure) { setError(failure instanceof Error ? failure.message : 'Could not read your bookmarks.'); setRows(null); } }, []); // Debounced, because this types straight into a request: long enough that a five-letter word is one // query instead of five, short enough that the list moves while you type. useEffect(() => { const timer = setTimeout(() => void load(list, query), 250); return () => clearTimeout(timer); }, [list, query, load]); return (
setQuery(event.target.value)} placeholder="Search titles, notes, tags and saved text…" className="h-8 text-xs" /> {query.trim() && list !== ALL && ( // Said rather than silently ignored: the result set is not what the sidebar implies.

Searching everything, not just this list.

)}
{error && (

{error} {error.includes('no Karakeep account') && ' — the owner maps accounts in Settings.'}

)} {rows === null && !error ? ( Loading… ) : rows && rows.length === 0 ? ( {query.trim() ? `Nothing matches “${query.trim()}”.` : 'Nothing saved here yet.'} ) : (
    {rows?.map((bookmark) => (
  • ))}
)}
); }; const Row = ({ bookmark }: { bookmark: Bookmark }) => { const content = bookmark.content; const url = content?.type === 'link' ? content.url : null; const title = bookmark.title || (content?.type === 'link' ? content.title : null) || (content?.type === 'asset' ? content.fileName : null) || (content?.type === 'text' ? content.text?.slice(0, 80) : null) || url || 'Untitled'; const Icon = content?.type === 'text' ? StickyNote : content?.type === 'asset' ? ImageIcon : FileText; return (
{url ? ( {title} ) : ( // A note or an asset has nowhere to go, so it is not dressed as a link. {title} )} {url &&

{url}

} {bookmark.tags && bookmark.tags.length > 0 && (

{bookmark.tags.map((tag) => ( {tag.name} ))}

)}
); }; const Centred = ({ children }: { children: React.ReactNode }) => (
{children}
);