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(null); const [url, setUrl] = useState(''); const [adminToken, setAdminToken] = useState(''); const [health, setHealth] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); const load = useCallback(async () => { try { const next = await clientRef.current.get('/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('/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 (

Linkwarden

One instance for this Officer. Officer stores no bookmarks — it holds the credential and the door.

{error && (

{error}

)}
setUrl(event.target.value)} placeholder="http://127.0.0.1:3000" />

Loopback when Linkwarden runs on this machine. This is not the address you give the browser extension — that one is NEXTAUTH_URL in Linkwarden’s own container, and it has to be reachable from wherever the client is.

setAdminToken(event.target.value)} placeholder={config?.url ? 'unchanged' : 'from Linkwarden → Settings → Access Tokens'} />

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.

{saved && Saved.}
{health && (

{health.ok ? ( Answering{health.upstream ? ` (${health.upstream})` : ''}. ) : ( /* 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. */ Not answering — {health.reason ?? 'no reason given'}. )}

)}
); };