feat: Added SubTopicPage with breadcrumbs and creator-grouped technique…

- "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"

GSD-Task: S01/T02
This commit is contained in:
jlightner 2026-03-31 06:03:18 +00:00
parent a7a038beea
commit 6a793c2c9a
7 changed files with 315 additions and 5 deletions

View file

@ -2258,6 +2258,135 @@ a.app-footer__repo:hover {
color: var(--color-border); 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) ─────────────────────────────────────────── */ /* ── Public responsive (extended) ─────────────────────────────────────────── */
@media (max-width: 640px) { @media (max-width: 640px) {

View file

@ -5,6 +5,7 @@ import TechniquePage from "./pages/TechniquePage";
import CreatorsBrowse from "./pages/CreatorsBrowse"; import CreatorsBrowse from "./pages/CreatorsBrowse";
import CreatorDetail from "./pages/CreatorDetail"; import CreatorDetail from "./pages/CreatorDetail";
import TopicsBrowse from "./pages/TopicsBrowse"; import TopicsBrowse from "./pages/TopicsBrowse";
import SubTopicPage from "./pages/SubTopicPage";
import AdminReports from "./pages/AdminReports"; import AdminReports from "./pages/AdminReports";
import AdminPipeline from "./pages/AdminPipeline"; import AdminPipeline from "./pages/AdminPipeline";
import About from "./pages/About"; import About from "./pages/About";
@ -38,6 +39,7 @@ export default function App() {
{/* Browse routes */} {/* Browse routes */}
<Route path="/creators" element={<CreatorsBrowse />} /> <Route path="/creators" element={<CreatorsBrowse />} />
<Route path="/creators/:slug" element={<CreatorDetail />} /> <Route path="/creators/:slug" element={<CreatorDetail />} />
<Route path="/topics/:category/:subtopic" element={<SubTopicPage />} />
<Route path="/topics" element={<TopicsBrowse />} /> <Route path="/topics" element={<TopicsBrowse />} />
{/* Admin routes */} {/* Admin routes */}

View file

@ -265,6 +265,20 @@ export async function fetchTopics(): Promise<TopicCategory[]> {
return request<TopicCategory[]>(`${BASE}/topics`); return request<TopicCategory[]>(`${BASE}/topics`);
} }
export async function fetchSubTopicTechniques(
categorySlug: string,
subtopicSlug: string,
params: { limit?: number; offset?: number } = {},
): Promise<TechniqueListResponse> {
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<TechniqueListResponse>(
`${BASE}/topics/${encodeURIComponent(categorySlug)}/${encodeURIComponent(subtopicSlug)}${query ? `?${query}` : ""}`,
);
}
// ── Creators ───────────────────────────────────────────────────────────────── // ── Creators ─────────────────────────────────────────────────────────────────
export interface CreatorListParams { export interface CreatorListParams {

View file

@ -40,7 +40,7 @@ export default function Home() {
void (async () => { void (async () => {
try { try {
const res = await fetchTechniques({ sort: "random", limit: 1 }); 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 { } catch {
// silently ignore — optional section // silently ignore — optional section
} }

View file

@ -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<string, { creatorName: string; creatorSlug: string; techniques: TechniqueListItem[] }>();
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<TechniqueListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div className="loading">Loading techniques</div>;
}
if (error) {
return (
<div className="loading error-text">
Error: {error}
</div>
);
}
const groups = groupByCreator(techniques);
return (
<div className="subtopic-page">
{/* Breadcrumbs */}
<nav className="breadcrumbs" aria-label="Breadcrumb">
<Link to="/topics" className="breadcrumbs__link">Topics</Link>
<span className="breadcrumbs__sep" aria-hidden="true"></span>
<span className="breadcrumbs__text">{categoryDisplay}</span>
<span className="breadcrumbs__sep" aria-hidden="true"></span>
<span className="breadcrumbs__current" aria-current="page">{subtopicDisplay}</span>
</nav>
<h2 className="subtopic-page__title">{subtopicDisplay}</h2>
<p className="subtopic-page__subtitle">
{techniques.length} technique{techniques.length !== 1 ? "s" : ""} in {categoryDisplay}
</p>
{techniques.length === 0 ? (
<div className="empty-state">
No techniques found for this sub-topic.
</div>
) : (
<div className="subtopic-groups">
{groups.map((group) => (
<section key={group.creatorName} className="subtopic-group">
<h3 className="subtopic-group__creator">
{group.creatorSlug ? (
<Link to={`/creators/${group.creatorSlug}`} className="subtopic-group__creator-link">
{group.creatorName}
</Link>
) : (
group.creatorName
)}
<span className="subtopic-group__count">
{group.techniques.length} technique{group.techniques.length !== 1 ? "s" : ""}
</span>
</h3>
<div className="subtopic-group__list">
{group.techniques.map((t) => (
<Link
key={t.id}
to={`/techniques/${t.slug}`}
className="subtopic-technique-card"
>
<span className="subtopic-technique-card__title">{t.title}</span>
{t.topic_tags && t.topic_tags.length > 0 && (
<span className="subtopic-technique-card__tags">
{t.topic_tags.map((tag) => (
<span key={tag} className="pill">{tag}</span>
))}
</span>
)}
{t.summary && (
<span className="subtopic-technique-card__summary">
{t.summary.length > 150
? `${t.summary.slice(0, 150)}`
: t.summary}
</span>
)}
</Link>
))}
</div>
</section>
))}
</div>
)}
</div>
);
}

View file

@ -156,10 +156,12 @@ export default function TopicsBrowse() {
{isExpanded && ( {isExpanded && (
<div className="topic-subtopics"> <div className="topic-subtopics">
{cat.sub_topics.map((st) => ( {cat.sub_topics.map((st) => {
const stSlug = st.name.toLowerCase().replace(/\s+/g, "-");
return (
<Link <Link
key={st.name} key={st.name}
to={`/search?q=${encodeURIComponent(st.name)}&scope=topics`} to={`/topics/${slug}/${stSlug}`}
className="topic-subtopic" className="topic-subtopic"
> >
<span className="topic-subtopic__name">{st.name}</span> <span className="topic-subtopic__name">{st.name}</span>
@ -173,7 +175,8 @@ export default function TopicsBrowse() {
</span> </span>
</span> </span>
</Link> </Link>
))} );
})}
</div> </div>
)} )}
</div> </div>

View file

@ -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"} {"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"}