diff --git a/frontend/src/App.css b/frontend/src/App.css index da9c9d2..520b1cc 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; } } diff --git a/frontend/src/pages/TopicsBrowse.tsx b/frontend/src/pages/TopicsBrowse.tsx index a6addb0..501755a 100644 --- a/frontend/src/pages/TopicsBrowse.tsx +++ b/frontend/src/pages/TopicsBrowse.tsx @@ -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([]); 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 ? (
- No topics matching "{filter}" + No topics matching “{filter}”
) : ( -
- {filtered.map((cat) => ( -
- +
+ {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) && ( -
- {cat.sub_topics.map((st) => ( - - {st.name} - - - {st.technique_count} technique{st.technique_count !== 1 ? "s" : ""} - - · - - {st.creator_count} creator{st.creator_count !== 1 ? "s" : ""} - - - - ))} + return ( +
+
+

+ + {cat.name} +

+

{cat.description}

+
+ {cat.sub_topics.length} sub-topic{cat.sub_topics.length !== 1 ? "s" : ""} + · + {totalTechniques} technique{totalTechniques !== 1 ? "s" : ""} +
+
- )} -
- ))} + + {isExpanded && ( +
+ {cat.sub_topics.map((st) => ( + + {st.name} + + + {st.technique_count} technique{st.technique_count !== 1 ? "s" : ""} + + · + + {st.creator_count} creator{st.creator_count !== 1 ? "s" : ""} + + + + ))} +
+ )} +
+ ); + })}
)}