diff --git a/web/LinkwardenPanel.tsx b/web/LinkwardenPanel.tsx new file mode 100644 index 0000000..4f17c04 --- /dev/null +++ b/web/LinkwardenPanel.tsx @@ -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(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'}. + )} +

+ )} +
+ ); +}; diff --git a/web/layout.ts b/web/layout.ts new file mode 100644 index 0000000..d3f8ebc --- /dev/null +++ b/web/layout.ts @@ -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 }], +}; diff --git a/web/panels.ts b/web/panels.ts new file mode 100644 index 0000000..5e2ea47 --- /dev/null +++ b/web/panels.ts @@ -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 }, +];