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)
|
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)
|
||||||
|
|||||||
@@ -1,27 +1,203 @@
|
|||||||
"""
|
"""
|
||||||
Heaps API router
|
Heaps API router
|
||||||
"""
|
"""
|
||||||
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Heap
|
from app.models import Heap
|
||||||
|
from app.models.heaps import heap_photos
|
||||||
|
|
||||||
router = APIRouter()
|
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("")
|
@router.get("")
|
||||||
async def list_heaps(db: AsyncSession = Depends(get_db)):
|
async def list_heaps(db: AsyncSession = Depends(get_db)):
|
||||||
"""List all heaps"""
|
"""List all heaps with photo counts."""
|
||||||
result = await db.execute(select(Heap))
|
# LEFT JOIN heap_photos and group so we can return counts in one query.
|
||||||
heaps = result.scalars().all()
|
count_subq = (
|
||||||
return heaps
|
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("")
|
stmt = (
|
||||||
async def create_heap(name: str, db: AsyncSession = Depends(get_db)):
|
select(Heap, count_subq.c.photo_count)
|
||||||
"""Create a new heap"""
|
.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)
|
heap = Heap(name=name)
|
||||||
db.add(heap)
|
db.add(heap)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(heap)
|
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.database import get_db
|
||||||
from app.models import Photo, Folder, Tag, PhotoTag
|
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.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
@@ -32,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",
|
||||||
@@ -89,13 +89,17 @@ 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)
|
||||||
|
|
||||||
|
# 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
|
# Apply all filters
|
||||||
if filters:
|
if filters:
|
||||||
query = query.where(and_(*filters))
|
query = query.where(and_(*filters))
|
||||||
@@ -459,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,7 +11,7 @@ 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: '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() {
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -19,6 +18,7 @@ import { sourceFolders, library } from '../../services/api'
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
|
import { HeapsPanel } from '../heaps/HeapsPanel'
|
||||||
|
|
||||||
interface TreeItem {
|
interface TreeItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -51,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')
|
||||||
@@ -137,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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -153,12 +148,6 @@ export function LeftSidebar() {
|
|||||||
type: 'folder',
|
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) => {
|
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
|
||||||
@@ -257,6 +246,7 @@ export function LeftSidebar() {
|
|||||||
{/* Tree View */}
|
{/* Tree View */}
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
{libraryTree.map((item) => renderTreeItem(item))}
|
{libraryTree.map((item) => renderTreeItem(item))}
|
||||||
|
<HeapsPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Actions */}
|
{/* Bottom Actions */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Star,
|
Star,
|
||||||
@@ -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,11 +28,24 @@ 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_notes: string | null
|
||||||
|
color_label: string | null
|
||||||
exif_json: 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 {
|
interface ExifData {
|
||||||
Make?: string
|
Make?: string
|
||||||
Model?: string
|
Model?: string
|
||||||
@@ -102,13 +117,16 @@ export function RightSidebar() {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mutations for rating / pick / reject. Optimistic-ish: invalidate the
|
// Mutation for any patchable field on the active photo. Invalidates both
|
||||||
// photo query and the timeline list query so the grid re-renders too.
|
// the photo detail cache and the timeline list so the grid reflects the
|
||||||
|
// change too.
|
||||||
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_notes?: string | null
|
||||||
|
color_label?: string | null
|
||||||
}) => photosApi.update(activePhotoId!, data),
|
}) => photosApi.update(activePhotoId!, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
|
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])
|
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
|
||||||
|
|
||||||
if (selectedPhotos.length === 0) {
|
if (selectedPhotos.length === 0) {
|
||||||
@@ -131,8 +216,8 @@ 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
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
@@ -154,8 +239,43 @@ export function RightSidebar() {
|
|||||||
|
|
||||||
{/* Quick Actions — operate on the active photo */}
|
{/* Quick Actions — operate on the active photo */}
|
||||||
{photo && !multipleSelected && (
|
{photo && !multipleSelected && (
|
||||||
<div className="border-b border-border p-4">
|
<div className="space-y-3 border-b border-border p-4">
|
||||||
<div className="mb-3">
|
{/* 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>
|
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{[1, 2, 3, 4, 5].map((value) => (
|
{[1, 2, 3, 4, 5].map((value) => (
|
||||||
@@ -180,32 +300,64 @@ export function RightSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
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',
|
||||||
|
|||||||
@@ -8,15 +8,12 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
Settings,
|
Settings,
|
||||||
Menu,
|
Menu,
|
||||||
Trash2,
|
|
||||||
X,
|
X,
|
||||||
|
ShoppingBasket,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
|
||||||
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
||||||
import { photos } from '../../services/api'
|
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
|
||||||
import { toast } from '../ToastContainer'
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import muliLogo from '../../assets/muli-logo.png'
|
import muliLogo from '../../assets/muli-logo.png'
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 300
|
const SEARCH_DEBOUNCE_MS = 300
|
||||||
@@ -53,25 +50,11 @@ export function TopBar() {
|
|||||||
}, [searchQuery, storeQ, setStoreQ])
|
}, [searchQuery, storeQ, setStoreQ])
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
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
|
// Currently active heap. Shown as a pill so the user always knows where
|
||||||
const discardPhotosMutation = useMutation({
|
// their next P-press will land.
|
||||||
mutationFn: async () => {
|
const { data: heapsList = [] } = useHeapsQuery()
|
||||||
await photos.bulkUpdate(selectedPhotos, { discard: true })
|
const activeHeap = heapsList.find((h) => h.is_active)
|
||||||
},
|
|
||||||
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')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
||||||
@@ -90,21 +73,14 @@ export function TopBar() {
|
|||||||
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
|
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
|
||||||
<h1 className="text-lg font-semibold text-text">Mulita</h1>
|
<h1 className="text-lg font-semibold text-text">Mulita</h1>
|
||||||
</div>
|
</div>
|
||||||
{selectedCount > 0 && (
|
{activeHeap && (
|
||||||
<>
|
<span
|
||||||
<span className="rounded bg-primary/20 px-2 py-0.5 text-sm text-primary">
|
className="flex items-center gap-1 rounded bg-primary/20 px-2 py-0.5 text-xs text-primary"
|
||||||
{selectedCount} selected
|
title="Active heap — press P to add selected photos here"
|
||||||
</span>
|
>
|
||||||
<button
|
<ShoppingBasket className="h-3 w-3" />
|
||||||
onClick={() => discardPhotosMutation.mutate()}
|
{activeHeap.name}
|
||||||
disabled={discardPhotosMutation.isPending}
|
</span>
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -55,6 +55,9 @@ function parseUrl(): Partial<FilterState> {
|
|||||||
out.flag = flag as FlagFilter
|
out.flag = flag as FlagFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const heapId = sp.get('heap_id')
|
||||||
|
if (heapId) out.heapId = heapId
|
||||||
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +70,7 @@ function writeUrl(f: FilterState) {
|
|||||||
if (f.ratingMin > 0) sp.set('rating_min', String(f.ratingMin))
|
if (f.ratingMin > 0) sp.set('rating_min', String(f.ratingMin))
|
||||||
if (f.colorLabel) sp.set('color_label', f.colorLabel)
|
if (f.colorLabel) sp.set('color_label', f.colorLabel)
|
||||||
if (f.flag !== 'any') sp.set('flag', f.flag)
|
if (f.flag !== 'any') sp.set('flag', f.flag)
|
||||||
|
if (f.heapId) sp.set('heap_id', f.heapId)
|
||||||
|
|
||||||
const search = sp.toString()
|
const search = sp.toString()
|
||||||
const next = search ? `?${search}` : window.location.pathname
|
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 { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { usePhotoStore } from '../store/photoStore'
|
import { usePhotoStore } from '../store/photoStore'
|
||||||
import { useFilterStore } from '../store/filterStore'
|
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 {
|
interface KeyboardShortcutsProps {
|
||||||
onToggleLeftSidebar: () => void
|
onToggleLeftSidebar: () => void
|
||||||
@@ -13,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
|
||||||
}
|
}
|
||||||
@@ -60,6 +61,99 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
updateMutation.mutate({ id, data })
|
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
|
// Toggle sidebars
|
||||||
useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
|
useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
|
||||||
useHotkeys('i', onToggleRightSidebar, HK_OPTS)
|
useHotkeys('i', onToggleRightSidebar, HK_OPTS)
|
||||||
@@ -103,26 +197,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(
|
||||||
@@ -133,4 +215,5 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
},
|
},
|
||||||
HK_OPTS
|
HK_OPTS
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function usePhotosQuery() {
|
|||||||
const ratingMin = useFilterStore((s) => s.ratingMin)
|
const ratingMin = useFilterStore((s) => s.ratingMin)
|
||||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||||
const flag = useFilterStore((s) => s.flag)
|
const flag = useFilterStore((s) => s.flag)
|
||||||
|
const heapId = useFilterStore((s) => s.heapId)
|
||||||
|
|
||||||
const filterParams = useMemo(
|
const filterParams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -29,8 +30,9 @@ export function usePhotosQuery() {
|
|||||||
ratingMin,
|
ratingMin,
|
||||||
colorLabel,
|
colorLabel,
|
||||||
flag,
|
flag,
|
||||||
|
heapId,
|
||||||
}),
|
}),
|
||||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag]
|
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId]
|
||||||
)
|
)
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -57,9 +57,12 @@ export const photos = {
|
|||||||
|
|
||||||
update: async (photoId: string, data: {
|
update: async (photoId: string, data: {
|
||||||
rating?: number
|
rating?: number
|
||||||
flag?: string
|
user_title?: string | null
|
||||||
user_title?: string
|
user_notes?: string | null
|
||||||
user_notes?: string
|
color_label?: string | null
|
||||||
|
is_picked?: boolean
|
||||||
|
is_discarded?: boolean
|
||||||
|
taken_at?: string
|
||||||
}) => {
|
}) => {
|
||||||
const response = await api.patch(`/photos/${photoId}`, data)
|
const response = await api.patch(`/photos/${photoId}`, data)
|
||||||
return response.data
|
return response.data
|
||||||
@@ -112,30 +115,56 @@ export const library = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Heaps API
|
// 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 = {
|
export const heaps = {
|
||||||
list: async () => {
|
list: async (): Promise<Heap[]> => {
|
||||||
const response = await api.get('/heaps')
|
const response = await api.get('/heaps')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (name: string, description?: string) => {
|
create: async (name: string): Promise<Heap> => {
|
||||||
const response = await api.post('/heaps', {
|
const response = await api.post('/heaps', { name })
|
||||||
name,
|
|
||||||
description,
|
|
||||||
})
|
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async (heapId: string, data: {
|
update: async (
|
||||||
name?: string
|
heapId: string,
|
||||||
description?: string
|
data: { name?: string; is_active?: boolean }
|
||||||
}) => {
|
): Promise<Heap> => {
|
||||||
const response = await api.patch(`/heaps/${heapId}`, data)
|
const response = await api.patch(`/heaps/${heapId}`, data)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (heapId: string) => {
|
delete: async (heapId: string): Promise<void> => {
|
||||||
const response = 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[]) => {
|
||||||
|
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
|
return response.data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -12,6 +12,9 @@ export interface FilterState {
|
|||||||
ratingMin: number // 0-5; 0 means no filter
|
ratingMin: number // 0-5; 0 means no filter
|
||||||
colorLabel: ColorLabel | null
|
colorLabel: ColorLabel | null
|
||||||
flag: FlagFilter
|
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 {
|
interface FilterStore extends FilterState {
|
||||||
@@ -24,6 +27,7 @@ interface FilterStore extends FilterState {
|
|||||||
setRatingMin: (rating: number) => void
|
setRatingMin: (rating: number) => void
|
||||||
setColorLabel: (label: ColorLabel | null) => void
|
setColorLabel: (label: ColorLabel | null) => void
|
||||||
setFlag: (flag: FlagFilter) => void
|
setFlag: (flag: FlagFilter) => void
|
||||||
|
setHeapId: (id: string | null) => void
|
||||||
|
|
||||||
setFilterBarOpen: (open: boolean) => void
|
setFilterBarOpen: (open: boolean) => void
|
||||||
toggleFilterBar: () => void
|
toggleFilterBar: () => void
|
||||||
@@ -40,6 +44,7 @@ export const INITIAL_FILTERS: FilterState = {
|
|||||||
ratingMin: 0,
|
ratingMin: 0,
|
||||||
colorLabel: null,
|
colorLabel: null,
|
||||||
flag: 'any',
|
flag: 'any',
|
||||||
|
heapId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useFilterStore = create<FilterStore>((set) => ({
|
export const useFilterStore = create<FilterStore>((set) => ({
|
||||||
@@ -58,6 +63,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
|||||||
setRatingMin: (ratingMin) => set({ ratingMin }),
|
setRatingMin: (ratingMin) => set({ ratingMin }),
|
||||||
setColorLabel: (colorLabel) => set({ colorLabel }),
|
setColorLabel: (colorLabel) => set({ colorLabel }),
|
||||||
setFlag: (flag) => set({ flag }),
|
setFlag: (flag) => set({ flag }),
|
||||||
|
setHeapId: (heapId) => set({ heapId }),
|
||||||
|
|
||||||
setFilterBarOpen: (filterBarOpen) => set({ filterBarOpen }),
|
setFilterBarOpen: (filterBarOpen) => set({ filterBarOpen }),
|
||||||
toggleFilterBar: () => set((s) => ({ filterBarOpen: !s.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.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'
|
if (f.heapId) params.heap_id = f.heapId
|
||||||
else if (f.flag === 'unflagged') params.is_picked = 'false'
|
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +96,7 @@ export function hasActiveFilters(f: FilterState): boolean {
|
|||||||
f.mediaTypes.length > 0 ||
|
f.mediaTypes.length > 0 ||
|
||||||
f.ratingMin > 0 ||
|
f.ratingMin > 0 ||
|
||||||
f.colorLabel !== null ||
|
f.colorLabel !== null ||
|
||||||
f.flag !== 'any'
|
f.flag !== 'any' ||
|
||||||
|
f.heapId !== null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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