404 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|