Files
mule-image/frontend/src/components/loupe/loupeSrc.ts
dtoro f4fc15101e feat: loupe view with zoom, pan, video, and filmstrip
Adds the Lightroom-style full-screen single-photo viewer (spec §6.11).
Open with E / Enter / double-click; navigate with arrow keys; Esc returns
to grid. Filmstrip at the bottom auto-scrolls the active cell into view.

Display:
- Stills source from /photos/{id}/proxy so RAW/HEIC are decoded server-
  side; large thumbnail is the onError fallback only.
- Videos render in a <video controls> sourced from /original.
- Continuous wheel zoom (1×–8×, ~15% per tick) with click-drag pan when
  zoomed past fit. Z toggles between fit and natural-resolution
  (computed from naturalWidth / clientWidth); a second Z snaps back.
- Live percentage indicator in the bottom-center while zoomed.

Polish:
- Neighbor preloading via new Image() when currentIndex changes so arrow
  nav feels instant (skips videos).
- Focus trap with role=dialog, aria-modal, focus-on-mount, restore-on-
  unmount, and Tab cycling among focusable children.

Plumbing:
- New canonical Photo TS interface in types/photo.ts; removes the three
  duplicated definitions in PhotoThumbnail/Timeline/photoStore.
- photoStore gains viewMode + openLoupe/closeLoupe.
- useKeyboardShortcuts wires E/Enter/G to open/close, gates rating and
  flag stubs with { enabled: viewMode === 'grid' } so they're inert in
  loupe.
- App.tsx mounts <LoupeView/> as a z-40 overlay covering TopBar, gates
  the auto-open right sidebar effect on viewMode === 'grid' so leaving
  loupe doesn't fight the user's prior sidebar state.
- PhotoThumbnail gains an onDoubleClick prop wired to openLoupe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:39:33 +02:00

34 lines
1.2 KiB
TypeScript

import { photos as photosApi } from '../../services/api'
import type { Photo } from '../../types/photo'
const VIDEO_EXTENSIONS = ['.mp4', '.mov', '.webm', '.mkv', '.m4v']
export function isVideo(photo: Photo): boolean {
if (photo.media_type === 'video') return true
const lower = photo.filepath.toLowerCase()
return VIDEO_EXTENSIONS.some(ext => lower.endsWith(ext))
}
/**
* Pick the best display URL for a still photo in the loupe view.
*
* Always uses the /proxy endpoint, which the backend resolves to:
* - the original file for web-safe formats (JPEG/PNG/WebP/GIF)
* - a transcoded full-res WebP for RAW/HEIC/TIFF (cached on first hit)
*
* Videos go through `getVideoSrc` instead and use /original directly.
*/
export function getLoupeImageSrc(photo: Photo): string {
return photosApi.getProxyUrl(photo.id)
}
/** Fallback used when the proxy endpoint fails or 404s — shows the 1280px
* large thumbnail so the user still sees something. */
export function getLoupeFallbackSrc(photo: Photo): string {
return photosApi.getThumbnailUrl(photo.id, 'large')
}
export function getVideoSrc(photo: Photo): string {
return photosApi.getOriginalUrl(photo.id)
}