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 { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { ConversationDetail } from './conversation-detail.js';
@@ -12,6 +12,8 @@ import { formatExactTime, formatShortRelativeTime } from './relative-time.js';
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply' | 'received-images';
const SMALL_VIEWPORT_QUERY = '(max-width: 51.9999rem)';
export interface ConversationFilters {
search: string;
filter: ConversationFilter;
@@ -39,10 +41,14 @@ function ConversationListItem({ conversation, isSelected, now, onSelect }: {
onSelect(): void;
}) {
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
const relativeTime = formatShortRelativeTime(conversation.lastMessageTime, now);
const itemClasses = ['conversation-list-item'];
if (isSelected) itemClasses.push('is-selected');
if (conversation.unread) itemClasses.push('is-unread');
return (
<li className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}>
<li className={itemClasses.join(' ')}>
<button
aria-label={`${redditUsernameLabel(conversation.username)}, ${state}${conversation.sellerDetected ? ', Seller' : ''}${conversation.hasReceivedImages ? ', Image received' : ''}`}
aria-label={`${redditUsernameLabel(conversation.username)}, ${state}, ${relativeTime}${conversation.sellerDetected ? ', Seller' : ''}${conversation.hasReceivedImages ? ', Image received' : ''}`}
aria-pressed={isSelected}
className="conversation-list-target"
onClick={onSelect}
@@ -53,7 +59,7 @@ function ConversationListItem({ conversation, isSelected, now, onSelect }: {
<span className="conversation-list-meta">
<small>{state}</small>
<time dateTime={new Date(conversation.lastMessageTime).toISOString()} title={formatExactTime(conversation.lastMessageTime)}>
{formatShortRelativeTime(conversation.lastMessageTime, now)}
{relativeTime}
</time>
</span>
</span>
@@ -71,6 +77,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
const [localFilters, setLocalFilters] = useState<ConversationFilters>({ search: '', filter: 'all', showSellers: false });
const [isStartingConversation, setIsStartingConversation] = useState(false);
const [now, setNow] = useState(() => Date.now());
const detailPanelRef = useRef<HTMLElement>(null);
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
const activeFilters = filters ?? localFilters;
@@ -79,6 +86,15 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
return () => window.clearInterval(interval);
}, []);
// On small viewports the detail panel sits below the list; bring it into
// view when a conversation is picked so the selection isn't invisible.
useEffect(() => {
if (!activeConversationId) return;
if (typeof window.matchMedia !== 'function') return;
if (!window.matchMedia(SMALL_VIEWPORT_QUERY).matches) return;
detailPanelRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
}, [activeConversationId]);
function updateFilters(next: ConversationFilters) {
if (filters === undefined) setLocalFilters(next);
onFiltersChange?.(next);
@@ -98,6 +114,11 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
.sort((left, right) => right.lastMessageTime - left.lastMessageTime);
}, [activeFilters, conversationsQuery.data]);
const unreadCount = useMemo(
() => (conversationsQuery.data ?? []).filter((conversation) => conversation.unread).length,
[conversationsQuery.data],
);
function selectConversation(id: string | null) {
if (selectedConversationId === undefined) setInternalSelectedId(id);
onSelectedConversationChange?.(id);
@@ -111,9 +132,22 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
<h1>Conversations</h1>
<p>Review replies, draft carefully, and send only after confirmation.</p>
</div>
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
</button>
<div className="conversations-header-actions">
<div className="conversation-refresh">
<button
aria-busy={conversationsQuery.isFetching || undefined}
disabled={conversationsQuery.isFetching}
onClick={() => void conversationsQuery.refetch()}
type="button"
>
{conversationsQuery.isFetching ? 'Refreshing…' : 'Refresh'}
</button>
<span className="conversation-refresh-note">Auto-refreshes every 30s</span>
</div>
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
</button>
</div>
</header>
{isStartingConversation ? (
@@ -146,6 +180,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
<option value="awaiting-reply">Awaiting reply</option>
<option value="received-images">Received images</option>
</select>
<span className="conversation-unread-count">{unreadCount} unread</span>
<label className="conversation-seller-toggle">
<input
checked={activeFilters.showSellers}
@@ -182,7 +217,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
)}
</section>
<section aria-label="Selected conversation" className="conversation-detail-panel">
<section aria-label="Selected conversation" className="conversation-detail-panel" ref={detailPanelRef}>
{activeConversationId ? (
<ConversationDetail conversationId={activeConversationId} key={activeConversationId} onClose={() => selectConversation(null)} />
) : (