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
+286
View File
@@ -0,0 +1,286 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Pencil, User, Star, Gem, Check } from "lucide-react";
import { ActressPortraitEditor } from "./ActressPortraitEditor";
import { CategoryIcon } from "./CategoryIcon";
import { useActressSelection } from "./ActressSelectionProvider";
import type { ActressCategory, ActressAllPortraits } from "@/lib/db/queries";
import { portraitUrl } from "@/lib/assetUrls";
import { toggleActressCategory } from "@/app/actions/actressCategories";
import { cn } from "@/lib/utils";
export interface ActressCardData {
id: number;
name: string;
slug: string;
count: number;
portraitPath: string | null;
portraitZoom: number;
portraitOffsetX: number;
portraitOffsetY: number;
categories: ActressCategory[];
portraits: ActressAllPortraits;
}
const PHI = 1.618;
const CARD_W = 240;
const CARD_H_PORTRAIT = Math.round(CARD_W * PHI);
const CARD_W_LANDSCAPE = Math.round(CARD_W * PHI);
const CARD_H_LANDSCAPE = CARD_W;
// Mirror the editor's canonical frame so cqw-based offsets line up.
const FRAME_H = 360;
const CANONICAL_PORTRAIT_W = Math.round(FRAME_H / PHI);
const CANONICAL_LANDSCAPE_W = Math.round(FRAME_H * PHI);
export function ActressCard({
actress,
builtins,
orderedIds,
view = "portrait",
}: {
actress: ActressCardData;
builtins: { favoriteId?: number; vipId?: number };
orderedIds: number[];
view?: "portrait" | "landscape";
}) {
const [editing, setEditing] = useState(false);
const router = useRouter();
const [, start] = useTransition();
const sel = useActressSelection();
const selected = sel.has(actress.id);
const anySelected = sel.ids.size > 0;
const ringCat = actress.categories[0];
const ringColor = ringCat?.color ?? (selected ? "var(--color-cyan)" : null);
// Optimistic per-category overrides. Map<categoryId, on?>; presence in
// `pending` disables the button while the server action is in flight.
const [optimistic, setOptimistic] = useState<Map<number, boolean>>(new Map());
const [pending, setPending] = useState<Set<number>>(new Set());
const isOnInServer = (id: number) => actress.categories.some((c) => c.id === id);
const isOn = (id: number) => optimistic.get(id) ?? isOnInServer(id);
const isFavorite = builtins.favoriteId != null && isOn(builtins.favoriteId);
const isVip = builtins.vipId != null && isOn(builtins.vipId);
// Per-view portrait selection. Landscape uses the L slot strictly —
// no fallback to P1, so missing L portraits surface as the empty
// user-icon state and the user knows to upload one.
const isLandscape = view === "landscape";
const slotData = isLandscape
? {
path: actress.portraits.ph.path,
zoom: actress.portraits.ph.zoom,
offsetX: actress.portraits.ph.offsetX,
offsetY: actress.portraits.ph.offsetY,
slot: "h" as const,
}
: {
path: actress.portraitPath,
zoom: actress.portraitZoom,
offsetX: actress.portraitOffsetX,
offsetY: actress.portraitOffsetY,
slot: "1" as const,
};
// In landscape mode, cards fill their grid column instead of being a
// fixed pixel width — the parent grid controls how many fit per row.
const cardW = isLandscape ? "100%" : CARD_W;
const cardH = isLandscape ? undefined : CARD_H_PORTRAIT;
const hasImg = !!slotData.path;
function toggleCat(e: React.MouseEvent, categoryId: number | undefined) {
e.preventDefault();
e.stopPropagation();
if (categoryId == null) return;
if (pending.has(categoryId)) return; // ignore double-clicks while in flight
const next = !isOn(categoryId);
setOptimistic((m) => new Map(m).set(categoryId, next));
setPending((s) => new Set(s).add(categoryId));
start(async () => {
try {
await toggleActressCategory(actress.id, categoryId);
router.refresh();
} catch (err) {
// Revert the optimistic flip on failure.
console.error("[toggleActressCategory] failed:", err);
setOptimistic((m) => {
const n = new Map(m);
n.delete(categoryId);
return n;
});
} finally {
setPending((s) => {
const n = new Set(s);
n.delete(categoryId);
return n;
});
}
});
}
function handleCheckbox(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) sel.selectRangeTo(actress.id, orderedIds);
else sel.toggle(actress.id);
}
function handleCardClick(e: React.MouseEvent) {
if (anySelected) {
e.preventDefault();
if (e.shiftKey) sel.selectRangeTo(actress.id, orderedIds);
else sel.toggle(actress.id);
}
}
return (
<>
<div
className={cn(
"cover-hero-frame group relative rounded-2xl overflow-hidden glass glass-hover",
anySelected && !selected && "opacity-70 hover:opacity-100",
)}
style={{
width: cardW,
boxShadow: selected && ringColor
? `0 0 0 4px ${ringColor}, 0 0 24px -2px ${ringColor}`
: undefined,
border: "none",
}}
>
<Link href={`/actress/${actress.slug}`} onClick={handleCardClick} className="block">
<div
className="cover-hero-hover relative bg-[var(--color-bg-1)] overflow-hidden"
style={
isLandscape
? { aspectRatio: `${PHI} / 1`, containerType: "inline-size" } // 1.618 : 1
: { height: cardH, containerType: "inline-size" }
}
>
{hasImg ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={portraitUrl({ path: slotData.path!, slug: actress.slug, slot: slotData.slot })}
alt={actress.name}
draggable={false}
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none"
style={{
transform: `translate(-50%, -50%) translate(${slotData.offsetX / (isLandscape ? CANONICAL_LANDSCAPE_W : CANONICAL_PORTRAIT_W) * 100}cqw, ${slotData.offsetY / (isLandscape ? CANONICAL_LANDSCAPE_W : CANONICAL_PORTRAIT_W) * 100}cqw) scale(${slotData.zoom})`,
width: "100cqw",
height: "auto",
}}
/>
) : (
<div className="absolute inset-0 grid place-items-center">
<User className="w-12 h-12 text-[var(--color-fg-muted)]" />
</div>
)}
{actress.categories.length > 0 && (
<div className="absolute top-2 left-2 z-10 flex flex-wrap gap-1 max-w-[60%]">
{actress.categories.map((c) => (
<span
key={c.id}
className="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: c.color ?? "#fff",
border: `1px solid ${c.color ?? "#888"}aa`,
textShadow: "0 1px 2px rgba(0,0,0,0.9)",
}}
title={c.name}
>
<CategoryIcon name={c.icon} className="w-3 h-3" />
{c.name}
</span>
))}
</div>
)}
<div className="absolute inset-x-0 bottom-0 z-10 bg-gradient-to-t from-black/85 via-black/45 to-transparent p-3 pt-10">
<div className="text-base font-medium text-white truncate">{actress.name}</div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)]">
{actress.count} cover{actress.count === 1 ? "" : "s"}
</div>
</div>
</div>
</Link>
<button
type="button"
onClick={handleCheckbox}
aria-label={selected ? "Deselect" : "Select"}
title={selected ? "Deselect (Shift+click for range)" : "Select (Shift+click for range)"}
className={cn(
"absolute top-2 right-2 w-8 h-8 grid place-items-center rounded-md transition-all backdrop-blur-md border-2 z-10",
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>
<div className="absolute bottom-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
{builtins.vipId != null && (
<button
type="button"
onClick={(e) => toggleCat(e, builtins.vipId)}
disabled={builtins.vipId != null && pending.has(builtins.vipId)}
title={isVip ? "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",
isVip
? "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>
)}
{builtins.favoriteId != null && (
<button
type="button"
onClick={(e) => toggleCat(e, builtins.favoriteId)}
disabled={builtins.favoriteId != null && pending.has(builtins.favoriteId)}
title={isFavorite ? "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",
isFavorite
? "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", isFavorite && "fill-amber-200")} />
</button>
)}
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setEditing(true); }}
title="Edit portrait"
className={cn(
"w-8 h-8 grid place-items-center rounded-md cursor-pointer transition-all duration-150",
"bg-black/70 text-white",
"hover:scale-110 hover:ring-2 hover:ring-[var(--color-cyan)] hover:bg-[var(--color-cyan)]/30 hover:text-[var(--color-cyan)] hover:shadow-lg active:scale-95",
)}
>
<Pencil className="w-4 h-4" />
</button>
</div>
</div>
{editing && (
<ActressPortraitEditor
actressId={actress.id}
actressName={actress.name}
initial={actress.portraits}
onClose={() => setEditing(false)}
/>
)}
</>
);
}