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