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,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
import type { BotStats, Timers } from '../../api/contracts.js';
@@ -17,6 +17,27 @@ function formatTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(timestamp);
}
function formatMetricValue(value: number): string {
return value.toLocaleString();
}
function formatCountdown(remainingMs: number): string {
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1_000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
}
/** Ticks a client clock so time-derived UI flips without a refetch. */
function useNow(intervalMs = 1_000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = globalThis.setInterval(() => setNow(Date.now()), intervalMs);
return () => globalThis.clearInterval(timer);
}, [intervalMs]);
return now;
}
function MetricCard({ label, value, detail, metric }: { label: string; value: string | number; detail: string; metric: string }) {
return (
<article className="metric-card">
@@ -28,7 +49,8 @@ function MetricCard({ label, value, detail, metric }: { label: string; value: st
}
function TimersPanel({ timers }: { timers: Timers }) {
const isReady = timers.nextMessageAvailableAt === null || timers.nextMessageAvailableAt <= Date.now();
const now = useNow();
const isReady = timers.nextMessageAvailableAt === null || timers.nextMessageAvailableAt <= now;
return (
<section aria-labelledby="timers-heading" className="panel">
<div className="panel-heading">
@@ -41,7 +63,9 @@ function TimersPanel({ timers }: { timers: Timers }) {
<dl className="definition-list">
<div>
<dt>Next message</dt>
<dd>{isReady ? 'Available now' : formatTime(timers.nextMessageAvailableAt ?? Date.now())}</dd>
<dd>{isReady
? 'Available now'
: `Ready in ${formatCountdown((timers.nextMessageAvailableAt ?? now) - now)} (${formatTime(timers.nextMessageAvailableAt ?? now)})`}</dd>
</div>
<div>
<dt>Hourly allowance</dt>
@@ -129,8 +153,8 @@ function ActivityPanel({ stats }: { stats: BotStats }) {
</div>
) : (
<ol className="activity-list">
{stats.recentActivity.map((item) => (
<li key={`${item.timestamp}-${item.message}`}>
{stats.recentActivity.map((item, index) => (
<li key={`${item.timestamp}-${index}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<div><strong>{item.postUrl && item.postTitle ? (
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
@@ -168,6 +192,15 @@ export function OverviewPage() {
}
if (statsQuery.isPending) {
if (statsQuery.isPaused) {
return (
<div className="page-state error-state" role="alert">
<strong>You appear to be offline</strong>
<span>The overview will load once the connection returns.</span>
<button className="button secondary" onClick={() => void statsQuery.refetch()} type="button">Retry overview</button>
</div>
);
}
return <div aria-label="Loading overview" className="page-state" role="status"><span className="spinner" />Loading overview…</div>;
}
@@ -194,6 +227,7 @@ export function OverviewPage() {
</div>
<div className="header-actions">
<button
aria-busy={isRefreshing ? 'true' : undefined}
aria-label={isRefreshing ? 'Refreshing overview' : 'Refresh overview'}
className="button secondary"
disabled={isRefreshing}
@@ -203,7 +237,6 @@ export function OverviewPage() {
{isRefreshing ? 'Refreshing…' : 'Refresh'}
</button>
<button
aria-label={`${action === 'start' ? 'Start' : 'Stop'} bot`}
className={action === 'start' ? 'button primary' : 'button danger'}
onClick={() => setConfirmationAction(action)}
type="button"
@@ -232,10 +265,10 @@ export function OverviewPage() {
) : null}
<section aria-label="Key metrics" className="metrics-grid">
<MetricCard detail="All recorded sends" label="Messages sent" metric="messages-sent" value={stats.totalMessagesSent} />
<MetricCard detail={`${stats.responseRate}% response rate`} label="Responses" metric="responses" value={stats.totalResponses} />
<MetricCard detail="Posts evaluated" label="Processed" metric="processed" value={stats.totalProcessed} />
<MetricCard detail={`Limit ${stats.maxMessagesPerHour}`} label="This hour" metric="messages-hour" value={stats.messagesThisHour} />
<MetricCard detail="All recorded sends" label="Messages sent" metric="messages-sent" value={formatMetricValue(stats.totalMessagesSent)} />
<MetricCard detail={`${stats.responseRate}% response rate`} label="Responses" metric="responses" value={formatMetricValue(stats.totalResponses)} />
<MetricCard detail="Posts evaluated" label="Processed" metric="processed" value={formatMetricValue(stats.totalProcessed)} />
<MetricCard detail={`Limit ${formatMetricValue(stats.maxMessagesPerHour)}`} label="This hour" metric="messages-hour" value={formatMetricValue(stats.messagesThisHour)} />
</section>
<AccountStatusPanel accounts={stats.accounts} />
@@ -256,12 +289,12 @@ export function OverviewPage() {
</div>
<RecentMessagesPanel />
{controlMutation.isError ? <div className="toast error" role="alert">{errorMessage(controlMutation.error)}</div> : null}
{confirmationAction ? (
<ConfirmationDialog
action={confirmationAction}
errorMessage={controlMutation.isError ? errorMessage(controlMutation.error) : null}
isPending={controlMutation.isPending}
onCancel={() => setConfirmationAction(null)}
onCancel={() => { setConfirmationAction(null); controlMutation.reset(); }}
onConfirm={confirmControl}
/>
) : null}