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