import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; import { useClient } from 'hooks/useClient'; import { Input } from '@/components/ui/input'; import { StickyNote, Image as ImageIcon, Link2 } 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, and would still only see the fields we * happened to fetch. */ type Asset = { id: string; assetType?: string; fileName?: string | null }; type Bookmark = { id: string; title?: string | null; createdAt?: string; tags?: { id: string; name: string }[]; assets?: Asset[]; content?: | { type: 'link'; url: string; title?: string | null; description?: string | null; imageUrl?: string | null; favicon?: string | null; publisher?: string | null; crawlStatus?: string | null; } | { type: 'text'; text?: string } | { type: 'asset'; assetType?: string; fileName?: string | null }; }; 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 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 faking it by filtering here would be a different feature wearing the same label. */ 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: long enough that a five-letter word is one query instead of five, short enough that the // grid moves while you type. useEffect(() => { const timer = setTimeout(() => void load(list, query), 250); return () => clearTimeout(timer); }, [list, query, load]); const waiting = rows?.filter((b) => b.content?.type === 'link' && b.content.crawlStatus === 'pending').length ?? 0; return (
setQuery(event.target.value)} placeholder="Search titles, notes, tags and saved text…" className="h-8 text-xs" />
{query.trim() && list !== ALL && Searching everything, not just this list.} {/* Said, rather than left as a grid of blank tiles. A bookmark has no picture until Karakeep has crawled it, and on a fresh import that is most of them for a while. Without this the panel looks broken at exactly the moment it is working. */} {waiting > 0 && {waiting} still being fetched — pictures appear as they finish.}
{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.'} ) : (
{/* `auto-fill` with a minimum, not a fixed column count: the panel is one pane of a layout the owner can drag, so the number of columns has to follow the width it is actually given rather than a breakpoint guessed from the window. */}
{rows?.map((bookmark) => ( ))}
)}
); }; const Card = ({ bookmark }: { bookmark: Bookmark }) => { const content = bookmark.content; const link = content?.type === 'link' ? content : null; const url = link?.url ?? null; const title = bookmark.title || link?.title || (content?.type === 'asset' ? content.fileName : null) || (content?.type === 'text' ? content.text?.slice(0, 120) : null) || url || 'Untitled'; const host = (() => { if (!url) return null; try { return new URL(url).hostname.replace(/^www\./, ''); } catch { // A saved address that does not parse is still a bookmark; it just has no domain to show. return null; } })(); const Body = ( <>

{title}

{host && (

{link?.favicon && ( // Decorative: the domain beside it already says what this is. )} {host}

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

{bookmark.tags.slice(0, 3).map((tag) => ( {tag.name} ))}

)}
); const shell = 'flex flex-col overflow-hidden rounded-md border border-border bg-card transition-colors hover:border-foreground/25'; // A note or an asset has nowhere to go, so it is not dressed as something clickable. return url ? ( {Body} ) : (
{Body}
); }; /** * The card's picture, or an honest stand-in. * * ── Why the ORIGIN's image and not Karakeep's screenshot ── * * A crawled bookmark carries both: `content.imageUrl`, the page's own og:image, and a `screenshot` * asset that Karakeep took and stores itself. The screenshot is the better picture — it is archived, it * cannot rot, and it never leaves the house. * * It is also unreachable from an ``. Officer authenticates with a bearer token in a header, and a * browser cannot put a header on an image request, so `/api/karakeep/api/assets/` answers 401 from * markup. Fetching each one as a blob through the client would work and costs a request, a decode and an * object URL per card, which is a lot of machinery for a thumbnail. * * So this uses the origin's image, with `referrerPolicy="no-referrer"` so the sites being fetched from * learn nothing about where the request came from, and `loading="lazy"` so a long grid does not fetch a * hundred of them at once. The honest cost: viewing your bookmarks makes requests to the sites they * point at. Proxying the archived asset instead is the fix if that matters — see the note above for what * it would take. */ const Picture = ({ bookmark, title }: { bookmark: Bookmark; title: string }) => { const [broken, setBroken] = useState(false); const content = bookmark.content; const image = content?.type === 'link' ? content.imageUrl : null; const pending = content?.type === 'link' && content.crawlStatus === 'pending'; if (image && !broken) { return (
setBroken(true)} className="size-full object-cover" />
); } /* The stand-in, which is deliberately not a grey rectangle. Three states land here and they are not the same: not crawled yet, crawled with no picture to find, and a picture that failed to load. The first is temporary and the others are permanent, so the first says so — otherwise a fresh import reads as a broken panel for as long as the crawler takes. */ const Icon = content?.type === 'text' ? StickyNote : content?.type === 'asset' ? ImageIcon : Link2; return (
{pending ? ( fetching… ) : ( {title} )}
); }; const Centred = ({ children }: { children: React.ReactNode }) => (
{children}
);