diff --git a/.gsd/milestones/M010/slices/S01/S01-PLAN.md b/.gsd/milestones/M010/slices/S01/S01-PLAN.md index b3cd828..ab60477 100644 --- a/.gsd/milestones/M010/slices/S01/S01-PLAN.md +++ b/.gsd/milestones/M010/slices/S01/S01-PLAN.md @@ -8,7 +8,7 @@ - Estimate: 45m - Files: backend/routers/topics.py, backend/tests/test_public_api.py - Verify: cd backend && python -m pytest tests/test_public_api.py -k 'subtopic' -v --timeout=30 -- [ ] **T02: Create SubTopicPage component, wire route and API client, update TopicsBrowse links** — Create the SubTopicPage React component at frontend/src/pages/SubTopicPage.tsx following the CreatorDetail pattern. Add fetchSubTopicTechniques function to public-client.ts that calls GET /topics/{category}/{subtopic}. Register route /topics/:category/:subtopic in App.tsx (before the /topics catch-all). Update TopicsBrowse.tsx sub-topic links from /search?q=... to /topics/{cat}/{subtopic}. Add CSS for breadcrumbs and creator-grouped layout to App.css. +- [x] **T02: Added SubTopicPage with breadcrumbs and creator-grouped technique list, wired /topics/:category/:subtopic route, updated TopicsBrowse links to dedicated pages** — Create the SubTopicPage React component at frontend/src/pages/SubTopicPage.tsx following the CreatorDetail pattern. Add fetchSubTopicTechniques function to public-client.ts that calls GET /topics/{category}/{subtopic}. Register route /topics/:category/:subtopic in App.tsx (before the /topics catch-all). Update TopicsBrowse.tsx sub-topic links from /search?q=... to /topics/{cat}/{subtopic}. Add CSS for breadcrumbs and creator-grouped layout to App.css. SubTopicPage must: - Extract category and subtopic from URL params diff --git a/.gsd/milestones/M010/slices/S01/tasks/T01-VERIFY.json b/.gsd/milestones/M010/slices/S01/tasks/T01-VERIFY.json new file mode 100644 index 0000000..557820c --- /dev/null +++ b/.gsd/milestones/M010/slices/S01/tasks/T01-VERIFY.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M010/S01/T01", + "timestamp": 1774936776440, + "passed": false, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd backend", + "exitCode": 0, + "durationMs": 5, + "verdict": "pass" + }, + { + "command": "python -m pytest tests/test_public_api.py -k 'subtopic' -v --timeout=30", + "exitCode": 4, + "durationMs": 199, + "verdict": "fail" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M010/slices/S01/tasks/T02-SUMMARY.md b/.gsd/milestones/M010/slices/S01/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..d216e2e --- /dev/null +++ b/.gsd/milestones/M010/slices/S01/tasks/T02-SUMMARY.md @@ -0,0 +1,86 @@ +--- +id: T02 +parent: S01 +milestone: M010 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/pages/SubTopicPage.tsx", "frontend/src/api/public-client.ts", "frontend/src/App.tsx", "frontend/src/pages/TopicsBrowse.tsx", "frontend/src/App.css", "frontend/src/pages/Home.tsx"] +key_decisions: ["Grouped techniques by creator with Map-based first-appearance ordering", "Breadcrumb pattern: nav.breadcrumbs with link/text/current spans for reuse"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "TypeScript check (npx tsc --noEmit) passed with exit 0. Production build (npm run build) passed with exit 0, 48 modules transformed." +completed_at: 2026-03-31T06:03:01.860Z +blocker_discovered: false +--- + +# T02: Added SubTopicPage with breadcrumbs and creator-grouped technique list, wired /topics/:category/:subtopic route, updated TopicsBrowse links to dedicated pages + +> Added SubTopicPage with breadcrumbs and creator-grouped technique list, wired /topics/:category/:subtopic route, updated TopicsBrowse links to dedicated pages + +## What Happened +--- +id: T02 +parent: S01 +milestone: M010 +key_files: + - frontend/src/pages/SubTopicPage.tsx + - frontend/src/api/public-client.ts + - frontend/src/App.tsx + - frontend/src/pages/TopicsBrowse.tsx + - frontend/src/App.css + - frontend/src/pages/Home.tsx +key_decisions: + - Grouped techniques by creator with Map-based first-appearance ordering + - Breadcrumb pattern: nav.breadcrumbs with link/text/current spans for reuse +duration: "" +verification_result: passed +completed_at: 2026-03-31T06:03:01.860Z +blocker_discovered: false +--- + +# T02: Added SubTopicPage with breadcrumbs and creator-grouped technique list, wired /topics/:category/:subtopic route, updated TopicsBrowse links to dedicated pages + +**Added SubTopicPage with breadcrumbs and creator-grouped technique list, wired /topics/:category/:subtopic route, updated TopicsBrowse links to dedicated pages** + +## What Happened + +Created SubTopicPage.tsx following CreatorDetail pattern: extracts category/subtopic from URL params, fetches via fetchSubTopicTechniques, groups by creator_name, renders breadcrumbs. Added API client function to public-client.ts. Registered route in App.tsx before /topics catch-all. Updated TopicsBrowse links from /search?q= to /topics/{cat}/{subtopic}. Added breadcrumb and sub-topic page CSS. Fixed pre-existing TS strict error in Home.tsx. + +## Verification + +TypeScript check (npx tsc --noEmit) passed with exit 0. Production build (npm run build) passed with exit 0, 48 modules transformed. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 3400ms | +| 2 | `cd frontend && npm run build` | 0 | ✅ pass | 3300ms | + + +## Deviations + +Fixed pre-existing TS strict error in Home.tsx (res.items[0] undefined narrowing) to pass npm run build. + +## Known Issues + +Slice-level pytest verification uses --timeout=30 but pytest-timeout not installed. Backend tests require PostgreSQL on ub01. + +## Files Created/Modified + +- `frontend/src/pages/SubTopicPage.tsx` +- `frontend/src/api/public-client.ts` +- `frontend/src/App.tsx` +- `frontend/src/pages/TopicsBrowse.tsx` +- `frontend/src/App.css` +- `frontend/src/pages/Home.tsx` + + +## Deviations +Fixed pre-existing TS strict error in Home.tsx (res.items[0] undefined narrowing) to pass npm run build. + +## Known Issues +Slice-level pytest verification uses --timeout=30 but pytest-timeout not installed. Backend tests require PostgreSQL on ub01. diff --git a/frontend/src/App.css b/frontend/src/App.css index e0c0cf9..f21728f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2258,6 +2258,135 @@ a.app-footer__repo:hover { color: var(--color-border); } +/* ── Breadcrumbs ──────────────────────────────────────────────────────────── */ + +.breadcrumbs { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--color-text-secondary); + margin-bottom: 1.5rem; +} + +.breadcrumbs__link { + color: var(--color-accent); + text-decoration: none; + transition: color 0.15s; +} + +.breadcrumbs__link:hover { + color: var(--color-accent-hover); +} + +.breadcrumbs__sep { + color: var(--color-border); + user-select: none; +} + +.breadcrumbs__text { + color: var(--color-text-secondary); +} + +.breadcrumbs__current { + color: var(--color-text-primary); + font-weight: 500; +} + +/* ── Sub-topic page ──────────────────────────────────────────────────────── */ + +.subtopic-page { + max-width: 56rem; + margin: 0 auto; + padding: 1rem 0; +} + +.subtopic-page__title { + font-size: 1.75rem; + font-weight: 700; + color: var(--color-text-primary); + margin: 0 0 0.25rem; +} + +.subtopic-page__subtitle { + font-size: 0.95rem; + color: var(--color-text-secondary); + margin: 0 0 2rem; +} + +.subtopic-groups { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.subtopic-group__creator { + font-size: 1.15rem; + font-weight: 600; + color: var(--color-text-primary); + margin: 0 0 0.75rem; + display: flex; + align-items: baseline; + gap: 0.75rem; +} + +.subtopic-group__creator-link { + color: var(--color-accent); + text-decoration: none; + transition: color 0.15s; +} + +.subtopic-group__creator-link:hover { + color: var(--color-accent-hover); +} + +.subtopic-group__count { + font-size: 0.8rem; + font-weight: 400; + color: var(--color-text-muted); +} + +.subtopic-group__list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.subtopic-technique-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem 1rem; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + text-decoration: none; + color: inherit; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.subtopic-technique-card:hover { + border-color: var(--color-accent); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} + +.subtopic-technique-card__title { + font-weight: 600; + color: var(--color-text-primary); +} + +.subtopic-technique-card__tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.subtopic-technique-card__summary { + font-size: 0.875rem; + color: var(--color-text-secondary); + line-height: 1.5; +} + /* ── Public responsive (extended) ─────────────────────────────────────────── */ @media (max-width: 640px) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c22b061..2dd144e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import TechniquePage from "./pages/TechniquePage"; import CreatorsBrowse from "./pages/CreatorsBrowse"; import CreatorDetail from "./pages/CreatorDetail"; import TopicsBrowse from "./pages/TopicsBrowse"; +import SubTopicPage from "./pages/SubTopicPage"; import AdminReports from "./pages/AdminReports"; import AdminPipeline from "./pages/AdminPipeline"; import About from "./pages/About"; @@ -38,6 +39,7 @@ export default function App() { {/* Browse routes */} } /> } /> + } /> } /> {/* Admin routes */} diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts index 04d03af..0094fcd 100644 --- a/frontend/src/api/public-client.ts +++ b/frontend/src/api/public-client.ts @@ -265,6 +265,20 @@ export async function fetchTopics(): Promise { return request(`${BASE}/topics`); } +export async function fetchSubTopicTechniques( + categorySlug: string, + subtopicSlug: string, + params: { limit?: number; offset?: number } = {}, +): Promise { + const qs = new URLSearchParams(); + if (params.limit !== undefined) qs.set("limit", String(params.limit)); + if (params.offset !== undefined) qs.set("offset", String(params.offset)); + const query = qs.toString(); + return request( + `${BASE}/topics/${encodeURIComponent(categorySlug)}/${encodeURIComponent(subtopicSlug)}${query ? `?${query}` : ""}`, + ); +} + // ── Creators ───────────────────────────────────────────────────────────────── export interface CreatorListParams { diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index a136532..1b1a5cd 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -40,7 +40,7 @@ export default function Home() { void (async () => { try { const res = await fetchTechniques({ sort: "random", limit: 1 }); - if (!cancelled && res.items.length > 0) setFeatured(res.items[0]); + if (!cancelled && res.items.length > 0) setFeatured(res.items[0] ?? null); } catch { // silently ignore — optional section } diff --git a/frontend/src/pages/SubTopicPage.tsx b/frontend/src/pages/SubTopicPage.tsx new file mode 100644 index 0000000..ebc53e2 --- /dev/null +++ b/frontend/src/pages/SubTopicPage.tsx @@ -0,0 +1,162 @@ +/** + * Sub-topic detail page. + * + * Shows techniques for a specific sub-topic within a category, + * grouped by creator. Breadcrumb navigation back to Topics. + */ + +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { + fetchSubTopicTechniques, + type TechniqueListItem, +} from "../api/public-client"; + +/** Convert a URL slug to a display name: replace hyphens with spaces, title-case. */ +function slugToDisplayName(slug: string): string { + return slug + .replace(/-/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Group techniques by creator_name, preserving order of first appearance. */ +function groupByCreator( + items: TechniqueListItem[], +): { creatorName: string; creatorSlug: string; techniques: TechniqueListItem[] }[] { + const map = new Map(); + for (const item of items) { + const name = item.creator_name || "Unknown"; + const existing = map.get(name); + if (existing) { + existing.techniques.push(item); + } else { + map.set(name, { + creatorName: name, + creatorSlug: item.creator_slug || "", + techniques: [item], + }); + } + } + return Array.from(map.values()); +} + +export default function SubTopicPage() { + const { category, subtopic } = useParams<{ category: string; subtopic: string }>(); + const [techniques, setTechniques] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const categoryDisplay = category ? slugToDisplayName(category) : ""; + const subtopicDisplay = subtopic ? slugToDisplayName(subtopic) : ""; + + useEffect(() => { + if (!category || !subtopic) return; + + let cancelled = false; + setLoading(true); + setError(null); + + void (async () => { + try { + const data = await fetchSubTopicTechniques(category, subtopic, { limit: 100 }); + if (!cancelled) { + setTechniques(data.items); + } + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "Failed to load techniques", + ); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [category, subtopic]); + + if (loading) { + return
Loading techniques…
; + } + + if (error) { + return ( +
+ Error: {error} +
+ ); + } + + const groups = groupByCreator(techniques); + + return ( +
+ {/* Breadcrumbs */} + + +

{subtopicDisplay}

+

+ {techniques.length} technique{techniques.length !== 1 ? "s" : ""} in {categoryDisplay} +

+ + {techniques.length === 0 ? ( +
+ No techniques found for this sub-topic. +
+ ) : ( +
+ {groups.map((group) => ( +
+

+ {group.creatorSlug ? ( + + {group.creatorName} + + ) : ( + group.creatorName + )} + + {group.techniques.length} technique{group.techniques.length !== 1 ? "s" : ""} + +

+
+ {group.techniques.map((t) => ( + + {t.title} + {t.topic_tags && t.topic_tags.length > 0 && ( + + {t.topic_tags.map((tag) => ( + {tag} + ))} + + )} + {t.summary && ( + + {t.summary.length > 150 + ? `${t.summary.slice(0, 150)}…` + : t.summary} + + )} + + ))} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/TopicsBrowse.tsx b/frontend/src/pages/TopicsBrowse.tsx index 0b90dd6..1a3876b 100644 --- a/frontend/src/pages/TopicsBrowse.tsx +++ b/frontend/src/pages/TopicsBrowse.tsx @@ -156,10 +156,12 @@ export default function TopicsBrowse() { {isExpanded && (
- {cat.sub_topics.map((st) => ( + {cat.sub_topics.map((st) => { + const stSlug = st.name.toLowerCase().replace(/\s+/g, "-"); + return ( {st.name} @@ -173,7 +175,8 @@ export default function TopicsBrowse() { - ))} + ); + })}
)} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 0bc3fdb..38b7d02 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx"],"version":"5.6.3"} \ No newline at end of file