feat: tags end-to-end (CRUD, photo membership, filter, sidebar UI)

The Tag model and photo_tags join table were already in place; this
fills in the rest — full backend CRUD, per-photo add/remove, list-
endpoint filtering, and a Tags section in the RightSidebar with
autocomplete-create.

Backend
- routers/tags.py rewritten from a 27-line stub:
    GET    /tags                — list with photo counts
    POST   /tags                — create (idempotent on name)
    PATCH  /tags/{id}           — rename / recolor
    DELETE /tags/{id}           — delete (FK cascades photo_tags)
- routers/photos.py:
    POST   /photos/{id}/tags    — add tag ids (idempotent)
    DELETE /photos/{id}/tags/{tag_id} — remove
    GET    /photos/{id}         — now returns a `tags` list alongside
                                  the existing PhotoResponse fields
                                  (fetched via the photo_tags join)
- list_photos applies the existing tag_ids query param: comma-
  separated, AND semantics, one IN-subquery per id since SQLite
  has no native set-contains-all.

Frontend
- New hooks/useTagsQuery.ts.
- services/api.ts: Tag interface, full tags client (list/create/
  update/delete), addToPhoto/removeFromPhoto helpers.
- filterStore: tagIds: string[] field, setTagIds, toggleTagId,
  hasActiveFilters update, filtersToParams sends tag_ids comma list.
- useFilterUrlSync round-trips ?tag_ids=… so tag-filtered views
  are bookmarkable.
- usePhotosQuery threads tagIds through.
- RightSidebar gains a new Tags section using a TagsEditor
  component:
    - shows existing tag chips with X to remove
    - autocomplete input that matches the user's typing against
      existing tag names
    - shows an inline "+ Create '<name>'" affordance when there's
      no exact match
    - Enter creates and attaches in one shot; Esc clears the input
    - existing colour values render as a tinted chip background
- FilterBar gets a Tags group (only rendered when there's at
  least one tag) with toggleable chips per tag.
- ActiveFilterChips shows "Tag: <name>" chips for each active
  tag id, looking up names lazily from the tags query.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:20:32 +02:00
parent 63383ecf1c
commit bc1e63095c
10 changed files with 480 additions and 32 deletions

View File

@@ -16,9 +16,10 @@ import logging
logger = logging.getLogger(__name__)
from app.database import get_db
from app.models import Photo, Folder, Tag, PhotoTag
from app.models import Photo, Folder, Tag
from app.models.folders import SourceRoot
from app.models.heaps import heap_photos
from app.models.tags import photo_tags
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
from app.config import settings
@@ -120,6 +121,19 @@ async def list_photos(
)
)
# Tag filter — comma-separated tag ids, AND semantics. A photo must
# have a row in photo_tags for EVERY listed tag. Implemented as one
# subquery per tag id since SQLite doesn't have an efficient
# "set-contains-all" operator.
if tag_ids:
tag_id_list = [t.strip() for t in tag_ids.split(',') if t.strip()]
for tid in tag_id_list:
filters.append(
Photo.id.in_(
select(photo_tags.c.photo_id).where(photo_tags.c.tag_id == tid)
)
)
# Apply all filters
if filters:
query = query.where(and_(*filters))
@@ -153,21 +167,89 @@ async def list_photos(
pages=(total + per_page - 1) // per_page
)
@router.get("/{photo_id}", response_model=PhotoResponse)
@router.get("/{photo_id}")
async def get_photo(
photo_id: str,
db: AsyncSession = Depends(get_db)
):
"""Get single photo with full EXIF and tags"""
"""Get single photo with full EXIF and its tags."""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
return PhotoResponse.from_orm(photo)
# Fetch tags via the join table so we don't need to declare a
# relationship on the Photo model side.
tag_result = await db.execute(
select(Tag)
.join(photo_tags, Tag.id == photo_tags.c.tag_id)
.where(photo_tags.c.photo_id == photo_id)
.order_by(Tag.name.asc())
)
tags = tag_result.scalars().all()
base = PhotoResponse.from_orm(photo).dict()
base["tags"] = [
{"id": t.id, "name": t.name, "color": t.color} for t in tags
]
return base
@router.post("/{photo_id}/tags", status_code=201)
async def add_photo_tags(
photo_id: str,
body: dict,
db: AsyncSession = Depends(get_db),
):
"""Add one or more tags to a photo. Body: { tag_ids: [str, ...] }.
Idempotent: re-adding existing members is a no-op."""
photo_result = await db.execute(select(Photo).where(Photo.id == photo_id))
if photo_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Photo not found")
tag_ids = body.get("tag_ids") or []
if not isinstance(tag_ids, list) or not tag_ids:
return {"status": "success", "added": 0}
existing = await db.execute(
select(photo_tags.c.tag_id).where(
photo_tags.c.photo_id == photo_id,
photo_tags.c.tag_id.in_(tag_ids),
)
)
existing_ids = {row[0] for row in existing.all()}
new_ids = [tid for tid in tag_ids if tid not in existing_ids]
if new_ids:
from sqlalchemy import insert
await db.execute(
insert(photo_tags),
[{"photo_id": photo_id, "tag_id": tid} for tid in new_ids],
)
await db.commit()
return {"status": "success", "added": len(new_ids)}
@router.delete("/{photo_id}/tags/{tag_id}", status_code=204)
async def remove_photo_tag(
photo_id: str,
tag_id: str,
db: AsyncSession = Depends(get_db),
):
"""Remove a tag from a photo. Removing a non-member is a no-op."""
from sqlalchemy import delete as sql_delete
await db.execute(
sql_delete(photo_tags).where(
photo_tags.c.photo_id == photo_id,
photo_tags.c.tag_id == tag_id,
)
)
await db.commit()
return None
@router.get("/{photo_id}/thumb/{size}")
async def get_thumbnail(

View File

@@ -1,27 +1,114 @@
"""
Tags API router
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from pydantic import BaseModel
from sqlalchemy import select, func, insert, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Tag
from app.models.tags import photo_tags
router = APIRouter()
# ── Schemas ───────────────────────────────────────────────────────────────
class TagCreate(BaseModel):
name: str
color: Optional[str] = None
class TagUpdate(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
# ── Endpoints ─────────────────────────────────────────────────────────────
@router.get("")
async def list_tags(db: AsyncSession = Depends(get_db)):
"""List all tags with usage counts"""
result = await db.execute(select(Tag))
tags = result.scalars().all()
return tags
"""List all tags with their photo counts."""
count_subq = (
select(
photo_tags.c.tag_id,
func.count(photo_tags.c.photo_id).label("photo_count"),
)
.group_by(photo_tags.c.tag_id)
.subquery()
)
stmt = (
select(Tag, count_subq.c.photo_count)
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
.order_by(Tag.name.asc())
)
result = await db.execute(stmt)
rows = result.all()
@router.post("")
async def create_tag(name: str, color: str = None, db: AsyncSession = Depends(get_db)):
"""Create a new tag"""
tag = Tag(name=name, color=color)
return [
{
"id": tag.id,
"name": tag.name,
"color": tag.color,
"photo_count": int(count or 0),
}
for tag, count in rows
]
@router.post("", status_code=201)
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
"""Create a new tag. Names are unique — re-creating an existing name
returns the existing row instead of erroring (idempotent for the
autocomplete UI flow)."""
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Tag name is required")
existing = await db.execute(select(Tag).where(Tag.name == name))
found = existing.scalar_one_or_none()
if found:
return {"id": found.id, "name": found.name, "color": found.color, "photo_count": 0}
tag = Tag(name=name, color=body.color)
db.add(tag)
await db.commit()
await db.refresh(tag)
return tag
return {"id": tag.id, "name": tag.name, "color": tag.color, "photo_count": 0}
@router.patch("/{tag_id}")
async def update_tag(
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db)
):
"""Rename or recolor a tag."""
result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = result.scalar_one_or_none()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
if body.name is not None:
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="Tag name is required")
tag.name = name
if body.color is not None:
tag.color = body.color or None
await db.commit()
await db.refresh(tag)
return {"id": tag.id, "name": tag.name, "color": tag.color}
@router.delete("/{tag_id}", status_code=204)
async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db)):
"""Delete a tag. Photo associations cascade-delete via the FK."""
result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = result.scalar_one_or_none()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
await db.delete(tag)
await db.commit()
return None