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 "./ui/Avatar"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; // 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 => !!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 ( <>

{video.title}

{video.channel_title}
{metaBits.length > 0 &&

{metaBits.join(" · ")}

} {description ? (

{linkify(description)}

) : detail ? (

{t("card.noDescription")}

) : null} {tagNames.length > 0 && (
{tagNames.map((tg) => ( {tg.name} ))}
)}
); }