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 +1 @@
{"feature_directory":"specs/006-header-nav-signout"} {"feature_directory":"specs/007-tag-browser"}

View File

@@ -1,5 +1,5 @@
<!-- SPECKIT START --> <!-- SPECKIT START -->
For additional context about technologies to be used, project structure, For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan at shell commands, and other important information, read the current plan at
`specs/005-ui-polish/plan.md`. `specs/007-tag-browser/plan.md`.
<!-- SPECKIT END --> <!-- SPECKIT END -->

View File

@@ -1,4 +1,4 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings from app.config import get_settings

View File

@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import UTC, datetime
from sqlalchemy import String, Integer, BigInteger, DateTime, ForeignKey, UniqueConstraint, Index from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -9,7 +9,7 @@ from app.database import Base
def _utcnow() -> datetime: def _utcnow() -> datetime:
return datetime.now(timezone.utc) return datetime.now(UTC)
class Image(Base): class Image(Base):
@@ -24,9 +24,13 @@ class Image(Base):
height: Mapped[int] = mapped_column(Integer, nullable=False) height: Mapped[int] = mapped_column(Integer, nullable=False)
storage_key: Mapped[str] = mapped_column(String(64), nullable=False) storage_key: Mapped[str] = mapped_column(String(64), nullable=False)
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None) thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False) created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, nullable=False
)
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="image", cascade="all, delete-orphan") image_tags: Mapped[list["ImageTag"]] = relationship(
back_populates="image", cascade="all, delete-orphan"
)
@property @property
def tags(self) -> list[str]: def tags(self) -> list[str]:
@@ -38,7 +42,9 @@ class Tag(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False) created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, nullable=False
)
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="tag") image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="tag")

View File

@@ -1,5 +1,4 @@
import uuid import uuid
from typing import Optional
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,15 +11,19 @@ class ImageRepository:
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
self._session = session 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( 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() 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( 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() return result.scalar_one_or_none()
@@ -57,7 +60,7 @@ class ImageRepository:
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
) -> tuple[list[Image], int]: ) -> tuple[list[Image], int]:
from sqlalchemy import func, and_ from sqlalchemy import func
base_query = select(Image).options( base_query = select(Image).options(
selectinload(Image.image_tags).selectinload(ImageTag.tag) selectinload(Image.image_tags).selectinload(ImageTag.tag)

View File

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

View File

@@ -12,9 +12,13 @@ async def list_tags(
q: str | None = None, q: str | None = None,
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
sort: str = "name",
min_count: int = 0,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
limit = min(limit, 200) limit = min(limit, 500)
tag_repo = TagRepository(db) tag_repo = TagRepository(db)
items, total = await tag_repo.list_tags(prefix=q, limit=limit, offset=offset) items, total = await tag_repo.list_tags(
prefix=q, limit=limit, offset=offset, sort=sort, min_count=min_count
)
return {"items": items, "total": total, "limit": limit, "offset": offset} return {"items": items, "total": total, "limit": limit, "offset": offset}

View File

@@ -1,19 +1,19 @@
import os import os
import pytest
import pytest_asyncio import pytest_asyncio
from httpx import AsyncClient, ASGITransport from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
# Provide required settings for the test environment before any app imports resolve them # Provide required settings for the test environment before any app imports resolve them
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-testing-only") os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-testing-only")
os.environ.setdefault("OWNER_USERNAME", "testowner") os.environ.setdefault("OWNER_USERNAME", "testowner")
os.environ.setdefault("OWNER_PASSWORD", "testpassword") os.environ.setdefault("OWNER_PASSWORD", "testpassword")
from app.main import app from app.auth.jwt_provider import JWTAuthProvider
from app.config import get_settings from app.config import get_settings
from app.database import Base from app.database import Base
from app.dependencies import get_db, get_storage, get_auth from app.dependencies import get_auth, get_db, get_storage
from app.auth.jwt_provider import JWTAuthProvider from app.main import app
# Bust the LRU cache so get_settings() picks up the env vars set above # Bust the LRU cache so get_settings() picks up the env vars set above
get_settings.cache_clear() get_settings.cache_clear()
@@ -48,8 +48,8 @@ async def db_session(engine):
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def client(db_session): async def client(db_session):
from app.storage.s3_backend import S3StorageBackend
from app.auth.noop import NoOpAuthProvider from app.auth.noop import NoOpAuthProvider
from app.storage.s3_backend import S3StorageBackend
storage = S3StorageBackend() storage = S3StorageBackend()
auth = NoOpAuthProvider() auth = NoOpAuthProvider()

View File

@@ -44,7 +44,6 @@ async def test_delete_removes_storage_object(client):
) )
assert upload.status_code in (200, 201) assert upload.status_code in (200, 201)
image_id = upload.json()["id"] image_id = upload.json()["id"]
storage_key = upload.json()["hash"]
delete_resp = await client.delete(f"/api/v1/images/{image_id}") delete_resp = await client.delete(f"/api/v1/images/{image_id}")
assert delete_resp.status_code == 204 assert delete_resp.status_code == 204

View File

@@ -3,7 +3,6 @@ US3 regression tests: all read endpoints must remain accessible without a token
even after require_auth is applied to write endpoints. even after require_auth is applied to write endpoints.
""" """
import io import io
import uuid
import pytest import pytest

View File

@@ -3,6 +3,7 @@ T041 — GET /api/v1/images?tags=cat,funny → only images with both tags
T042 — same query excludes images with only one matching tag T042 — same query excludes images with only one matching tag
""" """
import io import io
import pytest import pytest

View File

@@ -5,13 +5,17 @@ T057 — PATCH replaces tags, old tags unlinked, new tags upserted
T058 — PATCH with invalid tag → 422 invalid_tag T058 — PATCH with invalid tag → 422 invalid_tag
T073 — GET /api/v1/tags returns all tags alphabetically with correct image_count T073 — GET /api/v1/tags returns all tags alphabetically with correct image_count
T074 — GET /api/v1/tags?q=ca returns only tags prefixed "ca" T074 — GET /api/v1/tags?q=ca returns only tags prefixed "ca"
T001 — GET /api/v1/tags?sort=count_desc returns tags ordered highest-count-first
T002 — GET /api/v1/tags?min_count=N excludes tags with image_count < N
""" """
import io import io
import pytest import pytest
def _minimal_png() -> bytes: def _minimal_png() -> bytes:
import struct, zlib import struct
import zlib
def chunk(name: bytes, data: bytes) -> bytes: def chunk(name: bytes, data: bytes) -> bytes:
c = name + data c = name + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF) return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
@@ -130,3 +134,64 @@ async def test_list_tags_prefix_filter(client):
for item in body["items"]: for item in body["items"]:
assert item["name"].startswith("cat") assert item["name"].startswith("cat")
assert not any(item["name"] == "dog" for item in body["items"]) assert not any(item["name"] == "dog" for item in body["items"])
def _unique_png(seed: int) -> bytes:
"""Generate a 1x1 PNG with a seed-determined pixel so each seed produces a distinct hash."""
import struct
import zlib
def chunk(name: bytes, data: bytes) -> bytes:
c = name + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
r, g, b = (seed * 37) % 256, (seed * 53) % 256, (seed * 71) % 256
idat_data = zlib.compress(bytes([0, r, g, b]))
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", ihdr)
+ chunk(b"IDAT", idat_data)
+ chunk(b"IEND", b"")
)
@pytest.mark.asyncio
async def test_list_tags_sort_count_desc(client):
# popular-sort-tag appears on 2 images, rare-sort-tag on 1 — verify count_desc ordering
for seed in (100, 101):
await client.post(
"/api/v1/images",
files={"file": (f"sort-{seed}.png", io.BytesIO(_unique_png(seed)), "image/png")},
data={"tags": "popular-sort-tag,rare-sort-tag" if seed == 100 else "popular-sort-tag"},
)
response = await client.get("/api/v1/tags?sort=count_desc")
assert response.status_code == 200
items = response.json()["items"]
sort_items = [i for i in items if i["name"] in ("popular-sort-tag", "rare-sort-tag")]
assert len(sort_items) == 2
# popular-sort-tag (count=2) must come before rare-sort-tag (count=1)
names = [i["name"] for i in sort_items]
assert names.index("popular-sort-tag") < names.index("rare-sort-tag")
# Counts must be non-increasing
counts = [i["image_count"] for i in items]
assert counts == sorted(counts, reverse=True)
@pytest.mark.asyncio
async def test_list_tags_min_count_excludes_below_threshold(client):
# common-min-tag appears on 2 images, uncommon-min-tag on 1
for seed in (200, 201):
await client.post(
"/api/v1/images",
files={"file": (f"min-{seed}.png", io.BytesIO(_unique_png(seed)), "image/png")},
data={"tags": "common-min-tag,uncommon-min-tag" if seed == 200 else "common-min-tag"},
)
# min_count=2 should exclude uncommon-min-tag (count=1) but keep common-min-tag (count=2)
response = await client.get("/api/v1/tags?min_count=2")
assert response.status_code == 200
items = response.json()["items"]
names = [i["name"] for i in items]
assert "common-min-tag" in names
assert "uncommon-min-tag" not in names
# All returned tags must have image_count >= 2
for item in items:
assert item["image_count"] >= 2

View File

@@ -79,6 +79,7 @@ async def test_upload_invalid_mime_type_returns_422(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upload_oversized_file_returns_422(client): async def test_upload_oversized_file_returns_422(client):
import os import os
from app.config import get_settings from app.config import get_settings
os.environ["MAX_UPLOAD_BYTES"] = "10" os.environ["MAX_UPLOAD_BYTES"] = "10"

View File

@@ -1,5 +1,3 @@
import os
import pytest
_BASE_ENV = { _BASE_ENV = {
@@ -26,6 +24,7 @@ def test_settings_load_from_env(monkeypatch):
# Import inside test to pick up monkeypatched env # Import inside test to pick up monkeypatched env
import importlib import importlib
import app.config as config_module import app.config as config_module
importlib.reload(config_module) importlib.reload(config_module)
@@ -42,6 +41,7 @@ def test_settings_max_upload_bytes_override(monkeypatch):
_apply_env(monkeypatch, {"MAX_UPLOAD_BYTES": "10485760"}) _apply_env(monkeypatch, {"MAX_UPLOAD_BYTES": "10485760"})
import importlib import importlib
import app.config as config_module import app.config as config_module
importlib.reload(config_module) importlib.reload(config_module)
@@ -53,6 +53,7 @@ def test_settings_jwt_expiry_override(monkeypatch):
_apply_env(monkeypatch, {"JWT_EXPIRY_SECONDS": "3600"}) _apply_env(monkeypatch, {"JWT_EXPIRY_SECONDS": "3600"})
import importlib import importlib
import app.config as config_module import app.config as config_module
importlib.reload(config_module) importlib.reload(config_module)

View File

@@ -1,4 +1,5 @@
import hashlib import hashlib
from app.utils import compute_sha256 from app.utils import compute_sha256

View File

@@ -1,6 +1,5 @@
import time
import pytest
import jwt as pyjwt import jwt as pyjwt
import pytest
from fastapi import HTTPException from fastapi import HTTPException
from app.auth.jwt_provider import JWTAuthProvider from app.auth.jwt_provider import JWTAuthProvider

View File

@@ -3,6 +3,7 @@ T037 — tag normalisation: uppercase → lowercase, whitespace stripped
T038 — tag validation: rejects names > 64 chars, invalid chars T038 — tag validation: rejects names > 64 chars, invalid chars
""" """
import pytest import pytest
from app.repositories.tag_repo import TagRepository from app.repositories.tag_repo import TagRepository

View File

@@ -1,5 +1,6 @@
import pytest import pytest
from app.validation import validate_mime_type, validate_file_size, MimeTypeError, FileSizeError
from app.validation import FileSizeError, MimeTypeError, validate_file_size, validate_mime_type
ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"] ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
REJECTED_TYPES = ["application/pdf", "video/mp4", "text/plain", "application/octet-stream"] REJECTED_TYPES = ["application/pdf", "video/mp4", "text/plain", "application/octet-stream"]

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Tag Browser
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-06
**Feature**: [spec.md](../spec.md)
## Content Quality
- [X] No implementation details (languages, frameworks, APIs)
- [X] Focused on user value and business needs
- [X] Written for non-technical stakeholders
- [X] All mandatory sections completed
## Requirement Completeness
- [X] No [NEEDS CLARIFICATION] markers remain
- [X] Requirements are testable and unambiguous
- [X] Success criteria are measurable
- [X] Success criteria are technology-agnostic (no implementation details)
- [X] All acceptance scenarios are defined
- [X] Edge cases are identified
- [X] Scope is clearly bounded
- [X] Dependencies and assumptions identified
## Feature Readiness
- [X] All functional requirements have clear acceptance criteria
- [X] User scenarios cover primary flows
- [X] Feature meets measurable outcomes defined in Success Criteria
- [X] No implementation details leak into specification
## Notes
- All items pass. Feature is small and well-bounded — two P1 stories (browse + navigate) form the core MVP; P2 (discoverability link) is a natural follow-on. No clarifications needed. Ready for `/speckit-plan`.

View File

@@ -0,0 +1,58 @@
# Contract: GET /api/v1/tags (enhanced)
## Overview
Extends the existing tags list endpoint with two new optional query parameters. All existing behaviour is preserved when the new parameters are omitted.
## Request
```
GET /api/v1/tags
```
### Query Parameters
| Parameter | Type | Default | Description |
|------------|---------|----------|-------------|
| `q` | string | — | Filter tags by name prefix (existing) |
| `limit` | integer | 100 | Max items to return; capped at 200 (existing) |
| `offset` | integer | 0 | Pagination offset (existing) |
| `sort` | string | `name` | Sort order: `name` (alphabetical asc) or `count_desc` (image count descending, alphabetical secondary) |
| `min_count`| integer | 0 | Exclude tags with fewer than this many images. Use `1` to hide zero-count tags. |
### Authentication
Not required. Public endpoint.
## Response
```json
{
"items": [
{ "id": "uuid", "name": "string", "image_count": 0 }
],
"total": 0,
"limit": 100,
"offset": 0
}
```
No changes to the response shape.
## Tag Browser Usage
The tag browser component calls:
```
GET /api/v1/tags?sort=count_desc&min_count=1&limit=500
```
`limit=500` is a safe upper bound for a personal library. If `total` exceeds `limit` in the response, the component logs a warning but renders what it received (no pagination UI required at this scale).
## Library Autocomplete Usage (unchanged)
```
GET /api/v1/tags?q=<prefix>&limit=10
```
Uses neither `sort` nor `min_count` — default behaviour is unchanged.

View File

@@ -0,0 +1,23 @@
# Data Model: Tag Browser
No schema changes are required for this feature. All data needed to power the tag browser already exists.
## Derived Entity: Tag with Count
The tag browser displays a **read-only, derived view** of existing data:
| Field | Source | Notes |
|-------|--------|-------|
| `name` | `tags.name` | Lowercase, normalised string |
| `image_count` | `COUNT(image_tags.image_id) WHERE image_tags.tag_id = tags.id` | Computed at query time |
This is exactly the shape already returned by `GET /api/v1/tags` as `{"id", "name", "image_count"}`.
## What Changes
The query in `TagRepository.list_tags()` gains two optional behaviours:
1. **Sort by count descending** — adds `ORDER BY image_count DESC, name ASC` (count-desc primary, alphabetical secondary) instead of the current `ORDER BY name ASC`.
2. **Exclude zero-count tags** — adds `HAVING image_count > 0` (or equivalent `WHERE` on the subquery) when `min_count=1` is requested.
No new tables, columns, indexes, or migrations are needed.

View File

@@ -0,0 +1,96 @@
# Implementation Plan: Tag Browser
**Branch**: `007-tag-browser` | **Date**: 2026-05-06 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/007-tag-browser/spec.md`
## Summary
Add a `/tags` page that lists every tag with its image count, sorted by popularity, each linking to the filtered library view. Requires: (1) two new query parameters on the existing `/api/v1/tags` endpoint to support sort-by-count and zero-count exclusion, (2) query-parameter-driven filtering on the library route so tag browser links deep-link correctly, (3) a new `TagBrowserComponent`, and (4) a navigation entry point from the library.
## Technical Context
**Language/Version**: Python 3.12 (API), TypeScript strict / Angular 19 (UI)
**Primary Dependencies**: FastAPI, SQLAlchemy 2.x async, Angular standalone components
**Storage**: PostgreSQL (read-only for this feature — no schema changes)
**Testing**: pytest + httpx (API integration), Jasmine/Karma (Angular unit)
**Target Platform**: Web (same stack as all prior features)
**Project Type**: Web service + SPA
**Performance Goals**: Tag list page load perceived as instant (same bar as library)
**Constraints**: No schema changes; no new dependencies; counts must be accurate at page-load time
**Scale/Scope**: Personal library — tag count is bounded; no pagination UI needed for tag browser, but the API call uses existing paginated endpoint
## Constitution Check
| Principle | Status | Notes |
|-----------|--------|-------|
| §2.1 Strict separation of concerns | ✅ | UI calls API; API owns all DB logic |
| §2.5 Repository layer | ✅ | All query changes go in `TagRepository.list_tags()` |
| §2.6 No speculative abstraction | ✅ | No new interfaces; extends existing repo method |
| §3.1 API versioning `/api/v1/` | ✅ | Modifying existing versioned endpoint |
| §3.2 OpenAPI as contract | ✅ | New query params documented via FastAPI |
| §3.3 Error shape | ✅ | No new error paths |
| §3.4 Pagination | ✅ | Existing endpoint already paginates; tag browser fetches with `limit=500` (safe upper bound for a personal library) |
| §4.1 Tags lowercase normalised | ✅ | No change to tag creation/normalisation |
| §5.1 TDD non-negotiable | ✅ | Tests written before implementation in tasks |
| §5.3 Tests colocated | ✅ | API tests in `api/tests/`, Angular spec next to component |
| §6 Tech stack | ✅ | No new dependencies |
| §7.3 Linting/formatting enforced | ✅ | `ng lint` + `ruff` gates in tasks |
**Gate**: All principles pass. Phase 0 research not required — no unknowns.
## Project Structure
### Documentation (this feature)
```text
specs/007-tag-browser/
├── plan.md ← this file
├── research.md ← not required (no unknowns)
├── data-model.md ← see below (derived data, no schema changes)
├── contracts/
│ └── tags-endpoint.md ← enhanced GET /api/v1/tags contract
└── tasks.md ← generated by /speckit-tasks
```
### Source Code Changes
```text
api/
├── app/
│ ├── repositories/
│ │ └── tag_repo.py ← extend list_tags() with sort + min_count params
│ └── routers/
│ └── tags.py ← expose sort + min_count as query params
└── tests/
├── integration/
│ └── test_tags.py ← new tests: sort=count_desc, min_count=1
└── unit/
└── test_tags.py ← unit tests for repo sort/filter logic (if applicable)
ui/src/app/
├── tags/
│ ├── tags.component.ts ← new TagBrowserComponent
│ └── tags.component.spec.ts ← component tests
├── services/
│ └── tag.service.ts ← add sort param to list() method
├── library/
│ └── library.component.ts ← read ?tags= query param on init; add /tags nav link
└── app.routes.ts ← add /tags route (lazy-loaded)
```
## Design Decisions
### API: extend existing endpoint rather than add new one
The `/api/v1/tags` endpoint already returns tags with `image_count`. Two new optional query parameters make it serve the tag browser without breaking existing callers (the library autocomplete uses the endpoint unchanged):
- `sort`: `name` (default, current behaviour) | `count_desc` (tag browser use case)
- `min_count`: integer, default `0` (all tags, current behaviour) | `1` (excludes zero-count tags)
### Library: query param deep-linking
The library component currently manages `activeFilters` in memory only. Adding `?tags=cat,funny` query parameter support (read on `ngOnInit` via `ActivatedRoute`) allows the tag browser to link directly to a pre-filtered library view. The library already uses `addFilter()` / `applyFilter()` internally — reading from query params simply pre-populates `activeFilters` before the initial `load()` call. Navigation from within the library that changes filters should update the URL to keep it shareable, but that is a polish concern — minimum requirement is that arriving at `/?tags=cat` shows the cat-filtered library.
### Tag browser UI layout
A responsive chip/card grid sorted by count descending. Each item shows the tag name and count. Each item is a `routerLink` to `/?tags=<name>`. Follows the existing design token system (`--surface`, `--accent`, `--chip` styles). Empty state if no tags exist.

View File

@@ -0,0 +1,45 @@
# Quickstart: Tag Browser
## Verifying the feature end-to-end
### Prerequisites
- Docker stack running (`docker compose up`)
- At least 3 images uploaded with different tags (e.g., `cat`, `funny`, `reaction`)
- At least one image with two tags (e.g., both `cat` and `funny`)
### Scenario 1 — Tag browser shows all tags with correct counts
1. Open the app (not logged in).
2. Navigate to `/tags`.
3. **Expected**: A list of tags is shown. Each tag displays the number of images with that tag. Tags are ordered from most images to fewest.
4. Verify: Count next to `cat` matches the number of images actually tagged `cat`.
5. Verify: Tags with zero images are not shown.
### Scenario 2 — Clicking a tag navigates to the filtered library
1. On the `/tags` page, click the `cat` tag.
2. **Expected**: Navigated to the library (`/`) showing only images tagged `cat`.
3. Verify: The active filter chip shows `cat` in the library.
### Scenario 3 — Library page links to tag browser
1. Navigate to `/` (library, logged in or out).
2. **Expected**: A link or button labelled "Browse by tag" (or similar) is visible.
3. Click it.
4. **Expected**: The tag browser page loads.
### Scenario 4 — Empty state
1. If the library has no images at all, navigate to `/tags`.
2. **Expected**: An empty state message is shown rather than a blank page or error.
### API verification
```bash
# Sorted by count, zero-count tags excluded
curl http://localhost:8000/api/v1/tags?sort=count_desc&min_count=1
# Existing autocomplete behaviour unchanged
curl http://localhost:8000/api/v1/tags?q=ca&limit=10
```

View File

@@ -0,0 +1,95 @@
# Feature Specification: Tag Browser
**Feature Branch**: `007-tag-browser`
**Created**: 2026-05-06
**Status**: Draft
**Input**: User description: "A page that lists all tags with their image counts so that users don't have to guess at searches to find image categories/tags"
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Browse All Tags (Priority: P1)
The owner (or any visitor) wants to know what categories of images exist in the library without having to type guesses into a search box. They navigate to the tag browser page and see every tag in the library alongside the number of images associated with it, sorted so the most-used tags appear first.
**Why this priority**: This is the entire purpose of the feature. A visitor who doesn't know what tags exist has no way to discover them otherwise — the tag filter on the library page only helps when you already know what to type.
**Independent Test**: Navigate to the tag browser page without being logged in. Confirm every tag in the library is shown with its image count, ordered from highest to lowest count.
**Acceptance Scenarios**:
1. **Given** the library contains images with various tags, **When** a visitor opens the tag browser page, **Then** every tag in the library is listed with the number of images that carry that tag.
2. **Given** the tag list is displayed, **When** the visitor looks at the ordering, **Then** tags with more images appear before tags with fewer images.
3. **Given** the visitor is not logged in, **When** they open the tag browser page, **Then** the page loads and displays tags without requiring authentication.
---
### User Story 2 — Navigate from Tag to Library (Priority: P1)
A visitor sees a tag they are interested in and wants to view the images in that category. Clicking a tag on the tag browser page takes them directly to the library filtered to that tag, without requiring them to retype it.
**Why this priority**: The tag browser page has no value as a dead end. Each tag must be a link to the filtered library view — that is the core action the page enables. Treated as P1 because the browse and navigate actions together form the minimum useful feature.
**Independent Test**: Click any tag on the tag browser page. Confirm the library view opens showing only images carrying that tag.
**Acceptance Scenarios**:
1. **Given** the tag browser is showing a list of tags, **When** the visitor clicks a tag, **Then** they are taken to the library view filtered to show only images with that tag.
2. **Given** the visitor clicks a tag with a count of one, **When** the library loads, **Then** exactly one image is shown.
---
### User Story 3 — Reach the Tag Browser from the Library (Priority: P2)
The owner is browsing the image library and wants to switch to the tag browser to explore by category. A navigation element on the library page makes the tag browser discoverable without requiring the visitor to type the URL directly.
**Why this priority**: The tag browser is only useful if visitors can find it. A direct entry point from the library is the most natural discovery path; however, the core value of browsing and navigating tags is independently deliverable without it.
**Independent Test**: Load the library page. Confirm a visible link or button leads to the tag browser and navigates correctly when clicked.
**Acceptance Scenarios**:
1. **Given** the visitor is on the library page, **When** they look for a way to browse by tag, **Then** a visible link or button leads them to the tag browser.
2. **Given** the visitor clicks that link, **When** the tag browser loads, **Then** all tags and counts are shown as expected.
---
### Edge Cases
- What if there are no tags in the library at all? The page displays an appropriate empty state message rather than a blank page or error.
- What if a tag has been removed from all images (count reaches zero)? Tags with a count of zero are not shown on the tag browser page.
- What if the library contains a very large number of distinct tags? The page renders all of them without truncation; pagination is not required at personal library scale.
- What if two tags share the same count? An alphabetical secondary sort is acceptable — no specific tie-breaking order was requested.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The application MUST provide a dedicated tag browser page accessible at a stable URL.
- **FR-002**: The tag browser page MUST display every tag that exists in the library with at least one associated image, each shown with its current image count.
- **FR-003**: Tags with an image count of zero MUST NOT appear on the tag browser page.
- **FR-004**: Tags MUST be ordered from highest image count to lowest image count.
- **FR-005**: Each tag on the tag browser page MUST be a navigable link that takes the visitor to the library view filtered to that tag.
- **FR-006**: The tag browser page MUST be publicly accessible without authentication.
- **FR-007**: The library page MUST include a discoverable navigation element leading to the tag browser page.
### Key Entities
- **Tag with count**: A tag label paired with the number of images currently carrying that tag. No new stored data — counts are derived from existing imagetag relationships at read time.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Every tag present in the library with at least one image appears on the tag browser page — 0% omission rate.
- **SC-002**: The image count displayed next to each tag matches the actual number of images with that tag — 100% accuracy.
- **SC-003**: Clicking any tag on the tag browser navigates to the correctly filtered library view in 100% of cases.
- **SC-004**: The tag browser page loads successfully without authentication — verified by opening it while logged out.
- **SC-005**: A visitor can go from the library page to the tag browser and on to a filtered library view in three interactions or fewer.
## Assumptions
- Tags are already a first-class concept in the library — images can have multiple tags and the data needed to derive counts already exists. No schema changes are required.
- The library page already supports filtering by tag (via the existing search/filter mechanism); the tag browser links into that existing behaviour.
- Alphabetical secondary sort for equal-count tags is acceptable.
- Pagination of the tag list is out of scope for a personal image library.
- Creating, renaming, or deleting tags from the tag browser page is out of scope; it is a read-only view.

View File

@@ -0,0 +1,152 @@
# Tasks: Tag Browser
**Input**: Design documents from `specs/007-tag-browser/`
**Prerequisites**: plan.md ✅, spec.md ✅, data-model.md ✅, contracts/ ✅, quickstart.md ✅
**Tests**: TDD is non-negotiable (§5.1). Every implementation task is preceded by a failing-test task. Test tasks MUST be written and confirmed failing before the corresponding implementation task begins.
**Organization**: Foundational API + service changes first (block all stories), then one phase per user story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel with other [P] tasks in the same phase
- **[Story]**: Which user story this task belongs to
- Exact file paths included in every task description
---
## Phase 1: Setup
No new project structure required. The existing layout accommodates all changes.
---
## Phase 2: Foundational — API Enhancement & Service Update
**Purpose**: Extend `GET /api/v1/tags` with `sort` and `min_count` query parameters; update the Angular `TagService` to pass them. All three user stories depend on the API returning tags sorted by count with zero-count tags excluded.
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
- [X] T001 [P] Write failing API integration tests for `sort=count_desc` and `min_count=1` params in `api/tests/integration/test_tags.py` — assert response is ordered highest-count-first and excludes zero-count tags
- [X] T002 [P] Write failing spec for updated `TagService.list()` accepting `sort` and `minCount` params in `ui/src/app/services/tag.service.spec.ts` — final signature: `list(prefix = '', limit = 100, offset = 0, sort?: string, minCount?: number)`
- [X] T003 Extend `TagRepository.list_tags()` in `api/app/repositories/tag_repo.py` — add `sort: str = "name"` and `min_count: int = 0` params; apply `ORDER BY image_count DESC, name ASC` when `sort="count_desc"`; apply `HAVING image_count >= min_count` filter — run AFTER T001 (TDD)
- [X] T004 Expose `sort` and `min_count` as optional query params in `api/app/routers/tags.py` — pass through to `tag_repo.list_tags()` — run AFTER T003
- [X] T005 Update `TagService.list()` in `ui/src/app/services/tag.service.ts` — final signature: `list(prefix = '', limit = 100, offset = 0, sort?: string, minCount?: number)`; include `sort` and `min_count` in `HttpParams` when provided — run AFTER T002 (TDD)
**Execution order**: T001 ∥ T002 → T003 (after T001), T005 (after T002) → T004 (after T003)
**Checkpoint**: `GET /api/v1/tags?sort=count_desc&min_count=1` returns tags sorted by image count descending with zero-count tags excluded. `TagService.list()` passes the new params.
---
## Phase 3: User Story 1 — Browse All Tags (Priority: P1) 🎯 MVP
**Goal**: A `/tags` page that lists every tag (with count ≥ 1) sorted from most-used to least-used, with loading skeleton, empty state, and error state matching the existing design system.
**Independent Test**: Navigate to `/tags` while logged out. Confirm every tag with at least one image is shown with its count, ordered by count descending. Confirm the empty state appears when no tags exist.
### Tests for User Story 1
- [X] T006 [US1] Write failing spec for `TagBrowserComponent` in `ui/src/app/tags/tags.component.spec.ts` covering: (a) skeleton shown while loading, (b) tag list rendered with name and count after load, (c) tags ordered by count descending, (d) empty state shown when tag list is empty, (e) error state shown on fetch failure with retry button, (f) each rendered tag element has an `href` of `/?tags=<tagname>` (FR-005 coverage), (g) component renders when `AuthService` is not present / user is unauthenticated (FR-006 coverage)
### Implementation for User Story 1
- [X] T007 [US1] Create `TagBrowserComponent` in `ui/src/app/tags/tags.component.ts` — standalone component; on init call `tagService.list('', 500, 0, 'count_desc', 1)` (positional order matches T005 signature); display tag chips with name + count; each chip is a `routerLink="/"` with `[queryParams]="{tags: tag.name}"` so the href renders as `/?tags=<name>`; include skeleton loading state (reuse `.skeleton` class from global styles), empty state, and error state with retry; apply design tokens throughout
- [X] T008 [P] [US1] Add `/tags` lazy route to `ui/src/app/app.routes.ts` — load `TagBrowserComponent`; no auth guard (public route)
**Checkpoint**: `/tags` renders a sorted, filterable tag list visible without authentication.
---
## Phase 4: User Story 2 — Navigate from Tag to Library (Priority: P1)
**Goal**: Clicking a tag on the tag browser navigates to the library pre-filtered to that tag. Requires the library to read `?tags=<name>` from the URL on init and apply it as an active filter before the first image load.
**Independent Test**: Navigate directly to `/?tags=cat` in the browser. Confirm the library loads showing only images tagged `cat` and the `cat` chip appears in the active filter bar.
### Tests for User Story 2
- [X] T009 [US2] Write failing spec for `LibraryComponent` reading `?tags=` query param in `ui/src/app/library/library.component.spec.ts` — assert that when the component initialises with `?tags=cat` in the URL, `activeFilters` contains `['cat']` and `imageService.list` is called with `['cat']`
### Implementation for User Story 2
- [X] T010 [US2] Update `LibraryComponent` in `ui/src/app/library/library.component.ts` — inject `ActivatedRoute`; in `ngOnInit`, read `snapshot.queryParamMap.get('tags')`; if present, split by comma, set `activeFilters` before calling `load()` so the first fetch is already filtered
**Checkpoint**: Navigating to `/?tags=cat` from the tag browser shows the correctly filtered library.
---
## Phase 5: User Story 3 — Tag Browser Discoverable from Library (Priority: P2)
**Goal**: A visible "Browse tags" link in the library page header navigates to `/tags`. Makes the tag browser discoverable without requiring the user to type the URL.
**Independent Test**: Load the library page. Confirm a link to `/tags` is visible in the header and navigates correctly when clicked.
### Tests for User Story 3
- [X] T011 [US3] Write failing spec for library nav link to `/tags` in `ui/src/app/library/library.component.spec.ts` — assert a link element with `href="/tags"` is present in the rendered header
### Implementation for User Story 3
- [X] T012 [US3] Add "Browse tags" `routerLink="/tags"` link to `LibraryComponent` header in `ui/src/app/library/library.component.ts` — place alongside the existing Upload button; style consistently with the existing header button pattern
**Checkpoint**: All three user stories are independently functional.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T013 [P] Run `ruff check api/app/ api/tests/` and fix any violations
- [X] T014 [P] Run `ng lint` in `ui/` — zero violations required
- [X] T015 Run `ng build` in `ui/` — zero errors required
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 2 (Foundational)**: Blocks all user story phases — must complete first
- **Phase 3 (US1)**: Depends on Phase 2 — TagBrowserComponent needs the sorted tag endpoint
- **Phase 4 (US2)**: Depends on Phase 2 — library deep-link needs no API change, but should follow US1 for coherent testing
- **Phase 5 (US3)**: Depends on Phase 3 (needs the `/tags` route to exist for the link to be meaningful)
- **Phase 6 (Polish)**: Depends on all prior phases
### Within Phase 2
- T001 ∥ T002 (different repos, both write failing tests)
- T003 after T001 (TDD: failing test must exist first)
- T005 after T002 (TDD: failing test must exist first)
- T003 ∥ T005 (different repos, after their respective tests)
- T004 after T003 (router wraps repo)
### Execution Order (Phase 2)
```
Step 1 (parallel): T001, T002
Step 2 (parallel): T003 (after T001), T005 (after T002)
Step 3: T004 (after T003)
```
### Parallel Opportunities (Phases 35)
- T007 and T008 are parallel within Phase 3
---
## Implementation Strategy
### MVP (US1 + US2 — both P1)
1. Complete Phase 2 (Foundational)
2. Complete Phase 3 (US1 — TagBrowserComponent)
3. Complete Phase 4 (US2 — library deep-link)
4. **Validate**: Navigate from tag browser → library → confirm pre-filtered results
5. Phases 56 add discoverability and polish
### Incremental Delivery
- After Phase 3: `/tags` page is live and usable (visitors can browse tags)
- After Phase 4: clicking a tag works end-to-end (browse → filtered library)
- After Phase 5: tag browser is discoverable from the library without typing the URL
- After Phase 6: lint and build clean, ready for merge

View File

@@ -18,6 +18,11 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./upload/upload.component').then((m) => m.UploadComponent), import('./upload/upload.component').then((m) => m.UploadComponent),
}, },
{
path: 'tags',
loadComponent: () =>
import('./tags/tags.component').then((m) => m.TagsComponent),
},
{ {
path: 'images/:id', path: 'images/:id',
loadComponent: () => loadComponent: () =>

View File

@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { provideRouter, ActivatedRoute } from '@angular/router';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClientTesting } from '@angular/common/http/testing';
import { of } from 'rxjs'; import { of } from 'rxjs';
@@ -7,6 +7,16 @@ import { LibraryComponent } from './library.component';
import { ImageService } from '../services/image.service'; import { ImageService } from '../services/image.service';
import { routes } from '../app.routes'; import { routes } from '../app.routes';
function makeActivatedRoute(queryParams: Record<string, string> = {}) {
return {
snapshot: {
queryParamMap: {
get: (key: string) => queryParams[key] ?? null,
},
},
};
}
const EMPTY_PAGE = { items: [], total: 0, limit: 50, offset: 0 }; const EMPTY_PAGE = { items: [], total: 0, limit: 50, offset: 0 };
const ONE_IMAGE = { const ONE_IMAGE = {
items: [{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, created_at: '' }], items: [{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, created_at: '' }],
@@ -107,4 +117,32 @@ describe('LibraryComponent', () => {
fixture.componentInstance.onImgError(event); fixture.componentInstance.onImgError(event);
expect(imgEl.src).toBe(originalSrc); expect(imgEl.src).toBe(originalSrc);
}); });
it('pre-populates activeFilters from ?tags= query param on init', () => {
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ tags: 'cat,funny' }) });
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
fixture.detectChanges();
expect(fixture.componentInstance.activeFilters).toEqual(['cat', 'funny']);
expect(listSpy).toHaveBeenCalledWith(['cat', 'funny'], jasmine.any(Number), jasmine.any(Number));
});
it('does not set activeFilters when no ?tags= param present', () => {
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute() });
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
fixture.detectChanges();
expect(fixture.componentInstance.activeFilters).toEqual([]);
});
it('header contains a link to /tags', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(EMPTY_PAGE));
fixture.detectChanges();
const link = (fixture.nativeElement as HTMLElement).querySelector('a[href="/tags"]');
expect(link).not.toBeNull();
});
}); });

View File

@@ -5,7 +5,7 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink, ActivatedRoute } from '@angular/router';
import { Subject, debounceTime, distinctUntilChanged, share, timer } from 'rxjs'; import { Subject, debounceTime, distinctUntilChanged, share, timer } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { ImageRecord, ImageService } from '../services/image.service'; import { ImageRecord, ImageService } from '../services/image.service';
@@ -22,7 +22,10 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
<div class="library"> <div class="library">
<header> <header>
<h1>Reactbin</h1> <h1>Reactbin</h1>
<button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button> <div class="header-actions">
<a routerLink="/tags" class="tags-link">Browse tags</a>
<button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button>
</div>
</header> </header>
<div class="filter-bar"> <div class="filter-bar">
@@ -88,6 +91,9 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
styles: [` styles: [`
.library { max-width: 1200px; margin: 0 auto; padding: 24px 16px; } .library { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.tags-link { color: var(--text-muted); text-decoration: none; font-size: 0.9rem; transition: color var(--transition); }
.tags-link:hover { color: var(--text); }
.upload-btn { padding: 8px 20px; background: var(--accent); color: var(--accent-text); border: none; border-radius: var(--radius); cursor: pointer; font-weight: 600; } .upload-btn { padding: 8px 20px; background: var(--accent); color: var(--accent-text); border: none; border-radius: var(--radius); cursor: pointer; font-weight: 600; }
.filter-bar { position: relative; margin-bottom: 24px; } .filter-bar { position: relative; margin-bottom: 24px; }
.filter-bar input { width: 100%; padding: 10px; background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); } .filter-bar input { width: 100%; padding: 10px; background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); }
@@ -134,9 +140,14 @@ export class LibraryComponent implements OnInit {
private tagService: TagService, private tagService: TagService,
public router: Router, public router: Router,
private cdr: ChangeDetectorRef, private cdr: ChangeDetectorRef,
private route: ActivatedRoute,
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
const tagsParam = this.route.snapshot.queryParamMap.get('tags');
if (tagsParam) {
this.activeFilters = tagsParam.split(',').map((t) => t.trim()).filter((t) => t.length > 0);
}
this.load(); this.load();
this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => { this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => {
if (q) { if (q) {

View File

@@ -30,4 +30,26 @@ describe('TagService', () => {
expect(req.request.params.has('q')).toBeFalse(); expect(req.request.params.has('q')).toBeFalse();
req.flush({ items: [], total: 0, limit: 100, offset: 0 }); req.flush({ items: [], total: 0, limit: 100, offset: 0 });
}); });
it('should include sort param when provided', () => {
service.list('', 100, 0, 'count_desc').subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/v1/tags');
expect(req.request.params.get('sort')).toBe('count_desc');
req.flush({ items: [], total: 0, limit: 100, offset: 0 });
});
it('should include min_count param when minCount is provided', () => {
service.list('', 500, 0, 'count_desc', 1).subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/v1/tags');
expect(req.request.params.get('min_count')).toBe('1');
req.flush({ items: [], total: 0, limit: 500, offset: 0 });
});
it('should omit sort and min_count when not provided', () => {
service.list('cat').subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/v1/tags');
expect(req.request.params.has('sort')).toBeFalse();
expect(req.request.params.has('min_count')).toBeFalse();
req.flush({ items: [], total: 0, limit: 100, offset: 0 });
});
}); });

View File

@@ -21,11 +21,17 @@ export class TagService {
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
list(prefix?: string, limit = 100, offset = 0): Observable<TagListResponse> { list(prefix = '', limit = 100, offset = 0, sort?: string, minCount?: number): Observable<TagListResponse> {
let params = new HttpParams().set('limit', limit).set('offset', offset); let params = new HttpParams().set('limit', limit).set('offset', offset);
if (prefix) { if (prefix) {
params = params.set('q', prefix); params = params.set('q', prefix);
} }
if (sort) {
params = params.set('sort', sort);
}
if (minCount !== undefined) {
params = params.set('min_count', minCount);
}
return this.http.get<TagListResponse>(`${this.base}/tags`, { params }); return this.http.get<TagListResponse>(`${this.base}/tags`, { params });
} }
} }

View File

@@ -0,0 +1,102 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Subject, of, throwError } from 'rxjs';
import { TagsComponent } from './tags.component';
import { TagService, TagListResponse } from '../services/tag.service';
import { routes } from '../app.routes';
const TAGS_PAGE = (items: { name: string; image_count: number }[]): TagListResponse => ({
items: items.map((t, i) => ({ id: String(i), ...t })),
total: items.length,
limit: 500,
offset: 0,
});
describe('TagsComponent', () => {
let tagSvc: jasmine.SpyObj<TagService>;
beforeEach(async () => {
tagSvc = jasmine.createSpyObj('TagService', ['list']);
await TestBed.configureTestingModule({
imports: [TagsComponent],
providers: [
{ provide: TagService, useValue: tagSvc },
provideRouter(routes),
],
}).compileComponents();
});
it('shows skeleton while loading', () => {
// list() never resolves during this test
tagSvc.list.and.returnValue(new Subject<never>().asObservable());
const fixture = TestBed.createComponent(TagsComponent);
fixture.componentInstance.showSpinner = true;
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.skeleton')).not.toBeNull();
});
it('renders tag list with name and count after load', () => {
tagSvc.list.and.returnValue(of(TAGS_PAGE([
{ name: 'cat', image_count: 5 },
{ name: 'dog', image_count: 2 },
])));
const fixture = TestBed.createComponent(TagsComponent);
fixture.detectChanges();
const items = (fixture.nativeElement as HTMLElement).querySelectorAll('.tag-item');
expect(items.length).toBe(2);
expect(items[0].textContent).toContain('cat');
expect(items[0].textContent).toContain('5');
});
it('tags are ordered by count descending (service is called with count_desc)', () => {
tagSvc.list.and.returnValue(of(TAGS_PAGE([])));
const fixture = TestBed.createComponent(TagsComponent);
fixture.detectChanges();
expect(tagSvc.list).toHaveBeenCalledWith('', 500, 0, 'count_desc', 1);
});
it('shows empty state when tag list is empty', () => {
tagSvc.list.and.returnValue(of(TAGS_PAGE([])));
const fixture = TestBed.createComponent(TagsComponent);
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.empty-state')).not.toBeNull();
});
it('shows error state on fetch failure', () => {
tagSvc.list.and.returnValue(throwError(() => new Error('network')));
const fixture = TestBed.createComponent(TagsComponent);
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.error-card')).not.toBeNull();
});
it('retry button in error state calls load again', () => {
tagSvc.list.and.returnValue(throwError(() => new Error('network')));
const fixture = TestBed.createComponent(TagsComponent);
fixture.detectChanges();
spyOn(fixture.componentInstance, 'load');
const btn = (fixture.nativeElement as HTMLElement).querySelector('.error-card .retry-btn') as HTMLButtonElement;
expect(btn).not.toBeNull();
btn.click();
expect(fixture.componentInstance.load).toHaveBeenCalled();
});
it('each tag item links to /?tags=<tagname>', () => {
tagSvc.list.and.returnValue(of(TAGS_PAGE([
{ name: 'funny', image_count: 3 },
])));
const fixture = TestBed.createComponent(TagsComponent);
fixture.detectChanges();
const link = (fixture.nativeElement as HTMLElement).querySelector('.tag-item a') as HTMLAnchorElement;
expect(link).not.toBeNull();
expect(link.getAttribute('href')).toBe('/?tags=funny');
});
it('renders without requiring authentication', () => {
tagSvc.list.and.returnValue(of(TAGS_PAGE([{ name: 'test', image_count: 1 }])));
// No AuthService injected — component must not depend on it
const fixture = TestBed.createComponent(TagsComponent);
expect(() => fixture.detectChanges()).not.toThrow();
expect((fixture.nativeElement as HTMLElement).querySelector('.tag-item')).not.toBeNull();
});
});

View File

@@ -0,0 +1,96 @@
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { TagRecord, TagService } from '../services/tag.service';
@Component({
selector: 'app-tags',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="tags-page">
<header class="tags-header">
<h1>Browse Tags</h1>
<a routerLink="/" class="back-link">← Library</a>
</header>
<!-- Skeleton -->
<div *ngIf="showSpinner" class="tag-grid">
<div *ngFor="let _ of skeletonItems" class="tag-item skeleton tag-skeleton"></div>
</div>
<!-- Error -->
<div *ngIf="error && !showSpinner" class="error-card">
<p>Failed to load tags. Please check your connection.</p>
<button class="retry-btn" (click)="load()">Retry</button>
</div>
<!-- Empty -->
<div *ngIf="!showSpinner && !error && tags.length === 0" class="empty-state">
<span class="empty-icon">✦</span>
<p>No tags yet. Upload some images and add tags to get started.</p>
</div>
<!-- Tag grid -->
<div *ngIf="!showSpinner && !error && tags.length > 0" class="tag-grid">
<div *ngFor="let tag of tags" class="tag-item">
<a [routerLink]="['/']" [queryParams]="{ tags: tag.name }" class="tag-link">
<span class="tag-name">{{ tag.name }}</span>
<span class="tag-count">{{ tag.image_count }}</span>
</a>
</div>
</div>
</div>
`,
styles: [`
.tags-page { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
.tags-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.tags-header h1 { margin: 0; }
.back-link { color: var(--text-muted); text-decoration: none; font-size: 0.9rem; }
.back-link:hover { color: var(--text); }
.tag-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; }
.tag-item { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); transition: border-color var(--transition); }
.tag-item:hover { border-color: var(--border-focus); }
.tag-skeleton { height: 56px; }
.tag-link { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; text-decoration: none; color: var(--text); }
.tag-name { font-size: 0.95rem; font-weight: 500; }
.tag-count { font-size: 0.8rem; color: var(--text-muted); background: var(--surface-raised); padding: 2px 8px; border-radius: var(--radius-chip); }
.empty-state { text-align: center; padding: 60px 0; color: var(--text-muted); }
.empty-icon { display: block; font-size: 2rem; margin-bottom: 12px; }
.error-card { text-align: center; padding: 40px; background: var(--surface); border-radius: var(--radius); border: 1px solid var(--border); }
.error-card p { color: var(--text-muted); margin-bottom: 16px; }
.retry-btn { padding: 8px 24px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
.retry-btn:hover { border-color: var(--border-focus); }
`],
})
export class TagsComponent implements OnInit {
tags: TagRecord[] = [];
showSpinner = false;
error = false;
readonly skeletonItems = Array(12).fill(null);
constructor(private tagService: TagService, private cdr: ChangeDetectorRef) {}
ngOnInit(): void {
this.load();
}
load(): void {
this.error = false;
this.showSpinner = true;
this.cdr.markForCheck();
this.tagService.list('', 500, 0, 'count_desc', 1).subscribe({
next: (res) => {
this.tags = res.items;
this.showSpinner = false;
this.cdr.markForCheck();
},
error: () => {
this.showSpinner = false;
this.error = true;
this.cdr.markForCheck();
},
});
}
}