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:
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}
|
||||
|
||||
Reference in new issue
Block a user