)}
);
}
function VideoCard({
video,
view,
onState,
onResetState,
onToggleSave,
onOpenChannel,
onOpen,
}: {
video: Video;
view: "grid" | "list";
onState: (id: string, status: string) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onOpenChannel?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
}) {
const { t, i18n } = useTranslation();
const watched = video.status === "watched";
const meta = (
<>
{video.view_count != null && (
<>
{formatViews(video.view_count)} {t("card.views")}
{" "}
·{" "}
>
)}
{relativeTime(video.published_at)}
{video.published_at && <>{" · "}{formatDate(video.published_at, i18n.language)}>}
>
);
// Show the full title in a native tooltip only when it's clamped (overflows its 2 lines).
const titleRef = useRef(null);
const [titleClamped, setTitleClamped] = useState(false);
useEffect(() => {
const el = titleRef.current;
if (!el) return;
const check = () => setTitleClamped(el.scrollHeight > el.clientHeight + 1);
check();
const ro = new ResizeObserver(check);
ro.observe(el);
return () => ro.disconnect();
}, [video.title]);
const title = (
openInApp(e, video, onOpen)}
title={titleClamped ? video.title ?? undefined : undefined}
className="font-medium leading-snug line-clamp-2 hover:text-accent"
>
{video.title}
);
// Title + channel button + meta — identical in both layouts (only their wrapper / Actions placement
// differs). Safe to reuse the same element: exactly one of the two branches below renders.
const textBlock = (
<>
{title}
{meta}
>
);
if (view === "list") {
return (
{textBlock}
);
}
return (
{textBlock}
);
}
// Memoized so appending a feed page only renders the new cards, not every existing
// one (the callbacks from Feed are stable; non-overridden video objects keep their
// identity across renders).
export default memo(VideoCard);