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.'} ) : ( - +
+ {/* + `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} + )}
); };