diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2b68764..e875693 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@tanstack/react-query": "^5.51.0", "@tanstack/react-virtual": "^3.14.3", "clsx": "^2.1.1", + "flag-icons": "^7.5.0", "hls.js": "^1.6.16", "i18next": "^23.11.5", "lucide-react": "^0.408.0", @@ -1809,6 +1810,12 @@ "node": ">=8" } }, + "node_modules/flag-icons": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz", + "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==", + "license": "MIT" + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 26c103b..83d361a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@tanstack/react-query": "^5.51.0", "@tanstack/react-virtual": "^3.14.3", "clsx": "^2.1.1", + "flag-icons": "^7.5.0", "hls.js": "^1.6.16", "i18next": "^23.11.5", "lucide-react": "^0.408.0", diff --git a/frontend/src/components/ChannelAbout.tsx b/frontend/src/components/ChannelAbout.tsx index 9e5f2ba..cc19b35 100644 --- a/frontend/src/components/ChannelAbout.tsx +++ b/frontend/src/components/ChannelAbout.tsx @@ -1,13 +1,9 @@ import { useTranslation } from "react-i18next"; import { ExternalLink } from "lucide-react"; import type { ChannelDetail } from "../lib/api"; +import { linkify } from "../lib/linkify"; // --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) --- -// ISO 3166-1 alpha-2 code → 🇺🇸 regional-indicator flag emoji. -function countryFlag(code: string): string { - if (!/^[A-Za-z]{2}$/.test(code)) return ""; - return String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65)); -} function displayName(code: string, lang: string, type: "region" | "language"): string { try { return new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ?? code; @@ -51,7 +47,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) { return ( <> {ch.description ? ( -

{ch.description}

+

{linkify(ch.description)}

) : (

{t("channel.noDescription")}

)} @@ -76,8 +72,12 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) { {ch.country && (
{t("channel.country")} - - {countryFlag(ch.country)} {displayName(ch.country, i18n.language, "region")} + + {/^[A-Za-z]{2}$/.test(ch.country) && ( + // Real SVG flag (flag-icons) — Windows/Chrome don't render flag emoji. + + )} + {displayName(ch.country, i18n.language, "region")}
)} diff --git a/frontend/src/lib/linkify.tsx b/frontend/src/lib/linkify.tsx new file mode 100644 index 0000000..2187c77 --- /dev/null +++ b/frontend/src/lib/linkify.tsx @@ -0,0 +1,36 @@ +import { Fragment, type ReactNode } from "react"; + +// A bare http(s) URL: greedy up to whitespace/<, but don't swallow a trailing sentence +// punctuation mark (so "…see https://x.com/y." links "https://x.com/y", not the dot). +const URL_RE = /(https?:\/\/[^\s<]+[^\s<.,!?;:)\]}"'])/g; + +/** + * Turn bare http(s) URLs inside a plain-text string into clickable links that open in a new tab + * (with a safe rel). Everything else is returned as text, so it composes with `whitespace-pre-wrap` + * (line breaks preserved). Used for channel/video descriptions where links arrive as plain text. + */ +export function linkify(text: string): ReactNode[] { + const out: ReactNode[] = []; + let last = 0; + let key = 0; + URL_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = URL_RE.exec(text)) !== null) { + if (m.index > last) out.push({text.slice(last, m.index)}); + const url = m[0]; + out.push( + + {url} + , + ); + last = m.index + url.length; + } + if (last < text.length) out.push({text.slice(last)}); + return out; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 553b436..c0575ec 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -13,6 +13,7 @@ import { PrefsProvider } from "./components/PrefsProvider"; import { WizardProvider } from "./components/WizardProvider"; import "./i18n"; import "./index.css"; +import "flag-icons/css/flag-icons.min.css"; // Split by top-level route so each entry point is its own chunk: a public /watch share link or a // legal page never downloads the authenticated app bundle, and the app never carries them either.