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 b01e5949b6
commit fa1fc82d5a
8 changed files with 82 additions and 20 deletions

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