Compare commits
5 Commits
322969c938
...
ee6b49952e
| Author | SHA1 | Date | |
|---|---|---|---|
| ee6b49952e | |||
| 324cc0298b | |||
| 351ccd7bb4 | |||
| 02fb1cd508 | |||
| ebae775f3f |
@@ -55,10 +55,10 @@ class Photo(Base):
|
||||
user_notes = Column(Text)
|
||||
rating = Column(Integer, default=0) # 0-5 stars
|
||||
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"
|
||||
# concept). The DB column may still exist on legacy installs but is no
|
||||
# longer read or written.
|
||||
# concept). is_picked was unified with active-heap membership — picking a
|
||||
# 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
|
||||
is_duplicate = Column(Boolean, default=False)
|
||||
|
||||
@@ -1,27 +1,203 @@
|
||||
"""
|
||||
Heaps API router
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func, update, insert, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Heap
|
||||
from app.models.heaps import heap_photos
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────────
|
||||
|
||||
class HeapCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class HeapUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class HeapPhotosBody(BaseModel):
|
||||
photo_ids: list[str]
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_heaps(db: AsyncSession = Depends(get_db)):
|
||||
"""List all heaps"""
|
||||
result = await db.execute(select(Heap))
|
||||
heaps = result.scalars().all()
|
||||
return heaps
|
||||
"""List all heaps with photo counts."""
|
||||
# LEFT JOIN heap_photos and group so we can return counts in one query.
|
||||
count_subq = (
|
||||
select(
|
||||
heap_photos.c.heap_id,
|
||||
func.count(heap_photos.c.photo_id).label("photo_count"),
|
||||
)
|
||||
.group_by(heap_photos.c.heap_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
@router.post("")
|
||||
async def create_heap(name: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a new heap"""
|
||||
stmt = (
|
||||
select(Heap, count_subq.c.photo_count)
|
||||
.outerjoin(count_subq, Heap.id == count_subq.c.heap_id)
|
||||
.order_by(Heap.created_at.asc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": h.id,
|
||||
"name": h.name,
|
||||
"is_active": bool(h.is_active),
|
||||
"created_at": h.created_at,
|
||||
"updated_at": h.updated_at,
|
||||
"photo_count": int(count or 0),
|
||||
}
|
||||
for h, count in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a new heap."""
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Heap name is required")
|
||||
heap = Heap(name=name)
|
||||
db.add(heap)
|
||||
await db.commit()
|
||||
await db.refresh(heap)
|
||||
return heap
|
||||
return {
|
||||
"id": heap.id,
|
||||
"name": heap.name,
|
||||
"is_active": bool(heap.is_active),
|
||||
"created_at": heap.created_at,
|
||||
"updated_at": heap.updated_at,
|
||||
"photo_count": 0,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{heap_id}")
|
||||
async def update_heap(
|
||||
heap_id: str, body: HeapUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Rename a heap and/or toggle active state. Setting is_active=true on
|
||||
one heap deactivates all others (single-active invariant)."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
|
||||
if body.name is not None:
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Heap name is required")
|
||||
heap.name = name
|
||||
|
||||
if body.is_active is not None:
|
||||
if body.is_active:
|
||||
# Clear active flag on all other heaps in one statement
|
||||
await db.execute(update(Heap).values(is_active=False))
|
||||
heap.is_active = True
|
||||
else:
|
||||
heap.is_active = False
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(heap)
|
||||
return {
|
||||
"id": heap.id,
|
||||
"name": heap.name,
|
||||
"is_active": bool(heap.is_active),
|
||||
"created_at": heap.created_at,
|
||||
"updated_at": heap.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{heap_id}", status_code=204)
|
||||
async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Delete a heap. Photos themselves are unaffected — only the membership
|
||||
rows in heap_photos cascade-delete."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
await db.delete(heap)
|
||||
await db.commit()
|
||||
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")
|
||||
async def add_photos_to_heap(
|
||||
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Add photos to a heap. Idempotent: re-adding existing members is a
|
||||
no-op (handled by an INSERT OR IGNORE-style filter on duplicates)."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
|
||||
if not body.photo_ids:
|
||||
return {"status": "success", "added": 0}
|
||||
|
||||
# Find which ids are already members so we don't violate the PK.
|
||||
existing = await db.execute(
|
||||
select(heap_photos.c.photo_id).where(
|
||||
heap_photos.c.heap_id == heap_id,
|
||||
heap_photos.c.photo_id.in_(body.photo_ids),
|
||||
)
|
||||
)
|
||||
existing_ids = {row[0] for row in existing.all()}
|
||||
new_ids = [pid for pid in body.photo_ids if pid not in existing_ids]
|
||||
|
||||
if new_ids:
|
||||
await db.execute(
|
||||
insert(heap_photos),
|
||||
[{"heap_id": heap_id, "photo_id": pid} for pid in new_ids],
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "added": len(new_ids), "already_present": len(existing_ids)}
|
||||
|
||||
|
||||
@router.delete("/{heap_id}/photos")
|
||||
async def remove_photos_from_heap(
|
||||
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Remove photos from a heap. Removing a non-member is a no-op."""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
heap = result.scalar_one_or_none()
|
||||
if not heap:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
|
||||
if not body.photo_ids:
|
||||
return {"status": "success", "removed": 0}
|
||||
|
||||
res = await db.execute(
|
||||
delete(heap_photos).where(
|
||||
heap_photos.c.heap_id == heap_id,
|
||||
heap_photos.c.photo_id.in_(body.photo_ids),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return {"status": "success", "removed": res.rowcount or 0}
|
||||
|
||||
@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Photo, Folder, Tag, PhotoTag
|
||||
from app.models.heaps import heap_photos
|
||||
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
|
||||
from app.config import settings
|
||||
|
||||
@@ -32,7 +33,6 @@ async def list_photos(
|
||||
rating_min: Optional[int] = Query(None, ge=0, le=5),
|
||||
rating_max: Optional[int] = Query(None, ge=0, le=5),
|
||||
color_label: Optional[str] = None,
|
||||
is_picked: Optional[bool] = None,
|
||||
is_discarded: Optional[bool] = False,
|
||||
heap_id: Optional[str] = None,
|
||||
sort: str = "taken_at",
|
||||
@@ -89,13 +89,17 @@ async def list_photos(
|
||||
else:
|
||||
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
|
||||
filters.append(Photo.is_discarded == is_discarded)
|
||||
|
||||
|
||||
# Heap membership filter — restrict to photos that belong to the heap.
|
||||
if heap_id:
|
||||
filters.append(
|
||||
Photo.id.in_(
|
||||
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
||||
)
|
||||
)
|
||||
|
||||
# Apply all filters
|
||||
if filters:
|
||||
query = query.where(and_(*filters))
|
||||
@@ -459,10 +463,6 @@ async def bulk_action(
|
||||
elif action.action == 'set_color':
|
||||
for photo in photos:
|
||||
photo.color_label = action.value
|
||||
elif action.action == 'pick':
|
||||
for photo in photos:
|
||||
photo.is_picked = True
|
||||
photo.is_discarded = False
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ class PhotoBase(BaseModel):
|
||||
user_notes: Optional[str] = None
|
||||
rating: int = 0
|
||||
color_label: Optional[str] = None
|
||||
is_picked: bool = False
|
||||
|
||||
class PhotoResponse(PhotoBase):
|
||||
"""Photo response schema"""
|
||||
@@ -51,7 +50,6 @@ class PhotoUpdate(BaseModel):
|
||||
user_notes: Optional[str] = None
|
||||
rating: Optional[int] = Field(None, ge=0, le=5)
|
||||
color_label: Optional[str] = None
|
||||
is_picked: Optional[bool] = None
|
||||
is_discarded: Optional[bool] = None
|
||||
taken_at: Optional[datetime] = None
|
||||
|
||||
@@ -66,5 +64,5 @@ class PhotoListResponse(BaseModel):
|
||||
class BulkAction(BaseModel):
|
||||
"""Bulk action on photos"""
|
||||
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)
|
||||
@@ -11,7 +11,7 @@ export function KeyboardHints() {
|
||||
const hints = selectedCount > 0
|
||||
? [
|
||||
{ key: '1-5', action: 'Rate' },
|
||||
{ key: 'P', action: 'Pick' },
|
||||
{ key: 'P', action: 'Pick → heap' },
|
||||
{ key: 'X', action: 'Discard' },
|
||||
{ key: 'Space', action: 'Preview' },
|
||||
{ key: 'Esc', action: 'Deselect' },
|
||||
|
||||
@@ -25,9 +25,7 @@ const COLOR_LABELS: { value: ColorLabel; className: string }[] = [
|
||||
|
||||
const FLAG_OPTIONS: { value: FlagFilter; label: string }[] = [
|
||||
{ value: 'any', label: 'Any' },
|
||||
{ value: 'picked', label: 'Picked' },
|
||||
{ value: 'discarded', label: 'Discarded' },
|
||||
{ value: 'unflagged', label: 'Unflagged' },
|
||||
]
|
||||
|
||||
export function FilterBar() {
|
||||
|
||||
224
frontend/src/components/heaps/HeapsPanel.tsx
Normal file
224
frontend/src/components/heaps/HeapsPanel.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ShoppingBasket,
|
||||
Plus,
|
||||
Target,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||
import { heaps as heapsApi } from '../../services/api'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { toast } from '../ToastContainer'
|
||||
|
||||
/**
|
||||
* Heaps panel for the left sidebar. Renders the list of heaps with the
|
||||
* basket icon, lets the user create a new heap, click one to filter the
|
||||
* timeline to its contents, set one as the "active" target for the T
|
||||
* shortcut, and delete heaps.
|
||||
*
|
||||
* Heap state:
|
||||
* - filter heapId: which heap is currently filtered to (visual)
|
||||
* - heap.is_active: which heap T adds to (server-side, single per row)
|
||||
*/
|
||||
export function HeapsPanel() {
|
||||
const { data: heaps = [] } = useHeapsQuery()
|
||||
const filterHeapId = useFilterStore((s) => s.heapId)
|
||||
const setFilterHeapId = useFilterStore((s) => s.setHeapId)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||
}
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => heapsApi.create(name),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
setNewName('')
|
||||
setCreating(false)
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Failed to create heap', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const setActiveMutation = useMutation({
|
||||
mutationFn: (heapId: string) =>
|
||||
heapsApi.update(heapId, { is_active: true }),
|
||||
onSuccess: (heap) => {
|
||||
invalidate()
|
||||
toast.success('Active heap', `Now adding to "${heap.name}" with T`)
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Failed to set active', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (heapId: string) => heapsApi.delete(heapId),
|
||||
onSuccess: (_, heapId) => {
|
||||
invalidate()
|
||||
// If we were filtering by this heap, clear the filter
|
||||
if (filterHeapId === heapId) setFilterHeapId(null)
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Failed to delete heap', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const handleCreate = () => {
|
||||
const name = newName.trim()
|
||||
if (!name) return
|
||||
createMutation.mutate(name)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Section header */}
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm text-text hover:bg-surface-2"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<button className="rounded p-0.5 hover:bg-surface-offset">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<ShoppingBasket className="h-4 w-4 text-text-muted" />
|
||||
<span className="flex-1 truncate">Heaps</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setCreating(true)
|
||||
setExpanded(true)
|
||||
}}
|
||||
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
|
||||
title="New heap"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div>
|
||||
{/* Inline create form */}
|
||||
{creating && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1"
|
||||
style={{ paddingLeft: '32px' }}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate()
|
||||
if (e.key === 'Escape') {
|
||||
setCreating(false)
|
||||
setNewName('')
|
||||
}
|
||||
}}
|
||||
placeholder="Heap name"
|
||||
className="flex-1 rounded border border-border bg-bg px-2 py-0.5 text-xs text-text focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!newName.trim() || createMutation.isPending}
|
||||
className="rounded bg-primary px-2 py-0.5 text-xs text-white hover:bg-primary/80 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{heaps.length === 0 && !creating && (
|
||||
<div
|
||||
className="px-2 py-1 text-xs text-text-faint"
|
||||
style={{ paddingLeft: '32px' }}
|
||||
>
|
||||
No heaps yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{heaps.map((heap) => {
|
||||
const isFiltered = filterHeapId === heap.id
|
||||
const isActive = heap.is_active
|
||||
return (
|
||||
<div
|
||||
key={heap.id}
|
||||
className={clsx(
|
||||
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[13px]',
|
||||
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2'
|
||||
)}
|
||||
style={{ paddingLeft: '32px' }}
|
||||
onClick={() => setFilterHeapId(heap.id)}
|
||||
>
|
||||
<ShoppingBasket
|
||||
className={clsx(
|
||||
'h-4 w-4 flex-shrink-0',
|
||||
isFiltered ? 'text-primary' : 'text-text-muted'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={clsx(
|
||||
'flex-1 truncate',
|
||||
isActive && 'font-semibold'
|
||||
)}
|
||||
title={heap.name}
|
||||
>
|
||||
{heap.name}
|
||||
</span>
|
||||
{isActive && (
|
||||
<Target
|
||||
className="h-3 w-3 text-primary"
|
||||
aria-label="Active heap (T target)"
|
||||
/>
|
||||
)}
|
||||
{heap.photo_count > 0 && (
|
||||
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
|
||||
{heap.photo_count}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!isActive) setActiveMutation.mutate(heap.id)
|
||||
}}
|
||||
className={clsx(
|
||||
'rounded p-0.5 hover:bg-surface-offset hover:text-text',
|
||||
isActive
|
||||
? 'invisible'
|
||||
: 'invisible text-text-muted group-hover:visible'
|
||||
)}
|
||||
title="Set as active heap (T target)"
|
||||
>
|
||||
<Target className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`Delete heap "${heap.name}"? Photos are not affected.`)) {
|
||||
deleteMutation.mutate(heap.id)
|
||||
}
|
||||
}}
|
||||
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-reject group-hover:visible"
|
||||
title="Delete heap"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
Image,
|
||||
Calendar,
|
||||
Star,
|
||||
Flag,
|
||||
Trash2,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
HardDrive,
|
||||
RefreshCw
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
|
||||
@@ -19,6 +18,7 @@ import { sourceFolders, library } from '../../services/api'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { HeapsPanel } from '../heaps/HeapsPanel'
|
||||
|
||||
interface TreeItem {
|
||||
id: string
|
||||
@@ -51,10 +51,6 @@ export function LeftSidebar() {
|
||||
clearAllFilters()
|
||||
setRatingMin(1)
|
||||
break
|
||||
case 'flagged':
|
||||
clearAllFilters()
|
||||
setFlag('picked')
|
||||
break
|
||||
case 'discarded':
|
||||
clearAllFilters()
|
||||
setFlag('discarded')
|
||||
@@ -137,7 +133,6 @@ export function LeftSidebar() {
|
||||
{ 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: '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 },
|
||||
],
|
||||
},
|
||||
@@ -153,12 +148,6 @@ export function LeftSidebar() {
|
||||
type: 'folder',
|
||||
})) || [],
|
||||
},
|
||||
{
|
||||
id: 'heaps',
|
||||
label: 'Heaps',
|
||||
icon: <Folder className="h-4 w-4" />,
|
||||
children: [], // Will be populated from API
|
||||
},
|
||||
]
|
||||
|
||||
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
|
||||
@@ -257,6 +246,7 @@ export function LeftSidebar() {
|
||||
{/* Tree View */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{libraryTree.map((item) => renderTreeItem(item))}
|
||||
<HeapsPanel />
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import {
|
||||
X,
|
||||
Star,
|
||||
@@ -8,14 +8,16 @@ import {
|
||||
Info,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Check,
|
||||
ShoppingBasket,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
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 {
|
||||
id: string
|
||||
@@ -26,11 +28,24 @@ interface PhotoDetails {
|
||||
file_size: number | null
|
||||
taken_at: string | null
|
||||
rating: number
|
||||
is_picked: boolean
|
||||
is_discarded: boolean
|
||||
user_title: string | null
|
||||
user_notes: string | null
|
||||
color_label: string | null
|
||||
exif_json: string | null
|
||||
}
|
||||
|
||||
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
||||
|
||||
const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [
|
||||
{ value: 'red', className: 'bg-red-500' },
|
||||
{ value: 'orange', className: 'bg-orange-500' },
|
||||
{ value: 'yellow', className: 'bg-yellow-400' },
|
||||
{ value: 'green', className: 'bg-green-500' },
|
||||
{ value: 'blue', className: 'bg-blue-500' },
|
||||
{ value: 'purple', className: 'bg-purple-500' },
|
||||
]
|
||||
|
||||
interface ExifData {
|
||||
Make?: string
|
||||
Model?: string
|
||||
@@ -102,13 +117,16 @@ export function RightSidebar() {
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
// Mutations for rating / pick / reject. Optimistic-ish: invalidate the
|
||||
// photo query and the timeline list query so the grid re-renders too.
|
||||
// Mutation for any patchable field on the active photo. Invalidates both
|
||||
// the photo detail cache and the timeline list so the grid reflects the
|
||||
// change too.
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
rating?: number
|
||||
is_picked?: boolean
|
||||
is_discarded?: boolean
|
||||
user_title?: string | null
|
||||
user_notes?: string | null
|
||||
color_label?: string | null
|
||||
}) => photosApi.update(activePhotoId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
|
||||
@@ -116,6 +134,73 @@ 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])
|
||||
},
|
||||
// Optimistic flip so the badge / button label update instantly.
|
||||
onMutate: ({ remove }) => {
|
||||
if (!activeHeap || !activePhotoId) return { previous: undefined }
|
||||
const key = ['heap-photo-ids', activeHeap.id] as const
|
||||
const previous = queryClient.getQueryData<string[]>(key)
|
||||
const set = new Set(previous ?? [])
|
||||
if (remove) set.delete(activePhotoId)
|
||||
else set.add(activePhotoId)
|
||||
queryClient.setQueryData<string[]>(key, Array.from(set))
|
||||
return { previous }
|
||||
},
|
||||
onError: (_e, _vars, ctx) => {
|
||||
if (activeHeap && ctx?.previous) {
|
||||
queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||
if (activeHeap) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['heap-photo-ids', activeHeap.id],
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 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
|
||||
// clobber edits with stale refetches.
|
||||
const [titleDraft, setTitleDraft] = useState('')
|
||||
const [notesDraft, setNotesDraft] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setTitleDraft(photo?.user_title ?? '')
|
||||
setNotesDraft(photo?.user_notes ?? '')
|
||||
}, [photo?.id, photo?.user_title, photo?.user_notes])
|
||||
|
||||
const commitTitle = () => {
|
||||
const next = titleDraft.trim()
|
||||
const current = photo?.user_title ?? ''
|
||||
if (next === current) return
|
||||
updateMutation.mutate({ user_title: next || null })
|
||||
}
|
||||
|
||||
const commitNotes = () => {
|
||||
const next = notesDraft
|
||||
const current = photo?.user_notes ?? ''
|
||||
if (next === current) return
|
||||
updateMutation.mutate({ user_notes: next || null })
|
||||
}
|
||||
|
||||
const setColor = (label: ColorLabel | null) => {
|
||||
updateMutation.mutate({ color_label: label })
|
||||
}
|
||||
|
||||
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
|
||||
|
||||
if (selectedPhotos.length === 0) {
|
||||
@@ -131,8 +216,8 @@ export function RightSidebar() {
|
||||
|
||||
const multipleSelected = selectedPhotos.length > 1
|
||||
const rating = photo?.rating ?? 0
|
||||
const isPicked = photo?.is_picked ?? false
|
||||
const isDiscarded = photo?.is_discarded ?? false
|
||||
const colorLabel = (photo?.color_label ?? null) as ColorLabel | null
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-surface">
|
||||
@@ -154,8 +239,43 @@ export function RightSidebar() {
|
||||
|
||||
{/* Quick Actions — operate on the active photo */}
|
||||
{photo && !multipleSelected && (
|
||||
<div className="border-b border-border p-4">
|
||||
<div className="mb-3">
|
||||
<div className="space-y-3 border-b border-border p-4">
|
||||
{/* Title (editable) */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
setTitleDraft(photo.user_title ?? '')
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder="No title"
|
||||
className="w-full rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes (editable) */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Notes</label>
|
||||
<textarea
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
onBlur={commitNotes}
|
||||
placeholder="Add notes…"
|
||||
rows={3}
|
||||
className="w-full resize-none rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
@@ -180,32 +300,64 @@ export function RightSidebar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color label */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Color label</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
|
||||
const active = colorLabel === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setColor(active ? null : value)}
|
||||
className={clsx(
|
||||
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
|
||||
className,
|
||||
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
|
||||
)}
|
||||
title={value}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{colorLabel && (
|
||||
<button
|
||||
onClick={() => setColor(null)}
|
||||
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Clear color label"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flag */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
updateMutation.mutate({
|
||||
is_picked: !isPicked,
|
||||
is_discarded: false,
|
||||
})
|
||||
}
|
||||
onClick={() => heapMutation.mutate({ remove: isInActiveHeap })}
|
||||
disabled={!activeHeap || heapMutation.isPending}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||
isPicked
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isInActiveHeap
|
||||
? 'bg-pick/20 text-pick'
|
||||
: '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" />
|
||||
Pick
|
||||
<ShoppingBasket className="h-3 w-3" />
|
||||
{isInActiveHeap ? 'Picked' : 'Pick'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
updateMutation.mutate({
|
||||
is_discarded: !isDiscarded,
|
||||
is_picked: false,
|
||||
})
|
||||
updateMutation.mutate({ is_discarded: !isDiscarded })
|
||||
}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||
|
||||
@@ -8,15 +8,12 @@ import {
|
||||
Upload,
|
||||
Settings,
|
||||
Menu,
|
||||
Trash2,
|
||||
X,
|
||||
ShoppingBasket,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
||||
import { photos } from '../../services/api'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
|
||||
import muliLogo from '../../assets/muli-logo.png'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
@@ -53,26 +50,12 @@ export function TopBar() {
|
||||
}, [searchQuery, storeQ, setStoreQ])
|
||||
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
|
||||
const clearSelection = usePhotoStore((state) => state.clearSelection)
|
||||
const selectedCount = selectedPhotos.length
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Mutation for discarding selected photos
|
||||
const discardPhotosMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await photos.bulkUpdate(selectedPhotos, { discard: true })
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Discarded', `${selectedCount} photo${selectedCount > 1 ? 's' : ''} discarded`)
|
||||
clearSelection()
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error('Failed to discard', error.message || 'An error occurred')
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
// Currently active heap. Shown as a pill so the user always knows where
|
||||
// their next P-press will land.
|
||||
const { data: heapsList = [] } = useHeapsQuery()
|
||||
const activeHeap = heapsList.find((h) => h.is_active)
|
||||
|
||||
return (
|
||||
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
||||
{/* Left Section - Menu and App Name */}
|
||||
@@ -90,21 +73,14 @@ export function TopBar() {
|
||||
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
|
||||
<h1 className="text-lg font-semibold text-text">Mulita</h1>
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<span className="rounded bg-primary/20 px-2 py-0.5 text-sm text-primary">
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => discardPhotosMutation.mutate()}
|
||||
disabled={discardPhotosMutation.isPending}
|
||||
className="flex items-center gap-1 rounded bg-reject/20 px-2 py-0.5 text-sm text-reject hover:bg-reject/30 disabled:opacity-50"
|
||||
title="Discard"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Discard
|
||||
</button>
|
||||
</>
|
||||
{activeHeap && (
|
||||
<span
|
||||
className="flex items-center gap-1 rounded bg-primary/20 px-2 py-0.5 text-xs text-primary"
|
||||
title="Active heap — press P to add selected photos here"
|
||||
>
|
||||
<ShoppingBasket className="h-3 w-3" />
|
||||
{activeHeap.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { photos as photosApi } from '../../services/api'
|
||||
import type { Photo } from '../../types/photo'
|
||||
@@ -12,11 +12,20 @@ interface PhotoThumbnailProps {
|
||||
photo: Photo
|
||||
size: number
|
||||
isSelected: boolean
|
||||
/** True when the photo belongs to the currently active heap. */
|
||||
isInActiveHeap?: boolean
|
||||
onClick: (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 [imageLoaded, setImageLoaded] = useState(false)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
@@ -167,9 +176,14 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick
|
||||
)}
|
||||
|
||||
{/* Flag Indicators */}
|
||||
<div className="absolute bottom-1 right-1">
|
||||
{photo.is_picked && (
|
||||
<Check className="h-4 w-4 text-pick" />
|
||||
<div className="absolute bottom-1 right-1 flex items-center gap-1">
|
||||
{isInActiveHeap && (
|
||||
<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 && (
|
||||
<Trash2 className="h-4 w-4 text-reject" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import type { Photo } from '../../types/photo'
|
||||
|
||||
export function Timeline() {
|
||||
@@ -48,6 +49,11 @@ export function Timeline() {
|
||||
// they share one cache entry, regardless of filter state.
|
||||
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
|
||||
const rows = useMemo(() => {
|
||||
const result: Photo[][] = []
|
||||
@@ -212,6 +218,7 @@ export function Timeline() {
|
||||
photo={photo}
|
||||
size={thumbnailSize}
|
||||
isSelected={selectedPhotos.includes(photo.id)}
|
||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||
onClick={(e) => {
|
||||
if (e.shiftKey && lastSelectedIndex !== null) {
|
||||
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',
|
||||
'purple',
|
||||
]
|
||||
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'discarded', 'unflagged']
|
||||
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'discarded']
|
||||
|
||||
function parseUrl(): Partial<FilterState> {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
@@ -55,6 +55,9 @@ function parseUrl(): Partial<FilterState> {
|
||||
out.flag = flag as FlagFilter
|
||||
}
|
||||
|
||||
const heapId = sp.get('heap_id')
|
||||
if (heapId) out.heapId = heapId
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -67,6 +70,7 @@ function writeUrl(f: FilterState) {
|
||||
if (f.ratingMin > 0) sp.set('rating_min', String(f.ratingMin))
|
||||
if (f.colorLabel) sp.set('color_label', f.colorLabel)
|
||||
if (f.flag !== 'any') sp.set('flag', f.flag)
|
||||
if (f.heapId) sp.set('heap_id', f.heapId)
|
||||
|
||||
const search = sp.toString()
|
||||
const next = search ? `?${search}` : window.location.pathname
|
||||
|
||||
12
frontend/src/hooks/useHeapsQuery.ts
Normal file
12
frontend/src/hooks/useHeapsQuery.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { heaps as heapsApi, type Heap } from '../services/api'
|
||||
|
||||
export const HEAPS_QUERY_KEY = ['heaps'] as const
|
||||
|
||||
export function useHeapsQuery() {
|
||||
return useQuery<Heap[]>({
|
||||
queryKey: HEAPS_QUERY_KEY,
|
||||
queryFn: heapsApi.list,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePhotoStore } from '../store/photoStore'
|
||||
import { useFilterStore } from '../store/filterStore'
|
||||
import { photos as photosApi } from '../services/api'
|
||||
import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/api'
|
||||
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
|
||||
import { toast } from '../components/ToastContainer'
|
||||
|
||||
interface KeyboardShortcutsProps {
|
||||
onToggleLeftSidebar: () => void
|
||||
@@ -13,7 +15,6 @@ interface KeyboardShortcutsProps {
|
||||
|
||||
interface PhotoUpdate {
|
||||
rating?: number
|
||||
is_picked?: boolean
|
||||
is_discarded?: boolean
|
||||
color_label?: string | null
|
||||
}
|
||||
@@ -60,6 +61,99 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||
updateMutation.mutate({ id, data })
|
||||
}
|
||||
|
||||
// P key (Pick): toggle the current selection's membership in the active
|
||||
// heap. If every selected photo is already a member, remove them; otherwise
|
||||
// add the missing ones. No active heap → toast hint.
|
||||
const heapMutation = useMutation({
|
||||
mutationFn: ({
|
||||
heapId,
|
||||
photoIds,
|
||||
remove,
|
||||
}: {
|
||||
heapId: string
|
||||
photoIds: string[]
|
||||
remove: boolean
|
||||
}) =>
|
||||
remove
|
||||
? heapsApi.removePhotos(heapId, photoIds)
|
||||
: heapsApi.addPhotos(heapId, photoIds),
|
||||
// Optimistically flip the membership cache so the basket affordance
|
||||
// updates instantly and a quick second P press reads the new state
|
||||
// (otherwise invalidate-then-refetch leaves a brief stale window).
|
||||
onMutate: ({ heapId, photoIds, remove }) => {
|
||||
const key = ['heap-photo-ids', heapId] as const
|
||||
const previous = queryClient.getQueryData<string[]>(key)
|
||||
const set = new Set(previous ?? [])
|
||||
if (remove) photoIds.forEach((id) => set.delete(id))
|
||||
else photoIds.forEach((id) => set.add(id))
|
||||
queryClient.setQueryData<string[]>(key, Array.from(set))
|
||||
return { previous }
|
||||
},
|
||||
onError: (e: any, _vars, ctx) => {
|
||||
// Roll back the optimistic update on failure.
|
||||
if (ctx?.previous) {
|
||||
queryClient.setQueryData(['heap-photo-ids', _vars.heapId], ctx.previous)
|
||||
}
|
||||
toast.error('Heap update failed', e.message || 'Unknown error')
|
||||
},
|
||||
onSuccess: (data, vars) => {
|
||||
const heap = (queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []).find(
|
||||
(h) => h.id === vars.heapId
|
||||
)
|
||||
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) {
|
||||
toast.success(
|
||||
`Added to ${heapName}`,
|
||||
`${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}`
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, vars) => {
|
||||
// Re-sync with server truth (heap counts in particular need this).
|
||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: ['heap-photo-ids', vars.heapId] })
|
||||
},
|
||||
})
|
||||
|
||||
const togglePickOnSelection = () => {
|
||||
const state = usePhotoStore.getState()
|
||||
const ids =
|
||||
state.selectedPhotos.length > 0
|
||||
? state.selectedPhotos
|
||||
: state.activePhotoId
|
||||
? [state.activePhotoId]
|
||||
: []
|
||||
if (ids.length === 0) {
|
||||
toast.info('Nothing selected', 'Select photos first, then press P')
|
||||
return
|
||||
}
|
||||
const heapsList = queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []
|
||||
const active = heapsList.find((h) => h.is_active)
|
||||
if (!active) {
|
||||
toast.info('No active heap', 'Set an active heap (target icon next to a heap)')
|
||||
return
|
||||
}
|
||||
// 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
|
||||
useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
|
||||
useHotkeys('i', onToggleRightSidebar, HK_OPTS)
|
||||
@@ -103,26 +197,14 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||
|
||||
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
|
||||
|
||||
// Pick / discard / unflag. Discard is a soft flag that hides the photo
|
||||
// from the default timeline view; restore via the Discarded view (or the
|
||||
// U shortcut).
|
||||
useHotkeys(
|
||||
'p',
|
||||
() => updateActive({ is_picked: true, is_discarded: false }),
|
||||
HK_OPTS
|
||||
)
|
||||
// P (Pick) is unified with "add to active heap" — Pick a photo and you're
|
||||
// adding it to the heap you set as active. Toggling on already-picked
|
||||
// photos removes them from the heap.
|
||||
useHotkeys('p', togglePickOnSelection, HK_OPTS)
|
||||
|
||||
useHotkeys(
|
||||
'x',
|
||||
() => updateActive({ is_discarded: true, is_picked: false }),
|
||||
HK_OPTS
|
||||
)
|
||||
useHotkeys('x', () => updateActive({ is_discarded: true }), HK_OPTS)
|
||||
|
||||
useHotkeys(
|
||||
'u',
|
||||
() => updateActive({ is_picked: false, is_discarded: false }),
|
||||
HK_OPTS
|
||||
)
|
||||
useHotkeys('u', () => updateActive({ is_discarded: false }), HK_OPTS)
|
||||
|
||||
// Color labels 6-9 (red/orange/yellow/green per spec §6.4).
|
||||
useHotkeys(
|
||||
@@ -133,4 +215,5 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||
},
|
||||
HK_OPTS
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export function usePhotosQuery() {
|
||||
const ratingMin = useFilterStore((s) => s.ratingMin)
|
||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||
const flag = useFilterStore((s) => s.flag)
|
||||
const heapId = useFilterStore((s) => s.heapId)
|
||||
|
||||
const filterParams = useMemo(
|
||||
() =>
|
||||
@@ -29,8 +30,9 @@ export function usePhotosQuery() {
|
||||
ratingMin,
|
||||
colorLabel,
|
||||
flag,
|
||||
heapId,
|
||||
}),
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag]
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId]
|
||||
)
|
||||
|
||||
return useQuery({
|
||||
|
||||
@@ -57,9 +57,12 @@ export const photos = {
|
||||
|
||||
update: async (photoId: string, data: {
|
||||
rating?: number
|
||||
flag?: string
|
||||
user_title?: string
|
||||
user_notes?: string
|
||||
user_title?: string | null
|
||||
user_notes?: string | null
|
||||
color_label?: string | null
|
||||
is_picked?: boolean
|
||||
is_discarded?: boolean
|
||||
taken_at?: string
|
||||
}) => {
|
||||
const response = await api.patch(`/photos/${photoId}`, data)
|
||||
return response.data
|
||||
@@ -112,30 +115,56 @@ export const library = {
|
||||
}
|
||||
|
||||
// Heaps API
|
||||
export interface Heap {
|
||||
id: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
photo_count: number
|
||||
}
|
||||
|
||||
export const heaps = {
|
||||
list: async () => {
|
||||
list: async (): Promise<Heap[]> => {
|
||||
const response = await api.get('/heaps')
|
||||
return response.data
|
||||
},
|
||||
|
||||
create: async (name: string, description?: string) => {
|
||||
const response = await api.post('/heaps', {
|
||||
name,
|
||||
description,
|
||||
})
|
||||
create: async (name: string): Promise<Heap> => {
|
||||
const response = await api.post('/heaps', { name })
|
||||
return response.data
|
||||
},
|
||||
|
||||
update: async (heapId: string, data: {
|
||||
name?: string
|
||||
description?: string
|
||||
}) => {
|
||||
update: async (
|
||||
heapId: string,
|
||||
data: { name?: string; is_active?: boolean }
|
||||
): Promise<Heap> => {
|
||||
const response = await api.patch(`/heaps/${heapId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
delete: async (heapId: string) => {
|
||||
const response = await api.delete(`/heaps/${heapId}`)
|
||||
delete: async (heapId: string): Promise<void> => {
|
||||
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[]) => {
|
||||
const response = await api.post(`/heaps/${heapId}/photos`, {
|
||||
photo_ids: photoIds,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
removePhotos: async (heapId: string, photoIds: string[]) => {
|
||||
const response = await api.delete(`/heaps/${heapId}/photos`, {
|
||||
data: { photo_ids: photoIds },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand'
|
||||
|
||||
export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
|
||||
export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
||||
export type FlagFilter = 'any' | 'picked' | 'discarded' | 'unflagged'
|
||||
export type FlagFilter = 'any' | 'discarded'
|
||||
|
||||
export interface FilterState {
|
||||
q: string
|
||||
@@ -12,6 +12,9 @@ export interface FilterState {
|
||||
ratingMin: number // 0-5; 0 means no filter
|
||||
colorLabel: ColorLabel | null
|
||||
flag: FlagFilter
|
||||
/** When set, restrict to photos in this heap. Independent of `activeHeapId`
|
||||
* on the heap store — that's the target for the T shortcut. */
|
||||
heapId: string | null
|
||||
}
|
||||
|
||||
interface FilterStore extends FilterState {
|
||||
@@ -24,6 +27,7 @@ interface FilterStore extends FilterState {
|
||||
setRatingMin: (rating: number) => void
|
||||
setColorLabel: (label: ColorLabel | null) => void
|
||||
setFlag: (flag: FlagFilter) => void
|
||||
setHeapId: (id: string | null) => void
|
||||
|
||||
setFilterBarOpen: (open: boolean) => void
|
||||
toggleFilterBar: () => void
|
||||
@@ -40,6 +44,7 @@ export const INITIAL_FILTERS: FilterState = {
|
||||
ratingMin: 0,
|
||||
colorLabel: null,
|
||||
flag: 'any',
|
||||
heapId: null,
|
||||
}
|
||||
|
||||
export const useFilterStore = create<FilterStore>((set) => ({
|
||||
@@ -58,6 +63,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
||||
setRatingMin: (ratingMin) => set({ ratingMin }),
|
||||
setColorLabel: (colorLabel) => set({ colorLabel }),
|
||||
setFlag: (flag) => set({ flag }),
|
||||
setHeapId: (heapId) => set({ heapId }),
|
||||
|
||||
setFilterBarOpen: (filterBarOpen) => set({ filterBarOpen }),
|
||||
toggleFilterBar: () => set((s) => ({ filterBarOpen: !s.filterBarOpen })),
|
||||
@@ -76,9 +82,8 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
|
||||
if (f.mediaTypes.length > 0) params.media_type = f.mediaTypes.join(',')
|
||||
if (f.ratingMin > 0) params.rating_min = f.ratingMin
|
||||
if (f.colorLabel) params.color_label = f.colorLabel
|
||||
if (f.flag === 'picked') params.is_picked = 'true'
|
||||
else if (f.flag === 'discarded') params.is_discarded = 'true'
|
||||
else if (f.flag === 'unflagged') params.is_picked = 'false'
|
||||
if (f.flag === 'discarded') params.is_discarded = 'true'
|
||||
if (f.heapId) params.heap_id = f.heapId
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -91,6 +96,7 @@ export function hasActiveFilters(f: FilterState): boolean {
|
||||
f.mediaTypes.length > 0 ||
|
||||
f.ratingMin > 0 ||
|
||||
f.colorLabel !== null ||
|
||||
f.flag !== 'any'
|
||||
f.flag !== 'any' ||
|
||||
f.heapId !== null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export interface Photo {
|
||||
height: number | null
|
||||
taken_at: string | null
|
||||
rating: number
|
||||
is_picked: boolean
|
||||
is_discarded: boolean
|
||||
file_hash: string
|
||||
thumb_small?: string
|
||||
|
||||
Reference in New Issue
Block a user