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.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// A single full-width panel. The framework requires a layout even when there is only one thing in it —
|
||||
// a plugin contributes panels and an arrangement, never a screen — and this is that arrangement stated
|
||||
// honestly rather than padded out with a sidebar nothing needs yet.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'linkwarden-root',
|
||||
direction: 'horizontal',
|
||||
children: [{ node: { type: 'panel', id: 'linkwarden-settings', appType: 'linkwarden-settings' }, size: 100 }],
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { Bookmark } from 'lucide-react';
|
||||
import { LinkwardenPanel } from './LinkwardenPanel';
|
||||
|
||||
// One panel, for now: where the instance is, and whether it answers.
|
||||
//
|
||||
// Reading and saving links happens in Linkwarden's own UI and in the browser extension, both of which
|
||||
// are better at it than anything reproduced here would be. What Officer has to own is the part neither
|
||||
// of those can do — pointing the install at a server, and saying who is who on it.
|
||||
//
|
||||
// `availableOnPanel: false` keeps it out of the generic picker: it belongs to the /linkwarden screen
|
||||
// rather than being something to drop into an arbitrary dashboard.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'linkwarden-settings', name: 'Linkwarden', icon: Bookmark, component: LinkwardenPanel, availableOnPanel: false },
|
||||
];
|
||||
Reference in New Issue
Block a user