feat: Created shared TagList component with max-4 overflow, applied acr…

- "frontend/src/components/TagList.tsx"
- "frontend/src/pages/Home.tsx"
- "frontend/src/pages/SearchResults.tsx"
- "frontend/src/pages/SubTopicPage.tsx"
- "frontend/src/pages/CreatorDetail.tsx"
- "frontend/src/pages/TopicsBrowse.tsx"
- "frontend/src/App.css"

GSD-Task: S02/T03
This commit is contained in:
jlightner 2026-03-31 08:35:07 +00:00
parent caa55381ab
commit adc86446f1
11 changed files with 200 additions and 21 deletions

View file

@ -57,7 +57,7 @@
- Estimate: 20m - Estimate: 20m
- Files: frontend/src/pages/CreatorDetail.tsx, frontend/src/App.css - Files: frontend/src/pages/CreatorDetail.tsx, frontend/src/App.css
- Verify: cd frontend && npx tsc --noEmit && npm run build - Verify: cd frontend && npx tsc --noEmit && npm run build
- [ ] **T03: TagList component, tag overflow limit, and empty subtopic handling** — Create a shared TagList component for tag overflow (R027), apply it across all 5 tag-rendering sites, and add empty subtopic handling in TopicsBrowse (R028). - [x] **T03: Created shared TagList component with max-4 overflow, applied across all 5 tag sites, and added empty subtopic Coming soon badge** — Create a shared TagList component for tag overflow (R027), apply it across all 5 tag-rendering sites, and add empty subtopic handling in TopicsBrowse (R028).
## Steps ## Steps

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"taskId": "T02",
"unitId": "M011/S02/T02",
"timestamp": 1774945929488,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 7,
"verdict": "pass"
},
{
"command": "npx tsc --noEmit",
"exitCode": 1,
"durationMs": 762,
"verdict": "fail"
},
{
"command": "npm run build",
"exitCode": 254,
"durationMs": 89,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -0,0 +1,87 @@
---
id: T03
parent: S02
milestone: M011
provides: []
requires: []
affects: []
key_files: ["frontend/src/components/TagList.tsx", "frontend/src/pages/Home.tsx", "frontend/src/pages/SearchResults.tsx", "frontend/src/pages/SubTopicPage.tsx", "frontend/src/pages/CreatorDetail.tsx", "frontend/src/pages/TopicsBrowse.tsx", "frontend/src/App.css"]
key_decisions: ["Added pillClass prop to TagList so Home.tsx can pass pill--tag while other pages use bare pill class"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "TypeScript compilation (tsc --noEmit) passed with zero errors. Production build (npm run build) succeeded — 51 modules transformed, built in 827ms."
completed_at: 2026-03-31T08:34:55.792Z
blocker_discovered: false
---
# T03: Created shared TagList component with max-4 overflow, applied across all 5 tag sites, and added empty subtopic Coming soon badge
> Created shared TagList component with max-4 overflow, applied across all 5 tag sites, and added empty subtopic Coming soon badge
## What Happened
---
id: T03
parent: S02
milestone: M011
key_files:
- frontend/src/components/TagList.tsx
- frontend/src/pages/Home.tsx
- frontend/src/pages/SearchResults.tsx
- frontend/src/pages/SubTopicPage.tsx
- frontend/src/pages/CreatorDetail.tsx
- frontend/src/pages/TopicsBrowse.tsx
- frontend/src/App.css
key_decisions:
- Added pillClass prop to TagList so Home.tsx can pass pill--tag while other pages use bare pill class
duration: ""
verification_result: passed
completed_at: 2026-03-31T08:34:55.792Z
blocker_discovered: false
---
# T03: Created shared TagList component with max-4 overflow, applied across all 5 tag sites, and added empty subtopic Coming soon badge
**Created shared TagList component with max-4 overflow, applied across all 5 tag sites, and added empty subtopic Coming soon badge**
## What Happened
Created TagList component with configurable max and pillClass props. Replaced all 5 inline topic_tags.map() calls across Home, SearchResults, SubTopicPage, CreatorDetail with the shared component. Added empty subtopic handling in TopicsBrowse — technique_count === 0 renders a non-clickable span with Coming soon pill instead of a Link. Added pill--overflow, pill--coming-soon, and topic-subtopic--empty CSS.
## Verification
TypeScript compilation (tsc --noEmit) passed with zero errors. Production build (npm run build) succeeded — 51 modules transformed, built in 827ms.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 3600ms |
| 2 | `cd frontend && npm run build` | 0 | ✅ pass | 2900ms |
## Deviations
Added pillClass prop to TagList (not in plan) to preserve existing pill--tag class used in Home.tsx.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/components/TagList.tsx`
- `frontend/src/pages/Home.tsx`
- `frontend/src/pages/SearchResults.tsx`
- `frontend/src/pages/SubTopicPage.tsx`
- `frontend/src/pages/CreatorDetail.tsx`
- `frontend/src/pages/TopicsBrowse.tsx`
- `frontend/src/App.css`
## Deviations
Added pillClass prop to TagList (not in plan) to preserve existing pill--tag class used in Home.tsx.
## Known Issues
None.

View file

@ -1456,6 +1456,19 @@ a.app-footer__repo:hover {
color: var(--color-pill-plugin-text); color: var(--color-pill-plugin-text);
} }
.pill--overflow {
background: var(--color-surface-2);
color: var(--color-text-secondary);
font-style: italic;
}
.pill--coming-soon {
font-size: 0.65rem;
background: var(--color-surface-2);
color: var(--color-text-secondary);
font-style: italic;
}
.pill-list { .pill-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -2353,6 +2366,15 @@ a.app-footer__repo:hover {
background: var(--color-bg-surface-hover); background: var(--color-bg-surface-hover);
} }
.topic-subtopic--empty {
opacity: 0.5;
cursor: default;
}
.topic-subtopic--empty:hover {
background: transparent;
}
.topic-subtopic + .topic-subtopic { .topic-subtopic + .topic-subtopic {
border-top: 1px solid var(--color-bg-surface-hover); border-top: 1px solid var(--color-bg-surface-hover);
} }

View file

@ -0,0 +1,33 @@
/**
* Shared tag list with overflow indicator.
*
* Renders up to `max` tag pills (default 4), plus a "+N more" pill
* when the list exceeds the limit. Used across cards and detail pages
* to keep tag displays compact and consistent (R027).
*/
interface TagListProps {
tags: string[];
max?: number;
/** Extra CSS class added to each pill (e.g. "pill--tag") */
pillClass?: string;
}
export default function TagList({ tags, max = 4, pillClass }: TagListProps) {
const visible = tags.slice(0, max);
const overflow = tags.length - max;
const cls = pillClass ? `pill ${pillClass}` : "pill";
return (
<>
{visible.map((tag) => (
<span key={tag} className={cls}>
{tag}
</span>
))}
{overflow > 0 && (
<span className="pill pill--overflow">+{overflow} more</span>
)}
</>
);
}

View file

@ -15,6 +15,7 @@ import {
} from "../api/public-client"; } from "../api/public-client";
import CreatorAvatar from "../components/CreatorAvatar"; import CreatorAvatar from "../components/CreatorAvatar";
import { catSlug } from "../utils/catSlug"; import { catSlug } from "../utils/catSlug";
import TagList from "../components/TagList";
export default function CreatorDetail() { export default function CreatorDetail() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
@ -156,11 +157,7 @@ export default function CreatorDetail() {
</span> </span>
{t.topic_tags && t.topic_tags.length > 0 && ( {t.topic_tags && t.topic_tags.length > 0 && (
<span className="creator-technique-card__tags"> <span className="creator-technique-card__tags">
{t.topic_tags.map((tag) => ( <TagList tags={t.topic_tags} />
<span key={tag} className="pill">
{tag}
</span>
))}
</span> </span>
)} )}
</span> </span>

View file

@ -7,6 +7,7 @@
import { IconTopics, IconCreators } from "../components/CategoryIcons"; import { IconTopics, IconCreators } from "../components/CategoryIcons";
import SearchAutocomplete from "../components/SearchAutocomplete"; import SearchAutocomplete from "../components/SearchAutocomplete";
import TagList from "../components/TagList";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { import {
@ -198,9 +199,9 @@ export default function Home() {
{featured.topic_category && ( {featured.topic_category && (
<span className="badge badge--category">{featured.topic_category}</span> <span className="badge badge--category">{featured.topic_category}</span>
)} )}
{featured.topic_tags && featured.topic_tags.length > 0 && featured.topic_tags.map(tag => ( {featured.topic_tags && featured.topic_tags.length > 0 && (
<span key={tag} className="pill pill--tag">{tag}</span> <TagList tags={featured.topic_tags} pillClass="pill--tag" />
))} )}
{featured.key_moment_count > 0 && ( {featured.key_moment_count > 0 && (
<span className="home-featured__moments"> <span className="home-featured__moments">
{featured.key_moment_count} moment{featured.key_moment_count !== 1 ? "s" : ""} {featured.key_moment_count} moment{featured.key_moment_count !== 1 ? "s" : ""}
@ -244,9 +245,9 @@ export default function Home() {
<span className="badge badge--category"> <span className="badge badge--category">
{t.topic_category} {t.topic_category}
</span> </span>
{t.topic_tags && t.topic_tags.length > 0 && t.topic_tags.map(tag => ( {t.topic_tags && t.topic_tags.length > 0 && (
<span key={tag} className="pill pill--tag">{tag}</span> <TagList tags={t.topic_tags} pillClass="pill--tag" />
))} )}
{t.summary && ( {t.summary && (
<span className="recent-card__summary"> <span className="recent-card__summary">
{t.summary.length > 150 {t.summary.length > 150

View file

@ -11,6 +11,7 @@ import { Link, useSearchParams, useNavigate } from "react-router-dom";
import { searchApi, type SearchResultItem } from "../api/public-client"; import { searchApi, type SearchResultItem } from "../api/public-client";
import { catSlug } from "../utils/catSlug"; import { catSlug } from "../utils/catSlug";
import SearchAutocomplete from "../components/SearchAutocomplete"; import SearchAutocomplete from "../components/SearchAutocomplete";
import TagList from "../components/TagList";
export default function SearchResults() { export default function SearchResults() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@ -142,11 +143,7 @@ function SearchResultCard({ item, staggerIndex }: { item: SearchResultItem; stag
)} )}
{item.topic_tags.length > 0 && ( {item.topic_tags.length > 0 && (
<span className="search-result-card__tags"> <span className="search-result-card__tags">
{item.topic_tags.map((tag) => ( <TagList tags={item.topic_tags} />
<span key={tag} className="pill">
{tag}
</span>
))}
</span> </span>
)} )}
</div> </div>

View file

@ -12,6 +12,7 @@ import {
type TechniqueListItem, type TechniqueListItem,
} from "../api/public-client"; } from "../api/public-client";
import { catSlug } from "../utils/catSlug"; import { catSlug } from "../utils/catSlug";
import TagList from "../components/TagList";
/** Convert a URL slug to a display name: replace hyphens with spaces, title-case. */ /** Convert a URL slug to a display name: replace hyphens with spaces, title-case. */
function slugToDisplayName(slug: string): string { function slugToDisplayName(slug: string): string {
@ -146,9 +147,7 @@ export default function SubTopicPage() {
<span className="subtopic-technique-card__title">{t.title}</span> <span className="subtopic-technique-card__title">{t.title}</span>
{t.topic_tags && t.topic_tags.length > 0 && ( {t.topic_tags && t.topic_tags.length > 0 && (
<span className="subtopic-technique-card__tags"> <span className="subtopic-technique-card__tags">
{t.topic_tags.map((tag) => ( <TagList tags={t.topic_tags} />
<span key={tag} className="pill">{tag}</span>
))}
</span> </span>
)} )}
{t.summary && ( {t.summary && (

View file

@ -155,6 +155,19 @@ export default function TopicsBrowse() {
<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, "-"); const stSlug = st.name.toLowerCase().replace(/\s+/g, "-");
if (st.technique_count === 0) {
return (
<span
key={st.name}
className="topic-subtopic topic-subtopic--empty"
>
<span className="topic-subtopic__name">{st.name}</span>
<span className="topic-subtopic__counts">
<span className="pill pill--coming-soon">Coming soon</span>
</span>
</span>
);
}
return ( return (
<Link <Link
key={st.name} key={st.name}

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/components/SearchAutocomplete.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","./src/utils/catSlug.ts"],"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/components/SearchAutocomplete.tsx","./src/components/TagList.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","./src/utils/catSlug.ts"],"version":"5.6.3"}