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 {
|
.topics-browse {
|
||||||
max-width: 56rem;
|
max-width: 64rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topics-browse__title {
|
.topics-browse__title {
|
||||||
|
|
@ -1869,7 +1869,7 @@ body {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background: var(--color-bg-input);
|
background: var(--color-bg-input);
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.5rem;
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1879,67 +1879,92 @@ body {
|
||||||
box-shadow: 0 0 0 2px var(--color-accent-focus);
|
box-shadow: 0 0 0 2px var(--color-accent-focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Topics hierarchy ─────────────────────────────────────────────────────── */
|
/* ── Card grid ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.topics-list {
|
.topics-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 0.75rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-category {
|
.topic-card {
|
||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
border-left: 3px solid var(--color-border);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 1px 3px var(--color-shadow);
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.625rem;
|
gap: 0.5rem;
|
||||||
width: 100%;
|
margin: 0;
|
||||||
padding: 0.875rem 1.25rem;
|
}
|
||||||
|
|
||||||
|
.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;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
font-family: inherit;
|
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 {
|
.topic-card__toggle: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;
|
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-category__desc {
|
/* ── Sub-topics inside card ───────────────────────────────────────────────── */
|
||||||
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 ───────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.topic-subtopics {
|
.topic-subtopics {
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
|
|
@ -1949,10 +1974,10 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.625rem 1.25rem 0.625rem 2.75rem;
|
padding: 0.5rem 1.25rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-size: 0.875rem;
|
font-size: 0.8125rem;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1974,7 +1999,7 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2037,12 +2062,16 @@ body {
|
||||||
font-size: 1.375rem;
|
font-size: 1.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-category__desc {
|
.topics-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-card__desc {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-subtopic {
|
.topic-subtopic {
|
||||||
padding-left: 2rem;
|
padding-left: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,23 @@
|
||||||
/**
|
/**
|
||||||
* Topics browse page (R008).
|
* Topics browse page (R008).
|
||||||
*
|
*
|
||||||
* Two-level hierarchy: 6 top-level categories with expandable/collapsible
|
* Responsive card grid layout with 7 top-level categories.
|
||||||
* sub-topics. Each sub-topic shows technique_count and creator_count.
|
* Each card shows: category name, description, summary stats
|
||||||
* Filter input narrows categories and sub-topics.
|
* (sub-topic count + total technique count), and an expand/collapse
|
||||||
* Click sub-topic → search results filtered to that topic.
|
* 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 { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { fetchTopics, type TopicCategory } from "../api/public-client";
|
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() {
|
export default function TopicsBrowse() {
|
||||||
const [categories, setCategories] = useState<TopicCategory[]>([]);
|
const [categories, setCategories] = useState<TopicCategory[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -62,7 +69,7 @@ export default function TopicsBrowse() {
|
||||||
// Apply filter: show categories whose name or sub-topics match
|
// Apply filter: show categories whose name or sub-topics match
|
||||||
const lowerFilter = filter.toLowerCase();
|
const lowerFilter = filter.toLowerCase();
|
||||||
const filtered = filter
|
const filtered = filter
|
||||||
? categories
|
? (categories
|
||||||
.map((cat) => {
|
.map((cat) => {
|
||||||
const catMatches = cat.name.toLowerCase().includes(lowerFilter);
|
const catMatches = cat.name.toLowerCase().includes(lowerFilter);
|
||||||
const matchingSubs = cat.sub_topics.filter((st) =>
|
const matchingSubs = cat.sub_topics.filter((st) =>
|
||||||
|
|
@ -74,7 +81,7 @@ export default function TopicsBrowse() {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.filter(Boolean) as TopicCategory[]
|
.filter(Boolean) as TopicCategory[])
|
||||||
: categories;
|
: categories;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -104,51 +111,76 @@ export default function TopicsBrowse() {
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
No topics matching "{filter}"
|
No topics matching “{filter}”
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="topics-list">
|
<div className="topics-grid">
|
||||||
{filtered.map((cat) => (
|
{filtered.map((cat) => {
|
||||||
<div key={cat.name} className="topic-category">
|
const slug = catSlug(cat.name);
|
||||||
<button
|
const isExpanded = expanded.has(cat.name);
|
||||||
className="topic-category__header"
|
const totalTechniques = cat.sub_topics.reduce(
|
||||||
onClick={() => toggleCategory(cat.name)}
|
(sum, st) => sum + st.technique_count,
|
||||||
aria-expanded={expanded.has(cat.name)}
|
0,
|
||||||
>
|
);
|
||||||
<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>
|
|
||||||
|
|
||||||
{expanded.has(cat.name) && (
|
return (
|
||||||
<div className="topic-subtopics">
|
<div
|
||||||
{cat.sub_topics.map((st) => (
|
key={cat.name}
|
||||||
<Link
|
className="topic-card"
|
||||||
key={st.name}
|
style={{
|
||||||
to={`/search?q=${encodeURIComponent(st.name)}&scope=topics`}
|
borderLeftColor: `var(--color-badge-cat-${slug}-text)`,
|
||||||
className="topic-subtopic"
|
}}
|
||||||
>
|
>
|
||||||
<span className="topic-subtopic__name">{st.name}</span>
|
<div className="topic-card__body">
|
||||||
<span className="topic-subtopic__counts">
|
<h3 className="topic-card__name">
|
||||||
<span className="topic-subtopic__count">
|
<span
|
||||||
{st.technique_count} technique{st.technique_count !== 1 ? "s" : ""}
|
className="topic-card__dot"
|
||||||
</span>
|
style={{
|
||||||
<span className="topic-subtopic__separator">·</span>
|
background: `var(--color-badge-cat-${slug}-text)`,
|
||||||
<span className="topic-subtopic__count">
|
}}
|
||||||
{st.creator_count} creator{st.creator_count !== 1 ? "s" : ""}
|
/>
|
||||||
</span>
|
{cat.name}
|
||||||
</span>
|
</h3>
|
||||||
</Link>
|
<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>
|
||||||
)}
|
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue