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 7b9f6785cb
commit 51e01f8b7c
6 changed files with 46 additions and 15 deletions

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());
} }