diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dd84b95..9aaeeda 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { Timeline } from './components/timeline/Timeline' import { LeftSidebar } from './components/layout/LeftSidebar' import { RightSidebar } from './components/layout/RightSidebar' @@ -7,27 +8,40 @@ import { ScanProgress } from './components/ScanProgress' import { ToastContainer } from './components/ToastContainer' import { KeyboardShortcuts } from './components/KeyboardShortcuts' import { KeyboardHints } from './components/KeyboardHints' +import { LoupeView } from './components/loupe/LoupeView' import { usePhotoStore } from './store/photoStore' import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' +import type { Photo } from './types/photo' function App() { const [leftSidebarOpen, setLeftSidebarOpen] = useState(true) const [rightSidebarOpen, setRightSidebarOpen] = useState(false) const selectedPhotos = usePhotoStore((state) => state.selectedPhotos) + const viewMode = usePhotoStore((state) => state.viewMode) + const queryClient = useQueryClient() // Set up global keyboard shortcuts useKeyboardShortcuts({ onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen), onToggleRightSidebar: () => setRightSidebarOpen(!rightSidebarOpen), + getFirstPhotoId: () => { + const photos = queryClient.getQueryData(['photos']) + return photos && photos.length > 0 ? photos[0].id : null + }, }) - // Show right sidebar when photos are selected - if (selectedPhotos.length > 0 && !rightSidebarOpen) { - setRightSidebarOpen(true) - } else if (selectedPhotos.length === 0 && rightSidebarOpen) { - setRightSidebarOpen(false) + // Auto-show right sidebar when photos are selected — but only in grid mode, + // so leaving loupe doesn't fight the user's prior sidebar state. + if (viewMode === 'grid') { + if (selectedPhotos.length > 0 && !rightSidebarOpen) { + setRightSidebarOpen(true) + } else if (selectedPhotos.length === 0 && rightSidebarOpen) { + setRightSidebarOpen(false) + } } + const showRightSidebar = rightSidebarOpen && viewMode === 'grid' + return (
@@ -50,24 +64,27 @@ function App() { {/* Right Sidebar */}
- + {/* Contextual Keyboard Hints */} - + {/* Keyboard Shortcuts Legend */} - + {/* Scan Progress Indicator */} - + {/* Toast Notifications */} + + {/* Loupe overlay — covers TopBar when active */} + {viewMode === 'loupe' && } ) } diff --git a/frontend/src/components/loupe/LoupeFilmstrip.tsx b/frontend/src/components/loupe/LoupeFilmstrip.tsx new file mode 100644 index 0000000..bc627d0 --- /dev/null +++ b/frontend/src/components/loupe/LoupeFilmstrip.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef } from 'react' +import clsx from 'clsx' +import type { Photo } from '../../types/photo' +import { photos as photosApi } from '../../services/api' + +interface LoupeFilmstripProps { + photos: Photo[] + currentIndex: number + onSelect: (id: string) => void +} + +const CELL_SIZE = 72 + +export function LoupeFilmstrip({ photos, currentIndex, onSelect }: LoupeFilmstripProps) { + const activeRef = useRef(null) + + useEffect(() => { + activeRef.current?.scrollIntoView({ + block: 'nearest', + inline: 'center', + behavior: 'smooth', + }) + }, [currentIndex]) + + return ( +
+ {photos.map((photo, index) => { + const isActive = index === currentIndex + return ( + + ) + })} +
+ ) +} diff --git a/frontend/src/components/loupe/LoupeImage.tsx b/frontend/src/components/loupe/LoupeImage.tsx new file mode 100644 index 0000000..897ec98 --- /dev/null +++ b/frontend/src/components/loupe/LoupeImage.tsx @@ -0,0 +1,179 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { useHotkeys } from 'react-hotkeys-hook' +import clsx from 'clsx' +import type { Photo } from '../../types/photo' +import { + getLoupeImageSrc, + getLoupeFallbackSrc, + getVideoSrc, + isVideo, +} from './loupeSrc' + +interface LoupeImageProps { + photo: Photo +} + +const MIN_SCALE = 1 +const MAX_SCALE = 8 +const WHEEL_STEP = 1.15 + +export function LoupeImage({ photo }: LoupeImageProps) { + if (isVideo(photo)) { + return + } + return +} + +function LoupeVideo({ photo }: { photo: Photo }) { + return ( +
+
+ ) +} + +function LoupeStillImage({ photo }: { photo: Photo }) { + const [loaded, setLoaded] = useState(false) + const [usingFallback, setUsingFallback] = useState(false) + + // scale=1 means "fit to viewport". Anything >1 zooms in; we don't allow <1 + // because the fit size already fills the viewport. + const [scale, setScale] = useState(1) + const [offset, setOffset] = useState({ x: 0, y: 0 }) + const dragStateRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null) + const imgRef = useRef(null) + + // Reset everything when the photo changes. + useEffect(() => { + setLoaded(false) + setUsingFallback(false) + setScale(1) + setOffset({ x: 0, y: 0 }) + }, [photo.id]) + + const primarySrc = getLoupeImageSrc(photo) + const fallbackSrc = getLoupeFallbackSrc(photo) + const src = usingFallback ? fallbackSrc : primarySrc + + const handleError = () => { + if (!usingFallback && primarySrc !== fallbackSrc) { + setUsingFallback(true) + } + } + + // Z key: toggle between fit (scale=1) and actual size (natural/displayed). + // If we're already zoomed (manual wheel zoom), Z snaps back to fit. + const toggleZoom = useCallback(() => { + if (scale !== 1) { + setScale(1) + setOffset({ x: 0, y: 0 }) + return + } + const img = imgRef.current + if (!img) return + const ratio = img.naturalWidth / img.clientWidth + if (!isFinite(ratio) || ratio <= 1) return + setScale(Math.min(ratio, MAX_SCALE)) + }, [scale]) + + useHotkeys('z', (e) => { + e.preventDefault() + toggleZoom() + }, [toggleZoom]) + + const handleWheel = (e: React.WheelEvent) => { + e.preventDefault() + const delta = e.deltaY < 0 ? WHEEL_STEP : 1 / WHEEL_STEP + setScale((prev) => { + const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, prev * delta)) + // Snapping back to 1 also resets pan offset. + if (next === 1) setOffset({ x: 0, y: 0 }) + return next + }) + } + + const handleMouseDown = (e: React.MouseEvent) => { + if (scale === 1) return + e.preventDefault() + dragStateRef.current = { + x: e.clientX, + y: e.clientY, + ox: offset.x, + oy: offset.y, + } + } + + const handleMouseMove = (e: React.MouseEvent) => { + const drag = dragStateRef.current + if (!drag) return + setOffset({ + x: drag.ox + (e.clientX - drag.x), + y: drag.oy + (e.clientY - drag.y), + }) + } + + const endDrag = () => { + dragStateRef.current = null + } + + const isZoomed = scale > 1 + const cursor = isZoomed + ? dragStateRef.current + ? 'grabbing' + : 'grab' + : 'zoom-in' + + return ( +
+ {!loaded && ( +
+
+
+ )} + {photo.filename} setLoaded(true)} + onError={handleError} + className={clsx( + 'max-h-full max-w-full object-contain transition-opacity duration-150', + loaded ? 'opacity-100' : 'opacity-0' + )} + style={{ + transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`, + transformOrigin: 'center center', + // Disable transition during pan/zoom — only fade-in is animated. + transition: 'opacity 150ms', + willChange: 'transform', + }} + /> + + {/* Zoom indicator */} + {isZoomed && ( +
+ {Math.round(scale * 100)}% +
+ )} +
+ ) +} diff --git a/frontend/src/components/loupe/LoupeView.tsx b/frontend/src/components/loupe/LoupeView.tsx new file mode 100644 index 0000000..3b92205 --- /dev/null +++ b/frontend/src/components/loupe/LoupeView.tsx @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useRef } from 'react' +import { useHotkeys } from 'react-hotkeys-hook' +import { useQueryClient } from '@tanstack/react-query' +import { X } from 'lucide-react' +import { usePhotoStore } from '../../store/photoStore' +import type { Photo } from '../../types/photo' +import { LoupeImage } from './LoupeImage' +import { LoupeFilmstrip } from './LoupeFilmstrip' +import { getLoupeImageSrc, isVideo } from './loupeSrc' + +export function LoupeView() { + const activePhotoId = usePhotoStore((s) => s.activePhotoId) + const setActivePhoto = usePhotoStore((s) => s.setActivePhoto) + const closeLoupe = usePhotoStore((s) => s.closeLoupe) + + const containerRef = useRef(null) + const previouslyFocusedRef = useRef(null) + + // Read photos from the TanStack Query cache populated by Timeline. + // Same query key so we share the cache and never refetch. + const queryClient = useQueryClient() + const photos = queryClient.getQueryData(['photos']) ?? [] + + const currentIndex = activePhotoId + ? photos.findIndex((p) => p.id === activePhotoId) + : 0 + const safeIndex = currentIndex < 0 ? 0 : currentIndex + const currentPhoto: Photo | undefined = photos[safeIndex] + + const goPrev = useCallback(() => { + if (photos.length === 0) return + const next = Math.max(0, safeIndex - 1) + setActivePhoto(photos[next].id) + }, [photos, safeIndex, setActivePhoto]) + + const goNext = useCallback(() => { + if (photos.length === 0) return + const next = Math.min(photos.length - 1, safeIndex + 1) + setActivePhoto(photos[next].id) + }, [photos, safeIndex, setActivePhoto]) + + // Loupe-scoped hotkeys: only mounted while LoupeView is rendered. + useHotkeys('escape', (e) => { + e.preventDefault() + closeLoupe() + }) + + useHotkeys('left', (e) => { + e.preventDefault() + goPrev() + }, [goPrev]) + + useHotkeys('right', (e) => { + e.preventDefault() + goNext() + }, [goNext]) + + // Preload the immediate neighbors so arrow nav feels instant. Skip videos + // (browsers can't preload them via Image()) and skip when at the edges. + useEffect(() => { + const neighbors: Photo[] = [] + if (safeIndex > 0) neighbors.push(photos[safeIndex - 1]) + if (safeIndex < photos.length - 1) neighbors.push(photos[safeIndex + 1]) + for (const p of neighbors) { + if (isVideo(p)) continue + const img = new Image() + img.src = getLoupeImageSrc(p) + } + }, [safeIndex, photos]) + + // Focus trap: focus the loupe container on mount, restore focus on unmount. + // The container is keyboard-focusable (tabIndex=-1) so screen readers and + // tab navigation stay scoped here. + useEffect(() => { + previouslyFocusedRef.current = document.activeElement as HTMLElement | null + containerRef.current?.focus() + return () => { + previouslyFocusedRef.current?.focus?.() + } + }, []) + + // Trap Tab inside the dialog so users can't accidentally tab into the + // hidden grid behind. Simple cycle implementation. + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== 'Tab') return + const root = containerRef.current + if (!root) return + const focusable = root.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + if (focusable.length === 0) { + e.preventDefault() + root.focus() + return + } + const first = focusable[0] + const last = focusable[focusable.length - 1] + const active = document.activeElement as HTMLElement | null + if (e.shiftKey && active === first) { + e.preventDefault() + last.focus() + } else if (!e.shiftKey && active === last) { + e.preventDefault() + first.focus() + } + } + + if (!currentPhoto) { + return ( +
+
No photo to display
+ +
+ ) + } + + return ( +
+ {/* Close button */} + + + {/* Filename + counter */} +
+
{currentPhoto.filename}
+
+ {safeIndex + 1} / {photos.length} +
+
+ + + + +
+ ) +} diff --git a/frontend/src/components/loupe/loupeSrc.ts b/frontend/src/components/loupe/loupeSrc.ts new file mode 100644 index 0000000..918568e --- /dev/null +++ b/frontend/src/components/loupe/loupeSrc.ts @@ -0,0 +1,33 @@ +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) +} diff --git a/frontend/src/components/timeline/PhotoThumbnail.tsx b/frontend/src/components/timeline/PhotoThumbnail.tsx index 14c975e..908c7f4 100644 --- a/frontend/src/components/timeline/PhotoThumbnail.tsx +++ b/frontend/src/components/timeline/PhotoThumbnail.tsx @@ -2,33 +2,21 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { Star, Check, X, RefreshCw } from 'lucide-react' import clsx from 'clsx' import { photos as photosApi } from '../../services/api' +import type { Photo } from '../../types/photo' // Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so // first hit often 404s. Try a few times with backoff before giving up. const AUTO_RETRY_DELAYS = [1500, 3500, 6000] -interface Photo { - id: string - filepath: string - filename: string - width: number | null - height: number | null - taken_at: string | null - rating: number - is_picked: boolean - is_rejected: boolean - file_hash: string - media_type: string -} - interface PhotoThumbnailProps { photo: Photo size: number isSelected: boolean onClick: (e: React.MouseEvent) => void + onDoubleClick?: (e: React.MouseEvent) => void } -export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbnailProps) { +export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick }: PhotoThumbnailProps) { const [imageError, setImageError] = useState(false) const [imageLoaded, setImageLoaded] = useState(false) const [retryCount, setRetryCount] = useState(0) @@ -108,7 +96,8 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbn height: displayHeight, }} onClick={onClick} - title="Click to select • Shift+Click for range • Ctrl+Click to add" + onDoubleClick={onDoubleClick} + title="Click to select • Double-click to open • Shift+Click for range • Ctrl+Click to add" > {/* Thumbnail Image */} {!imageError ? ( diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index be27a4d..d9fa60f 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -4,21 +4,7 @@ import { usePhotoStore } from '../../store/photoStore' import { PhotoThumbnail } from './PhotoThumbnail' import { useQuery } from '@tanstack/react-query' import axios from 'axios' - - -interface Photo { - id: string - filepath: string - filename: string - width: number | null - height: number | null - taken_at: string | null - rating: number - is_picked: boolean - is_rejected: boolean - file_hash: string - media_type: string -} +import type { Photo } from '../../types/photo' export function Timeline() { const parentRef = useRef(null) @@ -31,7 +17,7 @@ export function Timeline() { selectPhoto, togglePhotoSelection, clearSelection, - + openLoupe, } = usePhotoStore() // Helper function for range selection @@ -247,6 +233,7 @@ export function Timeline() { selectPhoto(photo.id, globalIndex) } }} + onDoubleClick={() => openLoupe(photo.id)} /> ) })} diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index c91d8ec..4cdd97e 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -1,55 +1,96 @@ import { useHotkeys } from 'react-hotkeys-hook' +import { usePhotoStore } from '../store/photoStore' interface KeyboardShortcutsProps { onToggleLeftSidebar: () => void onToggleRightSidebar: () => void + /** Returns the first photo id in the current timeline, or null if empty. */ + getFirstPhotoId?: () => string | null } export function useKeyboardShortcuts(props: KeyboardShortcutsProps) { - const { onToggleLeftSidebar, onToggleRightSidebar } = props - - // Toggle sidebars + const { onToggleLeftSidebar, onToggleRightSidebar, getFirstPhotoId } = props + + const viewMode = usePhotoStore((s) => s.viewMode) + const activePhotoId = usePhotoStore((s) => s.activePhotoId) + const openLoupe = usePhotoStore((s) => s.openLoupe) + const closeLoupe = usePhotoStore((s) => s.closeLoupe) + + const isGrid = viewMode === 'grid' + const isLoupe = viewMode === 'loupe' + + // Toggle sidebars (allowed in both modes; right sidebar is hidden in loupe + // by App-level CSS so toggling it is effectively grid-only.) useHotkeys('tab', (e) => { e.preventDefault() onToggleLeftSidebar() }) - + useHotkeys('i', (e) => { e.preventDefault() onToggleRightSidebar() }) - - // Navigation shortcuts + + // Grid view: G always returns to grid (closes loupe if open). useHotkeys('g', () => { - // Go to grid view - console.log('Grid view') + closeLoupe() }) - - useHotkeys('e', () => { - // Go to loupe view - console.log('Loupe view') - }) - - // Rating shortcuts - useHotkeys('1,2,3,4,5', (_e, handler) => { - const rating = parseInt(handler.keys![0]) - console.log('Set rating:', rating) - }) - + + // Loupe view: E toggles loupe (open from grid, close from loupe). + // Enter also opens loupe from grid. + const openLoupeFromGrid = () => { + const id = activePhotoId ?? getFirstPhotoId?.() ?? null + if (id) openLoupe(id) + } + + useHotkeys( + 'e', + () => { + if (isLoupe) { + closeLoupe() + } else { + openLoupeFromGrid() + } + }, + [isLoupe, activePhotoId, getFirstPhotoId] + ) + + useHotkeys( + 'enter', + (e) => { + if (isGrid) { + e.preventDefault() + openLoupeFromGrid() + } + }, + { enabled: isGrid }, + [isGrid, activePhotoId, getFirstPhotoId] + ) + + // Rating shortcuts (grid only — stubs from a different phase) + useHotkeys( + '1,2,3,4,5', + (_e, handler) => { + const rating = parseInt(handler.keys![0]) + console.log('Set rating:', rating) + }, + { enabled: isGrid } + ) + useHotkeys('0', () => { console.log('Remove rating') - }) - - // Flag shortcuts + }, { enabled: isGrid }) + + // Flag shortcuts (grid only) useHotkeys('p', () => { console.log('Pick photo') - }) - + }, { enabled: isGrid }) + useHotkeys('x', () => { console.log('Reject photo') - }) - + }, { enabled: isGrid }) + useHotkeys('u', () => { console.log('Unflag photo') - }) -} \ No newline at end of file + }, { enabled: isGrid }) +} diff --git a/frontend/src/store/photoStore.ts b/frontend/src/store/photoStore.ts index 811c3a5..057cf64 100644 --- a/frontend/src/store/photoStore.ts +++ b/frontend/src/store/photoStore.ts @@ -1,20 +1,7 @@ import { create } from 'zustand' +import type { Photo } from '../types/photo' -interface Photo { - id: string - filename: string - filepath: string - media_type: string - width?: number - height?: number - taken_at?: string - thumb_small?: string - thumb_medium?: string - thumb_large?: string - rating: number - is_picked: boolean - is_rejected: boolean -} +type ViewMode = 'grid' | 'loupe' interface PhotoStore { photos: Photo[] @@ -22,7 +9,8 @@ interface PhotoStore { activePhotoId: string | null lastSelectedIndex: number | null rangeStartIndex: number | null - + viewMode: ViewMode + setPhotos: (photos: Photo[]) => void selectPhoto: (id: string, index: number) => void togglePhotoSelection: (id: string, index: number) => void @@ -30,6 +18,9 @@ interface PhotoStore { deselectPhoto: (id: string) => void clearSelection: () => void setActivePhoto: (id: string | null) => void + setViewMode: (mode: ViewMode) => void + openLoupe: (id: string) => void + closeLoupe: () => void } export const usePhotoStore = create((set) => ({ @@ -38,7 +29,8 @@ export const usePhotoStore = create((set) => ({ activePhotoId: null, lastSelectedIndex: null, rangeStartIndex: null, - + viewMode: 'grid', + setPhotos: (photos) => set({ photos }), selectPhoto: (id, index) => set({ @@ -78,4 +70,10 @@ export const usePhotoStore = create((set) => ({ }), setActivePhoto: (id) => set({ activePhotoId: id }), + + setViewMode: (mode) => set({ viewMode: mode }), + + openLoupe: (id) => set({ viewMode: 'loupe', activePhotoId: id }), + + closeLoupe: () => set({ viewMode: 'grid' }), })) \ No newline at end of file diff --git a/frontend/src/types/photo.ts b/frontend/src/types/photo.ts new file mode 100644 index 0000000..5fe9b38 --- /dev/null +++ b/frontend/src/types/photo.ts @@ -0,0 +1,16 @@ +export interface Photo { + id: string + filepath: string + filename: string + media_type: string + width: number | null + height: number | null + taken_at: string | null + rating: number + is_picked: boolean + is_rejected: boolean + file_hash: string + thumb_small?: string + thumb_medium?: string + thumb_large?: string +}