Everything needed to be a plugin

manifest, one table, a router and two panels. Discovery is by convention, so
this is the point at which the directory stops being a directory and becomes
something Officer can install.

The manifest holds only what a listing cannot say. One permission, grantable,
with ownerOnly on /_config — reading and editing the site is exactly what a
member might be brought in for, but the token is unrestricted write authority
over everything the public sees, so handing it over is the owner's.

db/schema.ts is ONE table and should stay that way. fofos owns its own Postgres
and its own media store; this holds a URL and a token. That is what makes the
strongest thing about this plugin true: no operation on it can destroy the
client's work, checkbox ticked or not, because none of it was ever here. A cache
would be the thin end of that.

The router mostly forwards. fofos's /api/admin already answers in the shape a
panel wants, so re-wrapping it would be a second place for the contract to drift.
Two things in it are load-bearing rather than style:

  registerProxiedPrefix('/api/fofos-admin/media'). bodyParser calls parseBody on
  multipart across the whole protected router, and Hono then caches the parsed
  FormData — so asking for the bytes afterwards re-serialises them under a NEW
  boundary while the ORIGINAL content-type header is what gets forwarded. Header
  and body disagree and every upload fails with "Unexpected end of form".
  Narrow to the one route: everything else is JSON that is fine to parse.

  The catch-all is LAST in the file. Hono runs matching handlers in registration
  order, so /* above /_config would swallow it and the settings form would
  forward itself to fofos, which has no such endpoint — a 404 from a plugin that
  is working perfectly.

Panels are a nav and a detail pane that never speak to each other: the nav writes
?section=, the detail pane reads it. Real <Link>s rather than the buttons
proxy-manager uses — AGENTS.md says nav items are links, and the file documenting
that pattern says not to copy the older one. <Link> and not <NavLink> because
active state here is a query parameter and NavLink compares paths, which would
light every row at once.

Which component renders which section is a MAP, not a conditional on `built`.
The first version read `built ? <MessagesPanel/> : <Planned/>`, which is correct
only while exactly one section exists — the second one finished would have
rendered Messages under its own name.

Messages is the only section built, chosen because it is one read-only route: it
proves the path from an Officer panel through this proxy to fofos and back with
nothing else able to be wrong at the same time. The other eight are in the nav,
dimmed and labelled, saying what they will do and that the old admin still edits
them. A nav that hides what is unfinished gives no sense of what the thing is for.

Typechecked against the platform — and the typechecker was verified to actually
cover plugins/ by planting an error and watching it fail, because a clean run
that does not include your files is worse than no run.

NOT yet: installed, or run against a live fofos. Nothing here has been through
the permission gate or a real request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Pastilhas
2026-08-28 23:05:34 +01:00
co-authored by Claude Opus 5
parent 0a967bef98
commit 6b87956e44
15 changed files with 1408 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
import { db } from 'officerdb/db';
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
import { eq } from 'drizzle-orm';
import { fofosAdminConnection, type FofosAdminConnection } from '../db/schema';
import { normaliseUrl, type FofosCredentials } from './fofos-client';
/**
* Reading and writing the one connection row.
*
* The token is encrypted under this plugin's own purpose key, so a leak of that key opens this column
* and nothing else — the per-purpose split the platform's secret store already decided on.
*
* **The token never leaves this module in plaintext except into `fofosRequest`.** No route returns
* it, at any permission level, to anybody, including the owner. A form that wants to show "a token is
* set" gets a boolean.
*/
const PURPOSE = 'fofos-admin';
export type ConnectionView = {
configured: boolean;
url: string | null;
/** Never the token itself. Only whether one is stored. */
hasToken: boolean;
sections: string[] | null;
lastProbeAt: string | null;
lastProbeOk: boolean | null;
};
const EMPTY: ConnectionView = {
configured: false,
url: null,
hasToken: false,
sections: null,
lastProbeAt: null,
lastProbeOk: null,
};
const row = async (): Promise<FofosAdminConnection | null> => {
const rows = await db.select().from(fofosAdminConnection).limit(1);
return rows[0] ?? null;
};
export async function readConnection(): Promise<ConnectionView> {
const found = await row();
if (!found) return EMPTY;
return {
configured: true,
url: found.url,
hasToken: found.token.length > 0,
sections: found.sections ?? null,
lastProbeAt: found.lastProbeAt ? found.lastProbeAt.toISOString() : null,
lastProbeOk: found.lastProbeOk === null ? null : found.lastProbeOk === 1,
};
}
/** Decrypted, for the client only. Never serialise the result of this. */
export async function readCredentials(): Promise<FofosCredentials | null> {
const found = await row();
if (!found) return null;
return { url: found.url, token: decryptSecret(PURPOSE, found.token) };
}
export type SaveParams = { url: string; token?: string };
/**
* `token` omitted means "keep the stored one".
*
* So a typo in the URL can be corrected without going to find the token again, and a settings form
* never has to round-trip a secret in order to send it back.
*/
export async function saveConnection(params: SaveParams): Promise<ConnectionView> {
const url = normaliseUrl(params.url);
const existing = await row();
if (!existing && !params.token) {
throw new Error('A token is required the first time the connection is set.');
}
const token = params.token ? encryptSecret(PURPOSE, params.token) : (existing?.token as string);
if (existing) {
await db
.update(fofosAdminConnection)
.set({ url, token, updatedAt: new Date() })
.where(eq(fofosAdminConnection.id, existing.id));
} else {
await db.insert(fofosAdminConnection).values({ url, token });
}
return readConnection();
}
export async function deleteConnection(): Promise<void> {
await db.delete(fofosAdminConnection);
}
export async function recordProbe(sections: string[] | null, ok: boolean): Promise<void> {
const existing = await row();
if (!existing) return;
await db
.update(fofosAdminConnection)
.set({ sections, lastProbeAt: new Date(), lastProbeOk: ok ? 1 : 0 })
.where(eq(fofosAdminConnection.id, existing.id));
}
+143
View File
@@ -0,0 +1,143 @@
/**
* Talking to the fofos server.
*
* A thin client on purpose. fofos's `/api/admin` already answers in the shape a panel wants — every
* route returns `{ data: … }`, and a mutation returns the whole section it belongs to — so there is
* nothing here to reshape. What this owns is the credential, the failure vocabulary, and the one
* decision about what a non-answer means.
*
* See `docs/SURFACES.md` in `fofos/platform` for the routes.
*/
export type FofosCredentials = { url: string; token: string };
/** No trailing slash, so joining a path never produces a double one. */
export const normaliseUrl = (url: string): string => url.trim().replace(/\/+$/, '');
/**
* A failure with the two things a panel needs to say something useful: whether fofos answered at all,
* and whether it refused the credential.
*
* Those are genuinely different problems — one is "the server is down or the URL is wrong", the other
* is "the token is wrong" — and a panel that cannot tell them apart can only say "something went
* wrong", which sends someone to check the wrong half.
*/
export class FofosError extends Error {
constructor(
message: string,
readonly status: number | null,
/** fofos refused the credential. `status` is 401. */
readonly unauthorised = false,
) {
super(message);
this.name = 'FofosError';
}
}
/** `null` status means the request never got an answer — DNS, refused connection, timeout. */
const unreachable = (url: string, cause: unknown) =>
new FofosError(
`Could not reach fofos at ${url}. ${cause instanceof Error ? cause.message : String(cause)}`,
null,
);
type RequestOptions = {
method?: string;
path: string;
search?: string;
/** Already-serialised. Forwarded as given so what fofos receives is what the caller sent. */
body?: string | FormData | null;
contentType?: string | null;
};
export type FofosResponse = { status: number; body: string; contentType: string | null };
/**
* One request, returning fofos's own answer verbatim — including the bad ones.
*
* It does NOT throw on a 4xx or 5xx. fofos's error envelope is more informative than anything this
* could substitute (it names the field), and the caller is forwarding to a panel that should see what
* the server actually said. Only a request that got no answer at all throws, because there is nothing
* to forward.
*/
export async function fofosRequest(
credentials: FofosCredentials,
options: RequestOptions,
): Promise<FofosResponse> {
const url = `${normaliseUrl(credentials.url)}/api/admin${options.path}${options.search ?? ''}`;
const headers: Record<string, string> = { Authorization: `Bearer ${credentials.token}` };
// Left unset for FormData: fetch has to write its own, including the multipart boundary, and a
// hand-set Content-Type here produces a body the other end cannot parse.
if (options.contentType) headers['Content-Type'] = options.contentType;
let response: Response;
try {
response = await fetch(url, {
method: options.method ?? 'GET',
headers,
body: options.body ?? undefined,
// fofos is a small server on the same network and every one of these is a database read. A
// request still hanging after this is not going to complete.
signal: AbortSignal.timeout(30_000),
});
} catch (cause) {
throw unreachable(normaliseUrl(credentials.url), cause);
}
return {
status: response.status,
body: await response.text(),
contentType: response.headers.get('content-type'),
};
}
export type ProbeResult = {
reachable: boolean;
authenticated: boolean;
sections?: string[];
error?: string;
};
/**
* Is this URL a fofos server, and does it accept this token?
*
* Both questions at once, because a person setting this up has one form and should not have to work
* out which half is wrong. `GET /api/admin` sits behind the guard for exactly this reason: a 200 is
* both answers, and a 401 separates them.
*/
export async function probe(credentials: FofosCredentials): Promise<ProbeResult> {
let response: FofosResponse;
try {
response = await fofosRequest(credentials, { path: '' });
} catch (error) {
return { reachable: false, authenticated: false, error: error instanceof Error ? error.message : String(error) };
}
if (response.status === 401) {
return { reachable: true, authenticated: false, error: 'fofos refused that token.' };
}
if (response.status !== 200) {
return {
reachable: true,
authenticated: false,
// A 404 here is the interesting one: something answered, but it is not serving /api/admin. That
// is usually a URL pointing at the built static site rather than at the live server.
error:
response.status === 404
? 'That address answered, but it is not serving /api/admin. Is it pointing at the static site rather than the fofos server?'
: `fofos answered ${response.status}.`,
};
}
try {
const parsed = JSON.parse(response.body) as { data?: { service?: string; sections?: string[] } };
if (parsed.data?.service !== 'fofos') {
return { reachable: true, authenticated: false, error: 'That address answered, but it is not fofos.' };
}
return { reachable: true, authenticated: true, sections: parsed.data.sections ?? [] };
} catch {
return { reachable: true, authenticated: false, error: 'That address answered with something that is not JSON.' };
}
}
+206
View File
@@ -0,0 +1,206 @@
import type { Context } from 'hono';
import { createRouter, type HonoVariables } from '@@/create-router';
import { registerProxiedPrefix } from '@@/sidecar/proxied-prefixes';
import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection';
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
/**
* Mounted at `/api/fofos-admin`.
*
* ── Mostly a pass-through, and that is the design ──
*
* fofos's `/api/admin` is already the right shape for a panel: every route answers `{ data: … }`, and
* a mutation returns the whole section it belongs to. Re-wrapping that here would add a second place
* for the contract to be described and a second place for it to drift.
*
* So this holds the credential and forwards. What it does NOT do is decide what fofos's answers mean:
* a 404 from fofos reaches the panel as a 404, because fofos's error envelope names the field and
* nothing here could improve on that.
*
* The exception is `/_config`, which is this plugin's own and never leaves the machine.
*
* ── Permissions ──
*
* The platform's gate decides who gets here, and `/_config` is `ownerOnly` in the manifest. Nothing in
* this file checks again: two places deciding the same thing is how they come to disagree.
*/
export const router = createRouter();
/**
* The upload path forwards a body it must not have parsed. This is load-bearing.
*
* `bodyParser` runs across the whole protected router and calls `parseBody` on `multipart/form-data`.
* Hono then caches the parsed FormData, so asking for the bytes afterwards RE-SERIALISES them under a
* NEW multipart boundary — while the original `content-type` header, naming the old one, is what gets
* forwarded. Header and body disagree and fofos rejects every upload with "Unexpected end of form".
*
* Registering the prefix tells `bodyParser` to leave these requests alone. Narrow on purpose — one
* route, not the whole plugin — because everything else here is JSON that is genuinely fine to parse,
* and claiming more than is needed turns an opt-out into a habit.
*/
registerProxiedPrefix('/api/fofos-admin/media');
/** fofos's own answer, passed through with its status and content type intact. */
const passThrough = (response: FofosResponse) =>
new Response(response.body, {
status: response.status,
headers: { 'content-type': response.contentType ?? 'application/json' },
});
/**
* A shaped failure for the two things that can go wrong before fofos answers at all.
*
* `unconfigured` is the one panels branch on: it is a setup step rather than an error, and rendering
* it as a broken list would tell somebody their site was down when they have simply not connected it
* yet.
*/
const noConnection = (ctx: Context<{ Variables: HonoVariables }>) =>
ctx.json({ error: 'No fofos connection is configured.', unconfigured: true }, 409);
const unreachable = (ctx: Context<{ Variables: HonoVariables }>, error: unknown) => {
if (error instanceof FofosError) {
return ctx.json({ error: error.message, unreachable: error.status === null }, 502);
}
return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 500);
};
// ── Everything the panels read and write ─────────────────────────────────────────────────────────
/**
* Uploads. Registered above so its body arrives untouched.
*
* Buffered rather than streamed: Bun's fetch can take a stream, but the platform is a hop in the
* middle and a half-forwarded upload that fails mid-flight is harder to reason about than one that is
* whole before it leaves. `arrayBuffer()` here is the ORIGINAL bytes with the ORIGINAL boundary,
* which is exactly why the original `content-type` can be forwarded with them.
*/
router.post('/media', async (ctx) => {
const credentials = await readCredentials();
if (!credentials) return noConnection(ctx);
try {
const body = await ctx.req.arrayBuffer();
const response = await fofosRequest(credentials, {
method: 'POST',
path: '/media',
body: new Blob([body]) as unknown as FormData,
contentType: ctx.req.header('content-type') ?? null,
});
return passThrough(response);
} catch (error) {
return unreachable(ctx, error);
}
});
// ── Settings — owner only, by the manifest ───────────────────────────────────────────────────────
router.get('/_config', async (ctx) => ctx.json(await readConnection()));
router.put('/_config', async (ctx) => {
const payload = (await ctx.req.json().catch(() => null)) as { url?: string; token?: string } | null;
if (!payload?.url?.trim()) return ctx.json({ error: 'A fofos URL is required.' }, 400);
try {
const saved = await saveConnection({ url: payload.url, token: payload.token?.trim() || undefined });
// Probed immediately, so the form can say whether it works rather than only that it saved. A
// failure here is NOT a failed save — the connection is stored either way, which is what lets
// somebody fix a token without re-entering the URL.
const credentials = await readCredentials();
if (credentials) {
const result = await probe(credentials);
await recordProbe(result.sections ?? null, result.authenticated);
return ctx.json({ ...(await readConnection()), probe: result });
}
return ctx.json(saved);
} catch (error) {
return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 400);
}
});
router.delete('/_config', async (ctx) => {
await deleteConnection();
return ctx.json(await readConnection());
});
/**
* Check a URL and token WITHOUT storing them.
*
* So a wrong token never replaces a working one — the same reason proxy-manager's test route exists.
* When the token is omitted it tests the STORED one, which is how "is my connection still good?" is
* answered without anybody having to retype a secret.
*/
router.post('/_config/test', async (ctx) => {
const payload = (await ctx.req.json().catch(() => null)) as { url?: string; token?: string } | null;
const stored = await readCredentials();
const url = payload?.url?.trim() || stored?.url;
const token = payload?.token?.trim() || stored?.token;
if (!url || !token) {
return ctx.json({ reachable: false, authenticated: false, error: 'A URL and a token are needed.' }, 400);
}
const result = await probe({ url, token });
// Only when testing what is actually stored. Recording a probe of some other credential would put a
// verdict about a different server against this row.
if (!payload?.url && !payload?.token && stored) {
await recordProbe(result.sections ?? null, result.authenticated);
}
return ctx.json(result);
});
/**
* Is this plugin usable? Asked once by the detail panel rather than by all nine sections.
*
* Not owner-only, and not under `/_config` for that reason: a member needs to know whether to render a
* section or a "not connected yet" message, and that answer contains no credential.
*/
router.get('/status', async (ctx) => {
const connection = await readConnection();
return ctx.json({ configured: connection.configured, lastProbeOk: connection.lastProbeOk });
});
/**
* Everything else, forwarded verbatim.
*
* A catch-all rather than a route per section, because the sections are fofos's to define — see its
* `docs/SURFACES.md`. Enumerating them here would mean this plugin needs a release every time fofos
* adds one, which is a coupling with nothing to show for it.
*
* ── LAST in the file, and that is load-bearing ──
*
* Hono runs every matching handler in REGISTRATION order, so `/*` declared above `/_config` would
* swallow it — the settings routes would forward themselves to fofos, which has no such endpoint, and
* the settings form would 404 against a plugin that was working perfectly. Everything this plugin
* serves itself is registered before this point.
*/
router.all('/*', async (ctx) => {
const credentials = await readCredentials();
if (!credentials) return noConnection(ctx);
const url = new URL(ctx.req.url);
// Everything after the mount, so `/gallery/3/media` reaches fofos as `/api/admin/gallery/3/media`.
const path = url.pathname.slice(url.pathname.indexOf('/fofos-admin') + '/fofos-admin'.length) || '';
const method = ctx.req.method;
// Read as TEXT and forwarded as text, so what fofos parses is what the panel sent. Safe after
// bodyParser because Hono caches the body — and correct because these are all JSON.
const body = method === 'GET' || method === 'HEAD' ? null : await ctx.req.text();
try {
const response = await fofosRequest(credentials, {
method,
path,
search: url.search,
body,
contentType: body ? 'application/json' : null,
});
return passThrough(response);
} catch (error) {
return unreachable(ctx, error);
}
});
+61
View File
@@ -0,0 +1,61 @@
import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core';
/**
* The connection to the fofos server. One row, and the ONLY thing this plugin stores.
*
* ── Why there is nothing else here ──
*
* Every temptation to add a table to this file should be resisted, and it is worth writing down why
* rather than relying on taste. fofos owns its own Postgres and its own media store; this plugin
* reaches them over HTTP. If the client's collections and photographs lived here instead, they would
* live under Officer's plugin installer — which drops a plugin's tables when the "delete its data"
* checkbox is ticked.
*
* So the strongest thing that can be said about this plugin is a consequence of this file being one
* table long: **no operation on it can destroy the client's work.** Deleting it removes a URL and a
* token, and the site is untouched.
*
* A cache would be the thin end of it. If a panel ever needs one, it belongs in memory or in the
* browser, not in a table that outlives the request.
*
* ── Why not core `service_connections` ──
*
* It fits the shape — `url`, an encrypted `secret` — but its `ServiceName` is a closed TypeScript
* union, so using it means editing the platform to add a name, and a plugin should install into a
* platform it did not have to change. The same reasoning `plugins/proxy-manager/db/schema.ts` gives,
* and worth revisiting on the day that union opens.
*/
export const fofosAdminConnection = pgTable('fofos_admin_connection', {
id: serial('id').primaryKey(),
/** Where the fofos server is — the origin its API is served from. Normalised without a trailing slash. */
url: text('url').notNull(),
/**
* fofos's `FOFOS_API_TOKEN`, encrypted with `encryptSecret('fofos-admin', …)` — its own purpose
* key, so a leak of that one opens this column and nothing else.
*
* **Never returned across the API boundary**, at any level, to anybody, including the owner. A
* settings form asking "is one set" gets a boolean.
*
* There is exactly one of these rather than one per user, and that is not laziness: fofos has a
* single token and no notion of accounts. WHO is editing is a question Officer answers with its own
* accounts and permissions, in front of this.
*/
token: text('token').notNull(),
/** From the last successful probe — which sections that server says it serves. Diagnostic only. */
sections: text('sections').array(),
lastProbeAt: timestamp('last_probe_at', { withTimezone: true }),
/**
* `integer` rather than `boolean` for the same reason proxy-manager uses one: three states, not
* two. Null means never probed, which reads differently from probed-and-failed.
*/
lastProbeOk: integer('last_probe_ok'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export type FofosAdminConnection = typeof fofosAdminConnection.$inferSelect;
+64
View File
@@ -0,0 +1,64 @@
import type { PluginManifest } from '@@/plugins/manifest';
/**
* Fofos — the fofos website's admin, inside Officer.
*
* ── What it replaces ──
*
* A server-rendered admin on its own subdomain with its own argon2 passwords, its own session cookie
* and its own `/admin/setup` bootstrap. Every part of that duplicated something Officer already does,
* and none of it could be shared: giving somebody access to the machine still meant giving them
* access to the admin, separately, by hand. That is the whole reason this is a plugin.
*
* ── It holds no content ──
*
* fofos owns its own database and its own media store, and this reads and writes them over HTTP. The
* only thing in Officer's database is the connection — a URL and a token.
*
* That is a deliberate property rather than a convenience. **Deleting this plugin cannot lose a
* photograph or a line of the client's copy**, with the "delete its data" checkbox ticked or not,
* because none of it was ever here. The alternative — a `db/schema.ts` carrying fofos's own tables —
* would have put every one of them under an installer that is allowed to drop them.
*
* ── The permission is grantable; the credential is not ──
*
* Reading and editing the site is exactly what a member might be brought in to do. The TOKEN is a
* different matter: it is unrestricted write authority over everything the public sees, so `/_config`
* is `ownerOnly` — the split that field exists for, and the same shape proxy-manager and Jellyfin use
* for the same reason.
*
* ── No `screens` ──
*
* One screen at the mount prefix, labelled `label`. The nine sections are `?section=` on that one
* screen rather than nine routes: they are panels of one workspace, and `screens` is for a plugin
* contributing genuinely separate destinations.
*/
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '2026.08.28.01',
platform: '>=1.0.0',
label: 'Fofos',
summary: "The fofos website's content — pages, galleries, drops and collabs",
/** one website's admin, and useless without that website. */
tags: ['Personal'],
icon: 'PenLine',
color: '#c2410c',
// Nothing to declare. It talks HTTP to fofos and stores one row; there is no host binary anywhere
// on the path, and fofos itself is a separate deployment rather than something this installs.
permissions: [
{
key: 'fofos-admin',
label: 'Fofos',
description: "Edit the fofos website — pages, galleries, drops, collabs and the messages people send",
// Where the fofos URL and token are set, and the probe that checks them. Prefix-matched, so
// `/_config/test` is covered too — deliberately, because that request carries the token in its
// BODY. A member who could call it would have an oracle pointed at a credential that can
// rewrite the whole public site.
ownerOnly: ['/_config'],
},
],
};
+240
View File
@@ -0,0 +1,240 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { CheckCircle2, Loader2, Plug, ShieldAlert, Trash2, TriangleAlert } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { readableError } from './errors';
import { useFofosChanged } from './channel';
/**
* Where fofos is, and the token to reach it. Both the first-run setup and the permanent settings.
*
* One component for both — the same choice `plugins/proxy-manager/web/ConnectionPanel.tsx` makes, and
* for the same reason: a connection edited months later goes through exactly the code path that
* stored the first one, so there is no second path to rot.
*
* ── Owner only, and it says so rather than failing oddly ──
*
* The manifest marks `/_config` `ownerOnly`, so every request from this component 403s for anybody
* else — including the test button, deliberately, because that request carries the token in its body.
* A member who reaches this screen should read a sentence explaining that, not a raw refusal.
*
* ── The token is never loaded ──
*
* `GET /_config` returns `hasToken`, never the token. So the field starts empty even when one is
* stored, and an empty field on save means "keep the stored one" — which is what lets a typo in the
* URL be fixed without going to find the credential again.
*/
type ConnectionView = {
configured: boolean;
url: string | null;
hasToken: boolean;
sections: string[] | null;
lastProbeAt: string | null;
lastProbeOk: boolean | null;
};
type TestResult = {
reachable: boolean;
authenticated: boolean;
sections?: string[];
error?: string;
};
export const ConnectionPanel = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [view, setView] = useState<ConnectionView | null>(null);
const [url, setUrl] = useState('');
const [token, setToken] = useState('');
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<TestResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [forbidden, setForbidden] = useState(false);
const [, bump] = useFofosChanged();
const load = useCallback(async () => {
try {
const current = await clientRef.current.get<ConnectionView>('/fofos-admin/_config');
setView(current);
setUrl(current.url ?? '');
} catch (failure) {
// A 403 here is not a fault — it is a member looking at an owner-only screen, and it needs a
// different sentence from "that did not work".
if ((failure as { status?: number })?.status === 403) setForbidden(true);
else setError(readableError(failure));
setView({ configured: false, url: null, hasToken: false, sections: null, lastProbeAt: null, lastProbeOk: null });
}
}, []);
useEffect(() => {
void load();
}, [load]);
const test = async () => {
setBusy(true);
setError(null);
try {
// Sent only when typed. Omitted, the server tests what is STORED — which is how "is my
// connection still good?" is answered without retyping a secret.
const body: Record<string, string> = {};
if (url.trim()) body.url = url.trim();
if (token.trim()) body.token = token.trim();
setResult(await clientRef.current.post<TestResult>('/fofos-admin/_config/test', body));
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const save = async () => {
setBusy(true);
setError(null);
try {
const body: Record<string, string> = { url: url.trim() };
if (token.trim()) body.token = token.trim();
const saved = await clientRef.current.put<ConnectionView & { probe?: TestResult }>(
'/fofos-admin/_config',
body,
);
setView(saved);
setResult(saved.probe ?? null);
// Cleared on success: it is stored now, and keeping it on screen serves nobody.
setToken('');
// Everything else re-reads. Saving a connection is the one action that changes what every other
// panel can see.
bump();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const forget = async () => {
setBusy(true);
try {
setView(await clientRef.current.delete<ConnectionView>('/fofos-admin/_config'));
setUrl('');
setToken('');
setResult(null);
bump();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
if (forbidden) {
return (
<div className="flex size-full flex-col items-center justify-center gap-2 p-6 text-center">
<ShieldAlert className="size-6 text-muted-foreground/60" />
<p className="text-sm">Only the owner can change this connection.</p>
<p className="max-w-sm text-xs text-muted-foreground">
The token stored here can rewrite the whole public site, so setting it belongs to whoever owns the
server. You can still edit content.
</p>
</div>
);
}
if (!view) {
return <div className="flex size-full items-center justify-center p-6 text-sm text-muted-foreground">Loading…</div>;
}
return (
<div className="flex size-full flex-col gap-4 overflow-auto p-5">
<div className="space-y-1">
<h2 className="flex items-center gap-2 text-sm font-medium">
<Plug className="size-4" />
The fofos server
</h2>
<p className="text-xs text-muted-foreground">
Where the site lives, and a token to edit it. The token is fofos's <code>FOFOS_API_TOKEN</code>.
</p>
</div>
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">Address</span>
<Input
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="http://localhost:3010"
autoComplete="off"
spellCheck={false}
/>
<span className="block text-xs text-muted-foreground/80">
The live fofos server, not the published static site — the static one serves no API.
</span>
</label>
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">Token</span>
<Input
type="password"
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder={view.hasToken ? 'A token is stored — leave empty to keep it' : ''}
autoComplete="new-password"
spellCheck={false}
/>
</label>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" onClick={() => void save()} disabled={busy || !url.trim()}>
{busy && <Loader2 className="size-3.5 animate-spin" />}
Save
</Button>
<Button variant="outline" size="sm" onClick={() => void test()} disabled={busy || (!url.trim() && !view.configured)}>
Test
</Button>
{view.configured && (
<Button variant="ghost" size="sm" onClick={() => void forget()} disabled={busy}>
<Trash2 className="size-3.5" />
Forget
</Button>
)}
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
{result && (
<div
className={`flex items-start gap-2 rounded-md p-3 text-xs ${
result.authenticated ? 'bg-success/15 text-success' : 'bg-destructive/15 text-destructive'
}`}
>
{result.authenticated ? (
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0" />
) : (
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" />
)}
<span>
{result.authenticated
? `Connected. ${result.sections?.length ?? 0} sections available.`
: (result.error ?? 'That did not work.')}
</span>
</div>
)}
{view.configured && !result && (
<p className="text-xs text-muted-foreground">
{view.lastProbeOk === null
? 'Saved, and not yet checked.'
: view.lastProbeOk
? `Last checked ${view.lastProbeAt ? new Date(view.lastProbeAt).toLocaleString() : ''} — working.`
: 'The last check failed. Press Test to try again.'}
</p>
)}
</div>
);
};
+102
View File
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { Settings } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { DEFAULT_SECTION, isSectionKey, sectionFor, type SectionKey } from './sections';
import { ConnectionPanel } from './ConnectionPanel';
import { MessagesPanel } from './MessagesPanel';
import { PlannedSection } from './PlannedSection';
import { useFofosChanged } from './channel';
/**
* The right column: whichever section the nav has selected.
*
* It reads `?section=` from the URL rather than being handed it — the two panels are siblings in a
* workspace and neither owns the other. `?config=1` renders the connection form over the top of
* whatever is selected, so settings are addressable too.
*
* ── The unconfigured check lives HERE, once ──
*
* Every section needs a connection, so every section would otherwise have to handle its absence and
* each would word it slightly differently. Asking `/status` once and rendering the connection form
* instead of any section is one answer for all nine, and it makes "not connected yet" read as a setup
* step rather than as nine broken lists.
*/
type Status = { configured: boolean; lastProbeOk: boolean | null };
/**
* Which component renders which section.
*
* A map rather than a conditional on `built`. The version of this that read
* `built ? <MessagesPanel/> : <Planned/>` was correct only while exactly one section existed — the
* second one to be finished would have rendered Messages under its own name, which is the kind of bug
* that looks like a data problem for an hour. A key with no entry here falls through to Planned, so
* marking a section `built` in sections.ts without writing its panel fails visibly rather than
* silently showing somebody else's.
*/
const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
messages: MessagesPanel,
};
export const DetailPanel = () => {
const [params] = useSearchParams();
const raw = params.get('section');
const section: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION;
const showingConfig = params.get('config') === '1';
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [configured, setConfigured] = useState<boolean | null>(null);
const [signal] = useFofosChanged();
const check = useCallback(async () => {
try {
const status = await clientRef.current.get<Status>('/fofos-admin/status');
setConfigured(status.configured);
} catch {
// Cannot tell. Render the section rather than the setup form — a transient failure must not look
// like "you have not set this up", which is a much more alarming thing to be told, and would
// invite somebody to re-enter a connection that was never the problem.
setConfigured(true);
}
}, []);
useEffect(() => {
void check();
}, [check, signal]);
if (configured === null) {
return <div className="flex size-full items-center justify-center p-6 text-sm text-muted-foreground">Loading…</div>;
}
if (showingConfig || !configured) return <ConnectionPanel />;
const current = sectionFor(section);
// Everything but `config`, so the settings link keeps the section underneath it.
const configSearch = new URLSearchParams(params);
configSearch.set('config', '1');
const Panel = current.built ? PANELS[current.key] : undefined;
return (
<div className="flex size-full flex-col overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2">
<h2 className="truncate text-sm font-medium">{current.label}</h2>
{/* A real link, so the settings are addressable and cmd-clickable like anything else. */}
<Link
to={{ search: `?${configSearch.toString()}` }}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<Settings className="size-3.5" />
Connection
</Link>
</div>
<div className="min-h-0 flex-1">{Panel ? <Panel /> : <PlannedSection section={current} />}</div>
</div>
);
};
+143
View File
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Mail, RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { readableError } from './errors';
import { useFofosChanged } from './channel';
/**
* Messages — what people sent through the contact form.
*
* The first section built, and chosen for that on purpose: it is one read-only route, so it proves
* the whole path — Officer panel, through this plugin's proxy, to fofos and back — with nothing else
* in the way to be wrong at the same time.
*
* **Read only, and there is no delete.** fofos's query layer exposes no way to remove a submission, so
* this API cannot offer one. That is the right way round: these are things people sent, and a button
* that destroys one should be a decision somebody makes deliberately rather than a control that
* appeared because a list looked bare without it.
*/
type Message = {
id: number;
name: string;
email: string;
project_or_company: string | null;
idea: string;
budget_range: string | null;
timeline: string | null;
created_at: string | null;
};
/**
* A date somebody can read, in their own locale, without the seconds nobody wants.
*
* `undefined` as the locale so the browser uses the reader's, rather than this file deciding that
* everyone reads dates the way one developer does.
*/
const when = (iso: string | null): string => {
if (!iso) return '';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
};
export const MessagesPanel = () => {
const client = useClient();
// useClient() rebuilds its verbs every render, so it must never sit in a dependency list.
const clientRef = useRef(client);
clientRef.current = client;
const [messages, setMessages] = useState<Message[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [signal] = useFofosChanged();
const load = useCallback(async () => {
setLoading(true);
try {
const response = await clientRef.current.get<{ data: Message[] }>('/fofos-admin/messages');
setMessages(response.data);
setError(null);
} catch (failure) {
setError(readableError(failure));
// The previous list is KEPT. A refresh that fails should not blank a screen somebody was
// reading — the error says what happened, and what was there is still there.
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load, signal]);
if (messages === null && error) {
return (
<div className="flex size-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-sm text-destructive">{error}</p>
<Button variant="outline" size="sm" onClick={() => void load()}>
Try again
</Button>
</div>
);
}
if (messages === null) {
return <div className="flex size-full items-center justify-center p-6 text-sm text-muted-foreground">Loading…</div>;
}
return (
<div className="flex size-full flex-col overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
<p className="text-sm">
{messages.length} message{messages.length === 1 ? '' : 's'}
</p>
<Button variant="ghost" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCw className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* An error ABOVE the list rather than instead of it, once there is something to show. */}
{error && <p className="border-b border-border bg-destructive/10 px-4 py-2 text-xs text-destructive">{error}</p>}
{messages.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center">
<Mail className="size-6 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">Nobody has used the contact form yet.</p>
</div>
) : (
<ul className="flex-1 overflow-auto">
{messages.map((message) => (
<li key={message.id} className="border-b border-border px-4 py-3">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<p className="text-sm font-medium">{message.name}</p>
<time className="text-xs text-muted-foreground">{when(message.created_at)}</time>
</div>
{/* A real mailto — replying is the only thing anybody wants to do from here. */}
<a href={`mailto:${message.email}`} className="text-xs text-info hover:underline">
{message.email}
</a>
{message.project_or_company && (
<span className="text-xs text-muted-foreground"> · {message.project_or_company}</span>
)}
{/* `whitespace-pre-wrap`, because what somebody typed had line breaks in it and they
meant them. */}
<p className="mt-1.5 text-sm whitespace-pre-wrap text-muted-foreground">{message.idea}</p>
{(message.budget_range || message.timeline) && (
<p className="mt-1.5 flex flex-wrap gap-x-3 text-xs text-muted-foreground/80">
{message.budget_range && <span>Budget: {message.budget_range}</span>}
{message.timeline && <span>Timeline: {message.timeline}</span>}
</p>
)}
</li>
))}
</ul>
)}
</div>
);
};
+73
View File
@@ -0,0 +1,73 @@
import { Link, useSearchParams } from 'react-router';
import { DEFAULT_SECTION, SECTIONS, isSectionKey, type SectionKey } from './sections';
/**
* The left column: which part of the fofos site the detail panel is showing.
*
* ── Selection is in the URL ──
*
* `?section=…`, per `docs/navigation-audit.md`: what is on screen is addressable, so it survives a
* reload, can be linked, and the detail panel reads it directly. The two panels are siblings in a
* workspace and never speak to each other.
*
* ── Real links, not buttons ──
*
* `AGENTS.md`: "Rows and nav items are real links." `plugins/proxy-manager/web/NavPanel.tsx` uses
* buttons calling `setParams`, which keeps the URL correct but gives up anchor semantics — no
* cmd-click into a new tab, no middle-click, not link-focusable. That is the older pattern and the
* file that documents it says not to copy it.
*
* `<Link>` rather than `<NavLink>` because active state here is a QUERY parameter and NavLink's
* `isActive` compares paths — every section would light up at once. So the active class is computed
* from the same value the detail panel reads, which is the one place it can come from.
*/
const rowClass = (active: boolean) =>
`flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-left transition-colors ${
active ? 'bg-muted' : 'hover:bg-muted/50'
}`;
export const NavPanel = () => {
const [params] = useSearchParams();
const raw = params.get('section');
const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION;
return (
<nav className="flex size-full flex-col overflow-auto">
{SECTIONS.map((section) => {
const Icon = section.icon;
// Everything else in the URL is preserved. `?config=1` is the one that matters — opening the
// settings and then clicking a section should leave the settings, not silently keep them open
// underneath.
const search = new URLSearchParams(params);
search.set('section', section.key);
search.delete('config');
return (
<Link
key={section.key}
to={{ search: `?${search.toString()}` }}
replace
className={rowClass(active === section.key)}
>
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className={`truncate text-sm ${section.built ? '' : 'text-muted-foreground'}`}>
{section.label}
</span>
{/* Dimmed and labelled rather than hidden. A nav that shows only what is finished gives
no sense of what the thing is for — see sections.ts. */}
{!section.built && (
<span className="text-[10px] tracking-wide text-muted-foreground/70 uppercase">soon</span>
)}
</span>
<span className="block truncate text-xs text-muted-foreground">{section.hint}</span>
</span>
</Link>
);
})}
</nav>
);
};
+24
View File
@@ -0,0 +1,24 @@
import { Hammer } from 'lucide-react';
import type { Section } from './sections';
/**
* A section that has a place in the nav and no panel yet.
*
* It says what it WILL do rather than apologising. A screen reading "coming soon" tells somebody they
* were right to look and wrong to expect anything; naming the thing at least confirms they are in the
* right place, and that the section is planned rather than broken.
*
* The htmx admin at `admin.fofos.com` still edits every one of these, so nothing here is unreachable
* work — it is work that has not moved yet. Saying so is the difference between "not built" and
* "not possible".
*/
export const PlannedSection = ({ section }: { section: Section }) => (
<div className="flex size-full flex-col items-center justify-center gap-2 p-6 text-center">
<Hammer className="size-6 text-muted-foreground/50" />
<p className="text-sm">{section.label} is not built here yet.</p>
<p className="max-w-sm text-xs text-muted-foreground">{section.hint}.</p>
<p className="max-w-sm text-xs text-muted-foreground/80">
Until it moves, this section is still editable in the old admin.
</p>
</div>
);
+36
View File
@@ -0,0 +1,36 @@
import { useCallback, useRef } from 'react';
import { defineChannel } from 'hooks/usePanelChannel';
/**
* "Something on the fofos site changed — anyone reading it should read it again."
*
* ── Why a channel and not the URL ──
*
* `docs/navigation-audit.md` draws the line: anything ADDRESSABLE goes in the URL, and a channel is
* only for a signal. Nobody wants to link to "the moment after the connection was saved", and there is
* nothing to select — this is the second kind.
*
* ── The nonce is a plain counter, and that is not an accident ──
*
* Copied deliberately from `plugins/proxy-manager/web/channel.ts`, which records why.
* `setSignal(n => n + 1)` is WRONG: the functional setter applies against the value captured at
* render, so two bumps inside one render window both compute `snapshot + 1` and the second silently
* overwrites the first — nobody re-reads. `Date.now()` has the same flaw in miniature, two bumps in
* one millisecond being one number. A module counter never reads React state, so it cannot be wrong
* however often it is called.
*/
const useFofosChangedChannel = defineChannel<number>('fofos-admin:changed', 0);
let nonce = 0;
export const useFofosChanged = () => {
const [signal, setSignal] = useFofosChangedChannel();
// The setter is a fresh closure every render and this `bump` goes into dependency lists, so it is
// read through a ref — stable identity without freezing the first render's setter.
const setRef = useRef(setSignal);
setRef.current = setSignal;
const bump = useCallback(() => setRef.current(++nonce), []);
return [signal, bump] as const;
};
+31
View File
@@ -0,0 +1,31 @@
/**
* Turning a failed request into a sentence somebody can act on.
*
* The three that matter are genuinely different problems and send you to different places: nothing is
* configured yet, fofos cannot be reached, or fofos refused the token. A panel that says "something
* went wrong" for all three is the reason people restart servers that were fine.
*/
export type ApiFailure = {
error?: string;
/** No connection stored. A setup step, not a fault. */
unconfigured?: boolean;
/** fofos never answered — down, wrong URL, no route to it. */
unreachable?: boolean;
};
/** What the client throws carries the parsed body on `.body` when there was one. */
export const failureOf = (error: unknown): ApiFailure => {
const body = (error as { body?: unknown })?.body;
if (body && typeof body === 'object') return body as ApiFailure;
return { error: error instanceof Error ? error.message : String(error) };
};
export const readableError = (error: unknown): string => {
const failure = failureOf(error);
if (failure.unconfigured) return 'No fofos connection is set up yet.';
if (failure.unreachable) return failure.error ?? 'fofos did not answer.';
return failure.error ?? 'That did not work, and the server did not say why.';
};
+21
View File
@@ -0,0 +1,21 @@
import type { LayoutNode } from 'officerdev';
/**
* A nav down the left, whatever it selects beside it.
*
* Two panels rather than three: `plugins/proxy-manager` puts a chat across the bottom, and this does
* not, because there is no fofos skill for an agent to use yet. A chat panel with nothing to tell the
* agent about the site would be a third of the screen spent on a general-purpose conversation that is
* better had in the chat screen. Worth adding on the day this plugin ships a `skills/` directory.
*
* The nav is the narrow one: nine rows of two short lines, and nothing in it grows.
*/
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'fofos-admin-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'fofos-admin-nav', appType: 'fofos-admin-nav' }, size: 28 },
{ node: { type: 'panel', id: 'fofos-admin-detail', appType: 'fofos-admin-detail' }, size: 72 },
],
};
+31
View File
@@ -0,0 +1,31 @@
import type { AppRegistryMeta } from 'officerdev';
import { List, PanelRight } from 'lucide-react';
import { NavPanel } from './NavPanel';
import { DetailPanel } from './DetailPanel';
/**
* Two panels: a nav, and whatever it selects.
*
* They are SIBLINGS that never speak to each other — the nav writes `?section=` and the detail panel
* reads it. That is `docs/navigation-audit.md`'s rule, and it is what makes every section linkable and
* survive a reload.
*
* `availableOnPanel: false` — these belong to this plugin's screen, not to the generic panel picker.
* A "Fofos sections" panel offered on somebody's dashboard would render a nav with nothing beside it.
*/
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'fofos-admin-nav',
name: 'Sections',
icon: List,
component: NavPanel,
availableOnPanel: false,
},
{
key: 'fofos-admin-detail',
name: 'Detail',
icon: PanelRight,
component: DetailPanel,
availableOnPanel: false,
},
];
+126
View File
@@ -0,0 +1,126 @@
import type { LucideIcon } from 'lucide-react';
import { Home, Info, Layers, Images, Handshake, Sparkles, Wrench, Settings, Mail } from 'lucide-react';
/**
* The sections of the fofos admin, in nav order.
*
* ── The order is the client's site, not the database's ──
*
* It follows the sitemap: the pages someone edits most, first. Site settings and Messages sit at the
* bottom because they are not pages — one is configuration and the other is an inbox.
*
* ── `built` ──
*
* The panels arrive one section at a time, each verified against the live site before the next is
* started. A section that is not built yet says so, plainly, with what it will do — which is a better
* answer than a nav that hides most of itself and gives no sense of what is coming.
*
* It mirrors `built` in fofos's own `src/apps/web/site-map.ts`, and for the same reason: one list, so
* the nav and what actually renders cannot disagree.
*/
export type SectionKey =
| 'messages'
| 'site'
| 'about'
| 'home'
| 'gallery'
| 'drops'
| 'collabs'
| 'collections'
| 'services';
export type Section = {
key: SectionKey;
label: string;
icon: LucideIcon;
/** One line under the label in the nav — what this section is, for somebody who has not opened it. */
hint: string;
/** The fofos path this reads, relative to the plugin's mount. Null when nothing is built. */
path: string | null;
built: boolean;
};
export const SECTIONS: Section[] = [
{
key: 'home',
label: 'Home',
icon: Home,
hint: 'The hero, the intro, and the gallery',
path: '/fofos-admin/home',
built: false,
},
{
key: 'about',
label: 'About Us',
icon: Info,
hint: 'The header photograph and the copy',
path: '/fofos-admin/about',
built: false,
},
{
key: 'collections',
label: 'Collections',
icon: Layers,
hint: 'Collections, and the items in them',
path: '/fofos-admin/collections',
built: false,
},
{
key: 'gallery',
label: 'Gallery',
icon: Images,
hint: 'Standalone pieces',
path: '/fofos-admin/gallery',
built: false,
},
{
key: 'collabs',
label: 'Collabs',
icon: Handshake,
hint: 'Projects with other people',
path: '/fofos-admin/collabs',
built: false,
},
{
key: 'drops',
label: 'Drops',
icon: Sparkles,
hint: 'Releases',
path: '/fofos-admin/drops',
built: false,
},
{
key: 'services',
label: 'Services',
icon: Wrench,
hint: 'A fixed tree — the words, not the shape',
path: '/fofos-admin/services',
built: false,
},
{
key: 'site',
label: 'Site settings',
icon: Settings,
hint: 'Partners, social links, contact details',
path: '/fofos-admin/site',
built: false,
},
{
key: 'messages',
label: 'Messages',
icon: Mail,
hint: 'What people sent through the contact form',
path: '/fofos-admin/messages',
built: true,
},
];
/** Where the detail panel goes when the URL says nothing, or says something it does not recognise. */
export const DEFAULT_SECTION: SectionKey = 'messages';
export const isSectionKey = (value: string | null): value is SectionKey =>
SECTIONS.some((section) => section.key === value);
export const sectionFor = (key: SectionKey): Section =>
SECTIONS.find((section) => section.key === key) ?? SECTIONS[SECTIONS.length - 1]!;