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 { 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,
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
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")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user