feat(managers): clickable links in descriptions + real SVG country flags

- linkify: bare http(s) URLs inside channel descriptions become clickable links
  (new tab, rel=noopener noreferrer) — new lib/linkify.tsx, used in the shared
  ChannelAboutContent so both the About overlay and the channel detail page get it
- Country flag: the flag EMOJI never renders on Windows/Chrome (showed the small-caps
  'hu' fallback), so swap it for a real self-hosted SVG flag via the flag-icons package;
  applies to the About overlay + the channel detail page
This commit is contained in:
2026-07-19 20:42:28 +02:00
parent 8abb78b0bd
commit b741a8fd28
5 changed files with 53 additions and 8 deletions
+7
View File
@@ -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",
+1
View File
@@ -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",
+8 -8
View File
@@ -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 ? (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{ch.description}</p>
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{linkify(ch.description)}</p>
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
@@ -76,8 +72,12 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
{ch.country && (
<div className="flex gap-2">
<span className="text-muted w-24 shrink-0">{t("channel.country")}</span>
<span>
{countryFlag(ch.country)} {displayName(ch.country, i18n.language, "region")}
<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-sm shrink-0`} aria-hidden />
)}
{displayName(ch.country, i18n.language, "region")}
</span>
</div>
)}
+36
View File
@@ -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(<Fragment key={key++}>{text.slice(last, m.index)}</Fragment>);
const url = m[0];
out.push(
<a
key={key++}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline break-all"
>
{url}
</a>,
);
last = m.index + url.length;
}
if (last < text.length) out.push(<Fragment key={key++}>{text.slice(last)}</Fragment>);
return out;
}
+1
View File
@@ -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.