Initial commit
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
"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,
|
||||
);
|
||||
Reference in New Issue
Block a user