2 Commits
Author SHA1 Message Date
pastilhas 2b3b986c0e settings belong at the bottom of the sidebar, not behind a gear in the header
The owner asked for this after failing to find it, which is the whole argument: a
gear in a header is where a settings control is easy to write and not where
anybody looks for one. The bottom of a sidebar is its conventional home, and a
labelled row rather than an icon means it says what it is.

The form opens ABOVE the button so the control does not move when you use it. A
disclosure that walks away from the pointer as it expands is the small kind of
wrong that makes a thing feel unfinished.

`aria-expanded` on the button, because it is a disclosure and a screen reader has
no other way to know the state of one.

The error path still force-opens it - "no account is mapped to you" is the state
every user starts in, and the fix is in that form.
2026-09-05 21:49:09 +01:00
pastilhas d882e349a9 the grid never noticed the crawler finishing
It loaded once. A bookmark is not finished when it is saved - Karakeep crawls it
afterwards, and the picture, description and favicon arrive minutes later - so
the panel sat on "fetching…" while the work quietly completed behind it.

Worse, the page most likely to be full of unfinished ones is the FIRST page.
Newest first, and the crawler works through the backlog, so a fresh import shows
exactly the page with the least on it. Measured while diagnosing: 79 of 97
crawled, and all 16 pending sitting on page one - which reads as a stalled
crawler and is the opposite.

Polls every five seconds while anything is pending, through the same code path as
a first load so a refresh cannot drift from it. Self-limiting and needs no
switch: it re-arms only while something is actually pending, so a settled library
makes no requests at all.
2026-09-05 21:45:07 +01:00
3 changed files with 63 additions and 13 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
*/ */
export const manifest: PluginManifest = { export const manifest: PluginManifest = {
publisher: 'officerdev', publisher: 'officerdev',
version: '2026.09.05.02', version: '2026.09.05.04',
platform: '>=1.0.0', platform: '>=1.0.0',
label: 'Karakeep', label: 'Karakeep',
+37 -2
View File
@@ -79,15 +79,46 @@ export const BookmarksPanel = () => {
} }
}, []); }, []);
/*
`tick` exists to re-ask the same question, which is different from asking a new one.
Everything else in this effect is a NEW question — a different list, different words. This one is
the poll below saying "ask that again", and it has to go through the same path so a refresh cannot
drift from a first load.
*/
const [tick, setTick] = useState(0);
// Debounced: long enough that a five-letter word is one query instead of five, short enough that the // Debounced: long enough that a five-letter word is one query instead of five, short enough that the
// grid moves while you type. // grid moves while you type.
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => void load(list, query), 250); const timer = setTimeout(() => void load(list, query), 250);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [list, query, load]); }, [list, query, load, tick]);
const waiting = rows?.filter((b) => b.content?.type === 'link' && b.content.crawlStatus === 'pending').length ?? 0; const waiting = rows?.filter((b) => b.content?.type === 'link' && b.content.crawlStatus === 'pending').length ?? 0;
/*
── While anything is still being fetched, ask again ──
Without this the panel loads once and never moves. That is wrong here in a way it would not be for
most lists, because a bookmark is not finished when it is saved: Karakeep crawls it afterwards, and
the picture, the description and the favicon all arrive minutes later.
Worse, the page most likely to be full of unfinished ones is the FIRST page — newest first, and the
crawler works through the backlog — so a fresh import shows a grid of "fetching…" that never
resolves until somebody reloads by hand. Measured on this instance: 79 of 97 crawled, and all 16
pending sitting on page one.
The poll is self-limiting and needs no switch: it only re-arms while something is actually pending,
so a settled library makes no requests at all. Five seconds because a crawl takes a few and nobody
is watching the clock.
*/
useEffect(() => {
if (waiting === 0) return;
const timer = setTimeout(() => setTick((n) => n + 1), 5000);
return () => clearTimeout(timer);
}, [waiting, tick]);
return ( return (
<div className="flex size-full flex-col"> <div className="flex size-full flex-col">
<header className="border-b p-2"> <header className="border-b p-2">
@@ -105,7 +136,11 @@ export const BookmarksPanel = () => {
A bookmark has no picture until Karakeep has crawled it, and on a fresh import that is most 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. of them for a while. Without this the panel looks broken at exactly the moment it is working.
*/} */}
{waiting > 0 && <span>{waiting} still being fetched — pictures appear as they finish.</span>} {waiting > 0 && (
<span>
{waiting} still being fetched — this updates itself as they finish.
</span>
)}
</div> </div>
</header> </header>
+25 -10
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Folder, Settings2, Layers } from 'lucide-react'; import { Folder, Settings2, Layers, ChevronUp, ChevronDown } from 'lucide-react';
import { SettingsForm } from './SettingsForm'; import { SettingsForm } from './SettingsForm';
/** /**
@@ -61,16 +61,8 @@ export const ListsPanel = () => {
return ( return (
<div className="flex size-full flex-col"> <div className="flex size-full flex-col">
<header className="flex items-center justify-between border-b px-2 py-1.5"> <header className="border-b px-3 py-1.5">
<span className="text-xs font-medium">Lists</span> <span className="text-xs font-medium">Lists</span>
<button
type="button"
onClick={() => setShowSettings((was) => !was)}
className="p-1 text-muted-foreground hover:text-foreground"
aria-label="Settings"
>
<Settings2 className="size-3.5" />
</button>
</header> </header>
{error && <p className="px-3 py-2 text-[11px] text-destructive">{error}</p>} {error && <p className="px-3 py-2 text-[11px] text-destructive">{error}</p>}
@@ -105,7 +97,30 @@ export const ListsPanel = () => {
))} ))}
</ul> </ul>
{/*
Settings, pinned to the bottom.
It was a gear in the header, which is where a settings control is easy to WRITE and not where
anybody looks for one: the owner asked for this after failing to find it. The bottom of a
sidebar is the conventional home for the thing you configure once, and being a labelled row
rather than an icon means it says what it is.
The form opens ABOVE the button rather than below it, so the control does not move when you use
it. A disclosure that walks away from the pointer as it expands is the small kind of wrong that
makes something feel unfinished.
*/}
{showSettings && <SettingsForm />} {showSettings && <SettingsForm />}
<button
type="button"
onClick={() => setShowSettings((was) => !was)}
aria-expanded={showSettings}
className="flex w-full shrink-0 items-center gap-2 border-t px-3 py-2 text-left text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground"
>
<Settings2 className="size-3.5 shrink-0" />
<span className="flex-1">Settings</span>
{showSettings ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />}
</button>
</div> </div>
); );
}; };