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