Files
fofos-admin/web/MessagesPanel.tsx
T
PastilhasandClaude Opus 5 6b87956e44 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>
2026-08-28 23:05:34 +01:00

144 lines
5.6 KiB
TypeScript

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>
);
};