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 AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import VideoInfoCard from "./VideoInfoCard";
import clsx from "clsx";
import type { Video, VideoStatus } from "../lib/api";
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
// keep the native "open youtube.com in a new tab" behavior via the underlying href.
function openInApp(
@@ -312,6 +324,10 @@ function Thumb({
)}
</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). */}
{pct > 0 && (
<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
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
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
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
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")}
>
{/* 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. */}
<div className="shrink-0 w-48">{actions}</div>
they're the shortest pointer trip from it. This row has no thumbnail to host the info
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
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
@@ -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",
"play": "Play",
"stream": "stream",
"views": "views"
"views": "views",
"info": "Details",
"noDescription": "No description"
}
+3 -1
View File
@@ -17,5 +17,7 @@
"restart": "Elölről",
"play": "Lejátszás",
"stream": "stream",
"views": "megtekintés"
"views": "megtekintés",
"info": "Részletek",
"noDescription": "Nincs leírás"
}