Compare commits
3 Commits
72d301a9c7
...
9089ad2f61
| Author | SHA1 | Date | |
|---|---|---|---|
| 9089ad2f61 | |||
| f4fc15101e | |||
| 1096854553 |
@@ -228,7 +228,7 @@ async def get_original(
|
|||||||
photo_id: str,
|
photo_id: str,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Serve original file for download"""
|
"""Serve original file (download for RAW, inline for web-safe formats)"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Photo).where(Photo.id == photo_id)
|
select(Photo).where(Photo.id == photo_id)
|
||||||
)
|
)
|
||||||
@@ -240,12 +240,150 @@ async def get_original(
|
|||||||
if not os.path.exists(photo.filepath):
|
if not os.path.exists(photo.filepath):
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
# Pick a media type the browser can render inline for web-safe formats
|
||||||
|
# so the loupe view and <video> tags work without forcing a download.
|
||||||
|
ext = Path(photo.filepath).suffix.lower()
|
||||||
|
inline_types = {
|
||||||
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
|
||||||
|
'.mp4': 'video/mp4', '.mov': 'video/quicktime',
|
||||||
|
'.webm': 'video/webm', '.mkv': 'video/x-matroska',
|
||||||
|
}
|
||||||
|
media_type = inline_types.get(ext, 'application/octet-stream')
|
||||||
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
photo.filepath,
|
photo.filepath,
|
||||||
filename=photo.filename,
|
filename=photo.filename if media_type == 'application/octet-stream' else None,
|
||||||
media_type='application/octet-stream'
|
media_type=media_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Extensions that the browser can decode natively. Anything else (RAW, HEIC,
|
||||||
|
# TIFF) needs the /proxy endpoint to convert to WebP for display.
|
||||||
|
_WEB_SAFE_DISPLAY_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_proxy_webp(src_path: str, dst_path: str) -> None:
|
||||||
|
"""Decode src_path with the appropriate backend and write a full-res WebP
|
||||||
|
to dst_path. Used by GET /photos/{id}/proxy for RAW/HEIC/TIFF display.
|
||||||
|
|
||||||
|
Conservative: catches per-format failures and falls back to extracting an
|
||||||
|
embedded preview where possible (RAW), so a single broken file never
|
||||||
|
crashes the request.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
ext = Path(src_path).suffix.lower()
|
||||||
|
|
||||||
|
img = None
|
||||||
|
|
||||||
|
# RAW formats — decode via rawpy at full size
|
||||||
|
raw_exts = {'.cr2', '.cr3', '.nef', '.nrw', '.arw', '.srf',
|
||||||
|
'.raf', '.rw2', '.orf', '.srw', '.pef', '.rwl', '.dng'}
|
||||||
|
if ext in raw_exts:
|
||||||
|
try:
|
||||||
|
import rawpy
|
||||||
|
with rawpy.imread(src_path) as raw:
|
||||||
|
rgb = raw.postprocess(use_camera_wb=True, no_auto_bright=False)
|
||||||
|
img = Image.fromarray(rgb, 'RGB')
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"rawpy decode failed for {src_path}: {e}; trying embedded preview")
|
||||||
|
try:
|
||||||
|
import rawpy
|
||||||
|
with rawpy.imread(src_path) as raw:
|
||||||
|
thumb = raw.extract_thumb()
|
||||||
|
if thumb.format == rawpy.ThumbFormat.JPEG:
|
||||||
|
from io import BytesIO
|
||||||
|
img = Image.open(BytesIO(thumb.data))
|
||||||
|
except Exception as e2:
|
||||||
|
logger.error(f"RAW preview extraction also failed for {src_path}: {e2}")
|
||||||
|
raise HTTPException(status_code=415, detail="Unable to decode RAW file")
|
||||||
|
|
||||||
|
# HEIC/HEIF — pillow-heif registers a PIL plugin
|
||||||
|
elif ext in {'.heic', '.heif'}:
|
||||||
|
try:
|
||||||
|
from pillow_heif import register_heif_opener
|
||||||
|
register_heif_opener()
|
||||||
|
img = Image.open(src_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"HEIC decode failed for {src_path}: {e}")
|
||||||
|
raise HTTPException(status_code=415, detail="Unable to decode HEIC file")
|
||||||
|
|
||||||
|
# TIFF and any other PIL-supported format
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
img = Image.open(src_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"PIL open failed for {src_path}: {e}")
|
||||||
|
raise HTTPException(status_code=415, detail="Unable to decode image")
|
||||||
|
|
||||||
|
# Auto-rotate via EXIF
|
||||||
|
try:
|
||||||
|
from PIL import ImageOps
|
||||||
|
img = ImageOps.exif_transpose(img)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if img.mode not in ('RGB', 'RGBA'):
|
||||||
|
img = img.convert('RGB')
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
|
||||||
|
img.save(dst_path, 'WEBP', quality=90, method=4)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{photo_id}/proxy")
|
||||||
|
async def get_proxy(
|
||||||
|
photo_id: str,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Serve a full-resolution WebP proxy for non-web-safe formats (RAW, HEIC,
|
||||||
|
TIFF) so the loupe view can display them inline. Web-safe formats are
|
||||||
|
redirected to /original to avoid pointless transcoding.
|
||||||
|
|
||||||
|
Cached at /data/proxies/{photo_id}.webp; subsequent requests serve the
|
||||||
|
cached file (with optional X-Accel-Redirect for production).
|
||||||
|
"""
|
||||||
|
result = await db.execute(select(Photo).where(Photo.id == photo_id))
|
||||||
|
photo = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not photo:
|
||||||
|
raise HTTPException(status_code=404, detail="Photo not found")
|
||||||
|
|
||||||
|
if not os.path.exists(photo.filepath):
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
ext = Path(photo.filepath).suffix.lower()
|
||||||
|
|
||||||
|
# Web-safe formats don't need a proxy — serve the original directly so the
|
||||||
|
# browser uses its native decoder. Saves disk and CPU.
|
||||||
|
if ext in _WEB_SAFE_DISPLAY_EXTS:
|
||||||
|
return FileResponse(
|
||||||
|
photo.filepath,
|
||||||
|
media_type={
|
||||||
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
|
||||||
|
}[ext],
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy_dir = "/data/proxies"
|
||||||
|
proxy_path = f"{proxy_dir}/{photo_id}.webp"
|
||||||
|
|
||||||
|
if not os.path.exists(proxy_path):
|
||||||
|
try:
|
||||||
|
_generate_proxy_webp(photo.filepath, proxy_path)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Proxy generation failed for {photo_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Proxy generation failed")
|
||||||
|
|
||||||
|
if os.environ.get('USE_X_ACCEL_REDIRECT'):
|
||||||
|
response.headers['X-Accel-Redirect'] = f'/internal_proxies/{photo_id}.webp'
|
||||||
|
response.headers['Content-Type'] = 'image/webp'
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
return FileResponse(proxy_path, media_type='image/webp')
|
||||||
|
|
||||||
@router.patch("/{photo_id}", response_model=PhotoResponse)
|
@router.patch("/{photo_id}", response_model=PhotoResponse)
|
||||||
async def update_photo(
|
async def update_photo(
|
||||||
photo_id: str,
|
photo_id: str,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Timeline } from './components/timeline/Timeline'
|
import { Timeline } from './components/timeline/Timeline'
|
||||||
import { LeftSidebar } from './components/layout/LeftSidebar'
|
import { LeftSidebar } from './components/layout/LeftSidebar'
|
||||||
import { RightSidebar } from './components/layout/RightSidebar'
|
import { RightSidebar } from './components/layout/RightSidebar'
|
||||||
@@ -7,26 +8,39 @@ import { ScanProgress } from './components/ScanProgress'
|
|||||||
import { ToastContainer } from './components/ToastContainer'
|
import { ToastContainer } from './components/ToastContainer'
|
||||||
import { KeyboardShortcuts } from './components/KeyboardShortcuts'
|
import { KeyboardShortcuts } from './components/KeyboardShortcuts'
|
||||||
import { KeyboardHints } from './components/KeyboardHints'
|
import { KeyboardHints } from './components/KeyboardHints'
|
||||||
|
import { LoupeView } from './components/loupe/LoupeView'
|
||||||
import { usePhotoStore } from './store/photoStore'
|
import { usePhotoStore } from './store/photoStore'
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
|
import type { Photo } from './types/photo'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
||||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false)
|
const [rightSidebarOpen, setRightSidebarOpen] = useState(false)
|
||||||
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
|
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
|
||||||
|
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
// Set up global keyboard shortcuts
|
// Set up global keyboard shortcuts
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
|
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
|
||||||
onToggleRightSidebar: () => setRightSidebarOpen(!rightSidebarOpen),
|
onToggleRightSidebar: () => setRightSidebarOpen(!rightSidebarOpen),
|
||||||
|
getFirstPhotoId: () => {
|
||||||
|
const photos = queryClient.getQueryData<Photo[]>(['photos'])
|
||||||
|
return photos && photos.length > 0 ? photos[0].id : null
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Show right sidebar when photos are selected
|
// 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) {
|
if (selectedPhotos.length > 0 && !rightSidebarOpen) {
|
||||||
setRightSidebarOpen(true)
|
setRightSidebarOpen(true)
|
||||||
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
|
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
|
||||||
setRightSidebarOpen(false)
|
setRightSidebarOpen(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-bg text-text">
|
<div className="flex flex-col h-screen bg-bg text-text">
|
||||||
@@ -50,7 +64,7 @@ function App() {
|
|||||||
{/* Right Sidebar */}
|
{/* Right Sidebar */}
|
||||||
<div
|
<div
|
||||||
className={`transition-all duration-200 ${
|
className={`transition-all duration-200 ${
|
||||||
rightSidebarOpen ? 'w-80' : 'w-0'
|
showRightSidebar ? 'w-80' : 'w-0'
|
||||||
} overflow-hidden border-l border-border bg-surface`}
|
} overflow-hidden border-l border-border bg-surface`}
|
||||||
>
|
>
|
||||||
<RightSidebar />
|
<RightSidebar />
|
||||||
@@ -68,6 +82,9 @@ function App() {
|
|||||||
|
|
||||||
{/* Toast Notifications */}
|
{/* Toast Notifications */}
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
|
||||||
|
{/* Loupe overlay — covers TopBar when active */}
|
||||||
|
{viewMode === 'loupe' && <LoupeView />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Star,
|
Star,
|
||||||
@@ -9,45 +9,113 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Check,
|
Check,
|
||||||
Plus
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
|
import { photos as photosApi } from '../../services/api'
|
||||||
|
|
||||||
|
interface PhotoDetails {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
filepath: string
|
||||||
|
width: number | null
|
||||||
|
height: number | null
|
||||||
|
file_size: number | null
|
||||||
|
taken_at: string | null
|
||||||
|
rating: number
|
||||||
|
is_picked: boolean
|
||||||
|
is_rejected: boolean
|
||||||
|
exif_json: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExifData {
|
||||||
|
Make?: string
|
||||||
|
Model?: string
|
||||||
|
LensModel?: string
|
||||||
|
Lens?: string
|
||||||
|
ISO?: number | string
|
||||||
|
FNumber?: number | string
|
||||||
|
ApertureValue?: number | string
|
||||||
|
ExposureTime?: string
|
||||||
|
ShutterSpeedValue?: string
|
||||||
|
FocalLength?: string
|
||||||
|
FocalLengthIn35mmFormat?: string
|
||||||
|
GPSLatitude?: number | string
|
||||||
|
GPSLongitude?: number | string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number | null): string {
|
||||||
|
if (bytes == null) return '—'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExifValue(v: unknown): string {
|
||||||
|
if (v == null || v === '') return '—'
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFirst(exif: ExifData, ...keys: string[]): string {
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = exif[k]
|
||||||
|
if (v != null && v !== '') return String(v)
|
||||||
|
}
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExif(json: string | null): ExifData {
|
||||||
|
if (!json) return {}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json)
|
||||||
|
return typeof parsed === 'object' && parsed !== null ? (parsed as ExifData) : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function RightSidebar() {
|
export function RightSidebar() {
|
||||||
const { selectedPhotos, clearSelection } = usePhotoStore()
|
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||||
new Set(['basic', 'camera', 'location', 'tags'])
|
new Set(['basic', 'camera', 'location'])
|
||||||
)
|
)
|
||||||
const [rating, setRating] = useState(0)
|
|
||||||
const [flagStatus, setFlagStatus] = useState<'none' | 'pick' | 'reject'>('none')
|
|
||||||
|
|
||||||
const toggleSection = (section: string) => {
|
const toggleSection = (section: string) => {
|
||||||
const newExpanded = new Set(expandedSections)
|
const newExpanded = new Set(expandedSections)
|
||||||
if (newExpanded.has(section)) {
|
if (newExpanded.has(section)) newExpanded.delete(section)
|
||||||
newExpanded.delete(section)
|
else newExpanded.add(section)
|
||||||
} else {
|
|
||||||
newExpanded.add(section)
|
|
||||||
}
|
|
||||||
setExpandedSections(newExpanded)
|
setExpandedSections(newExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock photo data - in real app, fetch based on selectedPhotos
|
// Fetch the active photo's full record (with EXIF) on demand.
|
||||||
const mockPhoto = selectedPhotos.length > 0 ? {
|
const { data: photo } = useQuery<PhotoDetails>({
|
||||||
filename: 'IMG_1234.jpg',
|
queryKey: ['photo', activePhotoId],
|
||||||
size: '3.2 MB',
|
queryFn: () => photosApi.get(activePhotoId!),
|
||||||
dimensions: '4032 × 3024',
|
enabled: !!activePhotoId,
|
||||||
dateTaken: new Date('2024-01-15T14:30:00'),
|
staleTime: 60_000,
|
||||||
camera: 'Canon EOS R5',
|
})
|
||||||
lens: 'RF 24-70mm F2.8L IS USM',
|
|
||||||
iso: 400,
|
// Mutations for rating / pick / reject. Optimistic-ish: invalidate the
|
||||||
aperture: 'f/2.8',
|
// photo query and the timeline list query so the grid re-renders too.
|
||||||
shutterSpeed: '1/250',
|
const updateMutation = useMutation({
|
||||||
focalLength: '50mm',
|
mutationFn: (data: {
|
||||||
location: 'San Francisco, CA',
|
rating?: number
|
||||||
tags: ['landscape', 'sunset', 'golden hour'],
|
is_picked?: boolean
|
||||||
} : null
|
is_rejected?: boolean
|
||||||
|
}) => photosApi.update(activePhotoId!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
|
||||||
|
|
||||||
if (selectedPhotos.length === 0) {
|
if (selectedPhotos.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -61,6 +129,9 @@ export function RightSidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const multipleSelected = selectedPhotos.length > 1
|
const multipleSelected = selectedPhotos.length > 1
|
||||||
|
const rating = photo?.rating ?? 0
|
||||||
|
const isPicked = photo?.is_picked ?? false
|
||||||
|
const isRejected = photo?.is_rejected ?? false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
@@ -74,22 +145,26 @@ export function RightSidebar() {
|
|||||||
<button
|
<button
|
||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Clear selection"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions — operate on the active photo */}
|
||||||
|
{photo && !multipleSelected && (
|
||||||
<div className="border-b border-border p-4">
|
<div className="border-b border-border p-4">
|
||||||
{/* Rating Stars */}
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{[1, 2, 3, 4, 5].map((value) => (
|
{[1, 2, 3, 4, 5].map((value) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
onClick={() => setRating(rating === value ? 0 : value)}
|
onClick={() =>
|
||||||
|
updateMutation.mutate({ rating: rating === value ? 0 : value })
|
||||||
|
}
|
||||||
className="p-0.5"
|
className="p-0.5"
|
||||||
|
title={`Set rating to ${value}`}
|
||||||
>
|
>
|
||||||
<Star
|
<Star
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -104,15 +179,19 @@ export function RightSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Flag Status */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setFlagStatus(flagStatus === 'pick' ? 'none' : 'pick')}
|
onClick={() =>
|
||||||
|
updateMutation.mutate({
|
||||||
|
is_picked: !isPicked,
|
||||||
|
is_rejected: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||||
flagStatus === 'pick'
|
isPicked
|
||||||
? 'bg-pick/20 text-pick'
|
? 'bg-pick/20 text-pick'
|
||||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||||
)}
|
)}
|
||||||
@@ -121,10 +200,15 @@ export function RightSidebar() {
|
|||||||
Pick
|
Pick
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFlagStatus(flagStatus === 'reject' ? 'none' : 'reject')}
|
onClick={() =>
|
||||||
|
updateMutation.mutate({
|
||||||
|
is_rejected: !isRejected,
|
||||||
|
is_picked: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||||
flagStatus === 'reject'
|
isRejected
|
||||||
? 'bg-reject/20 text-reject'
|
? 'bg-reject/20 text-reject'
|
||||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||||
)}
|
)}
|
||||||
@@ -135,156 +219,113 @@ export function RightSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Metadata Sections */}
|
{/* Metadata */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{mockPhoto && (
|
{photo && !multipleSelected && (
|
||||||
<>
|
<>
|
||||||
{/* Basic Info */}
|
{/* Basic Info */}
|
||||||
<div className="border-b border-border">
|
<Section
|
||||||
<button
|
title="Basic Info"
|
||||||
onClick={() => toggleSection('basic')}
|
expanded={expandedSections.has('basic')}
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
onToggle={() => toggleSection('basic')}
|
||||||
>
|
>
|
||||||
<span className="font-medium text-text">Basic Info</span>
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
{expandedSections.has('basic') ? (
|
<Field label="Filename" value={photo.filename} />
|
||||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
<Field label="Size" value={formatFileSize(photo.file_size)} />
|
||||||
) : (
|
<Field
|
||||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
label="Dimensions"
|
||||||
)}
|
value={
|
||||||
</button>
|
photo.width && photo.height
|
||||||
{expandedSections.has('basic') && (
|
? `${photo.width} × ${photo.height}`
|
||||||
<div className="px-4 pb-3 text-xs">
|
: '—'
|
||||||
<div className="grid grid-cols-2 gap-2">
|
}
|
||||||
<div>
|
/>
|
||||||
<span className="text-text-muted">Filename:</span>
|
<Field
|
||||||
<p className="text-text">{mockPhoto.filename}</p>
|
label="Date Taken"
|
||||||
</div>
|
value={
|
||||||
<div>
|
photo.taken_at
|
||||||
<span className="text-text-muted">Size:</span>
|
? format(new Date(photo.taken_at), 'MMM d, yyyy HH:mm')
|
||||||
<p className="text-text">{mockPhoto.size}</p>
|
: '—'
|
||||||
</div>
|
}
|
||||||
<div>
|
/>
|
||||||
<span className="text-text-muted">Dimensions:</span>
|
|
||||||
<p className="text-text">{mockPhoto.dimensions}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">Date Taken:</span>
|
|
||||||
<p className="text-text">
|
|
||||||
{format(mockPhoto.dateTaken, 'MMM d, yyyy')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* Camera Info */}
|
{/* Camera */}
|
||||||
<div className="border-b border-border">
|
<Section
|
||||||
<button
|
title="Camera"
|
||||||
onClick={() => toggleSection('camera')}
|
expanded={expandedSections.has('camera')}
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
onToggle={() => toggleSection('camera')}
|
||||||
>
|
>
|
||||||
<span className="font-medium text-text">Camera</span>
|
<div className="space-y-1 text-xs">
|
||||||
{expandedSections.has('camera') ? (
|
|
||||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{expandedSections.has('camera') && (
|
|
||||||
<div className="px-4 pb-3 text-xs">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Camera className="h-3 w-3 text-text-muted" />
|
<Camera className="h-3 w-3 text-text-muted" />
|
||||||
<span className="text-text">{mockPhoto.camera}</span>
|
<span className="text-text">
|
||||||
|
{pickFirst(exif, 'Make', 'Model') === '—'
|
||||||
|
? '—'
|
||||||
|
: `${formatExifValue(exif.Make)} ${formatExifValue(exif.Model)}`.trim()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Aperture className="h-3 w-3 text-text-muted" />
|
<Aperture className="h-3 w-3 text-text-muted" />
|
||||||
<span className="text-text">{mockPhoto.lens}</span>
|
<span className="text-text">
|
||||||
</div>
|
{pickFirst(exif, 'LensModel', 'Lens')}
|
||||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
</span>
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">ISO:</span>
|
|
||||||
<span className="ml-1 text-text">{mockPhoto.iso}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">Aperture:</span>
|
|
||||||
<span className="ml-1 text-text">{mockPhoto.aperture}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">Shutter:</span>
|
|
||||||
<span className="ml-1 text-text">{mockPhoto.shutterSpeed}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-text-muted">Focal:</span>
|
|
||||||
<span className="ml-1 text-text">{mockPhoto.focalLength}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<Field label="ISO" value={formatExifValue(exif.ISO)} />
|
||||||
|
<Field
|
||||||
|
label="Aperture"
|
||||||
|
value={
|
||||||
|
exif.FNumber
|
||||||
|
? `f/${exif.FNumber}`
|
||||||
|
: pickFirst(exif, 'ApertureValue')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Shutter"
|
||||||
|
value={pickFirst(exif, 'ExposureTime', 'ShutterSpeedValue')}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Focal"
|
||||||
|
value={pickFirst(
|
||||||
|
exif,
|
||||||
|
'FocalLength',
|
||||||
|
'FocalLengthIn35mmFormat'
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* Location */}
|
{/* Location */}
|
||||||
<div className="border-b border-border">
|
<Section
|
||||||
<button
|
title="Location"
|
||||||
onClick={() => toggleSection('location')}
|
expanded={expandedSections.has('location')}
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
onToggle={() => toggleSection('location')}
|
||||||
>
|
>
|
||||||
<span className="font-medium text-text">Location</span>
|
{exif.GPSLatitude && exif.GPSLongitude ? (
|
||||||
{expandedSections.has('location') ? (
|
|
||||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{expandedSections.has('location') && (
|
|
||||||
<div className="px-4 pb-3">
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<MapPin className="h-3 w-3 text-text-muted" />
|
<MapPin className="h-3 w-3 text-text-muted" />
|
||||||
<span className="text-text">{mockPhoto.location}</span>
|
<span className="font-mono text-text">
|
||||||
</div>
|
{String(exif.GPSLatitude)}, {String(exif.GPSLongitude)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="border-b border-border">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleSection('tags')}
|
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<span className="font-medium text-text">Tags</span>
|
|
||||||
{expandedSections.has('tags') ? (
|
|
||||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{expandedSections.has('tags') && (
|
|
||||||
<div className="px-4 pb-3">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{mockPhoto.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
|
||||||
<button className="rounded bg-surface-2 px-2 py-0.5 text-xs text-text-muted hover:bg-surface-offset hover:text-text">
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-text-muted">No GPS data</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!photo && !multipleSelected && (
|
||||||
|
<div className="p-4 text-xs text-text-muted">Loading…</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Actions */}
|
{/* Footer Actions for multi-select */}
|
||||||
{multipleSelected && (
|
{multipleSelected && (
|
||||||
<div className="border-t border-border p-3">
|
<div className="border-t border-border p-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -300,3 +341,41 @@ export function RightSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
expanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-text">{title}</span>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-text-muted" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expanded && <div className="px-4 pb-3">{children}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="text-text-muted">{label}:</span>
|
||||||
|
<p className="break-words text-text">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
55
frontend/src/components/loupe/LoupeFilmstrip.tsx
Normal file
55
frontend/src/components/loupe/LoupeFilmstrip.tsx
Normal file
@@ -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<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeRef.current?.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
inline: 'center',
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
}, [currentIndex])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-24 shrink-0 items-center gap-1 overflow-x-auto border-t border-border bg-surface px-2 py-2">
|
||||||
|
{photos.map((photo, index) => {
|
||||||
|
const isActive = index === currentIndex
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={photo.id}
|
||||||
|
ref={isActive ? activeRef : null}
|
||||||
|
onClick={() => onSelect(photo.id)}
|
||||||
|
className={clsx(
|
||||||
|
'shrink-0 overflow-hidden rounded-sm transition-all',
|
||||||
|
'hover:opacity-100',
|
||||||
|
isActive
|
||||||
|
? 'ring-2 ring-primary opacity-100'
|
||||||
|
: 'opacity-60'
|
||||||
|
)}
|
||||||
|
style={{ width: CELL_SIZE, height: CELL_SIZE }}
|
||||||
|
title={photo.filename}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={photosApi.getThumbnailUrl(photo.id, 'small')}
|
||||||
|
alt={photo.filename}
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
179
frontend/src/components/loupe/LoupeImage.tsx
Normal file
179
frontend/src/components/loupe/LoupeImage.tsx
Normal file
@@ -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 <LoupeVideo photo={photo} />
|
||||||
|
}
|
||||||
|
return <LoupeStillImage photo={photo} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoupeVideo({ photo }: { photo: Photo }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center bg-black">
|
||||||
|
<video
|
||||||
|
key={photo.id}
|
||||||
|
src={getVideoSrc(photo)}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
className="max-h-full max-w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLImageElement>(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 (
|
||||||
|
<div
|
||||||
|
className="relative flex flex-1 select-none items-center justify-center overflow-hidden bg-black"
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={endDrag}
|
||||||
|
onMouseLeave={endDrag}
|
||||||
|
style={{ cursor }}
|
||||||
|
>
|
||||||
|
{!loaded && (
|
||||||
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-text-muted">
|
||||||
|
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
key={`${photo.id}-${usingFallback}`}
|
||||||
|
src={src}
|
||||||
|
alt={photo.filename}
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
onLoad={() => 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 && (
|
||||||
|
<div className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded bg-black/60 px-2 py-1 text-xs font-mono text-white">
|
||||||
|
{Math.round(scale * 100)}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
frontend/src/components/loupe/LoupeView.tsx
Normal file
166
frontend/src/components/loupe/LoupeView.tsx
Normal file
@@ -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<HTMLDivElement>(null)
|
||||||
|
const previouslyFocusedRef = useRef<HTMLElement | null>(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<Photo[]>(['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<HTMLElement>(
|
||||||
|
'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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Photo viewer"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="fixed inset-0 z-40 flex flex-col items-center justify-center bg-black text-text-muted outline-none"
|
||||||
|
>
|
||||||
|
<div>No photo to display</div>
|
||||||
|
<button
|
||||||
|
onClick={closeLoupe}
|
||||||
|
className="mt-4 rounded border border-border px-3 py-1 text-sm hover:bg-surface"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Photo viewer: ${currentPhoto.filename}`}
|
||||||
|
tabIndex={-1}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="fixed inset-0 z-40 flex flex-col bg-black outline-none"
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={closeLoupe}
|
||||||
|
className="absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80"
|
||||||
|
title="Close (Esc)"
|
||||||
|
aria-label="Close photo viewer"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Filename + counter */}
|
||||||
|
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-3 py-1.5 text-xs text-white">
|
||||||
|
<div className="font-mono">{currentPhoto.filename}</div>
|
||||||
|
<div className="text-text-muted">
|
||||||
|
{safeIndex + 1} / {photos.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoupeImage photo={currentPhoto} />
|
||||||
|
|
||||||
|
<LoupeFilmstrip
|
||||||
|
photos={photos}
|
||||||
|
currentIndex={safeIndex}
|
||||||
|
onSelect={setActivePhoto}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
frontend/src/components/loupe/loupeSrc.ts
Normal file
33
frontend/src/components/loupe/loupeSrc.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -2,33 +2,21 @@ import { useState, useEffect, useCallback, useRef } from 'react'
|
|||||||
import { Star, Check, X, RefreshCw } from 'lucide-react'
|
import { Star, Check, X, RefreshCw } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { photos as photosApi } from '../../services/api'
|
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
|
// 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.
|
// first hit often 404s. Try a few times with backoff before giving up.
|
||||||
const AUTO_RETRY_DELAYS = [1500, 3500, 6000]
|
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 {
|
interface PhotoThumbnailProps {
|
||||||
photo: Photo
|
photo: Photo
|
||||||
size: number
|
size: number
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
onClick: (e: React.MouseEvent) => void
|
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 [imageError, setImageError] = useState(false)
|
||||||
const [imageLoaded, setImageLoaded] = useState(false)
|
const [imageLoaded, setImageLoaded] = useState(false)
|
||||||
const [retryCount, setRetryCount] = useState(0)
|
const [retryCount, setRetryCount] = useState(0)
|
||||||
@@ -108,7 +96,8 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbn
|
|||||||
height: displayHeight,
|
height: displayHeight,
|
||||||
}}
|
}}
|
||||||
onClick={onClick}
|
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 */}
|
{/* Thumbnail Image */}
|
||||||
{!imageError ? (
|
{!imageError ? (
|
||||||
|
|||||||
@@ -4,21 +4,7 @@ import { usePhotoStore } from '../../store/photoStore'
|
|||||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import type { Photo } from '../../types/photo'
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Timeline() {
|
export function Timeline() {
|
||||||
const parentRef = useRef<HTMLDivElement>(null)
|
const parentRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -31,7 +17,7 @@ export function Timeline() {
|
|||||||
selectPhoto,
|
selectPhoto,
|
||||||
togglePhotoSelection,
|
togglePhotoSelection,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
|
openLoupe,
|
||||||
} = usePhotoStore()
|
} = usePhotoStore()
|
||||||
|
|
||||||
// Helper function for range selection
|
// Helper function for range selection
|
||||||
@@ -247,6 +233,7 @@ export function Timeline() {
|
|||||||
selectPhoto(photo.id, globalIndex)
|
selectPhoto(photo.id, globalIndex)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onDoubleClick={() => openLoupe(photo.id)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
import { usePhotoStore } from '../store/photoStore'
|
||||||
|
|
||||||
interface KeyboardShortcutsProps {
|
interface KeyboardShortcutsProps {
|
||||||
onToggleLeftSidebar: () => void
|
onToggleLeftSidebar: () => void
|
||||||
onToggleRightSidebar: () => void
|
onToggleRightSidebar: () => void
|
||||||
|
/** Returns the first photo id in the current timeline, or null if empty. */
|
||||||
|
getFirstPhotoId?: () => string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||||
const { onToggleLeftSidebar, onToggleRightSidebar } = props
|
const { onToggleLeftSidebar, onToggleRightSidebar, getFirstPhotoId } = props
|
||||||
|
|
||||||
// Toggle sidebars
|
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) => {
|
useHotkeys('tab', (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onToggleLeftSidebar()
|
onToggleLeftSidebar()
|
||||||
@@ -19,37 +31,66 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
onToggleRightSidebar()
|
onToggleRightSidebar()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Navigation shortcuts
|
// Grid view: G always returns to grid (closes loupe if open).
|
||||||
useHotkeys('g', () => {
|
useHotkeys('g', () => {
|
||||||
// Go to grid view
|
closeLoupe()
|
||||||
console.log('Grid view')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
useHotkeys('e', () => {
|
// Loupe view: E toggles loupe (open from grid, close from loupe).
|
||||||
// Go to loupe view
|
// Enter also opens loupe from grid.
|
||||||
console.log('Loupe view')
|
const openLoupeFromGrid = () => {
|
||||||
})
|
const id = activePhotoId ?? getFirstPhotoId?.() ?? null
|
||||||
|
if (id) openLoupe(id)
|
||||||
|
}
|
||||||
|
|
||||||
// Rating shortcuts
|
useHotkeys(
|
||||||
useHotkeys('1,2,3,4,5', (_e, handler) => {
|
'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])
|
const rating = parseInt(handler.keys![0])
|
||||||
console.log('Set rating:', rating)
|
console.log('Set rating:', rating)
|
||||||
})
|
},
|
||||||
|
{ enabled: isGrid }
|
||||||
|
)
|
||||||
|
|
||||||
useHotkeys('0', () => {
|
useHotkeys('0', () => {
|
||||||
console.log('Remove rating')
|
console.log('Remove rating')
|
||||||
})
|
}, { enabled: isGrid })
|
||||||
|
|
||||||
// Flag shortcuts
|
// Flag shortcuts (grid only)
|
||||||
useHotkeys('p', () => {
|
useHotkeys('p', () => {
|
||||||
console.log('Pick photo')
|
console.log('Pick photo')
|
||||||
})
|
}, { enabled: isGrid })
|
||||||
|
|
||||||
useHotkeys('x', () => {
|
useHotkeys('x', () => {
|
||||||
console.log('Reject photo')
|
console.log('Reject photo')
|
||||||
})
|
}, { enabled: isGrid })
|
||||||
|
|
||||||
useHotkeys('u', () => {
|
useHotkeys('u', () => {
|
||||||
console.log('Unflag photo')
|
console.log('Unflag photo')
|
||||||
})
|
}, { enabled: isGrid })
|
||||||
}
|
}
|
||||||
@@ -85,6 +85,12 @@ export const photos = {
|
|||||||
getOriginalUrl: (photoId: string) => {
|
getOriginalUrl: (photoId: string) => {
|
||||||
return `${API_BASE_URL}/photos/${photoId}/original`
|
return `${API_BASE_URL}/photos/${photoId}/original`
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Full-resolution display URL. Backend serves the original for web-safe
|
||||||
|
* formats and a transcoded WebP for RAW/HEIC/TIFF. */
|
||||||
|
getProxyUrl: (photoId: string) => {
|
||||||
|
return `${API_BASE_URL}/photos/${photoId}/proxy`
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Library API
|
// Library API
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import type { Photo } from '../types/photo'
|
||||||
|
|
||||||
interface Photo {
|
type ViewMode = 'grid' | 'loupe'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PhotoStore {
|
interface PhotoStore {
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
@@ -22,6 +9,7 @@ interface PhotoStore {
|
|||||||
activePhotoId: string | null
|
activePhotoId: string | null
|
||||||
lastSelectedIndex: number | null
|
lastSelectedIndex: number | null
|
||||||
rangeStartIndex: number | null
|
rangeStartIndex: number | null
|
||||||
|
viewMode: ViewMode
|
||||||
|
|
||||||
setPhotos: (photos: Photo[]) => void
|
setPhotos: (photos: Photo[]) => void
|
||||||
selectPhoto: (id: string, index: number) => void
|
selectPhoto: (id: string, index: number) => void
|
||||||
@@ -30,6 +18,9 @@ interface PhotoStore {
|
|||||||
deselectPhoto: (id: string) => void
|
deselectPhoto: (id: string) => void
|
||||||
clearSelection: () => void
|
clearSelection: () => void
|
||||||
setActivePhoto: (id: string | null) => void
|
setActivePhoto: (id: string | null) => void
|
||||||
|
setViewMode: (mode: ViewMode) => void
|
||||||
|
openLoupe: (id: string) => void
|
||||||
|
closeLoupe: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePhotoStore = create<PhotoStore>((set) => ({
|
export const usePhotoStore = create<PhotoStore>((set) => ({
|
||||||
@@ -38,6 +29,7 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
|
|||||||
activePhotoId: null,
|
activePhotoId: null,
|
||||||
lastSelectedIndex: null,
|
lastSelectedIndex: null,
|
||||||
rangeStartIndex: null,
|
rangeStartIndex: null,
|
||||||
|
viewMode: 'grid',
|
||||||
|
|
||||||
setPhotos: (photos) => set({ photos }),
|
setPhotos: (photos) => set({ photos }),
|
||||||
|
|
||||||
@@ -78,4 +70,10 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
setActivePhoto: (id) => set({ activePhotoId: id }),
|
setActivePhoto: (id) => set({ activePhotoId: id }),
|
||||||
|
|
||||||
|
setViewMode: (mode) => set({ viewMode: mode }),
|
||||||
|
|
||||||
|
openLoupe: (id) => set({ viewMode: 'loupe', activePhotoId: id }),
|
||||||
|
|
||||||
|
closeLoupe: () => set({ viewMode: 'grid' }),
|
||||||
}))
|
}))
|
||||||
16
frontend/src/types/photo.ts
Normal file
16
frontend/src/types/photo.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user