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:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user