diff --git a/apps/dashboard/src/features/media/media-card.tsx b/apps/dashboard/src/features/media/media-card.tsx index 1f14a21..1651f10 100644 --- a/apps/dashboard/src/features/media/media-card.tsx +++ b/apps/dashboard/src/features/media/media-card.tsx @@ -5,22 +5,22 @@ interface MediaCardProps { item: MediaItem; isRevealed: boolean; onToggleReveal(): void; + onOpenPreview(): void; } function formatMediaDate(timestamp: number): string { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp); } -export function MediaCard({ item, isRevealed, onToggleReveal }: MediaCardProps) { +export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: MediaCardProps) { return (
- {`Media + {isRevealed ? ( + + ) : {`Media} {!isRevealed ? Private media hidden : null}
diff --git a/apps/dashboard/src/features/media/media-page.tsx b/apps/dashboard/src/features/media/media-page.tsx index ef2be5f..c8995dd 100644 --- a/apps/dashboard/src/features/media/media-page.tsx +++ b/apps/dashboard/src/features/media/media-page.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'; import { filterMedia, mediaConversation, type MediaFilters } from './filters.js'; import { FullSyncDialog } from './full-sync-dialog.js'; import { MediaCard } from './media-card.js'; +import { MediaPreviewDialog } from './media-preview-dialog.js'; import { useMediaQuery, useMediaSyncStatusQuery, useTriggerMediaSyncMutation } from './queries.js'; import './media.css'; @@ -34,6 +35,7 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media const [revealedIds, setRevealedIds] = useState>(() => new Set()); const [isRevealAll, setIsRevealAll] = useState(false); const [isFullSyncDialogOpen, setIsFullSyncDialogOpen] = useState(false); + const [previewItemId, setPreviewItemId] = useState(null); const items = mediaQuery.data ?? []; const visibleItems = useMemo(() => filterMedia(items, filters, Date.now()), [items, filters]); @@ -46,6 +48,7 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media [items], ); const isSyncBusy = statusQuery.data?.isSyncing === true || syncMutation.isPending; + const previewItem = items.find((item) => item.id === previewItemId) ?? null; function updateFilter(key: Key, value: MediaFilters[Key]) { const next = { ...filters, [key]: value }; @@ -196,6 +199,7 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media isRevealed={isRevealAll || revealedIds.has(item.id)} item={item} key={item.id} + onOpenPreview={() => setPreviewItemId(item.id)} onToggleReveal={() => toggleItem(item.id)} /> ))} @@ -211,6 +215,7 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media onConfirm={() => runSync(true)} /> ) : null} + {previewItem ? setPreviewItemId(null)} /> : null}
); } diff --git a/apps/dashboard/src/features/media/media-preview-dialog.tsx b/apps/dashboard/src/features/media/media-preview-dialog.tsx new file mode 100644 index 0000000..3864755 --- /dev/null +++ b/apps/dashboard/src/features/media/media-preview-dialog.tsx @@ -0,0 +1,37 @@ +import { useEffect, useRef } from 'react'; +import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js'; + +interface MediaPreviewDialogProps { + item: MediaItem; + onClose(): void; +} + +export function MediaPreviewDialog({ item, onClose }: MediaPreviewDialogProps) { + const closeButtonRef = useRef(null); + + useEffect(() => { + closeButtonRef.current?.focus(); + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') onClose(); + } + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + role="dialog" + > +
+ {`Media + +
+
+ ); +} diff --git a/apps/dashboard/src/features/media/media.css b/apps/dashboard/src/features/media/media.css index 0380a9a..d982402 100644 --- a/apps/dashboard/src/features/media/media.css +++ b/apps/dashboard/src/features/media/media.css @@ -12,6 +12,8 @@ .media-grid { display: grid; grid-template-columns: 1fr; gap: 1rem; } .media-card { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; } .media-preview { position: relative; display: grid; place-items: center; aspect-ratio: 4 / 3; overflow: hidden; background: #050907; } +.media-preview-button { width: 100%; height: 100%; padding: 0; border: 0; background: transparent; cursor: zoom-in; } +.media-preview-button:focus-visible { outline: 2px solid var(--accent); outline-offset: -4px; } .media-image { width: 100%; height: 100%; object-fit: cover; transition: filter 160ms ease, transform 160ms ease; } .media-image.is-blurred { filter: blur(24px) brightness(.55); transform: scale(1.12); } .privacy-label { position: absolute; padding: .45rem .65rem; color: white; background: rgba(0, 0, 0, .72); border-radius: 999px; font-size: .75rem; font-weight: 800; } @@ -23,6 +25,11 @@ .media-card-body dt { color: var(--muted); } .media-card-body dd { margin: 0; text-align: right; } .media-reveal { width: 100%; } +.media-preview-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .88); } +.media-preview-dialog-content { position: relative; display: grid; place-items: center; width: min(100%, 90rem); height: min(100%, 90rem); } +.media-preview-dialog-image { display: block; max-width: 100%; max-height: 100%; object-fit: contain; } +.media-preview-dialog-close { position: absolute; top: .5rem; right: .5rem; width: 2.75rem; height: 2.75rem; padding: 0; border: 1px solid rgba(255, 255, 255, .5); border-radius: 50%; color: white; background: rgba(0, 0, 0, .7); font-size: 2rem; line-height: 1; cursor: pointer; } +.media-preview-dialog-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } @media (min-width: 40rem) { .media-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/tests/dashboard/media.test.tsx b/tests/dashboard/media.test.tsx index 5f5149c..f3a3a95 100644 --- a/tests/dashboard/media.test.tsx +++ b/tests/dashboard/media.test.tsx @@ -106,6 +106,21 @@ describe('media page', () => { expect(await screen.findByRole('img', { name: 'Media from alice' })).toHaveClass('is-blurred'); }); + it('opens revealed media in a dismissible full-size preview', async () => { + installMediaApi(); + const user = userEvent.setup(); + renderMedia(); + + await user.click(await screen.findByRole('button', { name: 'Reveal media from alice' })); + 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'); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog', { name: 'Media from alice' })).not.toBeInTheDocument(); + }); + it('filters by conversation, sender, media type, and age with an explicit no-match state', async () => { installMediaApi(); const user = userEvent.setup();