Compare commits
3 Commits
006-header
...
007-tag-br
| Author | SHA1 | Date | |
|---|---|---|---|
| 265b967f6b | |||
| 355014f975 | |||
| 6092a4454e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
# Developer notes
|
||||
notes/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"feature_directory":"specs/006-header-nav-signout"}
|
||||
{"feature_directory":"specs/007-tag-browser"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- SPECKIT START -->
|
||||
For additional context about technologies to be used, project structure,
|
||||
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 -->
|
||||
|
||||
@@ -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 app.config import get_settings
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.database import Base
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class Image(Base):
|
||||
@@ -24,9 +24,13 @@ class Image(Base):
|
||||
height: Mapped[int] = mapped_column(Integer, 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)
|
||||
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
|
||||
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)
|
||||
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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.where(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 = [
|
||||
|
||||
@@ -12,9 +12,13 @@ async def list_tags(
|
||||
q: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
sort: str = "name",
|
||||
min_count: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
limit = min(limit, 200)
|
||||
limit = min(limit, 500)
|
||||
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}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import os
|
||||
import pytest
|
||||
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
# 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("OWNER_USERNAME", "testowner")
|
||||
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.database import Base
|
||||
from app.dependencies import get_db, get_storage, get_auth
|
||||
from app.auth.jwt_provider import JWTAuthProvider
|
||||
from app.dependencies import get_auth, get_db, get_storage
|
||||
from app.main import app
|
||||
|
||||
# Bust the LRU cache so get_settings() picks up the env vars set above
|
||||
get_settings.cache_clear()
|
||||
@@ -48,8 +48,8 @@ async def db_session(engine):
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session):
|
||||
from app.storage.s3_backend import S3StorageBackend
|
||||
from app.auth.noop import NoOpAuthProvider
|
||||
from app.storage.s3_backend import S3StorageBackend
|
||||
|
||||
storage = S3StorageBackend()
|
||||
auth = NoOpAuthProvider()
|
||||
|
||||
@@ -44,7 +44,6 @@ async def test_delete_removes_storage_object(client):
|
||||
)
|
||||
assert upload.status_code in (200, 201)
|
||||
image_id = upload.json()["id"]
|
||||
storage_key = upload.json()["hash"]
|
||||
|
||||
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
|
||||
assert delete_resp.status_code == 204
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
import io
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
|
||||
@@ -5,13 +5,17 @@ T057 — PATCH replaces tags, old tags unlinked, new tags upserted
|
||||
T058 — PATCH with invalid tag → 422 invalid_tag
|
||||
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"
|
||||
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 pytest
|
||||
|
||||
|
||||
def _minimal_png() -> bytes:
|
||||
import struct, zlib
|
||||
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)
|
||||
@@ -130,3 +134,64 @@ async def test_list_tags_prefix_filter(client):
|
||||
for item in body["items"]:
|
||||
assert item["name"].startswith("cat")
|
||||
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
|
||||
|
||||
@@ -79,6 +79,7 @@ async def test_upload_invalid_mime_type_returns_422(client):
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_oversized_file_returns_422(client):
|
||||
import os
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
os.environ["MAX_UPLOAD_BYTES"] = "10"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
_BASE_ENV = {
|
||||
@@ -26,6 +24,7 @@ def test_settings_load_from_env(monkeypatch):
|
||||
|
||||
# Import inside test to pick up monkeypatched env
|
||||
import importlib
|
||||
|
||||
import app.config as 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"})
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
importlib.reload(config_module)
|
||||
|
||||
@@ -53,6 +53,7 @@ def test_settings_jwt_expiry_override(monkeypatch):
|
||||
_apply_env(monkeypatch, {"JWT_EXPIRY_SECONDS": "3600"})
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
importlib.reload(config_module)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
|
||||
from app.utils import compute_sha256
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import time
|
||||
import pytest
|
||||
import jwt as pyjwt
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.auth.jwt_provider import JWTAuthProvider
|
||||
|
||||
@@ -3,6 +3,7 @@ T037 — tag normalisation: uppercase → lowercase, whitespace stripped
|
||||
T038 — tag validation: rejects names > 64 chars, invalid chars
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.repositories.tag_repo import TagRepository
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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"]
|
||||
REJECTED_TYPES = ["application/pdf", "video/mp4", "text/plain", "application/octet-stream"]
|
||||
|
||||
34
specs/007-tag-browser/checklists/requirements.md
Normal file
34
specs/007-tag-browser/checklists/requirements.md
Normal 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`.
|
||||
58
specs/007-tag-browser/contracts/tags-endpoint.md
Normal file
58
specs/007-tag-browser/contracts/tags-endpoint.md
Normal 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.
|
||||
23
specs/007-tag-browser/data-model.md
Normal file
23
specs/007-tag-browser/data-model.md
Normal 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.
|
||||
96
specs/007-tag-browser/plan.md
Normal file
96
specs/007-tag-browser/plan.md
Normal 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.
|
||||
45
specs/007-tag-browser/quickstart.md
Normal file
45
specs/007-tag-browser/quickstart.md
Normal 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
|
||||
```
|
||||
95
specs/007-tag-browser/spec.md
Normal file
95
specs/007-tag-browser/spec.md
Normal 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 image–tag 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.
|
||||
152
specs/007-tag-browser/tasks.md
Normal file
152
specs/007-tag-browser/tasks.md
Normal 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 3–5)
|
||||
|
||||
- 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 5–6 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
|
||||
@@ -18,6 +18,11 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./upload/upload.component').then((m) => m.UploadComponent),
|
||||
},
|
||||
{
|
||||
path: 'tags',
|
||||
loadComponent: () =>
|
||||
import('./tags/tags.component').then((m) => m.TagsComponent),
|
||||
},
|
||||
{
|
||||
path: 'images/:id',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideRouter, ActivatedRoute } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
@@ -7,6 +7,16 @@ import { LibraryComponent } from './library.component';
|
||||
import { ImageService } from '../services/image.service';
|
||||
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 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: '' }],
|
||||
@@ -107,4 +117,32 @@ describe('LibraryComponent', () => {
|
||||
fixture.componentInstance.onImgError(event);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ChangeDetectorRef,
|
||||
} from '@angular/core';
|
||||
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 { takeUntil } from 'rxjs/operators';
|
||||
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">
|
||||
<header>
|
||||
<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>
|
||||
|
||||
<div class="filter-bar">
|
||||
@@ -88,6 +91,9 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
styles: [`
|
||||
.library { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
|
||||
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; }
|
||||
.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); }
|
||||
@@ -134,9 +140,14 @@ export class LibraryComponent implements OnInit {
|
||||
private tagService: TagService,
|
||||
public router: Router,
|
||||
private cdr: ChangeDetectorRef,
|
||||
private route: ActivatedRoute,
|
||||
) {}
|
||||
|
||||
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.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => {
|
||||
if (q) {
|
||||
|
||||
@@ -30,4 +30,26 @@ describe('TagService', () => {
|
||||
expect(req.request.params.has('q')).toBeFalse();
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,11 +21,17 @@ export class TagService {
|
||||
|
||||
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);
|
||||
if (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 });
|
||||
}
|
||||
}
|
||||
|
||||
102
ui/src/app/tags/tags.component.spec.ts
Normal file
102
ui/src/app/tags/tags.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
96
ui/src/app/tags/tags.component.ts
Normal file
96
ui/src/app/tags/tags.component.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user