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,
)}