Feat: Add tag browser page at /tags with count-sorted tag list and library deep-link

- Extends GET /api/v1/tags with sort=count_desc and min_count query params
- New TagsComponent at /tags (public, no auth guard) shows all tags sorted by image count
- Clicking a tag navigates to /?tags=<name> for a pre-filtered library view
- LibraryComponent reads ?tags= query param on init to support deep-linking from tag browser
- Library header gains a "Browse tags" link to /tags for discoverability
- All 15 TDD tasks complete; ruff, ng lint, and ng build clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 18:40:06 +00:00
parent 6092a4454e
commit 355014f975
32 changed files with 908 additions and 38 deletions

View File

@@ -1,5 +1,4 @@
import uuid
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,15 +11,19 @@ class ImageRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_hash(self, hash_hex: str) -> Optional[Image]:
async def get_by_hash(self, hash_hex: str) -> Image | None:
result = await self._session.execute(
select(Image).where(Image.hash == hash_hex).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
select(Image)
.where(Image.hash == hash_hex)
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
)
return result.scalar_one_or_none()
async def get_by_id(self, image_id: uuid.UUID) -> Optional[Image]:
async def get_by_id(self, image_id: uuid.UUID) -> Image | None:
result = await self._session.execute(
select(Image).where(Image.id == image_id).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
select(Image)
.where(Image.id == image_id)
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
)
return result.scalar_one_or_none()
@@ -57,7 +60,7 @@ class ImageRepository:
limit: int = 50,
offset: int = 0,
) -> tuple[list[Image], int]:
from sqlalchemy import func, and_
from sqlalchemy import func
base_query = select(Image).options(
selectinload(Image.image_tags).selectinload(ImageTag.tag)

View File

@@ -1,7 +1,7 @@
import re
import uuid
from sqlalchemy import select, func
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Image, ImageTag, Tag
@@ -76,6 +76,8 @@ class TagRepository:
prefix: str | None = None,
limit: int = 100,
offset: int = 0,
sort: str = "name",
min_count: int = 0,
) -> tuple[list[dict], int]:
count_subq = (
select(func.count(ImageTag.image_id))
@@ -87,12 +89,16 @@ class TagRepository:
query = select(Tag, count_subq.label("image_count"))
if prefix:
query = query.where(Tag.name.like(f"{prefix}%"))
if min_count > 0:
query = query.having(count_subq >= min_count)
total_query = select(func.count()).select_from(query.subquery())
total_result = await self._session.execute(total_query)
total = total_result.scalar_one()
paginated = query.order_by(Tag.name).limit(limit).offset(offset)
order = [count_subq.desc(), Tag.name.asc()] if sort == "count_desc" else [Tag.name.asc()]
paginated = query.order_by(*order).limit(limit).offset(offset)
rows = await self._session.execute(paginated)
items = [