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>
147 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
};
|