Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05d51302ec |
+1
-1
@@ -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',
|
||||
|
||||
@@ -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=<id>` 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<Collection[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex size-full flex-col">
|
||||
<header className="flex items-center justify-between border-b px-2 py-1.5">
|
||||
<span className="text-xs font-medium">Collections</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSettings((was) => !was)}
|
||||
className="p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{error && <p className="px-3 py-2 text-[11px] text-destructive">{error}</p>}
|
||||
|
||||
<ul className="flex-1 overflow-auto">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(ALL)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-muted/50 ${
|
||||
selected === ALL ? 'bg-muted' : ''
|
||||
}`}
|
||||
>
|
||||
<Layers className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
Everything
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{rows?.map((row) => (
|
||||
<li key={row.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(String(row.id))}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-muted/50 ${
|
||||
selected === String(row.id) ? 'bg-muted' : ''
|
||||
}`}
|
||||
>
|
||||
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{row.name}</span>
|
||||
{typeof row._count?.links === 'number' && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">{row._count.links}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{showSettings && <SettingsForm />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<Link[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex size-full flex-col">
|
||||
<header className="border-b p-2">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search names, tags and the text of saved pages…"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<p className="px-3 py-2 text-xs text-destructive">
|
||||
{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.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rows === null && !error ? (
|
||||
<Centred>Loading…</Centred>
|
||||
) : rows && rows.length === 0 ? (
|
||||
<Centred>{query.trim() ? `Nothing matches “${query.trim()}”.` : 'Nothing saved here yet.'}</Centred>
|
||||
) : (
|
||||
<ul className="flex-1 divide-y overflow-auto">
|
||||
{rows?.map((link) => (
|
||||
<li key={link.id} className="group px-3 py-2 hover:bg-muted/40">
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-center gap-1 text-xs hover:underline"
|
||||
>
|
||||
<span className="min-w-0 truncate">{link.name || link.url}</span>
|
||||
<ExternalLink className="size-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-60" />
|
||||
</a>
|
||||
<p className="truncate text-[11px] text-muted-foreground">{link.url}</p>
|
||||
|
||||
{(link.collection || link.tags?.length) && (
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-1 text-[10px] text-muted-foreground">
|
||||
{link.collection && <span>{link.collection.name}</span>}
|
||||
{link.tags?.map((tag) => (
|
||||
<span key={tag.id} className="rounded bg-muted px-1 py-px">
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Centred = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -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<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,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<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 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);
|
||||
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 (
|
||||
<div className="space-y-3 border-t p-3">
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] text-muted-foreground">Address the sidecar dials</label>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder="http://127.0.0.1:3000"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] text-muted-foreground">Admin access token</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={adminToken}
|
||||
onChange={(event) => setAdminToken(event.target.value)}
|
||||
placeholder={config?.url ? 'unchanged' : 'Linkwarden → Settings → Access Tokens'}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" className="h-7 text-xs" onClick={() => void save()} disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" className="h-7 text-xs" onClick={() => void check()}>
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{health && (
|
||||
<p className="text-[11px]">
|
||||
{health.ok ? (
|
||||
<span className="text-muted-foreground">Answering{health.upstream ? ` (${health.upstream})` : ''}.</span>
|
||||
) : (
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
<span className="text-destructive">Not answering — {health.reason ?? 'no reason given'}.</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+6
-4
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
+12
-9
@@ -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 },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user