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:
2026-07-19 20:25:06 +02:00
parent 9db1d3fba3
commit 8abb78b0bd
5 changed files with 212 additions and 138 deletions
+80 -31
View File
@@ -1,82 +1,131 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout";
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
* glass hover/focus overlay. The overlay is portalled + fixed-positioned so a table's overflow or
* stacking context can't clip it. Unlike Tooltip this is always on (not gated by the hints toggle)
* and much wider, since a channel "About" is a paragraph, not a one-liner. Shared by the
* subscribed and discovery channel tables.
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
* (the same content as the detail page's About tab — description, links, country, language, topics,
* keywords) in a portalled glass overlay. The detail is fetched ON DEMAND on hover/focus, sharing
* the ["channel", id] react-query cache with the channel page, so the list stays light. The overlay
* 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 }) {
const desc = (text ?? "").trim();
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
const preview = (text ?? "").trim();
const ref = useRef<HTMLSpanElement>(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);
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;
if (!el || !desc) return;
if (!el) return;
const r = el.getBoundingClientRect();
setCoords({
left: Math.min(Math.max(r.left, MARGIN), window.innerWidth - POP_W - MARGIN),
top: r.bottom + 6,
});
}
function hide() {
setCoords(null);
function show() {
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(() => {
if (!coords) return;
const close = () => setCoords(null);
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
if (!open) return;
const onScroll = (e: Event) => {
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
setOpen(false);
};
}, [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.
useLayoutEffect(() => {
const pop = popRef.current;
const el = ref.current;
if (!coords || !pop || !el) return;
if (!open || !coords || !pop || !el) return;
const r = el.getBoundingClientRect();
const h = pop.offsetHeight;
if (r.bottom + 6 + h > window.innerHeight - MARGIN && r.top - 6 - h > MARGIN) {
const top = r.top - 6 - h;
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 (
<span
ref={ref}
onMouseEnter={show}
onMouseLeave={hide}
onMouseLeave={scheduleHide}
onFocus={show}
onBlur={hide}
onBlur={scheduleHide}
tabIndex={0}
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
>
{desc}
{coords &&
{preview}
{open &&
coords &&
createPortal(
<div
ref={popRef}
role="tooltip"
onMouseEnter={cancelHide}
onMouseLeave={scheduleHide}
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>,
document.body,
)}
+119
View File
@@ -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>
)}
</>
);
}
+4 -4
View File
@@ -99,7 +99,7 @@ export default function ChannelDiscovery({
key: "about",
header: t("channels.cols.about"),
hideInCard: true,
render: (c) => <AboutCell text={c.description} />,
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
},
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). */}
<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>
</div>
</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 ? (
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
) : (
@@ -180,7 +180,7 @@ export default function ChannelDiscovery({
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
controlsPosition="both"
controlsInBand
controlsBandClassName="px-4 max-w-7xl mx-auto"
controlsBandClassName="px-4 max-w-[96rem] mx-auto"
emptyText={t("channels.discovery.empty")}
/>
)}
+3 -97
View File
@@ -5,6 +5,7 @@ import { useNavigationActions } from "./NavigationProvider";
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
import Avatar from "./Avatar";
import Feed from "./Feed";
import ChannelAboutContent from "./ChannelAbout";
import { PageToolbar, PageToolbarSlot } from "./PageShell";
import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
@@ -14,39 +15,6 @@ import { api, type FeedFilters, type Me } from "../lib/api";
import type { FeedView } from "../lib/feedView";
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
// 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,
joined ? t("channel.joined", { date: joined }) : null,
].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) =>
active
? "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">
{ch?.description ? (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{ch.description}</p>
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<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>
)}
</PageToolbarSlot.Provider>
+6 -6
View File
@@ -306,7 +306,7 @@ export default function Channels() {
key: "about",
header: t("channels.cols.about"),
hideInCard: true,
render: (c) => <AboutCell text={c.description} />,
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
},
{
key: "stored",
@@ -464,7 +464,7 @@ export default function Channels() {
);
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">
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
<button
@@ -492,7 +492,7 @@ export default function Channels() {
<PageToolbar>
{tabs}
{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
search lives in the shared top SearchBar, tag create/delete in the manager. */}
<div className="flex items-center justify-between gap-4 flex-wrap">
@@ -555,7 +555,7 @@ export default function Channels() {
/>
) : view === "blocked" ? (
/* 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 ? (
<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
`position: sticky` engages on the first scroll (the band's controls row above gives the
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
(status chips + pager) rides in the fixed band right above it (controlsInBand). */}
{channelsQuery.isLoading ? (
@@ -606,7 +606,7 @@ export default function Channels() {
controlsPosition="both"
controlsLeading={statusChips}
controlsInBand
controlsBandClassName="px-4 max-w-7xl mx-auto"
controlsBandClassName="px-4 max-w-[96rem] mx-auto"
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")}
/>