Files
fofos-admin/web/MessagesPanel.tsx
pastilhasandClaude Opus 5 9fd5ffd925 the detail panels scroll
Four sections could not be scrolled: About, and Gallery, Drops and Collabs, which are three
configurations of one panel. Both of those panels were written yesterday with `overflow-auto` and no
height, so they grew to fit their content and ran off the bottom of the workspace instead of scrolling
inside it.

`overflow-auto` only does something on an element with a height to overflow. DetailPanel gives its
child `min-h-0 flex-1` inside a `size-full` column, so a panel that says `size-full` fills exactly that
box and scrolls within it — which is what Home, Galleries and Administration were already doing and
what these two were copied from without.

Messages had the same fault in a different shape. Its list is a flex child with `flex-1 overflow-auto`,
and a flex child defaults to `min-height: auto` — it refuses to shrink below its content, so the list
grew the panel rather than scrolling. `min-h-0` is what lets it shrink, and therefore what makes the
overflow mean anything.

Audited the rest rather than fixing the reported ones: every panel with an `overflow-auto` now pairs it
with a height, and the two that have none are centred single screens with nothing to scroll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 22:37:23 +01:00

147 lines
5.9 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>
) : (
/* `min-h-0` is what makes this actually scroll. A flex child defaults to `min-height: auto`,
which refuses to shrink below its content — so `flex-1 overflow-auto` alone grows the list
and pushes it past the panel instead of scrolling inside it. */
<ul className="min-h-0 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>
);
};