From 51e01f8b7c12e300279031847bc9fd864b88ea53 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 23:09:33 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Normalized=20/topics=20and=20/videos=20?= =?UTF-8?q?endpoints=20from=20bare=20lists=20to=20pagin=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "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 --- backend/routers/topics.py | 7 ++++--- backend/routers/videos.py | 26 ++++++++++++++++++-------- backend/schemas.py | 14 ++++++++++++++ frontend/src/api/topics.ts | 9 +++++++-- frontend/src/pages/Home.tsx | 3 ++- frontend/src/pages/TopicsBrowse.tsx | 2 +- 6 files changed, 46 insertions(+), 15 deletions(-) diff --git a/backend/routers/topics.py b/backend/routers/topics.py index 518dcaa..78eb02d 100644 --- a/backend/routers/topics.py +++ b/backend/routers/topics.py @@ -18,6 +18,7 @@ from schemas import ( PaginatedResponse, TechniquePageRead, TopicCategory, + TopicListResponse, TopicSubTopic, ) @@ -41,10 +42,10 @@ def _load_canonical_tags() -> list[dict[str, Any]]: return [] -@router.get("", response_model=list[TopicCategory]) +@router.get("", response_model=TopicListResponse) async def list_topics( db: AsyncSession = Depends(get_session), -) -> list[TopicCategory]: +) -> TopicListResponse: """Return the two-level topic hierarchy with technique/creator counts per sub-topic. 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) diff --git a/backend/routers/videos.py b/backend/routers/videos.py index 8a55db3..f394484 100644 --- a/backend/routers/videos.py +++ b/backend/routers/videos.py @@ -4,33 +4,43 @@ import logging from typing import Annotated from fastapi import APIRouter, Depends, Query -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from database import get_session from models import SourceVideo -from schemas import SourceVideoRead +from schemas import SourceVideoRead, VideoListResponse logger = logging.getLogger("chrysopedia.videos") router = APIRouter(prefix="/videos", tags=["videos"]) -@router.get("", response_model=list[SourceVideoRead]) +@router.get("", response_model=VideoListResponse) async def list_videos( offset: Annotated[int, Query(ge=0)] = 0, limit: Annotated[int, Query(ge=1, le=100)] = 50, creator_id: str | None = None, db: AsyncSession = Depends(get_session), -) -> list[SourceVideoRead]: +) -> VideoListResponse: """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: - 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) videos = result.scalars().all() 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, + ) diff --git a/backend/schemas.py b/backend/schemas.py index 0ce3a3c..d57e3e9 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -380,6 +380,20 @@ class TopicCategory(BaseModel): 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 ─────────────────────────────────────────────────────────── class CreatorBrowseItem(CreatorRead): diff --git a/frontend/src/api/topics.ts b/frontend/src/api/topics.ts index 94250b0..3054a05 100644 --- a/frontend/src/api/topics.ts +++ b/frontend/src/api/topics.ts @@ -15,10 +15,15 @@ export interface TopicCategory { sub_topics: TopicSubTopic[]; } +export interface TopicListResponse { + items: TopicCategory[]; + total: number; +} + // ── Functions ──────────────────────────────────────────────────────────────── -export async function fetchTopics(): Promise { - return request(`${BASE}/topics`); +export async function fetchTopics(): Promise { + return request(`${BASE}/topics`); } export async function fetchSubTopicTechniques( diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 70123ed..09c2d10 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -92,7 +92,8 @@ export default function Home() { let cancelled = false; void (async () => { try { - const categories = await fetchTopics(); + const data = await fetchTopics(); + const categories = data.items; const all = categories.flatMap((cat) => cat.sub_topics.map((st) => ({ name: st.name, count: st.technique_count })) ); diff --git a/frontend/src/pages/TopicsBrowse.tsx b/frontend/src/pages/TopicsBrowse.tsx index 880dd9a..e172237 100644 --- a/frontend/src/pages/TopicsBrowse.tsx +++ b/frontend/src/pages/TopicsBrowse.tsx @@ -35,7 +35,7 @@ export default function TopicsBrowse() { try { const data = await fetchTopics(); if (!cancelled) { - setCategories(data); + setCategories(data.items); // Start collapsed setExpanded(new Set()); }