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,6 +1,7 @@
import { useEffect, useReducer, useRef, useState } from 'react';
import type { ActivityItem, ChatSyncStatus, DashboardEvent, RedditAccountOwner } from '../../../../../packages/api-contracts/src/index.js';
import { useDashboardRealtime } from '../../app/realtime-provider.js';
import { Modal } from '../../components/modal.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { RedditUserText } from '../../components/reddit-user-text.js';
import { OriginPostButton } from '../../components/origin-post.js';
@@ -28,8 +29,8 @@ function ActivityView({ items }: { items: readonly ActivityItem[] }) {
<p className="feature-empty">No recent operator activity.</p>
) : (
<ol className="operations-activity-list">
{items.map((item) => (
<li key={`${item.timestamp}-${item.message}`}>
{items.map((item, index) => (
<li key={`${item.timestamp}-${index}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<AccountOwnerBadge owner={item.owner} />
<div><strong>{item.postUrl && item.postTitle ? (
@@ -86,6 +87,40 @@ export interface OperationsPageProps {
realtimeEnabled?: boolean;
}
const NOTICE_DISMISS_MS = 5_000;
const expensiveSyncCopy = {
full: {
title: 'Run a full cache sync?',
body: 'This rebuilds the entire chat cache and can take significantly longer than a delta sync.',
confirmLabel: 'Run full sync',
},
'fetch-all': {
title: 'Fetch every chat now?',
body: 'This is a legacy blocking operation that fetches every chat and may take a while.',
confirmLabel: 'Start fetching',
},
} as const;
function ConfirmSyncDialog({ kind, onCancel, onConfirm }: {
kind: 'full' | 'fetch-all';
onCancel(): void;
onConfirm(): void;
}) {
const copy = expensiveSyncCopy[kind];
return (
<Modal className="dialog-card" labelledBy="confirm-sync-title" onClose={onCancel}>
<span className="eyebrow">Confirm action</span>
<h2 id="confirm-sync-title">{copy.title}</h2>
<p>{copy.body}</p>
<div className="dialog-actions">
<button autoFocus className="button secondary" onClick={onCancel} type="button">Cancel</button>
<button className="button primary" onClick={onConfirm} type="button">{copy.confirmLabel}</button>
</div>
</Modal>
);
}
export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) {
const realtime = useDashboardRealtime();
const activityQuery = useActivityQuery();
@@ -94,12 +129,16 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
const syncMutation = useTriggerChatSyncMutation();
const fetchAllMutation = useFetchAllChatsMutation();
const [consoleClearNotice, setConsoleClearNotice] = useState(false);
const [pendingSyncConfirm, setPendingSyncConfirm] = useState<'full' | 'fetch-all' | null>(null);
const [pauseAnnouncement, setPauseAnnouncement] = useState('');
const [isPinnedToBottom, setIsPinnedToBottom] = useState(true);
const [consoleState, dispatchConsole] = useReducer(
reduceLiveConsoleState,
createLiveConsoleState(500),
);
const connection = realtimeEnabled ? realtime.connection : { status: 'idle', isStale: true } as const;
const consoleEnd = useRef<HTMLDivElement>(null);
const consoleViewport = useRef<HTMLDivElement>(null);
useEffect(() => {
logsQuery.data?.forEach((entry, index) => {
@@ -136,22 +175,54 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
const visibleEntries = selectVisibleConsoleEntries(consoleState);
const syncBusy = syncQuery.data?.syncing === true || syncMutation.isPending || fetchAllMutation.isPending;
function confirmAndRun(kind: 'full' | 'fetch-all') {
const confirmed = globalThis.confirm(kind === 'full'
? 'Run a full chat cache sync? This can take significantly longer than a delta sync.'
: 'Fetch every chat now? This is a legacy blocking operation and may take a while.');
if (!confirmed) return;
function runExpensiveSync(kind: 'full' | 'fetch-all') {
setPendingSyncConfirm(null);
if (kind === 'full') syncMutation.mutate('full'); else fetchAllMutation.mutate();
}
const resetSyncMutation = syncMutation.reset;
const resetFetchAllMutation = fetchAllMutation.reset;
useEffect(() => {
if (!syncMutation.isSuccess) return;
const timer = globalThis.setTimeout(() => resetSyncMutation(), NOTICE_DISMISS_MS);
return () => globalThis.clearTimeout(timer);
}, [resetSyncMutation, syncMutation.isSuccess]);
useEffect(() => {
if (!fetchAllMutation.isSuccess) return;
const timer = globalThis.setTimeout(() => resetFetchAllMutation(), NOTICE_DISMISS_MS);
return () => globalThis.clearTimeout(timer);
}, [fetchAllMutation.isSuccess, resetFetchAllMutation]);
useEffect(() => {
if (!consoleClearNotice) return;
const timer = globalThis.setTimeout(() => setConsoleClearNotice(false), NOTICE_DISMISS_MS);
return () => globalThis.clearTimeout(timer);
}, [consoleClearNotice]);
function handleConsoleScroll() {
const viewport = consoleViewport.current;
if (!viewport) return;
const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= 16;
setIsPinnedToBottom(atBottom);
}
function jumpToLatest() {
setIsPinnedToBottom(true);
consoleEnd.current?.scrollIntoView({ block: 'nearest' });
}
useEffect(() => {
if (
consoleState.isAutoScrollEnabled &&
isPinnedToBottom &&
!consoleState.isPaused &&
typeof consoleEnd.current?.scrollIntoView === 'function'
) {
consoleEnd.current.scrollIntoView({ block: 'nearest' });
}
}, [consoleState.entries, consoleState.isAutoScrollEnabled, consoleState.isPaused]);
}, [consoleState.entries, consoleState.isAutoScrollEnabled, consoleState.isPaused, isPinnedToBottom]);
return (
<div className="page operations-page">
@@ -167,7 +238,7 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
</section>
<section aria-labelledby="sync-jobs-heading" className="panel feature-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Background work</span><h2 id="sync-jobs-heading">Sync jobs</h2></div></div>
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <><SyncSummary status={syncQuery.data} /><div className="header-actions"><button className="button secondary" disabled={syncBusy} onClick={() => syncMutation.mutate('delta')} type="button">Sync latest chats</button><button className="button secondary" disabled={syncBusy} onClick={() => confirmAndRun('full')} type="button">Full cache sync</button><button className="button secondary" disabled={syncBusy} onClick={() => confirmAndRun('fetch-all')} type="button">Fetch all chats</button></div></>}
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <><SyncSummary status={syncQuery.data} /><div className="header-actions"><button className="button secondary" disabled={syncBusy} onClick={() => syncMutation.mutate('delta')} type="button">Sync latest chats</button><button className="button secondary" disabled={syncBusy} onClick={() => setPendingSyncConfirm('full')} type="button">Full cache sync</button><button className="button secondary" disabled={syncBusy} onClick={() => setPendingSyncConfirm('fetch-all')} type="button">Fetch all chats</button></div></>}
{syncMutation.isSuccess ? <p role="status">{syncMutation.data.kind === 'delta' ? 'Delta' : 'Full'} sync started. Progress is shown from server status.</p> : null}
{fetchAllMutation.isSuccess ? <p role="status">Fetched {fetchAllMutation.data.count} chats.</p> : null}
{syncMutation.isError ? <p role="alert">{syncMutation.error.message}</p> : null}
@@ -175,16 +246,28 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
</section>
</div>
<section aria-labelledby="live-console-heading" className="panel console-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Raw diagnostics</span><h2 id="live-console-heading">Live console</h2></div><span className="console-count">{consoleState.entries.length} retained</span></div>
<div className="panel-heading"><div><span className="eyebrow">Raw diagnostics</span><h2 id="live-console-heading">Live console</h2></div><span className="console-count">{visibleEntries.length === consoleState.entries.length
? `${consoleState.entries.length} retained`
: `${visibleEntries.length} shown · ${consoleState.entries.length} retained`}</span></div>
<div className="console-controls">
<button aria-pressed={consoleState.isPaused} className="button secondary" onClick={() => dispatchConsole({ type: 'set-paused', paused: !consoleState.isPaused })} type="button">{consoleState.isPaused ? `Resume console (${consoleState.pendingEntries.length})` : 'Pause console'}</button>
<button
aria-pressed={consoleState.isPaused}
className="button secondary"
onClick={() => {
const paused = !consoleState.isPaused;
dispatchConsole({ type: 'set-paused', paused });
setPauseAnnouncement(paused ? 'Console paused.' : 'Console resumed.');
}}
type="button"
>{consoleState.isPaused ? `Resume console (${consoleState.pendingEntries.length})` : 'Pause console'}</button>
<button className="button secondary" onClick={() => { dispatchConsole({ type: 'clear' }); setConsoleClearNotice(true); }} type="button">Clear local console</button>
<label>Log level<select aria-label="Log level" onChange={(event) => dispatchConsole({ type: 'set-level', level: event.target.value as ConsoleLevelFilter })} value={consoleState.level}><option value="all">All levels</option><option value="debug">Debug</option><option value="info">Info</option><option value="warn">Warning</option><option value="error">Error</option></select></label>
<label>Search logs<input aria-label="Search logs" onChange={(event) => dispatchConsole({ type: 'set-search', search: event.target.value })} placeholder="Message or metadata" type="search" value={consoleState.search} /></label>
<label className="checkbox-control"><input checked={consoleState.isAutoScrollEnabled} onChange={(event) => dispatchConsole({ type: 'set-auto-scroll', enabled: event.target.checked })} type="checkbox" />Auto-scroll</label>
</div>
{consoleClearNotice ? <p role="status">Cleared from this browser view. Server logs were not deleted.</p> : null}
<div aria-live="polite" className="live-console" role="log">
<p aria-live="polite" className="sr-only" role="status">{pauseAnnouncement}</p>
<div className="live-console" onScroll={handleConsoleScroll} ref={consoleViewport} role="log">
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => {
const owner = accountOwnerFromLogData(entry.data);
return (
@@ -198,7 +281,17 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
})}
<div ref={consoleEnd} />
</div>
{!isPinnedToBottom ? (
<button className="button secondary console-jump" onClick={jumpToLatest} type="button">Jump to latest</button>
) : null}
</section>
{pendingSyncConfirm ? (
<ConfirmSyncDialog
kind={pendingSyncConfirm}
onCancel={() => setPendingSyncConfirm(null)}
onConfirm={() => runExpensiveSync(pendingSyncConfirm)}
/>
) : null}
</div>
);
}