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>
296 lines
12 KiB
TypeScript
296 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { filterMedia, mediaAlt, mediaConversation, type MediaFilters } from './filters.js';
|
|
import { FullSyncDialog } from './full-sync-dialog.js';
|
|
import { MediaCard } from './media-card.js';
|
|
import { MediaPreviewDialog, type MediaPreviewEntry } from './media-preview-dialog.js';
|
|
import { useMediaQuery, useMediaSyncStatusQuery, useTriggerMediaSyncMutation } from './queries.js';
|
|
import './media.css';
|
|
|
|
export const initialMediaFilters: MediaFilters = {
|
|
conversation: 'all',
|
|
sender: 'all',
|
|
type: 'all',
|
|
dateRange: 'all',
|
|
};
|
|
|
|
const PAGE_SIZE = 48;
|
|
const SUCCESS_NOTICE_MS = 5_000;
|
|
|
|
function errorMessage(error: Error | null): string {
|
|
return error?.message ?? 'Something went wrong.';
|
|
}
|
|
|
|
function formatLastSync(timestamp: number): string {
|
|
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
|
}
|
|
|
|
export interface MediaPageProps {
|
|
filters?: MediaFilters;
|
|
onFiltersChange?(filters: MediaFilters): void;
|
|
}
|
|
|
|
export function MediaPage({ filters: controlledFilters, onFiltersChange }: MediaPageProps) {
|
|
const mediaQuery = useMediaQuery();
|
|
const statusQuery = useMediaSyncStatusQuery();
|
|
const syncMutation = useTriggerMediaSyncMutation();
|
|
const [localFilters, setLocalFilters] = useState<MediaFilters>(initialMediaFilters);
|
|
const filters = controlledFilters ?? localFilters;
|
|
const [revealedIds, setRevealedIds] = useState<Set<string>>(() => new Set());
|
|
const [isRevealAll, setIsRevealAll] = useState(false);
|
|
const [isFullSyncDialogOpen, setIsFullSyncDialogOpen] = useState(false);
|
|
const [previewItemId, setPreviewItemId] = useState<string | null>(null);
|
|
const [visibleLimit, setVisibleLimit] = useState(PAGE_SIZE);
|
|
|
|
const items = mediaQuery.data ?? [];
|
|
const visibleItems = useMemo(() => filterMedia(items, filters, Date.now()), [items, filters]);
|
|
const displayedItems = visibleItems.slice(0, visibleLimit);
|
|
const conversations = useMemo(
|
|
() => [...new Set(items.map(mediaConversation))].sort((left, right) => left.localeCompare(right)),
|
|
[items],
|
|
);
|
|
const senders = useMemo(
|
|
() => [...new Set(items.map((item) => item.sender))].sort((left, right) => left.localeCompare(right)),
|
|
[items],
|
|
);
|
|
const isSyncBusy = statusQuery.data?.isSyncing === true || syncMutation.isPending;
|
|
const isFiltered = filters.conversation !== 'all' || filters.sender !== 'all' || filters.type !== 'all' || filters.dateRange !== 'all';
|
|
const previewIndex = previewItemId === null ? -1 : visibleItems.findIndex((item) => item.id === previewItemId);
|
|
const previewEntries: MediaPreviewEntry[] = useMemo(
|
|
() => visibleItems.map((item) => ({
|
|
id: item.id,
|
|
src: item.urlPath,
|
|
alt: mediaAlt(item),
|
|
label: `Media from ${item.sender}`,
|
|
captionTitle: `u/${item.sender}`,
|
|
captionSubtitle: formatLastSync(item.timestamp),
|
|
})),
|
|
[visibleItems],
|
|
);
|
|
|
|
const isSyncSuccess = syncMutation.isSuccess;
|
|
useEffect(() => {
|
|
if (!isSyncSuccess) return;
|
|
const timer = setTimeout(() => syncMutation.reset(), SUCCESS_NOTICE_MS);
|
|
return () => clearTimeout(timer);
|
|
// syncMutation is a fresh object every render; keying on isSuccess is intentional.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isSyncSuccess]);
|
|
|
|
function updateFilters(next: MediaFilters) {
|
|
if (controlledFilters === undefined) setLocalFilters(next);
|
|
onFiltersChange?.(next);
|
|
setVisibleLimit(PAGE_SIZE);
|
|
}
|
|
|
|
function updateFilter<Key extends keyof MediaFilters>(key: Key, value: MediaFilters[Key]) {
|
|
updateFilters({ ...filters, [key]: value });
|
|
}
|
|
|
|
function toggleItem(itemId: string) {
|
|
if (isRevealAll) {
|
|
setIsRevealAll(false);
|
|
setRevealedIds(new Set(items.filter((item) => item.id !== itemId).map((item) => item.id)));
|
|
return;
|
|
}
|
|
setRevealedIds((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(itemId)) next.delete(itemId);
|
|
else next.add(itemId);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function runSync(isFull: boolean) {
|
|
syncMutation.mutate(isFull, {
|
|
onSuccess: () => {
|
|
if (isFull) setIsFullSyncDialogOpen(false);
|
|
},
|
|
onError: () => {
|
|
if (isFull) setIsFullSyncDialogOpen(false);
|
|
},
|
|
});
|
|
}
|
|
|
|
if (mediaQuery.isPending) {
|
|
return <div aria-label="Loading media" className="page-state" role="status"><span className="spinner" />Loading media…</div>;
|
|
}
|
|
|
|
if (mediaQuery.isError) {
|
|
return (
|
|
<div className="page-state error-state" role="alert">
|
|
<strong>Media unavailable</strong>
|
|
<span>{errorMessage(mediaQuery.error)}</span>
|
|
<button
|
|
aria-busy={mediaQuery.isFetching || undefined}
|
|
className="button secondary"
|
|
disabled={mediaQuery.isFetching}
|
|
onClick={() => void mediaQuery.refetch()}
|
|
type="button"
|
|
>
|
|
{mediaQuery.isFetching ? 'Refreshing…' : 'Retry media'}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="page media-page">
|
|
<header className="page-header">
|
|
<div>
|
|
<span className="eyebrow">Private gallery</span>
|
|
<h1>Media</h1>
|
|
<p>Browse authenticated conversation media. Everything stays blurred until you choose to reveal it.</p>
|
|
</div>
|
|
<div className="header-actions">
|
|
{items.length > 0 ? (
|
|
<button
|
|
aria-pressed={isRevealAll}
|
|
className="button secondary"
|
|
onClick={() => setIsRevealAll((current) => !current)}
|
|
type="button"
|
|
>
|
|
{isRevealAll ? 'Blur all media' : 'Reveal all media'}
|
|
</button>
|
|
) : null}
|
|
<button
|
|
className="button secondary"
|
|
disabled={isSyncBusy}
|
|
onClick={() => {
|
|
syncMutation.reset();
|
|
setIsFullSyncDialogOpen(true);
|
|
}}
|
|
type="button"
|
|
>
|
|
Full sync
|
|
</button>
|
|
<button aria-busy={isSyncBusy || undefined} className="button primary" disabled={isSyncBusy} onClick={() => runSync(false)} type="button">
|
|
{isSyncBusy ? 'Syncing…' : 'Sync new media'}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<section aria-label="Media sync status" className="status-strip media-sync-status">
|
|
{statusQuery.isPending ? <span role="status">Loading sync status…</span> : null}
|
|
{statusQuery.isError ? (
|
|
<div role="alert">
|
|
<span>{errorMessage(statusQuery.error)}</span>
|
|
<button
|
|
aria-busy={statusQuery.isFetching || undefined}
|
|
className="button secondary"
|
|
disabled={statusQuery.isFetching}
|
|
onClick={() => void statusQuery.refetch()}
|
|
type="button"
|
|
>
|
|
{statusQuery.isFetching ? 'Refreshing…' : 'Retry sync status'}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{statusQuery.data ? (
|
|
statusQuery.data.isSyncing ? (
|
|
<div role="status"><span className="spinner" /><strong>Media sync in progress</strong></div>
|
|
) : (
|
|
<div>
|
|
<span className="status-dot running" aria-hidden="true" />
|
|
<div>
|
|
<small>Sync status</small>
|
|
<strong>{statusQuery.data.lastSyncChats === null ? 'No completed sync yet' : `Last synced ${statusQuery.data.lastSyncChats} conversations`}</strong>
|
|
</div>
|
|
</div>
|
|
)
|
|
) : null}
|
|
{statusQuery.data?.lastSyncAt ? <span className="status-note">{formatLastSync(statusQuery.data.lastSyncAt)}</span> : null}
|
|
</section>
|
|
|
|
{statusQuery.data?.lastError ? <div className="cooldown-banner" role="alert"><strong>Last sync failed</strong><span>{statusQuery.data.lastError}</span></div> : null}
|
|
{syncMutation.isSuccess ? <div className="media-notice success" role="status">{syncMutation.variables ? 'Full media sync completed.' : 'Media sync completed.'}</div> : null}
|
|
{syncMutation.isError ? <div className="media-notice error" role="alert">{errorMessage(syncMutation.error)}</div> : null}
|
|
|
|
{items.length === 0 ? (
|
|
<section className="panel empty-state"><strong>No media yet.</strong><span>Use Sync new media to check recent conversations.</span></section>
|
|
) : (
|
|
<>
|
|
<section aria-label="Media filters" className="panel media-filters">
|
|
<label>Conversation
|
|
<select value={filters.conversation} onChange={(event) => updateFilter('conversation', event.target.value)}>
|
|
<option value="all">All conversations</option>
|
|
{conversations.map((conversation) => <option key={conversation} value={conversation}>{conversation}</option>)}
|
|
</select>
|
|
</label>
|
|
<label>Sender
|
|
<select value={filters.sender} onChange={(event) => updateFilter('sender', event.target.value)}>
|
|
<option value="all">All senders</option>
|
|
{senders.map((sender) => <option key={sender} value={sender}>{sender}</option>)}
|
|
</select>
|
|
</label>
|
|
<label>Media type
|
|
<select value={filters.type} onChange={(event) => updateFilter('type', event.target.value as MediaFilters['type'])}>
|
|
<option value="all">All types</option><option value="image">Images</option><option value="gif">GIFs</option>
|
|
</select>
|
|
</label>
|
|
<label>Date range
|
|
<select value={filters.dateRange} onChange={(event) => updateFilter('dateRange', event.target.value as MediaFilters['dateRange'])}>
|
|
<option value="all">Any time</option><option value="24h">Past 24 hours</option><option value="7d">Past 7 days</option><option value="30d">Past 30 days</option>
|
|
</select>
|
|
</label>
|
|
<div className="media-filter-summary">
|
|
<span role="status">Showing {displayedItems.length} of {items.length} items</span>
|
|
{isFiltered ? (
|
|
<button className="button secondary" onClick={() => updateFilters(initialMediaFilters)} type="button">
|
|
Clear filters
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
|
|
{visibleItems.length === 0 ? (
|
|
<section className="panel empty-state">
|
|
<strong>No media matches these filters.</strong>
|
|
<span>Try broadening one or more filters.</span>
|
|
<button className="button secondary" onClick={() => updateFilters(initialMediaFilters)} type="button">
|
|
Clear filters
|
|
</button>
|
|
</section>
|
|
) : (
|
|
<>
|
|
<section aria-label="Media gallery" className="media-grid">
|
|
{displayedItems.map((item) => (
|
|
<MediaCard
|
|
isRevealed={isRevealAll || revealedIds.has(item.id)}
|
|
item={item}
|
|
key={item.id}
|
|
onOpenPreview={() => setPreviewItemId(item.id)}
|
|
onToggleReveal={() => toggleItem(item.id)}
|
|
/>
|
|
))}
|
|
</section>
|
|
{visibleItems.length > displayedItems.length ? (
|
|
<div className="media-load-more">
|
|
<button className="button secondary" onClick={() => setVisibleLimit((current) => current + PAGE_SIZE)} type="button">
|
|
Load more ({visibleItems.length - displayedItems.length} remaining)
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{isFullSyncDialogOpen ? (
|
|
<FullSyncDialog
|
|
isPending={syncMutation.isPending}
|
|
onCancel={() => setIsFullSyncDialogOpen(false)}
|
|
onConfirm={() => runSync(true)}
|
|
/>
|
|
) : null}
|
|
{previewIndex >= 0 ? (
|
|
<MediaPreviewDialog
|
|
entries={previewEntries}
|
|
index={previewIndex}
|
|
onClose={() => setPreviewItemId(null)}
|
|
onNavigate={(nextIndex) => setPreviewItemId(visibleItems[nextIndex]?.id ?? null)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|