421 lines
17 KiB
TypeScript
421 lines
17 KiB
TypeScript
"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 (
|
||
<>
|
||
<Link
|
||
href={coverHref(image)}
|
||
onClick={handleCardClick}
|
||
onContextMenu={handleContextMenu}
|
||
draggable={false}
|
||
className={cn(
|
||
// No `glass` here — backdrop-filter on every card kills scroll
|
||
// FPS, and the card root is fully covered by the cover image
|
||
// anyway. Inner overlays (badges, pills) keep their blurs.
|
||
"cover-hero-frame group relative flex flex-col justify-end rounded-2xl overflow-hidden bg-[var(--color-glass)] border border-[var(--color-glass-border)] cursor-default transition-shadow hover:border-[var(--color-glass-border-strong)]",
|
||
selected && "ring-2 ring-[var(--color-cyan)] shadow-[var(--shadow-glow-cyan)]",
|
||
anySelected && !selected && "opacity-70 hover:opacity-100",
|
||
)}
|
||
style={{ breakInside: "avoid" } as React.CSSProperties}
|
||
>
|
||
<div className="cover-hero-hover relative">
|
||
{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.
|
||
<div
|
||
className="w-full block transition-transform duration-500 group-hover:scale-[1.02]"
|
||
style={{
|
||
aspectRatio: "373 / 538",
|
||
backgroundImage: `url(${thumbUrl({ thumbPath: image.thumbPath, code: image.code, id: image.id })})`,
|
||
backgroundSize: "auto 100%",
|
||
backgroundPosition: "right center",
|
||
backgroundRepeat: "no-repeat",
|
||
}}
|
||
role="img"
|
||
aria-label={image.title ?? image.code ?? ""}
|
||
/>
|
||
) : (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={thumbUrl({ thumbPath: image.thumbPath, code: image.code, id: image.id })}
|
||
alt={image.title ?? image.code ?? ""}
|
||
loading="lazy"
|
||
draggable={false}
|
||
width={image.width}
|
||
height={image.height}
|
||
className="w-full h-auto block transition-transform duration-500 group-hover:scale-[1.02]"
|
||
/>
|
||
)}
|
||
|
||
{hasVideo && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setPlaying(true); }}
|
||
aria-label="Play video"
|
||
title="Play video"
|
||
className={cn(
|
||
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20 inline-flex items-center justify-center backdrop-blur-md text-white/95 cursor-pointer transition-colors hover:text-[var(--color-cyan)] hover:[animation:play-pulse_1.2s_ease-out_infinite] active:scale-95",
|
||
view === "portrait" ? "w-16 h-11 rounded-md" : "w-20 h-14 rounded-lg",
|
||
)}
|
||
style={{
|
||
background: "rgba(20,20,28,0.75)",
|
||
border: 0,
|
||
boxShadow: "0 6px 16px rgba(0,0,0,0.55), 0 1px 2px rgba(0,0,0,0.5)",
|
||
}}
|
||
>
|
||
<Play className={view === "portrait" ? "w-[18px] h-[18px]" : "w-6 h-6"} style={{ fill: "currentColor" }} />
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
onClick={handleCheckbox}
|
||
aria-label={selected ? "Deselect" : "Select"}
|
||
className={cn(
|
||
"absolute top-3 right-3 z-20 w-8 h-8 rounded-md grid place-items-center transition-all backdrop-blur-md border-2",
|
||
selected
|
||
? "bg-[var(--color-cyan)] border-[var(--color-cyan)] text-black shadow-[var(--shadow-glow-cyan)]"
|
||
: "bg-black/40 border-white/50 text-transparent",
|
||
!selected && !anySelected && "opacity-0 group-hover:opacity-100",
|
||
)}
|
||
>
|
||
<Check className="w-4 h-4" strokeWidth={3} />
|
||
</button>
|
||
|
||
{hasVideo && !vip && !favorite && (
|
||
<span
|
||
className="absolute top-3 left-3 z-10 flex items-center gap-1 text-[11px] uppercase tracking-wider font-mono font-semibold px-2.5 py-0.5 rounded-full bg-black/80 shadow-md"
|
||
style={{
|
||
color: "var(--color-cyan)",
|
||
border: "1px solid color-mix(in oklch, var(--color-cyan) 60%, transparent)",
|
||
textShadow: "0 1px 2px rgba(0,0,0,0.9)",
|
||
}}
|
||
title="Has playable video"
|
||
>
|
||
<Play className="w-3 h-3" style={{ fill: "currentColor" }} /> Video
|
||
</span>
|
||
)}
|
||
{(vip || favorite) && (
|
||
<span
|
||
className="absolute top-3 left-3 z-10 flex items-center gap-1 text-[11px] uppercase tracking-wider font-mono font-semibold px-2.5 py-0.5 rounded-full bg-black/80 backdrop-blur-md shadow-md"
|
||
style={{
|
||
color: vip ? "var(--color-cyan)" : "#fbbf24",
|
||
border: `1px solid ${vip ? "var(--color-cyan)" : "#fbbf24"}aa`,
|
||
textShadow: "0 1px 2px rgba(0,0,0,0.9)",
|
||
}}
|
||
>
|
||
{vip ? <Gem className="w-3 h-3" /> : <Star className="w-3 h-3" style={{ fill: "#fbbf24" }} />}
|
||
{vip ? "VIP" : "Favorite"}
|
||
</span>
|
||
)}
|
||
|
||
<div
|
||
className={cn(
|
||
"absolute right-3 z-20 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity",
|
||
view === "portrait"
|
||
? "bottom-7 flex-col items-end"
|
||
: "bottom-3 flex-row items-center",
|
||
)}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={toggleVip}
|
||
title={vip ? "Unmark VIP" : "Mark VIP"}
|
||
className={cn(
|
||
"w-8 h-8 grid place-items-center rounded-md cursor-pointer transition-all duration-150",
|
||
"hover:scale-110 hover:ring-2 hover:ring-cyan-300 hover:shadow-lg active:scale-95",
|
||
vip
|
||
? "bg-cyan-400/40 text-cyan-200 hover:bg-cyan-400/60"
|
||
: "bg-black/70 text-white hover:bg-cyan-400/30 hover:text-cyan-200",
|
||
)}
|
||
>
|
||
<Gem className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={toggleFavorite}
|
||
title={favorite ? "Unmark Favorite" : "Mark Favorite"}
|
||
className={cn(
|
||
"w-8 h-8 grid place-items-center rounded-md cursor-pointer transition-all duration-150",
|
||
"hover:scale-110 hover:ring-2 hover:ring-amber-300 hover:shadow-lg active:scale-95",
|
||
favorite
|
||
? "bg-amber-400/40 text-amber-200 hover:bg-amber-400/60"
|
||
: "bg-black/70 text-white hover:bg-amber-400/30 hover:text-amber-200",
|
||
)}
|
||
>
|
||
<Star className={cn("w-4 h-4", favorite && "fill-amber-200")} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={toggleWatched}
|
||
title={watched ? "Mark as not watched" : "Mark as watched"}
|
||
className={cn(
|
||
"w-8 h-8 grid place-items-center rounded-md cursor-pointer transition-all duration-150",
|
||
"hover:scale-110 hover:ring-2 hover:ring-emerald-300 hover:shadow-lg active:scale-95",
|
||
watched
|
||
? "bg-emerald-400/40 text-emerald-200 hover:bg-emerald-400/60"
|
||
: "bg-black/70 text-white hover:bg-emerald-400/30 hover:text-emerald-200",
|
||
)}
|
||
>
|
||
{watched ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={toggleOwned}
|
||
title={owned ? "Unmark Owned" : "Mark Owned"}
|
||
className={cn(
|
||
"w-8 h-8 grid place-items-center rounded-md cursor-pointer transition-all duration-150",
|
||
"hover:scale-110 hover:ring-2 hover:ring-violet-300 hover:shadow-lg active:scale-95",
|
||
owned
|
||
? "bg-violet-400/40 text-violet-200 hover:bg-violet-400/60"
|
||
: "bg-black/70 text-white hover:bg-violet-400/30 hover:text-violet-200",
|
||
)}
|
||
>
|
||
<Package className={cn("w-4 h-4", owned && "fill-violet-200/20")} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="absolute inset-x-0 bottom-0 z-10 p-3 pt-12 bg-gradient-to-t from-black/90 via-black/60 to-transparent">
|
||
{image.code && (
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span
|
||
className="text-base uppercase tracking-wider font-mono font-bold text-[var(--color-cyan)] truncate"
|
||
style={{ textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
|
||
>
|
||
{image.code}
|
||
</span>
|
||
{hasSubtitle && (
|
||
<span
|
||
title={hasVideo ? "Has playable video and subtitles" : "Has subtitle file"}
|
||
className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider font-mono font-semibold px-1.5 py-0.5 rounded border border-[var(--color-mint)]/50 bg-black/60 backdrop-blur-md text-[var(--color-mint)] shrink-0"
|
||
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
|
||
>
|
||
<Captions className="w-3 h-3" /> CC
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{image.actresses.length > 0 && (
|
||
<div
|
||
className="text-xs text-white/75 truncate mt-0.5"
|
||
style={{ textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
|
||
>
|
||
{image.actresses.map((a, i) => (
|
||
<span key={a.id}>
|
||
{i > 0 && <span className="text-white/40">, </span>}
|
||
{/* Programmatic navigation rather than <Link>: HTML
|
||
forbids nested <a> elements, and the cover card
|
||
is wrapped in a Link of its own. */}
|
||
<span
|
||
role="link"
|
||
tabIndex={0}
|
||
onClick={(e) => {
|
||
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}
|
||
</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
{menuPos && (
|
||
<ImageContextMenu
|
||
imageIds={menuPos.ids}
|
||
singleHref={menuPos.ids.length > 1 ? null : coverHref(image)}
|
||
x={menuPos.x}
|
||
y={menuPos.y}
|
||
onClose={() => setMenuPos(null)}
|
||
/>
|
||
)}
|
||
{playing && image.code && (
|
||
<VideoPlayerModal
|
||
code={image.code}
|
||
actresses={image.actresses}
|
||
onClose={() => 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,
|
||
);
|