Files
2026-05-26 22:46:00 +02:00

421 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
);