Initial commit
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Minus, X, Loader2, Sparkles } from "lucide-react";
|
||||
import { CategoryIcon } from "./CategoryIcon";
|
||||
import { useActressSelection } from "./ActressSelectionProvider";
|
||||
import {
|
||||
bulkAddCategory,
|
||||
bulkRemoveCategory,
|
||||
createActressCategory,
|
||||
} from "@/app/actions/actressCategories";
|
||||
import type { ActressCategory } from "@/lib/db/queries";
|
||||
|
||||
const PALETTE = ["#fbbf24", "#22d3ee", "#a78bfa", "#f472b6", "#34d399", "#fb7185", "#f97316", "#60a5fa"];
|
||||
|
||||
export function ActressBulkBar({ categories }: { categories: ActressCategory[] }) {
|
||||
const sel = useActressSelection();
|
||||
const router = useRouter();
|
||||
const [, start] = useTransition();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [openMenu, setOpenMenu] = useState<"add" | "remove" | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newColor, setNewColor] = useState(PALETTE[0]);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Click outside to close menus.
|
||||
useEffect(() => {
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpenMenu(null);
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
if (openMenu || creating) document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [openMenu, creating]);
|
||||
|
||||
const empty = sel.ids.size === 0;
|
||||
const selectedIds = Array.from(sel.ids);
|
||||
|
||||
function runAdd(categoryId: number) {
|
||||
setBusy(true);
|
||||
setOpenMenu(null);
|
||||
start(async () => {
|
||||
try {
|
||||
await bulkAddCategory(selectedIds, categoryId);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function runRemove(categoryId: number) {
|
||||
setBusy(true);
|
||||
setOpenMenu(null);
|
||||
start(async () => {
|
||||
try {
|
||||
await bulkRemoveCategory(selectedIds, categoryId);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createAndAdd() {
|
||||
if (busy) return;
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const created = await createActressCategory({ name, color: newColor, icon: "tag", priority: 50 });
|
||||
if (created) {
|
||||
await bulkAddCategory(selectedIds, created.id);
|
||||
router.refresh();
|
||||
}
|
||||
setCreating(false);
|
||||
setNewName("");
|
||||
setOpenMenu(null);
|
||||
} catch (err) {
|
||||
console.error("[createAndAdd] failed:", err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
aria-hidden={empty}
|
||||
className={`flex items-center gap-2 px-2.5 py-1 rounded-full border border-[var(--color-cyan)]/40 bg-[var(--color-cyan)]/5 transition-opacity ${empty ? "invisible" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-1">
|
||||
<Sparkles className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
|
||||
<span className="text-xs font-medium tabular-nums">{sel.ids.size}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">selected</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpenMenu(openMenu === "add" ? null : "add"); setCreating(false); }}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-full glass glass-hover disabled:opacity-50"
|
||||
>
|
||||
{busy && openMenu !== "remove" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
|
||||
Add
|
||||
</button>
|
||||
{openMenu === "add" && (
|
||||
<Menu>
|
||||
{categories.map((c) => (
|
||||
<MenuItem key={c.id} onClick={() => runAdd(c.id)} icon={<CategoryIcon name={c.icon} className="w-3.5 h-3.5" />} color={c.color}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
<div className="border-t border-[var(--color-glass-border)] my-1" />
|
||||
{creating ? (
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") createAndAdd(); if (e.key === "Escape") setCreating(false); }}
|
||||
placeholder="Category name"
|
||||
maxLength={32}
|
||||
className="w-full glass rounded-md px-2 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)]"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{PALETTE.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setNewColor(p)}
|
||||
className={`w-5 h-5 rounded-full border-2 ${newColor === p ? "border-white" : "border-transparent"}`}
|
||||
style={{ background: p }}
|
||||
aria-label={`Color ${p}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(false)}
|
||||
className="flex-1 text-xs px-2 py-1.5 rounded-md glass glass-hover"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={createAndAdd}
|
||||
disabled={!newName.trim() || busy}
|
||||
className="flex-1 text-xs px-2 py-1.5 rounded-md bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
|
||||
>
|
||||
Create & Assign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
className="w-full flex items-center gap-2 text-xs px-3 py-2 hover:bg-[var(--color-glass)] text-[var(--color-cyan)]"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> New Category…
|
||||
</button>
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpenMenu(openMenu === "remove" ? null : "remove"); setCreating(false); }}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-full glass glass-hover disabled:opacity-50"
|
||||
>
|
||||
{busy && openMenu === "remove" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Minus className="w-3.5 h-3.5" />}
|
||||
Remove
|
||||
</button>
|
||||
{openMenu === "remove" && (
|
||||
<Menu>
|
||||
{categories.map((c) => (
|
||||
<MenuItem key={c.id} onClick={() => runRemove(c.id)} icon={<CategoryIcon name={c.icon} className="w-3.5 h-3.5" />} color={c.color}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sel.clear()}
|
||||
title="Clear selection"
|
||||
className="flex items-center gap-1 text-xs px-2 py-1 rounded-full text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Menu({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="absolute top-full mt-2 left-0 min-w-[220px] rounded-xl border border-[var(--color-glass-border)] bg-[var(--color-bg-0)] shadow-2xl py-1 overflow-hidden max-h-[60vh] overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
icon, color, children, onClick,
|
||||
}: { icon?: React.ReactNode; color?: string | null; children: React.ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-2 text-sm px-3 py-2 hover:bg-[var(--color-glass)] text-left"
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
<span className="shrink-0">{icon}</span>
|
||||
<span className="truncate">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Plus, Upload } from "lucide-react";
|
||||
import { createActressAction } from "@/app/actions/entities";
|
||||
import { ActressImportDialog } from "./ActressImportDialog";
|
||||
|
||||
export function ActressCreateBar() {
|
||||
const [importing, setImporting] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<form action={createActressAction} className="flex items-center gap-2">
|
||||
<input
|
||||
name="name"
|
||||
placeholder="New Actress"
|
||||
required
|
||||
maxLength={80}
|
||||
className="glass rounded-lg px-3 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)] w-56"
|
||||
/>
|
||||
<button className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium">
|
||||
<Plus className="w-4 h-4" /> Create
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImporting(true)}
|
||||
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
|
||||
title="Bulk import a list of actresses"
|
||||
>
|
||||
<Upload className="w-4 h-4" /> Import…
|
||||
</button>
|
||||
</div>
|
||||
{importing && <ActressImportDialog onClose={() => setImporting(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Search, X, CheckSquare, RectangleVertical, RectangleHorizontal } from "lucide-react";
|
||||
import { ActressCard, type ActressCardData } from "./ActressCard";
|
||||
import { CategoryIcon } from "./CategoryIcon";
|
||||
import { ActressSelectionProvider, useActressSelection } from "./ActressSelectionProvider";
|
||||
import { ActressBulkBar } from "./ActressBulkBar";
|
||||
import { reverseName } from "@/lib/jav/nameUtils";
|
||||
import type { ActressCategory } from "@/lib/db/queries";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
||||
const NON_LATIN = "#";
|
||||
|
||||
function bucketFor(name: string): string {
|
||||
const c = name.trim().slice(0, 1).toUpperCase();
|
||||
return c >= "A" && c <= "Z" ? c : NON_LATIN;
|
||||
}
|
||||
|
||||
interface ActressFull extends ActressCardData {
|
||||
altNames?: string | null;
|
||||
}
|
||||
|
||||
export function ActressDirectory(props: { items: ActressFull[]; categories: ActressCategory[] }) {
|
||||
return (
|
||||
<ActressSelectionProvider>
|
||||
<DirectoryInner {...props} />
|
||||
</ActressSelectionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectoryInner({
|
||||
items,
|
||||
categories,
|
||||
}: {
|
||||
items: ActressFull[];
|
||||
categories: ActressCategory[];
|
||||
}) {
|
||||
const sel = useActressSelection();
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeLetter, setActiveLetter] = useState<string | null>(null);
|
||||
// null = ALL, "unassigned" = actresses with no categories, number = category id
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<number | "unassigned" | null>(null);
|
||||
// P (default) uses portraits.p1, L uses portraits.ph (golden landscape).
|
||||
const [view, setView] = useState<"portrait" | "landscape">("portrait");
|
||||
|
||||
const builtins = useMemo(() => ({
|
||||
favoriteId: categories.find((c) => c.slug === "favorite")?.id,
|
||||
vipId: categories.find((c) => c.slug === "vip")?.id,
|
||||
}), [categories]);
|
||||
|
||||
const enriched = useMemo(() => items.map((a) => {
|
||||
const reversed = reverseName(a.name);
|
||||
const altParts = (a.altNames ?? "").split(/[,、,]/).map((s) => s.trim()).filter(Boolean);
|
||||
const haystack = [a.name, reversed ?? "", ...altParts].join(" ").toLowerCase();
|
||||
return { actress: a, haystack, bucket: bucketFor(a.name) };
|
||||
}), [items]);
|
||||
|
||||
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const searched = tokens.length === 0
|
||||
? enriched
|
||||
: enriched.filter(({ haystack }) => tokens.every((t) => haystack.includes(t)));
|
||||
|
||||
const categoryFiltered = activeCategoryId == null
|
||||
? searched
|
||||
: activeCategoryId === "unassigned"
|
||||
? searched.filter(({ actress }) => actress.categories.length === 0)
|
||||
: searched.filter(({ actress }) => actress.categories.some((c) => c.id === activeCategoryId));
|
||||
|
||||
const unassignedCount = useMemo(
|
||||
() => searched.reduce((n, e) => (e.actress.categories.length === 0 ? n + 1 : n), 0),
|
||||
[searched],
|
||||
);
|
||||
|
||||
// Pill display order: ALL · VIP · Favorite · Not Assigned · everything
|
||||
// else. VIP and Favorite are seeded built-ins identified by slug; if
|
||||
// they're absent for any reason we just skip them.
|
||||
const orderedCategories = useMemo(() => {
|
||||
const vip = categories.find((c) => c.slug === "vip");
|
||||
const fav = categories.find((c) => c.slug === "favorite");
|
||||
const rest = categories.filter((c) => c.slug !== "favorite" && c.slug !== "vip");
|
||||
return [...(vip ? [vip] : []), ...(fav ? [fav] : []), ...rest];
|
||||
}, [categories]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const m: Record<string, number> = {};
|
||||
for (const e of categoryFiltered) m[e.bucket] = (m[e.bucket] ?? 0) + 1;
|
||||
return m;
|
||||
}, [categoryFiltered]);
|
||||
|
||||
const categoryCounts = useMemo(() => {
|
||||
const m: Record<number, number> = {};
|
||||
for (const e of searched) {
|
||||
for (const c of e.actress.categories) m[c.id] = (m[c.id] ?? 0) + 1;
|
||||
}
|
||||
return m;
|
||||
}, [searched]);
|
||||
|
||||
const visible = activeLetter
|
||||
? categoryFiltered.filter((e) => e.bucket === activeLetter)
|
||||
: categoryFiltered;
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<string, ActressFull[]> = {};
|
||||
for (const { actress, bucket } of visible) {
|
||||
(groups[bucket] ??= []).push(actress);
|
||||
}
|
||||
for (const k of Object.keys(groups)) {
|
||||
groups[k].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
||||
}
|
||||
return groups;
|
||||
}, [visible]);
|
||||
|
||||
const orderedBuckets = [...LETTERS, NON_LATIN].filter((b) => (grouped[b]?.length ?? 0) > 0);
|
||||
|
||||
// Visible IDs in the exact rendered order — used for shift-click range and "select all visible".
|
||||
const orderedIds = useMemo(() => {
|
||||
const out: number[] = [];
|
||||
for (const b of orderedBuckets) for (const a of grouped[b]) out.push(a.id);
|
||||
return out;
|
||||
}, [orderedBuckets, grouped]);
|
||||
|
||||
const allVisibleSelected = orderedIds.length > 0 && orderedIds.every((id) => sel.has(id));
|
||||
|
||||
const renderCategoryPill = (c: ActressCategory) => {
|
||||
const n = categoryCounts[c.id] ?? 0;
|
||||
const enabled = n > 0;
|
||||
const active = activeCategoryId === c.id;
|
||||
const color = c.color ?? "var(--color-cyan)";
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
onClick={() => setActiveCategoryId(active ? null : c.id)}
|
||||
className={`flex items-center justify-center gap-1.5 text-xs font-mono font-medium px-3 py-1.5 rounded-full border transition-colors min-w-[140px] ${
|
||||
active
|
||||
? "border-transparent"
|
||||
: enabled
|
||||
? "glass glass-hover"
|
||||
: "text-[var(--color-fg-muted)]/40 cursor-not-allowed"
|
||||
}`}
|
||||
style={active ? { background: color, color: "#000" } : undefined}
|
||||
>
|
||||
<CategoryIcon name={c.icon} className="w-3 h-3" />
|
||||
{c.name}
|
||||
{enabled && (
|
||||
<span className={`tabular-nums ${active ? "text-black/70" : "text-[var(--color-fg-muted)]"}`}>{n}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-fg-muted)] pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filter Cast — Name, Reversed, Alt Names…"
|
||||
className="w-full glass rounded-lg pl-9 pr-9 py-2 text-sm outline-none focus:border-[var(--color-cyan)]"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
|
||||
aria-label="Clear filter"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => allVisibleSelected ? sel.clear() : sel.selectMany(orderedIds)}
|
||||
disabled={orderedIds.length === 0}
|
||||
className={`flex items-center justify-center gap-1.5 text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-40 w-[180px] ${
|
||||
allVisibleSelected ? "bg-[var(--color-cyan)] text-black font-medium" : "glass glass-hover"
|
||||
}`}
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5" />
|
||||
{allVisibleSelected ? "Deselect All Visible" : "Select All Visible"}
|
||||
{orderedIds.length > 0 && (
|
||||
<span className={`tabular-nums ${allVisibleSelected ? "text-black/70" : "text-[var(--color-fg-muted)]"}`}>
|
||||
{orderedIds.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div className="flex items-start gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5 flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCategoryId(null)}
|
||||
className={`text-xs font-mono font-medium px-3 py-1.5 rounded-full border transition-colors ${
|
||||
activeCategoryId === null ? "bg-[var(--color-cyan)] text-black border-transparent" : "glass glass-hover"
|
||||
}`}
|
||||
>
|
||||
ALL
|
||||
<span className={`ml-1.5 tabular-nums ${activeCategoryId === null ? "text-black/70" : "text-[var(--color-fg-muted)]"}`}>
|
||||
{searched.length}
|
||||
</span>
|
||||
</button>
|
||||
{orderedCategories
|
||||
.filter((c) => c.slug === "favorite" || c.slug === "vip")
|
||||
.map((c) => renderCategoryPill(c))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={unassignedCount === 0}
|
||||
onClick={() => setActiveCategoryId(activeCategoryId === "unassigned" ? null : "unassigned")}
|
||||
className={`flex items-center justify-center text-xs font-mono font-medium px-3 py-1.5 rounded-full border transition-colors min-w-[140px] ${
|
||||
activeCategoryId === "unassigned"
|
||||
? "bg-[var(--color-coral)] text-black border-transparent"
|
||||
: unassignedCount > 0
|
||||
? "glass glass-hover"
|
||||
: "text-[var(--color-fg-muted)]/40 cursor-not-allowed"
|
||||
}`}
|
||||
title="Actresses with no category assigned"
|
||||
>
|
||||
Not Assigned
|
||||
<span className={`ml-1.5 tabular-nums ${activeCategoryId === "unassigned" ? "text-black/70" : "text-[var(--color-fg-muted)]"}`}>
|
||||
{unassignedCount}
|
||||
</span>
|
||||
</button>
|
||||
{orderedCategories
|
||||
.filter((c) => c.slug !== "favorite" && c.slug !== "vip")
|
||||
.map((c) => {
|
||||
return renderCategoryPill(c);
|
||||
})}
|
||||
</div>
|
||||
<div className="shrink-0 flex justify-end items-center gap-2">
|
||||
<ActressBulkBar categories={categories} />
|
||||
<div className="flex items-center rounded-lg glass overflow-hidden text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("portrait")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2.5 py-1.5",
|
||||
view === "portrait"
|
||||
? "bg-[var(--color-cyan)] text-black font-medium"
|
||||
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
title="Portrait view (default — uses P1 portrait slot)"
|
||||
>
|
||||
<RectangleVertical className="w-3.5 h-3.5" /> P
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("landscape")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2.5 py-1.5",
|
||||
view === "landscape"
|
||||
? "bg-[var(--color-cyan)] text-black font-medium"
|
||||
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
title="Landscape view (uses L portrait slot)"
|
||||
>
|
||||
<RectangleHorizontal className="w-3.5 h-3.5" /> L
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-stretch gap-1 mt-4 mb-4 w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveLetter(null)}
|
||||
className={`flex-1 flex flex-col items-center justify-center font-mono py-2 rounded-lg border transition-colors ${
|
||||
activeLetter === null ? "bg-[var(--color-cyan)] text-black border-transparent" : "glass glass-hover"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base font-semibold leading-none">ALL</span>
|
||||
<span className={`text-[10px] font-semibold tabular-nums mt-0.5 ${activeLetter === null ? "text-black/70" : "text-[var(--color-fg-muted)]"}`}>
|
||||
{categoryFiltered.length}
|
||||
</span>
|
||||
</button>
|
||||
{[...LETTERS, NON_LATIN].map((L) => {
|
||||
const n = counts[L] ?? 0;
|
||||
const enabled = n > 0;
|
||||
const active = activeLetter === L;
|
||||
return (
|
||||
<button
|
||||
key={L}
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
onClick={() => setActiveLetter(active ? null : L)}
|
||||
className={`flex-1 flex flex-col items-center justify-center font-mono py-2 rounded-lg border transition-colors ${
|
||||
active
|
||||
? "bg-[var(--color-cyan)] text-black border-transparent"
|
||||
: enabled
|
||||
? "glass glass-hover"
|
||||
: "border-transparent text-[var(--color-fg-muted)]/40 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base font-semibold leading-none">{L}</span>
|
||||
<span className={`text-[10px] font-semibold tabular-nums mt-0.5 ${
|
||||
active ? "text-black/70" : enabled ? "text-[var(--color-fg-muted)]" : "text-transparent"
|
||||
}`}>
|
||||
{enabled ? n : 0}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{orderedBuckets.length === 0 && (
|
||||
<div className="glass rounded-2xl p-card text-center text-[var(--color-fg-dim)] text-sm">
|
||||
No matches.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeLetter === null ? (
|
||||
<div key={view} className={"fade-in " + (view === "landscape"
|
||||
? "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||
: "flex flex-wrap gap-4")}>
|
||||
{orderedBuckets.flatMap((b) => grouped[b]).map((a) => (
|
||||
<ActressCard key={a.id} actress={a} builtins={builtins} orderedIds={orderedIds} view={view} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div key={view} className="fade-in space-y-8">
|
||||
{orderedBuckets.map((b) => (
|
||||
<section key={b} id={`letter-${b}`} className="scroll-mt-20">
|
||||
<h2 className="text-sm font-mono uppercase tracking-wider text-[var(--color-fg-muted)] mb-3">
|
||||
{b}
|
||||
<span className="ml-2 text-[var(--color-fg-dim)] tabular-nums">{grouped[b].length}</span>
|
||||
</h2>
|
||||
<div className={view === "landscape"
|
||||
? "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||
: "flex flex-wrap gap-4"}>
|
||||
{grouped[b].map((a) => (
|
||||
<ActressCard key={a.id} actress={a} builtins={builtins} orderedIds={orderedIds} view={view} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { X, Upload, Users, AlertCircle, Check, Loader2, Star, Gem } from "lucide-react";
|
||||
import {
|
||||
previewActressImport,
|
||||
commitActressImport,
|
||||
type ImportResult,
|
||||
} from "@/app/actions/actressImport";
|
||||
import { listActressCategoriesAction } from "@/app/actions/actressCategoriesQuery";
|
||||
import type { ActressCategory } from "@/lib/db/queries";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ActressImportDialog({ onClose }: Props) {
|
||||
const router = useRouter();
|
||||
const [text, setText] = useState("");
|
||||
const [preview, setPreview] = useState<ImportResult | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [categories, setCategories] = useState<ActressCategory[]>([]);
|
||||
const [defaultCategoryId, setDefaultCategoryId] = useState<number | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const previewSeq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
listActressCategoriesAction().then(setCategories).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const favoriteCat = categories.find((c) => c.slug === "favorite");
|
||||
const vipCat = categories.find((c) => c.slug === "vip");
|
||||
|
||||
// Debounced preview as the user types.
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
const requestText = text;
|
||||
const requestId = ++previewSeq.current;
|
||||
if (!requestText.trim()) { setPreview(null); setError(null); return; }
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const r = await previewActressImport(requestText);
|
||||
if (previewSeq.current !== requestId) return;
|
||||
setPreview(r);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
if (previewSeq.current !== requestId) return;
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, 300);
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [text]);
|
||||
|
||||
async function onFile(file: File) {
|
||||
const t = await file.text();
|
||||
setText(t);
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (!preview || preview.added === 0) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const defaults = defaultCategoryId != null ? [defaultCategoryId] : [];
|
||||
await commitActressImport(text, defaults);
|
||||
router.refresh();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 top-16 z-50 flex items-center justify-center px-4 py-4 fade-in overflow-y-auto"
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="bg-[var(--color-bg-0)] rounded-2xl border border-[var(--color-glass-border)] shadow-2xl p-5 w-[min(720px,calc(100vw-32px))] max-h-[calc(100vh-120px)] flex flex-col">
|
||||
<div className="flex items-center justify-between mb-3 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-[var(--color-cyan)]" />
|
||||
<div>
|
||||
<div className="text-base font-medium">Import Actresses</div>
|
||||
<div className="text-[11px] text-[var(--color-fg-muted)]">
|
||||
One name per line. Optionally <span className="font-mono">Name | alt names | categories</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-3 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="flex items-center gap-1.5 text-sm px-3 py-2 rounded-lg glass glass-hover"
|
||||
>
|
||||
<Upload className="w-4 h-4" /> Choose File
|
||||
</button>
|
||||
<span className="text-xs text-[var(--color-fg-muted)]">.txt, .csv (one per line)</span>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".txt,.csv,text/plain,text/csv"
|
||||
hidden
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) onFile(f); e.target.value = ""; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={`Ichika Matsumoto\nAiba Reika | 愛葉れいか | Favorite\nYui Hatano | | VIP, Watchlist`}
|
||||
rows={8}
|
||||
className="w-full bg-[var(--color-bg-0)]/40 rounded-lg p-3 text-xs font-mono outline-none border border-[var(--color-glass-border)] focus:border-[var(--color-cyan)] resize-y leading-relaxed shrink-0"
|
||||
/>
|
||||
|
||||
{(favoriteCat || vipCat) && (
|
||||
<div className="flex items-center gap-2 mt-3 shrink-0">
|
||||
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">Mark All As</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDefaultCategoryId((v) => v === null ? null : null)}
|
||||
className={cn(
|
||||
"text-xs px-3 py-1 rounded-full font-mono transition-colors",
|
||||
defaultCategoryId === null ? "bg-[var(--color-cyan)] text-black font-medium" : "glass glass-hover",
|
||||
)}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
{vipCat && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDefaultCategoryId((v) => v === vipCat.id ? null : vipCat.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs px-3 py-1 rounded-full font-mono transition-colors",
|
||||
defaultCategoryId === vipCat.id
|
||||
? "bg-cyan-400/40 text-cyan-100 font-medium ring-1 ring-cyan-300"
|
||||
: "glass glass-hover",
|
||||
)}
|
||||
>
|
||||
<Gem className="w-3 h-3" /> VIP
|
||||
</button>
|
||||
)}
|
||||
{favoriteCat && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDefaultCategoryId((v) => v === favoriteCat.id ? null : favoriteCat.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs px-3 py-1 rounded-full font-mono transition-colors",
|
||||
defaultCategoryId === favoriteCat.id
|
||||
? "bg-amber-400/40 text-amber-100 font-medium ring-1 ring-amber-300"
|
||||
: "glass glass-hover",
|
||||
)}
|
||||
>
|
||||
<Star className="w-3 h-3" /> Favorite
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 flex items-start gap-2 text-xs text-red-300 shrink-0">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" /> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="mt-4 flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex items-center gap-3 text-xs mb-2 shrink-0">
|
||||
<span className="flex items-center gap-1 text-[var(--color-mint)]">
|
||||
<Check className="w-3.5 h-3.5" /> {preview.added} new
|
||||
</span>
|
||||
<span className="text-[var(--color-fg-muted)]">·</span>
|
||||
<span className="text-[var(--color-fg-dim)]">{preview.skipped} already exist</span>
|
||||
{preview.newCategories.length > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--color-fg-muted)]">·</span>
|
||||
<span className="text-[var(--color-cyan)]">
|
||||
will create categories: {preview.newCategories.join(", ")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="glass rounded-xl overflow-y-auto flex-1 min-h-0">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-[var(--color-bg-0)]/95 backdrop-blur">
|
||||
<tr className="text-left text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
|
||||
<th className="px-3 py-2 w-20">Status</th>
|
||||
<th className="px-3 py-2">Name</th>
|
||||
<th className="px-3 py-2">Alt Names</th>
|
||||
<th className="px-3 py-2">Categories</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.lines.filter((l) => l.status !== "blank").map((l, i) => (
|
||||
<tr key={i} className="border-t border-[var(--color-glass-border)]/30">
|
||||
<td className="px-3 py-1.5">
|
||||
{l.status === "new" && <span className="text-[var(--color-mint)]">+ new</span>}
|
||||
{l.status === "exists" && <span className="text-[var(--color-fg-muted)]">skip</span>}
|
||||
{l.status === "error" && <span className="text-red-300">error</span>}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-medium">{l.name}</td>
|
||||
<td className="px-3 py-1.5 text-[var(--color-fg-dim)] font-mono">{l.altNames ?? ""}</td>
|
||||
<td className="px-3 py-1.5 text-[var(--color-fg-dim)] font-mono">{l.categories.join(", ")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-[var(--color-glass-border)] shrink-0">
|
||||
<button onClick={onClose} className="flex-1 text-sm px-3 py-2 rounded-lg glass glass-hover">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={commit}
|
||||
disabled={busy || !preview || preview.added === 0}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-sm px-3 py-2 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
{preview ? `Import ${preview.added} actress${preview.added === 1 ? "" : "es"}` : "Import"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { X, Loader2 } from "lucide-react";
|
||||
import { updateActressMeta } from "@/app/actions/actressMeta";
|
||||
|
||||
interface Props {
|
||||
actressId: number;
|
||||
initial: {
|
||||
name: string;
|
||||
altNames: string | null;
|
||||
notes: string | null;
|
||||
bornOn?: string | null;
|
||||
heightCm?: number | null;
|
||||
weightKg?: number | null;
|
||||
cupSize?: string | null;
|
||||
};
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ActressMetaEditor({ actressId, initial, onClose }: Props) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(initial.name);
|
||||
const [altNames, setAltNames] = useState(initial.altNames ?? "");
|
||||
const [notes, setNotes] = useState(initial.notes ?? "");
|
||||
const [bornOn, setBornOn] = useState(initial.bornOn ?? "");
|
||||
const [height, setHeight] = useState(initial.heightCm != null ? String(initial.heightCm) : "");
|
||||
const [weight, setWeight] = useState(initial.weightKg != null ? String(initial.weightKg) : "");
|
||||
const [cup, setCup] = useState(initial.cupSize ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [, start] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await updateActressMeta(actressId, {
|
||||
name,
|
||||
altNames,
|
||||
notes,
|
||||
bornOn: bornOn || null,
|
||||
heightCm: height ? Number(height) : null,
|
||||
weightKg: weight ? Number(weight) : null,
|
||||
cupSize: cup || null,
|
||||
});
|
||||
router.refresh();
|
||||
onClose();
|
||||
if (r && r.slug) router.push(`/actress/${r.slug}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 top-16 z-50 flex items-center justify-center px-4 py-4 fade-in overflow-y-auto"
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="bg-[var(--color-bg-0)] border border-[var(--color-glass-border)] shadow-2xl rounded-2xl p-5 w-[min(820px,calc(100vw-32px))]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-base font-medium">Edit Actress</div>
|
||||
<button onClick={onClose} className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 items-stretch">
|
||||
<div className="rounded-xl border border-[var(--color-glass-border)] bg-[var(--color-bg-1)]/30 p-4 flex flex-col gap-3">
|
||||
<SectionHeader>Identity</SectionHeader>
|
||||
<Field label="Name">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={80}
|
||||
className="w-full glass rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--color-cyan)]"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Alt Names" hint="comma-separated · used for search (kanji, romaji, nicknames). Reversed name is added automatically.">
|
||||
<input
|
||||
value={altNames}
|
||||
onChange={(e) => setAltNames(e.target.value)}
|
||||
placeholder="松本いちか"
|
||||
className="w-full glass rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--color-cyan)]"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-1">Notes</div>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="flex-1 min-h-[140px] w-full glass rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--color-cyan)] resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[var(--color-glass-border)] bg-[var(--color-bg-1)]/30 p-4 flex flex-col gap-3">
|
||||
<SectionHeader>Personal</SectionHeader>
|
||||
<Field label="Born" hint="YYYY-MM-DD · age is computed from this">
|
||||
<input
|
||||
type="date"
|
||||
value={bornOn}
|
||||
onChange={(e) => setBornOn(e.target.value)}
|
||||
className="w-full glass rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--color-cyan)]"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="pt-2"><SectionHeader>Body</SectionHeader></div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Field label="Height (cm)">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
className="w-full glass rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--color-cyan)]"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Weight (kg)">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
className="w-full glass rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--color-cyan)]"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Cup Size">
|
||||
<input
|
||||
value={cup}
|
||||
onChange={(e) => setCup(e.target.value)}
|
||||
placeholder="C"
|
||||
maxLength={6}
|
||||
className="w-full glass rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--color-cyan)]"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-5 pt-4 border-t border-[var(--color-glass-border)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 text-sm px-3 py-2 rounded-lg glass glass-hover"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={busy || !name.trim()}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-sm px-3 py-2 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : null} Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)]">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-1">{label}</div>
|
||||
{children}
|
||||
{hint && <div className="text-[10px] text-[var(--color-fg-muted)] mt-1">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { X, Upload, Trash2, Loader2, Move, ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
|
||||
import { setActressPortraitTransform, clearActressPortrait } from "@/app/actions/actressPortrait";
|
||||
import type { ActressAllPortraits, PortraitSlotKey } from "@/lib/db/queries";
|
||||
import { portraitUrl } from "@/lib/assetUrls";
|
||||
|
||||
interface Props {
|
||||
actressId: number;
|
||||
actressName: string;
|
||||
initial: ActressAllPortraits;
|
||||
initialSlot?: PortraitSlotKey;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PHI = 1.618;
|
||||
const FRAME_H = 360;
|
||||
const PORTRAIT_H = FRAME_H;
|
||||
const PORTRAIT_W = Math.round(FRAME_H / PHI);
|
||||
const HORIZ_H = FRAME_H;
|
||||
const HORIZ_W = Math.round(FRAME_H * PHI);
|
||||
|
||||
const SLOT_LABELS: Record<PortraitSlotKey, string> = { "1": "P1", "2": "P2", "3": "P3", "4": "P4", "h": "L" };
|
||||
const SLOT_KEYS: PortraitSlotKey[] = ["1", "2", "3", "4", "h"];
|
||||
|
||||
function slotKey(s: PortraitSlotKey): keyof ActressAllPortraits {
|
||||
return s === "h" ? "ph" : (`p${s}` as "p1" | "p2" | "p3" | "p4");
|
||||
}
|
||||
|
||||
export function ActressPortraitEditor({ actressId, actressName, initial, initialSlot = "1", onClose }: Props) {
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [slot, setSlot] = useState<PortraitSlotKey>(initialSlot);
|
||||
const [slots, setSlots] = useState<ActressAllPortraits>(initial);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, start] = useTransition();
|
||||
const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);
|
||||
|
||||
const cur = slots[slotKey(slot)];
|
||||
const isHorizontal = slot === "h";
|
||||
const W = isHorizontal ? HORIZ_W : PORTRAIT_W;
|
||||
const H = isHorizontal ? HORIZ_H : PORTRAIT_H;
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const ACCEPTED = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
if (busy) return;
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
let imageItem: DataTransferItem | null = null;
|
||||
let unsupported: string | null = null;
|
||||
for (const it of items) {
|
||||
if (it.kind !== "file") continue;
|
||||
if (ACCEPTED.has(it.type)) { imageItem = it; break; }
|
||||
if (it.type.startsWith("image/")) unsupported = it.type;
|
||||
}
|
||||
if (imageItem) {
|
||||
e.preventDefault();
|
||||
const file = imageItem.getAsFile();
|
||||
if (file) uploadFile(file);
|
||||
} else if (unsupported) {
|
||||
e.preventDefault();
|
||||
setError("Unsupported image format — paste JPEG, PNG, or WebP");
|
||||
}
|
||||
};
|
||||
window.addEventListener("paste", onPaste);
|
||||
return () => window.removeEventListener("paste", onPaste);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [busy, slot]);
|
||||
|
||||
function patchSlot(patch: Partial<ActressAllPortraits[keyof ActressAllPortraits]>) {
|
||||
setSlots((s) => ({ ...s, [slotKey(slot)]: { ...s[slotKey(slot)], ...patch } }));
|
||||
}
|
||||
|
||||
async function uploadFile(file: File) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch(`/api/actress-portrait/${actressId}?slot=${slot}`, { method: "POST", body: fd });
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error ?? `upload failed (${res.status})`);
|
||||
}
|
||||
const j = await res.json();
|
||||
patchSlot({ path: j.portraitPath, zoom: 1, offsetX: 0, offsetY: 0 });
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: React.PointerEvent) {
|
||||
if (!cur.path) return;
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
dragRef.current = { x: e.clientX, y: e.clientY, ox: cur.offsetX, oy: cur.offsetY };
|
||||
}
|
||||
function onPointerMove(e: React.PointerEvent) {
|
||||
if (!dragRef.current) return;
|
||||
const dx = e.clientX - dragRef.current.x;
|
||||
const dy = e.clientY - dragRef.current.y;
|
||||
patchSlot({ offsetX: dragRef.current.ox + dx, offsetY: dragRef.current.oy + dy });
|
||||
}
|
||||
function onPointerUp() { dragRef.current = null; }
|
||||
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = previewRef.current;
|
||||
if (!el) return;
|
||||
const handler = (e: WheelEvent) => {
|
||||
if (!cur.path) return;
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08;
|
||||
patchSlot({ zoom: Math.max(0.5, Math.min(5, cur.zoom * factor)) });
|
||||
};
|
||||
el.addEventListener("wheel", handler, { passive: false });
|
||||
return () => el.removeEventListener("wheel", handler);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cur.path, cur.zoom, slot]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await setActressPortraitTransform(actressId, slot, { zoom: cur.zoom, offsetX: cur.offsetX, offsetY: cur.offsetY });
|
||||
router.refresh();
|
||||
onClose();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() { patchSlot({ zoom: 1, offsetX: 0, offsetY: 0 }); }
|
||||
|
||||
async function removePortrait() {
|
||||
if (!confirm(`Remove ${SLOT_LABELS[slot]}?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await clearActressPortrait(actressId, slot);
|
||||
patchSlot({ path: null, zoom: 1, offsetX: 0, offsetY: 0 });
|
||||
router.refresh();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 top-16 z-50 flex items-center justify-center px-4 py-4 fade-in"
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
className="bg-[var(--color-bg-0)] rounded-2xl border border-[var(--color-glass-border)] shadow-2xl p-4"
|
||||
style={{ width: `min(${isHorizontal ? 800 : 480}px, calc(100vw - 32px))` }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">Portraits</div>
|
||||
<div className="text-base font-medium truncate">{actressName}</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] shrink-0">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 mb-3">
|
||||
{SLOT_KEYS.map((k) => {
|
||||
const active = k === slot;
|
||||
const has = !!slots[slotKey(k)].path;
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setSlot(k)}
|
||||
className={`flex items-center gap-1.5 text-xs font-mono font-semibold px-3 py-1.5 rounded-lg border transition-colors ${
|
||||
active ? "bg-[var(--color-cyan)] text-black border-transparent" : "glass glass-hover"
|
||||
}`}
|
||||
>
|
||||
{SLOT_LABELS[k]}
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${has ? "bg-emerald-400" : "bg-white/20"}`} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-stretch">
|
||||
{cur.path ? (
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative shrink-0 rounded-xl overflow-hidden bg-black/30 select-none border-2 border-dashed"
|
||||
style={{
|
||||
width: W,
|
||||
height: H,
|
||||
cursor: "grab",
|
||||
containerType: "inline-size",
|
||||
borderColor: "color-mix(in oklch, var(--color-violet) 60%, transparent)",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={cur.path ? portraitUrl({ path: cur.path, slot }) : ""}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none border-2 border-dashed box-border"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${cur.offsetX / W * 100}cqw, ${cur.offsetY / W * 100}cqw) scale(${cur.zoom})`,
|
||||
width: "100cqw",
|
||||
height: "auto",
|
||||
borderColor: "color-mix(in oklch, var(--color-cyan) 70%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-1.5 left-1/2 -translate-x-1/2 text-[9px] uppercase tracking-wider font-mono text-white flex items-center gap-1 pointer-events-none whitespace-nowrap px-1.5 py-0.5 rounded-md bg-black/70 backdrop-blur-sm">
|
||||
<Move className="w-3 h-3" /> drag · scroll
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="shrink-0 rounded-xl border border-dashed border-[var(--color-glass-border-strong)] text-center text-sm text-[var(--color-fg-muted)] hover:border-[var(--color-cyan)] hover:text-[var(--color-fg-dim)] transition-colors flex flex-col items-center justify-center gap-2"
|
||||
style={{ width: W, height: H }}
|
||||
>
|
||||
<Upload className="w-6 h-6" />
|
||||
Click Or Paste Image
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-between">
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ZoomOut className="w-4 h-4 text-[var(--color-fg-muted)] shrink-0" />
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={5}
|
||||
step={0.01}
|
||||
value={cur.zoom}
|
||||
onChange={(e) => patchSlot({ zoom: Number(e.target.value) })}
|
||||
className="flex-1 accent-[var(--color-cyan)] min-w-0"
|
||||
disabled={!cur.path}
|
||||
/>
|
||||
<ZoomIn className="w-4 h-4 text-[var(--color-fg-muted)] shrink-0" />
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-[var(--color-fg-dim)] tabular-nums text-right -mt-1">
|
||||
{cur.zoom.toFixed(2)}x
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg glass glass-hover disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
{cur.path ? "Replace" : "Upload"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
disabled={!cur.path || busy}
|
||||
title="Reset zoom & position"
|
||||
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{cur.path && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={removePortrait}
|
||||
disabled={busy}
|
||||
title="Remove"
|
||||
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
hidden
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadFile(f); e.target.value = ""; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-[var(--color-glass-border)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 text-xs px-3 py-1.5 rounded-lg glass glass-hover"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={busy || !cur.path}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null} Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
|
||||
type Ctx = {
|
||||
ids: Set<number>;
|
||||
has: (id: number) => boolean;
|
||||
toggle: (id: number) => void;
|
||||
selectMany: (ids: number[]) => void;
|
||||
setMany: (ids: number[]) => void;
|
||||
selectRangeTo: (id: number, orderedIds: number[]) => void;
|
||||
clear: () => void;
|
||||
lastClickedId: number | null;
|
||||
};
|
||||
|
||||
const ActressSelectCtx = createContext<Ctx | null>(null);
|
||||
|
||||
export function ActressSelectionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [ids, setIds] = useState<Set<number>>(new Set());
|
||||
const [lastClickedId, setLastClickedId] = useState<number | null>(null);
|
||||
|
||||
const toggle = useCallback((id: number) => {
|
||||
setIds((cur) => {
|
||||
const next = new Set(cur);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
setLastClickedId(id);
|
||||
}, []);
|
||||
|
||||
const selectMany = useCallback((newIds: number[]) => {
|
||||
setIds((cur) => {
|
||||
const next = new Set(cur);
|
||||
newIds.forEach((i) => next.add(i));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setMany = useCallback((newIds: number[]) => {
|
||||
setIds(new Set(newIds));
|
||||
}, []);
|
||||
|
||||
const selectRangeTo = useCallback((id: number, orderedIds: number[]) => {
|
||||
const last = lastClickedId;
|
||||
if (last == null) {
|
||||
setIds((cur) => {
|
||||
const next = new Set(cur);
|
||||
next.add(id);
|
||||
return next;
|
||||
});
|
||||
setLastClickedId(id);
|
||||
return;
|
||||
}
|
||||
const a = orderedIds.indexOf(last);
|
||||
const b = orderedIds.indexOf(id);
|
||||
if (a === -1 || b === -1) {
|
||||
setIds((cur) => {
|
||||
const next = new Set(cur);
|
||||
next.add(id);
|
||||
return next;
|
||||
});
|
||||
setLastClickedId(id);
|
||||
return;
|
||||
}
|
||||
const [start, end] = a < b ? [a, b] : [b, a];
|
||||
const range = orderedIds.slice(start, end + 1);
|
||||
setIds((cur) => {
|
||||
const next = new Set(cur);
|
||||
range.forEach((i) => next.add(i));
|
||||
return next;
|
||||
});
|
||||
setLastClickedId(id);
|
||||
}, [lastClickedId]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setIds(new Set());
|
||||
setLastClickedId(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<Ctx>(() => ({
|
||||
ids,
|
||||
has: (id) => ids.has(id),
|
||||
toggle,
|
||||
selectMany,
|
||||
setMany,
|
||||
selectRangeTo,
|
||||
clear,
|
||||
lastClickedId,
|
||||
}), [ids, toggle, selectMany, setMany, selectRangeTo, clear, lastClickedId]);
|
||||
|
||||
return <ActressSelectCtx.Provider value={value}>{children}</ActressSelectCtx.Provider>;
|
||||
}
|
||||
|
||||
export function useActressSelection() {
|
||||
const ctx = useContext(ActressSelectCtx);
|
||||
if (!ctx) throw new Error("useActressSelection must be used within ActressSelectionProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Star, Gem, Crown, Heart, Bookmark, Tag, Award, Flame, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
const ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
star: Star,
|
||||
gem: Gem,
|
||||
crown: Crown,
|
||||
heart: Heart,
|
||||
bookmark: Bookmark,
|
||||
tag: Tag,
|
||||
award: Award,
|
||||
flame: Flame,
|
||||
eye: Eye,
|
||||
"eye-off": EyeOff,
|
||||
};
|
||||
|
||||
export function CategoryIcon({ name, className }: { name: string | null; className?: string }) {
|
||||
const Icon = (name ? ICONS[name] : undefined) ?? Tag;
|
||||
return <Icon className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Link from "next/link";
|
||||
import { Users, User } from "lucide-react";
|
||||
import type { CoStar } from "@/lib/db/queries";
|
||||
import { portraitUrl } from "@/lib/assetUrls";
|
||||
|
||||
export function CoStarsRow({ actressName, costars }: { actressName: string; costars: CoStar[] }) {
|
||||
if (costars.length === 0) return null;
|
||||
return (
|
||||
<section className="my-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Users className="w-4 h-4 text-[var(--color-fg-muted)]" />
|
||||
<h2 className="text-xs uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
|
||||
Frequent co-stars
|
||||
</h2>
|
||||
<span className="text-[10px] font-mono text-[var(--color-fg-muted)]">
|
||||
({costars.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{costars.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/actress/${c.slug}`}
|
||||
title={`${c.shared} cover${c.shared === 1 ? "" : "s"} with ${actressName}`}
|
||||
className="group flex items-center gap-2 pl-1 pr-3 py-1 rounded-full glass glass-hover"
|
||||
>
|
||||
<span className="relative w-7 h-7 rounded-full overflow-hidden bg-[var(--color-bg-2)] shrink-0">
|
||||
{c.portraitPath ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={portraitUrl({ path: c.portraitPath, slug: c.slug, slot: "1" })}
|
||||
alt={c.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(${c.portraitOffsetX}px, ${c.portraitOffsetY}px) scale(${c.portraitZoom})`,
|
||||
width: 28,
|
||||
height: "auto",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="absolute inset-0 grid place-items-center text-[var(--color-fg-muted)]">
|
||||
<User className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm">{c.name}</span>
|
||||
<span className="text-[10px] font-mono text-[var(--color-cyan)]">{c.shared}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user