Files
linkwarden/web/LinkwardenPanel.tsx
T
pastilhas c2850fe8ed a settings panel, so installing it is enough
`/config` is owner-only and takes a PUT, so without this the only way to point
the install at a server was curl. A plugin cannot ask that - installing it has to
be enough, which is the same argument the schema makes for the URL being a table
rather than an env var.

`web/panels.ts` and `web/layout.ts` are required once `web/` exists, and the
directory existed empty, so discovery would have refused the plugin with exactly
that complaint.

The panel says the thing this install taught me, at the point where it would
otherwise be rediscovered: the address it asks for is the one the SIDECAR dials,
not the one a browser or the extension is given. Those are the same string on
most setups and are not here - the name Linkwarden advertises resolves to
127.0.1.1 on the host it runs on, where nothing is bound.

The admin token is write-only across the boundary: never sent back to the screen,
and only sent on save when something was typed, because an empty field means
"leave it alone" rather than "clear it".

Health has three distinct reasons - unconfigured, unreachable, sidecar not
running - because they are three different next actions and one word for all of
them sends somebody to check the wrong thing.
2026-09-05 21:00:30 +01:00

150 lines
5.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
/**
* Where this install's Linkwarden is, and whether it answers.
*
* ── Two addresses, and they are not the same one ──
*
* The field below is the address the SIDECAR dials, which on a machine running both Officer and
* Linkwarden is loopback. It is NOT the address you give a browser or the extension — that one is
* `NEXTAUTH_URL` inside the Linkwarden container, and it has to be reachable from wherever the client
* is. Measured on this install: the name Linkwarden advertises resolves to 127.0.1.1 on the host it
* runs on, where nothing is bound, so a sidecar told to use it would dial a name that answers from
* everywhere except the one place that matters. The panel says so rather than leaving it to be
* rediscovered.
*/
type Config = { url: string | null; enabled: boolean; updatedAt: string | null };
type Health = { ok: boolean; upstream?: number; reason?: string };
export const LinkwardenPanel = () => {
const client = useClient();
// `useClient()` rebuilds its verbs every render, so they must never sit in a dependency list — the
// trap that silently disabled every playback report in the Jellyfin player for days.
const clientRef = useRef(client);
clientRef.current = client;
const [config, setConfig] = useState<Config | null>(null);
const [url, setUrl] = useState('');
const [adminToken, setAdminToken] = useState('');
const [health, setHealth] = useState<Health | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const load = useCallback(async () => {
try {
const next = await clientRef.current.get<Config>('/linkwarden/config');
setConfig(next);
setUrl(next.url ?? '');
setError(null);
} catch {
setError('Could not read the configuration.');
}
}, []);
useEffect(() => {
void load();
}, [load]);
const check = useCallback(async () => {
setHealth(null);
try {
setHealth(await clientRef.current.get<Health>('/linkwarden/config/health'));
} catch {
setHealth({ ok: false, reason: 'the check itself failed' });
}
}, []);
const save = async () => {
setBusy(true);
setSaved(false);
try {
// The token is sent only when one was typed. Sending an empty string would read as "clear it",
// which is not what leaving a write-only field alone means.
await clientRef.current.put('/linkwarden/config', {
url: url.trim() || null,
...(adminToken.trim() ? { adminToken: adminToken.trim() } : {}),
});
setAdminToken('');
setSaved(true);
setError(null);
await load();
await check();
} catch (failure) {
setError(failure instanceof Error ? failure.message : 'Could not save that.');
} finally {
setBusy(false);
}
};
return (
<div className="flex size-full flex-col gap-4 overflow-auto p-4">
<header>
<h2 className="text-sm font-medium">Linkwarden</h2>
<p className="text-xs text-muted-foreground">
One instance for this Officer. Officer stores no bookmarks — it holds the credential and the door.
</p>
</header>
{error && (
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
{error}
</p>
)}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Address the sidecar dials</label>
<Input value={url} onChange={(event) => setUrl(event.target.value)} placeholder="http://127.0.0.1:3000" />
<p className="text-[11px] text-muted-foreground">
Loopback when Linkwarden runs on this machine. This is <em>not</em> the address you give the
browser extension — that one is <code>NEXTAUTH_URL</code> in Linkwarden’s own container, and it
has to be reachable from wherever the client is.
</p>
</div>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Admin access token</label>
<Input
type="password"
value={adminToken}
onChange={(event) => setAdminToken(event.target.value)}
placeholder={config?.url ? 'unchanged' : 'from Linkwarden → Settings → Access Tokens'}
/>
<p className="text-[11px] text-muted-foreground">
Used only to create accounts for Officer users. Write-only: it is never sent back to this screen,
and it is not the token any proxied request carries.
</p>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => void save()} disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</Button>
<Button size="sm" variant="secondary" onClick={() => void check()}>
Test
</Button>
{saved && <span className="text-xs text-muted-foreground">Saved.</span>}
</div>
{health && (
<p className="text-xs">
{health.ok ? (
<span className="text-muted-foreground">Answering{health.upstream ? ` (${health.upstream})` : ''}.</span>
) : (
/*
The reasons are distinct on purpose. "Unconfigured" is the owner not having finished,
"unreachable" is the address being wrong or the instance down, and "sidecar not running" is
Officer's own problem — three different next actions, and one word for all three would send
somebody to check the wrong thing.
*/
<span className="text-destructive">Not answering — {health.reason ?? 'no reason given'}.</span>
)}
</p>
)}
</div>
);
};