feat: dashboard UI/UX overhaul from full review

Shared foundation:
- New Modal component on native <dialog>.showModal() (focus trap, inert
  background, Escape, focus restore, scroll lock); all eight dialogs and
  both globalThis.confirm calls migrated to it
- Design tokens for success/warning/danger surfaces; pill danger variant;
  .sr-only utility; toast layering above dialog backdrops; not-allowed
  cursor for validity-disabled buttons

Conversations/attention: auto-scroll with jump-to-latest, per-conversation
draft persistence, suggestion undo, 30s auto-refresh with refresh controls,
unread emphasis and counts, blur-consistent share dialog, empty-accounts
guidance, collapsed user lookup, relative timestamps, danger pills for
critical items

Media/library/outreach: click-blurred-to-reveal, lightbox with arrow
navigation and captions, dirty-form preservation across refetches, upload
validation with thumbnail preview, save feedback, result counts with
load-more and clear-filters, ticking countdowns from a single clock

Overview/performance/operations/settings: mutation errors inside the
confirm dialog, live sending-window countdown, offline-aware loading
states, per-panel loading/error with retry, 7/30/90-day range toggle,
console scroll yielding with shown/retained counts, risky-save matching
by subreddit name instead of index, useBlocker unsaved-changes guard,
single-pass validation with focus-to-first-error, access panel JSON
validation and input reset

Tests: 806 -> 844 passing; both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Fable 5 committed 2026-08-10 09:07:42 +01:00
1 parent 82be197b79
commit e984bf20ac
46 files changed
+2462 -495

No files matched your search

@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
@@ -18,8 +19,14 @@ const STAGES: ReadonlyArray<{
{ status: 'retryable', label: 'Retrying', description: 'Cooling down' },
];
const KNOWN_STATUSES = new Set<string>(STAGES.map((stage) => stage.status));
function statusLabel(status: OutreachCandidateStatus): string {
return STAGES.find((stage) => stage.status === status)?.label ?? status;
const stage = STAGES.find((candidate) => candidate.status === status);
if (stage) return stage.label;
// Unknown statuses fall back to a readable neutral label instead of the raw enum.
const readable = String(status).replaceAll(/[-_]/g, ' ');
return readable.charAt(0).toUpperCase() + readable.slice(1);
}
function formatWaitTime(timestamp: number, now: number): string {
@@ -32,13 +39,14 @@ function formatWaitTime(timestamp: number, now: number): string {
function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) {
const waitingUntil = candidate.availableAt > now;
const isKnownStatus = KNOWN_STATUSES.has(candidate.status);
return (
<li className={`outreach-candidate is-${candidate.status}`}>
<li className={`outreach-candidate ${isKnownStatus ? `is-${candidate.status}` : 'is-unknown'}`}>
<div className="candidate-status-rail" aria-hidden="true"><span /></div>
<article>
<div className="candidate-heading">
<div className="candidate-labels">
<span className={`candidate-status status-${candidate.status}`}>{statusLabel(candidate.status)}</span>
<span className={`candidate-status ${isKnownStatus ? `status-${candidate.status}` : 'status-neutral'}`}>{statusLabel(candidate.status)}</span>
<span>r/{candidate.subreddit}</span>
<span>{candidate.sourceType === 'image' ? 'Image post' : 'Chat post'}</span>
</div>
@@ -46,7 +54,10 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
queued {formatShortRelativeTime(candidate.discoveredAt, now)}
</time>
</div>
<a className="candidate-title" href={candidate.postUrl} rel="noreferrer" target="_blank">{candidate.title}</a>
<a className="candidate-title" href={candidate.postUrl} rel="noreferrer" target="_blank">
{candidate.title} <span aria-hidden="true">↗</span>
</a>
<span className="sr-only">(opens Reddit in a new tab)</span>
<div className="candidate-meta">
<RedditUsernameLink username={candidate.author} />
<span>{candidate.hasPreparedMessage ? 'Message prepared' : 'Not prepared yet'}</span>
@@ -66,16 +77,49 @@ export function OutreachPage() {
queryFn: outreachApi.getFunnel,
refetchInterval: 10_000,
});
if (query.isPending) return <div className="page-state" role="status">Loading outreach funnel…</div>;
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Outreach funnel unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
// Single client-side clock for every displayed offset: retry countdowns tick
// every second while visible, everything else every 10 seconds.
const [now, setNow] = useState(() => Date.now());
const hasVisibleCountdown = (query.data?.candidates ?? []).some((candidate) => candidate.availableAt > now);
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), hasVisibleCountdown ? 1_000 : 10_000);
return () => clearInterval(interval);
}, [hasVisibleCountdown]);
if (query.isPending) return <div className="page-state" role="status"><span className="spinner" />Loading outreach funnel…</div>;
if (query.isError) {
return (
<div className="page-state error-state" role="alert">
<strong>Outreach funnel unavailable</strong>
<span>{query.error.message}</span>
<button
aria-busy={query.isFetching || undefined}
className="button secondary"
disabled={query.isFetching}
onClick={() => void query.refetch()}
type="button"
>
{query.isFetching ? 'Refreshing…' : 'Retry'}
</button>
</div>
);
}
const { data } = query;
const now = data.generatedAt;
return (
<div className="page outreach-page">
<header className="page-header">
<div><span className="eyebrow">Live candidate queue</span><h1>Outreach funnel</h1><p>Follow every potential post from discovery to delivery, and see exactly where the remaining queue is waiting.</p></div>
<button className="button secondary" disabled={query.isFetching} onClick={() => void query.refetch()} type="button">{query.isFetching ? 'Refreshing…' : 'Refresh'}</button>
<button
aria-busy={query.isFetching || undefined}
className="button secondary"
disabled={query.isFetching}
onClick={() => void query.refetch()}
type="button"
>
{query.isFetching ? 'Refreshing…' : 'Refresh'}
</button>
</header>
<section aria-labelledby="active-funnel-heading" className="outreach-runway">
@@ -96,7 +140,13 @@ export function OutreachPage() {
</section>
<section aria-labelledby="active-candidates-heading" className="candidate-section">
<div className="panel-heading"><div><span className="eyebrow">Potential posts</span><h2 id="active-candidates-heading">Active queue</h2></div><span className="candidate-updated">{data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''}Updated {formatShortRelativeTime(data.generatedAt, Date.now())}</span></div>
<div className="panel-heading">
<div><span className="eyebrow">Potential posts</span><h2 id="active-candidates-heading">Active queue</h2></div>
<span className="candidate-updated">
{data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''}
Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s
</span>
</div>
{data.candidates.length === 0 ? (
<div className="empty-state panel"><strong>The active queue is clear.</strong><span>New potential posts will appear here as discovery finds them.</span></div>
) : (