"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; presence in // `pending` disables the button while the server action is in flight. const [optimistic, setOptimistic] = useState>(new Map()); const [pending, setPending] = useState>(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 ( <>
{hasImg ? ( /* eslint-disable-next-line @next/next/no-img-element */ {actress.name} ) : (
)} {actress.categories.length > 0 && (
{actress.categories.map((c) => ( {c.name} ))}
)}
{actress.name}
{actress.count} cover{actress.count === 1 ? "" : "s"}
{builtins.vipId != null && ( )} {builtins.favoriteId != null && ( )}
{editing && ( setEditing(false)} /> )} ); }