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

349 lines
14 KiB
TypeScript

"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>
);
}