refactor: unify Pick with active heap membership
Pick and add-to-heap were two ways of saying "I want to keep this
one". Merging them: P now toggles the selection's membership in the
active heap. The is_picked flag goes away (orphaned in the DB the
same way is_trashed was).
Backend
- Drop is_picked from PhotoBase / PhotoUpdate / PhotoResponse and
from the photos list filter param.
- Drop the is_picked Column from the Photo model (DB column stays
on legacy installs but is no longer read or written).
- Drop the bulk action 'pick' branch.
- New GET /heaps/{id}/photo_ids returns just the flat string list.
Used by the frontend for fast client-side membership lookups
without fetching full photo records.
Frontend
- New hooks/useActiveHeapMembersQuery.ts → returns
{ activeHeap, memberIds: Set<string> }. Subscribes once at the
Timeline level and passes a derived isInActiveHeap bool down to
each PhotoThumbnail (avoids hundreds of thumbnails subscribing
to the same query).
- PhotoThumbnail: replaces the old check-icon Pick affordance with
a clear basket badge in the bottom-right corner — a small filled
pick-colour pill containing a ShoppingBasket icon — visible only
when the photo belongs to the active heap.
- P shortcut (useKeyboardShortcuts) now toggles membership: if every
selected photo is already a member, it removes them; otherwise it
adds the missing ones. T binding removed (P fully replaces it).
- RightSidebar Pick button is now a Pick / Picked toggle bound to
the active heap. Disabled with a hint when no heap is active.
Shows the heap name in its title attr.
- filterStore drops 'picked' and 'unflagged' from FlagFilter.
FilterBar's flag dropdown is now just Any / Discarded.
- LeftSidebar drops the "Flagged" virtual node (it just set
flag=picked, which no longer exists).
- KeyboardHints: P → "Pick → heap".
- Photo TS type drops is_picked.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,10 +55,10 @@ class Photo(Base):
|
|||||||
user_notes = Column(Text)
|
user_notes = Column(Text)
|
||||||
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)
|
|
||||||
# Note: is_rejected was merged into is_discarded (a single soft "discarded"
|
# Note: is_rejected was merged into is_discarded (a single soft "discarded"
|
||||||
# concept). The DB column may still exist on legacy installs but is no
|
# concept). is_picked was unified with active-heap membership — picking a
|
||||||
# longer read or written.
|
# photo just means adding it to the active heap. Both DB columns may still
|
||||||
|
# exist on legacy installs but are no longer read or written.
|
||||||
|
|
||||||
# Duplicate detection
|
# Duplicate detection
|
||||||
is_duplicate = Column(Boolean, default=False)
|
is_duplicate = Column(Boolean, default=False)
|
||||||
|
|||||||
@@ -134,6 +134,18 @@ async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{heap_id}/photo_ids")
|
||||||
|
async def get_heap_photo_ids(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Return just the photo ids belonging to a heap. Used by the frontend
|
||||||
|
to maintain a fast client-side membership lookup for the active heap
|
||||||
|
(for the basket affordance on thumbnails) without fetching full photo
|
||||||
|
records."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
||||||
|
)
|
||||||
|
return [row[0] for row in result.all()]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{heap_id}/photos")
|
@router.post("/{heap_id}/photos")
|
||||||
async def add_photos_to_heap(
|
async def add_photos_to_heap(
|
||||||
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ async def list_photos(
|
|||||||
rating_min: Optional[int] = Query(None, ge=0, le=5),
|
rating_min: Optional[int] = Query(None, ge=0, le=5),
|
||||||
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_discarded: Optional[bool] = False,
|
is_discarded: Optional[bool] = False,
|
||||||
heap_id: Optional[str] = None,
|
heap_id: Optional[str] = None,
|
||||||
sort: str = "taken_at",
|
sort: str = "taken_at",
|
||||||
@@ -90,10 +89,6 @@ async def list_photos(
|
|||||||
else:
|
else:
|
||||||
filters.append(Photo.color_label == color_label)
|
filters.append(Photo.color_label == color_label)
|
||||||
|
|
||||||
# Flag filters
|
|
||||||
if is_picked is not None:
|
|
||||||
filters.append(Photo.is_picked == is_picked)
|
|
||||||
|
|
||||||
# Discard filter — defaults to hiding discarded photos
|
# Discard filter — defaults to hiding discarded photos
|
||||||
filters.append(Photo.is_discarded == is_discarded)
|
filters.append(Photo.is_discarded == is_discarded)
|
||||||
|
|
||||||
@@ -468,10 +463,6 @@ async def bulk_action(
|
|||||||
elif action.action == 'set_color':
|
elif action.action == 'set_color':
|
||||||
for photo in photos:
|
for photo in photos:
|
||||||
photo.color_label = action.value
|
photo.color_label = action.value
|
||||||
elif action.action == 'pick':
|
|
||||||
for photo in photos:
|
|
||||||
photo.is_picked = True
|
|
||||||
photo.is_discarded = False
|
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Invalid action")
|
raise HTTPException(status_code=400, detail="Invalid action")
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ class PhotoBase(BaseModel):
|
|||||||
user_notes: Optional[str] = None
|
user_notes: Optional[str] = None
|
||||||
rating: int = 0
|
rating: int = 0
|
||||||
color_label: Optional[str] = None
|
color_label: Optional[str] = None
|
||||||
is_picked: bool = False
|
|
||||||
|
|
||||||
class PhotoResponse(PhotoBase):
|
class PhotoResponse(PhotoBase):
|
||||||
"""Photo response schema"""
|
"""Photo response schema"""
|
||||||
@@ -51,7 +50,6 @@ class PhotoUpdate(BaseModel):
|
|||||||
user_notes: Optional[str] = None
|
user_notes: Optional[str] = None
|
||||||
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_discarded: Optional[bool] = None
|
is_discarded: Optional[bool] = None
|
||||||
taken_at: Optional[datetime] = None
|
taken_at: Optional[datetime] = None
|
||||||
|
|
||||||
@@ -66,5 +64,5 @@ class PhotoListResponse(BaseModel):
|
|||||||
class BulkAction(BaseModel):
|
class BulkAction(BaseModel):
|
||||||
"""Bulk action on photos"""
|
"""Bulk action on photos"""
|
||||||
ids: List[str]
|
ids: List[str]
|
||||||
action: str # 'discard', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color', 'pick'
|
action: str # 'discard', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color'
|
||||||
value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id)
|
value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id)
|
||||||
@@ -11,9 +11,8 @@ export function KeyboardHints() {
|
|||||||
const hints = selectedCount > 0
|
const hints = selectedCount > 0
|
||||||
? [
|
? [
|
||||||
{ key: '1-5', action: 'Rate' },
|
{ key: '1-5', action: 'Rate' },
|
||||||
{ key: 'P', action: 'Pick' },
|
{ key: 'P', action: 'Pick → heap' },
|
||||||
{ key: 'X', action: 'Discard' },
|
{ key: 'X', action: 'Discard' },
|
||||||
{ key: 'T', action: 'Add to heap' },
|
|
||||||
{ key: 'Space', action: 'Preview' },
|
{ key: 'Space', action: 'Preview' },
|
||||||
{ key: 'Esc', action: 'Deselect' },
|
{ key: 'Esc', action: 'Deselect' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,9 +25,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: 'discarded', label: 'Discarded' },
|
{ value: 'discarded', label: 'Discarded' },
|
||||||
{ value: 'unflagged', label: 'Unflagged' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function FilterBar() {
|
export function FilterBar() {
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Calendar,
|
Calendar,
|
||||||
Star,
|
Star,
|
||||||
Flag,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Plus,
|
Plus,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
RefreshCw
|
RefreshCw,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
|
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
|
||||||
@@ -52,10 +51,6 @@ export function LeftSidebar() {
|
|||||||
clearAllFilters()
|
clearAllFilters()
|
||||||
setRatingMin(1)
|
setRatingMin(1)
|
||||||
break
|
break
|
||||||
case 'flagged':
|
|
||||||
clearAllFilters()
|
|
||||||
setFlag('picked')
|
|
||||||
break
|
|
||||||
case 'discarded':
|
case 'discarded':
|
||||||
clearAllFilters()
|
clearAllFilters()
|
||||||
setFlag('discarded')
|
setFlag('discarded')
|
||||||
@@ -138,7 +133,6 @@ export function LeftSidebar() {
|
|||||||
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
|
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
|
||||||
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
|
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
|
||||||
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
|
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
|
||||||
{ id: 'flagged', label: 'Flagged', icon: <Flag className="h-4 w-4" />, count: 0 },
|
|
||||||
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Check,
|
ShoppingBasket,
|
||||||
Trash2,
|
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'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import { photos as photosApi } from '../../services/api'
|
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
|
||||||
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||||
|
|
||||||
interface PhotoDetails {
|
interface PhotoDetails {
|
||||||
id: string
|
id: string
|
||||||
@@ -26,7 +28,6 @@ interface PhotoDetails {
|
|||||||
file_size: number | null
|
file_size: number | null
|
||||||
taken_at: string | null
|
taken_at: string | null
|
||||||
rating: number
|
rating: number
|
||||||
is_picked: boolean
|
|
||||||
is_discarded: boolean
|
is_discarded: boolean
|
||||||
user_title: string | null
|
user_title: string | null
|
||||||
user_notes: string | null
|
user_notes: string | null
|
||||||
@@ -122,7 +123,6 @@ export function RightSidebar() {
|
|||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (data: {
|
mutationFn: (data: {
|
||||||
rating?: number
|
rating?: number
|
||||||
is_picked?: boolean
|
|
||||||
is_discarded?: boolean
|
is_discarded?: boolean
|
||||||
user_title?: string | null
|
user_title?: string | null
|
||||||
user_notes?: string | null
|
user_notes?: string | null
|
||||||
@@ -134,6 +134,29 @@ export function RightSidebar() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Membership in the active heap (for the Pick toggle button).
|
||||||
|
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
|
const isInActiveHeap =
|
||||||
|
!!activePhotoId && activeHeapMembers.has(activePhotoId)
|
||||||
|
|
||||||
|
const heapMutation = useMutation({
|
||||||
|
mutationFn: ({ remove }: { remove: boolean }) => {
|
||||||
|
if (!activeHeap || !activePhotoId) return Promise.resolve(null)
|
||||||
|
return remove
|
||||||
|
? heapsApi.removePhotos(activeHeap.id, [activePhotoId])
|
||||||
|
: heapsApi.addPhotos(activeHeap.id, [activePhotoId])
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||||
|
if (activeHeap) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['heap-photo-ids', activeHeap.id],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// Local drafts for the editable text fields. These mirror the server value
|
// Local drafts for the editable text fields. These mirror the server value
|
||||||
// but stay independent while the user is typing, so we don't fight focus or
|
// but stay independent while the user is typing, so we don't fight focus or
|
||||||
// clobber edits with stale refetches.
|
// clobber edits with stale refetches.
|
||||||
@@ -178,7 +201,6 @@ 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 isDiscarded = photo?.is_discarded ?? false
|
const isDiscarded = photo?.is_discarded ?? false
|
||||||
const colorLabel = (photo?.color_label ?? null) as ColorLabel | null
|
const colorLabel = (photo?.color_label ?? null) as ColorLabel | null
|
||||||
|
|
||||||
@@ -299,28 +321,28 @@ export function RightSidebar() {
|
|||||||
<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={() =>
|
onClick={() => heapMutation.mutate({ remove: isInActiveHeap })}
|
||||||
updateMutation.mutate({
|
disabled={!activeHeap || heapMutation.isPending}
|
||||||
is_picked: !isPicked,
|
|
||||||
is_discarded: 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 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
isPicked
|
isInActiveHeap
|
||||||
? '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'
|
||||||
)}
|
)}
|
||||||
|
title={
|
||||||
|
activeHeap
|
||||||
|
? isInActiveHeap
|
||||||
|
? `Remove from "${activeHeap.name}"`
|
||||||
|
: `Add to "${activeHeap.name}"`
|
||||||
|
: 'Set an active heap first'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Check className="h-3 w-3" />
|
<ShoppingBasket className="h-3 w-3" />
|
||||||
Pick
|
{isInActiveHeap ? 'Picked' : 'Pick'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({ is_discarded: !isDiscarded })
|
||||||
is_discarded: !isDiscarded,
|
|
||||||
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',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Star, Check, Trash2, RefreshCw } from 'lucide-react'
|
import { Star, ShoppingBasket, Trash2, RefreshCw, Check } 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'
|
||||||
@@ -12,11 +12,20 @@ interface PhotoThumbnailProps {
|
|||||||
photo: Photo
|
photo: Photo
|
||||||
size: number
|
size: number
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
|
/** True when the photo belongs to the currently active heap. */
|
||||||
|
isInActiveHeap?: boolean
|
||||||
onClick: (e: React.MouseEvent) => void
|
onClick: (e: React.MouseEvent) => void
|
||||||
onDoubleClick?: (e: React.MouseEvent) => void
|
onDoubleClick?: (e: React.MouseEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick }: PhotoThumbnailProps) {
|
export function PhotoThumbnail({
|
||||||
|
photo,
|
||||||
|
size,
|
||||||
|
isSelected,
|
||||||
|
isInActiveHeap = false,
|
||||||
|
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)
|
||||||
@@ -167,9 +176,14 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Flag Indicators */}
|
{/* Flag Indicators */}
|
||||||
<div className="absolute bottom-1 right-1">
|
<div className="absolute bottom-1 right-1 flex items-center gap-1">
|
||||||
{photo.is_picked && (
|
{isInActiveHeap && (
|
||||||
<Check className="h-4 w-4 text-pick" />
|
<div
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-full bg-pick text-white shadow-md"
|
||||||
|
title="In active heap"
|
||||||
|
>
|
||||||
|
<ShoppingBasket className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{photo.is_discarded && (
|
{photo.is_discarded && (
|
||||||
<Trash2 className="h-4 w-4 text-reject" />
|
<Trash2 className="h-4 w-4 text-reject" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'
|
|||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||||
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
|
|
||||||
export function Timeline() {
|
export function Timeline() {
|
||||||
@@ -48,6 +49,11 @@ export function Timeline() {
|
|||||||
// they share one cache entry, regardless of filter state.
|
// they share one cache entry, regardless of filter state.
|
||||||
const { data: photos = [], isLoading } = usePhotosQuery()
|
const { data: photos = [], isLoading } = usePhotosQuery()
|
||||||
|
|
||||||
|
// Membership in the active heap (for the basket affordance). Subscribed
|
||||||
|
// once at this level so we don't have hundreds of thumbnails each
|
||||||
|
// subscribing to the same query.
|
||||||
|
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
|
|
||||||
// Group photos into rows for grid layout
|
// Group photos into rows for grid layout
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const result: Photo[][] = []
|
const result: Photo[][] = []
|
||||||
@@ -212,6 +218,7 @@ export function Timeline() {
|
|||||||
photo={photo}
|
photo={photo}
|
||||||
size={thumbnailSize}
|
size={thumbnailSize}
|
||||||
isSelected={selectedPhotos.includes(photo.id)}
|
isSelected={selectedPhotos.includes(photo.id)}
|
||||||
|
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.shiftKey && lastSelectedIndex !== null) {
|
if (e.shiftKey && lastSelectedIndex !== null) {
|
||||||
selectRange(globalIndex)
|
selectRange(globalIndex)
|
||||||
|
|||||||
39
frontend/src/hooks/useActiveHeapMembersQuery.ts
Normal file
39
frontend/src/hooks/useActiveHeapMembersQuery.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useHeapsQuery } from './useHeapsQuery'
|
||||||
|
import { heaps as heapsApi } from '../services/api'
|
||||||
|
|
||||||
|
const EMPTY_SET: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the photo ids that belong to the active heap as a Set, plus the
|
||||||
|
* active heap itself. Used by PhotoThumbnail to render the basket affordance
|
||||||
|
* and by the P shortcut to decide between add vs remove.
|
||||||
|
*
|
||||||
|
* If no heap is active, the Set is empty (and shared across renders).
|
||||||
|
*/
|
||||||
|
export function useActiveHeapMembers(): {
|
||||||
|
activeHeap: ReturnType<typeof useHeapsQuery>['data'] extends (infer T)[] | undefined
|
||||||
|
? T | null
|
||||||
|
: never
|
||||||
|
memberIds: ReadonlySet<string>
|
||||||
|
} {
|
||||||
|
const { data: heaps } = useHeapsQuery()
|
||||||
|
const activeHeap = heaps?.find((h) => h.is_active) ?? null
|
||||||
|
|
||||||
|
const { data: ids } = useQuery({
|
||||||
|
queryKey: ['heap-photo-ids', activeHeap?.id],
|
||||||
|
queryFn: () => heapsApi.photoIds(activeHeap!.id),
|
||||||
|
enabled: !!activeHeap,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const memberIds = useMemo(
|
||||||
|
() => (ids ? new Set(ids) : EMPTY_SET),
|
||||||
|
[ids]
|
||||||
|
)
|
||||||
|
|
||||||
|
return { activeHeap: activeHeap as any, memberIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ACTIVE_HEAP_MEMBERS_QUERY_KEY_PREFIX = ['heap-photo-ids'] as const
|
||||||
@@ -16,7 +16,7 @@ const ALLOWED_COLORS: ColorLabel[] = [
|
|||||||
'blue',
|
'blue',
|
||||||
'purple',
|
'purple',
|
||||||
]
|
]
|
||||||
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'discarded', 'unflagged']
|
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'discarded']
|
||||||
|
|
||||||
function parseUrl(): Partial<FilterState> {
|
function parseUrl(): Partial<FilterState> {
|
||||||
const sp = new URLSearchParams(window.location.search)
|
const sp = new URLSearchParams(window.location.search)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ interface KeyboardShortcutsProps {
|
|||||||
|
|
||||||
interface PhotoUpdate {
|
interface PhotoUpdate {
|
||||||
rating?: number
|
rating?: number
|
||||||
is_picked?: boolean
|
|
||||||
is_discarded?: boolean
|
is_discarded?: boolean
|
||||||
color_label?: string | null
|
color_label?: string | null
|
||||||
}
|
}
|
||||||
@@ -62,50 +61,77 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
updateMutation.mutate({ id, data })
|
updateMutation.mutate({ id, data })
|
||||||
}
|
}
|
||||||
|
|
||||||
// T key: add the current selection to the active heap. If no heap is
|
// P key (Pick): toggle the current selection's membership in the active
|
||||||
// active or no photos are selected, it's a no-op with a toast hint.
|
// heap. If every selected photo is already a member, remove them; otherwise
|
||||||
const addToHeapMutation = useMutation({
|
// add the missing ones. No active heap → toast hint.
|
||||||
mutationFn: ({ heapId, photoIds }: { heapId: string; photoIds: string[] }) =>
|
const heapMutation = useMutation({
|
||||||
heapsApi.addPhotos(heapId, photoIds),
|
mutationFn: ({
|
||||||
|
heapId,
|
||||||
|
photoIds,
|
||||||
|
remove,
|
||||||
|
}: {
|
||||||
|
heapId: string
|
||||||
|
photoIds: string[]
|
||||||
|
remove: boolean
|
||||||
|
}) =>
|
||||||
|
remove
|
||||||
|
? heapsApi.removePhotos(heapId, photoIds)
|
||||||
|
: heapsApi.addPhotos(heapId, photoIds),
|
||||||
onSuccess: (data, vars) => {
|
onSuccess: (data, vars) => {
|
||||||
const added = data?.added ?? 0
|
|
||||||
const already = data?.already_present ?? 0
|
|
||||||
const heap = (queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []).find(
|
const heap = (queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []).find(
|
||||||
(h) => h.id === vars.heapId
|
(h) => h.id === vars.heapId
|
||||||
)
|
)
|
||||||
const heapName = heap?.name ?? 'heap'
|
const heapName = heap?.name ?? 'heap'
|
||||||
|
if (vars.remove) {
|
||||||
|
const removed = data?.removed ?? 0
|
||||||
|
toast.success(`Removed from ${heapName}`, `${removed} photo${removed === 1 ? '' : 's'}`)
|
||||||
|
} else {
|
||||||
|
const added = data?.added ?? 0
|
||||||
|
const already = data?.already_present ?? 0
|
||||||
if (added > 0) {
|
if (added > 0) {
|
||||||
toast.success(
|
toast.success(
|
||||||
`Added to ${heapName}`,
|
`Added to ${heapName}`,
|
||||||
`${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}`
|
`${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}`
|
||||||
)
|
)
|
||||||
} else if (already > 0) {
|
}
|
||||||
toast.info(`Already in ${heapName}`, `${already} photo${already > 1 ? 's' : ''}`)
|
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['heap-photo-ids', vars.heapId] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
},
|
},
|
||||||
onError: (e: any) => toast.error('Failed to add to heap', e.message || 'Unknown error'),
|
onError: (e: any) => toast.error('Heap update failed', e.message || 'Unknown error'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const addSelectionToActiveHeap = () => {
|
const togglePickOnSelection = () => {
|
||||||
const state = usePhotoStore.getState()
|
const state = usePhotoStore.getState()
|
||||||
const ids = state.selectedPhotos.length > 0
|
const ids =
|
||||||
|
state.selectedPhotos.length > 0
|
||||||
? state.selectedPhotos
|
? state.selectedPhotos
|
||||||
: state.activePhotoId
|
: state.activePhotoId
|
||||||
? [state.activePhotoId]
|
? [state.activePhotoId]
|
||||||
: []
|
: []
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
toast.info('Nothing selected', 'Select photos first, then press T')
|
toast.info('Nothing selected', 'Select photos first, then press P')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const heapsList = queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []
|
const heapsList = queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []
|
||||||
const active = heapsList.find((h) => h.is_active)
|
const active = heapsList.find((h) => h.is_active)
|
||||||
if (!active) {
|
if (!active) {
|
||||||
toast.info('No active heap', 'Click the target icon next to a heap to set it as active')
|
toast.info('No active heap', 'Set an active heap (target icon next to a heap)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
addToHeapMutation.mutate({ heapId: active.id, photoIds: ids })
|
// Determine direction: if every selected photo is already a member, this
|
||||||
|
// press REMOVES them; otherwise it ADDS the missing ones. Mirrors how
|
||||||
|
// Lightroom's flag-toggle works.
|
||||||
|
const memberIds =
|
||||||
|
queryClient.getQueryData<string[]>(['heap-photo-ids', active.id]) ?? []
|
||||||
|
const memberSet = new Set(memberIds)
|
||||||
|
const allMembers = ids.every((id) => memberSet.has(id))
|
||||||
|
heapMutation.mutate({
|
||||||
|
heapId: active.id,
|
||||||
|
photoIds: ids,
|
||||||
|
remove: allMembers,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle sidebars
|
// Toggle sidebars
|
||||||
@@ -151,26 +177,14 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
|
|
||||||
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
|
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
|
||||||
|
|
||||||
// Pick / discard / unflag. Discard is a soft flag that hides the photo
|
// P (Pick) is unified with "add to active heap" — Pick a photo and you're
|
||||||
// from the default timeline view; restore via the Discarded view (or the
|
// adding it to the heap you set as active. Toggling on already-picked
|
||||||
// U shortcut).
|
// photos removes them from the heap.
|
||||||
useHotkeys(
|
useHotkeys('p', togglePickOnSelection, HK_OPTS)
|
||||||
'p',
|
|
||||||
() => updateActive({ is_picked: true, is_discarded: false }),
|
|
||||||
HK_OPTS
|
|
||||||
)
|
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys('x', () => updateActive({ is_discarded: true }), HK_OPTS)
|
||||||
'x',
|
|
||||||
() => updateActive({ is_discarded: true, is_picked: false }),
|
|
||||||
HK_OPTS
|
|
||||||
)
|
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys('u', () => updateActive({ is_discarded: false }), HK_OPTS)
|
||||||
'u',
|
|
||||||
() => updateActive({ is_picked: false, is_discarded: 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(
|
useHotkeys(
|
||||||
@@ -182,6 +196,4 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
HK_OPTS
|
HK_OPTS
|
||||||
)
|
)
|
||||||
|
|
||||||
// T: add selection (or active photo) to the active heap.
|
|
||||||
useHotkeys('t', addSelectionToActiveHeap, HK_OPTS)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,13 @@ export const heaps = {
|
|||||||
await api.delete(`/heaps/${heapId}`)
|
await api.delete(`/heaps/${heapId}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Lightweight: just the photo ids in a heap, for client-side membership
|
||||||
|
* lookups (the basket affordance on thumbnails). */
|
||||||
|
photoIds: async (heapId: string): Promise<string[]> => {
|
||||||
|
const response = await api.get(`/heaps/${heapId}/photo_ids`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
addPhotos: async (heapId: string, photoIds: string[]) => {
|
addPhotos: async (heapId: string, photoIds: string[]) => {
|
||||||
const response = await api.post(`/heaps/${heapId}/photos`, {
|
const response = await api.post(`/heaps/${heapId}/photos`, {
|
||||||
photo_ids: photoIds,
|
photo_ids: photoIds,
|
||||||
|
|||||||
@@ -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' | 'discarded' | 'unflagged'
|
export type FlagFilter = 'any' | 'discarded'
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
q: string
|
q: string
|
||||||
@@ -82,9 +82,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
|
|||||||
if (f.mediaTypes.length > 0) params.media_type = f.mediaTypes.join(',')
|
if (f.mediaTypes.length > 0) params.media_type = f.mediaTypes.join(',')
|
||||||
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 === 'discarded') params.is_discarded = 'true'
|
||||||
else if (f.flag === 'discarded') params.is_discarded = 'true'
|
|
||||||
else if (f.flag === 'unflagged') params.is_picked = 'false'
|
|
||||||
if (f.heapId) params.heap_id = f.heapId
|
if (f.heapId) params.heap_id = f.heapId
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export interface Photo {
|
|||||||
height: number | null
|
height: number | null
|
||||||
taken_at: string | null
|
taken_at: string | null
|
||||||
rating: number
|
rating: number
|
||||||
is_picked: boolean
|
|
||||||
is_discarded: boolean
|
is_discarded: boolean
|
||||||
file_hash: string
|
file_hash: string
|
||||||
thumb_small?: string
|
thumb_small?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user