feat(managers): About column with hover overlay on both channel tables (E4 S2)

- Backend: add channel description to the shared _channel_summary projection, so both
  the subscribed list and the discovery list carry it (no migration; description is
  already stored from the channels.list call)
- New AboutCell: one truncated line in the table + full text in a portalled, always-on
  glass hover/focus overlay (wider than Tooltip, flips above the anchor near the bottom,
  closes on scroll/resize)
- About column (after Channel) on both the subscribed and discovery tables; hidden in the
  mobile card fallback
This commit is contained in:
2026-07-19 19:35:07 +02:00
parent 669dc3f2a6
commit 9db1d3fba3
7 changed files with 104 additions and 0 deletions
+1
View File
@@ -214,6 +214,7 @@ def _channel_summary(ch: Channel) -> dict:
"thumbnail_url": ch.thumbnail_url,
"subscriber_count": ch.subscriber_count,
"video_count": ch.video_count,
"description": ch.description,
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
const MARGIN = 8;
const POP_W = 448; // 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.
*/
export default function AboutCell({ text }: { text: string | null }) {
const desc = (text ?? "").trim();
const ref = useRef<HTMLSpanElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const [coords, setCoords] = useState<{ left: number; top: number } | null>(null);
function show() {
const el = ref.current;
if (!el || !desc) 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);
}
// While open, any scroll or resize would strand the fixed overlay at a stale anchor — close it.
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);
};
}, [coords]);
// 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;
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]);
if (!desc) return <span className="text-muted"></span>;
return (
<span
ref={ref}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
tabIndex={0}
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
>
{desc}
{coords &&
createPortal(
<div
ref={popRef}
role="tooltip"
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]"
>
{desc}
</div>,
document.body,
)}
</span>
);
}
@@ -9,6 +9,7 @@ import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import Tooltip from "./Tooltip";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import DataTable, { type Column } from "./DataTable";
import { PageToolbar } from "./PageShell";
import { useConfirm } from "./ConfirmProvider";
@@ -94,6 +95,12 @@ export default function ChannelDiscovery({
/>
),
},
{
key: "about",
header: t("channels.cols.about"),
hideInCard: true,
render: (c) => <AboutCell text={c.description} />,
},
subsColumn<DiscoveredChannel>(t),
{
key: "videos",
+7
View File
@@ -27,6 +27,7 @@ import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable";
import { PageToolbar } from "./PageShell";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider";
@@ -301,6 +302,12 @@ export default function Channels() {
/>
),
},
{
key: "about",
header: t("channels.cols.about"),
hideInCard: true,
render: (c) => <AboutCell text={c.description} />,
},
{
key: "stored",
header: t("channels.cols.stored"),
@@ -46,6 +46,7 @@
"cols": {
"priority": "Prio",
"channel": "Channel",
"about": "About",
"stored": "Stored",
"subs": "Subscribers",
"lastUpload": "Last upload",
@@ -46,6 +46,7 @@
"cols": {
"priority": "Prio",
"channel": "Csatorna",
"about": "Leírás",
"stored": "Tárolt",
"subs": "Feliratkozók",
"lastUpload": "Utolsó feltöltés",
+2
View File
@@ -510,6 +510,7 @@ export interface ManagedChannel {
thumbnail_url: string | null;
subscriber_count: number | null;
video_count: number | null;
description: string | null;
stored_videos: number;
last_video_at: string | null;
total_duration_seconds: number;
@@ -535,6 +536,7 @@ export interface DiscoveredChannel {
thumbnail_url: string | null;
subscriber_count: number | null;
video_count: number | null;
description: string | null;
playlist_video_count: number; // how many of the user's playlist videos are from here
playlist_count: number; // across how many of their playlists
details_synced: boolean;