From 05d51302ec7c57385185484a24ff5731b8fcc04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 5 Sep 2026 21:17:21 +0100 Subject: [PATCH] somewhere to go, because the settings screen was the whole plugin 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. --- manifest.ts | 2 +- web/CollectionsPanel.tsx | 113 +++++++++++++++++++++++++++++ web/LinksPanel.tsx | 138 ++++++++++++++++++++++++++++++++++++ web/LinkwardenPanel.tsx | 149 --------------------------------------- web/SettingsForm.tsx | 131 ++++++++++++++++++++++++++++++++++ web/layout.ts | 10 +-- web/panels.ts | 21 +++--- 7 files changed, 401 insertions(+), 163 deletions(-) create mode 100644 web/CollectionsPanel.tsx create mode 100644 web/LinksPanel.tsx delete mode 100644 web/LinkwardenPanel.tsx create mode 100644 web/SettingsForm.tsx diff --git a/manifest.ts b/manifest.ts index b96358e..2671082 100644 --- a/manifest.ts +++ b/manifest.ts @@ -32,7 +32,7 @@ import type { PluginManifest } from '@@/plugins/manifest'; */ export const manifest: PluginManifest = { publisher: 'officerdev', - version: '2026.09.05.03', + version: '2026.09.05.04', platform: '>=1.0.0', label: 'Linkwarden', diff --git a/web/CollectionsPanel.tsx b/web/CollectionsPanel.tsx new file mode 100644 index 0000000..45c7a76 --- /dev/null +++ b/web/CollectionsPanel.tsx @@ -0,0 +1,113 @@ +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=` 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(null); + const [error, setError] = useState(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 ( +
+
+ Collections + +
+ + {error &&

{error}

} + +
    +
  • + +
  • + + {rows?.map((row) => ( +
  • + +
  • + ))} +
+ + {showSettings && } +
+ ); +}; diff --git a/web/LinksPanel.tsx b/web/LinksPanel.tsx new file mode 100644 index 0000000..ac55242 --- /dev/null +++ b/web/LinksPanel.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router'; +import { useClient } from 'hooks/useClient'; +import { Input } from '@/components/ui/input'; +import { ExternalLink, FileText } from 'lucide-react'; +import { ALL } from './CollectionsPanel'; + +/** + * Your saved links, as the calling Officer user. + * + * Every request goes through `/api/linkwarden/api/*` — the authenticated door, which finds the caller's + * mapped account and puts THEIR token on the request. This panel holds no credential and could not send + * one if it wanted to, which is the property the whole plugin exists for. + * + * ── Search is Linkwarden's, not ours ── + * + * `searchQueryString` searches names, descriptions, tags AND the archived page text, which is the thing + * a bookmark bar cannot do. Re-implementing it here would mean pulling every link to the browser to + * filter it, and would still only see the fields we happened to fetch. Verified against the live + * instance: 50 links unfiltered, 8 for "arch", 0 for a string that matches nothing. + */ +type Link = { + id: number; + name: string; + url: string; + description?: string; + textContent?: string; + collection?: { id: number; name: string }; + tags?: { id: number; name: string }[]; + createdAt?: string; +}; + +export const LinksPanel = () => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [params] = useSearchParams(); + const collection = params.get('collection') ?? ALL; + + const [query, setQuery] = useState(''); + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + + const load = useCallback(async (collectionId: string, search: string) => { + const qs = new URLSearchParams(); + if (collectionId !== ALL) qs.set('collectionId', collectionId); + if (search.trim()) qs.set('searchQueryString', search.trim()); + + try { + const body = await clientRef.current.get<{ response: Link[] }>( + `/linkwarden/api/v1/links${qs.toString() ? `?${qs}` : ''}`, + ); + setRows(body.response ?? []); + setError(null); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Could not read your links.'); + setRows(null); + } + }, []); + + /* + Debounced, because this types straight into a request. + + 250 ms is chosen against what it is: somebody typing a word, who wants the list to move while they + type rather than after they stop. Long enough that a five-letter word is one query instead of five. + */ + useEffect(() => { + const timer = setTimeout(() => void load(collection, query), 250); + return () => clearTimeout(timer); + }, [collection, query, load]); + + return ( +
+
+ setQuery(event.target.value)} + placeholder="Search names, tags and the text of saved pages…" + className="h-8 text-xs" + /> +
+ + {error && ( +

+ {error} + {/* The state every user starts in, said as an instruction rather than a failure. */} + {error.includes('no Linkwarden account') && ' — the owner maps accounts in Settings.'} +

+ )} + + {rows === null && !error ? ( + Loading… + ) : rows && rows.length === 0 ? ( + {query.trim() ? `Nothing matches “${query.trim()}”.` : 'Nothing saved here yet.'} + ) : ( +
    + {rows?.map((link) => ( +
  • +
    + +
    + + {link.name || link.url} + + +

    {link.url}

    + + {(link.collection || link.tags?.length) && ( +

    + {link.collection && {link.collection.name}} + {link.tags?.map((tag) => ( + + {tag.name} + + ))} +

    + )} +
    +
    +
  • + ))} +
+ )} +
+ ); +}; + +const Centred = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); diff --git a/web/LinkwardenPanel.tsx b/web/LinkwardenPanel.tsx deleted file mode 100644 index 4f17c04..0000000 --- a/web/LinkwardenPanel.tsx +++ /dev/null @@ -1,149 +0,0 @@ -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/SettingsForm.tsx b/web/SettingsForm.tsx new file mode 100644 index 0000000..10f9b79 --- /dev/null +++ b/web/SettingsForm.tsx @@ -0,0 +1,131 @@ +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. + * + * Was the whole plugin for an afternoon, which was the mistake: a settings screen with nothing behind it + * is a screen you cannot leave. It is a disclosure inside the collections panel now — reachable when + * something is wrong, and out of the way when it is not. + * + * ── 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 a browser or the extension is given — that is + * `NEXTAUTH_URL` inside Linkwarden's own 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 dials a name that answers from everywhere + * except the one place that matters. + */ +type Config = { url: string | null; enabled: boolean; updatedAt: string | null }; +type Health = { ok: boolean; upstream?: number; reason?: string }; + +export const SettingsForm = () => { + const client = useClient(); + // `useClient()` rebuilds its verbs every render, so they must never sit in a dependency list. + 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 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); + try { + // The token is sent only when one was typed: leaving a write-only field alone means "unchanged", + // not "clear it". + await clientRef.current.put('/linkwarden/config', { + url: url.trim() || null, + ...(adminToken.trim() ? { adminToken: adminToken.trim() } : {}), + }); + setAdminToken(''); + setError(null); + await load(); + await check(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Could not save that.'); + } finally { + setBusy(false); + } + }; + + return ( +
+ {error &&

{error}

} + +
+ + setUrl(event.target.value)} + placeholder="http://127.0.0.1:3000" + className="h-8 text-xs" + /> +
+ +
+ + setAdminToken(event.target.value)} + placeholder={config?.url ? 'unchanged' : 'Linkwarden → Settings → Access Tokens'} + className="h-8 text-xs" + /> +
+ +
+ + +
+ + {health && ( +

+ {health.ok ? ( + Answering{health.upstream ? ` (${health.upstream})` : ''}. + ) : ( + /* + Three distinct reasons on purpose — unconfigured, unreachable, sidecar not running are three + different next actions, and one word for all of them sends somebody to check the wrong thing. + */ + Not answering — {health.reason ?? 'no reason given'}. + )} +

+ )} +
+ ); +}; diff --git a/web/layout.ts b/web/layout.ts index d3f8ebc..c7b8abb 100644 --- a/web/layout.ts +++ b/web/layout.ts @@ -1,11 +1,13 @@ 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. +// The links are the screen; the collections are how you narrow them. 24/76 because a collection name is +// short and a saved page needs room for its title, its address and its tags without wrapping. export const defaultLayout: LayoutNode = { type: 'group', id: 'linkwarden-root', direction: 'horizontal', - children: [{ node: { type: 'panel', id: 'linkwarden-settings', appType: 'linkwarden-settings' }, size: 100 }], + children: [ + { node: { type: 'panel', id: 'linkwarden-collections', appType: 'linkwarden-collections' }, size: 24 }, + { node: { type: 'panel', id: 'linkwarden-links', appType: 'linkwarden-links' }, size: 76 }, + ], }; diff --git a/web/panels.ts b/web/panels.ts index 5e2ea47..d344f95 100644 --- a/web/panels.ts +++ b/web/panels.ts @@ -1,15 +1,18 @@ import type { AppRegistryMeta } from 'officerdev'; -import { Bookmark } from 'lucide-react'; -import { LinkwardenPanel } from './LinkwardenPanel'; +import { Bookmark, FolderTree } from 'lucide-react'; +import { CollectionsPanel } from './CollectionsPanel'; +import { LinksPanel } from './LinksPanel'; -// One panel, for now: where the instance is, and whether it answers. +// Two panels: what you have saved, and how it is filed. // -// 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. +// It was one — the settings form — and that was the mistake worth naming. A plugin whose only screen is +// its configuration is one you cannot leave: nothing is behind it, so the first thing anybody does after +// installing is look for the way forward and not find one. The settings are a disclosure inside the +// collections panel now, reachable when something is wrong and out of the way when it is not. // -// `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. +// `availableOnPanel: false` keeps them out of the generic picker: they belong to the /linkwarden screen +// rather than being things to drop into an arbitrary dashboard. export const appRegistryMetas: AppRegistryMeta[] = [ - { key: 'linkwarden-settings', name: 'Linkwarden', icon: Bookmark, component: LinkwardenPanel, availableOnPanel: false }, + { key: 'linkwarden-collections', name: 'Collections', icon: FolderTree, component: CollectionsPanel, availableOnPanel: false }, + { key: 'linkwarden-links', name: 'Links', icon: Bookmark, component: LinksPanel, availableOnPanel: false }, ];