feat(dashboard): add full-size media preview
This commit is contained in:
1 parent
c483c7b8be
commit
654bbda457
5 files changed
+71
-7
No files matched your search
@@ -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 (
|
||||
<article className="media-card">
|
||||
<div className="media-preview">
|
||||
<img
|
||||
alt={`Media from ${item.sender}`}
|
||||
className={isRevealed ? 'media-image' : 'media-image is-blurred'}
|
||||
loading="lazy"
|
||||
src={item.urlPath}
|
||||
/>
|
||||
{isRevealed ? (
|
||||
<button aria-label={`View media from ${item.sender} larger`} className="media-preview-button" onClick={onOpenPreview} type="button">
|
||||
<img alt={`Media from ${item.sender}`} className="media-image" loading="lazy" src={item.urlPath} />
|
||||
</button>
|
||||
) : <img alt={`Media from ${item.sender}`} className="media-image is-blurred" loading="lazy" src={item.urlPath} />}
|
||||
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
|
||||
</div>
|
||||
<div className="media-card-body">
|
||||
|
||||
@@ -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<Set<string>>(() => new Set());
|
||||
const [isRevealAll, setIsRevealAll] = useState(false);
|
||||
const [isFullSyncDialogOpen, setIsFullSyncDialogOpen] = useState(false);
|
||||
const [previewItemId, setPreviewItemId] = useState<string | null>(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 extends keyof MediaFilters>(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 ? <MediaPreviewDialog item={previewItem} onClose={() => setPreviewItemId(null)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLButtonElement>(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 (
|
||||
<div
|
||||
aria-label={`Media from ${item.sender}`}
|
||||
aria-modal="true"
|
||||
className="media-preview-dialog-backdrop"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
role="dialog"
|
||||
>
|
||||
<div className="media-preview-dialog-content">
|
||||
<img alt={`Media from ${item.sender}`} className="media-preview-dialog-image" src={item.urlPath} />
|
||||
<button aria-label="Close media preview" className="media-preview-dialog-close" onClick={onClose} ref={closeButtonRef} type="button">×</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)); }
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user