Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+228
View File
@@ -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>
);
}
+286
View File
@@ -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)}
/>
)}
</>
);
}
+36
View File
@@ -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)} />}
</>
);
}
+348
View File
@@ -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>
);
}
+403
View File
@@ -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>
);
}
+245
View File
@@ -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,
);
}
+182
View File
@@ -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;
}
+19
View File
@@ -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} />;
}
+54
View File
@@ -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>
);
}