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__)
|
||||
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<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 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() {
|
||||
})}
|
||||
</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 */}
|
||||
<Group label="Sort">
|
||||
<select
|
||||
|
||||
@@ -18,8 +18,16 @@ import { usePhotoStore } from '../../store/photoStore'
|
||||
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
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'
|
||||
|
||||
interface PhotoTagSummary {
|
||||
id: string
|
||||
name: string
|
||||
color: string | null
|
||||
}
|
||||
|
||||
interface PhotoDetails {
|
||||
id: string
|
||||
filename: string
|
||||
@@ -34,6 +42,7 @@ interface PhotoDetails {
|
||||
user_notes: string | null
|
||||
color_label: string | null
|
||||
exif_json: string | null
|
||||
tags?: PhotoTagSummary[]
|
||||
}
|
||||
|
||||
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
||||
@@ -100,7 +109,7 @@ export function RightSidebar() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set(['basic', 'camera', 'location'])
|
||||
new Set(['basic', 'camera', 'location', 'tags'])
|
||||
)
|
||||
|
||||
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
|
||||
// but stay independent while the user is typing, so we don't fight focus or
|
||||
// clobber edits with stale refetches.
|
||||
@@ -520,6 +569,26 @@ export function RightSidebar() {
|
||||
<div className="text-xs text-text-muted">No GPS data</div>
|
||||
)}
|
||||
</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 }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -71,6 +71,12 @@ function parseUrl(): Partial<FilterState> {
|
||||
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)
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
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
|
||||
export interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
color: string | null
|
||||
photo_count: number
|
||||
}
|
||||
|
||||
export const tags = {
|
||||
list: async () => {
|
||||
list: async (): Promise<Tag[]> => {
|
||||
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<Tag> => {
|
||||
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<Tag> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
await api.delete(`/photos/${photoId}/tags/${tagId}`)
|
||||
},
|
||||
}
|
||||
|
||||
// Discard API
|
||||
|
||||
@@ -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<FilterStore>((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<string, string | number>
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user