- ui/MediaCard + mediaCardStyles: one frame for every video, Plex title and Home tile; the meta line pinned to the bottom edge, the length a badge on the picture, the media Play only where the click plays, the hover row at the picture's bottom-right (the owner's pick C). - Shelf density: on any strip a one-line title over one meta line; mixed Home shelves are landscape; rails turn content-visibility off (a card's reserved height had become the strip's). Home slot 232px, so the Play and the hover row no longer touch; a landscape shelf takes the small Play. - Home hero is a card (hover row, the whole menu, a touch "…"), three quarters wide and centred; channel / playlist tiles get Play, open and "…"; a show tile acts on its next episode, ⓘ opens the show. - Plex preview: a ~0.9 s mouse rest opens a passive panel with the whole title, facts and synopsis (popover `pointerOnly`); the synopsis and title tooltips are gone. - Compact list row B: the title leads, "…" at the end, toggles over the date on hover. - Media-info layout B (collections, crew, cast beside the facts sidebar; an empty column is not drawn); filtering values are chips there and on the show page; "Cast" over the cast-only strips. - Browse tiles hover like a card; prose capped by the `measure` utility.
125 lines
4.6 KiB
TypeScript
125 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";
|
|
|
|
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
|
|
function displayName(code: string, lang: string, type: "region" | "language"): string {
|
|
try {
|
|
return (
|
|
new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ??
|
|
code
|
|
);
|
|
} catch {
|
|
return code;
|
|
}
|
|
}
|
|
// 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) → a readable label, de-duplicated.
|
|
function topicLabels(urls: string[]): string[] {
|
|
const seen = new Set<string>();
|
|
for (const url of urls) {
|
|
const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url;
|
|
let label = seg;
|
|
try {
|
|
label = decodeURIComponent(seg);
|
|
} catch {
|
|
/* keep raw */
|
|
}
|
|
label = label.replace(/_/g, " ").trim();
|
|
if (label) seen.add(label);
|
|
}
|
|
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 ? topicLabels(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
|
|
/>
|
|
)}
|
|
{displayName(ch.country, i18n.language, "region")}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{ch.default_language && (
|
|
<div className="flex gap-2">
|
|
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
|
|
<span>{displayName(ch.default_language, i18n.language, "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}>{tp}</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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|