Files
karakeep/web/ListsPanel.tsx
T
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

127 lines
4.7 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient';
import { Folder, Settings2, Layers, ChevronUp, ChevronDown } from 'lucide-react';
import { SettingsForm } from './SettingsForm';
/**
* The lists, and a way back to the settings.
*
* Selection is `?list=<id>` in the URL rather than a channel, so which list you are looking
* at survives a reload and can be linked to — `docs/navigation-audit.md`. The links panel reads the same
* parameter rather than being handed the id.
*
* The settings live behind the gear rather than on a screen of their own. They were a screen of their
* own for an afternoon and it was the wrong shape: nothing else was built, so opening the plugin put you
* on a configuration form with no way forward, which reads as broken rather than unfinished.
*/
/** Karakeep list ids are strings, and lists nest through `parentId`. */
type List = { id: string; name: string; icon?: string | null; parentId?: string | null };
/** `?list=` for "everything", which is a real choice and not the absence of one. */
export const ALL = 'all';
export const ListsPanel = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [rows, setRows] = useState<List[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [params, setParams] = useSearchParams();
const selected = params.get('list') ?? ALL;
const load = useCallback(async () => {
try {
const body = await clientRef.current.get<{ lists: List[] }>('/karakeep/api/v1/lists');
setRows(body.lists ?? []);
setError(null);
} catch (failure) {
// The message matters here. "No account is mapped to you" is something the owner can fix and is
// the state every user starts in, so it must not read as an outage.
setError(failure instanceof Error ? failure.message : 'Could not read your lists.');
setRows(null);
setShowSettings(true);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const select = (id: string) =>
setParams(
(p) => {
p.set('list', id);
return p;
},
{ replace: true },
);
return (
<div className="flex size-full flex-col">
<header className="border-b px-3 py-1.5">
<span className="text-xs font-medium">Lists</span>
</header>
{error && <p className="px-3 py-2 text-[11px] text-destructive">{error}</p>}
<ul className="flex-1 overflow-auto">
<li>
<button
type="button"
onClick={() => select(ALL)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-muted/50 ${
selected === ALL ? 'bg-muted' : ''
}`}
>
<Layers className="size-3.5 shrink-0 text-muted-foreground" />
Everything
</button>
</li>
{rows?.map((row) => (
<li key={row.id}>
<button
type="button"
onClick={() => select(row.id)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-muted/50 ${
selected === row.id ? 'bg-muted' : ''
}`}
>
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{row.name}</span>
</button>
</li>
))}
</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 />}
<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>
);
};