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