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
@@ -2,15 +2,31 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { AttentionItem } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
|
||||
import { attentionApi } from './attention-api.js';
|
||||
import './attention.css';
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
||||
function relativeTime(timestamp: number, now: number) {
|
||||
return (
|
||||
<time dateTime={new Date(timestamp).toISOString()} title={formatExactTime(timestamp)}>
|
||||
{formatShortRelativeTime(timestamp, now)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
function actionHint(kind: AttentionItem['kind']): string {
|
||||
switch (kind) {
|
||||
case 'account-login-required':
|
||||
return 'No linked conversation — check Settings.';
|
||||
case 'runtime-failed':
|
||||
case 'invitation-cooldown':
|
||||
default:
|
||||
return 'No linked conversation — check Operations.';
|
||||
}
|
||||
}
|
||||
|
||||
function itemAction(item: AttentionItem) {
|
||||
if (!item.conversationId) return null;
|
||||
if (!item.conversationId) return <span className="attention-item-hint">{actionHint(item.kind)}</span>;
|
||||
return <Link className="button secondary" to={`/conversations/${encodeURIComponent(item.conversationId)}`}>Open conversation</Link>;
|
||||
}
|
||||
|
||||
@@ -18,14 +34,51 @@ export function AttentionPage() {
|
||||
const query = useQuery({ queryKey: ['attention'], queryFn: attentionApi.getAttention, refetchInterval: 30_000 });
|
||||
if (query.isPending) return <div className="page-state" role="status">Loading attention inbox…</div>;
|
||||
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Attention inbox unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
|
||||
const now = Date.now();
|
||||
const critical = query.data.items.filter((item) => item.severity === 'critical').length;
|
||||
return (
|
||||
<div className="page attention-page">
|
||||
<header className="page-header"><div><span className="eyebrow">Operator queue</span><h1>Needs attention</h1><p>Boundaries, human handoffs, delivery failures, account problems, and Reddit cooldowns in one actionable inbox.</p></div><button className="button secondary" onClick={() => void query.refetch()} type="button">Refresh</button></header>
|
||||
<section aria-label="Attention totals" className="status-strip"><div><span className={`status-dot ${query.data.items.length === 0 ? 'running' : 'stopped'}`} aria-hidden="true" /><div><small>Open items</small><strong>{query.data.items.length}</strong></div></div><span className={critical > 0 ? 'pill warning' : 'pill success'}>{critical} critical</span><span className="status-note">Updated {formatTime(query.data.generatedAt)}</span></section>
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Operator queue</span>
|
||||
<h1>Needs attention</h1>
|
||||
<p>Boundaries, human handoffs, delivery failures, account problems, and Reddit cooldowns in one actionable inbox.</p>
|
||||
</div>
|
||||
<div className="attention-refresh">
|
||||
<button
|
||||
aria-busy={query.isFetching || undefined}
|
||||
className="button secondary"
|
||||
disabled={query.isFetching}
|
||||
onClick={() => void query.refetch()}
|
||||
type="button"
|
||||
>
|
||||
{query.isFetching ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
<span className="attention-refresh-note">Auto-refreshes every 30s</span>
|
||||
</div>
|
||||
</header>
|
||||
<section aria-label="Attention totals" className="status-strip">
|
||||
<div>
|
||||
<span aria-hidden="true" className={`attention-status-dot ${query.data.items.length === 0 ? 'is-clear' : 'is-open'}`} />
|
||||
<div><small>Open items</small><strong>{query.data.items.length}</strong></div>
|
||||
</div>
|
||||
<span className={critical > 0 ? 'pill danger' : 'pill success'}>{critical} critical</span>
|
||||
<span className="status-note">Updated {relativeTime(query.data.generatedAt, now)}</span>
|
||||
</section>
|
||||
{query.data.items.length === 0 ? <div className="empty-state panel"><strong>Nothing needs attention.</strong><span>Automation and account health have no outstanding operator items.</span></div> : (
|
||||
<ol aria-label="Attention items" className="attention-list">{query.data.items.map((item) => (
|
||||
<li className={`attention-item is-${item.severity}`} key={item.id}><div className="attention-item-heading"><div><span className={`pill ${item.severity === 'critical' ? 'warning' : 'neutral'}`}>{item.kind.replaceAll('-', ' ')}</span><h2>{item.title}</h2></div><time dateTime={new Date(item.occurredAt).toISOString()}>{formatTime(item.occurredAt)}</time></div><p>{item.detail}</p>{item.owner ? <AccountOwnerBadge owner={item.owner} /> : null}{itemAction(item)}</li>
|
||||
<li className={`attention-item is-${item.severity}`} key={item.id}>
|
||||
<div className="attention-item-heading">
|
||||
<div>
|
||||
<span className={`pill ${item.severity === 'critical' ? 'danger' : 'warning'}`}>{item.kind.replaceAll('-', ' ')}</span>
|
||||
<h2>{item.title}</h2>
|
||||
</div>
|
||||
{relativeTime(item.occurredAt, now)}
|
||||
</div>
|
||||
<p>{item.detail}</p>
|
||||
{item.owner ? <AccountOwnerBadge owner={item.owner} /> : null}
|
||||
{itemAction(item)}
|
||||
</li>
|
||||
))}</ol>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,3 +6,9 @@
|
||||
.attention-item-heading time { color: var(--muted); font-size: .8rem; text-align: right; }
|
||||
.attention-item p { margin: 0; color: var(--muted); }
|
||||
.attention-item .button { justify-self: start; text-decoration: none; }
|
||||
.attention-item-hint { justify-self: start; color: var(--muted); font-size: .82rem; font-style: italic; }
|
||||
.attention-refresh { display: grid; gap: .3rem; justify-items: end; }
|
||||
.attention-refresh-note { color: var(--muted); font-size: .72rem; }
|
||||
.attention-status-dot { width: .7rem; height: .7rem; border-radius: 50%; }
|
||||
.attention-status-dot.is-clear { background: var(--success-text); box-shadow: 0 0 0 .35rem rgba(170, 240, 196, .12); }
|
||||
.attention-status-dot.is-open { background: var(--danger); box-shadow: 0 0 0 .35rem rgba(255, 138, 132, .1); }
|
||||
Reference in new issue
Block a user