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