diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py
index 6f73e41..7996841 100644
--- a/backend/app/routers/photos.py
+++ b/backend/app/routers/photos.py
@@ -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(
diff --git a/backend/app/routers/tags.py b/backend/app/routers/tags.py
index e7dc234..643a66d 100644
--- a/backend/app/routers/tags.py
+++ b/backend/app/routers/tags.py
@@ -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
\ No newline at end of file
+ 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
diff --git a/frontend/src/components/filter/ActiveFilterChips.tsx b/frontend/src/components/filter/ActiveFilterChips.tsx
index d5503ff..3920fb7 100644
--- a/frontend/src/components/filter/ActiveFilterChips.tsx
+++ b/frontend/src/components/filter/ActiveFilterChips.tsx
@@ -1,7 +1,7 @@
import { X } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
-import { sourceFolders, heaps as heapsApi } from '../../services/api'
+import { sourceFolders, heaps as heapsApi, tags as tagsApi } from '../../services/api'
export function ActiveFilterChips() {
const f = useFilterStore()
@@ -24,6 +24,12 @@ export function ActiveFilterChips() {
})
const heap = f.heapId ? heaps.find((h) => h.id === f.heapId) : null
+ const { data: allTags = [] } = useQuery({
+ queryKey: ['tags'],
+ queryFn: tagsApi.list,
+ enabled: f.tagIds.length > 0,
+ })
+
if (!hasActiveFilters(f)) return null
const chips: { key: string; label: string; onRemove: () => void }[] = []
@@ -91,6 +97,14 @@ export function ActiveFilterChips() {
onRemove: () => f.setHeapId(null),
})
}
+ for (const tagId of f.tagIds) {
+ const tag = allTags.find((t) => t.id === tagId)
+ chips.push({
+ key: `tag-${tagId}`,
+ label: `Tag: ${tag?.name ?? tagId}`,
+ onRemove: () => f.toggleTagId(tagId),
+ })
+ }
return (
diff --git a/frontend/src/components/filter/FilterBar.tsx b/frontend/src/components/filter/FilterBar.tsx
index 7f6e668..5cd011f 100644
--- a/frontend/src/components/filter/FilterBar.tsx
+++ b/frontend/src/components/filter/FilterBar.tsx
@@ -7,6 +7,7 @@ import {
type FlagFilter,
type SortField,
} from '../../store/filterStore'
+import { useTagsQuery } from '../../hooks/useTagsQuery'
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
{ value: 'photo', label: 'Photo' },
@@ -47,6 +48,9 @@ export function FilterBar() {
const flag = useFilterStore((s) => s.flag)
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
+ const tagIds = useFilterStore((s) => s.tagIds)
+ const toggleTagId = useFilterStore((s) => s.toggleTagId)
+ const { data: allTags = [] } = useTagsQuery()
const setDateFrom = useFilterStore((s) => s.setDateFrom)
const setDateTo = useFilterStore((s) => s.setDateTo)
@@ -170,6 +174,29 @@ export function FilterBar() {
})}
+ {/* Tags */}
+ {allTags.length > 0 && (
+
+ {allTags.map((tag) => {
+ const active = tagIds.includes(tag.id)
+ return (
+
+ )
+ })}
+
+ )}
+
{/* Sort */}