feat: Normalized /topics and /videos endpoints from bare lists to pagin…

- "backend/schemas.py"
- "backend/routers/topics.py"
- "backend/routers/videos.py"
- "frontend/src/api/topics.ts"
- "frontend/src/pages/TopicsBrowse.tsx"
- "frontend/src/pages/Home.tsx"

GSD-Task: S05/T03
This commit is contained in:
jlightner 2026-04-03 23:09:33 +00:00
parent 1bbcb8f5bf
commit dbc4afcf42
9 changed files with 165 additions and 16 deletions

View file

@ -122,7 +122,7 @@ All 17 page components are eagerly imported in `App.tsx`. Wrap admin and creator
- Estimate: 30m - Estimate: 30m
- Files: frontend/src/App.tsx - Files: frontend/src/App.tsx
- Verify: cd frontend && npm run build && ls dist/assets/*.js | wc -l && rg 'React.lazy' src/App.tsx - Verify: cd frontend && npm run build && ls dist/assets/*.js | wc -l && rg 'React.lazy' src/App.tsx
- [ ] **T03: Normalize /topics and /videos endpoints to paginated response shape** — ## Description - [x] **T03: Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers** — ## Description
Two API endpoints return bare lists instead of the standard `{items, total, offset, limit}` paginated shape. Normalize both backend endpoints and update the frontend consumers. Two API endpoints return bare lists instead of the standard `{items, total, offset, limit}` paginated shape. Normalize both backend endpoints and update the frontend consumers.

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"taskId": "T02",
"unitId": "M019/S05/T02",
"timestamp": 1775257599084,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 7,
"verdict": "pass"
},
{
"command": "npm run build",
"exitCode": 254,
"durationMs": 96,
"verdict": "fail"
},
{
"command": "rg 'React.lazy' src/App.tsx",
"exitCode": 2,
"durationMs": 7,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -0,0 +1,88 @@
---
id: T03
parent: S05
milestone: M019
provides: []
requires: []
affects: []
key_files: ["backend/schemas.py", "backend/routers/topics.py", "backend/routers/videos.py", "frontend/src/api/topics.ts", "frontend/src/pages/TopicsBrowse.tsx", "frontend/src/pages/Home.tsx"]
key_decisions: ["Added TopicListResponse and VideoListResponse as separate typed schemas rather than reusing generic PaginatedResponse — keeps response_model type-safe for OpenAPI docs"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "npm run build exits 0 (80 modules, zero TS errors). rg confirms no response_model=list remains in topics.py or videos.py. All slice-level checks pass: no public-client refs, core API files exist, 11 API modules."
completed_at: 2026-04-03T23:09:24.299Z
blocker_discovered: false
---
# T03: Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers
> Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers
## What Happened
---
id: T03
parent: S05
milestone: M019
key_files:
- backend/schemas.py
- backend/routers/topics.py
- backend/routers/videos.py
- frontend/src/api/topics.ts
- frontend/src/pages/TopicsBrowse.tsx
- frontend/src/pages/Home.tsx
key_decisions:
- Added TopicListResponse and VideoListResponse as separate typed schemas rather than reusing generic PaginatedResponse — keeps response_model type-safe for OpenAPI docs
duration: ""
verification_result: passed
completed_at: 2026-04-03T23:09:24.299Z
blocker_discovered: false
---
# T03: Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers
**Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers**
## What Happened
Replaced bare list returns on GET /topics (list[TopicCategory] → TopicListResponse) and GET /videos (list[SourceVideoRead] → VideoListResponse). Added count query for videos total. Updated fetchTopics return type and both frontend consumers (TopicsBrowse.tsx, Home.tsx) to read .items. No frontend code calls the public /videos endpoint.
## Verification
npm run build exits 0 (80 modules, zero TS errors). rg confirms no response_model=list remains in topics.py or videos.py. All slice-level checks pass: no public-client refs, core API files exist, 11 API modules.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 3800ms |
| 2 | `rg 'response_model=list' backend/routers/topics.py backend/routers/videos.py` | 1 | ✅ pass (no matches) | 100ms |
| 3 | `rg 'public-client' frontend/src/ -g '*.ts' -g '*.tsx'` | 1 | ✅ pass (no matches) | 100ms |
| 4 | `test -f frontend/src/api/index.ts && test -f frontend/src/api/client.ts` | 0 | ✅ pass | 100ms |
| 5 | `ls frontend/src/api/*.ts | wc -l` | 0 | ✅ pass (11) | 100ms |
## Deviations
None.
## Known Issues
Backend tests require PostgreSQL on ub01 (port 5433) — not available locally.
## Files Created/Modified
- `backend/schemas.py`
- `backend/routers/topics.py`
- `backend/routers/videos.py`
- `frontend/src/api/topics.ts`
- `frontend/src/pages/TopicsBrowse.tsx`
- `frontend/src/pages/Home.tsx`
## Deviations
None.
## Known Issues
Backend tests require PostgreSQL on ub01 (port 5433) — not available locally.

View file

@ -18,6 +18,7 @@ from schemas import (
PaginatedResponse, PaginatedResponse,
TechniquePageRead, TechniquePageRead,
TopicCategory, TopicCategory,
TopicListResponse,
TopicSubTopic, TopicSubTopic,
) )
@ -41,10 +42,10 @@ def _load_canonical_tags() -> list[dict[str, Any]]:
return [] return []
@router.get("", response_model=list[TopicCategory]) @router.get("", response_model=TopicListResponse)
async def list_topics( async def list_topics(
db: AsyncSession = Depends(get_session), db: AsyncSession = Depends(get_session),
) -> list[TopicCategory]: ) -> TopicListResponse:
"""Return the two-level topic hierarchy with technique/creator counts per sub-topic. """Return the two-level topic hierarchy with technique/creator counts per sub-topic.
Categories come from ``canonical_tags.yaml``. Counts are computed Categories come from ``canonical_tags.yaml``. Counts are computed
@ -97,7 +98,7 @@ async def list_topics(
) )
) )
return result return TopicListResponse(items=result, total=len(result))
@router.get("/{category_slug}/{subtopic_slug}", response_model=PaginatedResponse) @router.get("/{category_slug}/{subtopic_slug}", response_model=PaginatedResponse)

View file

@ -4,33 +4,43 @@ import logging
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_session from database import get_session
from models import SourceVideo from models import SourceVideo
from schemas import SourceVideoRead from schemas import SourceVideoRead, VideoListResponse
logger = logging.getLogger("chrysopedia.videos") logger = logging.getLogger("chrysopedia.videos")
router = APIRouter(prefix="/videos", tags=["videos"]) router = APIRouter(prefix="/videos", tags=["videos"])
@router.get("", response_model=list[SourceVideoRead]) @router.get("", response_model=VideoListResponse)
async def list_videos( async def list_videos(
offset: Annotated[int, Query(ge=0)] = 0, offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 50, limit: Annotated[int, Query(ge=1, le=100)] = 50,
creator_id: str | None = None, creator_id: str | None = None,
db: AsyncSession = Depends(get_session), db: AsyncSession = Depends(get_session),
) -> list[SourceVideoRead]: ) -> VideoListResponse:
"""List source videos with optional filtering by creator.""" """List source videos with optional filtering by creator."""
stmt = select(SourceVideo).order_by(SourceVideo.created_at.desc()) base_stmt = select(SourceVideo).order_by(SourceVideo.created_at.desc())
if creator_id: if creator_id:
stmt = stmt.where(SourceVideo.creator_id == creator_id) base_stmt = base_stmt.where(SourceVideo.creator_id == creator_id)
stmt = stmt.offset(offset).limit(limit) # Total count (before offset/limit)
count_stmt = select(func.count()).select_from(base_stmt.subquery())
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
stmt = base_stmt.offset(offset).limit(limit)
result = await db.execute(stmt) result = await db.execute(stmt)
videos = result.scalars().all() videos = result.scalars().all()
logger.debug("Listed %d videos (offset=%d, limit=%d)", len(videos), offset, limit) logger.debug("Listed %d videos (offset=%d, limit=%d)", len(videos), offset, limit)
return [SourceVideoRead.model_validate(v) for v in videos] return VideoListResponse(
items=[SourceVideoRead.model_validate(v) for v in videos],
total=total,
offset=offset,
limit=limit,
)

View file

@ -380,6 +380,20 @@ class TopicCategory(BaseModel):
sub_topics: list[TopicSubTopic] = Field(default_factory=list) sub_topics: list[TopicSubTopic] = Field(default_factory=list)
class TopicListResponse(BaseModel):
"""Paginated list of topic categories."""
items: list[TopicCategory] = Field(default_factory=list)
total: int = 0
class VideoListResponse(BaseModel):
"""Paginated list of source videos."""
items: list[SourceVideoRead] = Field(default_factory=list)
total: int = 0
offset: int = 0
limit: int = 50
# ── Creator Browse ─────────────────────────────────────────────────────────── # ── Creator Browse ───────────────────────────────────────────────────────────
class CreatorBrowseItem(CreatorRead): class CreatorBrowseItem(CreatorRead):

View file

@ -15,10 +15,15 @@ export interface TopicCategory {
sub_topics: TopicSubTopic[]; sub_topics: TopicSubTopic[];
} }
export interface TopicListResponse {
items: TopicCategory[];
total: number;
}
// ── Functions ──────────────────────────────────────────────────────────────── // ── Functions ────────────────────────────────────────────────────────────────
export async function fetchTopics(): Promise<TopicCategory[]> { export async function fetchTopics(): Promise<TopicListResponse> {
return request<TopicCategory[]>(`${BASE}/topics`); return request<TopicListResponse>(`${BASE}/topics`);
} }
export async function fetchSubTopicTechniques( export async function fetchSubTopicTechniques(

View file

@ -92,7 +92,8 @@ export default function Home() {
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
try { try {
const categories = await fetchTopics(); const data = await fetchTopics();
const categories = data.items;
const all = categories.flatMap((cat) => const all = categories.flatMap((cat) =>
cat.sub_topics.map((st) => ({ name: st.name, count: st.technique_count })) cat.sub_topics.map((st) => ({ name: st.name, count: st.technique_count }))
); );

View file

@ -35,7 +35,7 @@ export default function TopicsBrowse() {
try { try {
const data = await fetchTopics(); const data = await fetchTopics();
if (!cancelled) { if (!cancelled) {
setCategories(data); setCategories(data.items);
// Start collapsed // Start collapsed
setExpanded(new Set()); setExpanded(new Set());
} }