The owner installed it, landed on the configuration form, and could not get off it. Not a bug in the form: there was nothing behind it. One panel was registered and it was the settings. That is the wrong shape for a plugin regardless of how much is finished. A screen whose only content is its own configuration reads as broken rather than unfinished, because the first thing anybody does after installing is look for the way forward. So: collections on the left, links on the right, and the settings behind the gear in the collections panel - reachable when something is wrong and out of the way when it is not. Selection is `?collection=` in the URL rather than a channel, so which collection you are looking at survives a reload and can be linked to. Search is Linkwarden's own `searchQueryString`, which searches names, descriptions, tags AND the archived page text - the thing a bookmark bar cannot do, and the reason this is worth more than a list of addresses. Doing it here would mean pulling every link to the browser and would still only see the fields we happened to fetch. Verified against the instance: 50 unfiltered, 8 for "arch", 0 for a string matching nothing. The panel holds no credential and could not send one. Every request goes through the authenticated door, which finds the caller's mapped account and puts their token on. `no Linkwarden account is mapped to you` is the state every user starts in, so it is said as an instruction rather than rendered as an outage.
114 lines
4.1 KiB
TypeScript
114 lines
4.1 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useSearchParams } from 'react-router';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Folder, Settings2, Layers } from 'lucide-react';
|
|
import { SettingsForm } from './SettingsForm';
|
|
|
|
/**
|
|
* The collections, and a way back to the settings.
|
|
*
|
|
* Selection is `?collection=<id>` in the URL rather than a channel, so which collection 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.
|
|
*/
|
|
type Collection = { id: number; name: string; _count?: { links?: number } };
|
|
|
|
/** `?collection=` for "everything", which is a real choice and not the absence of one. */
|
|
export const ALL = 'all';
|
|
|
|
export const CollectionsPanel = () => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [rows, setRows] = useState<Collection[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [params, setParams] = useSearchParams();
|
|
const selected = params.get('collection') ?? ALL;
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const body = await clientRef.current.get<{ response: Collection[] }>('/linkwarden/api/v1/collections');
|
|
setRows(body.response ?? []);
|
|
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 collections.');
|
|
setRows(null);
|
|
setShowSettings(true);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const select = (id: string) =>
|
|
setParams(
|
|
(p) => {
|
|
p.set('collection', id);
|
|
return p;
|
|
},
|
|
{ replace: true },
|
|
);
|
|
|
|
return (
|
|
<div className="flex size-full flex-col">
|
|
<header className="flex items-center justify-between border-b px-2 py-1.5">
|
|
<span className="text-xs font-medium">Collections</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>
|
|
|
|
{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(String(row.id))}
|
|
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-muted/50 ${
|
|
selected === String(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>
|
|
{typeof row._count?.links === 'number' && (
|
|
<span className="shrink-0 text-[10px] text-muted-foreground">{row._count.links}</span>
|
|
)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{showSettings && <SettingsForm />}
|
|
</div>
|
|
);
|
|
};
|