import { useLayoutEffect } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { api, type Video } from "../lib/api"; import { formatDate, formatDuration, formatCompact } from "../lib/format"; import { linkify } from "../lib/linkify"; import Avatar from "./ui/Avatar"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { SURFACE } from "./ui/surfaces"; import { Badge } from "./ui/Badge"; import { useTags } from "../lib/useTags"; // A video's details (E8·S3, decision D3): full (un-clamped) title, channel, views·duration·date, a // description snippet, and the channel's tags. E28 C1 moved it from a hover ⓘ on the thumbnail into // the video's action list ("Details"): the hover card was invisible on touch and one more button in // the card's hover row, and the menu is the one door every device has. It opens anchored to the card // the menu came from, as a small dialog. // // 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. export default function VideoDetailsAt({ video, anchor, onClose, }: { video: Video; anchor: Element; onClose: () => void; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; const pop = useAnchoredPopover({ open: true, onOpenChange: (next) => { if (!next) onClose(); }, mode: "dialog", role: "dialog", placement: "bottom-start", offset: 6, fitHeight: true, }); const { setPositionReference } = pop.refs; useLayoutEffect(() => setPositionReference(anchor), [anchor, setPositionReference]); const { data: detail } = useQuery({ queryKey: qk.videoDetail(video.id), queryFn: () => api.videoDetail(video.id), staleTime: 5 * 60_000, }); const { data: channel } = useQuery({ queryKey: qk.channel(video.channel_id), queryFn: () => api.channelDetail(video.channel_id), enabled: !!video.channel_id, staleTime: 5 * 60_000, }); const { data: tags } = useTags({ 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 ? `${formatCompact(video.view_count, lang)} ${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} ))}
)}
); }