refactor(managers): full About overlay + center the wider channel content (E4 S2 follow-up)
- The About overlay now shows the FULL channel About (description, external links, country, language, topics, keywords) — the same content as the detail page's About tab, via a new shared ChannelAboutContent component (reused by ChannelPage + AboutCell) - AboutCell fetches the channel detail ON DEMAND on hover (shared [channel,id] react-query cache with the channel page), so the list stays light; the overlay is interactive (scrollable + clickable links) via a small hover-bridge, and closes on page scroll/resize - Center the content: max-w-7xl -> max-w-[96rem] on the channel tables + header, so the wider About table stays symmetric with the header (mx-auto handles the responsive centering) - Topic chips are display-only for now; the future search epic makes them clickable
This commit is contained in:
@@ -1,82 +1,131 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import ChannelAboutContent from "./ChannelAbout";
|
||||||
|
|
||||||
const MARGIN = 8;
|
const MARGIN = 8;
|
||||||
const POP_W = 448; // the overlay's fixed width (px)
|
const POP_W = 480; // the overlay's fixed width (px)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A channel-description cell: one truncated line in the table, with the full text revealed in a
|
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
|
||||||
* glass hover/focus overlay. The overlay is portalled + fixed-positioned so a table's overflow or
|
* (the same content as the detail page's About tab — description, links, country, language, topics,
|
||||||
* stacking context can't clip it. Unlike Tooltip this is always on (not gated by the hints toggle)
|
* keywords) in a portalled glass overlay. The detail is fetched ON DEMAND on hover/focus, sharing
|
||||||
* and much wider, since a channel "About" is a paragraph, not a one-liner. Shared by the
|
* the ["channel", id] react-query cache with the channel page, so the list stays light. The overlay
|
||||||
* subscribed and discovery channel tables.
|
* is interactive (scrollable, links clickable) via a small hover-bridge; it closes on page scroll
|
||||||
|
* (but not when scrolling inside itself) or resize. Shared by the subscribed + discovery tables.
|
||||||
*/
|
*/
|
||||||
export default function AboutCell({ text }: { text: string | null }) {
|
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
|
||||||
const desc = (text ?? "").trim();
|
const preview = (text ?? "").trim();
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
const popRef = useRef<HTMLDivElement>(null);
|
const popRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const [coords, setCoords] = useState<{ left: number; top: number } | null>(null);
|
const [coords, setCoords] = useState<{ left: number; top: number } | null>(null);
|
||||||
|
|
||||||
function show() {
|
const { data: ch } = useQuery({
|
||||||
|
queryKey: ["channel", channelId],
|
||||||
|
queryFn: () => api.channelDetail(channelId),
|
||||||
|
enabled: open && !!channelId,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
function place() {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el || !desc) return;
|
if (!el) return;
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
setCoords({
|
setCoords({
|
||||||
left: Math.min(Math.max(r.left, MARGIN), window.innerWidth - POP_W - MARGIN),
|
left: Math.min(Math.max(r.left, MARGIN), window.innerWidth - POP_W - MARGIN),
|
||||||
top: r.bottom + 6,
|
top: r.bottom + 6,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function hide() {
|
function show() {
|
||||||
setCoords(null);
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = null;
|
||||||
|
}
|
||||||
|
place();
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
// Small grace period so the cursor can travel the gap from the cell to the overlay without it
|
||||||
|
// vanishing — the overlay's own mouseenter cancels it.
|
||||||
|
function scheduleHide() {
|
||||||
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = setTimeout(() => setOpen(false), 160);
|
||||||
|
}
|
||||||
|
function cancelHide() {
|
||||||
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// While open, any scroll or resize would strand the fixed overlay at a stale anchor — close it.
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close on page scroll (the fixed overlay would strand at a stale anchor) or resize — but NOT
|
||||||
|
// when the scroll happens inside the overlay itself (it is scrollable).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!coords) return;
|
if (!open) return;
|
||||||
const close = () => setCoords(null);
|
const onScroll = (e: Event) => {
|
||||||
window.addEventListener("scroll", close, true);
|
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
|
||||||
window.addEventListener("resize", close);
|
setOpen(false);
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", close, true);
|
|
||||||
window.removeEventListener("resize", close);
|
|
||||||
};
|
};
|
||||||
}, [coords]);
|
const onResize = () => setOpen(false);
|
||||||
|
window.addEventListener("scroll", onScroll, true);
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", onScroll, true);
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
// Flip above the anchor if the overlay would run off the bottom of the viewport.
|
// Flip above the anchor if the overlay would run off the bottom of the viewport.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const pop = popRef.current;
|
const pop = popRef.current;
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!coords || !pop || !el) return;
|
if (!open || !coords || !pop || !el) return;
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
const h = pop.offsetHeight;
|
const h = pop.offsetHeight;
|
||||||
if (r.bottom + 6 + h > window.innerHeight - MARGIN && r.top - 6 - h > MARGIN) {
|
if (r.bottom + 6 + h > window.innerHeight - MARGIN && r.top - 6 - h > MARGIN) {
|
||||||
const top = r.top - 6 - h;
|
const top = r.top - 6 - h;
|
||||||
if (Math.abs(top - coords.top) > 0.5) setCoords((c) => (c ? { ...c, top } : c));
|
if (Math.abs(top - coords.top) > 0.5) setCoords((c) => (c ? { ...c, top } : c));
|
||||||
}
|
}
|
||||||
}, [coords]);
|
}, [open, coords, ch]);
|
||||||
|
|
||||||
if (!desc) return <span className="text-muted">—</span>;
|
if (!preview) return <span className="text-muted">—</span>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onMouseEnter={show}
|
onMouseEnter={show}
|
||||||
onMouseLeave={hide}
|
onMouseLeave={scheduleHide}
|
||||||
onFocus={show}
|
onFocus={show}
|
||||||
onBlur={hide}
|
onBlur={scheduleHide}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
|
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
|
||||||
>
|
>
|
||||||
{desc}
|
{preview}
|
||||||
{coords &&
|
{open &&
|
||||||
|
coords &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
ref={popRef}
|
ref={popRef}
|
||||||
role="tooltip"
|
onMouseEnter={cancelHide}
|
||||||
|
onMouseLeave={scheduleHide}
|
||||||
style={{ position: "fixed", left: coords.left, top: coords.top, width: POP_W }}
|
style={{ position: "fixed", left: coords.left, top: coords.top, width: POP_W }}
|
||||||
className="glass pointer-events-none z-tooltip max-h-[24rem] overflow-hidden px-3 py-2 rounded-lg text-xs leading-relaxed text-fg whitespace-pre-wrap break-words animate-[fadeIn_0.12s_ease]"
|
className="glass z-tooltip max-h-[28rem] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||||
>
|
>
|
||||||
{desc}
|
{/* Full About once the detail loads; until then the list's truncated description. */}
|
||||||
|
{ch ? (
|
||||||
|
<ChannelAboutContent ch={ch} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{preview}</p>
|
||||||
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import type { ChannelDetail } from "../lib/api";
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
} 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="text-sm whitespace-pre-wrap break-words leading-relaxed">{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>
|
||||||
|
{countryFlag(ch.country)} {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) => (
|
||||||
|
<span key={tp} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{tp}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</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) => (
|
||||||
|
<span key={i} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{kw}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -99,7 +99,7 @@ export default function ChannelDiscovery({
|
|||||||
key: "about",
|
key: "about",
|
||||||
header: t("channels.cols.about"),
|
header: t("channels.cols.about"),
|
||||||
hideInCard: true,
|
hideInCard: true,
|
||||||
render: (c) => <AboutCell text={c.description} />,
|
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
|
||||||
},
|
},
|
||||||
subsColumn<DiscoveredChannel>(t),
|
subsColumn<DiscoveredChannel>(t),
|
||||||
{
|
{
|
||||||
@@ -165,11 +165,11 @@ export default function ChannelDiscovery({
|
|||||||
<>
|
<>
|
||||||
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
|
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
|
||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
<div className="px-4 pt-3 max-w-7xl mx-auto">
|
<div className="px-4 pt-3 max-w-[96rem] mx-auto">
|
||||||
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
|
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
|
||||||
</div>
|
</div>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
<div className="px-4 pb-4 max-w-7xl mx-auto">
|
<div className="px-4 pb-4 max-w-[96rem] mx-auto">
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
|
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -180,7 +180,7 @@ export default function ChannelDiscovery({
|
|||||||
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
||||||
controlsPosition="both"
|
controlsPosition="both"
|
||||||
controlsInBand
|
controlsInBand
|
||||||
controlsBandClassName="px-4 max-w-7xl mx-auto"
|
controlsBandClassName="px-4 max-w-[96rem] mx-auto"
|
||||||
emptyText={t("channels.discovery.empty")}
|
emptyText={t("channels.discovery.empty")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useNavigationActions } from "./NavigationProvider";
|
|||||||
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
|
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import Feed from "./Feed";
|
import Feed from "./Feed";
|
||||||
|
import ChannelAboutContent from "./ChannelAbout";
|
||||||
import { PageToolbar, PageToolbarSlot } from "./PageShell";
|
import { PageToolbar, PageToolbarSlot } from "./PageShell";
|
||||||
import { useFeedFilters } from "./FeedFiltersProvider";
|
import { useFeedFilters } from "./FeedFiltersProvider";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
@@ -14,39 +15,6 @@ import { api, type FeedFilters, type Me } from "../lib/api";
|
|||||||
import type { FeedView } from "../lib/feedView";
|
import type { FeedView } from "../lib/feedView";
|
||||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||||
|
|
||||||
// --- About-tab metadata helpers ---
|
|
||||||
// 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;
|
|
||||||
} 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];
|
|
||||||
}
|
|
||||||
|
|
||||||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
||||||
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
||||||
@@ -209,10 +177,6 @@ export default function ChannelPage({
|
|||||||
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
|
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
|
||||||
joined ? t("channel.joined", { date: joined }) : null,
|
joined ? t("channel.joined", { date: joined }) : null,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
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;
|
|
||||||
const tabClass = (active: boolean) =>
|
const tabClass = (active: boolean) =>
|
||||||
active
|
active
|
||||||
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
|
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
|
||||||
@@ -422,69 +386,11 @@ export default function ChannelPage({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-4 sm:px-6 py-4 max-w-3xl">
|
<div className="px-4 sm:px-6 py-4 max-w-3xl">
|
||||||
{ch?.description ? (
|
{ch ? (
|
||||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{ch.description}</p>
|
<ChannelAboutContent ch={ch} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted">{t("channel.noDescription")}</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>
|
|
||||||
{countryFlag(ch.country)} {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">
|
|
||||||
{topics.map((tp) => (
|
|
||||||
<span key={tp} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
|
||||||
{tp}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</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) => (
|
|
||||||
<span key={i} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
|
||||||
{kw}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PageToolbarSlot.Provider>
|
</PageToolbarSlot.Provider>
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ export default function Channels() {
|
|||||||
key: "about",
|
key: "about",
|
||||||
header: t("channels.cols.about"),
|
header: t("channels.cols.about"),
|
||||||
hideInCard: true,
|
hideInCard: true,
|
||||||
render: (c) => <AboutCell text={c.description} />,
|
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "stored",
|
key: "stored",
|
||||||
@@ -464,7 +464,7 @@ export default function Channels() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tabs = (
|
const tabs = (
|
||||||
<div className="px-4 pt-4 max-w-7xl mx-auto flex items-center justify-between gap-4 flex-wrap">
|
<div className="px-4 pt-4 max-w-[96rem] mx-auto flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
|
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
|
||||||
<button
|
<button
|
||||||
@@ -492,7 +492,7 @@ export default function Channels() {
|
|||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
{tabs}
|
{tabs}
|
||||||
{view === "subscribed" && (
|
{view === "subscribed" && (
|
||||||
<div className="px-4 pt-2 pb-2 max-w-7xl mx-auto">
|
<div className="px-4 pt-2 pb-2 max-w-[96rem] mx-auto">
|
||||||
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
|
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
|
||||||
search lives in the shared top SearchBar, tag create/delete in the manager. */}
|
search lives in the shared top SearchBar, tag create/delete in the manager. */}
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
@@ -555,7 +555,7 @@ export default function Channels() {
|
|||||||
/>
|
/>
|
||||||
) : view === "blocked" ? (
|
) : view === "blocked" ? (
|
||||||
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
||||||
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
|
<div className="px-4 pb-4 pt-3 max-w-[96rem] mx-auto">
|
||||||
{(blockedQuery.data?.length ?? 0) === 0 ? (
|
{(blockedQuery.data?.length ?? 0) === 0 ? (
|
||||||
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
|
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -592,7 +592,7 @@ export default function Channels() {
|
|||||||
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
|
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
|
||||||
`position: sticky` engages on the first scroll (the band's controls row above gives the
|
`position: sticky` engages on the first scroll (the band's controls row above gives the
|
||||||
visual gap). */
|
visual gap). */
|
||||||
<div className="px-4 pb-4 max-w-7xl mx-auto">
|
<div className="px-4 pb-4 max-w-[96rem] mx-auto">
|
||||||
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
|
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
|
||||||
(status chips + pager) rides in the fixed band right above it (controlsInBand). */}
|
(status chips + pager) rides in the fixed band right above it (controlsInBand). */}
|
||||||
{channelsQuery.isLoading ? (
|
{channelsQuery.isLoading ? (
|
||||||
@@ -606,7 +606,7 @@ export default function Channels() {
|
|||||||
controlsPosition="both"
|
controlsPosition="both"
|
||||||
controlsLeading={statusChips}
|
controlsLeading={statusChips}
|
||||||
controlsInBand
|
controlsInBand
|
||||||
controlsBandClassName="px-4 max-w-7xl mx-auto"
|
controlsBandClassName="px-4 max-w-[96rem] mx-auto"
|
||||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user