"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 ( <>
{view === "portrait" ? ( // JAV covers are composite back+spine+front, ~800×538 with the // front taking the rightmost ~373×538. We crop to that aspect // by anchoring the thumb to the right and scaling to fit // height — pure CSS, no extra fetch.
) : ( // eslint-disable-next-line @next/next/no-img-element {image.title )} {hasVideo && ( )} {hasVideo && !vip && !favorite && ( Video )} {(vip || favorite) && ( {vip ? : } {vip ? "VIP" : "Favorite"} )}
{image.code && (
{image.code} {hasSubtitle && ( CC )}
)} {image.actresses.length > 0 && (
{image.actresses.map((a, i) => ( {i > 0 && , } {/* Programmatic navigation rather than : HTML forbids nested elements, and the cover card is wrapped in a Link of its own. */} { e.preventDefault(); e.stopPropagation(); router.push(`/actress/${a.slug}`); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); router.push(`/actress/${a.slug}`); } }} className="cursor-pointer hover:text-[var(--color-violet)] hover:underline underline-offset-2" > {a.name} ))}
)}
{menuPos && ( 1 ? null : coverHref(image)} x={menuPos.x} y={menuPos.y} onClose={() => setMenuPos(null)} /> )} {playing && image.code && ( setPlaying(false)} /> )} ); } // Memoized so cards don't re-render on every Virtuoso scroll tick or // parent state change. Equality is shallow on `image` + `view` — the // card's mutable state (selection, watched, vip, etc.) is held inside // the component itself, so a stable `image` reference + same `view` // safely skip the re-render. export const ImageCard = memo(ImageCardImpl, (a, b) => a.view === b.view && a.image === b.image, );