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 */} onTagInputChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSubmit() + } else if (e.key === 'Escape') { + onTagInputChange('') + } + }} + placeholder="Add tag…" + className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none" + /> + {suggestions.length > 0 && ( +
+ {suggestions.map((s) => ( + + ))} +
+ )} + {trimmed && !exactMatch && ( + + )} +
+ + ) +} + function Field({ label, value }: { label: string; value: string }) { return (
diff --git a/frontend/src/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts index 54fb202..fc5f866 100644 --- a/frontend/src/hooks/useFilterUrlSync.ts +++ b/frontend/src/hooks/useFilterUrlSync.ts @@ -71,6 +71,12 @@ function parseUrl(): Partial { const folderId = sp.get('folder_id') if (folderId) out.folderId = folderId + const tagIds = sp.get('tag_ids') + if (tagIds) { + const ids = tagIds.split(',').map((t) => t.trim()).filter(Boolean) + if (ids.length > 0) out.tagIds = ids + } + const sortBy = sp.get('sort') if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) { out.sortBy = sortBy as SortField @@ -95,6 +101,7 @@ function writeUrl(f: FilterState) { if (f.flag !== 'any') sp.set('flag', f.flag) if (f.heapId) sp.set('heap_id', f.heapId) if (f.folderId) sp.set('folder_id', f.folderId) + if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(',')) if (f.sortBy !== 'taken_at') sp.set('sort', f.sortBy) if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder) diff --git a/frontend/src/hooks/usePhotosQuery.ts b/frontend/src/hooks/usePhotosQuery.ts index 749d1c0..7f61579 100644 --- a/frontend/src/hooks/usePhotosQuery.ts +++ b/frontend/src/hooks/usePhotosQuery.ts @@ -20,6 +20,7 @@ export function usePhotosQuery() { const flag = useFilterStore((s) => s.flag) const heapId = useFilterStore((s) => s.heapId) const folderId = useFilterStore((s) => s.folderId) + const tagIds = useFilterStore((s) => s.tagIds) const sortBy = useFilterStore((s) => s.sortBy) const sortOrder = useFilterStore((s) => s.sortOrder) @@ -35,10 +36,11 @@ export function usePhotosQuery() { flag, heapId, folderId, + tagIds, sortBy, sortOrder, }), - [q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, sortBy, sortOrder] + [q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, sortBy, sortOrder] ) return useQuery({ diff --git a/frontend/src/hooks/useTagsQuery.ts b/frontend/src/hooks/useTagsQuery.ts new file mode 100644 index 0000000..78fbfbb --- /dev/null +++ b/frontend/src/hooks/useTagsQuery.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' +import { tags as tagsApi, type Tag } from '../services/api' + +export const TAGS_QUERY_KEY = ['tags'] as const + +export function useTagsQuery() { + return useQuery({ + queryKey: TAGS_QUERY_KEY, + queryFn: tagsApi.list, + staleTime: 30_000, + }) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 21ee20d..e6fd91a 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -191,32 +191,43 @@ export const heaps = { } // Tags API +export interface Tag { + id: string + name: string + color: string | null + photo_count: number +} + export const tags = { - list: async () => { + list: async (): Promise => { const response = await api.get('/tags') return response.data }, - create: async (name: string, color?: string) => { - const response = await api.post('/tags', { - name, - color, - }) + create: async (name: string, color?: string): Promise => { + const response = await api.post('/tags', { name, color }) return response.data }, - update: async (tagId: string, data: { - name?: string - color?: string - }) => { + update: async (tagId: string, data: { name?: string; color?: string }): Promise => { const response = await api.patch(`/tags/${tagId}`, data) return response.data }, - delete: async (tagId: string) => { - const response = await api.delete(`/tags/${tagId}`) + delete: async (tagId: string): Promise => { + await api.delete(`/tags/${tagId}`) + }, + + /** Add one or more tags to a photo. */ + addToPhoto: async (photoId: string, tagIds: string[]) => { + const response = await api.post(`/photos/${photoId}/tags`, { tag_ids: tagIds }) return response.data }, + + /** Remove a tag from a photo. */ + removeFromPhoto: async (photoId: string, tagId: string): Promise => { + await api.delete(`/photos/${photoId}/tags/${tagId}`) + }, } // Discard API diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index 3931304..953d35f 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -24,6 +24,8 @@ export interface FilterState { heapId: string | null /** When set, restrict to photos in this folder. */ folderId: string | null + /** Restrict to photos that have ALL of these tag ids (AND semantics). */ + tagIds: string[] sortBy: SortField sortOrder: SortOrder } @@ -40,6 +42,8 @@ interface FilterStore extends FilterState { setFlag: (flag: FlagFilter) => void setHeapId: (id: string | null) => void setFolderId: (id: string | null) => void + setTagIds: (ids: string[]) => void + toggleTagId: (id: string) => void setSortBy: (field: SortField) => void setSortOrder: (order: SortOrder) => void toggleSortOrder: () => void @@ -61,6 +65,7 @@ export const INITIAL_FILTERS: FilterState = { flag: 'any', heapId: null, folderId: null, + tagIds: [], sortBy: 'taken_at', sortOrder: 'desc', } @@ -83,6 +88,13 @@ export const useFilterStore = create((set) => ({ setFlag: (flag) => set({ flag }), setHeapId: (heapId) => set({ heapId }), setFolderId: (folderId) => set({ folderId }), + setTagIds: (tagIds) => set({ tagIds }), + toggleTagId: (id) => + set((s) => ({ + tagIds: s.tagIds.includes(id) + ? s.tagIds.filter((t) => t !== id) + : [...s.tagIds, id], + })), setSortBy: (sortBy) => set({ sortBy }), setSortOrder: (sortOrder) => set({ sortOrder }), toggleSortOrder: () => @@ -108,6 +120,7 @@ export function filtersToParams(f: FilterState): Record if (f.flag === 'discarded') params.is_discarded = 'true' if (f.heapId) params.heap_id = f.heapId if (f.folderId) params.folder_id = f.folderId + if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',') params.sort = f.sortBy params.order = f.sortOrder return params @@ -124,6 +137,7 @@ export function hasActiveFilters(f: FilterState): boolean { f.colorLabel !== null || f.flag !== 'any' || f.heapId !== null || - f.folderId !== null + f.folderId !== null || + f.tagIds.length > 0 ) }