feat: Rewrote TopicsBrowse.tsx from vertical accordion to responsive 2-…
- "frontend/src/pages/TopicsBrowse.tsx" - "frontend/src/App.css" GSD-Task: S05/T02
This commit is contained in:
parent
66bf79ed4a
commit
96d7bc6e75
2 changed files with 157 additions and 96 deletions
|
|
@ -1839,11 +1839,11 @@ body {
|
|||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════════
|
||||
TOPICS BROWSE
|
||||
TOPICS BROWSE — Card Grid Layout
|
||||
══════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.topics-browse {
|
||||
max-width: 56rem;
|
||||
max-width: 64rem;
|
||||
}
|
||||
|
||||
.topics-browse__title {
|
||||
|
|
@ -1869,7 +1869,7 @@ body {
|
|||
font-family: inherit;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-input);
|
||||
margin-bottom: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
|
|
@ -1879,67 +1879,92 @@ body {
|
|||
box-shadow: 0 0 0 2px var(--color-accent-focus);
|
||||
}
|
||||
|
||||
/* ── Topics hierarchy ─────────────────────────────────────────────────────── */
|
||||
/* ── Card grid ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.topics-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
.topics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.topic-category {
|
||||
.topic-card {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px var(--color-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topic-category__header {
|
||||
.topic-card__body {
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.topic-card__name {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.25rem;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topic-card__dot {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topic-card__desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topic-card__stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.topic-card__stats-sep {
|
||||
color: var(--color-border);
|
||||
}
|
||||
|
||||
.topic-card__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.25rem 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.topic-category__header:hover {
|
||||
background: var(--color-bg-surface-hover);
|
||||
}
|
||||
|
||||
.topic-category__chevron {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
.topic-category__name {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
.topic-card__toggle:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.topic-category__desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.topic-category__count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Sub-topics ───────────────────────────────────────────────────────────── */
|
||||
/* ── Sub-topics inside card ───────────────────────────────────────────────── */
|
||||
|
||||
.topic-subtopics {
|
||||
border-top: 1px solid var(--color-border);
|
||||
|
|
@ -1949,10 +1974,10 @@ body {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 1.25rem 0.625rem 2.75rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
|
|
@ -1974,7 +1999,7 @@ body {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
|
|
@ -2037,12 +2062,16 @@ body {
|
|||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
.topic-category__desc {
|
||||
.topics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topic-card__desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topic-subtopic {
|
||||
padding-left: 2rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
/**
|
||||
* Topics browse page (R008).
|
||||
*
|
||||
* Two-level hierarchy: 6 top-level categories with expandable/collapsible
|
||||
* sub-topics. Each sub-topic shows technique_count and creator_count.
|
||||
* Filter input narrows categories and sub-topics.
|
||||
* Click sub-topic → search results filtered to that topic.
|
||||
* Responsive card grid layout with 7 top-level categories.
|
||||
* Each card shows: category name, description, summary stats
|
||||
* (sub-topic count + total technique count), and an expand/collapse
|
||||
* toggle revealing sub-topics as links to search.
|
||||
*
|
||||
* Filter input narrows visible cards by category name + sub-topic names.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { fetchTopics, type TopicCategory } from "../api/public-client";
|
||||
|
||||
/** Derive the badge CSS slug from a category name. */
|
||||
function catSlug(name: string): string {
|
||||
return name.toLowerCase().replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
export default function TopicsBrowse() {
|
||||
const [categories, setCategories] = useState<TopicCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -62,7 +69,7 @@ export default function TopicsBrowse() {
|
|||
// Apply filter: show categories whose name or sub-topics match
|
||||
const lowerFilter = filter.toLowerCase();
|
||||
const filtered = filter
|
||||
? categories
|
||||
? (categories
|
||||
.map((cat) => {
|
||||
const catMatches = cat.name.toLowerCase().includes(lowerFilter);
|
||||
const matchingSubs = cat.sub_topics.filter((st) =>
|
||||
|
|
@ -74,7 +81,7 @@ export default function TopicsBrowse() {
|
|||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as TopicCategory[]
|
||||
.filter(Boolean) as TopicCategory[])
|
||||
: categories;
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -104,51 +111,76 @@ export default function TopicsBrowse() {
|
|||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
No topics matching "{filter}"
|
||||
No topics matching “{filter}”
|
||||
</div>
|
||||
) : (
|
||||
<div className="topics-list">
|
||||
{filtered.map((cat) => (
|
||||
<div key={cat.name} className="topic-category">
|
||||
<button
|
||||
className="topic-category__header"
|
||||
onClick={() => toggleCategory(cat.name)}
|
||||
aria-expanded={expanded.has(cat.name)}
|
||||
>
|
||||
<span className="topic-category__chevron">
|
||||
{expanded.has(cat.name) ? "▼" : "▶"}
|
||||
</span>
|
||||
<span className="topic-category__name">{cat.name}</span>
|
||||
<span className="topic-category__desc">{cat.description}</span>
|
||||
<span className="topic-category__count">
|
||||
{cat.sub_topics.length} sub-topic{cat.sub_topics.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</button>
|
||||
<div className="topics-grid">
|
||||
{filtered.map((cat) => {
|
||||
const slug = catSlug(cat.name);
|
||||
const isExpanded = expanded.has(cat.name);
|
||||
const totalTechniques = cat.sub_topics.reduce(
|
||||
(sum, st) => sum + st.technique_count,
|
||||
0,
|
||||
);
|
||||
|
||||
{expanded.has(cat.name) && (
|
||||
<div className="topic-subtopics">
|
||||
{cat.sub_topics.map((st) => (
|
||||
<Link
|
||||
key={st.name}
|
||||
to={`/search?q=${encodeURIComponent(st.name)}&scope=topics`}
|
||||
className="topic-subtopic"
|
||||
>
|
||||
<span className="topic-subtopic__name">{st.name}</span>
|
||||
<span className="topic-subtopic__counts">
|
||||
<span className="topic-subtopic__count">
|
||||
{st.technique_count} technique{st.technique_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="topic-subtopic__separator">·</span>
|
||||
<span className="topic-subtopic__count">
|
||||
{st.creator_count} creator{st.creator_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
return (
|
||||
<div
|
||||
key={cat.name}
|
||||
className="topic-card"
|
||||
style={{
|
||||
borderLeftColor: `var(--color-badge-cat-${slug}-text)`,
|
||||
}}
|
||||
>
|
||||
<div className="topic-card__body">
|
||||
<h3 className="topic-card__name">
|
||||
<span
|
||||
className="topic-card__dot"
|
||||
style={{
|
||||
background: `var(--color-badge-cat-${slug}-text)`,
|
||||
}}
|
||||
/>
|
||||
{cat.name}
|
||||
</h3>
|
||||
<p className="topic-card__desc">{cat.description}</p>
|
||||
<div className="topic-card__stats">
|
||||
<span>{cat.sub_topics.length} sub-topic{cat.sub_topics.length !== 1 ? "s" : ""}</span>
|
||||
<span className="topic-card__stats-sep">·</span>
|
||||
<span>{totalTechniques} technique{totalTechniques !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
<button
|
||||
className="topic-card__toggle"
|
||||
onClick={() => toggleCategory(cat.name)}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
{isExpanded ? "Hide sub-topics ▲" : "Show sub-topics ▼"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isExpanded && (
|
||||
<div className="topic-subtopics">
|
||||
{cat.sub_topics.map((st) => (
|
||||
<Link
|
||||
key={st.name}
|
||||
to={`/search?q=${encodeURIComponent(st.name)}&scope=topics`}
|
||||
className="topic-subtopic"
|
||||
>
|
||||
<span className="topic-subtopic__name">{st.name}</span>
|
||||
<span className="topic-subtopic__counts">
|
||||
<span className="topic-subtopic__count">
|
||||
{st.technique_count} technique{st.technique_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="topic-subtopic__separator">·</span>
|
||||
<span className="topic-subtopic__count">
|
||||
{st.creator_count} creator{st.creator_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue