Files
pinkudex/components/actress/ActressHero.tsx
T
2026-05-26 22:46:00 +02:00

404 lines
14 KiB
TypeScript

"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Pencil, User, Image as ImageIcon, ArrowLeft } from "lucide-react";
import { ActressPortraitEditor } from "./ActressPortraitEditor";
import { ActressMetaEditor } from "./ActressMetaEditor";
import { CategoryIcon } from "./CategoryIcon";
import { buildAltNameChips } from "@/lib/jav/nameUtils";
import type { ActressCategory, ActressAllPortraits, PortraitSlotKey } from "@/lib/db/queries";
import { toggleActressCategory } from "@/app/actions/actressCategories";
import { reorderActressPortraitSlots } from "@/app/actions/actressPortrait";
import { portraitUrl } from "@/lib/assetUrls";
interface Props {
actress: {
id: number;
name: string;
slug: string;
altNames: string | null;
notes: string | null;
portraits: ActressAllPortraits;
categories: ActressCategory[];
bornOn: string | null;
heightCm: number | null;
weightKg: number | null;
cupSize: string | null;
};
coverCount: number;
allCategories: ActressCategory[];
}
function computeAge(bornOn: string | null): number | null {
if (!bornOn) return null;
const d = new Date(bornOn);
if (Number.isNaN(d.getTime())) return null;
const now = new Date();
let age = now.getFullYear() - d.getFullYear();
const m = now.getMonth() - d.getMonth();
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--;
return age >= 0 ? age : null;
}
const PHI = 1.618;
const FRAME_H = 308;
const PORTRAIT_W = Math.floor(FRAME_H / PHI);
const HORIZ_W = Math.floor(FRAME_H * PHI);
const SLOT_LABEL: Record<PortraitSlotKey, string> = { "1": "P1", "2": "P2", "3": "P3", "4": "P4", "h": "L" };
export function ActressHero({ actress, coverCount, allCategories }: Props) {
const [editingMeta, setEditingMeta] = useState(false);
const [editingSlot, setEditingSlot] = useState<PortraitSlotKey | null>(null);
const router = useRouter();
const [, start] = useTransition();
const altChips = buildAltNameChips(actress.name, actress.altNames);
const ringColor = actress.categories[0]?.color ?? null;
const activeIds = new Set(actress.categories.map((c) => c.id));
const orderedCategories = (() => {
const list = [...allCategories];
const vipIdx = list.findIndex((c) => c.slug === "vip");
const favIdx = list.findIndex((c) => c.slug === "favorite");
if (vipIdx !== -1 && favIdx !== -1 && vipIdx > favIdx) {
const [vip] = list.splice(vipIdx, 1);
list.splice(favIdx, 0, vip);
}
return list;
})();
function toggleCat(id: number) {
start(async () => {
await toggleActressCategory(actress.id, id);
router.refresh();
});
}
const dragSlotRef = useRef<PortraitSlotKey | null>(null);
const [dragSlot, setDragSlotState] = useState<PortraitSlotKey | null>(null);
function setDragSlot(s: PortraitSlotKey | null) {
dragSlotRef.current = s;
setDragSlotState(s);
}
const [overSlot, setOverSlot] = useState<PortraitSlotKey | null>(null);
const [optimistic, setOptimistic] = useState<ActressAllPortraits | null>(null);
const portraits = optimistic ?? actress.portraits;
useEffect(() => { setOptimistic(null); }, [actress.portraits]);
function handleDrop(target: PortraitSlotKey) {
const src = dragSlotRef.current;
setDragSlot(null);
setOverSlot(null);
if (!src || src === target) return;
if (src === "h" || target === "h") return;
const order: PortraitSlotKey[] = ["1", "2", "3", "4"];
const keyMap: Record<"1" | "2" | "3" | "4", "p1" | "p2" | "p3" | "p4"> = { "1": "p1", "2": "p2", "3": "p3", "4": "p4" };
const arr = order.map((k) => portraits[keyMap[k as "1" | "2" | "3" | "4"]]);
const srcIdx = order.indexOf(src);
const destIdx = order.indexOf(target);
const [moved] = arr.splice(srcIdx, 1);
arr.splice(destIdx, 0, moved);
setOptimistic({ ...portraits, p1: arr[0], p2: arr[1], p3: arr[2], p4: arr[3] });
start(async () => {
await reorderActressPortraitSlots(actress.id, src, target);
router.refresh();
});
}
return (
<>
<div className="flex items-center justify-between mb-4">
<Link
href="/actress"
className="inline-flex items-center gap-1 text-sm text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
>
<ArrowLeft className="w-4 h-4" /> All Actresses
</Link>
<button
type="button"
onClick={() => setEditingMeta(true)}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg glass glass-hover shrink-0"
>
<Pencil className="w-3.5 h-3.5" /> Edit
</button>
</div>
<div
className="glass rounded-2xl p-card mb-6 flex flex-col gap-section"
style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}, 0 0 24px -4px ${ringColor}66` } : undefined}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-3xl font-semibold tracking-tight text-[var(--color-violet)] break-words">{actress.name}</h1>
{altChips.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{altChips.map((c) => (
<span
key={c.value}
title={c.auto ? "Auto-generated reversed name (used for search)" : undefined}
className={`text-xs px-2 py-0.5 rounded-full border font-mono ${
c.auto
? "border-[var(--color-cyan)]/30 text-[var(--color-cyan)] bg-[var(--color-cyan)]/5"
: "border-[var(--color-glass-border)] text-[var(--color-fg-dim)]"
}`}
>
{c.value}
</span>
))}
</div>
)}
</div>
<div className="text-right shrink-0">
<Stat label="Covers" value={coverCount} />
</div>
</div>
<div className="flex flex-wrap gap-3 items-stretch justify-center">
<PortraitFrame
slot="1"
data={portraits.p1}
width={PORTRAIT_W}
height={FRAME_H}
onEdit={() => setEditingSlot("1")}
/>
<PortraitFrame
slot="2"
data={portraits.p2}
width={PORTRAIT_W}
height={FRAME_H}
onEdit={() => setEditingSlot("2")}
/>
<PortraitFrame
slot="3"
data={portraits.p3}
width={PORTRAIT_W}
height={FRAME_H}
onEdit={() => setEditingSlot("3")}
/>
<PortraitFrame
slot="4"
data={portraits.p4}
width={PORTRAIT_W}
height={FRAME_H}
onEdit={() => setEditingSlot("4")}
/>
<PortraitFrame
slot="h"
data={portraits.ph}
width={HORIZ_W}
height={FRAME_H}
onEdit={() => setEditingSlot("h")}
/>
<BioPanel actress={actress} width={PORTRAIT_W} height={FRAME_H} />
</div>
<div className="min-w-0 flex flex-col">
{allCategories.length > 0 && (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-label">Categories</div>
<div className="flex flex-wrap gap-chip">
{orderedCategories.map((c) => {
const active = activeIds.has(c.id);
const color = c.color ?? "#888";
return (
<button
key={c.id}
type="button"
onClick={() => toggleCat(c.id)}
className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full font-mono transition-all"
style={
active
? { background: `${color}25`, color, border: `1px solid ${color}aa` }
: { background: "transparent", color: "var(--color-fg-muted)", border: "1px solid var(--color-glass-border)" }
}
>
<CategoryIcon name={c.icon} className="w-3 h-3" />
{c.name}
</button>
);
})}
</div>
</div>
)}
{actress.notes && (
<p className="text-sm text-[var(--color-fg-dim)] mt-4 leading-relaxed whitespace-pre-wrap max-w-prose">
{actress.notes}
</p>
)}
</div>
</div>
{editingMeta && (
<ActressMetaEditor
actressId={actress.id}
initial={{
name: actress.name,
altNames: actress.altNames,
notes: actress.notes,
bornOn: actress.bornOn,
heightCm: actress.heightCm,
weightKg: actress.weightKg,
cupSize: actress.cupSize,
}}
onClose={() => setEditingMeta(false)}
/>
)}
{editingSlot && (
<ActressPortraitEditor
actressId={actress.id}
actressName={actress.name}
initial={portraits}
initialSlot={editingSlot}
onClose={() => setEditingSlot(null)}
/>
)}
</>
);
function PortraitFrame({
slot,
data,
width,
height,
onEdit,
}: {
slot: PortraitSlotKey;
data: ActressAllPortraits[keyof ActressAllPortraits];
width: number;
height: number;
onEdit: () => void;
}) {
const reorderable = slot !== "h";
const isDragging = dragSlot === slot;
const isDropTarget = reorderable && overSlot === slot && dragSlot !== null && dragSlot !== "h" && dragSlot !== slot;
return (
<div
className="relative shrink-0 rounded-xl overflow-hidden bg-[var(--color-bg-1)] group"
style={{
width,
height,
cursor: reorderable ? "grab" : undefined,
opacity: isDragging ? 0.4 : 1,
outline: isDropTarget ? "2px dashed var(--color-cyan)" : undefined,
outlineOffset: isDropTarget ? "-2px" : undefined,
transition: "opacity 120ms ease",
}}
draggable={reorderable}
onDragStart={(e) => {
if (!reorderable) return;
dragSlotRef.current = slot;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", slot);
requestAnimationFrame(() => setDragSlotState(slot));
}}
onDragEnd={() => { dragSlotRef.current = null; setDragSlotState(null); setOverSlot(null); }}
onDragOver={(e) => {
if (!reorderable) return;
const ds = dragSlotRef.current;
if (ds && ds !== "h" && ds !== slot) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}
}}
onDragEnter={() => { if (reorderable) setOverSlot(slot); }}
onDragLeave={(e) => {
if (e.currentTarget === e.target) setOverSlot((s) => (s === slot ? null : s));
}}
onDrop={(e) => { e.preventDefault(); handleDrop(slot); }}
>
{data.path ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={portraitUrl({ path: data.path!, slug: actress.slug, slot })}
alt=""
draggable={false}
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none"
style={{
transform: `translate(-50%, -50%) translate(${data.offsetX}px, ${data.offsetY}px) scale(${data.zoom})`,
width,
height: "auto",
}}
/>
) : (
<div className="absolute inset-0 grid place-items-center">
<User className="w-12 h-12 text-[var(--color-fg-muted)]" />
</div>
)}
<div className="absolute top-1.5 left-1.5 text-[9px] uppercase tracking-wider font-mono text-white px-1.5 py-0.5 rounded bg-black/60 backdrop-blur-sm pointer-events-none">
{SLOT_LABEL[slot]}
</div>
<div className="absolute inset-0 grid place-items-center bg-black/0 opacity-0 group-hover:opacity-100 group-hover:bg-black/40 transition-all pointer-events-none">
<button
type="button"
onClick={onEdit}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-black/60 text-white text-xs font-medium pointer-events-auto"
draggable={false}
>
<ImageIcon className="w-3.5 h-3.5" /> Edit
</button>
</div>
</div>
);
}
}
function BioPanel({
actress,
width,
height,
}: {
actress: { bornOn: string | null; heightCm: number | null; weightKg: number | null; cupSize: string | null };
width: number;
height: number;
}) {
const age = computeAge(actress.bornOn);
return (
<div
className="shrink-0 rounded-xl bg-[var(--color-bg-1)]/40 border border-[var(--color-glass-border)] p-3 flex flex-col gap-3"
style={{ width, height }}
>
<Section title="Personal">
<BioRow label="Age" value={age != null ? String(age) : null} />
<BioRow label="Born" value={actress.bornOn} />
</Section>
<Section title="Body">
<BioRow label="Height" value={actress.heightCm != null ? `${actress.heightCm} cm` : null} />
<BioRow label="Weight" value={actress.weightKg != null ? `${actress.weightKg} kg` : null} />
<BioRow label="Cup Size" value={actress.cupSize} />
</Section>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)] mb-1.5">{title}</div>
<div className="space-y-1">{children}</div>
</div>
);
}
function BioRow({ label, value }: { label: string; value: string | null }) {
return (
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="text-[var(--color-fg-muted)]">{label}</span>
<span className={`font-mono tabular-nums ${value ? "text-[var(--color-fg)]" : "text-[var(--color-fg-muted)]/40"}`}>
{value ?? "—"}
</span>
</div>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div>
<div className="text-2xl font-mono font-semibold tabular-nums">{value}</div>
<div className="text-[10px] uppercase tracking-wider text-[var(--color-fg-muted)]">{label}</div>
</div>
);
}