Initial commit
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user