diff --git a/manifest.ts b/manifest.ts
index 94e4d86..e086837 100644
--- a/manifest.ts
+++ b/manifest.ts
@@ -40,7 +40,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
*/
export const manifest: PluginManifest = {
publisher: 'officerdev',
- version: '2026.09.05.01',
+ version: '2026.09.05.02',
platform: '>=1.0.0',
label: 'Karakeep',
diff --git a/web/BookmarksPanel.tsx b/web/BookmarksPanel.tsx
index eee0a8f..1aabf5b 100644
--- a/web/BookmarksPanel.tsx
+++ b/web/BookmarksPanel.tsx
@@ -2,7 +2,7 @@ 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 { StickyNote, Image as ImageIcon, Link2 } from 'lucide-react';
import { ALL } from './ListsPanel';
/**
@@ -15,28 +15,33 @@ import { ALL } from './ListsPanel';
* ── 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
+ * would mean pulling every bookmark to the browser to filter, 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 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 }
+ | {
+ 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 };
};
-/** Paginated, and the cursor is ignored for now — the first page is a screenful. */
type Page = { bookmarks?: Bookmark[]; nextCursor?: string | null };
export const BookmarksPanel = () => {
@@ -54,10 +59,10 @@ export const BookmarksPanel = () => {
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.
+ 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())}`
@@ -74,13 +79,15 @@ export const BookmarksPanel = () => {
}
}, []);
- // 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.
+ // 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 (
@@ -90,10 +97,16 @@ export const BookmarksPanel = () => {
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.
- )}
+
+ {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 && (
@@ -108,54 +121,66 @@ export const BookmarksPanel = () => {
) : rows && rows.length === 0 ? (
{query.trim() ? `Nothing matches “${query.trim()}”.` : 'Nothing saved here yet.'}
) : (
-
- {rows?.map((bookmark) => (
- -
-
-
- ))}
-
+
+ {/*
+ `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 Row = ({ bookmark }: { bookmark: Bookmark }) => {
+const Card = ({ bookmark }: { bookmark: Bookmark }) => {
const content = bookmark.content;
- const url = content?.type === 'link' ? content.url : null;
+ const link = content?.type === 'link' ? content : null;
+ const url = link?.url ?? null;
+
const title =
bookmark.title ||
- (content?.type === 'link' ? content.title : null) ||
+ link?.title ||
(content?.type === 'asset' ? content.fileName : null) ||
- (content?.type === 'text' ? content.text?.slice(0, 80) : null) ||
+ (content?.type === 'text' ? content.text?.slice(0, 120) : null) ||
url ||
'Untitled';
- const Icon = content?.type === 'text' ? StickyNote : content?.type === 'asset' ? ImageIcon : FileText;
+ 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;
+ }
+ })();
- return (
-
-
-
- {url ? (
-
- {title}
-
-
- ) : (
- // A note or an asset has nowhere to go, so it is not dressed as a link.
-
{title}
+ const Body = (
+ <>
+
+
+
+
{title}
+
+ {host && (
+
+ {link?.favicon && (
+ // Decorative: the domain beside it already says what this is.
+
+ )}
+ {host}
+
)}
- {url &&
{url}
}
{bookmark.tags && bookmark.tags.length > 0 && (
-
- {bookmark.tags.map((tag) => (
+
+ {bookmark.tags.slice(0, 3).map((tag) => (
{tag.name}
@@ -163,6 +188,79 @@ const Row = ({ bookmark }: { bookmark: Bookmark }) => {
)}
+ >
+ );
+
+ 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}
+ )}
);
};