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
+138
-11
@@ -30,6 +30,15 @@ const mediaItems = [
|
||||
},
|
||||
];
|
||||
|
||||
function altName(kind: 'Image' | 'GIF', sender: string, timestamp: number): string {
|
||||
const date = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(timestamp);
|
||||
return `${kind} from u/${sender}, ${date}`;
|
||||
}
|
||||
|
||||
const aliceAlt = altName('Image', 'alice', mediaItems[0].timestamp);
|
||||
const charlieAlt = altName('GIF', 'charlie', mediaItems[1].timestamp);
|
||||
const daveAlt = altName('Image', 'dave', mediaItems[2].timestamp);
|
||||
|
||||
const idleStatus = {
|
||||
isSyncing: false,
|
||||
lastSyncAt: now - 120_000,
|
||||
@@ -62,6 +71,11 @@ function renderMedia() {
|
||||
return { ...view, queryClient };
|
||||
}
|
||||
|
||||
/** The card's footer toggle carries aria-pressed; the image button does not. */
|
||||
function revealToggle(sender: string, pressed: boolean) {
|
||||
return screen.getByRole('button', { name: `Reveal media from ${sender}`, pressed });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -91,8 +105,8 @@ describe('media page', () => {
|
||||
await waitFor(() => expect(resolveMedia).toBeTypeOf('function'));
|
||||
resolveMedia?.(HttpResponse.json(mediaItems));
|
||||
|
||||
const aliceImage = await screen.findByRole('img', { name: 'Media from alice' });
|
||||
const charlieImage = screen.getByRole('img', { name: 'Media from charlie' });
|
||||
const aliceImage = await screen.findByRole('img', { name: aliceAlt });
|
||||
const charlieImage = screen.getByRole('img', { name: charlieAlt });
|
||||
for (const profileLink of screen.getAllByRole('link', { name: 'alice' })) {
|
||||
expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
|
||||
}
|
||||
@@ -100,17 +114,44 @@ describe('media page', () => {
|
||||
expect(charlieImage).toHaveClass('is-blurred');
|
||||
expect(credentials.every((value) => value === 'same-origin')).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reveal media from alice' }));
|
||||
await user.click(revealToggle('alice', false));
|
||||
expect(aliceImage).not.toHaveClass('is-blurred');
|
||||
expect(charlieImage).toHaveClass('is-blurred');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reveal all media' }));
|
||||
expect(charlieImage).not.toHaveClass('is-blurred');
|
||||
|
||||
// Turning reveal-all back off keeps the earlier individual reveal intact.
|
||||
await user.click(screen.getByRole('button', { name: 'Blur all media' }));
|
||||
expect(charlieImage).toHaveClass('is-blurred');
|
||||
expect(aliceImage).not.toHaveClass('is-blurred');
|
||||
|
||||
firstView.unmount();
|
||||
installMediaApi();
|
||||
renderMedia();
|
||||
expect(await screen.findByRole('img', { name: 'Media from alice' })).toHaveClass('is-blurred');
|
||||
expect(await screen.findByRole('img', { name: aliceAlt })).toHaveClass('is-blurred');
|
||||
});
|
||||
|
||||
it('reveals blurred media when its preview surface is clicked', async () => {
|
||||
installMediaApi();
|
||||
const user = userEvent.setup();
|
||||
renderMedia();
|
||||
|
||||
const aliceImage = await screen.findByRole('img', { name: aliceAlt });
|
||||
expect(aliceImage).toHaveClass('is-blurred');
|
||||
|
||||
// The image surface itself acts as a reveal control while blurred.
|
||||
const imageButton = screen
|
||||
.getAllByRole('button', { name: 'Reveal media from alice' })
|
||||
.find((button) => button.classList.contains('media-preview-button'));
|
||||
expect(imageButton).toBeDefined();
|
||||
expect(imageButton).toBeEnabled();
|
||||
await user.click(imageButton!);
|
||||
expect(aliceImage).not.toHaveClass('is-blurred');
|
||||
|
||||
// Once revealed, the same surface opens the full-size preview instead.
|
||||
await user.click(screen.getByRole('button', { name: 'View media from alice larger' }));
|
||||
expect(screen.getByRole('dialog', { name: 'Media from alice' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens revealed media in a dismissible full-size preview', async () => {
|
||||
@@ -118,16 +159,43 @@ describe('media page', () => {
|
||||
const user = userEvent.setup();
|
||||
renderMedia();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Reveal media from alice' }));
|
||||
await screen.findByRole('img', { name: aliceAlt });
|
||||
await user.click(revealToggle('alice', false));
|
||||
await user.click(screen.getByRole('button', { name: 'View media from alice larger' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Media from alice' });
|
||||
expect(within(dialog).getByRole('img', { name: 'Media from alice' })).toHaveAttribute('src', '/media/one.jpg');
|
||||
expect(within(dialog).getByRole('img', { name: aliceAlt })).toHaveAttribute('src', '/media/one.jpg');
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('dialog', { name: 'Media from alice' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates the preview across the visible items with buttons and arrow keys', async () => {
|
||||
installMediaApi();
|
||||
const user = userEvent.setup();
|
||||
renderMedia();
|
||||
|
||||
await screen.findByRole('img', { name: aliceAlt });
|
||||
await user.click(screen.getByRole('button', { name: 'Reveal all media' }));
|
||||
await user.click(screen.getByRole('button', { name: 'View media from alice larger' }));
|
||||
|
||||
let dialog = screen.getByRole('dialog', { name: 'Media from alice' });
|
||||
expect(within(dialog).getByRole('button', { name: 'Previous media' })).toBeDisabled();
|
||||
expect(within(dialog).getByText('1 of 3')).toBeInTheDocument();
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Next media' }));
|
||||
dialog = screen.getByRole('dialog', { name: 'Media from charlie' });
|
||||
expect(within(dialog).getByRole('img', { name: charlieAlt })).toHaveAttribute('src', '/media/two.gif');
|
||||
expect(within(dialog).getByText('u/charlie')).toBeInTheDocument();
|
||||
|
||||
await user.keyboard('{ArrowRight}');
|
||||
dialog = screen.getByRole('dialog', { name: 'Media from dave' });
|
||||
expect(within(dialog).getByRole('button', { name: 'Next media' })).toBeDisabled();
|
||||
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
expect(screen.getByRole('dialog', { name: 'Media from charlie' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links media cards to their account-owned conversations', async () => {
|
||||
installMediaApi();
|
||||
renderMedia();
|
||||
@@ -149,11 +217,11 @@ describe('media page', () => {
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'alice');
|
||||
expect(screen.getAllByRole('img')).toHaveLength(1);
|
||||
expect(screen.getByRole('img', { name: 'Media from alice' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: aliceAlt })).toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'all');
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'charlie');
|
||||
expect(screen.getByRole('img', { name: 'Media from charlie' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: charlieAlt })).toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'all');
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'gif');
|
||||
@@ -167,6 +235,49 @@ describe('media page', () => {
|
||||
expect(screen.getByText('No media matches these filters.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a result count and clears filters from both the panel and the empty state', async () => {
|
||||
installMediaApi();
|
||||
const user = userEvent.setup();
|
||||
renderMedia();
|
||||
|
||||
await screen.findByRole('img', { name: aliceAlt });
|
||||
expect(screen.getByText('Showing 3 of 3 items')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Clear filters' })).not.toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'alice');
|
||||
expect(screen.getByText('Showing 1 of 3 items')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Clear filters' }));
|
||||
expect(screen.getAllByRole('img')).toHaveLength(3);
|
||||
expect(screen.getByRole('combobox', { name: 'Conversation' })).toHaveValue('all');
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'charlie');
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'image');
|
||||
const emptyState = screen.getByText('No media matches these filters.').closest('section');
|
||||
expect(emptyState).not.toBeNull();
|
||||
await user.click(within(emptyState!).getByRole('button', { name: 'Clear filters' }));
|
||||
expect(screen.getAllByRole('img')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('paginates large galleries behind a Load more control', async () => {
|
||||
const manyItems = Array.from({ length: 60 }, (_, index) => ({
|
||||
id: `item-${index}`, chatUsername: 'alice', chatRoomId: `room-${index}`, sender: 'alice',
|
||||
timestamp: now - index * 60_000, originalUrl: `https://media.example/${index}.jpg`,
|
||||
fileName: `${index}.jpg`, relativePath: `${index}.jpg`, createdAt: now - index * 60_000,
|
||||
urlPath: `/media/${index}.jpg`,
|
||||
}));
|
||||
installMediaApi(manyItems);
|
||||
const user = userEvent.setup();
|
||||
renderMedia();
|
||||
|
||||
await screen.findByText('Showing 48 of 60 items');
|
||||
expect(screen.getAllByRole('img')).toHaveLength(48);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Load more (12 remaining)' }));
|
||||
expect(screen.getAllByRole('img')).toHaveLength(60);
|
||||
expect(screen.getByText('Showing 60 of 60 items')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Load more/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('orders media by received time', async () => {
|
||||
server.use(
|
||||
http.get('/api/media', () => HttpResponse.json([mediaItems[2], mediaItems[0], mediaItems[1]])),
|
||||
@@ -175,9 +286,9 @@ describe('media page', () => {
|
||||
renderMedia();
|
||||
|
||||
expect((await screen.findAllByRole('img')).map((item) => item.getAttribute('alt'))).toEqual([
|
||||
'Media from alice',
|
||||
'Media from charlie',
|
||||
'Media from dave',
|
||||
aliceAlt,
|
||||
charlieAlt,
|
||||
daveAlt,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -230,6 +341,22 @@ describe('media page', () => {
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Sync already in progress');
|
||||
});
|
||||
|
||||
it('auto-dismisses the sync success notice after five seconds', async () => {
|
||||
installMediaApi();
|
||||
server.use(http.post('/api/media/sync', () => HttpResponse.json({ success: true })));
|
||||
const user = userEvent.setup();
|
||||
renderMedia();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Sync new media' }));
|
||||
const notice = await screen.findByText('Media sync completed.');
|
||||
expect(notice).toHaveAttribute('role', 'status');
|
||||
|
||||
await waitFor(
|
||||
() => expect(screen.queryByText('Media sync completed.')).not.toBeInTheDocument(),
|
||||
{ timeout: 7_000 },
|
||||
);
|
||||
}, 15_000);
|
||||
|
||||
it('refreshes shared sync status as soon as a long-running mutation starts', async () => {
|
||||
let statusRequests = 0;
|
||||
let resolveSync: ((response: Response) => void) | undefined;
|
||||
|
||||
Reference in new issue
Block a user