Pick and add-to-heap were two ways of saying "I want to keep this
one". Merging them: P now toggles the selection's membership in the
active heap. The is_picked flag goes away (orphaned in the DB the
same way is_trashed was).
Backend
- Drop is_picked from PhotoBase / PhotoUpdate / PhotoResponse and
from the photos list filter param.
- Drop the is_picked Column from the Photo model (DB column stays
on legacy installs but is no longer read or written).
- Drop the bulk action 'pick' branch.
- New GET /heaps/{id}/photo_ids returns just the flat string list.
Used by the frontend for fast client-side membership lookups
without fetching full photo records.
Frontend
- New hooks/useActiveHeapMembersQuery.ts → returns
{ activeHeap, memberIds: Set<string> }. Subscribes once at the
Timeline level and passes a derived isInActiveHeap bool down to
each PhotoThumbnail (avoids hundreds of thumbnails subscribing
to the same query).
- PhotoThumbnail: replaces the old check-icon Pick affordance with
a clear basket badge in the bottom-right corner — a small filled
pick-colour pill containing a ShoppingBasket icon — visible only
when the photo belongs to the active heap.
- P shortcut (useKeyboardShortcuts) now toggles membership: if every
selected photo is already a member, it removes them; otherwise it
adds the missing ones. T binding removed (P fully replaces it).
- RightSidebar Pick button is now a Pick / Picked toggle bound to
the active heap. Disabled with a hint when no heap is active.
Shows the heap name in its title attr.
- filterStore drops 'picked' and 'unflagged' from FlagFilter.
FilterBar's flag dropdown is now just Any / Discarded.
- LeftSidebar drops the "Flagged" virtual node (it just set
flag=picked, which no longer exists).
- KeyboardHints: P → "Pick → heap".
- Photo TS type drops is_picked.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
204 lines
6.5 KiB
Python
204 lines
6.5 KiB
Python
"""
|
|
Heaps API router
|
|
"""
|
|
from typing import Optional
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
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 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()
|
|
)
|
|
|
|
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 {
|
|
"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}
|