Initial commit
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user