feat(feed): hover infobox on feed cards — full title, description, tags (E8·S3)

This commit is contained in:
2026-08-26 00:44:27 +02:00
parent edcea87ea5
commit fbb961b5b7
5 changed files with 303 additions and 5 deletions
+24 -3
View File
@@ -17,6 +17,7 @@ import {
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist"; import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton"; import DownloadButton from "./DownloadButton";
import VideoInfoCard from "./VideoInfoCard";
import clsx from "clsx"; import clsx from "clsx";
import type { Video, VideoStatus } from "../lib/api"; import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
@@ -105,6 +106,17 @@ function Actions({
); );
} }
// The hover-infobox trigger skins (E8·S3). The ⓘ is hover-revealed and dropped on touch (`reveal-hover`
// + the group opacity toggle), and lives OUTSIDE the six-button action budget — as a thumbnail-corner
// overlay for the thumbnailed views, and inline beside the actions for the one-line row.
const INFO_REVEAL =
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity";
const INFO_OVERLAY = clsx(
INFO_REVEAL,
"absolute bottom-1.5 left-1.5 z-10 rounded-md bg-black/70 p-1 text-white hover:bg-black/85",
);
const INFO_INLINE = clsx(INFO_REVEAL, "rounded-md p-1.5 text-muted hover:bg-surface hover:text-fg");
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click // Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
// keep the native "open youtube.com in a new tab" behavior via the underlying href. // keep the native "open youtube.com in a new tab" behavior via the underlying href.
function openInApp( function openInApp(
@@ -312,6 +324,10 @@ function Thumb({
)} )}
</div> </div>
)} )}
{/* Hover infobox trigger (E8·S3), docked bottom-left so it clears the centered play overlay, the
corner badges (saved / stream, top corners) and the duration (bottom-right). Revealed with the
rest of the hover chrome, dropped on touch. */}
<VideoInfoCard video={video} className={INFO_OVERLAY} />
{/* Resume progress bar (sits at the very bottom, under the duration badge). */} {/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && ( {pct > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40"> <div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
@@ -330,7 +346,7 @@ function Thumb({
// Keep this table and the classes in step; each number is measured against the worst case in the // Keep this table and the classes in step; each number is measured against the worst case in the
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from. // LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
const ROW_COL = { const ROW_COL = {
actions: 192, // w-48 — six buttons at 28 + five 4px gaps = 188 actions: 224, // w-48 (six buttons: 6×28 + 5×4 = 188) + gap-1 + the 28px inline info trigger
channel: 128, // w-32 channel: 128, // w-32
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
views: 64, // w-16 views: 64, // w-16
@@ -508,8 +524,13 @@ function VideoCard({
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")} className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
> >
{/* Actions lead: this is where the eye already is (the title is the thing you read), so {/* Actions lead: this is where the eye already is (the title is the thing you read), so
they're the shortest pointer trip from it. */} they're the shortest pointer trip from it. This row has no thumbnail to host the info
<div className="shrink-0 w-48">{actions}</div> trigger, so it sits inline right after the actions (still outside their six-button
budget). ROW_COL.actions already counts its width. */}
<div className="shrink-0 flex items-center gap-1">
<div className="w-48">{actions}</div>
<VideoInfoCard video={video} className={INFO_INLINE} />
</div>
{/* Everything else is shrink-0, so the title is the only thing that can give — which means {/* Everything else is shrink-0, so the title is the only thing that can give — which means
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
from the right as the row narrows, least important first: a title with no date beats a from the right as the row narrows, least important first: a title with no date beats a
@@ -0,0 +1,130 @@
// @vitest-environment jsdom
//
// Contract of the feed-card hover infobox (E8·S3): the trigger stays closed until opened; on open the
// header (full title + channel) renders IMMEDIATELY from the Video we already hold, while the
// description (videoDetail) and the channel's tags (channelDetail.tag_ids resolved against the tag
// list) fill in from their on-demand queries; an empty description falls back to the "no description"
// line. i18n is key-passthrough and the AnchoredPopover platform is floating-ui (hover mode → open via
// focus), so we stub the observers autoUpdate wires and wrap in a FloatingTree + QueryClient.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
const videoDetail = vi.fn();
const channelDetail = vi.fn();
const tags = vi.fn();
vi.mock("../lib/api", () => ({
api: {
videoDetail: (...a: unknown[]) => videoDetail(...a),
channelDetail: (...a: unknown[]) => channelDetail(...a),
tags: (...a: unknown[]) => tags(...a),
},
}));
import VideoInfoCard from "./VideoInfoCard";
import type { Video } from "../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const video = (over: Partial<Video> = {}): Video =>
({
id: "v1",
title: "The full untruncated title",
channel_id: "c1",
channel_title: "Test Channel",
channel_thumbnail: null,
channel_url: "https://youtube.com/c1",
published_at: "2026-02-24T00:00:00Z",
thumbnail_url: null,
duration_seconds: 754,
view_count: 1512,
is_short: false,
live_status: "none",
status: "new",
position_seconds: 0,
saved: false,
watch_url: "https://youtu.be/v1",
...over,
}) as Video;
const open = (v: Video = video()) => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<VideoInfoCard video={v} className="info-trigger" />
</FloatingTree>
</QueryClientProvider>,
);
fireEvent.focus(screen.getByRole("button", { name: "card.info" }));
};
describe("VideoInfoCard", () => {
it("renders a trigger, panel closed until opened (no fetch at rest)", () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<VideoInfoCard video={video()} className="info-trigger" />
</FloatingTree>
</QueryClientProvider>,
);
expect(screen.getByRole("button", { name: "card.info" })).toBeTruthy();
expect(screen.queryByText("The full untruncated title")).toBeNull();
// `enabled: open` gates the queries — nothing fired while closed.
expect(videoDetail).not.toHaveBeenCalled();
expect(channelDetail).not.toHaveBeenCalled();
});
it("shows the header immediately from the Video, then fills in description + tags on load", async () => {
videoDetail.mockResolvedValue({ id: "v1", description: "A longer description snippet." });
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [1, 3] });
tags.mockResolvedValue([
{ id: 1, name: "English", category: "language" },
{ id: 2, name: "Unused", category: "topic" },
{ id: 3, name: "Astronomy", category: "topic" },
]);
open();
// Header is from the Video prop — present without waiting on any query.
expect(screen.getByText("The full untruncated title")).toBeTruthy();
expect(screen.getByText("Test Channel")).toBeTruthy();
// Description + only the channel's own tags (1 + 3, not 2) once the queries resolve.
expect(await screen.findByText("A longer description snippet.")).toBeTruthy();
expect(await screen.findByText("English")).toBeTruthy();
expect(await screen.findByText("Astronomy")).toBeTruthy();
expect(screen.queryByText("Unused")).toBeNull();
});
it("falls back to the no-description line when the video has none", async () => {
videoDetail.mockResolvedValue({ id: "v1", description: null });
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [] });
tags.mockResolvedValue([]);
open();
expect(await screen.findByText("card.noDescription")).toBeTruthy();
});
});
+143
View File
@@ -0,0 +1,143 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Info } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type Video } from "../lib/api";
import { formatDate, formatDuration, formatViews } from "../lib/format";
import { linkify } from "../lib/linkify";
import Avatar from "./Avatar";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// The feed card's "more than the card face" hover infobox (E8·S3, decision D3): full (un-clamped)
// title, channel, views·duration·date, a description snippet, and the channel's tags. It hangs off a
// small ⓘ button — a thumbnail-corner overlay in the four thumbnailed views, an inline button beside
// the actions in the one-line row (see the caller). It reveals only with the other hover controls on a
// mouse and is dropped entirely on touch (`.reveal-hover`), where a tap already plays/resumes and a
// hover card would only be an invisible trap. Hover mode also opens it on keyboard focus, so it stays
// reachable by Tab.
//
// The card face already carries title/channel/date/duration/views, so the box's real added value is
// the FULL title (the card clamps to 2 lines) plus the description + tags, which aren't in the feed
// list payload. Both are fetched ON DEMAND on open (like AboutCell): the description via
// `videoDetail`, the tags by resolving the channel's `tag_ids` (from `channelDetail`, shared with the
// channel page's cache) against the tag list. Until they land the header still shows from the Video
// we already have, so the box is never empty.
//
// The caller owns the trigger's placement + reveal via `className`: a thumbnail-corner overlay for the
// four thumbnailed views, an inline button beside the actions for the one-line row. The action ROW
// itself is budgeted to exactly six buttons (a seventh wraps in every view — see VideoCard's ROW_COL),
// so the trigger deliberately lives OUTSIDE it.
export default function VideoInfoCard({
video,
className,
}: {
video: Video;
/** Trigger-button classes (placement + hover-reveal + skin). The reveal/touch-suppression rules
* live here so each host can match its surface (overlay vs inline). */
className?: string;
}) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "hover",
placement: "bottom-end",
offset: 6,
// A touch of open intent (so brushing past the button while reaching another control doesn't
// pop it), a longer close grace so the cursor can travel into the interactive card.
hoverDelay: { open: 140, close: 160 },
fitHeight: true,
});
const { data: detail } = useQuery({
queryKey: qk.videoDetail(video.id),
queryFn: () => api.videoDetail(video.id),
enabled: open,
staleTime: 5 * 60_000,
});
const { data: channel } = useQuery({
queryKey: qk.channel(video.channel_id),
queryFn: () => api.channelDetail(video.channel_id),
enabled: open && !!video.channel_id,
staleTime: 5 * 60_000,
});
const { data: tags } = useQuery({
queryKey: qk.tags(),
queryFn: () => api.tags(),
enabled: open,
staleTime: 5 * 60_000,
});
// The channel's tags (`tag_ids`), in the channel's own id order, resolved to names via the tag list.
// These are the channel-tags the channel actually carries; an unknown id (a just-deleted tag) drops.
const tagNames =
channel && tags
? channel.tag_ids
.map((id) => tags.find((tg) => tg.id === id))
.filter((tg): tg is NonNullable<typeof tg> => !!tg)
: [];
const description = detail?.description?.trim();
const metaBits = [
video.view_count != null ? `${formatViews(video.view_count)} ${t("card.views")}` : null,
video.duration_seconds != null ? formatDuration(video.duration_seconds) : null,
video.published_at ? formatDate(video.published_at, i18n.language) : null,
].filter(Boolean);
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// The overlay trigger sits inside the thumbnail's <a> (like the play buttons), so a click must
// both preventDefault (no navigation) and stopPropagation (no card open); harmless inline too.
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
aria-label={t("card.info")}
title={t("card.info")}
className={className}
>
<Info className="w-4 h-4" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={t("card.info")}
className="glass w-[340px] max-w-[92vw] overflow-y-auto rounded-lg px-3.5 py-3 animate-[fadeIn_0.12s_ease]"
style={{ maxHeight: "min(26rem, var(--fit-max-h, 26rem))" }}
>
<p className="text-sm font-medium leading-snug wrap-break-word">{video.title}</p>
<div className="mt-2 flex items-center gap-2">
<Avatar
src={video.channel_thumbnail}
fallback={video.channel_title ?? ""}
className="w-6 h-6 rounded-full shrink-0"
/>
<span className="text-xs text-muted truncate">{video.channel_title}</span>
</div>
{metaBits.length > 0 && <p className="mt-1.5 text-xs text-muted">{metaBits.join(" · ")}</p>}
{description ? (
<p className="mt-2.5 text-xs leading-relaxed text-muted line-clamp-5 wrap-break-word">
{linkify(description)}
</p>
) : detail ? (
<p className="mt-2.5 text-xs italic text-muted">{t("card.noDescription")}</p>
) : null}
{tagNames.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{tagNames.map((tg) => (
<span key={tg.id} className="glass-card px-2 py-0.5 rounded-full text-2xs">
{tg.name}
</span>
))}
</div>
)}
</PopoverSurface>
</>
);
}
+3 -1
View File
@@ -17,5 +17,7 @@
"restart": "Restart", "restart": "Restart",
"play": "Play", "play": "Play",
"stream": "stream", "stream": "stream",
"views": "views" "views": "views",
"info": "Details",
"noDescription": "No description"
} }
+3 -1
View File
@@ -17,5 +17,7 @@
"restart": "Elölről", "restart": "Elölről",
"play": "Lejátszás", "play": "Lejátszás",
"stream": "stream", "stream": "stream",
"views": "megtekintés" "views": "megtekintés",
"info": "Részletek",
"noDescription": "Nincs leírás"
} }