"use client"; import Link from "next/link"; import { memo, useEffect, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Check, Star, Eye, EyeOff, Gem, Package, Play, Captions } from "lucide-react"; import { useSelection } from "@/components/select/SelectionProvider"; import { ImageContextMenu } from "@/components/grid/ImageContextMenu"; import { cn, coverHref } from "@/lib/utils"; import { thumbUrl } from "@/lib/assetUrls"; import { setWatched, setCoverVip, setCoverFavorite, setCoverOwned } from "@/app/actions/coverMeta"; import { dispatchQueueRemove } from "@/components/queue/watchQueueEvents"; import { useVideoIndex } from "@/components/video/VideoIndexProvider"; import { VideoPlayerModal } from "@/components/video/VideoPlayerModal"; export interface CardImage { id: number; thumbPath: string; width: number; height: number; code: string | null; title: string | null; rating: number | null; watched: boolean; isVip: boolean; isFavorite: boolean; isOwned: boolean; studioName: string | null; actresses: Array<{ id: number; name: string; slug: string }>; /** Mirror of images.has_video — server-rendered fallback so the * play button shows correctly before the client-side video index * provider hydrates. */ hasVideo?: boolean; /** Mirror of images.has_subtitle. Same reason as hasVideo. */ hasSubtitle?: boolean; } function ImageCardImpl({ image, view = "landscape" }: { image: CardImage; view?: "portrait" | "landscape" }) { const sel = useSelection(); const router = useRouter(); const selected = sel.has(image.id); const anySelected = sel.ids.size > 0; // Snapshot imageIds at the moment the context menu opens. Otherwise the // prop array reference changes on every parent re-render, retriggering // the menu's data-fetch effect and risking the menu acting on a // selection that drifted between right-click and click. const [menuPos, setMenuPos] = useState<{ x: number; y: number; ids: number[] } | null>(null); const [watched, setLocalWatched] = useState(image.watched); const [vip, setLocalVip] = useState(image.isVip); const [favorite, setLocalFavorite] = useState(image.isFavorite); const [owned, setLocalOwned] = useState(image.isOwned); const [playing, setPlaying] = useState(false); const videoIdx = useVideoIndex(); // Provider is the live source of truth once it has scanned. Until // then (cold boot of the server before /api/video-status responds) // fall back to the SSR'd flags from the DB so play buttons / CC // chips don't flicker in. const providerReady = videoIdx.lastScannedAt > 0; const hasVideo = providerReady ? videoIdx.hasVideo(image.code) : !!image.hasVideo; const hasSubtitle = providerReady ? videoIdx.hasSubtitle(image.code) : !!image.hasSubtitle; const [, startMutate] = useTransition(); useEffect(() => { setLocalWatched(image.watched); }, [image.watched]); useEffect(() => { setLocalVip(image.isVip); }, [image.isVip]); useEffect(() => { setLocalFavorite(image.isFavorite); }, [image.isFavorite]); useEffect(() => { setLocalOwned(image.isOwned); }, [image.isOwned]); const toggleWatched = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const next = !watched; const prev = watched; setLocalWatched(next); startMutate(async () => { try { await setWatched(image.id, next); if (next) dispatchQueueRemove(image.id); router.refresh(); } catch (err) { setLocalWatched(prev); console.error("[toggleWatched] failed:", err); } }); }; const toggleVip = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const next = !vip; const prevVip = vip; const prevFav = favorite; setLocalVip(next); if (next) setLocalFavorite(false); // mutually exclusive startMutate(async () => { try { await setCoverVip(image.id, next); router.refresh(); } catch (err) { setLocalVip(prevVip); setLocalFavorite(prevFav); console.error("[toggleVip] failed:", err); } }); }; const toggleFavorite = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const next = !favorite; const prevFav = favorite; const prevVip = vip; setLocalFavorite(next); if (next) setLocalVip(false); // mutually exclusive startMutate(async () => { try { await setCoverFavorite(image.id, next); router.refresh(); } catch (err) { setLocalFavorite(prevFav); setLocalVip(prevVip); console.error("[toggleFavorite] failed:", err); } }); }; const toggleOwned = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const next = !owned; const prev = owned; setLocalOwned(next); startMutate(async () => { try { await setCoverOwned(image.id, next); router.refresh(); } catch (err) { setLocalOwned(prev); console.error("[toggleOwned] failed:", err); } }); }; const handleCheckbox = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); sel.toggle(image.id); }; const handleCardClick = (e: React.MouseEvent) => { if (anySelected) { e.preventDefault(); sel.toggle(image.id); } }; const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); const ids = sel.has(image.id) ? Array.from(sel.ids) : [image.id]; setMenuPos({ x: e.clientX, y: e.clientY, ids }); }; return ( <>