feat(feed): hover infobox on feed cards — full title, description, tags (E8·S3)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,5 +17,7 @@
|
||||
"restart": "Restart",
|
||||
"play": "Play",
|
||||
"stream": "stream",
|
||||
"views": "views"
|
||||
"views": "views",
|
||||
"info": "Details",
|
||||
"noDescription": "No description"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user