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.
127 lines
4.7 KiB
TypeScript
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>
|
|
);
|
|
};
|