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:
parent
7b9f6785cb
commit
51e01f8b7c
6 changed files with 46 additions and 15 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -15,10 +15,15 @@ export interface TopicCategory {
|
|||
sub_topics: TopicSubTopic[];
|
||||
}
|
||||
|
||||
export interface TopicListResponse {
|
||||
items: TopicCategory[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ── Functions ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchTopics(): Promise<TopicCategory[]> {
|
||||
return request<TopicCategory[]>(`${BASE}/topics`);
|
||||
export async function fetchTopics(): Promise<TopicListResponse> {
|
||||
return request<TopicListResponse>(`${BASE}/topics`);
|
||||
}
|
||||
|
||||
export async function fetchSubTopicTechniques(
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue