Compare commits
5 Commits
d2155d9dd2
...
2679214cb9
| Author | SHA1 | Date | |
|---|---|---|---|
| 2679214cb9 | |||
| ce2cda0565 | |||
| f7bf22db29 | |||
| 3e322576f2 | |||
| 6e6672f225 |
@@ -55,7 +55,9 @@ class Photo(Base):
|
|||||||
rating = Column(Integer, default=0) # 0-5 stars
|
rating = Column(Integer, default=0) # 0-5 stars
|
||||||
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
|
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
|
||||||
is_picked = Column(Boolean, default=False)
|
is_picked = Column(Boolean, default=False)
|
||||||
is_rejected = Column(Boolean, default=False)
|
# Note: is_rejected was merged into is_trashed (a single soft "trashed"
|
||||||
|
# concept). The DB column may still exist on legacy installs but is no
|
||||||
|
# longer read or written.
|
||||||
|
|
||||||
# Duplicate detection
|
# Duplicate detection
|
||||||
is_duplicate = Column(Boolean, default=False)
|
is_duplicate = Column(Boolean, default=False)
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ async def list_photos(
|
|||||||
rating_max: Optional[int] = Query(None, ge=0, le=5),
|
rating_max: Optional[int] = Query(None, ge=0, le=5),
|
||||||
color_label: Optional[str] = None,
|
color_label: Optional[str] = None,
|
||||||
is_picked: Optional[bool] = None,
|
is_picked: Optional[bool] = None,
|
||||||
is_rejected: Optional[bool] = None,
|
|
||||||
is_trashed: Optional[bool] = False,
|
is_trashed: Optional[bool] = False,
|
||||||
heap_id: Optional[str] = None,
|
heap_id: Optional[str] = None,
|
||||||
sort: str = "taken_at",
|
sort: str = "taken_at",
|
||||||
@@ -93,10 +92,8 @@ async def list_photos(
|
|||||||
# Flag filters
|
# Flag filters
|
||||||
if is_picked is not None:
|
if is_picked is not None:
|
||||||
filters.append(Photo.is_picked == is_picked)
|
filters.append(Photo.is_picked == is_picked)
|
||||||
if is_rejected is not None:
|
|
||||||
filters.append(Photo.is_rejected == is_rejected)
|
# Trash filter — defaults to hiding trashed photos
|
||||||
|
|
||||||
# Trash filter
|
|
||||||
filters.append(Photo.is_trashed == is_trashed)
|
filters.append(Photo.is_trashed == is_trashed)
|
||||||
|
|
||||||
# Apply all filters
|
# Apply all filters
|
||||||
@@ -414,33 +411,22 @@ async def trash_photo(
|
|||||||
photo_id: str,
|
photo_id: str,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Move photo to trash"""
|
"""Soft-trash a photo: sets is_trashed=true. The file stays on disk so
|
||||||
|
restore is just a flag flip. Permanent deletion happens via DELETE
|
||||||
|
/trash/{id} or DELETE /trash/empty.
|
||||||
|
"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Photo).where(Photo.id == photo_id)
|
select(Photo).where(Photo.id == photo_id)
|
||||||
)
|
)
|
||||||
photo = result.scalar_one_or_none()
|
photo = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not photo:
|
if not photo:
|
||||||
raise HTTPException(status_code=404, detail="Photo not found")
|
raise HTTPException(status_code=404, detail="Photo not found")
|
||||||
|
|
||||||
# Move file to trash directory
|
|
||||||
import shutil
|
|
||||||
trash_dir = f"{settings.trash.path}/{photo_id}"
|
|
||||||
os.makedirs(trash_dir, exist_ok=True)
|
|
||||||
|
|
||||||
trash_path = f"{trash_dir}/original{Path(photo.filepath).suffix}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
shutil.move(photo.filepath, trash_path)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to move file: {e}")
|
|
||||||
|
|
||||||
# Update database
|
|
||||||
photo.is_trashed = True
|
photo.is_trashed = True
|
||||||
photo.trashed_at = datetime.utcnow()
|
photo.trashed_at = datetime.utcnow()
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {"status": "success", "message": "Photo moved to trash"}
|
return {"status": "success", "message": "Photo moved to trash"}
|
||||||
|
|
||||||
@router.post("/bulk")
|
@router.post("/bulk")
|
||||||
@@ -476,11 +462,7 @@ async def bulk_action(
|
|||||||
elif action.action == 'pick':
|
elif action.action == 'pick':
|
||||||
for photo in photos:
|
for photo in photos:
|
||||||
photo.is_picked = True
|
photo.is_picked = True
|
||||||
photo.is_rejected = False
|
photo.is_trashed = False
|
||||||
elif action.action == 'reject':
|
|
||||||
for photo in photos:
|
|
||||||
photo.is_rejected = True
|
|
||||||
photo.is_picked = False
|
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Invalid action")
|
raise HTTPException(status_code=400, detail="Invalid action")
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class PhotoBase(BaseModel):
|
|||||||
rating: int = 0
|
rating: int = 0
|
||||||
color_label: Optional[str] = None
|
color_label: Optional[str] = None
|
||||||
is_picked: bool = False
|
is_picked: bool = False
|
||||||
is_rejected: bool = False
|
|
||||||
|
|
||||||
class PhotoResponse(PhotoBase):
|
class PhotoResponse(PhotoBase):
|
||||||
"""Photo response schema"""
|
"""Photo response schema"""
|
||||||
@@ -53,7 +52,7 @@ class PhotoUpdate(BaseModel):
|
|||||||
rating: Optional[int] = Field(None, ge=0, le=5)
|
rating: Optional[int] = Field(None, ge=0, le=5)
|
||||||
color_label: Optional[str] = None
|
color_label: Optional[str] = None
|
||||||
is_picked: Optional[bool] = None
|
is_picked: Optional[bool] = None
|
||||||
is_rejected: Optional[bool] = None
|
is_trashed: Optional[bool] = None
|
||||||
taken_at: Optional[datetime] = None
|
taken_at: Optional[datetime] = None
|
||||||
|
|
||||||
class PhotoListResponse(BaseModel):
|
class PhotoListResponse(BaseModel):
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ services:
|
|||||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||||
- ~/Pictures:/host/Pictures:ro
|
- ~/Pictures:/host/Pictures:ro
|
||||||
- thumbs_data:/data/thumbs
|
- thumbs_data:/data/thumbs
|
||||||
|
- proxies_data:/data/proxies
|
||||||
- db_data:/data/db
|
- db_data:/data/db
|
||||||
- trash_data:/data/trash
|
- trash_data:/data/trash
|
||||||
environment:
|
environment:
|
||||||
@@ -51,6 +52,7 @@ services:
|
|||||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||||
- ~/Pictures:/host/Pictures:ro
|
- ~/Pictures:/host/Pictures:ro
|
||||||
- thumbs_data:/data/thumbs
|
- thumbs_data:/data/thumbs
|
||||||
|
- proxies_data:/data/proxies
|
||||||
- db_data:/data/db
|
- db_data:/data/db
|
||||||
- trash_data:/data/trash
|
- trash_data:/data/trash
|
||||||
environment:
|
environment:
|
||||||
@@ -85,6 +87,7 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
thumbs_data:
|
thumbs_data:
|
||||||
|
proxies_data:
|
||||||
db_data:
|
db_data:
|
||||||
trash_data:
|
trash_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
@@ -1,43 +1,42 @@
|
|||||||
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'
|
||||||
import { TopBar } from './components/layout/TopBar'
|
import { TopBar } from './components/layout/TopBar'
|
||||||
import { ScanProgress } from './components/ScanProgress'
|
import { ScanProgress } from './components/ScanProgress'
|
||||||
import { ToastContainer } from './components/ToastContainer'
|
import { ToastContainer } from './components/ToastContainer'
|
||||||
import { KeyboardShortcuts } from './components/KeyboardShortcuts'
|
|
||||||
import { KeyboardHints } from './components/KeyboardHints'
|
import { KeyboardHints } from './components/KeyboardHints'
|
||||||
import { LoupeView } from './components/loupe/LoupeView'
|
import { PreviewView } from './components/preview/PreviewView'
|
||||||
import { FilterBar } from './components/filter/FilterBar'
|
import { FilterBar } from './components/filter/FilterBar'
|
||||||
import { ActiveFilterChips } from './components/filter/ActiveFilterChips'
|
import { ActiveFilterChips } from './components/filter/ActiveFilterChips'
|
||||||
import { usePhotoStore } from './store/photoStore'
|
import { usePhotoStore } from './store/photoStore'
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
||||||
import type { Photo } from './types/photo'
|
import { usePhotosQuery } from './hooks/usePhotosQuery'
|
||||||
|
|
||||||
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 viewMode = usePhotoStore((state) => state.viewMode)
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
// Bidirectional sync of filter store with URL query params.
|
// Bidirectional sync of filter store with URL query params.
|
||||||
useFilterUrlSync()
|
useFilterUrlSync()
|
||||||
|
|
||||||
|
// Subscribe to the same photos query the Timeline uses, so the keyboard
|
||||||
|
// "open preview on first photo" path can read from the live cache regardless
|
||||||
|
// of what filter key it's stored under.
|
||||||
|
const { data: allPhotos } = usePhotosQuery()
|
||||||
|
|
||||||
// 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: () => {
|
getFirstPhotoId: () => allPhotos?.[0]?.id ?? null,
|
||||||
const photos = queryClient.getQueryData<Photo[]>(['photos'])
|
|
||||||
return photos && photos.length > 0 ? photos[0].id : null
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-show right sidebar when photos are selected — but only in grid mode,
|
// 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.
|
// so leaving the preview doesn't fight the user's prior sidebar state.
|
||||||
if (viewMode === 'grid') {
|
if (viewMode === 'grid') {
|
||||||
if (selectedPhotos.length > 0 && !rightSidebarOpen) {
|
if (selectedPhotos.length > 0 && !rightSidebarOpen) {
|
||||||
setRightSidebarOpen(true)
|
setRightSidebarOpen(true)
|
||||||
@@ -53,6 +52,7 @@ function App() {
|
|||||||
<TopBar />
|
<TopBar />
|
||||||
<FilterBar />
|
<FilterBar />
|
||||||
<ActiveFilterChips />
|
<ActiveFilterChips />
|
||||||
|
<KeyboardHints />
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Left Sidebar */}
|
{/* Left Sidebar */}
|
||||||
@@ -79,20 +79,14 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contextual Keyboard Hints */}
|
|
||||||
<KeyboardHints />
|
|
||||||
|
|
||||||
{/* Keyboard Shortcuts Legend */}
|
|
||||||
<KeyboardShortcuts />
|
|
||||||
|
|
||||||
{/* Scan Progress Indicator */}
|
{/* Scan Progress Indicator */}
|
||||||
<ScanProgress />
|
<ScanProgress />
|
||||||
|
|
||||||
{/* Toast Notifications */}
|
{/* Toast Notifications */}
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
|
||||||
{/* Loupe overlay — covers TopBar when active */}
|
{/* Preview overlay — covers TopBar when active */}
|
||||||
{viewMode === 'loupe' && <LoupeView />}
|
{viewMode === 'preview' && <PreviewView />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,27 +2,35 @@ import { usePhotoStore } from '../store/photoStore'
|
|||||||
|
|
||||||
export function KeyboardHints() {
|
export function KeyboardHints() {
|
||||||
const selectedCount = usePhotoStore((state) => state.selectedPhotos.length)
|
const selectedCount = usePhotoStore((state) => state.selectedPhotos.length)
|
||||||
|
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||||
|
|
||||||
const hints = selectedCount > 0 ? [
|
// In preview mode the viewer has its own context, so the grid hints
|
||||||
{ key: '1-5', action: 'Rate' },
|
// would just be confusing. Hide them.
|
||||||
{ key: 'P', action: 'Pick' },
|
if (viewMode === 'preview') return null
|
||||||
{ key: 'X', action: 'Reject' },
|
|
||||||
{ key: 'Delete', action: 'Trash' },
|
const hints = selectedCount > 0
|
||||||
{ key: 'Esc', action: 'Deselect' },
|
? [
|
||||||
] : [
|
{ key: '1-5', action: 'Rate' },
|
||||||
{ key: '↑↓←→', action: 'Navigate' },
|
{ key: 'P', action: 'Pick' },
|
||||||
{ key: 'Click', action: 'Select' },
|
{ key: 'X', action: 'Trash' },
|
||||||
{ key: 'Shift+Click', action: 'Range' },
|
{ key: 'E / Space', action: 'Preview' },
|
||||||
{ key: 'Ctrl+A', action: 'Select All' },
|
{ key: 'Esc', action: 'Deselect' },
|
||||||
{ key: 'Space', action: 'Preview' },
|
]
|
||||||
]
|
: [
|
||||||
|
{ key: '↑↓←→', action: 'Navigate' },
|
||||||
|
{ key: 'Click', action: 'Select' },
|
||||||
|
{ key: 'Shift+Click', action: 'Range' },
|
||||||
|
{ key: 'E / Space', action: 'Preview' },
|
||||||
|
{ key: '\\', action: 'Filters' },
|
||||||
|
{ key: '/', action: 'Search' },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-14 left-1/2 z-20 -translate-x-1/2">
|
<div className="flex justify-center border-b border-border bg-surface/60 px-4 py-1.5">
|
||||||
<div className="flex items-center gap-3 rounded-full border border-border bg-surface/90 px-4 py-2 shadow-lg backdrop-blur-sm">
|
<div className="flex items-center gap-3">
|
||||||
{hints.map((hint, i) => (
|
{hints.map((hint, i) => (
|
||||||
<div key={i} className="flex items-center gap-1.5">
|
<div key={i} className="flex items-center gap-1.5">
|
||||||
<kbd className="rounded bg-surface-offset px-2 py-0.5 text-xs font-medium text-text">
|
<kbd className="rounded bg-surface-offset px-2 py-0.5 text-[11px] font-medium text-text">
|
||||||
{hint.key}
|
{hint.key}
|
||||||
</kbd>
|
</kbd>
|
||||||
<span className="text-xs text-text-muted">{hint.action}</span>
|
<span className="text-xs text-text-muted">{hint.action}</span>
|
||||||
@@ -42,4 +50,4 @@ export function KeyboardHints() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Keyboard, ChevronRight, ChevronDown, X } from 'lucide-react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
interface Shortcut {
|
|
||||||
keys: string[]
|
|
||||||
description: string
|
|
||||||
category: 'navigation' | 'selection' | 'actions' | 'view'
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortcuts: Shortcut[] = [
|
|
||||||
// Navigation
|
|
||||||
{ keys: ['↑', '↓', '←', '→'], description: 'Navigate photos', category: 'navigation' },
|
|
||||||
{ keys: ['Space'], description: 'Quick preview', category: 'navigation' },
|
|
||||||
{ keys: ['Enter'], description: 'Open in loupe view', category: 'navigation' },
|
|
||||||
|
|
||||||
// Selection
|
|
||||||
{ keys: ['Click'], description: 'Select photo', category: 'selection' },
|
|
||||||
{ keys: ['Shift', 'Click'], description: 'Select range', category: 'selection' },
|
|
||||||
{ keys: ['Ctrl/Cmd', 'Click'], description: 'Add to selection', category: 'selection' },
|
|
||||||
{ keys: ['Ctrl/Cmd', 'A'], description: 'Select all', category: 'selection' },
|
|
||||||
{ keys: ['Escape'], description: 'Clear selection', category: 'selection' },
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
{ keys: ['1-5'], description: 'Set rating', category: 'actions' },
|
|
||||||
{ keys: ['0'], description: 'Remove rating', category: 'actions' },
|
|
||||||
{ keys: ['P'], description: 'Pick photo', category: 'actions' },
|
|
||||||
{ keys: ['X'], description: 'Reject photo', category: 'actions' },
|
|
||||||
{ keys: ['U'], description: 'Unflag photo', category: 'actions' },
|
|
||||||
{ keys: ['Delete'], description: 'Move to trash', category: 'actions' },
|
|
||||||
|
|
||||||
// View
|
|
||||||
{ keys: ['Tab'], description: 'Toggle left sidebar', category: 'view' },
|
|
||||||
{ keys: ['I'], description: 'Toggle info panel', category: 'view' },
|
|
||||||
{ keys: ['G'], description: 'Grid view', category: 'view' },
|
|
||||||
{ keys: ['E'], description: 'Loupe view', category: 'view' },
|
|
||||||
{ keys: ['F'], description: 'Fullscreen', category: 'view' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function KeyboardShortcuts() {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(true)
|
|
||||||
const [isMinimized, setIsMinimized] = useState(false)
|
|
||||||
|
|
||||||
const categories = {
|
|
||||||
navigation: { label: 'Navigation', color: 'text-primary' },
|
|
||||||
selection: { label: 'Selection', color: 'text-pick' },
|
|
||||||
actions: { label: 'Actions', color: 'text-star' },
|
|
||||||
view: { label: 'View', color: 'text-text' },
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMinimized) {
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-4 left-4 z-30">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsMinimized(false)}
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-border bg-surface/90 px-3 py-2 text-sm backdrop-blur-sm hover:bg-surface"
|
|
||||||
title="Show keyboard shortcuts"
|
|
||||||
>
|
|
||||||
<Keyboard className="h-4 w-4 text-primary" />
|
|
||||||
<span className="text-text-muted">Shortcuts</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-4 left-4 z-30 w-80 overflow-hidden rounded-lg border border-border bg-surface/95 shadow-xl backdrop-blur-sm">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between bg-surface-2 px-3 py-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Keyboard className="h-4 w-4 text-primary" />
|
|
||||||
<span className="text-sm font-medium text-text">Keyboard Shortcuts</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
className="rounded p-1 text-text-muted hover:bg-surface-offset hover:text-text"
|
|
||||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsMinimized(true)}
|
|
||||||
className="rounded p-1 text-text-muted hover:bg-surface-offset hover:text-text"
|
|
||||||
title="Minimize"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="max-h-96 overflow-y-auto p-2">
|
|
||||||
{Object.entries(categories).map(([category, { label, color }]) => (
|
|
||||||
<div key={category} className="mb-3">
|
|
||||||
<h3 className={clsx('mb-1.5 text-xs font-semibold uppercase', color)}>
|
|
||||||
{label}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{shortcuts
|
|
||||||
.filter(s => s.category === category)
|
|
||||||
.map((shortcut, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex items-center justify-between rounded px-2 py-1 hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<span className="text-xs text-text-muted">
|
|
||||||
{shortcut.description}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{shortcut.keys.map((key, j) => (
|
|
||||||
<span key={j} className="flex items-center">
|
|
||||||
<kbd className="rounded bg-surface-offset px-1.5 py-0.5 text-[10px] font-medium text-text">
|
|
||||||
{key}
|
|
||||||
</kbd>
|
|
||||||
{j < shortcut.keys.length - 1 && (
|
|
||||||
<span className="mx-0.5 text-[10px] text-text-muted">+</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer Hint */}
|
|
||||||
{!isExpanded && (
|
|
||||||
<div className="px-3 pb-2 pt-1">
|
|
||||||
<p className="text-xs text-text-muted">Click to expand shortcuts list</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,7 @@ const COLOR_LABELS: { value: ColorLabel; className: string }[] = [
|
|||||||
const FLAG_OPTIONS: { value: FlagFilter; label: string }[] = [
|
const FLAG_OPTIONS: { value: FlagFilter; label: string }[] = [
|
||||||
{ value: 'any', label: 'Any' },
|
{ value: 'any', label: 'Any' },
|
||||||
{ value: 'picked', label: 'Picked' },
|
{ value: 'picked', label: 'Picked' },
|
||||||
{ value: 'rejected', label: 'Rejected' },
|
{ value: 'trashed', label: 'Trashed' },
|
||||||
{ value: 'unflagged', label: 'Unflagged' },
|
{ value: 'unflagged', label: 'Unflagged' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Check,
|
Check,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
@@ -26,7 +27,7 @@ interface PhotoDetails {
|
|||||||
taken_at: string | null
|
taken_at: string | null
|
||||||
rating: number
|
rating: number
|
||||||
is_picked: boolean
|
is_picked: boolean
|
||||||
is_rejected: boolean
|
is_trashed: boolean
|
||||||
exif_json: string | null
|
exif_json: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ export function RightSidebar() {
|
|||||||
mutationFn: (data: {
|
mutationFn: (data: {
|
||||||
rating?: number
|
rating?: number
|
||||||
is_picked?: boolean
|
is_picked?: boolean
|
||||||
is_rejected?: boolean
|
is_trashed?: boolean
|
||||||
}) => photosApi.update(activePhotoId!, data),
|
}) => photosApi.update(activePhotoId!, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
|
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
|
||||||
@@ -131,7 +132,7 @@ export function RightSidebar() {
|
|||||||
const multipleSelected = selectedPhotos.length > 1
|
const multipleSelected = selectedPhotos.length > 1
|
||||||
const rating = photo?.rating ?? 0
|
const rating = photo?.rating ?? 0
|
||||||
const isPicked = photo?.is_picked ?? false
|
const isPicked = photo?.is_picked ?? false
|
||||||
const isRejected = photo?.is_rejected ?? false
|
const isTrashed = photo?.is_trashed ?? false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
@@ -186,7 +187,7 @@ export function RightSidebar() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
is_picked: !isPicked,
|
is_picked: !isPicked,
|
||||||
is_rejected: false,
|
is_trashed: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -202,19 +203,19 @@ export function RightSidebar() {
|
|||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
is_rejected: !isRejected,
|
is_trashed: !isTrashed,
|
||||||
is_picked: false,
|
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',
|
||||||
isRejected
|
isTrashed
|
||||||
? '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'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
Reject
|
Trash
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import clsx from 'clsx'
|
|||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import { photos as photosApi } from '../../services/api'
|
import { photos as photosApi } from '../../services/api'
|
||||||
|
|
||||||
interface LoupeFilmstripProps {
|
interface PreviewFilmstripProps {
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
currentIndex: number
|
currentIndex: number
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
@@ -11,7 +11,7 @@ interface LoupeFilmstripProps {
|
|||||||
|
|
||||||
const CELL_SIZE = 72
|
const CELL_SIZE = 72
|
||||||
|
|
||||||
export function LoupeFilmstrip({ photos, currentIndex, onSelect }: LoupeFilmstripProps) {
|
export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilmstripProps) {
|
||||||
const activeRef = useRef<HTMLButtonElement>(null)
|
const activeRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import clsx from 'clsx'
|
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import {
|
import {
|
||||||
getLoupeImageSrc,
|
getPreviewImageSrc,
|
||||||
getLoupeFallbackSrc,
|
getPreviewFallbackSrc,
|
||||||
getVideoSrc,
|
getVideoSrc,
|
||||||
isVideo,
|
isVideo,
|
||||||
} from './loupeSrc'
|
} from './previewSrc'
|
||||||
|
|
||||||
interface LoupeImageProps {
|
interface PreviewImageProps {
|
||||||
photo: Photo
|
photo: Photo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,14 +16,14 @@ const MIN_SCALE = 1
|
|||||||
const MAX_SCALE = 8
|
const MAX_SCALE = 8
|
||||||
const WHEEL_STEP = 1.15
|
const WHEEL_STEP = 1.15
|
||||||
|
|
||||||
export function LoupeImage({ photo }: LoupeImageProps) {
|
export function PreviewImage({ photo }: PreviewImageProps) {
|
||||||
if (isVideo(photo)) {
|
if (isVideo(photo)) {
|
||||||
return <LoupeVideo photo={photo} />
|
return <PreviewVideo photo={photo} />
|
||||||
}
|
}
|
||||||
return <LoupeStillImage photo={photo} />
|
return <PreviewStillImage photo={photo} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoupeVideo({ photo }: { photo: Photo }) {
|
function PreviewVideo({ photo }: { photo: Photo }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-center bg-black">
|
<div className="flex flex-1 items-center justify-center bg-black">
|
||||||
<video
|
<video
|
||||||
@@ -39,7 +38,7 @@ function LoupeVideo({ photo }: { photo: Photo }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoupeStillImage({ photo }: { photo: Photo }) {
|
function PreviewStillImage({ photo }: { photo: Photo }) {
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [usingFallback, setUsingFallback] = useState(false)
|
const [usingFallback, setUsingFallback] = useState(false)
|
||||||
|
|
||||||
@@ -58,8 +57,8 @@ function LoupeStillImage({ photo }: { photo: Photo }) {
|
|||||||
setOffset({ x: 0, y: 0 })
|
setOffset({ x: 0, y: 0 })
|
||||||
}, [photo.id])
|
}, [photo.id])
|
||||||
|
|
||||||
const primarySrc = getLoupeImageSrc(photo)
|
const primarySrc = getPreviewImageSrc(photo)
|
||||||
const fallbackSrc = getLoupeFallbackSrc(photo)
|
const fallbackSrc = getPreviewFallbackSrc(photo)
|
||||||
const src = usingFallback ? fallbackSrc : primarySrc
|
const src = usingFallback ? fallbackSrc : primarySrc
|
||||||
|
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
@@ -83,10 +82,15 @@ function LoupeStillImage({ photo }: { photo: Photo }) {
|
|||||||
setScale(Math.min(ratio, MAX_SCALE))
|
setScale(Math.min(ratio, MAX_SCALE))
|
||||||
}, [scale])
|
}, [scale])
|
||||||
|
|
||||||
useHotkeys('z', (e) => {
|
useHotkeys(
|
||||||
e.preventDefault()
|
'z',
|
||||||
toggleZoom()
|
(e) => {
|
||||||
}, [toggleZoom])
|
e.preventDefault()
|
||||||
|
toggleZoom()
|
||||||
|
},
|
||||||
|
{ preventDefault: true },
|
||||||
|
[toggleZoom]
|
||||||
|
)
|
||||||
|
|
||||||
const handleWheel = (e: React.WheelEvent) => {
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -140,11 +144,6 @@ function LoupeStillImage({ photo }: { photo: Photo }) {
|
|||||||
onMouseLeave={endDrag}
|
onMouseLeave={endDrag}
|
||||||
style={{ cursor }}
|
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
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
key={`${photo.id}-${usingFallback}`}
|
key={`${photo.id}-${usingFallback}`}
|
||||||
@@ -155,18 +154,18 @@ function LoupeStillImage({ photo }: { photo: Photo }) {
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
onLoad={() => setLoaded(true)}
|
onLoad={() => setLoaded(true)}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
className={clsx(
|
className="max-h-full max-w-full object-contain"
|
||||||
'max-h-full max-w-full object-contain transition-opacity duration-150',
|
|
||||||
loaded ? 'opacity-100' : 'opacity-0'
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
||||||
transformOrigin: 'center center',
|
transformOrigin: 'center center',
|
||||||
// Disable transition during pan/zoom — only fade-in is animated.
|
|
||||||
transition: 'opacity 150ms',
|
|
||||||
willChange: 'transform',
|
willChange: 'transform',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{!loaded && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-text-muted">
|
||||||
|
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Zoom indicator */}
|
{/* Zoom indicator */}
|
||||||
{isZoomed && (
|
{isZoomed && (
|
||||||
@@ -1,25 +1,24 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import { LoupeImage } from './LoupeImage'
|
import { PreviewImage } from './PreviewImage'
|
||||||
import { LoupeFilmstrip } from './LoupeFilmstrip'
|
import { PreviewFilmstrip } from './PreviewFilmstrip'
|
||||||
import { getLoupeImageSrc, isVideo } from './loupeSrc'
|
import { getPreviewImageSrc, isVideo } from './previewSrc'
|
||||||
|
|
||||||
export function LoupeView() {
|
export function PreviewView() {
|
||||||
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
||||||
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
|
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
|
||||||
const closeLoupe = usePhotoStore((s) => s.closeLoupe)
|
const closePreview = usePhotoStore((s) => s.closePreview)
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
|
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
// Read photos from the TanStack Query cache populated by Timeline.
|
// Same hook Timeline uses, so we share one cache entry rather than looking
|
||||||
// Same query key so we share the cache and never refetch.
|
// it up by key (which broke when the key gained the filter params).
|
||||||
const queryClient = useQueryClient()
|
const { data: photos = [] } = usePhotosQuery()
|
||||||
const photos = queryClient.getQueryData<Photo[]>(['photos']) ?? []
|
|
||||||
|
|
||||||
const currentIndex = activePhotoId
|
const currentIndex = activePhotoId
|
||||||
? photos.findIndex((p) => p.id === activePhotoId)
|
? photos.findIndex((p) => p.id === activePhotoId)
|
||||||
@@ -39,21 +38,10 @@ export function LoupeView() {
|
|||||||
setActivePhoto(photos[next].id)
|
setActivePhoto(photos[next].id)
|
||||||
}, [photos, safeIndex, setActivePhoto])
|
}, [photos, safeIndex, setActivePhoto])
|
||||||
|
|
||||||
// Loupe-scoped hotkeys: only mounted while LoupeView is rendered.
|
// Preview-scoped hotkeys: only mounted while PreviewView is rendered.
|
||||||
useHotkeys('escape', (e) => {
|
useHotkeys('escape', closePreview, { preventDefault: true })
|
||||||
e.preventDefault()
|
useHotkeys('left', goPrev, { preventDefault: true }, [goPrev])
|
||||||
closeLoupe()
|
useHotkeys('right', goNext, { preventDefault: true }, [goNext])
|
||||||
})
|
|
||||||
|
|
||||||
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
|
// Preload the immediate neighbors so arrow nav feels instant. Skip videos
|
||||||
// (browsers can't preload them via Image()) and skip when at the edges.
|
// (browsers can't preload them via Image()) and skip when at the edges.
|
||||||
@@ -64,13 +52,13 @@ export function LoupeView() {
|
|||||||
for (const p of neighbors) {
|
for (const p of neighbors) {
|
||||||
if (isVideo(p)) continue
|
if (isVideo(p)) continue
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
img.src = getLoupeImageSrc(p)
|
img.src = getPreviewImageSrc(p)
|
||||||
}
|
}
|
||||||
}, [safeIndex, photos])
|
}, [safeIndex, photos])
|
||||||
|
|
||||||
// Focus trap: focus the loupe container on mount, restore focus on unmount.
|
// Focus trap: focus the preview container on mount, restore focus on
|
||||||
// The container is keyboard-focusable (tabIndex=-1) so screen readers and
|
// unmount. The container is keyboard-focusable (tabIndex=-1) so screen
|
||||||
// tab navigation stay scoped here.
|
// readers and tab navigation stay scoped here.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
previouslyFocusedRef.current = document.activeElement as HTMLElement | null
|
previouslyFocusedRef.current = document.activeElement as HTMLElement | null
|
||||||
containerRef.current?.focus()
|
containerRef.current?.focus()
|
||||||
@@ -111,13 +99,13 @@ export function LoupeView() {
|
|||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Photo viewer"
|
aria-label="Photo preview"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="fixed inset-0 z-40 flex flex-col items-center justify-center bg-black text-text-muted outline-none"
|
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>
|
<div>No photo to display</div>
|
||||||
<button
|
<button
|
||||||
onClick={closeLoupe}
|
onClick={closePreview}
|
||||||
className="mt-4 rounded border border-border px-3 py-1 text-sm hover:bg-surface"
|
className="mt-4 rounded border border-border px-3 py-1 text-sm hover:bg-surface"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
@@ -131,17 +119,17 @@ export function LoupeView() {
|
|||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={`Photo viewer: ${currentPhoto.filename}`}
|
aria-label={`Photo preview: ${currentPhoto.filename}`}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="fixed inset-0 z-40 flex flex-col bg-black outline-none"
|
className="fixed inset-0 z-40 flex flex-col bg-black outline-none"
|
||||||
>
|
>
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
onClick={closeLoupe}
|
onClick={closePreview}
|
||||||
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"
|
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)"
|
title="Close (Esc)"
|
||||||
aria-label="Close photo viewer"
|
aria-label="Close preview"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -154,9 +142,9 @@ export function LoupeView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LoupeImage photo={currentPhoto} />
|
<PreviewImage photo={currentPhoto} />
|
||||||
|
|
||||||
<LoupeFilmstrip
|
<PreviewFilmstrip
|
||||||
photos={photos}
|
photos={photos}
|
||||||
currentIndex={safeIndex}
|
currentIndex={safeIndex}
|
||||||
onSelect={setActivePhoto}
|
onSelect={setActivePhoto}
|
||||||
@@ -6,11 +6,11 @@ const VIDEO_EXTENSIONS = ['.mp4', '.mov', '.webm', '.mkv', '.m4v']
|
|||||||
export function isVideo(photo: Photo): boolean {
|
export function isVideo(photo: Photo): boolean {
|
||||||
if (photo.media_type === 'video') return true
|
if (photo.media_type === 'video') return true
|
||||||
const lower = photo.filepath.toLowerCase()
|
const lower = photo.filepath.toLowerCase()
|
||||||
return VIDEO_EXTENSIONS.some(ext => lower.endsWith(ext))
|
return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the best display URL for a still photo in the loupe view.
|
* Pick the best display URL for a still photo in the preview view.
|
||||||
*
|
*
|
||||||
* Always uses the /proxy endpoint, which the backend resolves to:
|
* Always uses the /proxy endpoint, which the backend resolves to:
|
||||||
* - the original file for web-safe formats (JPEG/PNG/WebP/GIF)
|
* - the original file for web-safe formats (JPEG/PNG/WebP/GIF)
|
||||||
@@ -18,13 +18,13 @@ export function isVideo(photo: Photo): boolean {
|
|||||||
*
|
*
|
||||||
* Videos go through `getVideoSrc` instead and use /original directly.
|
* Videos go through `getVideoSrc` instead and use /original directly.
|
||||||
*/
|
*/
|
||||||
export function getLoupeImageSrc(photo: Photo): string {
|
export function getPreviewImageSrc(photo: Photo): string {
|
||||||
return photosApi.getProxyUrl(photo.id)
|
return photosApi.getProxyUrl(photo.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fallback used when the proxy endpoint fails or 404s — shows the 1280px
|
/** Fallback used when the proxy endpoint fails or 404s — shows the 1280px
|
||||||
* large thumbnail so the user still sees something. */
|
* large thumbnail so the user still sees something. */
|
||||||
export function getLoupeFallbackSrc(photo: Photo): string {
|
export function getPreviewFallbackSrc(photo: Photo): string {
|
||||||
return photosApi.getThumbnailUrl(photo.id, 'large')
|
return photosApi.getThumbnailUrl(photo.id, 'large')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Star, Check, X, RefreshCw } from 'lucide-react'
|
import { Star, Check, Trash2, 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'
|
import type { Photo } from '../../types/photo'
|
||||||
@@ -28,9 +28,11 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick
|
|||||||
const baseUrl = photosApi.getThumbnailUrl(photo.id, 'medium')
|
const baseUrl = photosApi.getThumbnailUrl(photo.id, 'medium')
|
||||||
const thumbnailUrl = retryCount > 0 ? `${baseUrl}?retry=${retryCount}` : baseUrl
|
const thumbnailUrl = retryCount > 0 ? `${baseUrl}?retry=${retryCount}` : baseUrl
|
||||||
|
|
||||||
// Calculate aspect ratio for proper sizing (default to 1:1 if dimensions unknown)
|
// Square cells (Lightroom-style grid). Variable-aspect cells previously
|
||||||
const aspectRatio = (photo.height && photo.width) ? photo.height / photo.width : 1
|
// overflowed their row because TanStack Virtual estimates row height as a
|
||||||
const displayHeight = size * Math.min(aspectRatio, 1.5) // Cap height at 1.5x width
|
// single fixed value — portraits in a landscape row would overlap the row
|
||||||
|
// below. With object-cover the image still fills the cell, just cropped.
|
||||||
|
const displayHeight = size
|
||||||
|
|
||||||
const clearRetryTimer = () => {
|
const clearRetryTimer = () => {
|
||||||
if (retryTimerRef.current !== null) {
|
if (retryTimerRef.current !== null) {
|
||||||
@@ -169,8 +171,8 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick
|
|||||||
{photo.is_picked && (
|
{photo.is_picked && (
|
||||||
<Check className="h-4 w-4 text-pick" />
|
<Check className="h-4 w-4 text-pick" />
|
||||||
)}
|
)}
|
||||||
{photo.is_rejected && (
|
{photo.is_trashed && (
|
||||||
<X className="h-4 w-4 text-reject" />
|
<Trash2 className="h-4 w-4 text-reject" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useRef, useEffect, useMemo, useState } from 'react'
|
import { useRef, useEffect, useMemo, useState } from 'react'
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import { useFilterStore, filtersToParams } from '../../store/filterStore'
|
|
||||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||||
import axios from 'axios'
|
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
|
|
||||||
export function Timeline() {
|
export function Timeline() {
|
||||||
@@ -18,7 +16,7 @@ export function Timeline() {
|
|||||||
selectPhoto,
|
selectPhoto,
|
||||||
togglePhotoSelection,
|
togglePhotoSelection,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
openLoupe,
|
openPreview,
|
||||||
} = usePhotoStore()
|
} = usePhotoStore()
|
||||||
|
|
||||||
// Helper function for range selection
|
// Helper function for range selection
|
||||||
@@ -46,51 +44,9 @@ export function Timeline() {
|
|||||||
return Math.floor((containerWidth - padding * 2) / (thumbnailSize + gap))
|
return Math.floor((containerWidth - padding * 2) / (thumbnailSize + gap))
|
||||||
}, [containerWidth, thumbnailSize, gap, padding])
|
}, [containerWidth, thumbnailSize, gap, padding])
|
||||||
|
|
||||||
// Filter state — included in the query key so the cache invalidates when
|
// Shared photos query — both Timeline and PreviewView use the same hook so
|
||||||
// any filter changes. Subscribing field-by-field keeps re-renders cheap.
|
// they share one cache entry, regardless of filter state.
|
||||||
const q = useFilterStore((s) => s.q)
|
const { data: photos = [], isLoading } = usePhotosQuery()
|
||||||
const dateFrom = useFilterStore((s) => s.dateFrom)
|
|
||||||
const dateTo = useFilterStore((s) => s.dateTo)
|
|
||||||
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
|
||||||
const ratingMin = useFilterStore((s) => s.ratingMin)
|
|
||||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
|
||||||
const flag = useFilterStore((s) => s.flag)
|
|
||||||
|
|
||||||
const filterParams = useMemo(
|
|
||||||
() =>
|
|
||||||
filtersToParams({
|
|
||||||
q,
|
|
||||||
dateFrom,
|
|
||||||
dateTo,
|
|
||||||
mediaTypes,
|
|
||||||
ratingMin,
|
|
||||||
colorLabel,
|
|
||||||
flag,
|
|
||||||
}),
|
|
||||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fetch photos from backend. Note: backend uses page/per_page (max 500),
|
|
||||||
// not limit/offset — sending limit/offset previously was a silent no-op.
|
|
||||||
const { data: photos = [], isLoading } = useQuery({
|
|
||||||
queryKey: ['photos', filterParams],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await axios.get<{ photos: Photo[]; total: number }>(
|
|
||||||
'http://localhost:8001/api/v1/photos',
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
page: 1,
|
|
||||||
per_page: 500,
|
|
||||||
sort: 'taken_at',
|
|
||||||
order: 'desc',
|
|
||||||
...filterParams,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return response.data.photos || []
|
|
||||||
},
|
|
||||||
staleTime: 30000,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Group photos into rows for grid layout
|
// Group photos into rows for grid layout
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
@@ -265,7 +221,7 @@ export function Timeline() {
|
|||||||
selectPhoto(photo.id, globalIndex)
|
selectPhoto(photo.id, globalIndex)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => openLoupe(photo.id)}
|
onDoubleClick={() => openPreview(photo.id)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const ALLOWED_COLORS: ColorLabel[] = [
|
|||||||
'blue',
|
'blue',
|
||||||
'purple',
|
'purple',
|
||||||
]
|
]
|
||||||
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'rejected', 'unflagged']
|
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'trashed', 'unflagged']
|
||||||
|
|
||||||
function parseUrl(): Partial<FilterState> {
|
function parseUrl(): Partial<FilterState> {
|
||||||
const sp = new URLSearchParams(window.location.search)
|
const sp = new URLSearchParams(window.location.search)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface KeyboardShortcutsProps {
|
|||||||
interface PhotoUpdate {
|
interface PhotoUpdate {
|
||||||
rating?: number
|
rating?: number
|
||||||
is_picked?: boolean
|
is_picked?: boolean
|
||||||
is_rejected?: boolean
|
is_trashed?: boolean
|
||||||
color_label?: string | null
|
color_label?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,15 +26,20 @@ const COLOR_LABELS: Record<string, string> = {
|
|||||||
'9': 'green',
|
'9': 'green',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default options shared by every shortcut: preventDefault stops the browser
|
||||||
|
// from claiming the event (Firefox quick-find on letter keys, Cmd+F search,
|
||||||
|
// `/` quick-find, Tab focus traversal). enableOnFormTags is left default-off
|
||||||
|
// so typing in inputs doesn't fire culling shortcuts.
|
||||||
|
const HK_OPTS = { preventDefault: true } as const
|
||||||
|
|
||||||
export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||||
const { onToggleLeftSidebar, onToggleRightSidebar, getFirstPhotoId } = props
|
const { onToggleLeftSidebar, onToggleRightSidebar, getFirstPhotoId } = props
|
||||||
|
|
||||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||||
const openLoupe = usePhotoStore((s) => s.openLoupe)
|
const openPreview = usePhotoStore((s) => s.openPreview)
|
||||||
const closeLoupe = usePhotoStore((s) => s.closeLoupe)
|
const closePreview = usePhotoStore((s) => s.closePreview)
|
||||||
|
|
||||||
const isGrid = viewMode === 'grid'
|
const isPreview = viewMode === 'preview'
|
||||||
const isLoupe = viewMode === 'loupe'
|
|
||||||
|
|
||||||
// Photo mutation shared by every culling shortcut. Reads the active photo
|
// Photo mutation shared by every culling shortcut. Reads the active photo
|
||||||
// id from the store at fire time so the closure stays fresh without forcing
|
// id from the store at fire time so the closure stays fresh without forcing
|
||||||
@@ -55,96 +60,78 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
updateMutation.mutate({ id, data })
|
updateMutation.mutate({ id, data })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle sidebars (allowed in both modes; right sidebar is hidden in loupe
|
// Toggle sidebars
|
||||||
// by App-level CSS so toggling it is effectively grid-only.)
|
useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
|
||||||
useHotkeys('tab', (e) => {
|
useHotkeys('i', onToggleRightSidebar, HK_OPTS)
|
||||||
e.preventDefault()
|
|
||||||
onToggleLeftSidebar()
|
|
||||||
})
|
|
||||||
|
|
||||||
useHotkeys('i', (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
onToggleRightSidebar()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Filter bar toggle (\) and search focus (/ or Cmd/Ctrl+F).
|
// Filter bar toggle (\) and search focus (/ or Cmd/Ctrl+F).
|
||||||
useHotkeys('\\', (e) => {
|
useHotkeys('\\', () => useFilterStore.getState().toggleFilterBar(), HK_OPTS)
|
||||||
e.preventDefault()
|
|
||||||
useFilterStore.getState().toggleFilterBar()
|
|
||||||
})
|
|
||||||
|
|
||||||
const focusSearch = (e: KeyboardEvent) => {
|
const focusSearch = () => {
|
||||||
e.preventDefault()
|
|
||||||
const el = document.getElementById('topbar-search') as HTMLInputElement | null
|
const el = document.getElementById('topbar-search') as HTMLInputElement | null
|
||||||
el?.focus()
|
el?.focus()
|
||||||
el?.select()
|
el?.select()
|
||||||
}
|
}
|
||||||
useHotkeys('/', focusSearch)
|
useHotkeys('/', focusSearch, HK_OPTS)
|
||||||
useHotkeys('mod+f', focusSearch)
|
useHotkeys('mod+f', focusSearch, HK_OPTS)
|
||||||
|
|
||||||
// G always returns to grid (closes loupe if open).
|
// E and Space both toggle the preview view (open from grid, close from
|
||||||
useHotkeys('g', () => {
|
// preview). Double-click on a thumbnail does the same.
|
||||||
closeLoupe()
|
const openPreviewFromGrid = () => {
|
||||||
})
|
|
||||||
|
|
||||||
// E toggles loupe (open from grid, close from loupe). Enter opens from grid.
|
|
||||||
const openLoupeFromGrid = () => {
|
|
||||||
const id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null
|
const id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null
|
||||||
if (id) openLoupe(id)
|
if (id) openPreview(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
useHotkeys(
|
const togglePreview = () => {
|
||||||
'e',
|
if (isPreview) closePreview()
|
||||||
() => {
|
else openPreviewFromGrid()
|
||||||
if (isLoupe) {
|
}
|
||||||
closeLoupe()
|
|
||||||
} else {
|
|
||||||
openLoupeFromGrid()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isLoupe, getFirstPhotoId]
|
|
||||||
)
|
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys('e', togglePreview, HK_OPTS, [isPreview, getFirstPhotoId])
|
||||||
'enter',
|
useHotkeys('space', togglePreview, HK_OPTS, [isPreview, getFirstPhotoId])
|
||||||
(e) => {
|
|
||||||
if (isGrid) {
|
|
||||||
e.preventDefault()
|
|
||||||
openLoupeFromGrid()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ enabled: isGrid },
|
|
||||||
[isGrid, getFirstPhotoId]
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Culling shortcuts (work in both grid and loupe) ──────────────────────
|
// ── Culling shortcuts (work in both grid and preview) ────────────────────
|
||||||
|
|
||||||
// Star rating: 1-5 set, 0 clears.
|
// Star rating: 1-5 set, 0 clears.
|
||||||
useHotkeys('1,2,3,4,5', (_e, handler) => {
|
useHotkeys(
|
||||||
const rating = parseInt(handler.keys![0])
|
'1,2,3,4,5',
|
||||||
if (Number.isFinite(rating)) updateActive({ rating })
|
(_e, handler) => {
|
||||||
})
|
const rating = parseInt(handler.keys![0])
|
||||||
|
if (Number.isFinite(rating)) updateActive({ rating })
|
||||||
|
},
|
||||||
|
HK_OPTS
|
||||||
|
)
|
||||||
|
|
||||||
useHotkeys('0', () => {
|
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
|
||||||
updateActive({ rating: 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Pick / reject / unflag.
|
// Pick / trash / unflag. Trash is the merged "rejected" concept — a soft
|
||||||
useHotkeys('p', () => {
|
// flag that hides the photo from the default timeline view; restore via
|
||||||
updateActive({ is_picked: true, is_rejected: false })
|
// the trash view (or the U shortcut).
|
||||||
})
|
useHotkeys(
|
||||||
|
'p',
|
||||||
|
() => updateActive({ is_picked: true, is_trashed: false }),
|
||||||
|
HK_OPTS
|
||||||
|
)
|
||||||
|
|
||||||
useHotkeys('x', () => {
|
useHotkeys(
|
||||||
updateActive({ is_rejected: true, is_picked: false })
|
'x',
|
||||||
})
|
() => updateActive({ is_trashed: true, is_picked: false }),
|
||||||
|
HK_OPTS
|
||||||
|
)
|
||||||
|
|
||||||
useHotkeys('u', () => {
|
useHotkeys(
|
||||||
updateActive({ is_picked: false, is_rejected: false })
|
'u',
|
||||||
})
|
() => updateActive({ is_picked: false, is_trashed: false }),
|
||||||
|
HK_OPTS
|
||||||
|
)
|
||||||
|
|
||||||
// Color labels 6-9 (red/orange/yellow/green per spec §6.4).
|
// Color labels 6-9 (red/orange/yellow/green per spec §6.4).
|
||||||
useHotkeys('6,7,8,9', (_e, handler) => {
|
useHotkeys(
|
||||||
const label = COLOR_LABELS[handler.keys![0]]
|
'6,7,8,9',
|
||||||
if (label) updateActive({ color_label: label })
|
(_e, handler) => {
|
||||||
})
|
const label = COLOR_LABELS[handler.keys![0]]
|
||||||
|
if (label) updateActive({ color_label: label })
|
||||||
|
},
|
||||||
|
HK_OPTS
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
55
frontend/src/hooks/usePhotosQuery.ts
Normal file
55
frontend/src/hooks/usePhotosQuery.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useFilterStore, filtersToParams } from '../store/filterStore'
|
||||||
|
import type { Photo } from '../types/photo'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the timeline photos query. Both Timeline and
|
||||||
|
* PreviewView call this so they share one cache entry — previously
|
||||||
|
* PreviewView looked the cache up by key directly, which broke the moment
|
||||||
|
* Timeline's key gained the filter params.
|
||||||
|
*/
|
||||||
|
export function usePhotosQuery() {
|
||||||
|
const q = useFilterStore((s) => s.q)
|
||||||
|
const dateFrom = useFilterStore((s) => s.dateFrom)
|
||||||
|
const dateTo = useFilterStore((s) => s.dateTo)
|
||||||
|
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
||||||
|
const ratingMin = useFilterStore((s) => s.ratingMin)
|
||||||
|
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||||
|
const flag = useFilterStore((s) => s.flag)
|
||||||
|
|
||||||
|
const filterParams = useMemo(
|
||||||
|
() =>
|
||||||
|
filtersToParams({
|
||||||
|
q,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
mediaTypes,
|
||||||
|
ratingMin,
|
||||||
|
colorLabel,
|
||||||
|
flag,
|
||||||
|
}),
|
||||||
|
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag]
|
||||||
|
)
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['photos', filterParams],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios.get<{ photos: Photo[]; total: number }>(
|
||||||
|
'http://localhost:8001/api/v1/photos',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
page: 1,
|
||||||
|
per_page: 500,
|
||||||
|
sort: 'taken_at',
|
||||||
|
order: 'desc',
|
||||||
|
...filterParams,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return response.data.photos || []
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { create } from 'zustand'
|
|||||||
|
|
||||||
export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
|
export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
|
||||||
export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
||||||
export type FlagFilter = 'any' | 'picked' | 'rejected' | 'unflagged'
|
export type FlagFilter = 'any' | 'picked' | 'trashed' | 'unflagged'
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
q: string
|
q: string
|
||||||
@@ -77,11 +77,8 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
|
|||||||
if (f.ratingMin > 0) params.rating_min = f.ratingMin
|
if (f.ratingMin > 0) params.rating_min = f.ratingMin
|
||||||
if (f.colorLabel) params.color_label = f.colorLabel
|
if (f.colorLabel) params.color_label = f.colorLabel
|
||||||
if (f.flag === 'picked') params.is_picked = 'true'
|
if (f.flag === 'picked') params.is_picked = 'true'
|
||||||
else if (f.flag === 'rejected') params.is_rejected = 'true'
|
else if (f.flag === 'trashed') params.is_trashed = 'true'
|
||||||
else if (f.flag === 'unflagged') {
|
else if (f.flag === 'unflagged') params.is_picked = 'false'
|
||||||
params.is_picked = 'false'
|
|
||||||
params.is_rejected = 'false'
|
|
||||||
}
|
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { Photo } from '../types/photo'
|
import type { Photo } from '../types/photo'
|
||||||
|
|
||||||
type ViewMode = 'grid' | 'loupe'
|
type ViewMode = 'grid' | 'preview'
|
||||||
|
|
||||||
interface PhotoStore {
|
interface PhotoStore {
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
@@ -19,8 +19,8 @@ interface PhotoStore {
|
|||||||
clearSelection: () => void
|
clearSelection: () => void
|
||||||
setActivePhoto: (id: string | null) => void
|
setActivePhoto: (id: string | null) => void
|
||||||
setViewMode: (mode: ViewMode) => void
|
setViewMode: (mode: ViewMode) => void
|
||||||
openLoupe: (id: string) => void
|
openPreview: (id: string) => void
|
||||||
closeLoupe: () => void
|
closePreview: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePhotoStore = create<PhotoStore>((set) => ({
|
export const usePhotoStore = create<PhotoStore>((set) => ({
|
||||||
@@ -73,7 +73,7 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
|
|||||||
|
|
||||||
setViewMode: (mode) => set({ viewMode: mode }),
|
setViewMode: (mode) => set({ viewMode: mode }),
|
||||||
|
|
||||||
openLoupe: (id) => set({ viewMode: 'loupe', activePhotoId: id }),
|
openPreview: (id) => set({ viewMode: 'preview', activePhotoId: id }),
|
||||||
|
|
||||||
closeLoupe: () => set({ viewMode: 'grid' }),
|
closePreview: () => set({ viewMode: 'grid' }),
|
||||||
}))
|
}))
|
||||||
@@ -8,7 +8,7 @@ export interface Photo {
|
|||||||
taken_at: string | null
|
taken_at: string | null
|
||||||
rating: number
|
rating: number
|
||||||
is_picked: boolean
|
is_picked: boolean
|
||||||
is_rejected: boolean
|
is_trashed: boolean
|
||||||
file_hash: string
|
file_hash: string
|
||||||
thumb_small?: string
|
thumb_small?: string
|
||||||
thumb_medium?: string
|
thumb_medium?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user