Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+420
View File
@@ -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,
);