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 }, ];