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:
@@ -16,9 +16,10 @@ import logging
|
|||||||
logger = logging.getLogger(__name__)
|
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
|
||||||
from app.models.folders import SourceRoot
|
from app.models.folders import SourceRoot
|
||||||
from app.models.heaps import heap_photos
|
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.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
|
||||||
from app.config import settings
|
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
|
# Apply all filters
|
||||||
if filters:
|
if filters:
|
||||||
query = query.where(and_(*filters))
|
query = query.where(and_(*filters))
|
||||||
@@ -153,21 +167,89 @@ async def list_photos(
|
|||||||
pages=(total + per_page - 1) // per_page
|
pages=(total + per_page - 1) // per_page
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/{photo_id}", response_model=PhotoResponse)
|
@router.get("/{photo_id}")
|
||||||
async def get_photo(
|
async def get_photo(
|
||||||
photo_id: str,
|
photo_id: str,
|
||||||
db: AsyncSession = Depends(get_db)
|
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(
|
result = await db.execute(
|
||||||
select(Photo).where(Photo.id == photo_id)
|
select(Photo).where(Photo.id == photo_id)
|
||||||
)
|
)
|
||||||
photo = result.scalar_one_or_none()
|
photo = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not photo:
|
if not photo:
|
||||||
raise HTTPException(status_code=404, detail="Photo not found")
|
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}")
|
@router.get("/{photo_id}/thumb/{size}")
|
||||||
async def get_thumbnail(
|
async def get_thumbnail(
|
||||||
|
|||||||
@@ -1,27 +1,114 @@
|
|||||||
"""
|
"""
|
||||||
Tags API router
|
Tags API router
|
||||||
"""
|
"""
|
||||||
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Tag
|
from app.models import Tag
|
||||||
|
from app.models.tags import photo_tags
|
||||||
|
|
||||||
router = APIRouter()
|
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("")
|
@router.get("")
|
||||||
async def list_tags(db: AsyncSession = Depends(get_db)):
|
async def list_tags(db: AsyncSession = Depends(get_db)):
|
||||||
"""List all tags with usage counts"""
|
"""List all tags with their photo counts."""
|
||||||
result = await db.execute(select(Tag))
|
count_subq = (
|
||||||
tags = result.scalars().all()
|
select(
|
||||||
return tags
|
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("")
|
return [
|
||||||
async def create_tag(name: str, color: str = None, db: AsyncSession = Depends(get_db)):
|
{
|
||||||
"""Create a new tag"""
|
"id": tag.id,
|
||||||
tag = Tag(name=name, color=color)
|
"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)
|
db.add(tag)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(tag)
|
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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
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() {
|
export function ActiveFilterChips() {
|
||||||
const f = useFilterStore()
|
const f = useFilterStore()
|
||||||
@@ -24,6 +24,12 @@ export function ActiveFilterChips() {
|
|||||||
})
|
})
|
||||||
const heap = f.heapId ? heaps.find((h) => h.id === f.heapId) : null
|
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
|
if (!hasActiveFilters(f)) return null
|
||||||
|
|
||||||
const chips: { key: string; label: string; onRemove: () => void }[] = []
|
const chips: { key: string; label: string; onRemove: () => void }[] = []
|
||||||
@@ -91,6 +97,14 @@ export function ActiveFilterChips() {
|
|||||||
onRemove: () => f.setHeapId(null),
|
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 (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-surface-2 px-4 py-2 text-xs">
|
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-surface-2 px-4 py-2 text-xs">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type FlagFilter,
|
type FlagFilter,
|
||||||
type SortField,
|
type SortField,
|
||||||
} from '../../store/filterStore'
|
} from '../../store/filterStore'
|
||||||
|
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||||
|
|
||||||
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
||||||
{ value: 'photo', label: 'Photo' },
|
{ value: 'photo', label: 'Photo' },
|
||||||
@@ -47,6 +48,9 @@ export function FilterBar() {
|
|||||||
const flag = useFilterStore((s) => s.flag)
|
const flag = useFilterStore((s) => s.flag)
|
||||||
const sortBy = useFilterStore((s) => s.sortBy)
|
const sortBy = useFilterStore((s) => s.sortBy)
|
||||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
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 setDateFrom = useFilterStore((s) => s.setDateFrom)
|
||||||
const setDateTo = useFilterStore((s) => s.setDateTo)
|
const setDateTo = useFilterStore((s) => s.setDateTo)
|
||||||
@@ -170,6 +174,29 @@ export function FilterBar() {
|
|||||||
})}
|
})}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{allTags.length > 0 && (
|
||||||
|
<Group label="Tags">
|
||||||
|
{allTags.map((tag) => {
|
||||||
|
const active = tagIds.includes(tag.id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => toggleTagId(tag.id)}
|
||||||
|
className={clsx(
|
||||||
|
'rounded px-2 py-1 transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Sort */}
|
{/* Sort */}
|
||||||
<Group label="Sort">
|
<Group label="Sort">
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -18,8 +18,16 @@ import { usePhotoStore } from '../../store/photoStore'
|
|||||||
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
|
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||||
|
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||||
|
import { tags as tagsApi, type Tag } from '../../services/api'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
|
|
||||||
|
interface PhotoTagSummary {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface PhotoDetails {
|
interface PhotoDetails {
|
||||||
id: string
|
id: string
|
||||||
filename: string
|
filename: string
|
||||||
@@ -34,6 +42,7 @@ interface PhotoDetails {
|
|||||||
user_notes: string | null
|
user_notes: string | null
|
||||||
color_label: string | null
|
color_label: string | null
|
||||||
exif_json: string | null
|
exif_json: string | null
|
||||||
|
tags?: PhotoTagSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
||||||
@@ -100,7 +109,7 @@ export function RightSidebar() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||||
new Set(['basic', 'camera', 'location'])
|
new Set(['basic', 'camera', 'location', 'tags'])
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggleSection = (section: string) => {
|
const toggleSection = (section: string) => {
|
||||||
@@ -174,6 +183,46 @@ export function RightSidebar() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Tags state + mutations ──────────────────────────────────────────
|
||||||
|
const { data: allTags = [] } = useTagsQuery()
|
||||||
|
const [tagInput, setTagInput] = useState('')
|
||||||
|
|
||||||
|
const invalidateTagsAndPhoto = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTagMutation = useMutation({
|
||||||
|
mutationFn: async (name: string) => {
|
||||||
|
// Idempotent create — backend returns existing row if name matches.
|
||||||
|
const created = await tagsApi.create(name)
|
||||||
|
if (activePhotoId) {
|
||||||
|
await tagsApi.addToPhoto(activePhotoId, [created.id])
|
||||||
|
}
|
||||||
|
return created
|
||||||
|
},
|
||||||
|
onSuccess: () => invalidateTagsAndPhoto(),
|
||||||
|
onError: (e: any) =>
|
||||||
|
toast.error('Add tag failed', e?.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const attachExistingTagMutation = useMutation({
|
||||||
|
mutationFn: (tagId: string) =>
|
||||||
|
tagsApi.addToPhoto(activePhotoId!, [tagId]),
|
||||||
|
onSuccess: () => invalidateTagsAndPhoto(),
|
||||||
|
onError: (e: any) =>
|
||||||
|
toast.error('Add tag failed', e?.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeTagMutation = useMutation({
|
||||||
|
mutationFn: (tagId: string) =>
|
||||||
|
tagsApi.removeFromPhoto(activePhotoId!, tagId),
|
||||||
|
onSuccess: () => invalidateTagsAndPhoto(),
|
||||||
|
onError: (e: any) =>
|
||||||
|
toast.error('Remove tag failed', e?.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
// Local drafts for the editable text fields. These mirror the server value
|
// 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
|
// but stay independent while the user is typing, so we don't fight focus or
|
||||||
// clobber edits with stale refetches.
|
// clobber edits with stale refetches.
|
||||||
@@ -520,6 +569,26 @@ export function RightSidebar() {
|
|||||||
<div className="text-xs text-text-muted">No GPS data</div>
|
<div className="text-xs text-text-muted">No GPS data</div>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<Section
|
||||||
|
title="Tags"
|
||||||
|
expanded={expandedSections.has('tags')}
|
||||||
|
onToggle={() => toggleSection('tags')}
|
||||||
|
>
|
||||||
|
<TagsEditor
|
||||||
|
photoTags={photo.tags ?? []}
|
||||||
|
allTags={allTags}
|
||||||
|
tagInput={tagInput}
|
||||||
|
onTagInputChange={setTagInput}
|
||||||
|
onAttachExisting={(id) => attachExistingTagMutation.mutate(id)}
|
||||||
|
onCreateAndAttach={(name) => {
|
||||||
|
addTagMutation.mutate(name)
|
||||||
|
setTagInput('')
|
||||||
|
}}
|
||||||
|
onRemove={(id) => removeTagMutation.mutate(id)}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -574,6 +643,129 @@ function Section({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TagsEditorProps {
|
||||||
|
photoTags: PhotoTagSummary[]
|
||||||
|
allTags: Tag[]
|
||||||
|
tagInput: string
|
||||||
|
onTagInputChange: (value: string) => void
|
||||||
|
onAttachExisting: (id: string) => void
|
||||||
|
onCreateAndAttach: (name: string) => void
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagsEditor({
|
||||||
|
photoTags,
|
||||||
|
allTags,
|
||||||
|
tagInput,
|
||||||
|
onTagInputChange,
|
||||||
|
onAttachExisting,
|
||||||
|
onCreateAndAttach,
|
||||||
|
onRemove,
|
||||||
|
}: TagsEditorProps) {
|
||||||
|
const trimmed = tagInput.trim()
|
||||||
|
const lowerTrimmed = trimmed.toLowerCase()
|
||||||
|
const photoTagIds = new Set(photoTags.map((t) => t.id))
|
||||||
|
|
||||||
|
// Suggestions: tags whose name contains the input AND that aren't
|
||||||
|
// already on the photo. Capped at 6 to keep the dropdown short.
|
||||||
|
const suggestions = trimmed
|
||||||
|
? allTags
|
||||||
|
.filter(
|
||||||
|
(t) =>
|
||||||
|
!photoTagIds.has(t.id) &&
|
||||||
|
t.name.toLowerCase().includes(lowerTrimmed)
|
||||||
|
)
|
||||||
|
.slice(0, 6)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const exactMatch = trimmed
|
||||||
|
? allTags.find((t) => t.name.toLowerCase() === lowerTrimmed)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!trimmed) return
|
||||||
|
if (exactMatch) {
|
||||||
|
if (!photoTagIds.has(exactMatch.id)) {
|
||||||
|
onAttachExisting(exactMatch.id)
|
||||||
|
}
|
||||||
|
onTagInputChange('')
|
||||||
|
} else {
|
||||||
|
onCreateAndAttach(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Existing tag chips */}
|
||||||
|
{photoTags.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{photoTags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
|
||||||
|
style={tag.color ? { backgroundColor: `${tag.color}33`, color: tag.color } : undefined}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(tag.id)}
|
||||||
|
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100"
|
||||||
|
title="Remove tag"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-text-faint">No tags</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add tag input + suggestions */}
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<div className="mt-1 rounded border border-border bg-bg shadow-md">
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => {
|
||||||
|
onAttachExisting(s.id)
|
||||||
|
onTagInputChange('')
|
||||||
|
}}
|
||||||
|
className="block w-full px-2 py-1 text-left text-xs text-text hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{trimmed && !exactMatch && (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="mt-1 w-full rounded border border-dashed border-primary/50 px-2 py-1 text-left text-xs text-primary hover:bg-primary/10"
|
||||||
|
>
|
||||||
|
+ Create "{trimmed}"
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Field({ label, value }: { label: string; value: string }) {
|
function Field({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ function parseUrl(): Partial<FilterState> {
|
|||||||
const folderId = sp.get('folder_id')
|
const folderId = sp.get('folder_id')
|
||||||
if (folderId) out.folderId = folderId
|
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')
|
const sortBy = sp.get('sort')
|
||||||
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
|
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
|
||||||
out.sortBy = 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.flag !== 'any') sp.set('flag', f.flag)
|
||||||
if (f.heapId) sp.set('heap_id', f.heapId)
|
if (f.heapId) sp.set('heap_id', f.heapId)
|
||||||
if (f.folderId) sp.set('folder_id', f.folderId)
|
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.sortBy !== 'taken_at') sp.set('sort', f.sortBy)
|
||||||
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)
|
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function usePhotosQuery() {
|
|||||||
const flag = useFilterStore((s) => s.flag)
|
const flag = useFilterStore((s) => s.flag)
|
||||||
const heapId = useFilterStore((s) => s.heapId)
|
const heapId = useFilterStore((s) => s.heapId)
|
||||||
const folderId = useFilterStore((s) => s.folderId)
|
const folderId = useFilterStore((s) => s.folderId)
|
||||||
|
const tagIds = useFilterStore((s) => s.tagIds)
|
||||||
const sortBy = useFilterStore((s) => s.sortBy)
|
const sortBy = useFilterStore((s) => s.sortBy)
|
||||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
const sortOrder = useFilterStore((s) => s.sortOrder)
|
||||||
|
|
||||||
@@ -35,10 +36,11 @@ export function usePhotosQuery() {
|
|||||||
flag,
|
flag,
|
||||||
heapId,
|
heapId,
|
||||||
folderId,
|
folderId,
|
||||||
|
tagIds,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
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({
|
return useQuery({
|
||||||
|
|||||||
12
frontend/src/hooks/useTagsQuery.ts
Normal file
12
frontend/src/hooks/useTagsQuery.ts
Normal file
@@ -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<Tag[]>({
|
||||||
|
queryKey: TAGS_QUERY_KEY,
|
||||||
|
queryFn: tagsApi.list,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -191,32 +191,43 @@ export const heaps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tags API
|
// Tags API
|
||||||
|
export interface Tag {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: string | null
|
||||||
|
photo_count: number
|
||||||
|
}
|
||||||
|
|
||||||
export const tags = {
|
export const tags = {
|
||||||
list: async () => {
|
list: async (): Promise<Tag[]> => {
|
||||||
const response = await api.get('/tags')
|
const response = await api.get('/tags')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (name: string, color?: string) => {
|
create: async (name: string, color?: string): Promise<Tag> => {
|
||||||
const response = await api.post('/tags', {
|
const response = await api.post('/tags', { name, color })
|
||||||
name,
|
|
||||||
color,
|
|
||||||
})
|
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async (tagId: string, data: {
|
update: async (tagId: string, data: { name?: string; color?: string }): Promise<Tag> => {
|
||||||
name?: string
|
|
||||||
color?: string
|
|
||||||
}) => {
|
|
||||||
const response = await api.patch(`/tags/${tagId}`, data)
|
const response = await api.patch(`/tags/${tagId}`, data)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (tagId: string) => {
|
delete: async (tagId: string): Promise<void> => {
|
||||||
const response = await api.delete(`/tags/${tagId}`)
|
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
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Remove a tag from a photo. */
|
||||||
|
removeFromPhoto: async (photoId: string, tagId: string): Promise<void> => {
|
||||||
|
await api.delete(`/photos/${photoId}/tags/${tagId}`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discard API
|
// Discard API
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface FilterState {
|
|||||||
heapId: string | null
|
heapId: string | null
|
||||||
/** When set, restrict to photos in this folder. */
|
/** When set, restrict to photos in this folder. */
|
||||||
folderId: string | null
|
folderId: string | null
|
||||||
|
/** Restrict to photos that have ALL of these tag ids (AND semantics). */
|
||||||
|
tagIds: string[]
|
||||||
sortBy: SortField
|
sortBy: SortField
|
||||||
sortOrder: SortOrder
|
sortOrder: SortOrder
|
||||||
}
|
}
|
||||||
@@ -40,6 +42,8 @@ interface FilterStore extends FilterState {
|
|||||||
setFlag: (flag: FlagFilter) => void
|
setFlag: (flag: FlagFilter) => void
|
||||||
setHeapId: (id: string | null) => void
|
setHeapId: (id: string | null) => void
|
||||||
setFolderId: (id: string | null) => void
|
setFolderId: (id: string | null) => void
|
||||||
|
setTagIds: (ids: string[]) => void
|
||||||
|
toggleTagId: (id: string) => void
|
||||||
setSortBy: (field: SortField) => void
|
setSortBy: (field: SortField) => void
|
||||||
setSortOrder: (order: SortOrder) => void
|
setSortOrder: (order: SortOrder) => void
|
||||||
toggleSortOrder: () => void
|
toggleSortOrder: () => void
|
||||||
@@ -61,6 +65,7 @@ export const INITIAL_FILTERS: FilterState = {
|
|||||||
flag: 'any',
|
flag: 'any',
|
||||||
heapId: null,
|
heapId: null,
|
||||||
folderId: null,
|
folderId: null,
|
||||||
|
tagIds: [],
|
||||||
sortBy: 'taken_at',
|
sortBy: 'taken_at',
|
||||||
sortOrder: 'desc',
|
sortOrder: 'desc',
|
||||||
}
|
}
|
||||||
@@ -83,6 +88,13 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
|||||||
setFlag: (flag) => set({ flag }),
|
setFlag: (flag) => set({ flag }),
|
||||||
setHeapId: (heapId) => set({ heapId }),
|
setHeapId: (heapId) => set({ heapId }),
|
||||||
setFolderId: (folderId) => set({ folderId }),
|
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 }),
|
setSortBy: (sortBy) => set({ sortBy }),
|
||||||
setSortOrder: (sortOrder) => set({ sortOrder }),
|
setSortOrder: (sortOrder) => set({ sortOrder }),
|
||||||
toggleSortOrder: () =>
|
toggleSortOrder: () =>
|
||||||
@@ -108,6 +120,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
|
|||||||
if (f.flag === 'discarded') params.is_discarded = 'true'
|
if (f.flag === 'discarded') params.is_discarded = 'true'
|
||||||
if (f.heapId) params.heap_id = f.heapId
|
if (f.heapId) params.heap_id = f.heapId
|
||||||
if (f.folderId) params.folder_id = f.folderId
|
if (f.folderId) params.folder_id = f.folderId
|
||||||
|
if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',')
|
||||||
params.sort = f.sortBy
|
params.sort = f.sortBy
|
||||||
params.order = f.sortOrder
|
params.order = f.sortOrder
|
||||||
return params
|
return params
|
||||||
@@ -124,6 +137,7 @@ export function hasActiveFilters(f: FilterState): boolean {
|
|||||||
f.colorLabel !== null ||
|
f.colorLabel !== null ||
|
||||||
f.flag !== 'any' ||
|
f.flag !== 'any' ||
|
||||||
f.heapId !== null ||
|
f.heapId !== null ||
|
||||||
f.folderId !== null
|
f.folderId !== null ||
|
||||||
|
f.tagIds.length > 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user