Files
siftlode/frontend/src/components/ChannelAbout.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

117 lines
4.6 KiB
TypeScript

import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import type { ChannelDetail } from "../lib/api";
import { linkify } from "../lib/linkify";
import { Badge } from "./ui/Badge";
import { languageName, regionName, vocab } from "../lib/vocab";
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play".
function parseKeywords(s: string): string[] {
return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean);
}
// topicCategories are Wikipedia URLs (…/wiki/Music) → the page slug ("Pop_music"), de-duplicated. The
// slug is the vocabulary word: named in the reader's language, else shown with spaces.
function topicSlugs(urls: string[]): string[] {
const seen = new Set<string>();
for (const url of urls) {
const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url;
let slug = seg;
try {
slug = decodeURIComponent(seg);
} catch {
/* keep raw */
}
slug = slug.trim();
if (slug) seen.add(slug);
}
return [...seen];
}
/**
* The channel "About" content — description, external links, and the enriched metadata
* (country, language, topics, keywords). Shared by the channel detail page's About tab and the
* Channel manager's About-column hover overlay, so both show exactly the same thing.
*/
export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
const { t, i18n } = useTranslation();
const topics = ch.topic_categories?.length ? topicSlugs(ch.topic_categories) : [];
const keywords = ch.keywords ? parseKeywords(ch.keywords) : [];
const hasAboutDetails =
!!ch.country || !!ch.default_language || topics.length > 0 || keywords.length > 0;
return (
<>
{ch.description ? (
<p className="measure text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
{linkify(ch.description)}
</p>
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
{ch.external_links && ch.external_links.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{ch.external_links.map((l, i) => (
<a
key={i}
href={l.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-accent hover:underline"
>
<ExternalLink className="w-3.5 h-3.5" />
{l.title || l.url}
</a>
))}
</div>
)}
{hasAboutDetails && (
<div className="mt-5 space-y-2.5 text-sm border-t border-border pt-4">
{ch.country && (
<div className="flex gap-2">
<span className="text-muted w-24 shrink-0">{t("channel.country")}</span>
<span className="inline-flex items-center gap-2">
{/^[A-Za-z]{2}$/.test(ch.country) && (
// Real SVG flag (flag-icons) — Windows/Chrome don't render flag emoji.
<span
className={`fi fi-${ch.country.toLowerCase()} rounded-xs shrink-0`}
aria-hidden
/>
)}
{regionName(t, ch.country, i18n.language)}
</span>
</div>
)}
{ch.default_language && (
<div className="flex gap-2">
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
<span>{languageName(ch.default_language, i18n.language, ch.default_language)}</span>
</div>
)}
{topics.length > 0 && (
<div className="flex gap-2">
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.topics")}</span>
<div className="flex flex-wrap gap-1.5">
{/* Future search epic: these topic chips become clickable filters. */}
{topics.map((tp) => (
<Badge key={tp}>{vocab(t, "topic", tp, tp.replace(/_/g, " "))}</Badge>
))}
</div>
</div>
)}
{keywords.length > 0 && (
<div className="flex gap-2">
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.keywords")}</span>
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => (
<Badge key={i}>{kw}</Badge>
))}
</div>
</div>
)}
</div>
)}
</>
);
}