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>
);
}
@@ -0,0 +1,334 @@
"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 { setCategoryCoverTransform, clearCategoryCover, type CategoryCoverSlot } from "@/app/actions/categoryCover";
import { categoryCoverUrl } from "@/lib/assetUrls";
interface CoverState {
path: string | null;
zoom: number;
offsetX: number;
offsetY: number;
}
interface Props {
categoryId: number;
categoryName: string;
initial: { portrait: CoverState; landscape: CoverState };
initialSlot?: CategoryCoverSlot;
onClose: () => void;
}
const PHI = 1.618;
const FRAME_H = 360;
const PORTRAIT_H = FRAME_H;
const PORTRAIT_W = Math.round(FRAME_H / PHI);
const LANDSCAPE_H = FRAME_H;
const LANDSCAPE_W = Math.round(FRAME_H * PHI);
const SLOT_LABELS: Record<CategoryCoverSlot, string> = { portrait: "P", landscape: "L" };
const SLOT_KEYS: CategoryCoverSlot[] = ["portrait", "landscape"];
export function CategoryCoverEditor({ categoryId, categoryName, initial, initialSlot = "portrait", onClose }: Props) {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [slot, setSlot] = useState<CategoryCoverSlot>(initialSlot);
const [slots, setSlots] = useState(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[slot];
const isLandscape = slot === "landscape";
const W = isLandscape ? LANDSCAPE_W : PORTRAIT_W;
const H = isLandscape ? LANDSCAPE_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<CoverState>) {
setSlots((s) => ({ ...s, [slot]: { ...s[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/category-cover/${categoryId}?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.coverPath, 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 setCategoryCoverTransform(categoryId, 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 removeCover() {
if (!confirm(`Remove ${SLOT_LABELS[slot]} cover for "${categoryName}"?`)) return;
setBusy(true);
try {
await clearCategoryCover(categoryId, 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(${isLandscape ? 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)]">Category Cover</div>
<div className="text-base font-medium truncate">{categoryName}</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[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={categoryCoverUrl(cur.path)}
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={{
// cqw scales offsets with frame width so the grid card
// reproduces this preview at any column size.
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={removeCover}
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,141 @@
"use client";
import { useState } from "react";
import { Pencil, ImagePlus } from "lucide-react";
import { CategoryCoverEditor } from "./CategoryCoverEditor";
import { categoryCoverUrl } from "@/lib/assetUrls";
import type { CategoryCoverSlot } from "@/app/actions/categoryCover";
interface CoverState {
path: string | null;
zoom: number;
offsetX: number;
offsetY: number;
}
interface Props {
categoryId: number;
categoryName: string;
categoryColor: string | null;
portrait: CoverState;
landscape: CoverState;
}
const PHI = 1.618;
// Match heights so the two slots sit on a clean baseline; widths follow
// the golden ratio for each orientation.
const FRAME_H = 240;
const PORTRAIT_H = FRAME_H;
const PORTRAIT_W = Math.round(FRAME_H / PHI);
const LANDSCAPE_H = FRAME_H;
const LANDSCAPE_W = Math.round(FRAME_H * PHI);
export function CategoryCoverPanel({ categoryId, categoryName, categoryColor, portrait, landscape }: Props) {
const [open, setOpen] = useState<CategoryCoverSlot | null>(null);
return (
<>
<div className="flex flex-wrap gap-4">
<CoverSlot
label="Portrait"
width={PORTRAIT_W}
height={PORTRAIT_H}
state={portrait}
color={categoryColor}
name={categoryName}
onEdit={() => setOpen("portrait")}
/>
<CoverSlot
label="Landscape"
width={LANDSCAPE_W}
height={LANDSCAPE_H}
state={landscape}
color={categoryColor}
name={categoryName}
onEdit={() => setOpen("landscape")}
/>
</div>
{open && (
<CategoryCoverEditor
categoryId={categoryId}
categoryName={categoryName}
initial={{ portrait, landscape }}
initialSlot={open}
onClose={() => setOpen(null)}
/>
)}
</>
);
}
function CoverSlot({
label,
width,
height,
state,
color,
name,
onEdit,
}: {
label: string;
width: number;
height: number;
state: CoverState;
color: string | null;
name: string;
onEdit: () => void;
}) {
return (
<div className="space-y-1.5">
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">{label}</div>
<button
type="button"
onClick={onEdit}
className="group relative rounded-xl overflow-hidden border border-[var(--color-glass-border-strong)] hover:border-[var(--color-cyan)] transition-colors"
style={{ width, height }}
>
{state.path ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={categoryCoverUrl(state.path)}
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(${state.offsetX}px, ${state.offsetY}px) scale(${state.zoom})`,
width,
height: "auto",
}}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<span className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md bg-black/70 backdrop-blur-sm">
<Pencil className="w-3 h-3" /> Edit
</span>
</div>
</>
) : (
<CategoryCoverPlaceholder name={name} color={color} />
)}
</button>
</div>
);
}
function CategoryCoverPlaceholder({ name, color }: { name: string; color: string | null }) {
const accent = color ?? "var(--color-fg-muted)";
return (
<div
className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-center px-3"
style={{
background: `linear-gradient(135deg, color-mix(in oklch, ${accent} 25%, var(--color-bg-1)) 0%, var(--color-bg-1) 70%)`,
}}
>
<div className="w-2.5 h-2.5 rounded-full" style={{ background: accent }} />
<div className="text-sm font-medium truncate max-w-full" style={{ color: accent }}>{name}</div>
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
<ImagePlus className="w-3 h-3" /> add cover
</div>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Pencil } from "lucide-react";
import { CategoryCoverEditor } from "./CategoryCoverEditor";
import { categoryCoverUrl } from "@/lib/assetUrls";
import { cn } from "@/lib/utils";
import type { CategoryCoverSlot } from "@/app/actions/categoryCover";
// Mirror the editor's canonical frame so cqw-based offsets line up.
const PHI = 1.618;
const FRAME_H = 360;
const CANONICAL_PORTRAIT_W = Math.round(FRAME_H / PHI);
const CANONICAL_LANDSCAPE_W = Math.round(FRAME_H * PHI);
export interface CategoryGridCardProps {
id: number;
slug: string;
name: string;
color: string | null;
description: string | null;
tagCount: number;
imageCount: number;
view: "portrait" | "landscape";
portrait: { path: string | null; zoom: number; offsetX: number; offsetY: number };
landscape: { path: string | null; zoom: number; offsetX: number; offsetY: number };
}
export function CategoryGridCard(props: CategoryGridCardProps) {
const { view, portrait, landscape, name, color, slug, description, tagCount, imageCount, id } = props;
const [editing, setEditing] = useState<CategoryCoverSlot | null>(null);
const cur = view === "portrait" ? portrait : landscape;
const aspect = view === "portrait" ? "aspect-[1/1.618]" : "aspect-[1.618/1]";
const canonicalW = view === "portrait" ? CANONICAL_PORTRAIT_W : CANONICAL_LANDSCAPE_W;
function openEditor(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
setEditing(view);
}
return (
<>
<Link
href={`/category/${slug}`}
className={cn(
"group relative block rounded-2xl overflow-hidden glass glass-hover",
aspect,
)}
style={{ containerType: "inline-size" }}
>
{cur.path ? (
<div className="absolute inset-0 overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={categoryCoverUrl(cur.path)}
alt=""
draggable={false}
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none h-auto"
style={{
transform: `translate(-50%, -50%) translate(${cur.offsetX / canonicalW * 100}cqw, ${cur.offsetY / canonicalW * 100}cqw) scale(${cur.zoom})`,
width: "100cqw",
}}
/>
</div>
) : (
<Placeholder name={name} color={color} />
)}
<button
type="button"
onClick={openEditor}
aria-label={`Edit ${view} cover`}
title={`Edit ${view} cover`}
className={cn(
"absolute bottom-3 right-3 z-20 w-8 h-8 grid place-items-center rounded-md",
"bg-black/60 backdrop-blur-md text-white border border-white/20",
"opacity-0 group-hover:opacity-100 transition-all",
"hover:bg-[var(--color-cyan)]/30 hover:border-[var(--color-cyan)] hover:text-[var(--color-cyan)]",
"hover:scale-110 hover:shadow-lg active:scale-95",
)}
>
<Pencil className="w-4 h-4" />
</button>
<div className="absolute inset-x-0 bottom-0 p-3 pt-10 bg-gradient-to-t from-black/85 via-black/55 to-transparent">
<div className="flex items-center gap-2 min-w-0">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ background: color ?? "var(--color-fg-muted)" }}
/>
<div className="font-semibold truncate text-white">{name}</div>
</div>
{view === "landscape" && description && (
<p className="text-[11px] text-white/70 mt-1 line-clamp-2">{description}</p>
)}
<div className="text-[10px] font-mono text-white/60 tabular-nums mt-1">
{tagCount} tag{tagCount === 1 ? "" : "s"} · {imageCount} cover{imageCount === 1 ? "" : "s"}
</div>
</div>
</Link>
{editing && (
<CategoryCoverEditor
categoryId={id}
categoryName={name}
initial={{ portrait, landscape }}
initialSlot={editing}
onClose={() => setEditing(null)}
/>
)}
</>
);
}
function Placeholder({ name, color }: { name: string; color: string | null }) {
const accent = color ?? "var(--color-fg-muted)";
return (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
background: `linear-gradient(135deg, color-mix(in oklch, ${accent} 30%, var(--color-bg-1)) 0%, var(--color-bg-1) 80%)`,
}}
>
<div
className="text-center px-3 text-2xl font-semibold tracking-tight uppercase opacity-70"
style={{
color: accent,
textShadow: "0 2px 8px rgba(0,0,0,0.4)",
}}
>
{name}
</div>
</div>
);
}
@@ -0,0 +1,116 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Check, Search, Loader2 } from "lucide-react";
import { setTagCategory } from "@/app/actions/tagCategories";
import { cn } from "@/lib/utils";
interface AssignerTag {
id: number;
name: string;
count: number;
currentCategoryId: number | null;
currentCategoryName: string | null;
}
export function CategoryTagAssigner({
categoryId,
tags,
}: {
categoryId: number;
tags: AssignerTag[];
}) {
const [query, setQuery] = useState("");
const [pending, start] = useTransition();
const [busyId, setBusyId] = useState<number | null>(null);
const router = useRouter();
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return tags;
return tags.filter((t) => t.name.toLowerCase().includes(q));
}, [tags, query]);
function toggle(tag: AssignerTag) {
const inThis = tag.currentCategoryId === categoryId;
const next = inThis ? null : categoryId;
setBusyId(tag.id);
start(async () => {
await setTagCategory(tag.id, next);
setBusyId(null);
router.refresh();
});
}
return (
<div className="glass rounded-xl p-3">
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter tags…"
className="w-full bg-[var(--color-bg-1)]/60 rounded-lg pl-9 pr-3 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)] border border-[var(--color-glass-border)]"
/>
</div>
<div className="max-h-[420px] overflow-y-auto -mx-1 px-1">
{filtered.length === 0 ? (
<div className="text-center text-sm text-[var(--color-fg-muted)] italic py-4">
{query ? "No tags match." : "No tags exist yet — create one from the Tags page first."}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1">
{filtered.map((t) => {
const inThis = t.currentCategoryId === categoryId;
const elsewhere = !inThis && t.currentCategoryId != null;
const busy = busyId === t.id && pending;
return (
<button
key={t.id}
type="button"
onClick={() => toggle(t)}
disabled={pending}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm text-left transition-colors disabled:opacity-50",
inThis
? "bg-[var(--color-violet)]/15 text-[var(--color-violet)] ring-1 ring-[var(--color-violet)]/40"
: "hover:bg-[var(--color-glass)] text-[var(--color-fg)]",
)}
title={
inThis
? "Click to remove from this category"
: elsewhere
? `Currently in "${t.currentCategoryName}". Click to move it here.`
: "Uncategorised. Click to assign to this category."
}
>
{busy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
) : inThis ? (
<Check className="w-3.5 h-3.5 shrink-0" />
) : (
<span className="w-3.5 h-3.5 shrink-0" />
)}
<span className="truncate flex-1">{t.name}</span>
<span className="text-[10px] font-mono text-[var(--color-fg-muted)] tabular-nums">{t.count}</span>
{elsewhere && (
<span
className="text-[10px] font-mono uppercase tracking-wider px-1.5 py-0.5 rounded bg-[var(--color-glass)] text-[var(--color-fg-muted)] truncate max-w-[100px]"
title={t.currentCategoryName ?? ""}
>
{t.currentCategoryName}
</span>
)}
</button>
);
})}
</div>
)}
</div>
<p className="text-[11px] text-[var(--color-fg-muted)] mt-3 leading-relaxed">
A tag can only belong to one category. Selecting a tag that&apos;s in another category moves it here.
</p>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Layers, ChevronDown, Check, Loader2, X } from "lucide-react";
import { setTagCategory } from "@/app/actions/tagCategories";
import { cn } from "@/lib/utils";
interface CategoryOption {
id: number;
name: string;
slug: string;
color: string | null;
}
export function TagCategoryPicker({
tagId,
currentCategoryId,
categories,
}: {
tagId: number;
currentCategoryId: number | null;
categories: CategoryOption[];
}) {
const [open, setOpen] = useState(false);
const [pending, start] = useTransition();
const router = useRouter();
const ref = useRef<HTMLDivElement>(null);
const current = categories.find((c) => c.id === currentCategoryId) ?? null;
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
window.addEventListener("mousedown", onClick);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("mousedown", onClick);
window.removeEventListener("keydown", onKey);
};
}, [open]);
function pick(id: number | null) {
if (id === currentCategoryId) { setOpen(false); return; }
start(async () => {
await setTagCategory(tagId, id);
setOpen(false);
router.refresh();
});
}
return (
<div ref={ref} className="relative inline-block">
<button
type="button"
onClick={() => setOpen((s) => !s)}
disabled={pending}
className={cn(
"flex items-center gap-2 text-xs px-2.5 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] disabled:opacity-50",
)}
>
{pending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Layers className="w-3.5 h-3.5" />}
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">Category:</span>
{current ? (
<span className="flex items-center gap-1.5 text-[var(--color-fg)]">
<span className="w-2 h-2 rounded-full" style={{ background: current.color ?? "var(--color-fg-muted)" }} />
{current.name}
</span>
) : (
<span className="italic">Uncategorised</span>
)}
<ChevronDown className={cn("w-3 h-3 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div
role="menu"
className="absolute top-full left-0 mt-1 min-w-[220px] max-h-[320px] overflow-y-auto rounded-lg border border-[var(--color-glass-border-strong)] bg-[var(--color-bg-0)] shadow-lg backdrop-blur-xl py-1 z-50"
>
<button
type="button"
onClick={() => pick(null)}
className={cn(
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left",
currentCategoryId == null ? "text-[var(--color-cyan)] bg-[var(--color-glass)]" : "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
{currentCategoryId == null ? <Check className="w-3 h-3" /> : <X className="w-3 h-3 opacity-50" />}
<span className="italic">Uncategorised</span>
</button>
{categories.length === 0 ? (
<div className="px-3 py-2 text-xs text-[var(--color-fg-muted)] italic">
No categories yet.{" "}
<Link href="/category" className="underline hover:text-[var(--color-fg)]">
Create one
</Link>
</div>
) : (
categories.map((c) => {
const active = c.id === currentCategoryId;
return (
<button
key={c.id}
type="button"
onClick={() => pick(c.id)}
className={cn(
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left",
active ? "text-[var(--color-cyan)] bg-[var(--color-glass)]" : "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
{active ? <Check className="w-3 h-3" /> : <span className="w-3 h-3" />}
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: c.color ?? "var(--color-fg-muted)" }} />
<span className="truncate">{c.name}</span>
</button>
);
})
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,336 @@
"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 { setCollectionCoverTransform, clearCollectionCover, type CollectionCoverSlot } from "@/app/actions/collectionCover";
import { collectionCoverUrl } from "@/lib/assetUrls";
interface CoverState {
path: string | null;
zoom: number;
offsetX: number;
offsetY: number;
}
interface Props {
collectionId: number;
collectionName: string;
initial: { portrait: CoverState; landscape: CoverState };
initialSlot?: CollectionCoverSlot;
onClose: () => void;
}
const PHI = 1.618;
const FRAME_H = 360;
const PORTRAIT_H = FRAME_H;
const PORTRAIT_W = Math.round(FRAME_H / PHI);
const LANDSCAPE_H = FRAME_H;
const LANDSCAPE_W = Math.round(FRAME_H * PHI);
const SLOT_LABELS: Record<CollectionCoverSlot, string> = { portrait: "P", landscape: "L" };
const SLOT_KEYS: CollectionCoverSlot[] = ["portrait", "landscape"];
export function CollectionCoverEditor({ collectionId, collectionName, initial, initialSlot = "portrait", onClose }: Props) {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [slot, setSlot] = useState<CollectionCoverSlot>(initialSlot);
const [slots, setSlots] = useState(initial);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [, _start] = useTransition();
void _start;
const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);
const cur = slots[slot];
const isLandscape = slot === "landscape";
const W = isLandscape ? LANDSCAPE_W : PORTRAIT_W;
const H = isLandscape ? LANDSCAPE_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<CoverState>) {
setSlots((s) => ({ ...s, [slot]: { ...s[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/collection-cover/${collectionId}?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.coverPath, 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 setCollectionCoverTransform(collectionId, 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 removeCover() {
if (!confirm(`Remove ${SLOT_LABELS[slot]} cover for "${collectionName}"?`)) return;
setBusy(true);
try {
await clearCollectionCover(collectionId, 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(${isLandscape ? 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)]">Collection Cover</div>
<div className="text-base font-medium truncate">{collectionName}</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[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={collectionCoverUrl(cur.path)}
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={{
// cqw makes the offset scale with frame width so the
// grid card mirrors this preview exactly, regardless of
// its column width.
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={removeCover}
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,
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { FolderHeart, X, Plus } from "lucide-react";
import { addImageToCollection, removeImageFromCollection, createCollection } from "@/app/actions/collections";
interface PickedCollection { id: number; name: string; slug?: string }
export function CollectionPicker({
imageId,
current,
available,
}: {
imageId: number;
current: PickedCollection[];
available: PickedCollection[];
}) {
const [picked, setPicked] = useState(current);
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState("");
const [pool, setPool] = useState(available);
const [, start] = useTransition();
const add = (c: PickedCollection) => {
if (picked.some(p => p.id === c.id)) return;
setPicked((cur) => [...cur, c]);
start(async () => { await addImageToCollection(c.id, imageId); });
};
const remove = (id: number) => {
setPicked((cur) => cur.filter(c => c.id !== id));
start(async () => { await removeImageFromCollection(id, imageId); });
};
const createNew = async (e: React.FormEvent) => {
e.preventDefault();
const name = draft.trim();
if (!name) return;
setDraft("");
start(async () => {
const created = await createCollection(name);
if (created) {
setPool((cur) => [...cur, { id: created.id, name, slug: created.slug }]);
setPicked((cur) => [...cur, { id: created.id, name, slug: created.slug }]);
await addImageToCollection(created.id, imageId);
}
});
};
const remaining = pool.filter(p => !picked.some(pp => pp.id === p.id));
return (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-2">
Collections
</div>
<div className="flex flex-wrap gap-1.5">
{picked.map((c) => (
<span
key={c.id}
className="group flex items-center gap-1 px-2 py-1 rounded-full text-xs glass border-[var(--color-cyan)]/30 text-[var(--color-cyan)] bg-[color-mix(in_oklch,var(--color-cyan)_10%,transparent)]"
>
<FolderHeart className="w-3 h-3" />
{c.slug ? (
<Link
href={`/collection/${c.slug}`}
className="hover:underline"
>
{c.name}
</Link>
) : (
c.name
)}
<button onClick={() => remove(c.id)} className="opacity-50 hover:opacity-100">
<X className="w-3 h-3" />
</button>
</span>
))}
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 px-2 py-1 rounded-full text-xs border border-dashed border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-cyan)] hover:border-[var(--color-cyan)]"
>
<Plus className="w-3 h-3" />
Add to collection
</button>
</div>
{open && (
<div className="mt-2 glass rounded-xl p-3 space-y-2">
{remaining.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{remaining.map((c) => (
<button
key={c.id}
onClick={() => add(c)}
className="text-xs px-2 py-1 rounded-full glass-strong hover:text-[var(--color-cyan)]"
>
{c.name}
</button>
))}
</div>
)}
<form onSubmit={createNew} className="flex gap-1.5">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="New collection…"
className="flex-1 bg-transparent text-xs px-2 py-1 rounded-md border border-[var(--color-glass-border)] outline-none focus:border-[var(--color-cyan)]"
/>
<button type="submit" className="text-xs px-2 py-1 rounded-md bg-[var(--color-cyan)] text-black font-medium">
Create
</button>
</form>
</div>
)}
</div>
);
}
@@ -0,0 +1,43 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
type Ctx = {
ids: Set<number>;
has: (id: number) => boolean;
toggle: (id: number) => void;
setMany: (ids: number[]) => void;
clear: () => void;
};
const CollectionSelectCtx = createContext<Ctx | null>(null);
export function CollectionSelectionProvider({ children }: { children: React.ReactNode }) {
const [ids, setIds] = useState<Set<number>>(new Set());
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;
});
}, []);
const setMany = useCallback((newIds: number[]) => setIds(new Set(newIds)), []);
const clear = useCallback(() => setIds(new Set()), []);
const value = useMemo<Ctx>(() => ({
ids,
has: (id) => ids.has(id),
toggle,
setMany,
clear,
}), [ids, toggle, setMany, clear]);
return <CollectionSelectCtx.Provider value={value}>{children}</CollectionSelectCtx.Provider>;
}
export function useCollectionSelection() {
const ctx = useContext(CollectionSelectCtx);
if (!ctx) throw new Error("useCollectionSelection must be used within CollectionSelectionProvider");
return ctx;
}
@@ -0,0 +1,150 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { GripVertical } from "lucide-react";
import { ImageCard, type CardImage } from "@/components/grid/ImageCard";
import { reorderCollectionImage } from "@/app/actions/collections";
import { cn } from "@/lib/utils";
/**
* Drag-and-drop reorderable grid. Wraps each ImageCard in a draggable
* container; on drop, computes which image should be placed before
* which (or at the end) and calls the server action. The grid uses the
* same column count as the regular MasonryGrid so visuals match.
*/
export function ReorderableCollectionGrid({
images,
collectionId,
view = "landscape",
}: {
images: CardImage[];
collectionId: number;
view?: "portrait" | "landscape";
}) {
// Local state mirrors the prop so we can apply optimistic reordering
// before the server round-trips. Drift is reconciled when the page
// re-fetches via router.refresh().
const [items, setItems] = useState(images);
const [draggingId, setDraggingId] = useState<number | null>(null);
const [dropBeforeId, setDropBeforeId] = useState<number | "end" | null>(null);
const [, start] = useTransition();
const router = useRouter();
if (images.length === 0) return null;
// Reconcile when the prop changes (e.g. after a refresh).
if (items.length !== images.length || items.some((it, i) => it.id !== images[i]?.id)) {
setItems(images);
}
const cols = view === "portrait" ? "6" : "var(--grid-cols, 3)";
function onDragStart(id: number, e: React.DragEvent) {
setDraggingId(id);
e.dataTransfer.effectAllowed = "move";
// Firefox needs setData to actually begin drag.
e.dataTransfer.setData("text/plain", String(id));
}
function onDragOverCard(id: number, e: React.DragEvent) {
if (draggingId == null) return;
e.preventDefault();
setDropBeforeId(id);
}
function onDragOverEnd(e: React.DragEvent) {
if (draggingId == null) return;
e.preventDefault();
setDropBeforeId("end");
}
function commitDrop() {
if (draggingId == null || dropBeforeId == null) {
setDraggingId(null);
setDropBeforeId(null);
return;
}
const movedId = draggingId;
const beforeId = dropBeforeId === "end" ? null : dropBeforeId;
if (movedId === beforeId) {
setDraggingId(null);
setDropBeforeId(null);
return;
}
// Optimistic local reorder.
setItems((cur) => {
const fromIdx = cur.findIndex((x) => x.id === movedId);
if (fromIdx === -1) return cur;
const moved = cur[fromIdx];
const without = [...cur.slice(0, fromIdx), ...cur.slice(fromIdx + 1)];
let toIdx = beforeId == null ? without.length : without.findIndex((x) => x.id === beforeId);
if (toIdx === -1) toIdx = without.length;
return [...without.slice(0, toIdx), moved, ...without.slice(toIdx)];
});
setDraggingId(null);
setDropBeforeId(null);
start(async () => {
await reorderCollectionImage(collectionId, movedId, beforeId);
router.refresh();
});
}
function onDragEnd() {
setDraggingId(null);
setDropBeforeId(null);
}
return (
<div
className="grid gap-5"
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
onDragOver={(e) => {
// Required for the drop event to fire on this container at all
// — without preventDefault on dragover, browsers treat the area
// as a non-drop-target.
if (draggingId != null) e.preventDefault();
}}
onDrop={commitDrop}
onDragEnd={onDragEnd}
>
{items.map((img) => (
<div
key={img.id}
draggable
onDragStart={(e) => onDragStart(img.id, e)}
onDragOver={(e) => onDragOverCard(img.id, e)}
onDragEnd={onDragEnd}
className={cn(
"relative group/drag",
draggingId === img.id && "opacity-40",
dropBeforeId === img.id && "ring-2 ring-[var(--color-cyan)] ring-offset-2 ring-offset-[var(--color-bg-0)] rounded-2xl",
)}
>
<span
className="absolute top-3 left-3 z-20 w-7 h-7 grid place-items-center rounded-md bg-black/60 backdrop-blur-md border border-white/15 text-white/80 cursor-grab active:cursor-grabbing opacity-0 group-hover/drag:opacity-100 transition-opacity pointer-events-none"
aria-label="Drag handle"
title="Drag to reorder"
>
<GripVertical className="w-3.5 h-3.5" />
</span>
<ImageCard image={img} view={view} />
</div>
))}
{/* Sentinel drop zone for "end of list" */}
<div
onDragOver={onDragOverEnd}
className={cn(
"min-h-[100px] rounded-2xl border-2 border-dashed flex items-center justify-center text-xs text-[var(--color-fg-muted)] transition-colors",
dropBeforeId === "end"
? "border-[var(--color-cyan)] bg-[var(--color-cyan)]/5 text-[var(--color-cyan)]"
: "border-[var(--color-glass-border)]",
)}
>
{draggingId != null ? "Drop here for end of list" : ""}
</div>
</div>
);
}
@@ -0,0 +1,379 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Check, FolderHeart, Pencil, Type, X } from "lucide-react";
import { reorderCollection, renameCollection } from "@/app/actions/collections";
import { thumbUrl, collectionCoverUrl } from "@/lib/assetUrls";
import { cn } from "@/lib/utils";
import { CollectionSelectionProvider, useCollectionSelection } from "./CollectionSelectionProvider";
import { CollectionCoverEditor } from "./CollectionCoverEditor";
interface CollectionListItem {
id: number;
name: string;
slug: string;
description: string | null;
cover_thumb: string | null;
first_thumb: string | null;
count: number;
coverPortraitPath: string | null;
coverPortraitZoom: number;
coverPortraitOffsetX: number;
coverPortraitOffsetY: number;
coverLandscapePath: string | null;
coverLandscapeZoom: number;
coverLandscapeOffsetX: number;
coverLandscapeOffsetY: number;
}
const PHI = 1.618;
// Canonical frame widths used by the cover editor preview. The card
// reproduces editor offsets via `cqw` so a 50px offset on a 583px-wide
// editor preview scales correctly on a 430px-wide grid card.
const FRAME_H = 360;
const CANONICAL_PORTRAIT_W = Math.round(FRAME_H / PHI);
const CANONICAL_LANDSCAPE_W = Math.round(FRAME_H * PHI);
export function ReorderableCollectionsIndex({
items,
view = "landscape",
}: {
items: CollectionListItem[];
view?: "portrait" | "landscape";
}) {
return (
<CollectionSelectionProvider>
<Inner items={items} view={view} />
</CollectionSelectionProvider>
);
}
function Inner({ items: initial, view }: { items: CollectionListItem[]; view: "portrait" | "landscape" }) {
const sel = useCollectionSelection();
const [items, setItems] = useState(initial);
const [draggingId, setDraggingId] = useState<number | null>(null);
const [dropBeforeId, setDropBeforeId] = useState<number | "end" | null>(null);
const [editing, setEditing] = useState<CollectionListItem | null>(null);
const [renamingId, setRenamingId] = useState<number | null>(null);
const [draftName, setDraftName] = useState("");
const [renamePending, setRenamePending] = useState(false);
const [, start] = useTransition();
const router = useRouter();
function startRename(c: CollectionListItem) {
setRenamingId(c.id);
setDraftName(c.name);
}
function cancelRename() {
setRenamingId(null);
setDraftName("");
}
async function commitRename(c: CollectionListItem) {
const next = draftName.trim();
if (!next || next === c.name) { cancelRename(); return; }
setRenamePending(true);
await renameCollection(c.id, next);
setRenamePending(false);
setRenamingId(null);
setDraftName("");
router.refresh();
}
// Reconcile when the prop changes (after server refresh). Includes
// cover-art fields so save-and-router.refresh() picks up new
// transforms even when the row order is unchanged. Runs in an effect
// (not during render) so we never call setState mid-render.
useEffect(() => {
setItems(initial);
}, [initial]);
function onDragStart(id: number, e: React.DragEvent) {
setDraggingId(id);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(id));
}
function onDragOverCard(id: number, e: React.DragEvent) {
if (draggingId == null) return;
e.preventDefault();
setDropBeforeId(id);
}
function onDragOverEnd(e: React.DragEvent) {
if (draggingId == null) return;
e.preventDefault();
setDropBeforeId("end");
}
function commitDrop() {
if (draggingId == null || dropBeforeId == null) {
setDraggingId(null);
setDropBeforeId(null);
return;
}
const movedId = draggingId;
const beforeId = dropBeforeId === "end" ? null : dropBeforeId;
if (movedId === beforeId) {
setDraggingId(null);
setDropBeforeId(null);
return;
}
setItems((cur) => {
const fromIdx = cur.findIndex((x) => x.id === movedId);
if (fromIdx === -1) return cur;
const moved = cur[fromIdx];
const without = [...cur.slice(0, fromIdx), ...cur.slice(fromIdx + 1)];
let toIdx = beforeId == null ? without.length : without.findIndex((x) => x.id === beforeId);
if (toIdx === -1) toIdx = without.length;
return [...without.slice(0, toIdx), moved, ...without.slice(toIdx)];
});
setDraggingId(null);
setDropBeforeId(null);
start(async () => {
try {
await reorderCollection(movedId, beforeId);
router.refresh();
} catch (err) {
// Revert the optimistic reorder so the UI doesn't lie about
// the persisted order.
console.error("[reorderCollection] failed:", err);
setItems(initial);
}
});
}
function onDragEnd() {
setDraggingId(null);
setDropBeforeId(null);
}
const isLandscape = view === "landscape";
const gridCls = isLandscape
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
: "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4";
return (
<>
<div
key={view}
className={cn("fade-in", gridCls)}
onDragOver={(e) => { if (draggingId != null) e.preventDefault(); }}
onDrop={commitDrop}
onDragEnd={onDragEnd}
>
{items.map((c) => {
const customPath = isLandscape ? c.coverLandscapePath : c.coverPortraitPath;
const customZoom = isLandscape ? c.coverLandscapeZoom : c.coverPortraitZoom;
const customOffsetX = isLandscape ? c.coverLandscapeOffsetX : c.coverPortraitOffsetX;
const customOffsetY = isLandscape ? c.coverLandscapeOffsetY : c.coverPortraitOffsetY;
const fallbackThumb = c.cover_thumb ?? c.first_thumb;
const selected = sel.has(c.id);
const anySelected = sel.ids.size > 0;
return (
<div
key={c.id}
draggable={renamingId !== c.id}
onDragStart={(e) => onDragStart(c.id, e)}
onDragOver={(e) => onDragOverCard(c.id, e)}
onDragEnd={onDragEnd}
className={cn(
"relative group/drag",
draggingId === c.id && "opacity-40",
dropBeforeId === c.id && "ring-2 ring-[var(--color-cyan)] ring-offset-2 ring-offset-[var(--color-bg-0)] rounded-2xl",
selected && "ring-2 ring-[var(--color-cyan)] shadow-[var(--shadow-glow-cyan)] rounded-2xl",
anySelected && !selected && "opacity-70 hover:opacity-100",
)}
>
<Link
href={`/collection/${c.slug}`}
draggable={false}
onClick={(e) => {
if (anySelected) {
e.preventDefault();
sel.toggle(c.id);
}
}}
className="group glass glass-hover rounded-2xl overflow-hidden block"
>
<div
className="relative bg-[var(--color-bg-1)] overflow-hidden"
style={{
aspectRatio: isLandscape ? `${PHI} / 1` : `1 / ${PHI}`,
containerType: "inline-size",
}}
>
{customPath ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={collectionCoverUrl(customPath)}
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(${customOffsetX / (isLandscape ? CANONICAL_LANDSCAPE_W : CANONICAL_PORTRAIT_W) * 100}cqw, ${customOffsetY / (isLandscape ? CANONICAL_LANDSCAPE_W : CANONICAL_PORTRAIT_W) * 100}cqw) scale(${customZoom})`,
width: "100%",
height: "auto",
}}
/>
) : fallbackThumb ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={thumbUrl({ thumbPath: fallbackThumb, code: c.name })}
alt=""
draggable={false}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-[1.02] transition-transform"
/>
) : (
<div className="absolute inset-0 grid place-items-center">
<FolderHeart className="w-10 h-10 text-[var(--color-fg-muted)]" />
</div>
)}
<div
className="absolute top-3 left-3 text-[10px] uppercase tracking-wider font-mono font-semibold px-2 py-0.5 rounded-full bg-black/80 backdrop-blur-md text-[var(--color-cyan)] shadow-md"
style={{
border: "1px solid rgba(34,211,238,0.4)",
textShadow: "0 1px 2px rgba(0,0,0,0.9)",
}}
>
{c.count} Item{c.count === 1 ? "" : "s"}
</div>
<button
type="button"
draggable={false}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
sel.toggle(c.id);
}}
aria-label={selected ? "Deselect" : "Select"}
className={cn(
"absolute top-3 right-3 w-8 h-8 rounded-md grid place-items-center transition-all backdrop-blur-md border-2",
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/drag:opacity-100",
)}
>
<Check className="w-4 h-4" strokeWidth={3} />
</button>
<button
type="button"
draggable={false}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditing(c);
}}
aria-label="Edit cover"
title="Edit cover"
className={cn(
"absolute bottom-3 right-3 w-8 h-8 rounded-md grid place-items-center transition-all backdrop-blur-md border-2",
"bg-black/60 border-white/30 text-white hover:bg-[var(--color-cyan)]/30 hover:border-[var(--color-cyan)] hover:text-[var(--color-cyan)]",
"opacity-0 group-hover/drag:opacity-100",
)}
>
<Pencil className="w-4 h-4" />
</button>
</div>
<div className="p-3">
{renamingId === c.id ? (
<div
className="flex items-center gap-1 h-7"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
onMouseDown={(e) => e.stopPropagation()}
>
<input
autoFocus
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); commitRename(c); }
if (e.key === "Escape") { e.preventDefault(); cancelRename(); }
}}
disabled={renamePending}
maxLength={120}
className="flex-1 min-w-0 h-full glass rounded-md px-2 text-sm outline-none focus:border-[var(--color-cyan)]"
/>
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); commitRename(c); }}
disabled={renamePending || !draftName.trim() || draftName.trim() === c.name}
className="flex items-center justify-center w-6 h-6 rounded bg-[var(--color-cyan)] text-black disabled:opacity-40"
title="Save"
>
<Check className="w-3 h-3" strokeWidth={3} />
</button>
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); cancelRename(); }}
disabled={renamePending}
className="flex items-center justify-center w-6 h-6 rounded glass text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
title="Cancel"
>
<X className="w-3 h-3" />
</button>
</div>
) : (
<div className="flex items-center gap-1.5 h-7 min-w-0">
<div className="font-medium truncate flex-1 min-w-0">{c.name}</div>
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); startRename(c); }}
title="Rename"
aria-label="Rename collection"
className="flex-shrink-0 w-6 h-6 grid place-items-center rounded text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)] opacity-0 group-hover/drag:opacity-100 transition-opacity"
>
<Type className="w-3.5 h-3.5" />
</button>
</div>
)}
{c.description && (
<div className="text-xs text-[var(--color-fg-muted)] truncate mt-0.5">{c.description}</div>
)}
</div>
</Link>
</div>
);
})}
<div
onDragOver={onDragOverEnd}
className={cn(
"min-h-[120px] rounded-2xl border-2 border-dashed flex items-center justify-center text-xs text-[var(--color-fg-muted)] transition-colors",
dropBeforeId === "end"
? "border-[var(--color-cyan)] bg-[var(--color-cyan)]/5 text-[var(--color-cyan)]"
: "border-[var(--color-glass-border)]",
)}
>
{draggingId != null ? "Drop here for end of list" : ""}
</div>
</div>
{editing && (
<CollectionCoverEditor
collectionId={editing.id}
collectionName={editing.name}
initialSlot={view}
initial={{
portrait: {
path: editing.coverPortraitPath,
zoom: editing.coverPortraitZoom,
offsetX: editing.coverPortraitOffsetX,
offsetY: editing.coverPortraitOffsetY,
},
landscape: {
path: editing.coverLandscapePath,
zoom: editing.coverLandscapeZoom,
offsetX: editing.coverLandscapeOffsetX,
offsetY: editing.coverLandscapeOffsetY,
},
}}
onClose={() => setEditing(null)}
/>
)}
</>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { imageUrl } from "@/lib/assetUrls";
/**
* Wraps any element. On hover of the wrapped child, a floating popover
* shows the full-resolution image at /api/image/[imageId].
*/
export function HoverImagePreview({
imageId,
children,
}: {
imageId: number;
children: React.ReactNode;
}) {
const [show, setShow] = useState(false);
const [pos, setPos] = useState<{ maxW: number; maxH: number }>({ maxW: 0, maxH: 0 });
const ref = useRef<HTMLSpanElement>(null);
const timerRef = useRef<number | null>(null);
const open = () => {
const margin = 24;
setPos({
maxW: window.innerWidth - margin * 2,
maxH: window.innerHeight - margin * 2,
});
timerRef.current = window.setTimeout(() => setShow(true), 120);
};
const close = () => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setShow(false);
};
useEffect(() => () => { if (timerRef.current != null) clearTimeout(timerRef.current); }, []);
return (
<>
<span
ref={ref}
onMouseEnter={open}
onMouseLeave={close}
onFocus={open}
onBlur={close}
className="inline-block"
>
{children}
</span>
{show && (
<div
className="fixed z-[100] pointer-events-none rounded-xl overflow-hidden border border-[var(--color-glass-border-strong)] shadow-2xl"
style={{
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
maxWidth: pos.maxW,
maxHeight: pos.maxH,
background: "color-mix(in oklch, var(--color-bg-0) 92%, transparent)",
backdropFilter: "blur(20px)",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl({ id: imageId, code: null })}
alt=""
style={{
maxWidth: pos.maxW,
maxHeight: pos.maxH,
width: "auto",
height: "auto",
display: "block",
objectFit: "contain",
}}
/>
</div>
)}
</>
);
}
+197
View File
@@ -0,0 +1,197 @@
"use client";
import { useState, useRef, useMemo, useEffect } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export type ChipSuggestion =
| string
| { name: string; aliases?: string[]; primaryAliases?: string[] };
export function ChipInput({
values,
onChange,
placeholder,
accent = "cyan",
suggestions,
}: {
values: string[];
onChange: (next: string[]) => void;
placeholder?: string;
accent?: "cyan" | "violet";
/** Strings or { name, aliases? } — when aliases are provided, they're matched but the chip stores `name`. */
suggestions?: ChipSuggestion[];
}) {
const [draft, setDraft] = useState("");
const [highlight, setHighlight] = useState(0);
const [focused, setFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const blurTimerRef = useRef<number | null>(null);
// Cancel any pending blur-commit on unmount so we don't fire setState
// (or commit a stale draft) after the component is gone.
useEffect(() => () => {
if (blurTimerRef.current != null) window.clearTimeout(blurTimerRef.current);
}, []);
const accentVar = accent === "cyan" ? "var(--color-cyan)" : "var(--color-violet)";
const lowerSelected = useMemo(() => new Set(values.map((v) => v.toLowerCase())), [values]);
type Resolved = { name: string; matchedAlias?: string; rank: number };
const filtered = useMemo<Resolved[]>(() => {
if (!suggestions || !draft.trim()) return [];
const q = draft.trim().toLowerCase();
// rank: 0 = canonical name match, 1 = primary alias (reverse), 2 = alt alias.
const out: Resolved[] = [];
for (const s of suggestions) {
const name = typeof s === "string" ? s : s.name;
if (lowerSelected.has(name.toLowerCase())) continue;
const primaryAliases = typeof s === "string" ? [] : (s.primaryAliases ?? []);
const altAliases = typeof s === "string" ? [] : (s.aliases ?? []);
if (name.toLowerCase().includes(q)) {
out.push({ name, rank: 0 });
continue;
}
const primaryHit = primaryAliases.find((a) => a.toLowerCase().includes(q));
if (primaryHit) {
out.push({ name, matchedAlias: primaryHit, rank: 1 });
continue;
}
const altHit = altAliases.find((a) => a.toLowerCase().includes(q));
if (altHit) out.push({ name, matchedAlias: altHit, rank: 2 });
}
out.sort((a, b) => a.rank - b.rank);
return out.slice(0, 8);
}, [suggestions, draft, lowerSelected]);
const showDropdown = focused && filtered.length > 0;
const commitValue = (raw: string) => {
const t = raw.trim();
if (!t) return;
if (!lowerSelected.has(t.toLowerCase())) {
onChange([...values, t]);
}
setDraft("");
setHighlight(0);
};
const commit = () => commitValue(draft);
const remove = (idx: number) => {
onChange(values.filter((_, i) => i !== idx));
};
return (
<div className="relative">
<div
className="flex flex-wrap items-center gap-1.5 p-2 rounded-lg glass min-h-[42px] cursor-text"
onClick={() => inputRef.current?.focus()}
>
{values.map((v, i) => (
<span
key={`${v}-${i}`}
className="flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-full text-xs border"
style={{
background: `color-mix(in oklch, ${accentVar} 14%, transparent)`,
color: accentVar,
borderColor: `color-mix(in oklch, ${accentVar} 35%, transparent)`,
}}
>
{v}
<button
type="button"
onClick={(e) => { e.stopPropagation(); remove(i); }}
aria-label={`Remove ${v}`}
className="w-4 h-4 grid place-items-center rounded-full hover:bg-black/30"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
ref={inputRef}
value={draft}
onChange={(e) => { setDraft(e.target.value); setHighlight(0); }}
onFocus={() => {
if (blurTimerRef.current) { window.clearTimeout(blurTimerRef.current); blurTimerRef.current = null; }
setFocused(true);
}}
onBlur={() => {
// Delay to allow mousedown on a suggestion to fire commit before we hide.
blurTimerRef.current = window.setTimeout(() => {
setFocused(false);
commit();
}, 120);
}}
onKeyDown={(e) => {
if (showDropdown && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
e.preventDefault();
setHighlight((h) => {
const n = filtered.length;
if (e.key === "ArrowDown") return (h + 1) % n;
return (h - 1 + n) % n;
});
return;
}
if (e.key === "Enter" || e.key === "Tab") {
if (showDropdown && filtered[highlight]) {
e.preventDefault();
commitValue(filtered[highlight].name);
return;
}
if (e.key === "Enter") {
e.preventDefault();
commit();
}
return;
}
if (e.key === ",") {
e.preventDefault();
commit();
} else if (e.key === "Escape" && showDropdown) {
setFocused(false);
} else if (e.key === "Backspace" && !draft && values.length) {
remove(values.length - 1);
}
}}
placeholder={values.length === 0 ? placeholder : ""}
className={cn(
"flex-1 min-w-[100px] bg-transparent text-sm outline-none placeholder:text-[var(--color-fg-muted)]",
)}
/>
</div>
{showDropdown && (
<div
className="absolute left-0 right-0 top-full mt-1 z-20 rounded-lg border border-[var(--color-glass-border)] bg-[var(--color-bg-0)] shadow-2xl overflow-hidden"
onMouseDown={(e) => e.preventDefault()}
>
{filtered.map((s, i) => (
<button
key={s.name}
type="button"
onMouseDown={(e) => {
e.preventDefault();
if (blurTimerRef.current) { window.clearTimeout(blurTimerRef.current); blurTimerRef.current = null; }
commitValue(s.name);
inputRef.current?.focus();
}}
onMouseEnter={() => setHighlight(i)}
className={cn(
"w-full flex items-center justify-between gap-2 px-3 py-1.5 text-sm text-left",
i === highlight ? "bg-[var(--color-glass-strong)]" : "hover:bg-[var(--color-glass)]",
)}
style={{ color: accentVar }}
>
<span>{s.name}</span>
{s.matchedAlias && (
<span className="text-[10px] font-mono text-[var(--color-fg-muted)] truncate">
{s.matchedAlias}
</span>
)}
</button>
))}
</div>
)}
</div>
);
}
+362
View File
@@ -0,0 +1,362 @@
"use client";
import { useState, useTransition } from "react";
import { Save, Pencil, X, Check, Trash2, Star, Eye, EyeOff, FileJson } from "lucide-react";
import { useRouter } from "next/navigation";
import { saveCoverMeta } from "@/app/actions/coverMeta";
import { deleteImage } from "@/app/actions/bulk";
import { useUndoDeleteToast } from "@/components/select/UndoDeleteToast";
import { useSettings } from "@/components/settings/SettingsProvider";
import { ChipInput, type ChipSuggestion } from "./ChipInput";
import { NfoImportDialog } from "./NfoImportDialog";
import type { NfoMetadata } from "@/lib/jav/nfoParser";
import { cn } from "@/lib/utils";
export interface CoverEditorInitial {
imageId: number;
code: string | null;
title: string | null;
releaseDate: string | null;
runtimeMin: number | null;
director: string | null;
studio: string | null;
label: string | null;
series: string | null;
rating: number | null;
watched: boolean;
notes: string | null;
actresses: string[];
genres: string[];
}
export function CoverEditor({
initial,
actressSuggestions,
genreSuggestions,
}: {
initial: CoverEditorInitial;
actressSuggestions?: ChipSuggestion[];
genreSuggestions?: ChipSuggestion[];
}) {
const empty = !initial.code && !initial.title && initial.actresses.length === 0;
const [editing, setEditing] = useState(empty);
const [code, setCode] = useState(initial.code ?? "");
const [title, setTitle] = useState(initial.title ?? "");
const [releaseDate, setReleaseDate] = useState(initial.releaseDate ?? "");
const [runtime, setRuntime] = useState(initial.runtimeMin?.toString() ?? "");
const [director, setDirector] = useState(initial.director ?? "");
const [studio, setStudio] = useState(initial.studio ?? "");
const [label, setLabel] = useState(initial.label ?? "");
const [series, setSeries] = useState(initial.series ?? "");
const [rating, setRating] = useState<number | null>(initial.rating);
const [watched, setWatched] = useState(initial.watched);
const [notes, setNotes] = useState(initial.notes ?? "");
const [actresses, setActresses] = useState<string[]>(initial.actresses);
const [genres, setGenres] = useState<string[]>(initial.genres);
const [saved, setSaved] = useState(false);
const [importing, setImporting] = useState(false);
const [pending, start] = useTransition();
const router = useRouter();
const applyImported = (m: NfoMetadata) => {
if (m.code) setCode(m.code);
if (m.title) setTitle(m.title);
if (m.releaseDate) setReleaseDate(m.releaseDate);
if (m.runtimeMin != null) setRuntime(String(m.runtimeMin));
if (m.director) setDirector(m.director);
if (m.studio) setStudio(m.studio);
if (m.series) setSeries(m.series);
if (m.actresses && m.actresses.length) setActresses(Array.from(new Set([...actresses, ...m.actresses])));
if (m.genres && m.genres.length) setGenres(Array.from(new Set([...genres, ...m.genres])));
if (m.notes) setNotes(m.notes);
setEditing(true);
};
const { settings } = useSettings();
const { show: showUndo } = useUndoDeleteToast();
const save = () => {
start(async () => {
await saveCoverMeta({
imageId: initial.imageId,
code: code || null,
title: title || null,
releaseDate: releaseDate || null,
runtimeMin: runtime ? Number(runtime) : null,
director: director || null,
studio: studio || null,
label: label || null,
series: series || null,
rating,
watched,
notes: notes || null,
actresses,
genres,
});
router.refresh();
setSaved(true);
setEditing(false);
setTimeout(() => setSaved(false), 1600);
});
};
const cancel = () => {
setCode(initial.code ?? "");
setTitle(initial.title ?? "");
setReleaseDate(initial.releaseDate ?? "");
setRuntime(initial.runtimeMin?.toString() ?? "");
setDirector(initial.director ?? "");
setStudio(initial.studio ?? "");
setLabel(initial.label ?? "");
setSeries(initial.series ?? "");
setRating(initial.rating);
setWatched(initial.watched);
setNotes(initial.notes ?? "");
setActresses(initial.actresses);
setGenres(initial.genres);
setEditing(false);
};
const onDelete = (e: React.MouseEvent) => {
const permanent = e.shiftKey || !settings.useRecycleBin;
if (permanent && !confirm("Permanently delete this cover? Cannot be undone.")) return;
start(async () => {
await deleteImage(initial.imageId, permanent ? { permanent: true } : undefined);
if (!permanent) showUndo([initial.imageId]);
router.push("/");
});
};
if (!editing) {
return (
<>
<div className="grid grid-cols-3 gap-chip">
<button
onClick={() => setEditing(true)}
className="flex items-center justify-center gap-1.5 text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] min-w-0"
>
<Pencil className="w-3.5 h-3.5" />
<span className="truncate">Edit Metadata</span>
</button>
<button
onClick={() => setImporting(true)}
className="flex items-center justify-center gap-1.5 text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] min-w-0"
>
<FileJson className="w-3.5 h-3.5" />
<span className="truncate">Import .nfo / JSON</span>
</button>
<button
onClick={onDelete}
className="flex items-center justify-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-[var(--color-coral)]/30 bg-[var(--color-coral)]/10 text-[var(--color-coral)] hover:bg-[var(--color-coral)]/20 hover:border-[var(--color-coral)]/50 min-w-0"
>
<Trash2 className="w-3.5 h-3.5" />
<span className="truncate">Delete</span>
</button>
</div>
{/* Status row: fixed height so the toast/hint never reflows the
buttons above. Empty placeholder retains the line so the
transition between states stays CLS-free. */}
<div className="mt-1 h-4 text-xs flex items-center">
{saved ? (
<span className="flex items-center gap-1 text-[var(--color-mint)]">
<Check className="w-3 h-3" /> Saved
</span>
) : empty ? (
<span className="text-[var(--color-fg-muted)] italic">
No metadata yet click Edit to fill in details
</span>
) : (
<span aria-hidden>&nbsp;</span>
)}
</div>
{importing && <NfoImportDialog onClose={() => setImporting(false)} onApply={applyImported} />}
</>
);
}
return (
<div className="glass-strong rounded-2xl p-5 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">{empty ? "Add Metadata" : "Edit Metadata"}</h3>
<div className="flex items-center gap-2">
<button
onClick={onDelete}
disabled={pending}
className="flex items-center gap-1 text-xs px-3 py-1.5 rounded-lg border border-[var(--color-coral)]/30 bg-[var(--color-coral)]/10 text-[var(--color-coral)] hover:bg-[var(--color-coral)]/20 hover:border-[var(--color-coral)]/50 mr-auto"
>
<Trash2 className="w-3.5 h-3.5" /> Delete cover
</button>
<button
onClick={() => setImporting(true)}
disabled={pending}
className="flex items-center gap-1 text-xs px-3 py-1.5 rounded-lg glass glass-hover"
>
<FileJson className="w-3.5 h-3.5" /> Import
</button>
{!empty && (
<button
onClick={cancel}
disabled={pending}
className="flex items-center gap-1 text-xs px-3 py-1.5 rounded-lg glass hover:text-[var(--color-fg)]"
>
<X className="w-3.5 h-3.5" /> Cancel
</button>
)}
<button
onClick={save}
disabled={pending}
className={cn(
"flex items-center gap-1 text-xs px-3 py-1.5 rounded-lg font-medium",
"bg-[var(--color-cyan)] text-black hover:shadow-[var(--shadow-glow-cyan)] disabled:opacity-50",
)}
>
<Save className="w-3.5 h-3.5" />
{pending ? "Saving…" : "Save"}
</button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Field label="Code">
<Input value={code} onChange={setCode} placeholder="SSIS-001" mono uppercase />
</Field>
<Field label="Release Date">
<Input value={releaseDate} onChange={setReleaseDate} placeholder="YYYY-MM-DD" mono />
</Field>
<Field label="Runtime (min)">
<Input value={runtime} onChange={setRuntime} type="number" placeholder="120" mono />
</Field>
</div>
<Field label="Title">
<Input value={title} onChange={setTitle} placeholder="Full release title" />
</Field>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Field label="Studio">
<Input value={studio} onChange={setStudio} placeholder="e.g. S1 NO.1 STYLE" />
</Field>
<Field label="Label">
<Input value={label} onChange={setLabel} placeholder="Sub-label" />
</Field>
<Field label="Series">
<Input value={series} onChange={setSeries} placeholder="Series name" />
</Field>
</div>
<Field label="Director">
<Input value={director} onChange={setDirector} placeholder="Optional" />
</Field>
<Field label="Actresses" hint="Press Enter to add. Type a name then Enter — duplicates are ignored.">
<ChipInput values={actresses} onChange={setActresses} placeholder="Add actress…" accent="violet" suggestions={actressSuggestions} />
</Field>
<Field label="Genres" hint="Press Enter to add.">
<ChipInput values={genres} onChange={setGenres} placeholder="Add genre…" accent="cyan" suggestions={genreSuggestions} />
</Field>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="Rating">
<RatingPicker value={rating} onChange={setRating} />
</Field>
<Field label="Watched">
<button
type="button"
onClick={() => setWatched((v) => !v)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-colors w-full",
watched
? "bg-[var(--color-mint)]/15 border-[var(--color-mint)]/40 text-[var(--color-mint)]"
: "glass text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]",
)}
>
{watched ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
{watched ? "Watched" : "Not watched"}
</button>
</Field>
</div>
<Field label="Notes">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={4}
placeholder="Personal notes, plot summary, anything you want to remember."
className="w-full bg-[var(--color-bg-0)]/40 rounded-lg p-3 text-sm outline-none border border-[var(--color-glass-border)] focus:border-[var(--color-cyan)] resize-y leading-relaxed"
/>
</Field>
{importing && <NfoImportDialog onClose={() => setImporting(false)} onApply={applyImported} />}
</div>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div>
<label className="block text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-1.5">
{label}
</label>
{children}
{hint && <div className="text-[10px] text-[var(--color-fg-muted)] mt-1 italic">{hint}</div>}
</div>
);
}
function Input({
value, onChange, type = "text", placeholder, mono, uppercase,
}: {
value: string;
onChange: (s: string) => void;
type?: "text" | "number";
placeholder?: string;
mono?: boolean;
uppercase?: boolean;
}) {
return (
<input
value={value}
onChange={(e) => onChange(uppercase ? e.target.value.toUpperCase() : e.target.value)}
type={type}
placeholder={placeholder}
className={cn(
"w-full bg-[var(--color-bg-0)]/40 rounded-md px-2.5 py-1.5 text-sm outline-none border border-[var(--color-glass-border)] focus:border-[var(--color-cyan)]",
mono && "font-mono",
)}
/>
);
}
function RatingPicker({ value, onChange }: { value: number | null; onChange: (n: number | null) => void }) {
return (
<div className="flex items-center gap-1 px-2 py-2 rounded-lg glass">
{[1, 2, 3, 4, 5].map((n) => {
const filled = value != null && n <= value;
return (
<button
key={n}
type="button"
onClick={() => onChange(value === n ? null : n)}
aria-label={`${n} star${n === 1 ? "" : "s"}`}
className="p-0.5 transition-transform hover:scale-110"
>
<Star
className={cn(
"w-5 h-5 transition-colors",
filled ? "fill-[var(--color-cyan)] text-[var(--color-cyan)]" : "text-[var(--color-fg-muted)]",
)}
/>
</button>
);
})}
{value != null && (
<button
type="button"
onClick={() => onChange(null)}
className="ml-2 text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
>
clear
</button>
)}
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { X, Upload, FileJson, AlertCircle, Check } from "lucide-react";
import { parseMetaAny } from "@/lib/jav/metaImport";
import type { NfoMetadata } from "@/lib/jav/nfoParser";
interface Props {
onClose: () => void;
onApply: (meta: NfoMetadata) => void;
}
export function NfoImportDialog({ onClose, onApply }: Props) {
const [text, setText] = useState("");
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<NfoMetadata | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
function tryParse(raw: string) {
setError(null);
if (!raw.trim()) { setPreview(null); return; }
const m = parseMetaAny(raw);
if (!m) {
setPreview(null);
setError("Couldn't recognize this as a .nfo XML or metadata JSON.");
return;
}
setPreview(m);
}
async function onFile(file: File) {
const t = await file.text();
setText(t);
tryParse(t);
}
function apply() {
if (preview) {
onApply(preview);
onClose();
}
}
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(640px,calc(100vw-32px))] max-h-[calc(100vh-32px)] overflow-y-auto">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<FileJson className="w-5 h-5 text-[var(--color-cyan)]" />
<div>
<div className="text-base font-medium">Import Metadata</div>
<div className="text-[11px] text-[var(--color-fg-muted)]">From a .nfo (XML) file or metadata JSON</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">
<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)]">.nfo, .xml, .json</span>
<input
ref={fileRef}
type="file"
accept=".nfo,.xml,.json,application/xml,text/xml,application/json"
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); tryParse(e.target.value); }}
placeholder='Paste XML or JSON here…&#10;&#10;Example JSON:&#10;{ "code": "SSIS-001", "title": "...", "actresses": ["Ichika Matsumoto"] }'
rows={10}
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"
/>
{error && (
<div className="mt-3 flex items-start gap-2 text-xs text-red-300">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" /> {error}
</div>
)}
{preview && (
<div className="mt-4 glass rounded-xl p-3 text-xs space-y-1">
<div className="flex items-center gap-1.5 text-[var(--color-mint)] mb-2">
<Check className="w-3.5 h-3.5" />
<span className="uppercase tracking-wider font-mono text-[10px]">Parsed</span>
</div>
<PreviewRow k="Code" v={preview.code} />
<PreviewRow k="Title" v={preview.title} />
<PreviewRow k="Released" v={preview.releaseDate} />
<PreviewRow k="Runtime" v={preview.runtimeMin != null ? `${preview.runtimeMin} min` : undefined} />
<PreviewRow k="Director" v={preview.director} />
<PreviewRow k="Studio" v={preview.studio} />
<PreviewRow k="Series" v={preview.series} />
<PreviewRow k="Actresses" v={preview.actresses?.join(", ")} />
<PreviewRow k="Genres" v={preview.genres?.join(", ")} />
<PreviewRow k="Notes" v={preview.notes ? `${preview.notes.slice(0, 120)}${preview.notes.length > 120 ? "…" : ""}` : undefined} />
</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={apply}
disabled={!preview}
className="flex-1 text-sm px-3 py-2 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
>
Apply to form
</button>
</div>
</div>
</div>,
document.body,
);
}
function PreviewRow({ k, v }: { k: string; v: string | undefined }) {
if (!v) return null;
return (
<div className="grid grid-cols-[80px_1fr] gap-2">
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">{k}</span>
<span className="text-[var(--color-fg)] break-words">{v}</span>
</div>
);
}
@@ -0,0 +1,99 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Pencil, Check, X, Loader2 } from "lucide-react";
interface Props {
initialName: string;
/** Server action that renames and returns the new slug/name (or null on no-op). */
onRename: (name: string) => Promise<{ slug?: string; name?: string } | null>;
/** Path prefix for the post-rename redirect (e.g. "/studios/"). The new slug or URL-encoded name is appended. */
redirectBase?: string;
/** Which field of the rename result to append to redirectBase. Defaults to "slug". */
redirectKey?: "slug" | "name";
}
export function EntityRenameInline({ initialName, onRename, redirectBase, redirectKey = "slug" }: Props) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(initialName);
const [pending, start] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { setValue(initialName); }, [initialName]);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
function cancel() {
setEditing(false);
setValue(initialName);
}
function save() {
const next = value.trim();
if (!next || next === initialName) { cancel(); return; }
start(async () => {
const r = await onRename(next);
setEditing(false);
if (r && redirectBase) {
const key = redirectKey === "name" ? r.name : r.slug;
if (key) {
router.push(`${redirectBase}${redirectKey === "name" ? encodeURIComponent(key) : key}`);
return;
}
}
router.refresh();
});
}
if (!editing) {
return (
<button
type="button"
onClick={() => setEditing(true)}
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-lg glass text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
title="Rename"
>
<Pencil className="w-3.5 h-3.5" /> Rename
</button>
);
}
return (
<form
onSubmit={(e) => { e.preventDefault(); save(); }}
className="flex items-center gap-1"
>
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { if (e.key === "Escape") cancel(); }}
maxLength={120}
disabled={pending}
className="glass rounded-lg px-2 py-1 text-sm outline-none focus:border-[var(--color-cyan)] w-64"
/>
<button
type="submit"
disabled={pending || !value.trim() || value.trim() === initialName}
className="flex items-center justify-center w-7 h-7 rounded-lg bg-[var(--color-cyan)] text-black disabled:opacity-40"
title="Save"
>
{pending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
</button>
<button
type="button"
onClick={cancel}
disabled={pending}
className="flex items-center justify-center w-7 h-7 rounded-lg glass text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
title="Cancel"
>
<X className="w-3.5 h-3.5" />
</button>
</form>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FilterCriteria, FilterTabKey, StatusAxisKey } from "@/lib/filters";
import { anyActive, EMPTY_STATUS } from "@/lib/filters";
import { FILTER_TABS, type FilterOption } from "./MultiFilterPopover";
import { useSelection } from "@/components/select/SelectionProvider";
const WATCHED_LABELS: Record<string, string> = { watched: "Watched", unwatched: "Unwatched" };
const RATED_LABELS: Record<string, string> = { rated: "Rated", unrated: "No Rating" };
const PRESENCE_LABELS: Record<string, string> = { has: "Has", missing: "No" };
function pillFor(axis: StatusAxisKey, value: string): string | null {
if (value === "all") return null;
if (axis === "watched") return WATCHED_LABELS[value] ?? null;
if (axis === "rated") return RATED_LABELS[value] ?? null;
if (axis === "collection") return value === "has" ? "Has Collection" : "No Collection";
if (axis === "tags") return value === "has" ? "Has Tags" : "No Tags";
if (axis === "video") return value === "has" ? "Has Video" : "No Video";
return null;
}
const STATUS_AXES: StatusAxisKey[] = ["watched", "rated", "collection", "tags", "video"];
export function ActiveCriteriaStrip({
criteria,
options,
onChange,
}: {
criteria: FilterCriteria;
options: Record<FilterTabKey, FilterOption[]>;
onChange: (next: FilterCriteria) => void;
}) {
// SSR has no document, so defer the portal until after mount.
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
const sel = useSelection();
const selectionActive = sel.ids.size > 0;
if (!anyActive(criteria)) return null;
if (!mounted) return null;
const tabSections: Array<{ key: FilterTabKey; label: string; Icon: React.ComponentType<{ className?: string }>; pills: FilterOption[] }> = [];
for (const t of FILTER_TABS) {
const ids = criteria.ids[t.key];
if (ids.length === 0) continue;
const optionMap = new Map(options[t.key].map((o) => [o.id, o]));
const pills = ids.map((id) => optionMap.get(id)).filter((o): o is FilterOption => !!o);
if (pills.length > 0) tabSections.push({ key: t.key, label: t.label, Icon: t.Icon, pills });
}
function removeId(tab: FilterTabKey, id: number) {
onChange({ ...criteria, ids: { ...criteria.ids, [tab]: criteria.ids[tab].filter((x) => x !== id) } });
}
function resetAxis(key: StatusAxisKey) {
onChange({ ...criteria, status: { ...criteria.status, [key]: "all" } as typeof criteria.status });
}
function removeMark(m: typeof criteria.marks[number]) {
onChange({ ...criteria, marks: criteria.marks.filter((x) => x !== m) });
}
function clearAll() {
onChange({
ids: { actresses: [], studios: [], series: [], genres: [], collections: [], tags: [], categories: [] },
mode: criteria.mode,
status: { ...EMPTY_STATUS },
marks: [],
});
}
return createPortal(
<div
className="fixed left-1/2 -translate-x-1/2 z-40 flex items-center gap-1.5 flex-wrap py-2 px-3 rounded-2xl border border-[var(--color-glass-border-strong)] shadow-2xl backdrop-blur-2xl"
style={{
bottom: selectionActive ? "76px" : "20px",
background: "color-mix(in oklch, var(--color-bg-0) 88%, transparent)",
width: "max-content",
maxWidth: "min(96vw, 1600px)",
}}
>
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mr-1">Active Filters</span>
{tabSections.map((section, sIdx) => (
<span key={section.key} className="inline-flex items-center gap-1 flex-wrap">
{section.pills.map((p, i) => (
<span key={p.id} className="inline-flex items-center gap-1">
<Pill kind="cyan" Icon={section.Icon} label={p.name} onRemove={() => removeId(section.key, p.id)} />
{i < section.pills.length - 1 && (
<span className="text-[10px] font-mono text-[var(--color-fg-muted)] px-0.5">
{criteria.mode[section.key].toUpperCase()}
</span>
)}
</span>
))}
{sIdx < tabSections.length - 1 && (
<span className="text-[10px] font-mono text-[var(--color-violet)] px-1">AND</span>
)}
</span>
))}
{STATUS_AXES.map((axis) => {
const label = pillFor(axis, criteria.status[axis]);
if (!label) return null;
return <Pill key={axis} kind="coral" label={label} onRemove={() => resetAxis(axis)} />;
})}
{criteria.marks.map((m) => (
<Pill
key={m}
kind={m === "vip" ? "cyan" : m === "favorite" ? "amber" : m === "owned" ? "violet" : "coral"}
label={m === "vip" ? "VIP" : m === "favorite" ? "Favorite" : m === "owned" ? "Owned" : "Unmarked"}
onRemove={() => removeMark(m)}
/>
))}
<button
type="button"
onClick={clearAll}
className="ml-auto text-[11px] text-[var(--color-fg-muted)] hover:text-[var(--color-coral)] underline"
>
Clear All
</button>
</div>,
document.body,
);
}
function Pill({
kind,
label,
Icon,
onRemove,
}: {
kind: "cyan" | "coral" | "amber" | "violet";
label: string;
Icon?: React.ComponentType<{ className?: string }>;
onRemove: () => void;
}) {
const cls =
kind === "cyan"
? "bg-[var(--color-cyan)]/12 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
: kind === "amber"
? "border-amber-400/40 text-amber-200"
: kind === "violet"
? "bg-[var(--color-violet)]/12 border-[var(--color-violet)]/40 text-[var(--color-violet)]"
: "bg-[var(--color-coral)]/12 border-[var(--color-coral)]/40 text-[var(--color-coral)]";
return (
<span
className={cn("inline-flex items-center gap-1.5 pl-2.5 pr-1 py-0.5 rounded-full border text-xs font-mono", cls)}
style={kind === "amber" ? { background: "rgba(251,191,36,0.12)" } : undefined}
>
{Icon && <Icon className="w-3 h-3 opacity-70" />}
{label}
<button
type="button"
onClick={onRemove}
className="w-4 h-4 grid place-items-center rounded-full bg-black/30 hover:bg-[var(--color-coral)]/40 hover:text-white"
aria-label={`Remove ${label}`}
>
<X className="w-2.5 h-2.5" />
</button>
</span>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { listAllTags, listAllCollections, listAllActresses, listAllStudios, listAllSeries, listAllGenres, listAllTagCategories } from "@/lib/db/queries";
import { FilterBarClient } from "./FilterBarClient";
import type { FilterTabKey, FilterCriteria } from "@/lib/filters";
import type { FilterOption } from "./MultiFilterPopover";
import type { SortKey } from "@/lib/sort";
import type { LibraryView } from "./ViewToggle";
export type FilterContext =
| { kind: "all" }
| { kind: "tag"; name: string }
| { kind: "collection"; id: number; name: string }
| { kind: "actress"; name: string }
| { kind: "studio"; name: string }
| { kind: "series"; name: string }
| { kind: "genre"; name: string }
| { kind: "label"; name: string }
| { kind: "category"; name: string }
| { kind: "search"; query: string };
export interface FilterBarProps {
current?: FilterContext;
criteria: FilterCriteria;
sort?: SortKey;
view?: LibraryView;
}
export function FilterBar({ current = { kind: "all" }, criteria, sort, view }: FilterBarProps) {
const actresses = listAllActresses();
const studios = listAllStudios();
const seriesList = listAllSeries();
const genres = listAllGenres();
const collections = listAllCollections();
const tags = listAllTags();
const categories = listAllTagCategories();
const options: Record<FilterTabKey, FilterOption[]> = {
actresses: actresses.map((a) => ({ id: a.id, name: a.name, count: a.count })),
studios: studios.map((s) => ({ id: s.id, name: s.name, count: s.count })),
series: seriesList.map((s) => ({ id: s.id, name: s.name, count: s.count })),
genres: genres.map((g) => ({ id: g.id, name: g.name, count: g.count })),
collections: collections.map((c) => ({ id: c.id, name: c.name, count: c.count })),
tags: tags.map((t) => ({ id: t.id, name: t.name, count: t.count })),
categories: categories.map((c) => ({ id: c.id, name: c.name, count: c.imageCount })),
};
return (
<FilterBarClient
criteria={criteria}
options={options}
isHome={current.kind === "all"}
showSort={!!sort}
sort={sort}
view={view}
/>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import type { FilterCriteria, FilterTabKey } from "@/lib/filters";
import { writeFilterCriteria, anyActive, EMPTY_STATUS } from "@/lib/filters";
import { MultiFilterPopover, type FilterOption } from "./MultiFilterPopover";
import { MergedFilterPopover } from "./MergedFilterPopover";
import { MarkActionPopover } from "./MarkActionPopover";
import { ActiveCriteriaStrip } from "./ActiveCriteriaStrip";
import { GridSearchInput } from "./GridSearchInput";
import { SortMenu } from "./SortMenu";
import { ViewToggle, type LibraryView } from "./ViewToggle";
import { InfiniteScrollToggle } from "./InfiniteScrollToggle";
import type { SortKey } from "@/lib/sort";
export function FilterBarClient({
criteria,
options,
isHome,
showSort,
sort,
view,
}: {
criteria: FilterCriteria;
options: Record<FilterTabKey, FilterOption[]>;
isHome: boolean;
showSort: boolean;
sort?: SortKey;
view?: LibraryView;
}) {
const router = useRouter();
const params = useSearchParams();
const [, start] = useTransition();
function pushCriteria(next: FilterCriteria) {
const sp = new URLSearchParams(params.toString());
writeFilterCriteria(sp, next);
const y = typeof window !== "undefined" ? window.scrollY : 0;
start(() => {
router.push(`?${sp.toString()}`, { scroll: false });
// Defensive: restore scroll on the next two frames in case Next still resets.
requestAnimationFrame(() => window.scrollTo({ top: y, left: 0, behavior: "instant" as ScrollBehavior }));
requestAnimationFrame(() => window.scrollTo({ top: y, left: 0, behavior: "instant" as ScrollBehavior }));
});
}
const active = anyActive(criteria);
return (
<>
<div className="flex items-center gap-2 mb-3 flex-wrap">
{isHome ? (
<button
type="button"
onClick={() => pushCriteria({
ids: { actresses: [], studios: [], series: [], genres: [], collections: [], tags: [], categories: [] },
mode: criteria.mode,
status: { ...EMPTY_STATUS },
marks: [],
})}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors",
!active
? "bg-[var(--color-cyan)]/15 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
: "glass glass-hover text-[var(--color-fg-dim)]",
)}
>
ALL
</button>
) : (
<Link
href="/"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm glass glass-hover text-[var(--color-fg-dim)]"
>
ALL
</Link>
)}
<MultiFilterPopover criteria={criteria} options={options} onChange={pushCriteria} />
<MergedFilterPopover criteria={criteria} onChange={pushCriteria} />
<MarkActionPopover />
<div className="ml-auto flex items-center gap-2">
<GridSearchInput />
{showSort && sort && <SortMenu activeSort={sort} />}
{isHome && <InfiniteScrollToggle />}
{view && <ViewToggle current={view} />}
</div>
</div>
<ActiveCriteriaStrip criteria={criteria} options={options} onChange={pushCriteria} />
</>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useMemo, useRef, useState, useCallback } from "react";
import Link from "next/link";
import { ChevronDown, Search, Tag, FolderHeart, Users, Building2, Film, Hash, X } from "lucide-react";
import { useClickOutside } from "@/lib/hooks/useClickOutside";
import { cn } from "@/lib/utils";
export interface FilterOption {
id: string | number;
label: string;
href: string;
count?: number;
}
const ICONS = {
tag: Tag,
folder: FolderHeart,
actress: Users,
studio: Building2,
series: Film,
genre: Hash,
} as const;
export type FilterIconKey = keyof typeof ICONS;
export function FilterDropdown({
label,
iconKey,
options,
emptyMsg = "Nothing here yet",
align = "left",
activeLabel,
clearHref,
}: {
label: string;
iconKey?: FilterIconKey;
options: FilterOption[];
emptyMsg?: string;
align?: "left" | "right";
activeLabel?: string;
clearHref?: string;
}) {
const Icon = iconKey ? ICONS[iconKey] : null;
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, useCallback(() => setOpen(false), []), open);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
return q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options;
}, [options, filter]);
const isActive = activeLabel != null;
return (
<div ref={ref} className="relative">
<div
className={cn(
"flex items-center rounded-full border text-sm transition-colors",
isActive
? "bg-[var(--color-cyan)]/15 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
: "glass glass-hover text-[var(--color-fg-dim)]",
)}
>
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1.5 pl-3 pr-2 py-1.5"
>
{Icon && <Icon className="w-3.5 h-3.5" />}
<span>{label}{isActive ? `: ${activeLabel}` : ""}</span>
<ChevronDown className={cn("w-3 h-3 opacity-60 transition-transform", open && "rotate-180")} />
</button>
{isActive && clearHref && (
<Link
href={clearHref}
aria-label="Clear filter"
className="pr-2 pl-1 py-1.5 hover:opacity-70"
>
<X className="w-3.5 h-3.5" />
</Link>
)}
</div>
{open && (
<div
className={cn(
"absolute top-full mt-2 z-30 w-64 rounded-xl shadow-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden",
align === "right" ? "right-0" : "left-0",
)}
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
<div className="relative p-2 border-b border-[var(--color-glass-border)]">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-fg-muted)] pointer-events-none" />
<input
autoFocus
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={`Filter ${label.toLowerCase()}`}
className="w-full bg-[var(--color-bg-1)]/60 text-xs pl-7 pr-2 py-1.5 rounded-md border border-[var(--color-glass-border)] outline-none focus:border-[var(--color-cyan)]"
/>
</div>
{filtered.length === 0 ? (
<div className="px-3 py-4 text-xs text-[var(--color-fg-muted)] italic text-center">
{filter ? "No matches" : emptyMsg}
</div>
) : (
<div className="max-h-72 overflow-y-auto py-1">
{filtered.map((o) => (
<Link
key={o.id}
href={o.href}
onClick={() => setOpen(false)}
className="flex items-center justify-between gap-2 px-3 py-1.5 text-sm hover:bg-[var(--color-glass)]"
>
<span className="truncate">{o.label}</span>
{o.count != null && (
<span className="text-xs font-mono text-[var(--color-fg-muted)] tabular-nums flex-shrink-0">
{o.count}
</span>
)}
</Link>
))}
</div>
)}
</div>
)}
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Search, X } from "lucide-react";
export function GridSearchInput() {
const router = useRouter();
const params = useSearchParams();
const initial = params.get("q") ?? "";
const [value, setValue] = useState(initial);
const debounce = useRef<number | null>(null);
const lastApplied = useRef(initial);
useEffect(() => {
return () => {
if (debounce.current) window.clearTimeout(debounce.current);
};
}, []);
// Sync state from URL (e.g. when navigating, "All" link clears it).
useEffect(() => {
const fromUrl = params.get("q") ?? "";
if (fromUrl !== lastApplied.current) {
lastApplied.current = fromUrl;
setValue(fromUrl);
}
}, [params]);
function apply(next: string) {
if (next === lastApplied.current) return;
lastApplied.current = next;
const sp = new URLSearchParams(params.toString());
if (next.trim()) {
sp.set("q", next.trim());
// Activating search clears the letter filter so the user sees all matches.
sp.delete("letter");
} else {
sp.delete("q");
}
router.push(`?${sp.toString()}`, { scroll: false });
}
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const next = e.target.value;
setValue(next);
if (debounce.current) window.clearTimeout(debounce.current);
debounce.current = window.setTimeout(() => apply(next), 300);
}
function clear() {
setValue("");
if (debounce.current) window.clearTimeout(debounce.current);
apply("");
}
return (
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={value}
onChange={onChange}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (debounce.current) window.clearTimeout(debounce.current);
apply(value);
} else if (e.key === "Escape") {
clear();
}
}}
placeholder="Search Code, Title, Notes…"
className="glass rounded-lg pl-8 pr-7 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)] w-56"
/>
{value && (
<button
type="button"
onClick={clear}
aria-label="Clear search"
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
}
+420
View File
@@ -0,0 +1,420 @@
"use client";
import Link from "next/link";
import { memo, useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Check, Star, Eye, EyeOff, Gem, Package, Play, Captions } from "lucide-react";
import { useSelection } from "@/components/select/SelectionProvider";
import { ImageContextMenu } from "@/components/grid/ImageContextMenu";
import { cn, coverHref } from "@/lib/utils";
import { thumbUrl } from "@/lib/assetUrls";
import { setWatched, setCoverVip, setCoverFavorite, setCoverOwned } from "@/app/actions/coverMeta";
import { dispatchQueueRemove } from "@/components/queue/watchQueueEvents";
import { useVideoIndex } from "@/components/video/VideoIndexProvider";
import { VideoPlayerModal } from "@/components/video/VideoPlayerModal";
export interface CardImage {
id: number;
thumbPath: string;
width: number;
height: number;
code: string | null;
title: string | null;
rating: number | null;
watched: boolean;
isVip: boolean;
isFavorite: boolean;
isOwned: boolean;
studioName: string | null;
actresses: Array<{ id: number; name: string; slug: string }>;
/** Mirror of images.has_video — server-rendered fallback so the
* play button shows correctly before the client-side video index
* provider hydrates. */
hasVideo?: boolean;
/** Mirror of images.has_subtitle. Same reason as hasVideo. */
hasSubtitle?: boolean;
}
function ImageCardImpl({ image, view = "landscape" }: { image: CardImage; view?: "portrait" | "landscape" }) {
const sel = useSelection();
const router = useRouter();
const selected = sel.has(image.id);
const anySelected = sel.ids.size > 0;
// Snapshot imageIds at the moment the context menu opens. Otherwise the
// prop array reference changes on every parent re-render, retriggering
// the menu's data-fetch effect and risking the menu acting on a
// selection that drifted between right-click and click.
const [menuPos, setMenuPos] = useState<{ x: number; y: number; ids: number[] } | null>(null);
const [watched, setLocalWatched] = useState(image.watched);
const [vip, setLocalVip] = useState(image.isVip);
const [favorite, setLocalFavorite] = useState(image.isFavorite);
const [owned, setLocalOwned] = useState(image.isOwned);
const [playing, setPlaying] = useState(false);
const videoIdx = useVideoIndex();
// Provider is the live source of truth once it has scanned. Until
// then (cold boot of the server before /api/video-status responds)
// fall back to the SSR'd flags from the DB so play buttons / CC
// chips don't flicker in.
const providerReady = videoIdx.lastScannedAt > 0;
const hasVideo = providerReady ? videoIdx.hasVideo(image.code) : !!image.hasVideo;
const hasSubtitle = providerReady ? videoIdx.hasSubtitle(image.code) : !!image.hasSubtitle;
const [, startMutate] = useTransition();
useEffect(() => { setLocalWatched(image.watched); }, [image.watched]);
useEffect(() => { setLocalVip(image.isVip); }, [image.isVip]);
useEffect(() => { setLocalFavorite(image.isFavorite); }, [image.isFavorite]);
useEffect(() => { setLocalOwned(image.isOwned); }, [image.isOwned]);
const toggleWatched = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const next = !watched;
const prev = watched;
setLocalWatched(next);
startMutate(async () => {
try {
await setWatched(image.id, next);
if (next) dispatchQueueRemove(image.id);
router.refresh();
} catch (err) {
setLocalWatched(prev);
console.error("[toggleWatched] failed:", err);
}
});
};
const toggleVip = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const next = !vip;
const prevVip = vip;
const prevFav = favorite;
setLocalVip(next);
if (next) setLocalFavorite(false); // mutually exclusive
startMutate(async () => {
try {
await setCoverVip(image.id, next);
router.refresh();
} catch (err) {
setLocalVip(prevVip);
setLocalFavorite(prevFav);
console.error("[toggleVip] failed:", err);
}
});
};
const toggleFavorite = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const next = !favorite;
const prevFav = favorite;
const prevVip = vip;
setLocalFavorite(next);
if (next) setLocalVip(false); // mutually exclusive
startMutate(async () => {
try {
await setCoverFavorite(image.id, next);
router.refresh();
} catch (err) {
setLocalFavorite(prevFav);
setLocalVip(prevVip);
console.error("[toggleFavorite] failed:", err);
}
});
};
const toggleOwned = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const next = !owned;
const prev = owned;
setLocalOwned(next);
startMutate(async () => {
try {
await setCoverOwned(image.id, next);
router.refresh();
} catch (err) {
setLocalOwned(prev);
console.error("[toggleOwned] failed:", err);
}
});
};
const handleCheckbox = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
sel.toggle(image.id);
};
const handleCardClick = (e: React.MouseEvent) => {
if (anySelected) {
e.preventDefault();
sel.toggle(image.id);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
const ids = sel.has(image.id) ? Array.from(sel.ids) : [image.id];
setMenuPos({ x: e.clientX, y: e.clientY, ids });
};
return (
<>
<Link
href={coverHref(image)}
onClick={handleCardClick}
onContextMenu={handleContextMenu}
draggable={false}
className={cn(
// No `glass` here — backdrop-filter on every card kills scroll
// FPS, and the card root is fully covered by the cover image
// anyway. Inner overlays (badges, pills) keep their blurs.
"cover-hero-frame group relative flex flex-col justify-end rounded-2xl overflow-hidden bg-[var(--color-glass)] border border-[var(--color-glass-border)] cursor-default transition-shadow hover:border-[var(--color-glass-border-strong)]",
selected && "ring-2 ring-[var(--color-cyan)] shadow-[var(--shadow-glow-cyan)]",
anySelected && !selected && "opacity-70 hover:opacity-100",
)}
style={{ breakInside: "avoid" } as React.CSSProperties}
>
<div className="cover-hero-hover relative">
{view === "portrait" ? (
// JAV covers are composite back+spine+front, ~800×538 with the
// front taking the rightmost ~373×538. We crop to that aspect
// by anchoring the thumb to the right and scaling to fit
// height — pure CSS, no extra fetch.
<div
className="w-full block transition-transform duration-500 group-hover:scale-[1.02]"
style={{
aspectRatio: "373 / 538",
backgroundImage: `url(${thumbUrl({ thumbPath: image.thumbPath, code: image.code, id: image.id })})`,
backgroundSize: "auto 100%",
backgroundPosition: "right center",
backgroundRepeat: "no-repeat",
}}
role="img"
aria-label={image.title ?? image.code ?? ""}
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={thumbUrl({ thumbPath: image.thumbPath, code: image.code, id: image.id })}
alt={image.title ?? image.code ?? ""}
loading="lazy"
draggable={false}
width={image.width}
height={image.height}
className="w-full h-auto block transition-transform duration-500 group-hover:scale-[1.02]"
/>
)}
{hasVideo && (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setPlaying(true); }}
aria-label="Play video"
title="Play video"
className={cn(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20 inline-flex items-center justify-center backdrop-blur-md text-white/95 cursor-pointer transition-colors hover:text-[var(--color-cyan)] hover:[animation:play-pulse_1.2s_ease-out_infinite] active:scale-95",
view === "portrait" ? "w-16 h-11 rounded-md" : "w-20 h-14 rounded-lg",
)}
style={{
background: "rgba(20,20,28,0.75)",
border: 0,
boxShadow: "0 6px 16px rgba(0,0,0,0.55), 0 1px 2px rgba(0,0,0,0.5)",
}}
>
<Play className={view === "portrait" ? "w-[18px] h-[18px]" : "w-6 h-6"} style={{ fill: "currentColor" }} />
</button>
)}
<button
onClick={handleCheckbox}
aria-label={selected ? "Deselect" : "Select"}
className={cn(
"absolute top-3 right-3 z-20 w-8 h-8 rounded-md grid place-items-center transition-all backdrop-blur-md border-2",
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>
{hasVideo && !vip && !favorite && (
<span
className="absolute top-3 left-3 z-10 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 shadow-md"
style={{
color: "var(--color-cyan)",
border: "1px solid color-mix(in oklch, var(--color-cyan) 60%, transparent)",
textShadow: "0 1px 2px rgba(0,0,0,0.9)",
}}
title="Has playable video"
>
<Play className="w-3 h-3" style={{ fill: "currentColor" }} /> Video
</span>
)}
{(vip || favorite) && (
<span
className="absolute top-3 left-3 z-10 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: vip ? "var(--color-cyan)" : "#fbbf24",
border: `1px solid ${vip ? "var(--color-cyan)" : "#fbbf24"}aa`,
textShadow: "0 1px 2px rgba(0,0,0,0.9)",
}}
>
{vip ? <Gem className="w-3 h-3" /> : <Star className="w-3 h-3" style={{ fill: "#fbbf24" }} />}
{vip ? "VIP" : "Favorite"}
</span>
)}
<div
className={cn(
"absolute right-3 z-20 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity",
view === "portrait"
? "bottom-7 flex-col items-end"
: "bottom-3 flex-row items-center",
)}
>
<button
type="button"
onClick={toggleVip}
title={vip ? "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",
vip
? "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>
<button
type="button"
onClick={toggleFavorite}
title={favorite ? "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",
favorite
? "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", favorite && "fill-amber-200")} />
</button>
<button
type="button"
onClick={toggleWatched}
title={watched ? "Mark as not watched" : "Mark as watched"}
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-emerald-300 hover:shadow-lg active:scale-95",
watched
? "bg-emerald-400/40 text-emerald-200 hover:bg-emerald-400/60"
: "bg-black/70 text-white hover:bg-emerald-400/30 hover:text-emerald-200",
)}
>
{watched ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
<button
type="button"
onClick={toggleOwned}
title={owned ? "Unmark Owned" : "Mark Owned"}
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-violet-300 hover:shadow-lg active:scale-95",
owned
? "bg-violet-400/40 text-violet-200 hover:bg-violet-400/60"
: "bg-black/70 text-white hover:bg-violet-400/30 hover:text-violet-200",
)}
>
<Package className={cn("w-4 h-4", owned && "fill-violet-200/20")} />
</button>
</div>
<div className="absolute inset-x-0 bottom-0 z-10 p-3 pt-12 bg-gradient-to-t from-black/90 via-black/60 to-transparent">
{image.code && (
<div className="flex items-center gap-2 min-w-0">
<span
className="text-base uppercase tracking-wider font-mono font-bold text-[var(--color-cyan)] truncate"
style={{ textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
>
{image.code}
</span>
{hasSubtitle && (
<span
title={hasVideo ? "Has playable video and subtitles" : "Has subtitle file"}
className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider font-mono font-semibold px-1.5 py-0.5 rounded border border-[var(--color-mint)]/50 bg-black/60 backdrop-blur-md text-[var(--color-mint)] shrink-0"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
>
<Captions className="w-3 h-3" /> CC
</span>
)}
</div>
)}
{image.actresses.length > 0 && (
<div
className="text-xs text-white/75 truncate mt-0.5"
style={{ textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
>
{image.actresses.map((a, i) => (
<span key={a.id}>
{i > 0 && <span className="text-white/40">, </span>}
{/* Programmatic navigation rather than <Link>: HTML
forbids nested <a> elements, and the cover card
is wrapped in a Link of its own. */}
<span
role="link"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
router.push(`/actress/${a.slug}`);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
router.push(`/actress/${a.slug}`);
}
}}
className="cursor-pointer hover:text-[var(--color-violet)] hover:underline underline-offset-2"
>
{a.name}
</span>
</span>
))}
</div>
)}
</div>
</div>
</Link>
{menuPos && (
<ImageContextMenu
imageIds={menuPos.ids}
singleHref={menuPos.ids.length > 1 ? null : coverHref(image)}
x={menuPos.x}
y={menuPos.y}
onClose={() => setMenuPos(null)}
/>
)}
{playing && image.code && (
<VideoPlayerModal
code={image.code}
actresses={image.actresses}
onClose={() => setPlaying(false)}
/>
)}
</>
);
}
// Memoized so cards don't re-render on every Virtuoso scroll tick or
// parent state change. Equality is shallow on `image` + `view` — the
// card's mutable state (selection, watched, vip, etc.) is held inside
// the component itself, so a stable `image` reference + same `view`
// safely skip the re-render.
export const ImageCard = memo(ImageCardImpl, (a, b) =>
a.view === b.view && a.image === b.image,
);
+541
View File
@@ -0,0 +1,541 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
import { createPortal } from "react-dom";
import { Tag as TagIcon, FolderHeart, Trash2, Search, Gem, Star, Eye, Package, Loader2, ChevronRight, ListVideo } from "lucide-react";
import { useRouter } from "next/navigation";
import { fetchImageContextData } from "@/app/actions/imageMeta";
import { addTagToImage, removeTagFromImage } from "@/app/actions/tags";
import { addImageToCollection, removeImageFromCollection, createCollection } from "@/app/actions/collections";
import { setCoverVip, setCoverFavorite, setWatched, setCoverOwned } from "@/app/actions/coverMeta";
import { bulkSetMark, bulkSetWatched, bulkSetOwned, deleteImages } from "@/app/actions/bulk";
import { useClickOutside } from "@/lib/hooks/useClickOutside";
import { useUndoDeleteToast } from "@/components/select/UndoDeleteToast";
import { useSettings } from "@/components/settings/SettingsProvider";
import { useWatchQueue } from "@/components/queue/WatchQueueProvider";
import { dispatchQueueRemove } from "@/components/queue/watchQueueEvents";
import { cn } from "@/lib/utils";
import type { ContextData, ContextTagOption, ContextCollectionOption } from "@/lib/db/queries";
type SubmenuKey = "tags" | "collections" | null;
export function ImageContextMenu({
imageIds,
x,
y,
onClose,
}: {
imageIds: number[];
/** Single-cover convenience link href; only relevant in 1-cover mode. */
singleHref?: string | null;
x: number;
y: number;
onClose: () => void;
}) {
const isBulk = imageIds.length > 1;
const ref = useRef<HTMLDivElement>(null);
const [data, setData] = useState<ContextData | null>(null);
const [submenu, setSubmenu] = useState<SubmenuKey>(null);
const [, start] = useTransition();
const router = useRouter();
const { show: showUndo } = useUndoDeleteToast();
const { settings } = useSettings();
const queue = useWatchQueue();
const queuedCount = imageIds.reduce((acc, id) => acc + (queue.has(id) ? 1 : 0), 0);
const allQueued = queuedCount === imageIds.length && imageIds.length > 0;
useClickOutside(ref, onClose);
useEffect(() => {
let live = true;
fetchImageContextData(imageIds).then((d) => { if (live) setData(d); });
return () => { live = false; };
}, [imageIds]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (submenu) setSubmenu(null);
else onClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, submenu]);
// Header — derive from cover info when available.
const header = useMemo(() => {
if (!data) return { title: isBulk ? `${imageIds.length} covers` : `Image #${imageIds[0]}`, sub: null as string | null };
if (isBulk) {
return { title: `${imageIds.length} covers selected`, sub: null };
}
const c = data.covers[0];
if (!c) return { title: `Image #${imageIds[0]}`, sub: null };
const title = c.code ?? `Image #${c.id}`;
const sub = c.actresses.length > 0
? c.actresses.join(", ")
: (c.title ?? null);
return { title, sub };
}, [data, imageIds, isBulk]);
// Mark state — for single, read straight from data; for bulk, all-true means
// every cover has it.
const marks = useMemo(() => {
if (!data || data.covers.length === 0) {
return { vip: false, favorite: false, watched: false, owned: false };
}
const all = (pred: (c: ContextData["covers"][number]) => boolean) => data.covers.every(pred);
return {
vip: all((c) => c.isVip),
favorite: all((c) => c.isFavorite),
watched: all((c) => c.isWatched),
owned: all((c) => c.isOwned),
};
}, [data]);
const setMark = useCallback((kind: "vip" | "favorite" | "watched" | "owned") => {
const wasOn = marks[kind];
setData((d) => d && {
...d,
covers: d.covers.map((c) => {
if (kind === "vip") return { ...c, isVip: !wasOn, isFavorite: !wasOn ? false : c.isFavorite };
if (kind === "favorite") return { ...c, isFavorite: !wasOn, isVip: !wasOn ? false : c.isVip };
if (kind === "watched") return { ...c, isWatched: !wasOn };
return { ...c, isOwned: !wasOn };
}),
});
start(async () => {
if (isBulk) {
if (kind === "vip") await bulkSetMark(imageIds, !wasOn ? "vip" : "unmarked");
else if (kind === "favorite") await bulkSetMark(imageIds, !wasOn ? "favorite" : "unmarked");
else if (kind === "watched") await bulkSetWatched(imageIds, !wasOn);
else await bulkSetOwned(imageIds, !wasOn);
} else {
const id = imageIds[0];
if (kind === "vip") await setCoverVip(id, !wasOn);
else if (kind === "favorite") await setCoverFavorite(id, !wasOn);
else if (kind === "watched") await setWatched(id, !wasOn);
else await setCoverOwned(id, !wasOn);
}
if (kind === "watched" && !wasOn) dispatchQueueRemove(imageIds);
router.refresh();
});
}, [imageIds, isBulk, marks, router]);
const toggleTag = useCallback((tag: { id: number; name: string }, on: boolean) => {
setData((d) => d && {
...d,
tags: d.tags.map((t) =>
t.id === tag.id ? { ...t, count: on ? imageIds.length : 0 } : t,
),
});
start(async () => {
await Promise.all(imageIds.map((id) => on ? addTagToImage(id, tag.name) : removeTagFromImage(id, tag.id)));
router.refresh();
});
}, [imageIds, router]);
const toggleCollection = useCallback((coll: { id: number; name: string }, on: boolean) => {
setData((d) => d && {
...d,
collections: d.collections.map((c) =>
c.id === coll.id ? { ...c, count: on ? imageIds.length : 0 } : c,
),
});
start(async () => {
await Promise.all(imageIds.map((id) => on ? addImageToCollection(coll.id, id) : removeImageFromCollection(coll.id, id)));
router.refresh();
});
}, [imageIds, router]);
const onDelete = (e: React.MouseEvent) => {
// Honor the same Shift-for-permanent / useRecycleBin semantics as
// SelectionBar so right-click delete doesn't bypass the user's
// recycle-bin preference.
const permanent = e.shiftKey || !settings.useRecycleBin;
const target = [...imageIds];
onClose();
start(async () => {
await deleteImages(target, permanent ? { permanent: true } : undefined);
router.refresh();
if (!permanent) showUndo(target);
});
};
// Compute viewport-clamped position. We render via a portal to
// document.body so a transformed ancestor (the page's fade-in
// wrapper) can't hijack our fixed-positioning containing block.
// Horizontal: center on the cursor so the menu sits symmetrically
// around the click — keeps right-edge clicks from spilling offscreen.
// Vertical: top of the menu sits at the cursor, expanding down.
const margin = 8;
const menuW = 380;
const menuH = 320;
const [coords, setCoords] = useState({ left: x, top: y });
useEffect(() => {
if (typeof window === "undefined") return;
setCoords({
left: Math.max(margin, Math.min(x - menuW / 2, window.innerWidth - menuW - margin)),
top: Math.max(margin, Math.min(y + 20, window.innerHeight - menuH - margin)),
});
}, [x, y]);
if (typeof document === "undefined") return null;
return createPortal(
<div
ref={ref}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
className="fixed z-[80] flex items-start gap-2"
style={{ left: coords.left, top: coords.top }}
>
<div
className="w-[380px] rounded-xl shadow-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden text-sm"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
{/* Header */}
<div className="px-3 py-2.5 border-b border-[var(--color-glass-border)]">
<div className="text-[13px] font-mono font-semibold text-[var(--color-cyan)] truncate">
{header.title}
</div>
{header.sub && (
<div className="text-[11px] text-[var(--color-fg-dim)] truncate mt-0.5">{header.sub}</div>
)}
{isBulk && (
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)] mt-1">
Applying to {imageIds.length} covers
</div>
)}
</div>
{/* Quick marks: VIP, Fav, Watched, Owned */}
<div className="flex gap-1.5 px-3 py-2.5 border-b border-[var(--color-glass-border)]">
<QBtn icon={Gem} label="VIP" active={marks.vip} onClick={() => setMark("vip")} />
<QBtn icon={Star} label="Fav" active={marks.favorite} onClick={() => setMark("favorite")} accent="amber" />
<QBtn icon={Eye} label="Watched" active={marks.watched} onClick={() => setMark("watched")} accent="mint" />
<QBtn icon={Package} label="Owned" active={marks.owned} onClick={() => setMark("owned")} accent="violet" />
</div>
{/* Submenu trigger rows — open on hover (saves a click). Click
still toggles, in case you want to close one without moving
the cursor. */}
<SubmenuRow
icon={TagIcon}
label="Tags"
countLabel={data ? formatCountLabel(data.tags, data.selectedCount, isBulk) : "…"}
open={submenu === "tags"}
onHover={() => setSubmenu("tags")}
onClick={() => setSubmenu(submenu === "tags" ? null : "tags")}
/>
<SubmenuRow
icon={FolderHeart}
label="Collections"
countLabel={data ? formatCountLabel(data.collections, data.selectedCount, isBulk) : "…"}
open={submenu === "collections"}
onHover={() => setSubmenu("collections")}
onClick={() => setSubmenu(submenu === "collections" ? null : "collections")}
/>
{/* Watch queue */}
<button
onClick={() => {
if (allQueued) queue.removeMany(imageIds);
else queue.addMany(imageIds);
onClose();
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-[var(--color-glass)] border-t border-[var(--color-glass-border)]"
>
<ListVideo className={cn("w-3.5 h-3.5", allQueued ? "text-[var(--color-cyan)]" : "text-[var(--color-fg-dim)]")} />
<span className="text-sm">
{allQueued
? (isBulk ? `Remove ${imageIds.length} from Watch Queue` : "Remove from Watch Queue")
: isBulk
? (queuedCount > 0 ? `Add ${imageIds.length - queuedCount} to Watch Queue` : `Add ${imageIds.length} to Watch Queue`)
: "Add to Watch Queue"}
</span>
</button>
{/* Delete */}
<button
onClick={onDelete}
className="w-full flex items-center gap-2 px-3 py-2 text-[var(--color-coral)] hover:bg-[var(--color-coral)]/10 border-t border-[var(--color-glass-border)]"
>
<Trash2 className="w-3.5 h-3.5" />
{isBulk ? `Delete ${imageIds.length} images` : "Delete image"}
</button>
</div>
{/* Flyout */}
{submenu === "tags" && data && (
<Flyout
title="Tags"
options={data.tags}
recent={data.recentTags.map((t) => ({ id: t.id, name: t.name, color: t.color, count: 0 }))}
selectedCount={data.selectedCount}
isBulk={isBulk}
onToggle={(opt, on) => toggleTag(opt as ContextTagOption, on)}
onCreate={async (name) => {
for (const id of imageIds) await addTagToImage(id, name);
const fresh = await fetchImageContextData(imageIds);
setData(fresh);
router.refresh();
}}
/>
)}
{submenu === "collections" && data && (
<Flyout
title="Collections"
options={data.collections.map((c) => ({ id: c.id, name: c.name, color: null, count: c.count }))}
recent={data.recentCollections.map((c) => ({ id: c.id, name: c.name, color: null, count: 0 }))}
selectedCount={data.selectedCount}
isBulk={isBulk}
onToggle={(opt, on) => toggleCollection(opt as ContextCollectionOption, on)}
onCreate={async (name) => {
const created = await createCollection(name);
if (!created) return;
for (const id of imageIds) await addImageToCollection(created.id, id);
const fresh = await fetchImageContextData(imageIds);
setData(fresh);
router.refresh();
}}
/>
)}
</div>,
document.body,
);
}
/* ---------- helpers ---------- */
function formatCountLabel(opts: Array<{ count: number }>, selected: number, isBulk: boolean): string {
const fullyApplied = opts.filter((o) => o.count === selected && o.count > 0).length;
if (!isBulk) return String(fullyApplied);
const partial = opts.filter((o) => o.count > 0 && o.count < selected).length;
if (partial > 0) return "mixed";
return String(fullyApplied);
}
function QBtn({
icon: Icon,
label,
active,
accent = "cyan",
onClick,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
active: boolean;
accent?: "cyan" | "amber" | "mint" | "violet";
onClick: () => void;
}) {
const palette: Record<string, string> = {
cyan: "bg-[var(--color-cyan)]/18 text-[var(--color-cyan)]",
amber: "bg-amber-400/18 text-amber-300",
mint: "bg-[var(--color-mint)]/18 text-[var(--color-mint)]",
violet: "bg-[var(--color-violet)]/18 text-[var(--color-violet)]",
};
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex-1 min-w-0 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium whitespace-nowrap transition-colors",
active
? palette[accent]
: "bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:bg-[var(--color-glass-strong)] hover:text-[var(--color-fg)]",
)}
>
<Icon className="w-3.5 h-3.5 shrink-0" />
{label}
</button>
);
}
function SubmenuRow({
icon: Icon,
label,
countLabel,
open,
onHover,
onClick,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
countLabel: string;
open: boolean;
onHover?: () => void;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
onMouseEnter={onHover}
onFocus={onHover}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 transition-colors text-left",
open ? "bg-[var(--color-glass-strong)]" : "hover:bg-[var(--color-glass)]",
)}
>
<Icon className={cn("w-3.5 h-3.5 shrink-0", open ? "text-[var(--color-cyan)]" : "text-[var(--color-fg-dim)]")} />
<span className="flex-1 text-[var(--color-fg)]">{label}</span>
<span
className={cn(
"text-[10px] font-mono px-1.5 py-0.5 rounded",
open
? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)]"
: "bg-[var(--color-glass)] text-[var(--color-fg-muted)]",
)}
>
{countLabel}
</span>
<ChevronRight className={cn("w-3.5 h-3.5", open ? "text-[var(--color-cyan)]" : "text-[var(--color-fg-muted)]")} />
</button>
);
}
interface FlyoutOption {
id: number;
name: string;
color: string | null;
count: number;
}
function Flyout({
title,
options,
recent,
selectedCount,
isBulk,
onToggle,
onCreate,
}: {
title: string;
options: FlyoutOption[];
recent: FlyoutOption[];
selectedCount: number;
isBulk: boolean;
onToggle: (opt: FlyoutOption, on: boolean) => void;
onCreate: (name: string) => Promise<void>;
}) {
const [query, setQuery] = useState("");
const [creating, start] = useTransition();
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.name.toLowerCase().includes(q));
}, [options, query]);
const trimmed = query.trim();
const exact = trimmed
? options.find((o) => o.name.toLowerCase() === trimmed.toLowerCase())
: undefined;
const showCreate = trimmed.length > 0 && !exact;
const submitCreate = () => {
if (!showCreate || creating) return;
const name = trimmed;
setQuery("");
start(async () => { await onCreate(name); });
};
return (
<div
className="w-[300px] rounded-xl shadow-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden text-sm relative"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
<div className="px-3 py-2 border-b border-[var(--color-glass-border)] flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">{title}</span>
{isBulk && <span className="text-[9px] font-mono text-[var(--color-fg-muted)]">N / {selectedCount}</span>}
</div>
<div className="p-2 space-y-2">
{/* Filter or Create */}
<div className="relative">
<Search className="w-3 h-3 absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-fg-muted)] pointer-events-none" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submitCreate(); }}
placeholder={`Filter or add new ${title.toLowerCase()}`}
className="w-full bg-[var(--color-bg-1)]/60 text-xs pl-7 pr-[60px] py-1.5 rounded-md border border-[var(--color-glass-border)] outline-none focus:border-[var(--color-cyan)]"
/>
{showCreate && (
<button
type="button"
onClick={submitCreate}
disabled={creating}
className="absolute right-1 top-1/2 -translate-y-1/2 text-[10px] font-mono px-1.5 py-1 rounded bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/30 hover:bg-[var(--color-cyan)]/25 disabled:opacity-50"
>
{creating ? <Loader2 className="w-3 h-3 animate-spin" /> : "+ Create"}
</button>
)}
</div>
{/* Recent strip — only when no filter active */}
{recent.length > 0 && !query && (
<div>
<div className="text-[9px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] px-1 mb-1">Recent</div>
<div className="flex flex-wrap gap-1">
{recent.map((r) => (
<button
key={r.id}
type="button"
onClick={() => onToggle(r, true)}
className="text-[11px] px-2 py-0.5 rounded-full bg-[var(--color-violet)]/12 text-[var(--color-violet)] hover:bg-[var(--color-violet)]/20"
>
+ {r.name}
</button>
))}
</div>
</div>
)}
{/* Scrollable list — caps at ~5 rows */}
<div>
<div className="flex items-center justify-between px-1 mb-1">
<span className="text-[9px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">All</span>
<span className="text-[9px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]"> scroll</span>
</div>
{filtered.length === 0 ? (
<div className="text-xs text-[var(--color-fg-muted)] italic px-2 py-2">
{query ? "No matches" : `No ${title.toLowerCase()} yet`}
</div>
) : (
<div className="max-h-[150px] overflow-y-auto -mx-1 px-1">
{filtered.map((o) => {
const state = stateFor(o.count, selectedCount);
return (
<button
key={o.id}
type="button"
onClick={() => onToggle(o, state !== "all")}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-left text-sm transition-colors",
state === "all" && "text-[var(--color-violet)]",
state === "partial" && "text-amber-300",
state === "none" && "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]",
"hover:bg-[var(--color-glass)]",
)}
>
<span className="w-3 inline-flex justify-center text-xs">
{state === "all" ? "✓" : state === "partial" ? "" : ""}
</span>
<span className="flex-1 truncate">{o.name}</span>
<span className="text-[10px] font-mono text-[var(--color-fg-muted)]">
{isBulk ? `${o.count} / ${selectedCount}` : (o.count > 0 ? "✓" : "")}
</span>
</button>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function stateFor(count: number, total: number): "all" | "partial" | "none" {
if (count === 0) return "none";
if (count >= total) return "all";
return "partial";
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useEffect, useState } from "react";
import { Infinity, FileText } from "lucide-react";
import { cn } from "@/lib/utils";
const STORAGE_KEY = "pinkudex.infiniteScroll";
const EVENT_NAME = "pinkudex:infinite-scroll-toggled";
export function readInfiniteScrollEnabled(): boolean {
if (typeof window === "undefined") return true;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "0") return false;
return true;
} catch { return true; }
}
function writeInfiniteScrollEnabled(value: boolean): void {
try { localStorage.setItem(STORAGE_KEY, value ? "1" : "0"); } catch { /* ignore */ }
try { window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: value })); } catch { /* ignore */ }
}
/**
* Subscribe to toggle changes from anywhere in the app. Returns the
* current value. Updates synchronously when the toggle is flipped.
*/
export function useInfiniteScrollEnabled(): boolean {
const [enabled, setEnabled] = useState<boolean>(true);
useEffect(() => {
setEnabled(readInfiniteScrollEnabled());
const onChange = (e: Event) => {
const next = (e as CustomEvent<boolean>).detail;
setEnabled(next);
};
window.addEventListener(EVENT_NAME, onChange);
// Cross-tab updates via the storage event.
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) setEnabled(readInfiniteScrollEnabled());
};
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(EVENT_NAME, onChange);
window.removeEventListener("storage", onStorage);
};
}, []);
return enabled;
}
export function InfiniteScrollToggle() {
const enabled = useInfiniteScrollEnabled();
return (
<button
type="button"
onClick={() => writeInfiniteScrollEnabled(!enabled)}
title={enabled ? "Infinite scroll on — click to disable (paginated only)" : "Paginated only — click to enable infinite scroll"}
className={cn(
"inline-flex items-center justify-center w-9 h-9 rounded-lg border transition-colors cursor-pointer",
enabled
? "border-[var(--color-cyan)]/50 bg-[var(--color-cyan)]/10 text-[var(--color-cyan)]"
: "border-[var(--color-glass-border)] bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]",
)}
aria-pressed={enabled}
aria-label={enabled ? "Disable infinite scroll" : "Enable infinite scroll"}
>
{enabled ? <Infinity className="w-4 h-4" /> : <FileText className="w-4 h-4" />}
</button>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
import { cn } from "@/lib/utils";
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
const NON_LATIN = "#";
export function LetterBar({
active,
counts,
}: {
active: string | null;
counts: Record<string, number>;
}) {
const router = useRouter();
const params = useSearchParams();
const [, start] = useTransition();
const total = counts[""] ?? 0;
const hasSearch = !!(params.get("q") ?? "").trim();
function go(letter: string | null) {
const next = new URLSearchParams(params.toString());
if (letter == null) next.delete("letter");
else next.set("letter", letter);
start(() => router.push(`?${next.toString()}`, { scroll: false }));
}
return (
<div className="flex items-stretch gap-1 w-full">
<button
type="button"
onClick={() => go(null)}
className={cn(
"flex-1 flex flex-col items-center justify-center font-mono py-2 rounded-lg border transition-colors",
active === null
? "bg-[var(--color-cyan)] text-black border-transparent"
: "glass glass-hover",
)}
>
<span className="text-base font-semibold leading-none">ALL</span>
<span className={cn(
"text-[10px] font-semibold tabular-nums mt-0.5",
active === null ? "text-black/70" : "text-[var(--color-fg-muted)]",
)}>
{total}
</span>
</button>
{[...LETTERS, NON_LATIN].map((L) => {
const n = counts[L] ?? 0;
const enabled = n > 0 && !hasSearch;
const isActive = active === L;
return (
<button
key={L}
type="button"
disabled={!enabled}
onClick={() => go(isActive ? null : L)}
className={cn(
"flex-1 flex flex-col items-center justify-center font-mono py-2 rounded-lg border transition-colors",
isActive
? "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={cn(
"text-[10px] font-semibold tabular-nums mt-0.5",
isActive ? "text-black/70" : enabled ? "text-[var(--color-fg-muted)]" : "text-transparent",
)}>
{enabled ? n : 0}
</span>
</button>
);
})}
</div>
);
}
+443
View File
@@ -0,0 +1,443 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePathname, useSearchParams } from "next/navigation";
import type { VirtuosoHandle } from "react-virtuoso";
import { MasonryGrid } from "./MasonryGrid";
import { PaginationBar } from "./PaginationBar";
import { useInfiniteScrollEnabled } from "./InfiniteScrollToggle";
import { useSettings } from "@/components/settings/SettingsProvider";
import type { CardImage } from "./ImageCard";
import type { LibraryView } from "./ViewToggle";
interface Props {
initialItems: CardImage[];
initialPage: number;
totalPages: number;
totalCount: number;
view: LibraryView;
infiniteScrollEnabled?: boolean;
}
/**
* Wrapper that owns the "loaded pages" state for the cover grid. The
* top + bottom pagination bars and the grid itself all read from here
* so the bottom bar can show "Pages 17 of 7" once the user has
* scroll-appended through the whole result set.
*
* Top bar stays anchored at `initialPage` — that's where the user
* landed via URL. Bottom bar reflects `loadedEnd`, the highest page
* currently appended.
*/
export function LibraryGrid({
initialItems,
initialPage,
totalPages,
totalCount,
view,
infiniteScrollEnabled: infiniteFromProp = true,
}: Props) {
const sp = useSearchParams();
const pathname = usePathname();
// The toggle in the FilterBar persists per-user-tab via localStorage.
// The prop is the page-level "is this surface ever allowed to
// infinite-scroll?" gate; AND with the user's preference.
const userInfinite = useInfiniteScrollEnabled();
const infiniteScrollEnabled = infiniteFromProp && userInfinite;
const { settings } = useSettings();
const fadeMs = settings.fadeTransitions ? Math.max(0, settings.fadeDurationMs ?? 400) : 0;
// Loaded items split into two buckets: the SSR-rendered initial
// page (kept stable for hydration) and any appended-by-fetch pages.
const [extra, setExtra] = useState<CardImage[]>([]);
const [loadedEnd, setLoadedEnd] = useState<number>(initialPage);
// The page currently in the viewport, derived from Virtuoso's
// first-visible-row index. Used solely for the "Page X of Y" label
// — navigation still keys off `initialPage` (URL anchor) and
// `loadedEnd`. Defaults to initialPage so SSR matches.
const [visiblePage, setVisiblePage] = useState<number>(initialPage);
const pageSize = Math.max(25, Math.min(500, settings.coverPageSize || 100));
// Per-batch fade controller. Each appended page is a "batch"; when
// any row of that batch first intersects the viewport, every row in
// the same batch fades in together (rather than one-row-at-a-time
// as the user scrolls past). Rows subscribe to their batch's trigger
// so they all flip to "animated" at once.
const fadeController = useMemo(() => {
let seq = 0;
const itemBatch = new Map<number, number>();
const batchIds = new Map<number, number[]>();
const triggered = new Set<number>();
const subs = new Map<number, Set<() => void>>();
return {
addBatch(ids: number[]): number {
seq += 1;
const id = seq;
batchIds.set(id, ids);
for (const it of ids) itemBatch.set(it, id);
return id;
},
batchIdOf(itemId: number): number | null {
return itemBatch.get(itemId) ?? null;
},
isTriggered(batchId: number): boolean {
return triggered.has(batchId);
},
trigger(batchId: number) {
if (triggered.has(batchId)) return;
triggered.add(batchId);
const set = subs.get(batchId);
if (set) for (const cb of set) cb();
},
subscribe(batchId: number, cb: () => void): () => void {
let set = subs.get(batchId);
if (!set) { set = new Set(); subs.set(batchId, set); }
set.add(cb);
return () => { set?.delete(cb); };
},
// Drop a batch entirely — items no longer count as pending so a
// future remount won't replay the keyframe.
expire(batchId: number) {
const ids = batchIds.get(batchId);
if (ids) for (const it of ids) itemBatch.delete(it);
batchIds.delete(batchId);
triggered.delete(batchId);
subs.delete(batchId);
},
reset() {
seq = 0;
itemBatch.clear();
batchIds.clear();
triggered.clear();
subs.clear();
},
};
}, []);
const fetchInFlightRef = useRef<boolean>(false);
// Page number currently being fetched (or just resolved). Combined
// with fetchInFlightRef it dedupes simultaneous requests for the same
// page — strict-mode double-invoke and Virtuoso's onEndReached firing
// twice in rapid succession both hit this.
const lastFetchTargetRef = useRef<number>(0);
// Auto-fetch suppression: after appendNextPage resolves we set this
// true. Virtuoso's onEndReached path checks it and bails. The user
// scrolling at least one full viewport flips it back to false so the
// next bottom-trigger can append. Without this, mounting on a page
// whose SSR rows are shorter than the viewport causes onEndReached
// to fire repeatedly, chaining 3-4 page appends instantly and
// dragging visiblePage way past the URL anchor.
const autoFetchPausedRef = useRef<boolean>(false);
const [isFetching, setIsFetching] = useState(false);
// Reset when the SSR-anchor changes (filter/sort/page nav). Page
// remount via key in app/page.tsx already handles most of this.
useEffect(() => {
setExtra([]);
setLoadedEnd(initialPage);
fadeController.reset();
}, [initialPage, initialItems, fadeController]);
const allItems = useMemo(() => [...initialItems, ...extra], [initialItems, extra]);
// Mirror loadedEnd into a ref so the save closure (registered once
// on mount) always reads the current value, without rebinding.
const loadedEndRef = useRef(loadedEnd);
useEffect(() => { loadedEndRef.current = loadedEnd; }, [loadedEnd]);
// Reflect the visible page in the URL with `replaceState` (no
// history push, so the back button still goes to the previous
// route, not through every scroll position). Debounced via
// visiblePage state which only updates when the bottom-most-visible
// row changes pages — already throttled at the source.
useEffect(() => {
if (typeof window === "undefined") return;
const usp = new URLSearchParams(sp.toString());
if (visiblePage > 1) usp.set("page", String(visiblePage));
else usp.delete("page");
const qs = usp.toString();
const target = qs ? `${pathname}?${qs}` : pathname;
if (window.location.pathname + window.location.search !== target) {
window.history.replaceState(window.history.state, "", target);
}
}, [visiblePage, sp, pathname]);
// Track when we're mounted on the client so the portal can target
// document.body without breaking SSR.
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
// Imperative handle on Virtuoso so we can call scrollToIndex even
// when the target row has been unmounted from DOM (it's outside the
// overscan window).
const virtuosoHandleRef = useRef<VirtuosoHandle | null>(null);
// Scroll + appended-page restoration. The page is `force-dynamic` so
// Next.js re-renders fresh on back-nav (Router Cache doesn't apply),
// which means scroll is *not* preserved automatically.
//
// Strategy: save scrollY + loadedEnd to sessionStorage on every
// scroll (debounced) and on cleanup. On mount, if a snapshot exists,
// re-fetch any pages the user had appended, then scrollTo with a
// retry loop because Virtuoso lazily measures rows and the page may
// briefly be too short for the saved scrollY to be valid.
useEffect(() => {
if (typeof window === "undefined") return;
const key = `pinkudex:scroll:${pathname}?${sp.toString()}`;
// Skip restore when arriving via an internal Prev/Next click
// (PaginationBar sets this marker before pushing). Otherwise the
// user clicking Prev all the way back to / would replay a snapshot
// saved during their previous scroll session, re-fetching pages
// 2N and re-creating the visiblePage drift loop.
const internalMarker = sessionStorage.getItem("pinkudex:nav-internal");
if (internalMarker) {
sessionStorage.removeItem("pinkudex:nav-internal");
// Also clear the snapshot for this URL so subsequent scrolls
// capture fresh state instead of compounding on the old one.
try { sessionStorage.removeItem(key); } catch { /* ignore */ }
return;
}
let cancelled = false;
const restore = async () => {
let snap: { scrollY: number; loadedEnd: number } | null = null;
try {
const raw = sessionStorage.getItem(key);
if (raw) snap = JSON.parse(raw);
} catch { /* corrupt — ignore */ }
if (!snap || snap.scrollY <= 0) return;
// Refetch missing appended pages so the document is tall enough
// for the saved scrollY to land somewhere meaningful.
if (snap.loadedEnd > initialPage && infiniteScrollEnabled) {
const collected: CardImage[] = [];
for (let p = initialPage + 1; p <= snap.loadedEnd && p <= totalPages; p++) {
if (cancelled) return;
const usp = new URLSearchParams(sp.toString());
usp.set("page", String(p));
try {
const r = await fetch(`/api/covers?${usp.toString()}`, { cache: "no-store" });
if (!r.ok) break;
const data = (await r.json()) as { items: CardImage[]; page: number };
if (!Array.isArray(data.items)) break;
collected.push(...data.items);
} catch { break; }
}
if (cancelled) return;
if (collected.length > 0) {
setExtra(collected);
setLoadedEnd(snap.loadedEnd);
}
}
// Retry scrollTo for up to ~1s. Stops once position settles
// within a couple of pixels of target. Necessary because Next.js
// and Virtuoso both touch scroll/layout shortly after mount.
const target = snap.scrollY;
let attempts = 0;
const maxAttempts = 60;
const tryScroll = () => {
if (cancelled) return;
window.scrollTo(0, target);
attempts += 1;
if (Math.abs(window.scrollY - target) <= 2 || attempts >= maxAttempts) return;
requestAnimationFrame(tryScroll);
};
requestAnimationFrame(tryScroll);
};
restore();
// Save scroll + loadedEnd. No restoredRef gate — we want every
// scroll captured, and re-saving a stale value before restore is
// harmless (restore reads once, before it loops).
let t: ReturnType<typeof setTimeout> | null = null;
const save = () => {
try {
const payload = JSON.stringify({ scrollY: window.scrollY, loadedEnd: loadedEndRef.current });
sessionStorage.setItem(key, payload);
} catch { /* quota / private mode */ }
};
const onScroll = () => {
if (t) clearTimeout(t);
t = setTimeout(save, 100);
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("pagehide", save);
return () => {
cancelled = true;
// Intentionally NOT calling save() here. By the time cleanup
// runs on back-nav, Next.js has already reset window.scrollY=0,
// and saving that would clobber the snapshot.
window.removeEventListener("scroll", onScroll);
window.removeEventListener("pagehide", save);
if (t) clearTimeout(t);
};
// Run once per LibraryGrid mount. Filter changes already remount
// via the page-level key in app/page.tsx.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Scroll to the first row of `targetPage` once it's in the buffer.
// Pure DOM operation; caller is responsible for ensuring the target
// page has been loaded.
const scrollToLoadedPage = useCallback((targetPage: number): boolean => {
const cols = view === "portrait"
? Math.max(4, Math.min(10, settings.gridColumnsPortrait || 6))
: Math.max(2, Math.min(4, settings.gridColumns || 3));
const itemIdx = (targetPage - initialPage) * pageSize;
const rowIdx = Math.floor(itemIdx / cols);
const handle = virtuosoHandleRef.current;
if (!handle) return false;
handle.scrollToIndex({ index: rowIdx, align: "start", behavior: "smooth" });
return true;
}, [initialPage, pageSize, view, settings.gridColumns, settings.gridColumnsPortrait]);
// Append the page just past loadedEnd. Returns true on success, false
// if there's nothing more to load or the request fails. Shared between
// the infinite-scroll auto-fetch path, the explicit Load-More button,
// and the scroll-mode prefetch loop in scrollToPage.
const appendNextPage = useCallback(async (): Promise<boolean> => {
if (loadedEnd >= totalPages) return false;
const next = loadedEnd + 1;
// Dedupe: a second invocation for the same target while the first
// is in flight is a no-op. This catches strict-mode double-invoke
// in dev and Virtuoso firing onEndReached twice for one bottom hit.
if (fetchInFlightRef.current && lastFetchTargetRef.current === next) {
return false;
}
if (fetchInFlightRef.current) return false;
fetchInFlightRef.current = true;
lastFetchTargetRef.current = next;
setIsFetching(true);
try {
const usp = new URLSearchParams(sp.toString());
usp.set("page", String(next));
const r = await fetch(`/api/covers?${usp.toString()}`, { cache: "no-store" });
if (!r.ok) return false;
const data = (await r.json()) as { items: CardImage[]; page: number; hasMore: boolean };
if (!Array.isArray(data.items) || data.items.length === 0) return false;
if (fadeMs > 0) {
fadeController.addBatch(data.items.map((it) => it.id));
}
setExtra((cur) => [...cur, ...data.items]);
setLoadedEnd(data.page);
return true;
} catch {
return false;
} finally {
fetchInFlightRef.current = false;
setIsFetching(false);
}
}, [loadedEnd, sp, totalPages, fadeMs, fadeController]);
// Auto-fetch path used by Virtuoso's onEndReached. Gated on the
// infinite-scroll preference. After each successful append we pause
// until the user scrolls — that breaks the chain where a freshly
// mounted SSR page has the bottom row near the viewport, causing
// onEndReached to fire repeatedly and append 3-4 pages back-to-back.
// The explicit Load-More / prefetch paths bypass this guard via
// appendNextPage directly.
const fetchNextPage = useCallback(async () => {
if (!infiniteScrollEnabled) return;
if (autoFetchPausedRef.current) return;
const ok = await appendNextPage();
if (ok) autoFetchPausedRef.current = true;
}, [infiniteScrollEnabled, appendNextPage]);
// Release the auto-fetch pause only on a real user gesture — wheel,
// touchmove, or a scroll-direction key. The earlier window-scroll
// listener was too sensitive: programmatic scroll-restoration and
// browser overflow-anchor adjustments fire scroll events and were
// bypassing the pause, which let the auto-fetch chain still run
// 3-4 pages deep on initial mount and after URL nav back to /.
useEffect(() => {
if (typeof window === "undefined") return;
const release = () => { autoFetchPausedRef.current = false; };
const onKey = (e: KeyboardEvent) => {
if (e.key === "PageDown" || e.key === "ArrowDown" || e.key === "End" || e.key === " " || e.key === "Spacebar") {
autoFetchPausedRef.current = false;
}
};
window.addEventListener("wheel", release, { passive: true });
window.addEventListener("touchmove", release, { passive: true });
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("wheel", release);
window.removeEventListener("touchmove", release);
window.removeEventListener("keydown", onKey);
};
}, []);
// Build the scroll-mode entry point. Backward across the SSR anchor
// returns false → URL nav. Forward past loadedEnd prefetches one
// page at a time until the target lands inside the buffer, then
// scrolls to it. Each loop iteration awaits a real network round-trip
// — this is the "always scroll, prefetch when needed" behavior.
const scrollToPageScrollMode = useCallback(async (targetPage: number): Promise<boolean> => {
if (targetPage < initialPage) return false;
if (targetPage > totalPages) return false;
while (targetPage > loadedEndRef.current) {
const ok = await appendNextPage();
if (!ok) return false;
}
return scrollToLoadedPage(targetPage);
}, [initialPage, totalPages, appendNextPage, scrollToLoadedPage]);
// Pick the right Prev/Next handler based on the user's preference.
// - "url" → no callback; bar always URL-navs.
// - "scroll" → prefetch + smooth scroll, URL fallback only on backward.
const onScrollToPageProp = settings.paginationMode === "scroll" ? scrollToPageScrollMode : undefined;
// Same-URL nav handler — fires when the bar would push to the page
// we're already at (e.g. clicking Prev at "Page 5 (scrolled from
// URL=/)" wants to land on page 1). Resets the appended buffer +
// scrolls to top so the user sees a real change instead of nothing.
const handleSamePageNav = useCallback(() => {
setExtra([]);
setLoadedEnd(initialPage);
fadeController.reset();
autoFetchPausedRef.current = true;
if (typeof window !== "undefined") window.scrollTo({ top: 0, behavior: "smooth" });
}, [initialPage, fadeController]);
return (
<>
<MasonryGrid
images={allItems}
view={view}
infiniteScrollEnabled={infiniteScrollEnabled}
loadedEnd={loadedEnd}
totalPages={totalPages}
onEndReached={fetchNextPage}
isFetching={isFetching}
fadeController={fadeController}
fadeMs={fadeMs}
pageSize={pageSize}
ssrAnchorPage={initialPage}
onVisiblePageChange={setVisiblePage}
virtuosoHandleRef={virtuosoHandleRef}
/>
{/* Floating bar — portaled to <body> to escape any ancestor's
backdrop-filter / transform, which would otherwise trap a
`position: fixed` child to that ancestor's box. */}
{mounted && createPortal(
<div className="fixed inset-x-0 bottom-[12px] z-30 flex justify-center pointer-events-none">
<div
className="pointer-events-auto rounded-2xl shadow-2xl px-4 py-2.5 border border-[var(--color-glass-border-strong)] backdrop-blur-2xl"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 85%, transparent)" }}
>
<PaginationBar
currentPage={visiblePage}
totalPages={totalPages}
totalCount={totalCount}
onScrollToPage={onScrollToPageProp}
onSamePageNav={handleSamePageNav}
/>
</div>
</div>,
document.body,
)}
</>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Tag, ChevronDown, Eye, EyeOff, Gem, Star, Package, MinusCircle } from "lucide-react";
import { useSelection } from "@/components/select/SelectionProvider";
import { bulkSetMark, bulkSetWatched, bulkSetOwned } from "@/app/actions/bulk";
import { dispatchQueueRemove } from "@/components/queue/watchQueueEvents";
import { cn } from "@/lib/utils";
type Action = "watched" | "unwatched" | "vip" | "favorite" | "owned" | "unmark";
export function MarkActionPopover() {
const sel = useSelection();
const count = sel.ids.size;
const enabled = count > 0;
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const [pending, start] = useTransition();
const router = useRouter();
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
// If selection drains while menu is open, close it.
useEffect(() => {
if (!enabled && open) setOpen(false);
}, [enabled, open]);
function pick(action: Action) {
setOpen(false);
const ids = Array.from(sel.ids);
if (ids.length === 0) return;
start(async () => {
if (action === "watched") { await bulkSetWatched(ids, true); dispatchQueueRemove(ids); }
else if (action === "unwatched") await bulkSetWatched(ids, false);
else if (action === "vip") await bulkSetMark(ids, "vip");
else if (action === "favorite") await bulkSetMark(ids, "favorite");
else if (action === "owned") await bulkSetOwned(ids, true);
else if (action === "unmark") await bulkSetMark(ids, "unmarked");
router.refresh();
});
}
return (
<div className="relative" ref={wrapRef}>
<button
type="button"
onClick={() => enabled && setOpen((v) => !v)}
disabled={!enabled || pending}
title={enabled ? `Mark ${count} selected cover${count === 1 ? "" : "s"}` : "Select covers first"}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors min-w-[140px] justify-between",
enabled
? "bg-[var(--color-cyan)]/10 border-[var(--color-cyan)]/40 text-[var(--color-cyan)] hover:bg-[var(--color-cyan)]/20"
: "glass text-[var(--color-fg-muted)] opacity-50 cursor-not-allowed",
)}
>
<Tag className="w-3.5 h-3.5" />
Mark As
<span
className={cn(
"inline-flex items-center justify-center min-w-[22px] h-4 px-1 rounded-full text-black text-[10px] font-mono font-bold tabular-nums bg-[var(--color-cyan)]",
!enabled && "invisible",
)}
>
{count}
</span>
<ChevronDown className="w-3 h-3 opacity-70" />
</button>
{open && enabled && (
<div
className="absolute left-0 top-[calc(100%+6px)] z-30 bg-[var(--color-bg-0)] border border-[var(--color-glass-border-strong)] rounded-xl shadow-2xl p-1 w-56"
onClick={(e) => e.stopPropagation()}
>
<Row icon={Eye} label="Watched" colorClass="text-[var(--color-mint)]" onClick={() => pick("watched")} />
<Row icon={EyeOff} label="Unwatched" onClick={() => pick("unwatched")} />
<Divider />
<Row icon={Gem} label="VIP" colorClass="text-[var(--color-cyan)]" onClick={() => pick("vip")} />
<Row icon={Star} label="Favorite" colorClass="text-amber-300" iconStyle={{ fill: "#fbbf24" }} onClick={() => pick("favorite")} />
<Row icon={Package} label="Owned" colorClass="text-[var(--color-violet)]" onClick={() => pick("owned")} />
<Row icon={MinusCircle} label="Unmark VIP/Fav" colorClass="text-[var(--color-fg-muted)]" onClick={() => pick("unmark")} />
</div>
)}
</div>
);
}
function Divider() {
return <div className="h-px bg-[var(--color-glass-border)] my-1" />;
}
function Row({
icon: Icon,
label,
onClick,
colorClass,
iconStyle,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
label: string;
onClick: () => void;
colorClass?: string;
iconStyle?: React.CSSProperties;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md text-sm text-left transition-colors hover:bg-[var(--color-glass)]",
colorClass ?? "text-[var(--color-fg-dim)]",
)}
>
<Icon className="w-3.5 h-3.5" style={iconStyle} />
<span className="flex-1">{label}</span>
</button>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Bookmark, ChevronDown, Gem, Star, MinusCircle, Package, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FilterCriteria, MarkOption } from "@/lib/filters";
const OPTIONS: Array<{
value: MarkOption;
label: string;
Icon: React.ComponentType<{ className?: string }>;
tint: "cyan" | "amber" | "violet" | "muted";
}> = [
{ value: "vip", label: "VIP", Icon: Gem, tint: "cyan" },
{ value: "favorite", label: "Favorite", Icon: Star, tint: "amber" },
{ value: "owned", label: "Owned", Icon: Package, tint: "violet" },
{ value: "unmarked", label: "Unmarked", Icon: MinusCircle, tint: "muted" },
];
export function MarkPopover({
criteria,
onChange,
}: {
criteria: FilterCriteria;
onChange: (next: FilterCriteria) => void;
}) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const active = criteria.marks.length > 0;
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
function toggle(value: MarkOption) {
const has = criteria.marks.includes(value);
const next = has ? criteria.marks.filter((m) => m !== value) : [...criteria.marks, value];
onChange({ ...criteria, marks: next });
}
function selectAll() { onChange({ ...criteria, marks: [] }); }
return (
<div className="relative" ref={wrapRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors min-w-[140px] justify-between",
active
? "bg-[var(--color-violet)]/12 border-[var(--color-violet)]/40 text-[var(--color-violet)]"
: "glass glass-hover text-[var(--color-fg-dim)]",
)}
>
<Bookmark className="w-3.5 h-3.5" />
Filter
<span
className={cn(
"inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--color-violet)] text-black text-[10px] font-mono font-bold tabular-nums",
!active && "invisible",
)}
>
{criteria.marks.length || 0}
</span>
<ChevronDown className="w-3 h-3 opacity-70" />
</button>
{open && (
<div
className="absolute left-0 top-[calc(100%+6px)] z-30 bg-[var(--color-bg-0)] border border-[var(--color-glass-border-strong)] rounded-xl shadow-2xl p-2 w-60"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={selectAll}
className={cn(
"w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md text-sm text-left transition-colors",
!active
? "bg-[var(--color-glass-strong)] text-[var(--color-fg)]"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<span className={cn(
"w-4 h-4 rounded border flex items-center justify-center shrink-0",
!active ? "bg-[var(--color-fg)]/20 border-[var(--color-fg-dim)]" : "border-[var(--color-glass-border-strong)]",
)}>
{!active && <span className="w-1.5 h-1.5 rounded-full bg-[var(--color-fg)]" />}
</span>
<span className="flex-1">All (clear filter)</span>
</button>
<div className="h-px bg-[var(--color-glass-border)] my-1" />
{OPTIONS.map(({ value, label, Icon, tint }) => {
const on = criteria.marks.includes(value);
return (
<button
key={value}
type="button"
onClick={() => toggle(value)}
className={cn(
"w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md text-sm text-left transition-colors",
on
? tint === "cyan" ? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)]"
: tint === "amber" ? "text-amber-200"
: tint === "violet" ? "bg-[var(--color-violet)]/15 text-[var(--color-violet)]"
: "bg-[var(--color-glass-strong)] text-[var(--color-fg)]"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
style={on && tint === "amber" ? { background: "rgba(251,191,36,0.15)" } : undefined}
>
<span className={cn(
"w-4 h-4 rounded border flex items-center justify-center shrink-0",
on
? tint === "cyan" ? "bg-[var(--color-cyan)]/30 border-[var(--color-cyan)]"
: tint === "amber" ? "border-amber-400"
: tint === "violet" ? "bg-[var(--color-violet)]/30 border-[var(--color-violet)]"
: "bg-[var(--color-fg-dim)]/30 border-[var(--color-fg-dim)]"
: "border-[var(--color-glass-border-strong)]",
)}
style={on && tint === "amber" ? { background: "rgba(251,191,36,0.25)" } : undefined}>
{on && (
<Check
className="w-3 h-3"
strokeWidth={3}
style={{
color: tint === "cyan" ? "var(--color-cyan)"
: tint === "amber" ? "#fbbf24"
: tint === "violet" ? "var(--color-violet)"
: "var(--color-fg)",
}}
/>
)}
</span>
<span
className="inline-flex items-center"
style={{
color: tint === "cyan" ? "var(--color-cyan)"
: tint === "amber" ? "#fbbf24"
: tint === "violet" ? "var(--color-violet)"
: "var(--color-fg-muted)",
}}
>
<Icon className={cn("w-3.5 h-3.5", on && tint === "amber" && "fill-amber-300")} />
</span>
<span className="flex-1">{label}</span>
</button>
);
})}
</div>
)}
</div>
);
}
+302
View File
@@ -0,0 +1,302 @@
"use client";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { useEffect, useMemo, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import { ImageCard, type CardImage } from "./ImageCard";
import type { LibraryView } from "./ViewToggle";
import { useSettings } from "@/components/settings/SettingsProvider";
/**
* Windowed grid: renders only visible rows, mounting/unmounting cards
* as the user scrolls. Backed by react-virtuoso's window-scroll mode
* so the page itself scrolls (not an inner div).
*
* Items are bucketed into rows of `cols` cards each — Virtuoso treats
* each row as one item and measures its height dynamically. This keeps
* the masonry-style CSS grid (equal columns, variable card heights)
* working without forcing fixed-aspect cards.
*
* Below a small threshold we render a plain CSS grid — virtualization
* overhead isn't worth it for short lists, and the simpler DOM tree
* cooperates better with browser-native fade-in / scroll animations.
*/
interface Props {
images: CardImage[];
view?: LibraryView;
/** Highest page number currently appended. Used to decide whether
* to keep firing endReached. */
loadedEnd?: number;
/** Total pages for the current filter set. */
totalPages?: number;
/** When false, infinite-scroll appends are suppressed (filtered
* views, user toggle, etc.). */
infiniteScrollEnabled?: boolean;
/** Called when Virtuoso detects we're near the end and more pages
* exist. Parent fetches and grows `images`. */
onEndReached?: () => void;
/** True while the parent is fetching the next page — drives the
* small "Loading next page..." footer beneath the grid. */
isFetching?: boolean;
/** Per-batch fade controller. Each row looks up which batch its
* items belong to; the first row of that batch to intersect the
* viewport triggers the batch, fading every row of the batch in
* unison. */
fadeController?: FadeController;
/** Fade animation duration in ms; matches CSS `--fade-duration`. */
fadeMs?: number;
/** Items per logical "page" — used to derive the page currently
* scrolled into view from the first-visible row index. */
pageSize?: number;
/** The page the SSR rendered from. Item index 0 corresponds to this
* page, not page 1, when the user landed via ?page=N. */
ssrAnchorPage?: number;
/** Fires whenever the viewport's leading row changes pages. */
onVisiblePageChange?: (page: number) => void;
/** Receives Virtuoso's imperative handle so the parent can call
* `scrollToIndex` on it (e.g. for Prev/Next navigation jumps). */
virtuosoHandleRef?: React.MutableRefObject<VirtuosoHandle | null>;
}
interface FadeController {
batchIdOf(itemId: number): number | null;
isTriggered(batchId: number): boolean;
trigger(batchId: number): void;
subscribe(batchId: number, cb: () => void): () => void;
expire(batchId: number): void;
}
function MasonryRow({
rowIdx, row, cols, view, fadeController, fadeMs,
}: {
rowIdx: number;
row: CardImage[];
cols: number;
view: LibraryView;
fadeController?: FadeController;
fadeMs?: number;
}) {
// Resolve this row's batch once on mount. If null, the row was part
// of the SSR initial page (or its batch already expired) — no fade.
const [batchId] = useState<number | null>(
() => fadeController?.batchIdOf(row[0]?.id ?? -1) ?? null,
);
const isFresh = batchId != null;
// `animated` flips when the batch's trigger fires. Initial value
// honors a batch that was already triggered (e.g. row remounted via
// Virtuoso unmount/remount mid-fade).
const [animated, setAnimated] = useState<boolean>(
() => batchId != null && !!fadeController?.isTriggered(batchId),
);
const ref = useRef<HTMLDivElement | null>(null);
// Subscribe to batch trigger so every row flips at the same time.
useEffect(() => {
if (!isFresh || !fadeController || batchId == null) return;
return fadeController.subscribe(batchId, () => setAnimated(true));
}, [isFresh, fadeController, batchId]);
// First row to intersect triggers the batch. Virtuoso pre-renders
// rows in its overscan window (well below the viewport), so without
// this gate the animation would finish off-screen.
useEffect(() => {
if (!isFresh || animated || !fadeController || batchId == null) return;
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
fadeController.trigger(batchId);
io.disconnect();
return;
}
}
});
io.observe(el);
return () => io.disconnect();
}, [isFresh, animated, fadeController, batchId]);
// Once the animation has played, drop the batch so that any future
// remount of these rows doesn't replay the keyframe.
useEffect(() => {
if (!animated || !fadeController || batchId == null) return;
const t = setTimeout(() => fadeController.expire(batchId), (fadeMs ?? 0) + 50);
return () => clearTimeout(t);
}, [animated, fadeController, batchId, fadeMs]);
const showFade = isFresh && animated;
const hiddenUntilSeen = isFresh && !animated;
return (
<div
ref={ref}
data-masonry-row={rowIdx}
className={showFade ? "grid gap-5 pb-5 fade-in" : "grid gap-5 pb-5"}
style={{
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
...(hiddenUntilSeen ? { opacity: 0 } : null),
}}
>
{row.map((img) => (
<ImageCard key={img.id} image={img} view={view} />
))}
</div>
);
}
export function MasonryGrid({
images,
view = "landscape",
loadedEnd = 1,
totalPages = 1,
infiniteScrollEnabled = true,
onEndReached,
isFetching = false,
fadeController,
fadeMs,
pageSize = 100,
ssrAnchorPage = 1,
onVisiblePageChange,
virtuosoHandleRef,
}: Props) {
const { settings } = useSettings();
const cols = view === "portrait"
? Math.max(4, Math.min(10, settings.gridColumnsPortrait || 6))
: Math.max(2, Math.min(4, settings.gridColumns || 3));
// CSS var is used by the simple-grid path so the column count is
// correct on first paint regardless of what `settings.gridColumns`
// resolves to client-side. Virtuoso path uses the JS number because
// it bucket-rows items + only renders post-hydration anyway.
const cssCols = view === "portrait" ? "var(--grid-cols-portrait, 6)" : "var(--grid-cols, 3)";
const rows = useMemo(() => {
const out: CardImage[][] = [];
for (let i = 0; i < images.length; i += cols) {
out.push(images.slice(i, i + cols));
}
return out;
}, [images, cols]);
if (images.length === 0) return null;
// Threshold for short lists: skip virtualization and render plain
// grid. ImageCard's lazy-loaded thumbnails handle the bytes side.
// Above the threshold we go through Virtuoso *from first render* —
// initialItemCount lets SSR emit a few rows so the simple-grid →
// virtuoso swap (and its flash) is gone.
const SIMPLE_THRESHOLD = 24;
if (images.length <= SIMPLE_THRESHOLD) {
return (
<div
className="grid gap-5"
style={{ gridTemplateColumns: `repeat(${cssCols}, minmax(0, 1fr))` }}
>
{images.map((img) => (
<ImageCard key={img.id} image={img} view={view} />
))}
</div>
);
}
const canLoadMore = infiniteScrollEnabled && loadedEnd < totalPages;
// Track which "page" sits at the top of the viewport. We scan
// rendered rows on each scroll tick and pick the first one whose
// bottom edge is past the viewport top — that's the row currently
// crossing/just-below the top. Virtuoso's own rangeChanged reports
// the *rendered* range (includes overscan above viewport) so it
// lags by a row or two.
const lastReportedPage = useRef<number>(ssrAnchorPage);
const totalItemsRef = useRef<number>(images.length);
useEffect(() => { totalItemsRef.current = images.length; }, [images.length]);
useEffect(() => {
if (!onVisiblePageChange) return;
const update = () => {
// Find the *bottom-most* row currently visible — i.e. the row
// furthest along that has any pixel in the viewport. Combined
// with last-item page derivation, the label flips the moment
// the first card of the next page peeks in from the bottom of
// the viewport.
const vh = window.innerHeight;
const els = document.querySelectorAll<HTMLElement>("[data-masonry-row]");
let chosen: HTMLElement | null = null;
for (const el of els) {
const r = el.getBoundingClientRect();
if (r.top < vh && r.bottom > 0) chosen = el; // keep updating
}
if (!chosen && els.length > 0) chosen = els[0];
if (!chosen) return;
const idx = Number(chosen.dataset.masonryRow ?? 0);
// Last item of the bottom-most visible row drives the page
// label. As soon as a row containing the first card of the
// next page enters from the bottom, the label flips. Clamp to
// the actual item count so a partially-filled trailing row
// (e.g. only 1 of 3 slots populated) doesn't claim a page that
// has no real content yet.
const total = totalItemsRef.current;
const itemIdx = Math.min(
idx * cols + Math.max(0, cols - 1),
Math.max(0, total - 1),
);
const page = ssrAnchorPage + Math.floor(itemIdx / pageSize);
if (page !== lastReportedPage.current) {
lastReportedPage.current = page;
onVisiblePageChange(page);
}
};
let raf: number | null = null;
const onScroll = () => {
if (raf != null) return;
raf = requestAnimationFrame(() => { raf = null; update(); });
};
window.addEventListener("scroll", onScroll, { passive: true });
update();
return () => {
window.removeEventListener("scroll", onScroll);
if (raf != null) cancelAnimationFrame(raf);
};
}, [cols, pageSize, ssrAnchorPage, onVisiblePageChange]);
return (
<>
<Virtuoso
ref={(h) => { if (virtuosoHandleRef) virtuosoHandleRef.current = h; }}
useWindowScroll
// `data` makes Virtuoso re-render rows whenever the array
// reference changes (e.g. on append). Without it, item
// closures freeze on the first render and updates to
// fadeFromIndex / loadedEnd never propagate into rows.
data={rows}
initialItemCount={Math.min(rows.length, 8)}
endReached={canLoadMore ? onEndReached : undefined}
increaseViewportBy={600}
itemContent={(rowIdx, row) => {
if (!row) return null;
return (
<MasonryRow
rowIdx={rowIdx}
row={row}
cols={cols}
view={view}
fadeController={fadeController}
fadeMs={fadeMs}
/>
);
}}
overscan={600}
// Key by the first card's id, falling back to rowIdx for empty
// rows. Joining every id in the row meant a partial trailing
// row (e.g. 2 of 3 columns filled) re-keyed to a brand-new
// identity once infinite-scroll filled the missing slots,
// unmounting the row mid-scroll and blanking the in-flight
// fade-in batch. The first id is stable across that fill.
computeItemKey={(rowIdx, row) => (row && row[0] ? row[0].id : rowIdx)}
/>
{isFetching && (
<div className="flex items-center justify-center gap-2 py-4 text-xs font-mono text-[var(--color-fg-muted)]">
<Loader2 className="w-4 h-4 animate-spin" /> Loading next page...
</div>
)}
</>
);
}
+277
View File
@@ -0,0 +1,277 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Bookmark, ChevronDown, Gem, Star, MinusCircle, Package, Check, Eye, FolderHeart, Tag, Play } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FilterCriteria, FilterStatus, MarkOption, StatusAxisKey } from "@/lib/filters";
import { totalStatusActive, EMPTY_STATUS } from "@/lib/filters";
const MARK_OPTIONS: Array<{
value: MarkOption;
label: string;
Icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
tint: "cyan" | "amber" | "violet" | "muted";
}> = [
{ value: "vip", label: "VIP", Icon: Gem, tint: "cyan" },
{ value: "favorite", label: "Favorite", Icon: Star, tint: "amber" },
{ value: "owned", label: "Owned", Icon: Package, tint: "violet" },
{ value: "unmarked", label: "Unmarked", Icon: MinusCircle, tint: "muted" },
];
type AxisOpt<V extends string> = { value: V; label: string };
type AxisConfig = {
key: StatusAxisKey;
label: string;
Icon: React.ComponentType<{ className?: string }>;
options: Array<AxisOpt<string>>;
};
const WATCH_AXES: AxisConfig[] = [
{ key: "watched", label: "Watched", Icon: Eye, options: [
{ value: "all", label: "ALL" },
{ value: "watched", label: "Watched" },
{ value: "unwatched", label: "Unwatched" },
]},
{ key: "rated", label: "Rated", Icon: Star, options: [
{ value: "all", label: "ALL" },
{ value: "rated", label: "Rated" },
{ value: "unrated", label: "No Rating" },
]},
];
const HAS_AXES: AxisConfig[] = [
{ key: "collection", label: "Collection", Icon: FolderHeart, options: [
{ value: "all", label: "ALL" },
{ value: "has", label: "Has" },
{ value: "missing", label: "Missing" },
]},
{ key: "tags", label: "Tags", Icon: Tag, options: [
{ value: "all", label: "ALL" },
{ value: "has", label: "Has" },
{ value: "missing", label: "Missing" },
]},
{ key: "video", label: "Video", Icon: Play, options: [
{ value: "all", label: "ALL" },
{ value: "has", label: "Has" },
{ value: "missing", label: "Missing" },
]},
];
export function MergedFilterPopover({
criteria,
onChange,
}: {
criteria: FilterCriteria;
onChange: (next: FilterCriteria) => void;
}) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const markCount = criteria.marks.length;
const stateCount = totalStatusActive(criteria);
const total = markCount + stateCount;
const active = total > 0;
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
function toggleMark(value: MarkOption) {
const has = criteria.marks.includes(value);
const next = has ? criteria.marks.filter((m) => m !== value) : [...criteria.marks, value];
onChange({ ...criteria, marks: next });
}
function setAxis<K extends StatusAxisKey>(key: K, value: FilterStatus[K]) {
onChange({ ...criteria, status: { ...criteria.status, [key]: value } });
}
function resetAll() {
onChange({ ...criteria, marks: [], status: { ...EMPTY_STATUS } });
}
// Watch / Has section counts (used for the footer breakdown text only).
const watchCount = (["watched", "rated"] as StatusAxisKey[]).filter((k) => criteria.status[k] !== "all").length;
const hasCount = (["collection", "tags", "video"] as StatusAxisKey[]).filter((k) => criteria.status[k] !== "all").length;
return (
<div className="relative" ref={wrapRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors min-w-[140px] justify-between",
active
? "bg-[var(--color-cyan)]/10 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
: "glass glass-hover text-[var(--color-fg-dim)]",
)}
>
<span className="inline-flex items-center gap-1.5">
<Bookmark className="w-3.5 h-3.5" />
Filter
</span>
<span className="inline-flex items-center gap-1.5">
<span
className={cn(
"inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--color-cyan)] text-black text-[10px] font-mono font-bold tabular-nums",
!active && "invisible",
)}
>
{total || 0}
</span>
<ChevronDown className="w-3 h-3 opacity-70" />
</span>
</button>
{open && (
<div
className="absolute left-0 top-[calc(100%+6px)] z-30 bg-[var(--color-bg-0)] border border-[var(--color-glass-border-strong)] rounded-2xl shadow-2xl overflow-hidden w-[460px]"
onClick={(e) => e.stopPropagation()}
>
{/* Section 1 — Marks (multi-select OR) */}
<div className="p-3 border-b border-[var(--color-glass-border)]">
<div className="grid grid-cols-4 gap-1">
{MARK_OPTIONS.map(({ value, label, Icon, tint }) => {
const on = criteria.marks.includes(value);
return (
<button
key={value}
type="button"
onClick={() => toggleMark(value)}
className={cn(
"flex items-center gap-1.5 px-2 py-1.5 rounded-md text-xs text-left transition-colors whitespace-nowrap",
on
? tint === "cyan" ? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)]"
: tint === "amber" ? "text-amber-200"
: tint === "violet" ? "bg-[var(--color-violet)]/15 text-[var(--color-violet)]"
: "bg-[var(--color-glass-strong)] text-[var(--color-fg)]"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
style={on && tint === "amber" ? { background: "rgba(251,191,36,0.15)" } : undefined}
>
<span className={cn(
"w-4 h-4 rounded border flex items-center justify-center shrink-0",
on
? tint === "cyan" ? "bg-[var(--color-cyan)]/30 border-[var(--color-cyan)]"
: tint === "amber" ? "border-amber-400"
: tint === "violet" ? "bg-[var(--color-violet)]/30 border-[var(--color-violet)]"
: "bg-[var(--color-fg-dim)]/30 border-[var(--color-fg-dim)]"
: "border-[var(--color-glass-border-strong)]",
)}
style={on && tint === "amber" ? { background: "rgba(251,191,36,0.25)" } : undefined}>
{on && (
<Check
className="w-3 h-3"
strokeWidth={3}
style={{
color: tint === "cyan" ? "var(--color-cyan)"
: tint === "amber" ? "#fbbf24"
: tint === "violet" ? "var(--color-violet)"
: "var(--color-fg)",
}}
/>
)}
</span>
<Icon
className={cn("w-3.5 h-3.5", on && tint === "amber" && "fill-amber-300")}
style={{
color: tint === "cyan" ? "var(--color-cyan)"
: tint === "amber" ? "#fbbf24"
: tint === "violet" ? "var(--color-violet)"
: "var(--color-fg-muted)",
}}
/>
<span>{label}</span>
</button>
);
})}
</div>
</div>
{/* Section 2 — Watch State */}
<AxisSection axes={WATCH_AXES} status={criteria.status} onSet={setAxis} />
{/* Section 3 — Has… */}
<AxisSection axes={HAS_AXES} status={criteria.status} onSet={setAxis} />
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--color-bg-1)] border-t border-[var(--color-glass-border)] text-[11px] font-mono text-[var(--color-fg-muted)]">
<span>
{total === 0
? "no filters set"
: `${total} filter${total === 1 ? "" : "s"}` +
(markCount > 0 ? ` · ${markCount} mark${markCount === 1 ? "" : "s"}` : "") +
(watchCount > 0 ? ` · ${watchCount} watch` : "") +
(hasCount > 0 ? ` · ${hasCount} has` : "")}
</span>
<button
type="button"
onClick={resetAll}
disabled={!active}
className="text-[var(--color-cyan)] hover:underline disabled:opacity-40 disabled:no-underline"
>
Reset All
</button>
</div>
</div>
)}
</div>
);
}
function AxisSection({
axes,
status,
onSet,
}: {
axes: AxisConfig[];
status: FilterStatus;
onSet: <K extends StatusAxisKey>(key: K, value: FilterStatus[K]) => void;
}) {
return (
<div className="p-3 border-b border-[var(--color-glass-border)] last:border-b-0 space-y-2.5">
{axes.map(({ key, label, Icon, options }) => {
const current = status[key];
return (
<div key={key}>
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-1">
<Icon className="w-3 h-3" />
{label}
</div>
<div className="flex border border-[var(--color-glass-border)] rounded-lg overflow-hidden">
{options.map((o) => {
const on = current === o.value;
return (
<button
key={o.value}
type="button"
onClick={() => onSet(key, o.value as FilterStatus[typeof key])}
className={cn(
"flex-1 text-center px-2 py-1.5 text-xs font-mono whitespace-nowrap transition-colors border-r border-[var(--color-glass-border)] last:border-r-0",
on
? "bg-[var(--color-cyan)]/20 text-[var(--color-cyan)] font-bold"
: "text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
{o.label}
</button>
);
})}
</div>
</div>
);
})}
</div>
);
}
+246
View File
@@ -0,0 +1,246 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Search, ChevronDown, Users, Building2, Film, Hash, FolderHeart, Tag, X, Check, Layers } from "lucide-react";
import { cn } from "@/lib/utils";
import { tabSupportsAnd, type FilterCriteria, type FilterTabKey } from "@/lib/filters";
export interface FilterOption {
id: number;
name: string;
count?: number;
}
const TAB_META: Array<{ key: FilterTabKey; label: string; Icon: React.ComponentType<{ className?: string }> }> = [
{ key: "actresses", label: "Actresses", Icon: Users },
{ key: "studios", label: "Studios", Icon: Building2 },
{ key: "series", label: "Series", Icon: Film },
{ key: "categories", label: "Categories", Icon: Layers },
{ key: "tags", label: "Tags", Icon: Tag },
{ key: "genres", label: "Genres", Icon: Hash },
{ key: "collections", label: "Collections", Icon: FolderHeart },
];
export function MultiFilterPopover({
criteria,
options,
onChange,
}: {
criteria: FilterCriteria;
options: Record<FilterTabKey, FilterOption[]>;
onChange: (next: FilterCriteria) => void;
}) {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<FilterTabKey>("actresses");
const [search, setSearch] = useState("");
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
const total = useMemo(() => {
let n = 0;
for (const t of TAB_META) n += criteria.ids[t.key].length;
return n;
}, [criteria]);
function toggleId(tab: FilterTabKey, id: number) {
const cur = criteria.ids[tab];
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
onChange({ ...criteria, ids: { ...criteria.ids, [tab]: next } });
}
function setMode(tab: FilterTabKey, mode: "and" | "or") {
onChange({ ...criteria, mode: { ...criteria.mode, [tab]: mode } });
}
function clearTab(tab: FilterTabKey) {
onChange({ ...criteria, ids: { ...criteria.ids, [tab]: [] } });
}
function clearAllTabs() {
onChange({
...criteria,
ids: { actresses: [], studios: [], series: [], genres: [], collections: [], tags: [], categories: [] },
});
}
const tabOptions = options[activeTab] ?? [];
const q = search.trim().toLowerCase();
const filteredOptions = q ? tabOptions.filter((o) => o.name.toLowerCase().includes(q)) : tabOptions;
const supportsAnd = tabSupportsAnd(activeTab);
return (
<div className="relative" ref={wrapRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors min-w-[140px] justify-between",
total > 0
? "bg-[var(--color-cyan)]/15 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
: "glass glass-hover text-[var(--color-fg-dim)]",
)}
>
Browse
<span
className={cn(
"inline-flex items-center justify-center w-[22px] h-4 rounded-full bg-[var(--color-cyan)] text-black text-[10px] font-mono font-bold tabular-nums",
total === 0 && "invisible",
)}
>
{total || 0}
</span>
<ChevronDown className="w-3 h-3 opacity-70" />
</button>
{open && (
<div
className="absolute left-0 top-[calc(100%+6px)] z-30 bg-[var(--color-bg-0)] border border-[var(--color-glass-border-strong)] rounded-2xl shadow-2xl p-3 w-[720px] max-w-[calc(100vw-32px)]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-wrap gap-1 border-b border-[var(--color-glass-border)] pb-2 mb-3">
{TAB_META.map(({ key, label, Icon }) => {
const count = criteria.ids[key].length;
const isActive = key === activeTab;
return (
<button
key={key}
type="button"
onClick={() => { setActiveTab(key); setSearch(""); }}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs transition-colors",
isActive
? "bg-[var(--color-glass-strong)] text-[var(--color-cyan)]"
: "text-[var(--color-fg-muted)] hover:bg-[var(--color-glass)] hover:text-[var(--color-fg-dim)]",
)}
>
<Icon className="w-3.5 h-3.5" />
{label}
{count > 0 && (
<span className="inline-flex items-center justify-center min-w-[14px] h-3.5 px-1 rounded-full bg-[var(--color-cyan)] text-black text-[9px] font-mono font-bold">
{count}
</span>
)}
</button>
);
})}
</div>
<div className="flex items-center gap-2 mb-2">
<div className="relative flex-1">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-fg-muted)] pointer-events-none" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={`Filter ${activeTab}`}
className="w-full glass rounded-lg pl-8 pr-2 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)]"
/>
</div>
{supportsAnd && (
<div className="inline-flex border border-[var(--color-glass-border)] rounded-lg overflow-hidden text-[11px] font-mono">
<button
type="button"
onClick={() => setMode(activeTab, "and")}
className={cn(
"px-2.5 py-1 transition-colors",
criteria.mode[activeTab] === "and"
? "bg-[var(--color-cyan)] text-black font-bold"
: "text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]",
)}
>
AND
</button>
<button
type="button"
onClick={() => setMode(activeTab, "or")}
className={cn(
"px-2.5 py-1 transition-colors",
criteria.mode[activeTab] === "or"
? "bg-[var(--color-cyan)] text-black font-bold"
: "text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]",
)}
>
OR
</button>
</div>
)}
</div>
<div className="max-h-[260px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-xs text-[var(--color-fg-muted)] italic px-2 py-3">
{q ? "No matches" : `No ${activeTab} yet`}
</div>
) : (
filteredOptions.map((o) => {
const checked = criteria.ids[activeTab].includes(o.id);
return (
<button
key={o.id}
type="button"
onClick={() => toggleId(activeTab, o.id)}
className={cn(
"w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md text-sm text-left transition-colors",
checked ? "text-[var(--color-cyan)]" : "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<span className={cn(
"w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0",
checked ? "bg-[var(--color-cyan)]/20 border-[var(--color-cyan)]" : "border-[var(--color-glass-border-strong)]",
)}>
{checked && <Check className="w-2.5 h-2.5" strokeWidth={3} />}
</span>
<span className="flex-1 truncate">{o.name}</span>
{typeof o.count === "number" && (
<span className="font-mono text-[11px] text-[var(--color-fg-muted)]">{o.count}</span>
)}
</button>
);
})
)}
</div>
<div className="mt-2 pt-2 border-t border-[var(--color-glass-border)] flex items-center justify-between text-[11px] font-mono text-[var(--color-fg-muted)]">
<span>
{supportsAnd
? "tap to toggle · AND = match all · OR = match any"
: "tap to toggle"}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => clearTab(activeTab)}
className="text-[var(--color-coral)] hover:underline"
>
Clear Tab
</button>
<span className="text-[var(--color-fg-muted)]">|</span>
<button
type="button"
onClick={clearAllTabs}
className="text-[var(--color-coral)] hover:underline"
>
Clear All Tabs
</button>
</div>
</div>
</div>
)}
</div>
);
}
export const FILTER_TABS = TAB_META;
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { ChevronLeft, ChevronRight, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
interface Props {
currentPage: number;
totalPages: number;
totalCount: number;
/** When provided, Prev/Next first try to scroll within the already-
* loaded grid (or prefetch + scroll, depending on mode). If the
* callback returns false (or resolves to false), the bar falls back
* to a URL navigation. */
onScrollToPage?: (targetPage: number) => boolean | Promise<boolean>;
/** Called when the user clicks Prev/Next but the target equals the
* current URL page (i.e. no router.push will happen). The grid uses
* this to clear its appended buffer + scroll to top, so a Prev click
* at "Page 5 (scrolled from URL=/)" snaps back to Page 1 instead of
* silently doing nothing. */
onSamePageNav?: () => void;
}
/**
* Bottom pagination bar. Preserves all existing query params (filters,
* sort, view) when navigating between pages — only the `page` key
* changes. `page=1` is dropped from the URL for a clean default.
*/
export function PaginationBar({ currentPage, totalPages, totalCount, onScrollToPage, onSamePageNav }: Props) {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
const [, start] = useTransition();
const [jump, setJump] = useState<string>("");
// The page anchored in the URL — independent of the visible-page label
// (which drifts as the user scrolls into appended pages). Prev/Next nav
// math reads from here so a click at "Page 4 (showing)" while URL=?page=2
// walks back from 2, not from 4. Without this split, a buffer of
// appended pages combined with onEndReached chaining makes Prev appear
// to "stick" at page 1: each Prev pushes URL=/, the auto-fetch chain
// re-drifts visiblePage forward, and the loop never escapes.
const urlPageRaw = Number(sp.get("page") ?? "1");
const urlPage = Number.isFinite(urlPageRaw) && urlPageRaw >= 1
? Math.min(Math.floor(urlPageRaw), totalPages)
: 1;
const urlNav = (page: number) => {
const next = new URLSearchParams(sp.toString());
if (page > 1) next.set("page", String(page));
else next.delete("page");
const qs = next.toString();
// If the URL would not actually change (target page === urlPage),
// a router.push is a no-op visually and the loop "click Prev → no
// remount → drift returns" reappears. Hand off to onSamePageNav so
// the grid can reset its appended buffer + scroll to top.
if (page === urlPage && onSamePageNav) {
onSamePageNav();
return;
}
// Marker so the destination grid can distinguish an internal
// Prev/Next click from a browser back/forward. Internal nav skips
// scroll-restore (which otherwise replays a stale buffer snapshot
// and re-creates the visiblePage drift).
try { sessionStorage.setItem("pinkudex:nav-internal", "1"); } catch { /* ignore */ }
start(() => {
router.push(qs ? `${pathname}?${qs}` : pathname, { scroll: true });
});
};
const navTo = async (page: number) => {
if (onScrollToPage) {
const maybe = onScrollToPage(page);
const ok = typeof maybe === "boolean" ? maybe : await maybe;
if (ok) return;
}
urlNav(page);
};
const goJump = (e: React.FormEvent) => {
e.preventDefault();
const target = Number(jump);
if (!Number.isFinite(target) || target < 1) return;
const clamped = Math.min(Math.max(1, Math.floor(target)), totalPages);
setJump("");
void navTo(clamped);
};
// Prev/Next button math — relative to the displayed (visible) page so
// clicks feel responsive to where the user is scrolled. The same-URL
// case is handled by onSamePageNav (buffer reset), which prevents the
// old "Prev does nothing" trap when target === urlPage.
const prevTarget = Math.max(1, currentPage - 1);
const nextTarget = Math.min(totalPages, currentPage + 1);
const canPrev = currentPage > 1;
const canNext = currentPage < totalPages;
const showJump = totalPages > 5;
return (
<div className="flex items-center justify-center gap-2 flex-wrap">
<button
type="button"
disabled={!canPrev}
onClick={() => void navTo(prevTarget)}
className={cn(
"inline-flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg glass",
!canPrev
? "opacity-40 cursor-not-allowed"
: "glass-hover cursor-pointer",
)}
>
<ChevronLeft className="w-4 h-4" />
Prev
</button>
<div className="text-sm font-mono text-[var(--color-fg-dim)] px-2 text-center tabular-nums min-w-[240px]">
Page <span className="text-[var(--color-cyan)]">{currentPage}</span> of {totalPages}
<span className="opacity-50"> · </span>
{totalCount.toLocaleString()} total
</div>
<button
type="button"
disabled={!canNext}
onClick={() => void navTo(nextTarget)}
className={cn(
"inline-flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg glass",
!canNext
? "opacity-40 cursor-not-allowed"
: "glass-hover cursor-pointer",
)}
>
Next
<ChevronRight className="w-4 h-4" />
</button>
{showJump && (
<form onSubmit={goJump} className="ml-3 inline-flex items-center gap-1.5">
<span className="text-xs uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
Jump to
</span>
<input
type="text"
inputMode="numeric"
value={jump}
onChange={(e) => setJump(e.target.value.replace(/[^0-9]/g, ""))}
placeholder={`1${totalPages}`}
className="w-20 glass rounded-md px-2 py-1 text-xs font-mono outline-none focus:border-[var(--color-cyan)] text-center"
/>
<button
type="submit"
disabled={!jump || Number(jump) < 1 || Number(jump) > totalPages}
className="inline-flex items-center justify-center w-7 h-7 rounded-md glass glass-hover disabled:opacity-40 cursor-pointer"
title="Jump"
>
<ArrowRight className="w-3.5 h-3.5" />
</button>
</form>
)}
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { ArrowDownAZ, ArrowDownUp, ArrowUpAZ, Check, ChevronDown, Clock, Hash } from "lucide-react";
import { useClickOutside } from "@/lib/hooks/useClickOutside";
import { SORT_OPTIONS, labelFor, type SortKey } from "@/lib/sort";
import { cn } from "@/lib/utils";
const ICONS: Record<SortKey, React.ComponentType<{ className?: string }>> = {
newest: Clock,
oldest: Clock,
az: ArrowDownAZ,
za: ArrowUpAZ,
"code-az": Hash,
"code-za": Hash,
};
export function SortMenu({ activeSort }: { activeSort: SortKey }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, useCallback(() => setOpen(false), []), open);
const pathname = usePathname();
const params = useSearchParams();
const hrefFor = useMemo(() => (next: SortKey) => {
const sp = new URLSearchParams(params);
sp.set("sort", next);
return `${pathname}?${sp.toString()}`;
}, [pathname, params]);
const Icon = ICONS[activeSort] ?? ArrowDownUp;
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors glass glass-hover text-[var(--color-fg-dim)]"
title={`Sort: ${labelFor(activeSort)}`}
>
<Icon className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{labelFor(activeSort)}</span>
<ChevronDown className={cn("w-3 h-3 opacity-60 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div
className="absolute right-0 top-full mt-2 z-30 min-w-[200px] rounded-xl shadow-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
<div className="p-1">
{SORT_OPTIONS.map((o, i) => {
const OptIcon = ICONS[o.value];
const active = o.value === activeSort;
const prev = SORT_OPTIONS[i - 1];
// Group divider whenever the underlying sort dimension
// changes (date → title → code). newest/oldest share the
// date dimension; az/za and code-az/code-za each share theirs.
const groupOf = (v: string) =>
v === "newest" || v === "oldest"
? "date"
: v.replace(/-?(az|za)$/, "") || "title";
const showDivider = prev && groupOf(prev.value) !== groupOf(o.value);
return (
<div key={o.value}>
{showDivider && (
<div className="my-1 mx-2 border-t border-[var(--color-glass-border)]" />
)}
<Link
href={hrefFor(o.value)}
onClick={() => setOpen(false)}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md text-sm hover:bg-[var(--color-glass)]",
active && "text-[var(--color-cyan)]"
)}
>
<OptIcon className="w-3.5 h-3.5" />
<span className="flex-1">{o.label}</span>
{active && <Check className="w-3.5 h-3.5" />}
</Link>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
import { RectangleVertical, RectangleHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
export type LibraryView = "portrait" | "landscape";
export function ViewToggle({ current }: { current: LibraryView }) {
const router = useRouter();
const params = useSearchParams();
const [, start] = useTransition();
function set(view: LibraryView) {
if (view === current) return;
const sp = new URLSearchParams(params.toString());
if (view === "portrait") sp.set("view", "portrait");
else sp.delete("view");
const y = typeof window !== "undefined" ? window.scrollY : 0;
start(() => {
const qs = sp.toString();
router.push(qs ? `?${qs}` : `?`, { scroll: false });
requestAnimationFrame(() => window.scrollTo({ top: y, left: 0, behavior: "instant" as ScrollBehavior }));
});
}
return (
<div className="flex items-center rounded-lg glass overflow-hidden text-xs">
<button
type="button"
onClick={() => set("landscape")}
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
current === "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="Full cover (landscape)"
>
<RectangleHorizontal className="w-3.5 h-3.5" /> L
</button>
<button
type="button"
onClick={() => set("portrait")}
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
current === "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="Front cover only (portrait)"
>
<RectangleVertical className="w-3.5 h-3.5" /> P
</button>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2, Loader2 } from "lucide-react";
import { deleteAttachedImage } from "@/app/actions/attachments";
import { imageUrl } from "@/lib/assetUrls";
interface Attached {
id: number;
thumbPath: string;
width: number;
height: number;
filename: string;
sha256: string;
}
export function AttachedImages({ parentId, items }: { parentId: number; items: Attached[] }) {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [, start] = useTransition();
async function upload(files: FileList | null) {
if (!files || files.length === 0) return;
// Drop-zone bypasses the Add button's `disabled` state, so a second
// drop while a previous upload is in flight would race the busy
// flag (and clobber fileRef.value mid-flight).
if (busy) return;
setBusy(true);
setError(null);
try {
for (const file of Array.from(files)) {
const fd = new FormData();
fd.append("file", file);
fd.append("parentImageId", String(parentId));
const res = await fetch("/api/upload", { method: "POST", body: fd });
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error ?? `upload failed (${res.status})`);
}
}
router.refresh();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
if (fileRef.current) fileRef.current.value = "";
}
}
return (
<div className="mt-3 glass rounded-2xl p-3">
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
Back covers / extras {items.length > 0 && <span className="ml-1 text-[var(--color-fg-dim)]">({items.length})</span>}
</div>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={busy}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
>
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
{busy ? "Uploading…" : "Add"}
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
hidden
onChange={(e) => upload(e.target.files)}
/>
</div>
{error && (
<div className="mb-2 text-xs text-red-400">{error}</div>
)}
{items.length === 0 ? (
<div
onDragOver={(e) => { e.preventDefault(); }}
onDrop={(e) => { e.preventDefault(); upload(e.dataTransfer.files); }}
className="rounded-xl border border-dashed border-[var(--color-glass-border)] py-6 text-center text-xs text-[var(--color-fg-muted)]"
>
No back covers yet. Drag & drop here or click <span className="text-[var(--color-cyan)]">Add</span>.
</div>
) : (
<div
onDragOver={(e) => { e.preventDefault(); }}
onDrop={(e) => { e.preventDefault(); upload(e.dataTransfer.files); }}
className="flex flex-col gap-3"
>
{items.map((it) => (
<div key={it.id} className="relative group rounded-2xl overflow-hidden glass">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl({ id: it.id, code: null, ext: it.filename.match(/\.[^.]+$/)?.[0] ?? ".jpg", v: it.sha256.slice(0, 12) })}
alt={it.filename}
width={it.width}
height={it.height}
className="block w-full h-auto max-w-[800px] max-h-[538px]"
/>
<button
type="button"
onClick={() => start(async () => { await deleteAttachedImage(it.id); router.refresh(); })}
title="Remove"
aria-label="Remove"
className="absolute top-2 right-2 w-8 h-8 grid place-items-center rounded-md bg-black/70 text-red-300 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/90"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Eye, EyeOff, Gem, Star, Package } from "lucide-react";
import { setWatched, setCoverVip, setCoverFavorite, setCoverOwned } from "@/app/actions/coverMeta";
import { cn } from "@/lib/utils";
type Kind = "watched" | "vip" | "favorite" | "owned";
const CONFIG: Record<Kind, {
onLabel: string;
offLabel: string;
OnIcon: React.ComponentType<{ className?: string }>;
OffIcon: React.ComponentType<{ className?: string }>;
onClass: string;
offClass: string;
action: (id: number, on: boolean) => Promise<void>;
}> = {
watched: {
onLabel: "Watched",
offLabel: "Not Watched",
OnIcon: Eye,
OffIcon: EyeOff,
onClass: "bg-[var(--color-mint)]/10 border-[var(--color-mint)]/30 text-[var(--color-mint)] hover:bg-[var(--color-mint)]/20",
offClass: "border-[var(--color-glass-border)] text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:border-[var(--color-glass-border-strong)]",
action: setWatched,
},
vip: {
onLabel: "VIP",
offLabel: "VIP",
OnIcon: Gem,
OffIcon: Gem,
onClass: "bg-cyan-400/15 border-cyan-400/40 text-cyan-200 hover:bg-cyan-400/25",
offClass: "border-[var(--color-glass-border)] text-[var(--color-fg-muted)] hover:text-cyan-200 hover:border-cyan-400/40",
action: setCoverVip,
},
favorite: {
onLabel: "Favorite",
offLabel: "Favorite",
OnIcon: Star,
OffIcon: Star,
onClass: "bg-amber-400/15 border-amber-400/40 text-amber-200 hover:bg-amber-400/25",
offClass: "border-[var(--color-glass-border)] text-[var(--color-fg-muted)] hover:text-amber-200 hover:border-amber-400/40",
action: setCoverFavorite,
},
owned: {
onLabel: "Owned",
offLabel: "Owned",
OnIcon: Package,
OffIcon: Package,
onClass: "bg-[var(--color-violet)]/15 border-[var(--color-violet)]/40 text-[var(--color-violet)] hover:bg-[var(--color-violet)]/25",
offClass: "border-[var(--color-glass-border)] text-[var(--color-fg-muted)] hover:text-[var(--color-violet)] hover:border-[var(--color-violet)]/40",
action: setCoverOwned,
},
};
// Custom event that lets siblings predict the server-side mutual
// exclusion between VIP and Favorite (one being set on clears the
// other). Without this, the just-cleared pill would show as "on"
// optimistically until router.refresh() round-trips the new initial
// prop. The event lets the affected sibling clear its local state
// immediately on the same render tick.
const MUTEX_EVENT = "pinkudex:cover-flag-mutex";
interface MutexDetail { imageId: number; clearedKind: Kind }
export function CoverFlagToggle({
kind,
imageId,
initial,
}: {
kind: Kind;
imageId: number;
initial: boolean;
}) {
const router = useRouter();
const cfg = CONFIG[kind];
const [on, setLocal] = useState(initial);
const [, start] = useTransition();
// Sync to fresh server state — needed so VIP and Favorite stay mutually exclusive
// when the other one is toggled and the page refreshes.
useEffect(() => { setLocal(initial); }, [initial]);
// Listen for sibling toggles that would mutex-clear our flag.
useEffect(() => {
if (kind !== "vip" && kind !== "favorite") return;
const handler = (ev: Event) => {
const d = (ev as CustomEvent<MutexDetail>).detail;
if (d && d.imageId === imageId && d.clearedKind === kind) {
setLocal(false);
}
};
window.addEventListener(MUTEX_EVENT, handler);
return () => window.removeEventListener(MUTEX_EVENT, handler);
}, [imageId, kind]);
const Icon = on ? cfg.OnIcon : cfg.OffIcon;
function toggle() {
const next = !on;
setLocal(next);
// Server clears the opposite flag when VIP/Favorite is turned on.
// Tell our sibling instance now so its UI doesn't lag the action.
if (next && (kind === "vip" || kind === "favorite")) {
const cleared: Kind = kind === "vip" ? "favorite" : "vip";
window.dispatchEvent(new CustomEvent<MutexDetail>(MUTEX_EVENT, {
detail: { imageId, clearedKind: cleared },
}));
}
start(async () => {
await cfg.action(imageId, next);
router.refresh();
});
}
return (
<button
type="button"
onClick={toggle}
title={`Toggle ${cfg.onLabel}`}
className={cn(
"flex w-full min-w-0 items-center justify-center gap-1.5 px-2 py-1.5 rounded-full text-[10px] uppercase tracking-wider font-mono border transition-colors cursor-pointer",
on ? cfg.onClass : cfg.offClass,
)}
>
<Icon className={cn("w-3 h-3", kind === "favorite" && on && "fill-amber-200")} />
{on ? cfg.onLabel : cfg.offLabel}
</button>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { Trash2 } from "lucide-react";
import { deleteImage } from "@/app/actions/bulk";
import { useUndoDeleteToast } from "@/components/select/UndoDeleteToast";
import { useSettings } from "@/components/settings/SettingsProvider";
export function DetailDeleteButton({ id }: { id: number }) {
const [pending, start] = useTransition();
const router = useRouter();
const { show: showUndo } = useUndoDeleteToast();
const { settings } = useSettings();
const onClick = (e: React.MouseEvent) => {
const permanent = e.shiftKey || !settings.useRecycleBin;
if (permanent && !confirm("Permanently delete this cover? Cannot be undone.")) return;
start(async () => {
await deleteImage(id, permanent ? { permanent: true } : undefined);
if (!permanent) showUndo([id]);
router.push("/");
});
};
return (
<button
onClick={onClick}
disabled={pending}
title={settings.useRecycleBin ? "Send to trash · Shift-click for permanent delete" : "Delete permanently"}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-[var(--color-coral)]/40 bg-[var(--color-coral)]/10 text-[var(--color-coral)] hover:bg-[var(--color-coral)]/20 transition-colors whitespace-nowrap disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
{pending ? "Deleting…" : "Delete"}
</button>
);
}
+326
View File
@@ -0,0 +1,326 @@
import Link from "next/link";
import { Star, Calendar, Clock, Building2, Film, Tag as TagIcon, Captions } from "lucide-react";
import { CoverFlagToggle } from "./CoverFlagToggle";
import { getImageDetail, listAllCollections, listAttachedImages, listAllActresses, listAllGenres } from "@/lib/db/queries";
import { CoverEditor } from "@/components/cover/CoverEditor";
import { CoverPlayButton } from "@/components/video/CoverPlayButton";
import { TagEditor } from "@/components/tags/TagEditor";
import { CollectionPicker } from "@/components/collections/CollectionPicker";
import { AttachedImages } from "@/components/image/AttachedImages";
import { formatBytes } from "@/lib/utils";
import { imageUrl } from "@/lib/assetUrls";
import { formatBitrate, formatDuration, formatResolution, formatBytes as formatVideoBytes, formatVideoSummary, listStoredVideoMetadataForCode } from "@/lib/video/metadata";
import { Panel, PanelStack, PanelSection, PanelHeader, ChipCluster } from "@/components/ui/panel";
import path from "node:path";
export function ImageDetailView({ imageId }: { imageId: number }) {
const detail = getImageDetail(imageId);
if (!detail) return null;
const allCollections = listAllCollections().map((c) => ({ id: c.id, name: c.name, slug: c.slug }));
const attached = listAttachedImages(detail.image.id);
const actressSuggestions = listAllActresses().map((a) => {
const primaryAliases: string[] = [];
const tokens = a.name.trim().split(/\s+/).filter(Boolean);
if (tokens.length >= 2) primaryAliases.push(tokens.slice().reverse().join(" "));
const aliases: string[] = [];
if (a.altNames) {
for (const part of a.altNames.split(/[,、,]/)) {
const t = part.trim();
if (t) aliases.push(t);
}
}
return { name: a.name, primaryAliases, aliases };
});
const genreSuggestions = listAllGenres().map((g) => g.name);
const { image, studio, label, series, actresses, genres, tags, collections } = detail;
const videoMetas = image.hasVideo ? listStoredVideoMetadataForCode(image.code) : [];
const videoSummary = videoMetas.map((meta) => formatVideoSummary(meta)).find(Boolean);
// Pull the first probed-clean meta for the per-stat hero strip. Falls
// back to the very first row if none have a usable probe yet.
const heroMeta = videoMetas.find((m) => !m.probeError) ?? videoMetas[0] ?? null;
const heroStats = heroMeta && !heroMeta.probeError
? {
resolution: formatResolution(heroMeta.width, heroMeta.height),
bitrate: formatBitrate(heroMeta.videoBitrate),
// Sum across parts so the user sees the actual disk footprint
// for the whole title, not just the first file.
size: formatVideoBytes(videoMetas.reduce((acc, m) => acc + (m.sizeBytes ?? 0), 0)),
// Same for length — total runtime across all parts.
length: formatDuration(videoMetas.reduce((acc, m) => acc + (m.durationSec ?? 0), 0)),
}
: null;
return (
<div key={image.id} className="pb-12 fade-in">
<div className="grid grid-cols-1 lg:grid-cols-[800px_minmax(0,1fr)] gap-6">
<div>
<div className="glass rounded-2xl overflow-hidden relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl({ id: image.id, code: image.code, ext: path.extname(image.filename), v: image.sha256.slice(0, 12) })}
alt={image.title ?? image.code ?? image.filename}
width={image.width}
height={image.height}
className="block w-full h-auto max-w-[800px] max-h-[538px]"
/>
<CoverPlayButton
code={image.code}
actresses={actresses.map((a) => ({ id: a.id, name: a.name, slug: a.slug }))}
/>
</div>
<AttachedImages parentId={image.id} items={attached} />
</div>
<PanelStack as="aside">
<Panel>
<PanelSection>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1.5 flex-1">
<div className="flex items-center justify-between gap-3">
{image.code ? (
<div className="flex items-center gap-2">
<span className="text-base uppercase tracking-wider font-mono font-semibold text-[var(--color-cyan)]">
{image.code}
</span>
{image.hasSubtitle && (
<span
title="Subtitle file available"
className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider font-mono px-1.5 py-0.5 rounded border border-[var(--color-mint)]/40 bg-[var(--color-mint)]/10 text-[var(--color-mint)]"
>
<Captions className="w-3 h-3" /> CC
</span>
)}
</div>
) : (
<div />
)}
{image.rating != null && (
<div className="flex items-center gap-0.5 shrink-0">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={`w-3.5 h-3.5 ${i < image.rating! ? "fill-[var(--color-cyan)] text-[var(--color-cyan)]" : "text-[var(--color-fg-muted)]"}`}
/>
))}
</div>
)}
</div>
<h1 className="text-lg font-medium truncate" title={image.title ?? image.filename}>
{image.title || <span className="text-[var(--color-fg-muted)] italic">Untitled</span>}
</h1>
</div>
</div>
<div className="grid grid-cols-4 gap-chip text-xs w-full">
<CoverFlagToggle kind="vip" imageId={image.id} initial={image.isVip} />
<CoverFlagToggle kind="favorite" imageId={image.id} initial={image.isFavorite} />
<CoverFlagToggle kind="watched" imageId={image.id} initial={image.watched} />
<CoverFlagToggle kind="owned" imageId={image.id} initial={image.isOwned} />
</div>
{(image.releaseDate || image.runtimeMin || image.director) && (
<div className="grid grid-cols-3 gap-chip text-xs font-mono text-[var(--color-fg-dim)] pt-section border-t border-[var(--color-glass-border)]">
{image.releaseDate && <Field icon={Calendar} label="Released" value={image.releaseDate} />}
{image.runtimeMin != null && <Field icon={Clock} label="Runtime" value={`${image.runtimeMin} min`} />}
{image.director && <Field label="Director" value={image.director} />}
</div>
)}
<div className="flex flex-wrap items-baseline justify-center gap-x-5 gap-y-1 text-[12px] font-mono text-[var(--color-fg-muted)] pt-section border-t border-[var(--color-glass-border)]">
<InlineMeta label="Resolution" value={`${image.width}×${image.height}`} />
<span className="opacity-30">·</span>
<InlineMeta label="Size" value={formatBytes(image.bytes)} />
<span className="opacity-30">·</span>
<InlineMeta label="Imported" value={new Date(image.importedAt).toLocaleDateString()} />
</div>
{heroStats && (
<div className="grid grid-cols-4 gap-stat-gap text-center pt-section border-t border-[var(--color-glass-border)]">
{heroStats.resolution && (
<HeroStat label="Resolution" value={heroStats.resolution} />
)}
{heroStats.bitrate && (
<HeroStat label="Bitrate" value={heroStats.bitrate} />
)}
{heroStats.size && (
<HeroStat label="File Size" value={heroStats.size} accent />
)}
{heroStats.length && (
<HeroStat label="Length" value={heroStats.length} cyan />
)}
</div>
)}
{/* Video summary is suppressed when heroStats render — the 4-up
strip already covers resolution/bitrate/size/length. Falls
back to the inline summary on rows where the probe didn't
yield enough data for the hero strip. */}
{!heroStats && videoSummary && (
<div className="text-[13px] font-mono text-[var(--color-fg-muted)]">
<InlineMeta label={videoMetas.length > 1 ? `Video (${videoMetas.length} parts)` : "Video"} value={videoSummary} />
</div>
)}
</PanelSection>
</Panel>
{(studio || label || series) && (
<Panel className="flex flex-col gap-chip">
{studio && <EntityLink icon={Building2} href={`/studios/${studio.slug}`} label="Studio" name={studio.name} />}
{label && <EntityLink icon={TagIcon} href={`/labels/${label.slug}`} label="Label" name={label.name} />}
{series && <EntityLink icon={Film} href={`/series/${series.slug}`} label="Series" name={series.name} />}
</Panel>
)}
{actresses.length > 0 && (
<Section title="Actresses">
<ChipCluster>
{actresses.map((a) => (
<Link
key={a.id}
href={`/actress/${a.slug}`}
className="px-2.5 py-1 rounded-full text-xs border transition-colors"
style={{
background: "color-mix(in oklch, var(--color-violet) 14%, transparent)",
color: "var(--color-violet)",
borderColor: "color-mix(in oklch, var(--color-violet) 35%, transparent)",
}}
>
{a.name}
</Link>
))}
</ChipCluster>
</Section>
)}
{genres.length > 0 && (
<Section title="Genres">
<ChipCluster>
{genres.map((g) => (
<Link
key={g.id}
href={`/genres/${g.slug}`}
className="px-2.5 py-1 rounded-full text-xs border transition-colors"
style={{
background: "color-mix(in oklch, var(--color-cyan) 14%, transparent)",
color: "var(--color-cyan)",
borderColor: "color-mix(in oklch, var(--color-cyan) 35%, transparent)",
}}
>
{g.name}
</Link>
))}
</ChipCluster>
</Section>
)}
<Panel>
<TagEditor imageId={image.id} initial={tags} />
</Panel>
<Panel>
<CollectionPicker imageId={image.id} current={collections} available={allCollections} />
</Panel>
<CoverEditor
initial={{
imageId: image.id,
code: image.code,
title: image.title,
releaseDate: image.releaseDate,
runtimeMin: image.runtimeMin,
director: image.director,
studio: studio?.name ?? null,
label: label?.name ?? null,
series: series?.name ?? null,
rating: image.rating,
watched: image.watched,
notes: image.notes,
actresses: actresses.map((a) => a.name),
genres: genres.map((g) => g.name),
}}
actressSuggestions={actressSuggestions}
genreSuggestions={genreSuggestions}
/>
{image.notes && (
<Panel>
<PanelHeader>Notes</PanelHeader>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{image.notes}</p>
</Panel>
)}
</PanelStack>
</div>
</div>
);
}
function HeroStat({
label,
value,
accent = false,
cyan = false,
}: {
label: string;
value: string;
/** Render the value in the brighter primary fg (used for headline stats). */
accent?: boolean;
/** Tint the value cyan — reserved for the single most-prominent stat. */
cyan?: boolean;
}) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-stat">
{label}
</div>
<div
className={
cyan
? "font-mono text-3xl font-semibold tracking-tight leading-none text-[var(--color-cyan)]"
: accent
? "font-mono text-3xl font-semibold tracking-tight leading-none text-[var(--color-fg)]"
: "font-mono text-2xl font-semibold tracking-tight leading-none text-[var(--color-fg-dim)]"
}
>
{value}
</div>
</div>
);
}
function InlineMeta({ label, value }: { label: string; value: string }) {
return (
<span className="inline-flex items-baseline gap-1.5">
<span className="text-[10px] uppercase tracking-wider opacity-70">{label}</span>
<span className="text-[var(--color-fg)]">{value}</span>
</span>
);
}
function Field({ icon: Icon, label, value }: { icon?: React.ComponentType<{ className?: string }>; label: string; value: string }) {
return (
<div>
<div className="text-[9px] uppercase tracking-wider text-[var(--color-fg-muted)] flex items-center gap-1">
{Icon && <Icon className="w-3 h-3" />}
{label}
</div>
<div className="text-[var(--color-fg)] truncate">{value}</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<Panel>
<PanelHeader>{title}</PanelHeader>
{children}
</Panel>
);
}
function EntityLink({ icon: Icon, href, label, name }: { icon: React.ComponentType<{ className?: string }>; href: string; label: string; name: string }) {
return (
<Link href={href} className="flex items-center gap-2 text-sm group">
<Icon className="w-4 h-4 text-[var(--color-fg-muted)] group-hover:text-[var(--color-cyan)]" />
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] w-16">{label}</span>
<span className="text-[var(--color-fg)] group-hover:text-[var(--color-cyan)]">{name}</span>
</Link>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { ChevronLeft, ChevronRight, Shuffle, Undo2 } from "lucide-react";
import { cn, coverHref } from "@/lib/utils";
type Neighbor = { id: number; code: string | null } | null;
export function ImageNav({
prev,
next,
randomEndpoint,
}: {
prev: Neighbor;
next: Neighbor;
randomEndpoint: string;
}) {
const router = useRouter();
const prevHref = prev ? coverHref(prev) : null;
const nextHref = next ? coverHref(next) : null;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === "ArrowLeft") {
if (prevHref) { e.preventDefault(); router.push(prevHref); }
} else if (e.key === "ArrowRight") {
if (nextHref) { e.preventDefault(); router.push(nextHref); }
} else if (e.key === "ArrowUp") {
e.preventDefault();
router.push(randomEndpoint);
} else if (e.key === "ArrowDown") {
e.preventDefault();
router.back();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [prevHref, nextHref, randomEndpoint, router]);
return (
<div className="flex items-center gap-1.5">
<NavBtn href={prevHref} label="Previous (←)">
<ChevronLeft className="w-4 h-4" />
</NavBtn>
<NavBtn href={randomEndpoint} label="Random (↑)">
<Shuffle className="w-3.5 h-3.5" />
</NavBtn>
<button
onClick={() => router.back()}
title="Last viewed (↓)"
aria-label="Last viewed"
className="w-8 h-8 grid place-items-center rounded-lg border border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)] transition-colors"
>
<Undo2 className="w-3.5 h-3.5" />
</button>
<NavBtn href={nextHref} label="Next (→)">
<ChevronRight className="w-4 h-4" />
</NavBtn>
</div>
);
}
function NavBtn({ href, label, children }: { href: string | null; label: string; children: React.ReactNode }) {
const className = cn(
"w-8 h-8 grid place-items-center rounded-lg border transition-colors",
href
? "border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
: "border-[var(--color-glass-border)]/40 text-[var(--color-fg-muted)]/40 cursor-not-allowed",
);
if (!href) {
return (
<span aria-disabled title={label} className={className}>
{children}
</span>
);
}
return (
<Link href={href} title={label} aria-label={label} className={className}>
{children}
</Link>
);
}
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { X, Replace, SkipForward, ArrowUp, ArrowDown, Equal, Shuffle } from "lucide-react";
import { cn } from "@/lib/utils";
export type CollisionBucket = "upgrade" | "downgrade" | "sidegrade" | "mixed";
export type CollisionDecision = "replace" | "skip";
export interface CollisionReviewItem {
jobId: string;
filename: string;
code: string | null;
existingId: number;
existingFilename: string;
existingWidth: number;
existingHeight: number;
existingBytes: number;
existingThumbPath: string;
incomingWidth: number;
incomingHeight: number;
incomingBytes: number;
bucket: CollisionBucket;
}
export interface CollisionReviewProps {
items: CollisionReviewItem[];
onCancel: () => void;
onConfirm: (decisions: Record<string, CollisionDecision>) => void;
}
const BUCKET_META: Record<CollisionBucket, { label: string; icon: typeof ArrowUp; color: string; defaultDecision: CollisionDecision }> = {
upgrade: { label: "Upgrade", icon: ArrowUp, color: "var(--color-mint)", defaultDecision: "replace" },
downgrade: { label: "Downgrade", icon: ArrowDown, color: "var(--color-coral)", defaultDecision: "skip" },
sidegrade: { label: "Sidegrade", icon: Equal, color: "var(--color-amber, #fbbf24)", defaultDecision: "skip" },
mixed: { label: "Mixed", icon: Shuffle, color: "var(--color-violet)", defaultDecision: "skip" },
};
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
export function CollisionReviewDialog({ items, onCancel, onConfirm }: CollisionReviewProps) {
const [decisions, setDecisions] = useState<Record<string, CollisionDecision>>(() => {
const init: Record<string, CollisionDecision> = {};
for (const it of items) init[it.jobId] = BUCKET_META[it.bucket].defaultDecision;
return init;
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
const counts = useMemo(() => {
const c: Record<CollisionBucket, number> = { upgrade: 0, downgrade: 0, sidegrade: 0, mixed: 0 };
for (const it of items) c[it.bucket]++;
return c;
}, [items]);
function applyToBucket(bucket: CollisionBucket | "all", decision: CollisionDecision) {
setDecisions((s) => {
const next = { ...s };
for (const it of items) {
if (bucket === "all" || it.bucket === bucket) next[it.jobId] = decision;
}
return next;
});
}
const replaceCount = Object.values(decisions).filter((d) => d === "replace").length;
const skipCount = items.length - replaceCount;
if (typeof document === "undefined") return null;
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm"
// Backdrop dismiss on mousedown (not click) so a text-drag-select
// started inside the dialog and released over the backdrop doesn't
// discard the user's review decisions.
onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div
onMouseDown={(e) => e.stopPropagation()}
className="relative w-[min(95vw,900px)] max-h-[85vh] flex flex-col rounded-2xl border border-[var(--color-glass-border-strong)] bg-[var(--color-bg-0)] shadow-2xl"
>
<div className="px-5 py-4 border-b border-[var(--color-glass-border)] flex items-center justify-between">
<div>
<div className="text-base font-medium">Code collisions detected</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
{items.length} cover{items.length === 1 ? "" : "s"} share a code with an existing entry. Pick what to do with each.
</div>
</div>
<button onClick={onCancel} className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]">
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 py-3 border-b border-[var(--color-glass-border)] flex flex-wrap items-center gap-2 text-xs">
{(Object.keys(BUCKET_META) as CollisionBucket[]).map((b) => {
const meta = BUCKET_META[b];
const Icon = meta.icon;
const n = counts[b];
if (n === 0) return null;
return (
<div key={b} className="flex items-center gap-1.5">
<span
className="flex items-center gap-1 px-2 py-0.5 rounded-md font-mono uppercase tracking-wider text-[10px]"
style={{ color: meta.color, border: `1px solid ${meta.color}50` }}
>
<Icon className="w-3 h-3" />
{meta.label} ({n})
</span>
<button
type="button"
onClick={() => applyToBucket(b, "replace")}
className="text-[var(--color-fg-dim)] hover:text-[var(--color-mint)] underline-offset-2 hover:underline"
>
replace all
</button>
<span className="text-[var(--color-fg-muted)]">/</span>
<button
type="button"
onClick={() => applyToBucket(b, "skip")}
className="text-[var(--color-fg-dim)] hover:text-[var(--color-coral)] underline-offset-2 hover:underline"
>
skip all
</button>
</div>
);
})}
<div className="flex items-center gap-2 ml-auto">
<button
type="button"
onClick={() => applyToBucket("all", "replace")}
className="text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
>
Replace all
</button>
<span className="text-[var(--color-fg-muted)]">·</span>
<button
type="button"
onClick={() => applyToBucket("all", "skip")}
className="text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
>
Skip all
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
{items.map((it) => {
const meta = BUCKET_META[it.bucket];
const Icon = meta.icon;
const decision = decisions[it.jobId];
const incomingBigger = it.incomingWidth * it.incomingHeight > it.existingWidth * it.existingHeight;
return (
<div
key={it.jobId}
className="rounded-lg border border-[var(--color-glass-border)] bg-[var(--color-bg-1)]/40 px-3 py-2"
>
<div className="flex items-center gap-2 mb-1.5">
<span
className="flex items-center gap-1 px-1.5 py-0.5 rounded font-mono uppercase tracking-wider text-[9px]"
style={{ color: meta.color, border: `1px solid ${meta.color}50` }}
>
<Icon className="w-2.5 h-2.5" />
{meta.label}
</span>
{it.code && (
<span className="text-xs font-mono font-bold text-[var(--color-cyan)]">{it.code}</span>
)}
<div className="ml-auto flex items-center gap-1">
<button
type="button"
onClick={() => setDecisions((s) => ({ ...s, [it.jobId]: "replace" }))}
className={cn(
"flex items-center gap-1 px-2 py-1 rounded-md text-xs",
decision === "replace"
? "bg-[var(--color-mint)]/20 text-[var(--color-mint)] ring-1 ring-[var(--color-mint)]/50"
: "text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<Replace className="w-3 h-3" />
Replace
</button>
<button
type="button"
onClick={() => setDecisions((s) => ({ ...s, [it.jobId]: "skip" }))}
className={cn(
"flex items-center gap-1 px-2 py-1 rounded-md text-xs",
decision === "skip"
? "bg-[var(--color-coral)]/20 text-[var(--color-coral)] ring-1 ring-[var(--color-coral)]/50"
: "text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<SkipForward className="w-3 h-3" />
Skip
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className={cn("rounded-md p-2", !incomingBigger && "bg-[var(--color-mint)]/5")}>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-1">Existing #{it.existingId}</div>
<div className="font-mono truncate text-[var(--color-fg-dim)]" title={it.existingFilename}>{it.existingFilename}</div>
<div className="text-[var(--color-fg-muted)] tabular-nums mt-0.5">
{it.existingWidth}×{it.existingHeight} · {fmtBytes(it.existingBytes)}
</div>
</div>
<div className={cn("rounded-md p-2", incomingBigger && "bg-[var(--color-mint)]/5")}>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-1">Incoming</div>
<div className="font-mono truncate text-[var(--color-fg-dim)]" title={it.filename}>{it.filename}</div>
<div className="text-[var(--color-fg-muted)] tabular-nums mt-0.5">
{it.incomingWidth}×{it.incomingHeight} · {fmtBytes(it.incomingBytes)}
</div>
</div>
</div>
</div>
);
})}
</div>
<div className="px-5 py-3 border-t border-[var(--color-glass-border)] flex items-center justify-between">
<div className="text-xs text-[var(--color-fg-muted)]">
<span className="text-[var(--color-mint)]">{replaceCount} replace</span>
{" · "}
<span className="text-[var(--color-coral)]">{skipCount} skip</span>
{" · "}
replacements move the old file to <span className="font-mono">library/.superseded/</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onCancel}
className="text-sm px-3 py-1.5 rounded-lg text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Cancel
</button>
<button
type="button"
onClick={() => onConfirm(decisions)}
className="text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/20 text-[var(--color-cyan)] ring-1 ring-[var(--color-cyan)]/50 hover:bg-[var(--color-cyan)]/30"
>
Apply
</button>
</div>
</div>
</div>
</div>,
document.body,
);
}
+585
View File
@@ -0,0 +1,585 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useDropzone } from "react-dropzone";
import { useRouter } from "next/navigation";
import { UploadCloud, Loader2, CheckCircle2, AlertCircle, Copy, X, Check, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { parseDroppedFilename } from "@/lib/jav/dropParser";
import { lookupActressesByNames, type ActressLookupResult } from "@/app/actions/actressLookup";
import { IngestPreviewDialog, type ActressDecisions, type IngestPreviewFile } from "./IngestPreviewDialog";
import { CollisionReviewDialog, type CollisionReviewItem, type CollisionDecision } from "./CollisionReviewDialog";
interface FailedFile {
filename: string;
reason: string;
}
interface DuplicateFile {
filename: string;
code: string | null;
/** Metadata the client sent up alongside this re-upload — these are
* the merges that the dedup branch in ingestFile applies to the
* existing row (actress links via INSERT OR IGNORE, autoAssign tag,
* autoAssign collection). Surfaced in the summary so the user can
* tell whether anything actually changed for a "duplicate". */
mergedActresses: string[];
mergedTag: string | null;
mergedCollectionId: number | null;
}
interface UploadState {
total: number;
done: number;
duplicates: DuplicateFile[];
failed: FailedFile[];
finished: boolean;
}
interface PendingJob {
image: File;
nfo?: File;
parsed: ReturnType<typeof parseDroppedFilename>;
/** Stable id used to correlate jobs with collision-review decisions on
* re-submit. Composed from filename + size to survive React re-renders. */
jobId: string;
}
export function DropZone({
compact = false,
autoAssign,
}: {
compact?: boolean;
autoAssign?: { tagName?: string; collectionId?: number };
}) {
const router = useRouter();
const [state, setState] = useState<UploadState | null>(null);
const [copied, setCopied] = useState(false);
const [preview, setPreview] = useState<{
jobs: PendingJob[];
files: IngestPreviewFile[];
lookup: ActressLookupResult[];
} | null>(null);
const [collisionReview, setCollisionReview] = useState<{
items: CollisionReviewItem[];
jobsById: Record<string, PendingJob>;
actressDecisions?: ActressDecisions;
linkedActressNames?: Record<string, string>;
} | null>(null);
const runJobs = useCallback(async (
jobs: PendingJob[],
actressDecisions?: ActressDecisions,
linkedActressNames?: Record<string, string>,
collisionDecisions?: Record<string, CollisionDecision>,
) => {
let done = 0;
const duplicates: DuplicateFile[] = [];
const failed: FailedFile[] = [];
const collisions: CollisionReviewItem[] = [];
setState({ total: jobs.length, done: 0, duplicates: [], failed: [], finished: false });
const CONCURRENCY = 6;
const acceptedActressesFor = (job: PendingJob): string[] => {
if (!actressDecisions || job.parsed.actresses.length === 0) return [];
const perFile = actressDecisions[job.parsed.original] ?? {};
return job.parsed.actresses.flatMap((n) => {
const d = perFile[n.toLowerCase()];
if (d === "link") return [linkedActressNames?.[n.toLowerCase()] ?? n];
if (d === "create") return [n];
return [];
});
};
const buildBody = (job: PendingJob, accepted: string[]): FormData => {
const fd = new FormData();
fd.append("file", job.image);
if (job.nfo) fd.append("nfo", job.nfo);
if (autoAssign?.tagName) fd.append("autoTag", autoAssign.tagName);
if (autoAssign?.collectionId != null) fd.append("autoCollection", String(autoAssign.collectionId));
if (job.parsed.targetFilename !== job.image.name) {
fd.append("targetFilename", job.parsed.targetFilename);
}
if (accepted.length > 0) fd.append("actressNames", JSON.stringify(accepted));
const collisionDecision = collisionDecisions?.[job.jobId];
if (collisionDecision) fd.append("onCollision", collisionDecision);
return fd;
};
const runOne = async (job: PendingJob): Promise<void> => {
const accepted = acceptedActressesFor(job);
try {
const res = await fetch("/api/upload", { method: "POST", body: buildBody(job, accepted) });
if (!res.ok) {
let reason = `HTTP ${res.status}`;
try { const j = await res.json(); if (j?.error) reason = String(j.error); } catch {}
failed.push({ filename: job.image.name, reason });
} else {
const data = await res.json();
if (data.collision) {
collisions.push({
jobId: job.jobId,
filename: job.image.name,
code: data.code ?? null,
...data.collision,
});
} else if (data.duplicate) {
duplicates.push({
filename: job.image.name,
code: data.code ?? null,
mergedActresses: accepted,
mergedTag: autoAssign?.tagName ?? null,
mergedCollectionId: autoAssign?.collectionId ?? null,
});
}
}
} catch (e) {
failed.push({ filename: job.image.name, reason: (e as Error).message || "network error" });
}
done++;
setState({ total: jobs.length, done, duplicates: duplicates.slice(), failed: failed.slice(), finished: false });
};
let cursor = 0;
const workers = Array.from({ length: Math.min(CONCURRENCY, jobs.length) }, async () => {
while (true) {
const idx = cursor++;
if (idx >= jobs.length) return;
await runOne(jobs[idx]);
}
});
await Promise.all(workers);
if (collisions.length > 0 && !collisionDecisions) {
// First pass surfaced code collisions — pause and let the user
// decide. Decisions are funnelled back through runJobs with the
// colliding subset.
const jobsById: Record<string, PendingJob> = {};
for (const j of jobs) jobsById[j.jobId] = j;
setCollisionReview({ items: collisions, jobsById, actressDecisions, linkedActressNames });
// Mark the run finished for the non-colliding jobs; the resolution
// pass will produce a fresh state when it begins.
setState({ total: jobs.length, done, duplicates, failed, finished: true });
return;
}
setState({ total: jobs.length, done, duplicates, failed, finished: true });
router.refresh();
}, [autoAssign, router]);
const onDrop = useCallback(async (accepted: File[]) => {
if (accepted.length === 0) return;
const byBase = new Map<string, { image?: File; nfo?: File }>();
for (const f of accepted) {
const base = f.name.replace(/\.[^.]+$/, "").toLowerCase();
const slot = byBase.get(base) ?? {};
const ext = f.name.toLowerCase().match(/\.([^.]+)$/)?.[1];
if (ext === "nfo" || ext === "xml") slot.nfo = f;
else slot.image = f;
byBase.set(base, slot);
}
const jobs: PendingJob[] = Array.from(byBase.values())
.filter((j) => j.image)
.map((j) => ({
image: j.image!,
nfo: j.nfo,
parsed: parseDroppedFilename(j.image!.name),
jobId: `${j.image!.name}::${j.image!.size}`,
}));
if (jobs.length === 0) return;
const filesWithActresses = jobs.filter((j) => j.parsed.actresses.length > 0);
if (filesWithActresses.length === 0) {
await runJobs(jobs);
return;
}
const uniqueNames = Array.from(
new Set(filesWithActresses.flatMap((j) => j.parsed.actresses.map((s) => s.toLowerCase()))),
);
const displayNames = uniqueNames.map((lc) => {
const found = filesWithActresses.flatMap((j) => j.parsed.actresses).find((n) => n.toLowerCase() === lc);
return found ?? lc;
});
const lookup = await lookupActressesByNames(displayNames);
setPreview({
jobs,
files: jobs.map((j) => ({
original: j.parsed.original,
targetFilename: j.parsed.targetFilename,
code: j.parsed.code,
actresses: j.parsed.actresses,
})),
lookup,
});
}, [runJobs]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"image/png": [".png"],
"image/jpeg": [".jpg", ".jpeg"],
"image/webp": [".webp"],
"application/xml": [".nfo", ".xml"],
"text/xml": [".nfo", ".xml"],
},
});
function exportFailures() {
if (!state || state.failed.length === 0) return;
const lines = ["filename\treason", ...state.failed.map((f) => `${f.filename}\t${f.reason.replace(/\t/g, " ")}`)];
const blob = new Blob([lines.join("\n") + "\n"], { type: "text/tab-separated-values;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
a.download = `pinkudex-failed-imports-${stamp}.tsv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function copyFailures() {
if (!state || state.failed.length === 0) return;
const text = state.failed.map((f) => `${f.filename}\t${f.reason}`).join("\n");
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Fallback: select-all in a textarea
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
}
const inProgress = state && !state.finished;
// "added" = brand new rows in the DB. Duplicates are excluded because
// they hit an existing row by SHA — no insert happened.
const dupCount = state?.duplicates.length ?? 0;
const addedCount = state ? state.done - state.failed.length - dupCount : 0;
const progressPct = state && state.total > 0 ? Math.round((state.done / state.total) * 100) : 0;
const [showDuplicates, setShowDuplicates] = useState(true);
return (
<>
<div
{...getRootProps()}
className={cn(
"relative cursor-pointer rounded-2xl border-2 border-dashed transition-all overflow-hidden group",
compact ? "px-4 py-3 flex items-center gap-3" : "px-12 py-7 flex flex-col items-center gap-3 text-center",
isDragActive
? "border-[var(--color-cyan)] bg-[color-mix(in_oklch,var(--color-cyan)_8%,transparent)] shadow-[var(--shadow-glow-cyan)]"
: "border-[var(--color-glass-border-strong)] hover:border-[var(--color-cyan)] hover:bg-[var(--color-glass)]",
)}
>
<input {...getInputProps()} />
{state ? (
<>
{inProgress ? (
<Loader2 className={cn("text-[var(--color-cyan)] animate-spin", compact ? "w-5 h-5" : "w-8 h-8")} />
) : state.failed.length > 0 ? (
<AlertCircle className={cn("text-[var(--color-coral)]", compact ? "w-5 h-5" : "w-8 h-8")} />
) : (
<CheckCircle2 className={cn("text-[var(--color-mint)]", compact ? "w-5 h-5" : "w-8 h-8")} />
)}
<div className={compact ? "text-sm" : ""}>
<div className="font-medium">{state.done} / {state.total} processed</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5 flex items-center gap-2 justify-center flex-wrap">
<span className="text-[var(--color-mint)]">{addedCount} added</span>
{dupCount > 0 && <span>· {dupCount} already in library</span>}
{state.failed.length > 0 && <span className="text-[var(--color-coral)]">· {state.failed.length} failed</span>}
</div>
</div>
</>
) : (
<>
<UploadCloud className={cn("text-[var(--color-fg-dim)] group-hover:text-[var(--color-cyan)] transition-colors", compact ? "w-5 h-5" : "w-10 h-10")} />
<div>
<div className={cn("font-medium", compact ? "text-sm" : "text-base")}>
{isDragActive ? "Drop To Import" : "Drop Covers Here"}
</div>
{!compact && (
<div className="text-sm text-[var(--color-fg-muted)] mt-1">
JPG, PNG, WEBP drop a matching <span className="font-mono">.nfo</span> alongside to auto-fill metadata.
</div>
)}
</div>
</>
)}
{state && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-glass-border)]">
<div
className="h-full bg-[var(--color-cyan)] transition-all"
style={{ width: `${progressPct}%` }}
/>
</div>
)}
</div>
{state && state.finished && (
<ImportSummaryModal
addedCount={addedCount}
duplicates={state.duplicates}
failed={state.failed}
showDuplicates={showDuplicates}
onToggleDuplicates={() => setShowDuplicates((s) => !s)}
copied={copied}
onCopyFailures={copyFailures}
onExportFailures={exportFailures}
onClose={() => setState(null)}
/>
)}
{preview && (
<IngestPreviewDialog
files={preview.files}
lookup={preview.lookup}
onCancel={() => setPreview(null)}
onSkipActresses={() => {
const jobs = preview.jobs;
setPreview(null);
void runJobs(jobs);
}}
onConfirm={(decisions) => {
const jobs = preview.jobs;
const linkedNames = Object.fromEntries(
preview.lookup
.filter((r) => r.match)
.map((r) => [r.name.toLowerCase(), r.match!.name]),
);
setPreview(null);
void runJobs(jobs, decisions, linkedNames);
}}
/>
)}
{collisionReview && (
<CollisionReviewDialog
items={collisionReview.items}
onCancel={() => setCollisionReview(null)}
onConfirm={(decisions) => {
const cr = collisionReview;
setCollisionReview(null);
const replays = cr.items.map((it) => cr.jobsById[it.jobId]).filter((j): j is PendingJob => !!j);
void runJobs(replays, cr.actressDecisions, cr.linkedActressNames, decisions);
}}
/>
)}
</>
);
}
function ImportSummaryModal({
addedCount,
duplicates,
failed,
showDuplicates,
onToggleDuplicates,
copied,
onCopyFailures,
onExportFailures,
onClose,
}: {
addedCount: number;
duplicates: DuplicateFile[];
failed: FailedFile[];
showDuplicates: boolean;
onToggleDuplicates: () => void;
copied: boolean;
onCopyFailures: () => void;
onExportFailures: () => void;
onClose: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
if (typeof document === "undefined") return null;
const dupCount = duplicates.length;
const dupsWithMerges = duplicates.filter((d) =>
d.mergedActresses.length > 0 || d.mergedTag != null || d.mergedCollectionId != null,
);
const dupsUntouched = dupCount - dupsWithMerges.length;
const tone = failed.length > 0 ? "error" : "ok";
const total = addedCount + dupCount + failed.length;
// One-line plain-English narrative explaining what just happened. Helps
// when the count summary alone is ambiguous (e.g. "1 imported · 1 dup"
// could either mean a fresh insert plus a separate duplicate, or a
// single file that was deduped).
const narrative = (() => {
const parts: string[] = [];
if (addedCount > 0) parts.push(`Added ${addedCount} new cover${addedCount === 1 ? "" : "s"} to the library.`);
if (dupCount > 0) {
if (dupsWithMerges.length > 0 && dupsUntouched > 0) {
parts.push(`${dupCount} file${dupCount === 1 ? "" : "s"} matched existing covers — no new rows were inserted; ${dupsWithMerges.length} had fresh metadata merged into the existing entry.`);
} else if (dupsWithMerges.length > 0) {
parts.push(`${dupCount} file${dupCount === 1 ? " was" : "s were"} already in the library — no new rows were inserted, but the new metadata you picked was merged into the existing entr${dupCount === 1 ? "y" : "ies"}.`);
} else {
parts.push(`${dupCount} file${dupCount === 1 ? " was" : "s were"} already in the library — no new rows were inserted and there was no extra metadata to merge.`);
}
}
if (failed.length > 0) parts.push(`${failed.length} file${failed.length === 1 ? "" : "s"} failed.`);
if (parts.length === 0) parts.push(`Nothing was processed.`);
return parts.join(" ");
})();
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="relative w-[min(95vw,720px)] max-h-[85vh] flex flex-col rounded-2xl border border-[var(--color-glass-border-strong)] bg-[var(--color-bg-0)] shadow-2xl">
<div className="px-5 py-4 border-b border-[var(--color-glass-border)] flex items-start justify-between gap-3">
<div className="flex items-start gap-3 min-w-0">
{tone === "ok" ? (
<CheckCircle2 className="w-6 h-6 text-[var(--color-mint)] shrink-0 mt-0.5" />
) : (
<AlertCircle className="w-6 h-6 text-[var(--color-coral)] shrink-0 mt-0.5" />
)}
<div className="min-w-0">
<div className="text-base font-medium">Import complete · {total} file{total === 1 ? "" : "s"}</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5 flex items-center gap-2 flex-wrap">
<span className="text-[var(--color-mint)] font-medium">{addedCount} added</span>
{dupCount > 0 && (
<button
type="button"
onClick={onToggleDuplicates}
className="hover:text-[var(--color-fg)] underline-offset-2 hover:underline"
title="Toggle duplicate list"
>
· {dupCount} already in library
</button>
)}
{failed.length > 0 && (
<span className="text-[var(--color-coral)] font-medium">· {failed.length} failed</span>
)}
</div>
<p className="text-xs text-[var(--color-fg-dim)] mt-2 leading-relaxed">{narrative}</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] shrink-0"
title="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
{(failed.length > 0 || (showDuplicates && dupCount > 0)) && (
<div className="overflow-y-auto px-5 py-3 space-y-3">
{failed.length > 0 && (
<section>
<div className="flex items-center justify-between mb-2">
<div className="text-[11px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
Failed ({failed.length})
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onCopyFailures}
className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md border border-[var(--color-glass-border)] hover:bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
title="Copy failed filenames + reasons to clipboard"
>
{copied ? <Check className="w-3.5 h-3.5 text-[var(--color-mint)]" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? "Copied" : "Copy"}
</button>
<button
type="button"
onClick={onExportFailures}
className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md border border-[var(--color-glass-border)] hover:bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
title="Download failed list as a .tsv file"
>
<Download className="w-3.5 h-3.5" />
Export
</button>
</div>
</div>
<div className="max-h-60 overflow-y-auto text-xs font-mono space-y-0.5 rounded-md border border-[var(--color-glass-border)] p-2">
{failed.map((f) => (
<div key={f.filename} className="flex items-baseline gap-2 text-[var(--color-fg-dim)]">
<AlertCircle className="w-3 h-3 text-[var(--color-coral)] shrink-0 translate-y-0.5" />
<span className="truncate text-[var(--color-fg)]">{f.filename}</span>
<span className="text-[var(--color-fg-muted)] truncate"> {f.reason}</span>
</div>
))}
</div>
</section>
)}
{showDuplicates && dupCount > 0 && (
<section>
<div className="text-[11px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-2">
Already in library ({dupCount})
</div>
<div className="max-h-72 overflow-y-auto text-xs space-y-1 rounded-md border border-[var(--color-glass-border)] p-2">
{duplicates.map((d) => {
const merges: string[] = [];
if (d.mergedActresses.length > 0) {
merges.push(`linked actress${d.mergedActresses.length === 1 ? "" : "es"}: ${d.mergedActresses.join(", ")}`);
}
if (d.mergedTag) merges.push(`tag: ${d.mergedTag}`);
if (d.mergedCollectionId != null) merges.push(`collection #${d.mergedCollectionId}`);
return (
<div key={d.filename} className="flex items-start gap-2 font-mono text-[var(--color-fg-dim)]">
<span className="text-[10px] uppercase tracking-wider text-[var(--color-cyan)] shrink-0 w-20 truncate translate-y-0.5" title={d.code ?? "no code"}>
{d.code ?? "—"}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[var(--color-fg)]">{d.filename}</div>
{merges.length > 0 ? (
<div className="text-[var(--color-mint)]/80 text-[11px] mt-0.5">
+ {merges.join(" · ")}
</div>
) : (
<div className="text-[var(--color-fg-muted)] text-[11px] mt-0.5 italic">
no new metadata to merge
</div>
)}
</div>
</div>
);
})}
</div>
<p className="text-[11px] text-[var(--color-fg-muted)] mt-2 leading-relaxed">
These files were already imported earlier (matched by SHA-256 of the bytes). The cover row was kept as-is; any new actress / tag / collection picks shown above were merged into the existing entry via <span className="font-mono">INSERT OR IGNORE</span>, so previous links are untouched.
</p>
</section>
)}
</div>
)}
<div className="px-5 py-3 border-t border-[var(--color-glass-border)] flex items-center justify-end">
<button
type="button"
onClick={onClose}
className="text-sm px-4 py-1.5 rounded-lg bg-[var(--color-cyan)]/20 text-[var(--color-cyan)] ring-1 ring-[var(--color-cyan)]/50 hover:bg-[var(--color-cyan)]/30"
>
Close
</button>
</div>
</div>
</div>,
document.body,
);
}
+419
View File
@@ -0,0 +1,419 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { X, UserPlus, Link2, MinusCircle, AlertTriangle, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ActressLookupResult } from "@/app/actions/actressLookup";
export type ActressDecision = "link" | "create" | "skip";
export interface IngestPreviewFile {
original: string;
targetFilename: string;
code: string | null;
actresses: string[];
}
/** Per-file → per-actress decisions. Keyed by `file.original`, then by
* `actressName.toLowerCase()`. The two-level shape keeps the same actress
* appearing on multiple covers independent — skipping Mitsuki on one cover
* must not silently skip her on every other cover sharing the name. */
export type ActressDecisions = Record<string, Record<string, ActressDecision>>;
export interface IngestPreviewProps {
files: IngestPreviewFile[];
lookup: ActressLookupResult[];
onCancel: () => void;
onSkipActresses: () => void;
onConfirm: (decisions: ActressDecisions) => void;
}
const INITIAL_RENDER = 200;
const RENDER_INCREMENT = 200;
type StatusFilter = "all" | "ok" | "errors";
export function IngestPreviewDialog({ files, lookup, onCancel, onSkipActresses, onConfirm }: IngestPreviewProps) {
const [renderLimit, setRenderLimit] = useState(INITIAL_RENDER);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const lookupMap = useMemo(() => {
const m = new Map<string, ActressLookupResult>();
for (const r of lookup) m.set(r.name.toLowerCase(), r);
return m;
}, [lookup]);
// A row is in "errors" when there's something the user should look at
// before clicking Confirm. Today: missing code only — a file with no
// canonical code imports successfully but has no stable URL handle. The
// "new" pill on actresses is a state, not an error, and including it
// would make every fresh-actress batch flash red.
const isError = (f: IngestPreviewFile) => !f.code;
const errorCount = useMemo(() => files.filter(isError).length, [files]);
const okCount = files.length - errorCount;
const filtered = useMemo(() => {
if (statusFilter === "ok") return files.filter((f) => !isError(f));
if (statusFilter === "errors") return files.filter(isError);
return files;
}, [files, statusFilter]);
const [decisions, setDecisions] = useState<ActressDecisions>(() => {
const init: ActressDecisions = {};
for (const f of files) {
if (f.actresses.length === 0) continue;
const perFile: Record<string, ActressDecision> = {};
for (const name of f.actresses) {
const key = name.toLowerCase();
const lr = lookupMap.get(key);
perFile[key] = lr?.match ? "link" : "create";
}
init[f.original] = perFile;
}
return init;
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
useEffect(() => { setRenderLimit(INITIAL_RENDER); }, [statusFilter]);
function setDecision(fileOriginal: string, nameKey: string, d: ActressDecision) {
setDecisions((s) => ({
...s,
[fileOriginal]: { ...(s[fileOriginal] ?? {}), [nameKey]: d },
}));
}
// Total and per-bucket counts are computed across (file, actress) pairs,
// so the same actress on N covers contributes N rows to the bulk state.
const counts = useMemo(() => {
let matched = 0, unmatched = 0;
for (const f of files) {
for (const name of f.actresses) {
const lr = lookupMap.get(name.toLowerCase());
if (lr?.match) matched++; else unmatched++;
}
}
return { matched, unmatched, total: matched + unmatched };
}, [files, lookupMap]);
const bulkState = useMemo(() => {
let matchedAllLink = counts.matched > 0;
let unmatchedAllCreate = counts.unmatched > 0;
let allSkip = counts.total > 0;
for (const f of files) {
const perFile = decisions[f.original] ?? {};
for (const name of f.actresses) {
const key = name.toLowerCase();
const lr = lookupMap.get(key);
const d = perFile[key];
if (lr?.match) {
if (d !== "link") matchedAllLink = false;
} else {
if (d !== "create") unmatchedAllCreate = false;
}
if (d !== "skip") allSkip = false;
}
}
return { matchedAllLink, unmatchedAllCreate, allSkip };
}, [files, decisions, lookupMap, counts]);
function bulkApply(target: "matched" | "unmatched" | "all", decision: ActressDecision) {
setDecisions((s) => {
const next: ActressDecisions = { ...s };
for (const f of files) {
if (f.actresses.length === 0) continue;
const perFile: Record<string, ActressDecision> = { ...(next[f.original] ?? {}) };
let touched = false;
for (const name of f.actresses) {
const key = name.toLowerCase();
const isMatched = !!lookupMap.get(key)?.match;
if (target !== "all" && target === "matched" && !isMatched) continue;
if (target !== "all" && target === "unmatched" && isMatched) continue;
if (decision === "link" && !isMatched) continue;
if (decision === "create" && isMatched) continue;
perFile[key] = decision;
touched = true;
}
if (touched) next[f.original] = perFile;
}
return next;
});
}
if (typeof document === "undefined") return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center px-4 py-6 bg-black/50 fade-in"
onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div className="bg-[var(--color-bg-0)] border border-[var(--color-glass-border)] shadow-2xl rounded-2xl w-[min(720px,calc(100vw-32px))] max-h-[calc(100vh-48px)] flex flex-col">
<div className="flex items-center justify-between p-5 border-b border-[var(--color-glass-border)]">
<div>
<div className="text-base font-medium">Confirm Import</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
{files.length} file{files.length === 1 ? "" : "s"} · review actress assignments below
</div>
</div>
<button onClick={onCancel} className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]">
<X className="w-5 h-5" />
</button>
</div>
{files.length > INITIAL_RENDER && (
<div className="px-5 py-2 border-b border-[var(--color-glass-border)] text-[11px] text-[var(--color-fg-muted)] bg-[var(--color-cyan)]/5">
Large batch first {INITIAL_RENDER} of {files.length} files shown. Bulk actions still apply to <strong className="text-[var(--color-fg)]">all {files.length}</strong>.
</div>
)}
<div className="px-5 py-3 border-b border-[var(--color-glass-border)] flex items-center gap-1.5 text-xs">
<span className="font-mono text-[var(--color-fg-muted)] mr-1">Filter:</span>
<FilterChip active={statusFilter === "all"} onClick={() => setStatusFilter("all")}>
All {files.length}
</FilterChip>
<FilterChip
active={statusFilter === "ok"}
onClick={() => setStatusFilter("ok")}
kind="ok"
disabled={okCount === 0}
>
OK {okCount}
</FilterChip>
<FilterChip
active={statusFilter === "errors"}
onClick={() => setStatusFilter("errors")}
kind="error"
disabled={errorCount === 0}
>
Errors {errorCount}
</FilterChip>
</div>
{counts.total > 0 && (
<div className="px-5 py-3 border-b border-[var(--color-glass-border)] flex flex-wrap items-center gap-2 text-xs">
<span className="font-mono text-[var(--color-fg-muted)] mr-1">Bulk:</span>
{counts.matched > 0 && (
<BulkButton active={bulkState.matchedAllLink} onClick={() => bulkApply("matched", "link")}>
Link all matched ({counts.matched})
</BulkButton>
)}
{counts.unmatched > 0 && (
<BulkButton active={bulkState.unmatchedAllCreate} onClick={() => bulkApply("unmatched", "create")}>
Create all new ({counts.unmatched})
</BulkButton>
)}
<BulkButton active={bulkState.allSkip} onClick={() => bulkApply("all", "skip")}>
Skip all
</BulkButton>
</div>
)}
<div className="overflow-y-auto p-5 space-y-4">
{filtered.length === 0 && (
<div className="text-center py-8 text-sm text-[var(--color-fg-muted)] italic">
No files match this filter.
</div>
)}
{filtered.slice(0, renderLimit).map((f) => (
<div
key={f.original}
className="rounded-xl border border-[var(--color-glass-border)] bg-[var(--color-bg-1)]/30 p-3"
style={{ contentVisibility: "auto", containIntrinsicSize: "0 120px" } as React.CSSProperties}
>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="min-w-0">
<div className="text-sm font-medium font-mono truncate">{f.targetFilename}</div>
<div className="text-[11px] text-[var(--color-fg-muted)] truncate">from {f.original}</div>
</div>
{f.code ? (
<span className="shrink-0 text-[10px] uppercase tracking-wider font-mono px-2 py-0.5 rounded-full bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/30">
{f.code}
</span>
) : (
<span className="shrink-0 text-[10px] uppercase tracking-wider font-mono px-2 py-0.5 rounded-full bg-[var(--color-coral)]/15 text-[var(--color-coral)] border border-[var(--color-coral)]/30 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" /> No code
</span>
)}
</div>
{f.actresses.length > 0 ? (
<div className="space-y-1.5">
{f.actresses.map((name) => {
const key = name.toLowerCase();
const lr = lookupMap.get(key);
const decision = decisions[f.original]?.[key] ?? "skip";
return (
<div key={key} className="flex items-center gap-2 text-sm">
<div className="flex-1 min-w-0 truncate">{name}</div>
{lr?.match ? (
<Pill kind="match">match: {lr.match.name}</Pill>
) : (
<Pill kind="new">new</Pill>
)}
<DecisionButtons
available={lr?.match ? ["link", "skip"] : ["create", "skip"]}
value={decision}
onChange={(d) => setDecision(f.original, key, d)}
/>
</div>
);
})}
</div>
) : (
<div className="text-xs text-[var(--color-fg-muted)] italic">No actresses parsed.</div>
)}
</div>
))}
{filtered.length > renderLimit && (
<button
type="button"
onClick={() => setRenderLimit((n) => n + RENDER_INCREMENT)}
className="w-full text-xs px-3 py-2 rounded-lg border border-dashed border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Showing {renderLimit} of {filtered.length} files load {Math.min(RENDER_INCREMENT, filtered.length - renderLimit)} more
</button>
)}
</div>
<div className="flex items-center gap-2 p-4 border-t border-[var(--color-glass-border)]">
<button
onClick={onCancel}
className="text-sm px-3 py-2 rounded-lg glass glass-hover"
>
Cancel import
</button>
<button
onClick={onSkipActresses}
className="text-sm px-3 py-2 rounded-lg glass glass-hover"
title="Import the files but don't attach any actresses"
>
Skip actress assignment
</button>
<div className="flex-1" />
<button
onClick={() => onConfirm(decisions)}
className="text-sm px-4 py-2 rounded-lg bg-[var(--color-cyan)] text-black font-medium"
>
Confirm import
</button>
</div>
</div>
</div>,
document.body,
);
}
function FilterChip({
active,
onClick,
disabled,
kind,
children,
}: {
active: boolean;
onClick: () => void;
disabled?: boolean;
kind?: "ok" | "error";
children: React.ReactNode;
}) {
const accent =
kind === "ok"
? "var(--color-mint)"
: kind === "error"
? "var(--color-coral)"
: "var(--color-cyan)";
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"px-2.5 py-1 rounded-md text-xs border transition-colors disabled:opacity-40 disabled:cursor-not-allowed",
active
? "text-black border-transparent font-medium"
: "border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
style={active && !disabled ? { background: accent } : undefined}
>
{children}
</button>
);
}
function BulkButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded-md border transition-colors",
active
? "bg-[var(--color-cyan)] text-black border-transparent font-medium"
: "border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<Check className={cn("w-3 h-3", active ? "visible" : "invisible")} />
{children}
</button>
);
}
function Pill({ kind, children }: { kind: "match" | "new"; children: React.ReactNode }) {
const cls =
kind === "match"
? "bg-[var(--color-mint)]/15 text-[var(--color-mint)] border-[var(--color-mint)]/30"
: "bg-[var(--color-violet)]/15 text-[var(--color-violet)] border-[var(--color-violet)]/30";
return (
<span className={cn("text-[10px] uppercase tracking-wider font-mono px-2 py-0.5 rounded-full border", cls)}>
{children}
</span>
);
}
function DecisionButtons({
available,
value,
onChange,
}: {
available: ActressDecision[];
value: ActressDecision;
onChange: (d: ActressDecision) => void;
}) {
const opts: Array<{ key: ActressDecision; label: string; Icon: React.ComponentType<{ className?: string }> }> = [
{ key: "link", label: "Link", Icon: Link2 },
{ key: "create", label: "Create", Icon: UserPlus },
{ key: "skip", label: "Skip", Icon: MinusCircle },
];
return (
<div className="flex items-center gap-1">
{opts
.filter((o) => available.includes(o.key))
.map(({ key, label, Icon }) => (
<button
key={key}
type="button"
onClick={() => onChange(key)}
className={cn(
"flex items-center gap-1 text-[11px] font-mono px-2 py-1 rounded-md border transition-colors",
value === key
? "bg-[var(--color-cyan)] text-black border-transparent"
: "border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<Icon className="w-3 h-3" />
{label}
</button>
))}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { DropZone } from "./DropZone";
export interface AutoAssign {
tagName?: string;
collectionId?: number;
}
export function UploadCard({ autoAssign }: { autoAssign?: AutoAssign } = {}) {
return <DropZone autoAssign={autoAssign} />;
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { ListVideo } from "lucide-react";
import { useWatchQueue } from "./WatchQueueProvider";
import { useQueuePanel } from "./QueuePanelProvider";
import { cn } from "@/lib/utils";
export function QueueIndicator() {
const q = useWatchQueue();
const { open, toggle } = useQueuePanel();
const count = q.ids.length;
return (
<button
type="button"
onClick={toggle}
aria-label="Watch queue"
aria-pressed={open}
title={count > 0 ? `Watch queue · ${count} cover${count === 1 ? "" : "s"}` : "Watch queue"}
className={cn(
"relative w-9 h-9 grid place-items-center rounded-lg border transition-colors shrink-0",
open
? "bg-[var(--color-cyan)]/15 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
: count > 0
? "bg-[var(--color-cyan)]/15 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
: "border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<ListVideo className="w-4 h-4" />
{count > 0 && (
<span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--color-cyan)] text-black text-[10px] font-mono font-bold grid place-items-center">
{count > 99 ? "99+" : count}
</span>
)}
</button>
);
}
+192
View File
@@ -0,0 +1,192 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ListVideo, Eye, Trash2, X, Loader2 } from "lucide-react";
import { useQueuePanel } from "./QueuePanelProvider";
import { useWatchQueue } from "./WatchQueueProvider";
import { fetchQueueCovers } from "@/app/actions/queue";
import { ImageCard, type CardImage } from "@/components/grid/ImageCard";
import { bulkSetWatched } from "@/app/actions/bulk";
import { useClickOutside } from "@/lib/hooks/useClickOutside";
/**
* Watch-queue panel. Mirrors `TrashPanel` and `SettingsPanel`: a centered
* modal capped at 1400×900, dimmed backdrop, click-outside / Escape to
* close. Triggered by the topnav indicator (which now toggles instead of
* navigating to /queue).
*/
export function QueuePanel() {
const { open, close } = useQueuePanel();
const queue = useWatchQueue();
const router = useRouter();
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, close, open);
const [covers, setCovers] = useState<CardImage[]>([]);
const [loading, setLoading] = useState(true);
const [pending, start] = useTransition();
// Re-fetch when the panel opens or the underlying queue changes. The
// fetch is keyed by the comma-joined id list so order changes also
// refresh the displayed grid.
useEffect(() => {
if (!open) return;
let live = true;
setLoading(true);
fetchQueueCovers(queue.ids).then((c) => {
if (!live) return;
setCovers(c);
setLoading(false);
});
return () => { live = false; };
}, [open, queue.ids]);
// Prune any queued ids that no longer resolve to a live cover.
useEffect(() => {
if (!open || loading) return;
const present = new Set(covers.map((c) => c.id));
const stale = queue.ids.filter((id) => !present.has(id));
if (stale.length > 0) queue.removeMany(stale);
}, [open, loading, covers, queue]);
// Lock background scroll + Escape to close while open.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, close]);
if (!open) return null;
function markAllWatched() {
if (covers.length === 0) return;
if (!confirm(`Mark all ${covers.length} covers as watched and clear the queue?`)) return;
const ids = covers.map((c) => c.id);
start(async () => {
await bulkSetWatched(ids, true);
queue.removeMany(ids);
router.refresh();
});
}
function clearQueue() {
if (queue.ids.length === 0) return;
if (!confirm("Clear the watch queue? Covers themselves are not affected.")) return;
queue.clear();
}
return (
<div
className="fixed inset-0 z-50 backdrop-blur-sm grid place-items-center p-4 sm:p-8"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 65%, transparent)" }}
>
<div
ref={ref}
className="w-full max-w-[1400px] h-[min(900px,calc(100vh-4rem))] flex flex-col rounded-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden shadow-2xl"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
<header className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-glass-border)] shrink-0">
<div>
<div className="flex items-center gap-2">
<ListVideo className="w-5 h-5 text-[var(--color-cyan)]" />
<h2 className="text-xl font-semibold tracking-tight">Watch Queue</h2>
</div>
<p className="text-xs text-[var(--color-fg-dim)] mt-1">
{queue.ids.length === 0
? "Empty — right-click any cover and choose “Add to queue”."
: `${covers.length} cover${covers.length === 1 ? "" : "s"} queued · marking watched removes them automatically`}
</p>
</div>
<div className="flex items-center gap-2">
{queue.ids.length > 0 && (
<>
<button
onClick={markAllWatched}
disabled={pending}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-mint)]/15 text-[var(--color-mint)] border border-[var(--color-mint)]/40 hover:bg-[var(--color-mint)]/25 disabled:opacity-50"
>
{pending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
Mark All Watched ({covers.length})
</button>
<button
onClick={clearQueue}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-coral)]"
>
<X className="w-4 h-4" />
Clear Queue
</button>
</>
)}
<button
onClick={close}
aria-label="Close watch queue"
className="w-8 h-8 grid place-items-center rounded-lg text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
<X className="w-4 h-4" />
</button>
</div>
</header>
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="glass rounded-2xl p-card text-center text-[var(--color-fg-dim)]">
<Loader2 className="w-6 h-6 mx-auto animate-spin mb-label" />
Loading queue
</div>
) : covers.length === 0 ? (
<div className="glass rounded-2xl p-card text-center max-w-md mx-auto">
<ListVideo className="w-8 h-8 mx-auto text-[var(--color-fg-dim)] mb-label" />
<p className="text-[var(--color-fg-dim)]">
{queue.ids.length > 0
? "Queue items couldn't be resolved (covers may have been deleted)."
: "Queue is empty."}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{covers.map((c) => (
<div key={c.id} className="relative group/queue">
<ImageCard image={c} />
<div className="absolute inset-0 z-20 rounded-2xl bg-black/80 opacity-0 group-hover/queue:opacity-100 transition-opacity grid place-items-center cursor-default">
<div className="grid grid-cols-2 rounded-full overflow-hidden shadow-2xl border border-white/10" style={{ width: "min(86%, 420px)" }}>
<button
type="button"
onClick={(e) => {
e.preventDefault(); e.stopPropagation();
start(async () => {
await bulkSetWatched([c.id], true);
queue.remove(c.id);
router.refresh();
});
}}
title="Mark as watched (removes from queue)"
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[var(--color-mint)]/90 hover:bg-[var(--color-mint)] text-black font-semibold text-sm cursor-pointer"
>
<Eye className="w-4 h-4" />
Mark As Watched
</button>
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); queue.remove(c.id); }}
title="Remove from queue"
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[var(--color-coral)]/90 hover:bg-[var(--color-coral)] text-black font-semibold text-sm cursor-pointer border-l border-black/20"
>
<Trash2 className="w-4 h-4" />
Remove From Queue
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
type Ctx = { open: boolean; toggle: () => void; close: () => void };
const C = createContext<Ctx | null>(null);
export function QueuePanelProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const toggle = useCallback(() => setOpen((o) => !o), []);
const close = useCallback(() => setOpen(false), []);
const value = useMemo<Ctx>(() => ({ open, toggle, close }), [open, toggle, close]);
return <C.Provider value={value}>{children}</C.Provider>;
}
export function useQueuePanel() {
const ctx = useContext(C);
if (!ctx) throw new Error("useQueuePanel must be used within QueuePanelProvider");
return ctx;
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ListVideo, Eye, Trash2, X, Loader2 } from "lucide-react";
import { useWatchQueue } from "./WatchQueueProvider";
import { fetchQueueCovers } from "@/app/actions/queue";
import { ImageCard, type CardImage } from "@/components/grid/ImageCard";
import { bulkSetWatched } from "@/app/actions/bulk";
export function QueueView() {
const router = useRouter();
const queue = useWatchQueue();
const [covers, setCovers] = useState<CardImage[]>([]);
const [loading, setLoading] = useState(true);
const [pending, start] = useTransition();
// Re-fetch when the queue id list changes. Cheap — single query keyed by IN(...).
useEffect(() => {
let live = true;
setLoading(true);
fetchQueueCovers(queue.ids).then((c) => {
if (!live) return;
setCovers(c);
setLoading(false);
});
return () => { live = false; };
}, [queue.ids]);
// If a server-side delete or watched flip means a queued id no longer
// resolves to a cover, prune it locally so the count stays honest.
useEffect(() => {
if (loading) return;
const present = new Set(covers.map((c) => c.id));
const stale = queue.ids.filter((id) => !present.has(id));
if (stale.length > 0) queue.removeMany(stale);
}, [loading, covers, queue]);
function markAllWatched() {
if (covers.length === 0) return;
if (!confirm(`Mark all ${covers.length} covers as watched and clear the queue?`)) return;
const ids = covers.map((c) => c.id);
start(async () => {
await bulkSetWatched(ids, true);
queue.removeMany(ids);
router.refresh();
});
}
function clearQueue() {
if (queue.ids.length === 0) return;
if (!confirm("Clear the watch queue? Covers themselves are not affected.")) return;
queue.clear();
}
return (
<>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight flex items-center gap-2">
<ListVideo className="w-7 h-7 text-[var(--color-cyan)]" />
Watch queue
</h1>
<p className="text-[var(--color-fg-dim)] mt-1">
{queue.ids.length === 0
? "Empty — right-click any cover and choose “Add to queue”."
: `${covers.length} cover${covers.length === 1 ? "" : "s"} queued. Marking watched removes them automatically.`}
</p>
</div>
{queue.ids.length > 0 && (
<div className="flex items-center gap-2">
<button
onClick={markAllWatched}
disabled={pending}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-mint)]/15 text-[var(--color-mint)] border border-[var(--color-mint)]/40 hover:bg-[var(--color-mint)]/25 disabled:opacity-50"
>
{pending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
Mark All Watched ({covers.length})
</button>
<button
onClick={clearQueue}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-coral)]"
>
<X className="w-4 h-4" />
Clear Queue
</button>
</div>
)}
</div>
{loading ? (
<div className="glass rounded-2xl p-card text-center text-[var(--color-fg-dim)]">
<Loader2 className="w-6 h-6 mx-auto animate-spin mb-label" />
Loading queue
</div>
) : covers.length === 0 ? (
<div className="glass rounded-2xl p-card text-center">
<ListVideo className="w-8 h-8 mx-auto text-[var(--color-fg-dim)] mb-label" />
<p className="text-[var(--color-fg-dim)]">
{queue.ids.length > 0
? "Queue items couldn't be resolved (covers may have been deleted)."
: "Queue is empty."}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{covers.map((c) => (
<div key={c.id} className="relative group/queue">
<ImageCard image={c} />
<div className="absolute inset-0 z-20 rounded-2xl bg-black/80 opacity-0 group-hover/queue:opacity-100 transition-opacity grid place-items-center cursor-default">
<div className="grid grid-cols-2 rounded-full overflow-hidden shadow-2xl border border-white/10" style={{ width: "min(86%, 420px)" }}>
<button
type="button"
onClick={(e) => {
e.preventDefault(); e.stopPropagation();
start(async () => {
await bulkSetWatched([c.id], true);
queue.remove(c.id);
router.refresh();
});
}}
title="Mark as watched (removes from queue)"
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[var(--color-mint)]/90 hover:bg-[var(--color-mint)] text-black font-semibold text-sm cursor-pointer"
>
<Eye className="w-4 h-4" />
Mark As Watched
</button>
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); queue.remove(c.id); }}
title="Remove from queue"
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[var(--color-coral)]/90 hover:bg-[var(--color-coral)] text-black font-semibold text-sm cursor-pointer border-l border-black/20"
>
<Trash2 className="w-4 h-4" />
Remove From Queue
</button>
</div>
</div>
</div>
))}
</div>
)}
</>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { subscribeQueueRemove } from "./watchQueueEvents";
const STORAGE_KEY = "pinkudex.watch-queue";
type Ctx = {
ids: number[];
has: (id: number) => boolean;
add: (id: number) => void;
addMany: (ids: number[]) => void;
remove: (id: number) => void;
removeMany: (ids: number[]) => void;
toggle: (id: number) => void;
clear: () => void;
};
const WatchQueueCtx = createContext<Ctx | null>(null);
function readStorage(): number[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((n): n is number => typeof n === "number");
} catch {
return [];
}
}
export function WatchQueueProvider({ children }: { children: React.ReactNode }) {
const [ids, setIds] = useState<number[]>([]);
const [hydrated, setHydrated] = useState(false);
// Hydrate from localStorage. SSR renders an empty queue; this fills it
// on mount so the server-rendered HTML always matches initial paint.
useEffect(() => {
setIds(readStorage());
setHydrated(true);
}, []);
// Persist on change (after hydration, so we don't blow away storage with
// the empty-array initial state).
useEffect(() => {
if (!hydrated) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
} catch {}
}, [ids, hydrated]);
// Cross-tab sync.
useEffect(() => {
function onStorage(e: StorageEvent) {
if (e.key !== STORAGE_KEY) return;
setIds(readStorage());
}
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
// External signal — covers fire this after their watched flag flips true.
useEffect(() => subscribeQueueRemove((detail) => {
const drop = new Set(detail);
setIds((cur) => cur.filter((id) => !drop.has(id)));
}), []);
const add = useCallback((id: number) => {
setIds((cur) => (cur.includes(id) ? cur : [...cur, id]));
}, []);
const addMany = useCallback((newIds: number[]) => {
setIds((cur) => {
const have = new Set(cur);
const merged = [...cur];
for (const id of newIds) if (!have.has(id)) { merged.push(id); have.add(id); }
return merged;
});
}, []);
const remove = useCallback((id: number) => {
setIds((cur) => cur.filter((x) => x !== id));
}, []);
const removeMany = useCallback((dropIds: number[]) => {
const drop = new Set(dropIds);
setIds((cur) => cur.filter((id) => !drop.has(id)));
}, []);
const toggle = useCallback((id: number) => {
setIds((cur) => (cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]));
}, []);
const clear = useCallback(() => setIds([]), []);
const value = useMemo<Ctx>(() => ({
ids,
has: (id) => ids.includes(id),
add,
addMany,
remove,
removeMany,
toggle,
clear,
}), [ids, add, addMany, remove, removeMany, toggle, clear]);
return <WatchQueueCtx.Provider value={value}>{children}</WatchQueueCtx.Provider>;
}
export function useWatchQueue(): Ctx {
const ctx = useContext(WatchQueueCtx);
if (!ctx) throw new Error("useWatchQueue must be used within WatchQueueProvider");
return ctx;
}
+21
View File
@@ -0,0 +1,21 @@
// Event helpers split out of WatchQueueProvider.tsx so that the
// provider file's only exports are the Component and the `useXxx`
// hook — the shape Next.js Fast Refresh needs to swap in place
// instead of forcing a full reload.
const REMOVE_EVENT = "pinkudex:queue-remove";
export function dispatchQueueRemove(ids: number | number[]) {
if (typeof window === "undefined") return;
const arr = Array.isArray(ids) ? ids : [ids];
window.dispatchEvent(new CustomEvent(REMOVE_EVENT, { detail: arr }));
}
export function subscribeQueueRemove(cb: (ids: number[]) => void): () => void {
function onRemove(e: Event) {
const detail = (e as CustomEvent<number[]>).detail;
if (Array.isArray(detail) && detail.length > 0) cb(detail);
}
window.addEventListener(REMOVE_EVENT, onRemove);
return () => window.removeEventListener(REMOVE_EVENT, onRemove);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Eye, EyeOff, Gem, Star, MinusCircle, ChevronDown, Tag } from "lucide-react";
import { cn } from "@/lib/utils";
type Action = "watched" | "unwatched" | "vip" | "favorite" | "unmark";
export function MarkAsMenu({
onAction,
disabled,
}: {
onAction: (action: Action) => void;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
function pick(action: Action) {
setOpen(false);
onAction(action);
}
return (
<div className="relative" ref={wrapRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={disabled}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg glass hover:text-[var(--color-fg)] disabled:opacity-40"
>
<Tag className="w-3.5 h-3.5" />
Mark As
<ChevronDown className="w-3 h-3 opacity-70" />
</button>
{open && (
<div
className="absolute right-0 bottom-[calc(100%+6px)] z-30 bg-[var(--color-bg-0)] border border-[var(--color-glass-border-strong)] rounded-xl shadow-2xl p-1 w-52"
onClick={(e) => e.stopPropagation()}
>
<Row icon={Eye} label="Watched" colorClass="text-[var(--color-mint)]" onClick={() => pick("watched")} />
<Row icon={EyeOff} label="Unwatched" onClick={() => pick("unwatched")} />
<div className="h-px bg-[var(--color-glass-border)] my-1" />
<Row icon={Gem} label="VIP" colorClass="text-[var(--color-cyan)]" onClick={() => pick("vip")} />
<Row icon={Star} label="Favorite" colorClass="text-amber-300" iconStyle={{ fill: "#fbbf24" }} onClick={() => pick("favorite")} />
<Row icon={MinusCircle} label="Unmark" colorClass="text-[var(--color-fg-muted)]" onClick={() => pick("unmark")} />
</div>
)}
</div>
);
}
function Row({
icon: Icon,
label,
onClick,
colorClass,
iconStyle,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
label: string;
onClick: () => void;
colorClass?: string;
iconStyle?: React.CSSProperties;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md text-sm text-left transition-colors hover:bg-[var(--color-glass)]",
colorClass ?? "text-[var(--color-fg-dim)]",
)}
>
<Icon className="w-3.5 h-3.5" style={iconStyle} />
<span className="flex-1">{label}</span>
</button>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { useSelection } from "./SelectionProvider";
export function RegisterVisible({ ids }: { ids: number[] }) {
const pathname = usePathname();
const { clear, setVisibleIds } = useSelection();
useEffect(() => {
setVisibleIds(ids);
return () => setVisibleIds([]);
}, [ids, setVisibleIds]);
useEffect(() => {
return () => clear();
}, [pathname, clear]);
return null;
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useTransition } from "react";
import { useSelection } from "./SelectionProvider";
import { useRouter } from "next/navigation";
import { Trash2, X, ListChecks } from "lucide-react";
import { deleteImages, bulkSetWatched, bulkSetMark } from "@/app/actions/bulk";
import { useUndoDeleteToast } from "@/components/select/UndoDeleteToast";
import { useSettings } from "@/components/settings/SettingsProvider";
import { MarkAsMenu } from "./MarkAsMenu";
import { useWatchQueue } from "@/components/queue/WatchQueueProvider";
import { dispatchQueueRemove } from "@/components/queue/watchQueueEvents";
import { ListVideo } from "lucide-react";
export function SelectionBar() {
const { ids, clear, visibleIds, selectMany } = useSelection();
const { settings } = useSettings();
const { show: showUndo } = useUndoDeleteToast();
const [pending, start] = useTransition();
const router = useRouter();
const queue = useWatchQueue();
if (ids.size === 0) return null;
const count = ids.size;
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => ids.has(id));
const onDelete = (e: React.MouseEvent) => {
const permanent = e.shiftKey || !settings.useRecycleBin;
if (permanent) {
if (!confirm(`Permanently delete ${count} cover${count === 1 ? "" : "s"}? Cannot be undone.`)) return;
}
const targetIds = Array.from(ids);
start(async () => {
await deleteImages(targetIds, permanent ? { permanent: true } : undefined);
clear();
router.refresh();
if (!permanent) showUndo(targetIds);
});
};
const onSelectAllToggle = () => {
if (allVisibleSelected) clear();
else selectMany(visibleIds);
};
const onMarkAs = (action: "watched" | "unwatched" | "vip" | "favorite" | "unmark") => {
const targetIds = Array.from(ids);
start(async () => {
try {
if (action === "watched") { await bulkSetWatched(targetIds, true); dispatchQueueRemove(targetIds); }
else if (action === "unwatched") await bulkSetWatched(targetIds, false);
else if (action === "vip") await bulkSetMark(targetIds, "vip");
else if (action === "favorite") await bulkSetMark(targetIds, "favorite");
else if (action === "unmark") await bulkSetMark(targetIds, "unmarked");
router.refresh();
} catch (err) {
console.error(`[bulk ${action}] failed:`, err);
}
});
};
return (
<div className="fixed bottom-[80px] left-1/2 -translate-x-1/2 z-50">
<div
className="rounded-2xl shadow-2xl px-4 py-2.5 flex items-center gap-3 border border-[var(--color-glass-border-strong)] backdrop-blur-2xl"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 85%, transparent)" }}
>
<span className="text-sm font-mono tabular-nums">
<span className="text-[var(--color-cyan)] font-semibold">{count}</span>
<span className="text-[var(--color-fg-dim)]"> selected</span>
</span>
<div className="w-px h-5 bg-[var(--color-glass-border)]" />
{visibleIds.length > 0 && (
<button
onClick={onSelectAllToggle}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg glass hover:text-[var(--color-fg)]"
>
<ListChecks className="w-3.5 h-3.5" />
{allVisibleSelected ? "Deselect All" : `All (${visibleIds.length})`}
</button>
)}
<button
onClick={() => { queue.addMany(Array.from(ids)); }}
title="Add to watch queue"
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg glass hover:text-[var(--color-fg)]"
>
<ListVideo className="w-3.5 h-3.5" /> Queue
</button>
<MarkAsMenu onAction={onMarkAs} disabled={pending} />
<button
onClick={onDelete}
disabled={pending}
title={settings.useRecycleBin ? "Send to trash · Shift-click for permanent delete" : "Delete permanently"}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-[var(--color-coral)]/15 text-[var(--color-coral)] border border-[var(--color-coral)]/40 hover:bg-[var(--color-coral)]/25 disabled:opacity-40"
>
<Trash2 className="w-3.5 h-3.5" /> {pending ? "Deleting…" : "Delete"}
</button>
<button
onClick={clear}
className="flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-lg text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
>
<X className="w-3.5 h-3.5" /> Clear
</button>
</div>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { usePathname } from "next/navigation";
type Ctx = {
ids: Set<number>;
has: (id: number) => boolean;
toggle: (id: number) => void;
selectMany: (ids: number[]) => void;
clear: () => void;
visibleIds: number[];
setVisibleIds: (ids: number[]) => void;
};
const SelectCtx = createContext<Ctx | null>(null);
export function SelectionProvider({ children }: { children: React.ReactNode }) {
const [ids, setIds] = useState<Set<number>>(new Set());
const [visibleIds, setVisibleIdsState] = useState<number[]>([]);
// Guard against fresh-array identity churn: server-side renders pass a new
// `number[]` reference every time, which would otherwise re-fire all consumers.
const setVisibleIds = useCallback((next: number[]) => {
setVisibleIdsState((cur) => {
if (cur.length === next.length && cur.every((v, i) => v === next[i])) return cur;
return next;
});
}, []);
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;
}), []);
const selectMany = useCallback((newIds: number[]) => setIds((cur) => {
const next = new Set(cur);
newIds.forEach((i) => next.add(i));
return next;
}), []);
const clear = useCallback(() => setIds(new Set()), []);
// Global route-change cleanup: pages without RegisterVisible (e.g.
// /actress, /category, /tag, /search) would otherwise carry stale
// selections across navigation. Clear on any pathname change.
const pathname = usePathname();
const lastPath = useRef<string | null>(null);
useEffect(() => {
if (lastPath.current !== null && lastPath.current !== pathname) {
setIds(new Set());
setVisibleIdsState([]);
}
lastPath.current = pathname;
}, [pathname]);
const value = useMemo<Ctx>(() => ({
ids,
has: (id) => ids.has(id),
toggle,
selectMany,
clear,
visibleIds,
setVisibleIds,
}), [ids, toggle, selectMany, clear, visibleIds, setVisibleIds]);
return <SelectCtx.Provider value={value}>{children}</SelectCtx.Provider>;
}
export function useSelection() {
const ctx = useContext(SelectCtx);
if (!ctx) throw new Error("useSelection must be used within SelectionProvider");
return ctx;
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Undo2, X } from "lucide-react";
import { restoreImages } from "@/app/actions/trash";
interface ToastState {
ids: number[];
visibleAt: number;
failed?: { message: string };
}
interface Ctx {
show: (ids: number[]) => void;
}
const ToastCtx = createContext<Ctx | null>(null);
const VISIBLE_MS = 8000;
export function UndoDeleteToastProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<ToastState | null>(null);
const timerRef = useRef<number | null>(null);
const router = useRouter();
const [pending, start] = useTransition();
const dismiss = useCallback(() => {
setState(null);
if (timerRef.current != null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const show = useCallback((ids: number[]) => {
if (ids.length === 0) return;
setState({ ids, visibleAt: Date.now() });
if (timerRef.current != null) clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setState(null), VISIBLE_MS);
}, []);
useEffect(() => () => { if (timerRef.current != null) clearTimeout(timerRef.current); }, []);
const undo = () => {
if (!state) return;
const ids = state.ids;
// Hide the trash-confirmation copy while the restore is in flight,
// but DON'T dismiss the toast — if restoreImages throws, the items
// are still in trash and the user has no signal. Re-surface in the
// catch with a retry affordance.
if (timerRef.current != null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
start(async () => {
try {
await restoreImages(ids);
router.refresh();
setState(null);
} catch (e) {
setState({
ids,
visibleAt: Date.now(),
failed: { message: (e as Error).message || "Restore failed" },
});
timerRef.current = window.setTimeout(() => setState(null), VISIBLE_MS);
}
});
};
const ctx = useMemo<Ctx>(() => ({ show }), [show]);
return (
<ToastCtx.Provider value={ctx}>
{children}
{state && (
<div className="fixed bottom-[80px] left-1/2 -translate-x-1/2 z-[60]">
<div
className="rounded-2xl shadow-2xl px-4 py-2.5 flex items-center gap-3 border border-[var(--color-glass-border-strong)] backdrop-blur-2xl"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 90%, transparent)" }}
>
<span className="text-sm">
{state.failed ? (
<>
<span className="text-[var(--color-red)]">Restore failed</span>
<span className="text-[var(--color-fg-dim)]"> &mdash; {state.failed.message}</span>
</>
) : (
<>
<span className="font-mono text-[var(--color-cyan)]">{state.ids.length}</span>
<span className="text-[var(--color-fg-dim)]"> moved to trash</span>
</>
)}
</span>
<div className="w-px h-5 bg-[var(--color-glass-border)]" />
<button
onClick={undo}
disabled={pending}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-50"
>
<Undo2 className="w-3.5 h-3.5" /> {state.failed ? "Retry" : "Undo"}
</button>
<button
onClick={dismiss}
aria-label="Dismiss"
className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
</ToastCtx.Provider>
);
}
export function useUndoDeleteToast() {
const ctx = useContext(ToastCtx);
if (!ctx) throw new Error("useUndoDeleteToast must be used within UndoDeleteToastProvider");
return ctx;
}
@@ -0,0 +1,93 @@
"use client";
import { RotateCcw } from "lucide-react";
import { useSettings } from "./SettingsProvider";
const DEFAULT_PRIMARY_HEX = "#4dc4d4";
const DEFAULT_SECONDARY_HEX = "#b772f0";
export function AccentColorPickers() {
const { settings, set } = useSettings();
return (
<div className="space-y-3">
<ColorRow
label="Primary Accent"
description="Used for buttons, toggles, focus rings, and the cyan side of accent gradients."
value={settings.accentPrimary}
fallback={DEFAULT_PRIMARY_HEX}
onChange={(v) => set("accentPrimary", v)}
onReset={() => set("accentPrimary", "")}
/>
<ColorRow
label="Secondary Accent"
description="Used for the violet side of accent gradients, glows, and ambient page lighting."
value={settings.accentSecondary}
fallback={DEFAULT_SECONDARY_HEX}
onChange={(v) => set("accentSecondary", v)}
onReset={() => set("accentSecondary", "")}
/>
</div>
);
}
function ColorRow({
label,
description,
value,
fallback,
onChange,
onReset,
}: {
label: string;
description?: string;
value: string;
fallback: string;
onChange: (v: string) => void;
onReset: () => void;
}) {
const isDefault = value === "";
const display = value || fallback;
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{label}</div>
{description && (
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">{description}</div>
)}
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--color-fg-muted)] mt-1">
{isDefault ? "default" : display}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<label
className="relative w-9 h-9 rounded-lg border border-[var(--color-glass-border-strong)] bg-[var(--color-glass)] cursor-pointer overflow-hidden grid place-items-center hover:border-[color-mix(in_oklch,var(--color-cyan)_50%,var(--color-glass-border))] transition-colors"
title={`Change ${label.toLowerCase()}`}
>
<span
className="absolute inset-1 rounded-md"
style={{ background: display }}
aria-hidden
/>
<input
type="color"
value={display}
onChange={(e) => onChange(e.target.value.toLowerCase())}
className="absolute inset-0 opacity-0 cursor-pointer"
aria-label={label}
/>
</label>
<button
type="button"
onClick={onReset}
disabled={isDefault}
aria-label={`Reset ${label.toLowerCase()}`}
title="Reset to default"
className="w-9 h-9 grid place-items-center rounded-lg border border-[var(--color-glass-border-strong)] bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:border-[color-mix(in_oklch,var(--color-cyan)_50%,var(--color-glass-border))] transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:border-[var(--color-glass-border-strong)]"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Download, Upload, Loader2, AlertTriangle, Archive, PackageOpen } from "lucide-react";
export function BackupButtons() {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const zipFileRef = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
const [importingZip, setImportingZip] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportingAll, setExportingAll] = useState(false);
const [message, setMessage] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
// Synchronous guard: setImporting() updates state asynchronously, so a
// rapid second invocation could slip through before importing=true is
// visible in the next render. The ref blocks that window.
const importingRef = useRef(false);
const importingZipRef = useRef(false);
async function handleExport() {
setExporting(true);
setMessage(null);
try {
const res = await fetch("/api/backup/export");
if (!res.ok) throw new Error(`Export failed (${res.status})`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
a.download = `pinkudex-backup-${stamp}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
setMessage({ kind: "ok", text: "Backup downloaded." });
} catch (e) {
setMessage({ kind: "err", text: (e as Error).message });
} finally {
setExporting(false);
}
}
async function handleExportAll() {
setExportingAll(true);
setMessage(null);
try {
// Direct navigation so the browser owns the download (and shows its
// own progress bar, ETA, pause/cancel) — fetch+blob would buffer the
// whole zip in memory, which is fatal for multi-GB libraries.
const a = document.createElement("a");
a.href = "/api/backup/library-export";
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
setMessage({ kind: "ok", text: "Library export started — see browser downloads." });
} catch (e) {
setMessage({ kind: "err", text: (e as Error).message });
} finally {
setExportingAll(false);
}
}
async function handleImport(file: File) {
if (importingRef.current) return;
// Claim the lock BEFORE confirm() so a second invocation triggered
// while the dialog is still open (e.g. rapid double-pick of the same
// file) hits the early return instead of slipping through the race
// window between confirm and setImporting.
importingRef.current = true;
if (!confirm(
"Importing will REPLACE all existing actresses, covers, categories, tags, collections and settings with the contents of this backup.\n\nThis cannot be undone. Continue?",
)) {
importingRef.current = false;
return;
}
setImporting(true);
setMessage(null);
try {
const text = await file.text();
const res = await fetch("/api/backup/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: text,
});
const j = await res.json();
if (!res.ok) throw new Error(j.error ?? `Import failed (${res.status})`);
const totals = Object.entries(j.counts ?? {})
.filter(([, n]) => (n as number) > 0)
.map(([t, n]) => `${t}: ${n}`)
.join(", ");
setMessage({ kind: "ok", text: `Import complete. ${totals || "(empty)"}` });
router.refresh();
} catch (e) {
setMessage({ kind: "err", text: (e as Error).message });
} finally {
importingRef.current = false;
setImporting(false);
if (fileRef.current) fileRef.current.value = "";
}
}
async function handleImportZip(file: File) {
if (importingZipRef.current) return;
// Same race-window fix as handleImport — claim the lock before the
// confirm() dialog opens.
importingZipRef.current = true;
if (!confirm(
"Restoring will REPLACE the database AND your library/, data/thumbs/, data/portraits/, data/category-covers/ folders.\n\n" +
"Existing folders will be renamed to *.pre-restore-<timestamp>/ for manual rollback. The database is snapshotted first.\n\n" +
"This can take a long time for multi-GB archives. Continue?",
)) {
importingZipRef.current = false;
return;
}
setImportingZip(true);
setMessage(null);
try {
// Stream the file as the request body so multi-GB uploads don't have
// to be buffered into memory before sending. The route streams it
// straight to disk on the server side.
const res = await fetch("/api/backup/library-import", {
method: "POST",
headers: { "Content-Type": "application/zip" },
body: file,
// @ts-expect-error - Node fetch undici extension; lets the body stream.
duplex: "half",
});
const j = await res.json();
if (!res.ok) throw new Error(j.error ?? `Restore failed (${res.status})`);
const totals = Object.entries(j.counts ?? {})
.filter(([, n]) => (n as number) > 0)
.map(([t, n]) => `${t}: ${n}`)
.join(", ");
const mediaParts = (j.mediaRestored ?? []).join(", ");
setMessage({
kind: "ok",
text: `Restore complete. DB — ${totals || "(empty)"}. Media — ${mediaParts || "(none)"}. Old folders kept as *.pre-restore-* for rollback.`,
});
router.refresh();
} catch (e) {
setMessage({ kind: "err", text: (e as Error).message });
} finally {
importingZipRef.current = false;
setImportingZip(false);
if (zipFileRef.current) zipFileRef.current.value = "";
}
}
return (
<div className="py-2">
<div className="text-sm font-medium mb-1">Backup &amp; Restore</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-3">
<strong>Export backup</strong> metadata only (JSON). Fast, small, safe to email.
<br />
<strong>Export full library</strong> zip of <span className="font-mono">library/</span>, <span className="font-mono">data/thumbs/</span>, <span className="font-mono">data/portraits/</span>, <span className="font-mono">data/category-covers/</span> plus <span className="font-mono">database.json</span>. Skips <span className="font-mono">library/.superseded/</span>. Can be many GB; browser shows progress.
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleExport}
disabled={exporting}
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-50"
>
{exporting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Export backup
</button>
<button
type="button"
onClick={handleExportAll}
disabled={exportingAll}
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-50"
>
{exportingAll ? <Loader2 className="w-4 h-4 animate-spin" /> : <Archive className="w-4 h-4" />}
Export full library
</button>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={importing}
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-amber-500/40 text-amber-200 hover:bg-amber-500/10 disabled:opacity-50"
>
{importing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
Import backup
</button>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
hidden
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleImport(f); }}
/>
<button
type="button"
onClick={() => zipFileRef.current?.click()}
disabled={importingZip}
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-amber-500/40 text-amber-200 hover:bg-amber-500/10 disabled:opacity-50"
>
{importingZip ? <Loader2 className="w-4 h-4 animate-spin" /> : <PackageOpen className="w-4 h-4" />}
Restore full library
</button>
<input
ref={zipFileRef}
type="file"
accept="application/zip,.zip"
hidden
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleImportZip(f); }}
/>
</div>
{message && (
<div
className={`mt-3 flex items-start gap-2 text-xs ${
message.kind === "ok" ? "text-[var(--color-mint)]" : "text-red-300"
}`}
>
{message.kind === "err" && <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />}
<span className="break-words">{message.text}</span>
</div>
)}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { RefreshCw, CheckCircle2 } from "lucide-react";
import { clearCache } from "@/app/actions/maintenance";
export function ClearCacheButton() {
const [done, setDone] = useState(false);
const [pending, start] = useTransition();
const router = useRouter();
const onClick = () => {
start(async () => {
await clearCache();
router.refresh();
setDone(true);
setTimeout(() => setDone(false), 1600);
});
};
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">Clear Cache</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Drop the in-memory settings cache and force every page to re-fetch from the database.
Useful if data looks stale after a manual edit.
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{done ? (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-mint)]">
<CheckCircle2 className="w-3.5 h-3.5" /> Cleared
</span>
) : (
<button
onClick={onClick}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] disabled:opacity-50 whitespace-nowrap"
>
<RefreshCw className={`w-3.5 h-3.5 ${pending ? "animate-spin" : ""}`} />
{pending ? "Clearing…" : "Clear"}
</button>
)}
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useCallback, useRef, useState, useTransition } from "react";
import { Check, ChevronDown } from "lucide-react";
import { setDefaultSort } from "@/app/actions/sort";
import { SORT_OPTIONS, labelFor, type SortKey } from "@/lib/sort";
import { useClickOutside } from "@/lib/hooks/useClickOutside";
import { cn } from "@/lib/utils";
export function DefaultSortSelect({ initial }: { initial: SortKey }) {
const [value, setValue] = useState<SortKey>(initial);
const [open, setOpen] = useState(false);
const [saved, setSaved] = useState(false);
const [pending, start] = useTransition();
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, useCallback(() => setOpen(false), []), open);
const choose = (next: SortKey) => {
setOpen(false);
if (next === value) return;
setValue(next);
start(async () => {
await setDefaultSort(next);
setSaved(true);
setTimeout(() => setSaved(false), 1400);
});
};
return (
<div className="py-2">
<div className="flex items-start justify-between gap-4 mb-2">
<div>
<div className="text-sm font-medium">Default Sort</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Used on every grid page when no sort is chosen. Persisted on the server.
</div>
</div>
{saved && (
<span className="flex items-center gap-1 text-xs text-[var(--color-mint)]">
<Check className="w-3 h-3" /> Saved
</span>
)}
</div>
<div ref={ref} className="relative">
<button
onClick={() => setOpen((o) => !o)}
disabled={pending}
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm rounded-lg glass glass-hover text-[var(--color-fg)]"
>
<span>{labelFor(value)}</span>
<ChevronDown className={cn("w-3.5 h-3.5 opacity-60 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div
className="absolute left-0 right-0 top-full mt-2 z-30 rounded-xl shadow-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden p-1"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
{SORT_OPTIONS.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
onClick={() => choose(o.value)}
className={cn(
"w-full flex items-center justify-between gap-2 px-3 py-1.5 rounded-md text-sm text-left hover:bg-[var(--color-glass)]",
active && "text-[var(--color-cyan)]"
)}
>
<span>{o.label}</span>
{active && <Check className="w-3.5 h-3.5" />}
</button>
);
})}
</div>
)}
</div>
</div>
);
}
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { Copy, Loader2, AlertTriangle, ExternalLink } from "lucide-react";
import {
previewNearDupes,
backfillPhashes,
findNearDuplicates,
type NearDupePair,
type NearDupesPreview,
} from "@/app/actions/maintenance";
import { thumbUrl } from "@/lib/assetUrls";
import { useSettingsPanel } from "./SettingsPanelProvider";
type State =
| { kind: "idle" }
| { kind: "scanning" }
| { kind: "preview"; data: NearDupesPreview }
| { kind: "backfilling" }
| { kind: "running" }
| { kind: "result"; pairs: NearDupePair[] };
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
export function NearDupesButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [pending, start] = useTransition();
const [threshold, setThreshold] = useState(10);
const { close: closeSettings } = useSettingsPanel();
const scan = () => {
setState({ kind: "scanning" });
start(async () => {
const data = await previewNearDupes();
setState({ kind: "preview", data });
});
};
const backfillAndFind = async () => {
setState({ kind: "backfilling" });
await backfillPhashes();
setState({ kind: "running" });
const pairs = await findNearDuplicates({ threshold });
setState({ kind: "result", pairs });
};
const findOnly = async () => {
setState({ kind: "running" });
const pairs = await findNearDuplicates({ threshold });
setState({ kind: "result", pairs });
};
return (
<div className="py-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium">Find Near-Duplicate Covers</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Compares perceptual hashes of every cover and surfaces pairs that look the same but
aren&apos;t SHA-identical different encodes, mild crops, or upscales of the same image.
Complements the SHA dedup and code-collision detectors.
</div>
{state.kind === "preview" && (
<div className="text-xs mt-2 text-[var(--color-fg-dim)] space-y-2">
<div>
<span className="font-mono text-[var(--color-cyan)]">{state.data.hashed}</span> hashed
{" · "}
<span className="font-mono text-[var(--color-amber,#fbbf24)]">{state.data.unhashed}</span> need backfill
{" · "}of {state.data.total} covers
</div>
<div className="flex items-center gap-2">
<label className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-fg-muted)]">
Threshold
</label>
<input
type="range"
min={0}
max={20}
step={1}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
className="flex-1 max-w-[200px] accent-[var(--color-cyan)]"
/>
<span className="font-mono text-[var(--color-cyan)] tabular-nums w-8">{threshold}</span>
</div>
<div className="text-[11px] text-[var(--color-fg-muted)]">
0 = identical · 5 = very tight · 10 = robust default · 20 = noisy
</div>
</div>
)}
{state.kind === "result" && (
<div className="text-xs mt-2 flex items-center gap-1.5">
{state.pairs.length === 0 ? (
<span className="text-[var(--color-mint)]">No near-duplicate pairs found at this threshold.</span>
) : (
<span className="text-[var(--color-coral)] flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" />
{state.pairs.length} near-duplicate pair{state.pairs.length === 1 ? "" : "s"} (distance {threshold}).
</span>
)}
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{state.kind === "idle" && (
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
<Copy className="w-3.5 h-3.5" /> Scan
</button>
)}
{(state.kind === "scanning" || state.kind === "backfilling" || state.kind === "running") && (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-fg-dim)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{state.kind === "scanning" ? "Counting…" : state.kind === "backfilling" ? "Hashing…" : "Comparing…"}
</span>
)}
{state.kind === "preview" && (
<>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Cancel
</button>
{state.data.unhashed > 0 ? (
<button
onClick={() => start(() => backfillAndFind())}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-40 whitespace-nowrap"
>
Hash {state.data.unhashed} & find pairs
</button>
) : (
<button
onClick={() => start(() => findOnly())}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-40 whitespace-nowrap"
>
Find pairs
</button>
)}
</>
)}
{state.kind === "result" && (
<>
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
Re-scan
</button>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Dismiss
</button>
</>
)}
</div>
</div>
{state.kind === "result" && state.pairs.length > 0 && (
<div className="mt-3 max-h-96 overflow-y-auto rounded-md border border-[var(--color-glass-border)] bg-[var(--color-bg-1)]/40 divide-y divide-[var(--color-glass-border)]">
{state.pairs.map((p) => {
const aBigger = p.a.width * p.a.height >= p.b.width * p.b.height;
const aMoreBytes = p.a.bytes >= p.b.bytes;
return (
<div key={`${p.a.id}-${p.b.id}`} className="p-2">
<div className="flex items-center gap-2 mb-1.5">
<span className="font-mono text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-[var(--color-coral)]/15 text-[var(--color-coral)]">
Δ {p.distance}
</span>
<span className="text-[11px] text-[var(--color-fg-muted)]">
Hamming distance lower = more similar
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<DupeCell side={p.a} bigger={aBigger} moreBytes={aMoreBytes} onNavigate={closeSettings} />
<DupeCell side={p.b} bigger={!aBigger} moreBytes={!aMoreBytes} onNavigate={closeSettings} />
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function DupeCell({
side,
bigger,
moreBytes,
onNavigate,
}: {
side: NearDupePair["a"];
bigger: boolean;
moreBytes: boolean;
onNavigate: () => void;
}) {
return (
<Link
href={`/image/${side.id}`}
onClick={onNavigate}
className="flex items-center gap-2 rounded-md p-2 hover:bg-[var(--color-glass)] transition-colors"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={thumbUrl({ thumbPath: side.thumbPath, code: side.code, id: side.id })}
alt=""
className="w-12 h-12 object-contain bg-black/40 rounded shrink-0"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs">
{side.code ? (
<span className="font-mono font-bold text-[var(--color-cyan)]">{side.code}</span>
) : (
<span className="font-mono text-[var(--color-fg-muted)] italic">no code</span>
)}
<span className={`font-mono tabular-nums text-[11px] ${bigger ? "text-[var(--color-mint)]" : "text-[var(--color-fg-muted)]"}`}>
{side.width}×{side.height}
</span>
<span className={`font-mono tabular-nums text-[11px] ${moreBytes ? "text-[var(--color-mint)]" : "text-[var(--color-fg-muted)]"}`}>
{fmtBytes(side.bytes)}
</span>
</div>
<div className="text-[11px] text-[var(--color-fg-dim)] truncate font-mono mt-0.5">
{side.filename}
</div>
</div>
<ExternalLink className="w-3.5 h-3.5 text-[var(--color-fg-muted)] shrink-0" />
</Link>
);
}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Plus, Trash2, ArrowUp, ArrowDown, Save, RotateCcw, Hash } from "lucide-react";
import { setPartSuffixPatterns } from "@/app/actions/settings";
import { useSettings } from "./SettingsProvider";
import { cn } from "@/lib/utils";
const DEFAULT_PATTERNS = ["-cd{N}", ".part{N}", "_{N}", "_{L}"];
/**
* Validate a token-grammar pattern. Returns null if valid, or a short
* error message if not. Mirrors the rules in lib/video/partClassify.ts.
*/
function validateToken(source: string): string | null {
if (!source.trim()) return "empty";
let i = 0;
let captures = 0;
while (i < source.length) {
const c = source[i]!;
if (c === "{") {
const close = source.indexOf("}", i);
if (close < 0) return "unclosed {";
const tok = source.slice(i, close + 1);
if (tok !== "{N}" && tok !== "{L}") return `unknown token ${tok}`;
captures++;
i = close + 1;
} else {
i++;
}
}
if (captures === 0) return "needs {N} or {L}";
if (captures > 1) return "only one {N}/{L} per pattern";
return null;
}
/**
* Settings card body for editing the suffix patterns used to classify
* video parts vs. variants. Uses option A1 (token grammar) from the
* mockup — each row is a single editable pattern with reorder/delete.
*/
export function PartSuffixPatterns() {
const { settings } = useSettings();
const router = useRouter();
const [draft, setDraft] = useState<string[]>(settings.partSuffixPatterns ?? DEFAULT_PATTERNS);
const [saving, setSaving] = useState(false);
const [, startTransition] = useTransition();
// Keep the editor in sync with server-side updates (e.g. another tab).
useEffect(() => {
setDraft(settings.partSuffixPatterns ?? DEFAULT_PATTERNS);
}, [settings.partSuffixPatterns]);
const errors = draft.map(validateToken);
const hasErrors = errors.some((e) => e != null);
const dirty =
draft.length !== (settings.partSuffixPatterns ?? []).length ||
draft.some((v, i) => v !== (settings.partSuffixPatterns ?? [])[i]);
function update(i: number, value: string) {
setDraft((cur) => cur.map((v, idx) => (idx === i ? value : v)));
}
function remove(i: number) {
setDraft((cur) => cur.filter((_, idx) => idx !== i));
}
function add() {
setDraft((cur) => [...cur, ""]);
}
function move(i: number, dir: -1 | 1) {
setDraft((cur) => {
const j = i + dir;
if (j < 0 || j >= cur.length) return cur;
const next = cur.slice();
[next[i], next[j]] = [next[j]!, next[i]!];
return next;
});
}
function resetDefaults() {
setDraft(DEFAULT_PATTERNS.slice());
}
async function save() {
if (hasErrors) return;
setSaving(true);
try {
// Strip empties on save (the action also trims, but UX clarity).
const cleaned = draft.map((s) => s.trim()).filter(Boolean);
await setPartSuffixPatterns(cleaned);
startTransition(() => router.refresh());
} finally {
setSaving(false);
}
}
return (
<div className="pt-3 border-t border-[var(--color-glass-border)] space-y-3">
<div className="space-y-1">
<div className="text-sm font-medium flex items-center gap-1.5">
<Hash className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
Part Suffix Patterns
</div>
<div className="text-xs text-[var(--color-fg-muted)] max-w-2xl">
Match the trailing portion of a filename stem to identify
sequential parts.{" "}
<span className="font-mono text-[var(--color-cyan)]">{"{N}"}</span>{" "}
captures digits,{" "}
<span className="font-mono text-[var(--color-cyan)]">{"{L}"}</span>{" "}
captures a single letter (A=1, B=2). All other characters are
literal. Files in a code group that match nothing become
<em> variants</em> of the part they share a stem prefix with.
</div>
</div>
<ol className="space-y-1.5">
{draft.map((p, i) => {
const err = errors[i];
return (
<li key={i} className="flex items-center gap-2">
<input
type="text"
value={p}
onChange={(e) => update(i, e.target.value)}
placeholder="-cd{N}"
className={cn(
"flex-1 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none",
err
? "border border-red-500/50 focus:border-red-400"
: "focus:border-[var(--color-cyan)]",
)}
/>
<button
type="button"
onClick={() => move(i, -1)}
disabled={i === 0}
title="Move up"
className="p-1.5 rounded-lg glass glass-hover disabled:opacity-30"
>
<ArrowUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => move(i, 1)}
disabled={i === draft.length - 1}
title="Move down"
className="p-1.5 rounded-lg glass glass-hover disabled:opacity-30"
>
<ArrowDown className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => remove(i)}
title="Remove"
className="p-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
</button>
</li>
);
})}
</ol>
{hasErrors && (
<div className="text-[11px] text-red-300">
{errors.map((e, i) => (e ? `Line ${i + 1}: ${e}` : null)).filter(Boolean).join(" · ")}
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={add}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
>
<Plus className="w-4 h-4" /> Add Pattern
</button>
<button
type="button"
onClick={resetDefaults}
title="Reset to built-in defaults"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-muted)]"
>
<RotateCcw className="w-4 h-4" /> Reset to Defaults
</button>
<button
type="button"
onClick={save}
disabled={!dirty || saving || hasErrors}
className="ml-auto inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save &amp; Reclassify
</button>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useState, useTransition } from "react";
import { Trash2, Loader2, CheckCircle2 } from "lucide-react";
import { previewOrphanFiles, purgeOrphanFiles } from "@/app/actions/maintenance";
import { formatBytes } from "@/lib/utils";
type State =
| { kind: "idle" }
| { kind: "scanning" }
| { kind: "result"; count: number; bytes: number }
| { kind: "deleted"; deleted: number; bytes: number };
export function PurgeOrphansButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [pending, start] = useTransition();
const scan = () => {
setState({ kind: "scanning" });
start(async () => {
const r = await previewOrphanFiles();
setState({ kind: "result", count: r.count, bytes: r.bytes });
});
};
const purge = () => {
if (state.kind !== "result") return;
if (!confirm(`Delete ${state.count} orphan file${state.count === 1 ? "" : "s"} (${formatBytes(state.bytes)}) from disk? Cannot be undone.`)) return;
start(async () => {
const r = await purgeOrphanFiles();
setState({ kind: "deleted", deleted: r.deleted, bytes: r.bytes });
});
};
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">Purge Orphan Files</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Find and delete files in the library / thumbnail folders that no image record references.
Useful after deleting images with Delete files from disk turned off.
</div>
{state.kind === "result" && (
<div className="text-xs mt-2">
<span className="font-mono text-[var(--color-cyan)]">{state.count}</span>
<span className="text-[var(--color-fg-dim)]"> orphan{state.count === 1 ? "" : "s"} · {formatBytes(state.bytes)}</span>
</div>
)}
{state.kind === "deleted" && (
<div className="flex items-center gap-1.5 text-xs text-[var(--color-mint)] mt-2">
<CheckCircle2 className="w-3.5 h-3.5" />
Deleted {state.deleted} file{state.deleted === 1 ? "" : "s"} · freed {formatBytes(state.bytes)}
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{state.kind === "idle" && (
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
Scan
</button>
)}
{state.kind === "scanning" && (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-fg-dim)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Scanning
</span>
)}
{state.kind === "result" && state.count > 0 && (
<>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Cancel
</button>
<button
onClick={purge}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg bg-[var(--color-coral)]/15 text-[var(--color-coral)] border border-[var(--color-coral)]/40 hover:bg-[var(--color-coral)]/25 disabled:opacity-40 whitespace-nowrap"
>
<Trash2 className="w-3.5 h-3.5" /> {pending ? "Deleting…" : "Delete"}
</button>
</>
)}
{state.kind === "result" && state.count === 0 && (
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Dismiss
</button>
)}
{state.kind === "deleted" && (
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Done
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,119 @@
"use client";
import { useState, useTransition } from "react";
import { ImageIcon, Loader2, CheckCircle2 } from "lucide-react";
import { previewRegenThumbnails, regenerateThumbnails } from "@/app/actions/maintenance";
type State =
| { kind: "idle" }
| { kind: "scanning" }
| { kind: "preview"; total: number; missing: number; staleNames: number }
| { kind: "running" }
| { kind: "done"; regenerated: number; renamed: number; skipped: number; errors: number };
export function RegenThumbnailsButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [pending, start] = useTransition();
const scan = () => {
setState({ kind: "scanning" });
start(async () => {
const r = await previewRegenThumbnails();
setState({ kind: "preview", total: r.total, missing: r.missing, staleNames: r.staleNames });
});
};
const run = (force: boolean) => {
if (state.kind !== "preview") return;
const count = force ? state.total : state.missing + state.staleNames;
const verb = force ? "Re-encode" : "Regenerate missing + rename stale";
if (!confirm(`${verb} for ${count} thumbnail${count === 1 ? "" : "s"}? Reads each cover from library/ when it needs encoding and renames legacy files in place when possible. Cannot be undone.`)) return;
setState({ kind: "running" });
start(async () => {
const r = await regenerateThumbnails({ force });
setState({ kind: "done", ...r });
});
};
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">Regenerate Thumbnails</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Rebuild the grid-preview WebP files in <code className="font-mono">data/thumbs/</code> from the
originals in <code className="font-mono">library/</code>, and rename legacy <code className="font-mono">&lt;sha&gt;.webp</code>
files to the new <code className="font-mono">&lt;CODE&gt;-&lt;sha&gt;.webp</code> format. Use this if your thumbs folder
was wiped, restored from an incomplete backup, or you upgraded to the code-prefix naming.
</div>
{state.kind === "preview" && (
<div className="text-xs mt-2 text-[var(--color-fg-dim)] space-y-0.5">
<div>
<span className="font-mono text-[var(--color-cyan)]">{state.missing}</span> missing on disk
{" · "}
<span className="font-mono text-[var(--color-cyan)]">{state.staleNames}</span> with legacy filename
{" · "}of {state.total} total
</div>
</div>
)}
{state.kind === "done" && (
<div className="flex items-center gap-1.5 text-xs text-[var(--color-mint)] mt-2 flex-wrap">
<CheckCircle2 className="w-3.5 h-3.5" />
<span>Encoded {state.regenerated} · renamed {state.renamed} · skipped {state.skipped}</span>
{state.errors > 0 && <span className="text-[var(--color-coral)]">· {state.errors} error{state.errors === 1 ? "" : "s"}</span>}
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{state.kind === "idle" && (
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
Scan
</button>
)}
{(state.kind === "scanning" || state.kind === "running") && (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-fg-dim)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{state.kind === "scanning" ? "Scanning…" : "Regenerating…"}
</span>
)}
{state.kind === "preview" && (
<>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Cancel
</button>
{(state.missing > 0 || state.staleNames > 0) && (
<button
onClick={() => run(false)}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-40 whitespace-nowrap"
>
<ImageIcon className="w-3.5 h-3.5" /> Fix {state.missing + state.staleNames}
</button>
)}
<button
onClick={() => run(true)}
disabled={pending}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-[var(--color-glass-border-strong)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)] disabled:opacity-40 whitespace-nowrap"
title="Re-encode all thumbnails, replacing existing files. Useful after changing sharp/quality settings."
>
Re-encode all
</button>
</>
)}
{state.kind === "done" && (
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Done
</button>
)}
</div>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useState, useTransition } from "react";
import { FolderTree, Loader2, CheckCircle2 } from "lucide-react";
import { previewReorganize, reorganizeFiles } from "@/app/actions/maintenance";
type State =
| { kind: "idle" }
| { kind: "scanning" }
| { kind: "preview"; total: number; toMove: number }
| { kind: "running" }
| { kind: "done"; moved: number; skipped: number; errors: number };
export function ReorganizeButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [pending, start] = useTransition();
const scan = () => {
setState({ kind: "scanning" });
start(async () => {
const r = await previewReorganize();
setState({ kind: "preview", total: r.total, toMove: r.toMove });
});
};
const run = () => {
if (state.kind !== "preview") return;
if (!confirm(`Move ${state.toMove} file${state.toMove === 1 ? "" : "s"} into letter buckets on disk? This relocates files; cannot be undone.`)) return;
setState({ kind: "running" });
start(async () => {
const r = await reorganizeFiles();
setState({ kind: "done", ...r });
});
};
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">Re-organize Files</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Move covers into letter buckets on disk {" "}
<code className="font-mono">A-E / F-J / K-P / Q-U / V-Z</code> at the top level, single-letter
folders inside, keyed off each cover&apos;s code. Files without a code go to{" "}
<code className="font-mono">#/#/</code>. Attached images bucket with their parent.
</div>
{state.kind === "preview" && (
<div className="text-xs mt-2">
<span className="font-mono text-[var(--color-cyan)]">{state.toMove}</span>
<span className="text-[var(--color-fg-dim)]"> of {state.total} need to move</span>
</div>
)}
{state.kind === "done" && (
<div className="flex items-center gap-1.5 text-xs text-[var(--color-mint)] mt-2">
<CheckCircle2 className="w-3.5 h-3.5" />
Moved {state.moved} · skipped {state.skipped}
{state.errors > 0 && <span className="text-[var(--color-coral)]">· {state.errors} error{state.errors === 1 ? "" : "s"}</span>}
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{state.kind === "idle" && (
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
Scan
</button>
)}
{(state.kind === "scanning" || state.kind === "running") && (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-fg-dim)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{state.kind === "scanning" ? "Scanning…" : "Moving…"}
</span>
)}
{state.kind === "preview" && state.toMove > 0 && (
<>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Cancel
</button>
<button
onClick={run}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-40 whitespace-nowrap"
>
<FolderTree className="w-3.5 h-3.5" /> Re-organize
</button>
</>
)}
{state.kind === "preview" && state.toMove === 0 && (
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Dismiss
</button>
)}
{state.kind === "done" && (
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Done
</button>
)}
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useState, useTransition } from "react";
import { Hash, Loader2, CheckCircle2 } from "lucide-react";
import { previewReparseCodes, reparseCodes, type ReparseCodesPreview } from "@/app/actions/maintenance";
type State =
| { kind: "idle" }
| { kind: "scanning" }
| { kind: "preview"; data: ReparseCodesPreview }
| { kind: "running" }
| { kind: "done"; filled: number; updated: number; skipped: number };
export function ReparseCodesButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [pending, start] = useTransition();
const scan = () => {
setState({ kind: "scanning" });
start(async () => {
const data = await previewReparseCodes();
setState({ kind: "preview", data });
});
};
const run = (force: boolean) => {
if (state.kind !== "preview") return;
const count = force ? state.data.missing + state.data.changed : state.data.missing;
const verb = force ? "Re-parse all (overwrite manual edits)" : "Fill missing only";
if (!confirm(`${verb} for ${count} cover${count === 1 ? "" : "s"}? Files won't move into new letter buckets until you also run Re-organize, and thumbnail filenames won't update until Regenerate Thumbnails runs.`)) return;
setState({ kind: "running" });
start(async () => {
const r = await reparseCodes({ force });
setState({ kind: "done", ...r });
});
};
return (
<div className="py-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium">Re-parse Codes From Filenames</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Re-run the JAV-code parser against every cover&apos;s stored filename. Useful after a parser
change (added <code className="font-mono">z</code>-suffix or alphanumeric prefix support, etc.) so old
rows pick up the new behaviour. Pair with <em>Re-organize</em> + <em>Regenerate Thumbnails</em>
to also move files and rename thumbs.
</div>
{state.kind === "preview" && (
<div className="text-xs mt-2 text-[var(--color-fg-dim)] space-y-0.5">
<div>
<span className="font-mono text-[var(--color-cyan)]">{state.data.missing}</span> missing
{" · "}
<span className="font-mono text-[var(--color-amber,#fbbf24)]">{state.data.changed}</span> would change
{" · "}of {state.data.total} top-level covers
</div>
{state.data.sampleChanges.length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]">
Preview {state.data.sampleChanges.length} sample change{state.data.sampleChanges.length === 1 ? "" : "s"}
</summary>
<div className="mt-1.5 max-h-40 overflow-y-auto rounded-md border border-[var(--color-glass-border)] p-2 space-y-0.5 font-mono text-[11px]">
{state.data.sampleChanges.map((c) => (
<div key={c.id} className="flex items-baseline gap-2">
<span className="text-[var(--color-coral)] line-through">{c.oldCode}</span>
<span className="text-[var(--color-fg-muted)]"></span>
<span className="text-[var(--color-mint)]">{c.newCode}</span>
<span className="text-[var(--color-fg-muted)] truncate">{c.filename}</span>
</div>
))}
</div>
</details>
)}
</div>
)}
{state.kind === "done" && (
<div className="flex items-center gap-1.5 text-xs text-[var(--color-mint)] mt-2 flex-wrap">
<CheckCircle2 className="w-3.5 h-3.5" />
Filled {state.filled} · updated {state.updated} · skipped {state.skipped}
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{state.kind === "idle" && (
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
<Hash className="w-3.5 h-3.5" /> Scan
</button>
)}
{(state.kind === "scanning" || state.kind === "running") && (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-fg-dim)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{state.kind === "scanning" ? "Scanning…" : "Updating…"}
</span>
)}
{state.kind === "preview" && (
<>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Cancel
</button>
{state.data.missing > 0 && (
<button
onClick={() => run(false)}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-40 whitespace-nowrap"
>
Fill {state.data.missing} missing
</button>
)}
{state.data.changed > 0 && (
<button
onClick={() => run(true)}
disabled={pending}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-amber-500/40 text-amber-200 hover:bg-amber-500/10 disabled:opacity-40 whitespace-nowrap"
title="Overwrite codes that disagree with the parser. Will clobber any code you set manually."
>
Force overwrite {state.data.changed}
</button>
)}
</>
)}
{state.kind === "done" && (
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Done
</button>
)}
</div>
</div>
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
Settings as SettingsIcon, X, Palette, Trash2, Wrench, Film, FolderTree, Captions,
} from "lucide-react";
import { useSettingsPanel } from "./SettingsPanelProvider";
import { DefaultSortSelect } from "./DefaultSortSelect";
import { AccentColorPickers } from "./AccentColorPickers";
import { DisplayGroup, TrashGroup, MaintenanceGroup, BackupGroup } from "./SettingsToggles";
import { VideoLibrarySettings } from "./VideoLibrarySettings";
import { WhisperJavSettings } from "./WhisperJavSettings";
import { SubtitleLibraryPaths } from "./SubtitleLibraryPaths";
import { useClickOutside } from "@/lib/hooks/useClickOutside";
import { cn } from "@/lib/utils";
import type { SortKey } from "@/lib/sort";
import type { LibraryStats } from "@/lib/db/queries";
interface PanelData {
defaultSort: SortKey;
stats: LibraryStats;
libraryRoot: string;
dbPath: string;
}
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function fmtDate(ms: number | null): string {
if (!ms) return "—";
const d = new Date(ms);
return d.toISOString().slice(0, 10);
}
export function SettingsPanel({ data }: { data: PanelData }) {
const { open, close } = useSettingsPanel();
const panelRef = useRef<HTMLDivElement>(null);
useClickOutside(panelRef, close, open);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, close]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 backdrop-blur-sm grid place-items-center p-4 sm:p-8"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 65%, transparent)" }}
>
<div
ref={panelRef}
className="w-full max-w-[1400px] h-[min(900px,calc(100vh-4rem))] flex flex-col rounded-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden shadow-2xl"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
<header className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-glass-border)] shrink-0">
<div className="flex items-center gap-2">
<SettingsIcon className="w-5 h-5 text-[var(--color-cyan)]" />
<h2 className="text-xl font-semibold tracking-tight">Settings</h2>
</div>
<button
onClick={close}
aria-label="Close settings"
className="w-8 h-8 grid place-items-center rounded-lg text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
<X className="w-4 h-4" />
</button>
</header>
<SidebarLayout data={data} />
</div>
</div>
);
}
/* =====================================================================
Section content blocks. Five top-level sections:
Appearance · Library · Video · Tools · Info
===================================================================== */
function AppearanceSection({ data }: { data: PanelData }) {
return (
<Card title="Appearance">
<SubGroup label="Colors">
<AccentColorPickers />
</SubGroup>
<Divider />
<SubGroup label="Display">
<DisplayGroup />
</SubGroup>
<Divider />
<SubGroup label="Defaults">
<DefaultSortSelect initial={data.defaultSort} />
</SubGroup>
</Card>
);
}
function LibrarySection() {
return (
<Card title="Library · file handling">
<TrashGroup />
</Card>
);
}
function VideoSection() {
return <Card title="Video"><VideoLibrarySettings /></Card>;
}
function SubtitlesSection() {
return (
<Card title="Subtitles">
<SubtitleLibraryPaths />
<Divider />
<WhisperJavSettings />
</Card>
);
}
function ToolsSection() {
return (
<Card title="Tools">
<SubGroup label="Maintenance">
<MaintenanceGroup />
</SubGroup>
<Divider />
<SubGroup label="Backup">
<BackupGroup />
</SubGroup>
</Card>
);
}
function InfoSection({ data }: { data: PanelData }) {
const s = data.stats;
const watchedPct = s.images > 0 ? Math.round((s.watched / s.images) * 100) : 0;
return (
<Card title="Info">
<div className="grid grid-cols-1 md:grid-cols-2 gap-section">
<StatGroup label="Covers">
<Row label="Top-level" value={s.images.toLocaleString()} mono />
<Row label="Attached (back / stills)" value={s.attached.toLocaleString()} mono />
{s.trashed > 0 && (
<Row label="In trash" value={s.trashed.toLocaleString()} mono />
)}
</StatGroup>
<StatGroup label="Entities">
<Row label="Actresses" value={s.actresses.toLocaleString()} mono />
<Row label="Studios" value={s.studios.toLocaleString()} mono />
<Row label="Series" value={s.series.toLocaleString()} mono />
{s.labels > 0 && <Row label="Labels" value={s.labels.toLocaleString()} mono />}
<Row label="Genres" value={s.genres.toLocaleString()} mono />
</StatGroup>
<StatGroup label="Tagging">
<Row label="Tags" value={s.tags.toLocaleString()} mono />
<Row label="Tag categories" value={s.tagCategories.toLocaleString()} mono />
<Row label="Collections" value={s.collections.toLocaleString()} mono />
</StatGroup>
<StatGroup label="State">
<Row label="Watched" value={`${s.watched.toLocaleString()} (${watchedPct}%)`} mono />
<Row label="VIP" value={s.vip.toLocaleString()} mono />
<Row label="Favorite" value={s.favorite.toLocaleString()} mono />
<Row label="Owned" value={s.owned.toLocaleString()} mono />
<Row label="Rated" value={s.rated.toLocaleString()} mono />
</StatGroup>
</div>
<Divider />
<SubGroup label="Disk">
<dl className="space-y-1.5 text-sm">
<Row label="Total cover bytes" value={fmtBytes(s.totalBytes)} mono />
<Row
label="Imports"
value={
s.earliestImportedAt && s.latestImportedAt
? `${fmtDate(s.earliestImportedAt)}${fmtDate(s.latestImportedAt)}`
: "—"
}
mono
/>
</dl>
</SubGroup>
<Divider />
<SubGroup label="Paths">
<dl className="space-y-1.5 text-sm">
<Row label="Library folder" value={data.libraryRoot} mono />
<Row label="Database" value={data.dbPath} mono />
</dl>
</SubGroup>
</Card>
);
}
/* =====================================================================
Sidebar layout — single layout, no toggle.
===================================================================== */
const SIDEBAR_NAV = [
{ id: "appearance", label: "Appearance", Icon: Palette },
{ id: "library", label: "Library", Icon: Trash2 },
{ id: "video", label: "Video", Icon: Film },
{ id: "subtitles", label: "Subtitles", Icon: Captions },
{ id: "tools", label: "Tools", Icon: Wrench },
{ id: "info", label: "Info", Icon: FolderTree },
] as const;
type SidebarSection = typeof SIDEBAR_NAV[number]["id"];
function SidebarLayout({ data }: { data: PanelData }) {
const [active, setActive] = useState<SidebarSection>("appearance");
const content: Record<SidebarSection, React.ReactNode> = {
appearance: <AppearanceSection data={data} />,
library: <LibrarySection />,
video: <VideoSection />,
subtitles: <SubtitlesSection />,
tools: <ToolsSection />,
info: <InfoSection data={data} />,
};
return (
<div className="flex-1 grid grid-cols-[220px_1fr] min-h-0">
<nav className="border-r border-[var(--color-glass-border)] p-3 overflow-y-auto"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 50%, transparent)" }}>
{SIDEBAR_NAV.map(({ id, label, Icon }) => {
const isActive = id === active;
return (
<button
key={id}
type="button"
onClick={() => setActive(id)}
className={cn(
"w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors mb-0.5",
isActive
? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)]"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="truncate">{label}</span>
</button>
);
})}
</nav>
<div className="overflow-y-auto p-card">
{content[active]}
</div>
</div>
);
}
/* =====================================================================
Tiny primitives.
===================================================================== */
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="glass rounded-2xl p-card">
<h3 className="text-sm uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-label">
{title}
</h3>
{children}
</section>
);
}
/** Sub-group label inside a Card — small cyan caps header. */
function SubGroup({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)] mb-label">{label}</div>
{children}
</div>
);
}
/** Horizontal rule between sub-groups inside a Card. */
function Divider() {
return <hr className="my-section border-0 border-t border-[var(--color-glass-border)]" />;
}
function StatGroup({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)] mb-label">{label}</div>
<dl className="space-y-1.5 text-sm border-t border-[var(--color-glass-border)] pt-1.5">
{children}
</dl>
</div>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-start justify-between gap-4">
<dt className="text-[var(--color-fg-dim)]">{label}</dt>
<dd className={`text-right break-all ${mono ? "font-mono text-xs text-[var(--color-fg)]" : ""}`}>{value}</dd>
</div>
);
}
@@ -0,0 +1,20 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
type Ctx = { open: boolean; toggle: () => void; close: () => void };
const C = createContext<Ctx | null>(null);
export function SettingsPanelProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const toggle = useCallback(() => setOpen((o) => !o), []);
const close = useCallback(() => setOpen(false), []);
const value = useMemo<Ctx>(() => ({ open, toggle, close }), [open, toggle, close]);
return <C.Provider value={value}>{children}</C.Provider>;
}
export function useSettingsPanel() {
const ctx = useContext(C);
if (!ctx) throw new Error("useSettingsPanel must be used within SettingsPanelProvider");
return ctx;
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { AppSettings } from "@/lib/db/appSettings";
import { setBoolSetting, setNumberSetting, setColorSetting, setPaginationMode } from "@/app/actions/settings";
type BoolKey = "fadeTransitions" | "purgeFilesOnDelete" | "useRecycleBin";
type NumKey = "fadeDurationMs" | "trashRetentionDays" | "gridColumns" | "gridColumnsPortrait" | "supersededRetentionDays" | "coverPageSize";
type ColorKey = "accentPrimary" | "accentSecondary";
type PaginationModeKey = "paginationMode";
interface Ctx {
settings: AppSettings;
set(key: BoolKey, value: boolean): void;
set(key: NumKey, value: number): void;
set(key: ColorKey, value: string): void;
set(key: PaginationModeKey, value: AppSettings["paginationMode"]): void;
}
const SettingsCtx = createContext<Ctx | null>(null);
const NUM_KEYS = new Set<string>(["fadeDurationMs", "trashRetentionDays", "gridColumns", "gridColumnsPortrait", "supersededRetentionDays", "coverPageSize"]);
const COLOR_KEYS = new Set<string>(["accentPrimary", "accentSecondary"]);
const PAGINATION_MODE_KEYS = new Set<string>(["paginationMode"]);
const ACCENT_VARS: Record<ColorKey, [string, string]> = {
accentPrimary: ["--color-cyan", "--color-cyan-glow"],
accentSecondary: ["--color-violet", "--color-violet-glow"],
};
export function SettingsProvider({
initial,
children,
}: {
initial: AppSettings;
children: React.ReactNode;
}) {
const [settings, setSettings] = useState<AppSettings>(initial);
// The server is the source of truth, but the parent layout passes the latest
// server values on every render. Keep our state in sync after server mutations.
useEffect(() => {
setSettings(initial);
}, [initial]);
useEffect(() => {
document.documentElement.dataset.fade = settings.fadeTransitions ? "on" : "off";
document.documentElement.style.setProperty("--fade-duration", `${settings.fadeDurationMs}ms`);
}, [settings.fadeTransitions, settings.fadeDurationMs]);
useEffect(() => {
const n = Math.max(2, Math.min(4, settings.gridColumns || 3));
document.documentElement.style.setProperty("--grid-cols", String(n));
}, [settings.gridColumns]);
useEffect(() => {
const n = Math.max(4, Math.min(10, settings.gridColumnsPortrait || 6));
document.documentElement.style.setProperty("--grid-cols-portrait", String(n));
}, [settings.gridColumnsPortrait]);
useEffect(() => {
const root = document.documentElement;
for (const [key, [base, glow]] of Object.entries(ACCENT_VARS) as [ColorKey, [string, string]][]) {
const value = settings[key];
if (value) {
root.style.setProperty(base, value);
root.style.setProperty(glow, value);
} else {
root.style.removeProperty(base);
root.style.removeProperty(glow);
}
}
}, [settings.accentPrimary, settings.accentSecondary]);
const set = useCallback((key: BoolKey | NumKey | ColorKey | PaginationModeKey, value: boolean | number | string) => {
setSettings((cur) => ({ ...cur, [key]: value }));
if (NUM_KEYS.has(key)) {
void setNumberSetting(key as NumKey, value as number);
} else if (COLOR_KEYS.has(key)) {
void setColorSetting(key as ColorKey, value as string);
} else if (PAGINATION_MODE_KEYS.has(key)) {
void setPaginationMode(value as AppSettings["paginationMode"]);
} else {
void setBoolSetting(key as BoolKey, value as boolean);
}
}, []) as Ctx["set"];
const value = useMemo<Ctx>(() => ({ settings, set }), [settings, set]);
return <SettingsCtx.Provider value={value}>{children}</SettingsCtx.Provider>;
}
export function useSettings() {
const ctx = useContext(SettingsCtx);
if (!ctx) throw new Error("useSettings must be used within SettingsProvider");
return ctx;
}
+297
View File
@@ -0,0 +1,297 @@
"use client";
import { useSettings } from "./SettingsProvider";
import { PurgeOrphansButton } from "./PurgeOrphansButton";
import { ReorganizeButton } from "./ReorganizeButton";
import { RegenThumbnailsButton } from "./RegenThumbnailsButton";
import { UndersizedCoversButton } from "./UndersizedCoversButton";
import { NearDupesButton } from "./NearDupesButton";
import { ReparseCodesButton } from "./ReparseCodesButton";
import { ClearCacheButton } from "./ClearCacheButton";
import { BackupButtons } from "./BackupButtons";
import { cn } from "@/lib/utils";
/** Display group: grid columns + fade transitions. */
export function DisplayGroup() {
const { settings, set } = useSettings();
return (
<div className="space-y-1">
<SliderRow
label="Grid Columns (Landscape)"
description="Number of cover columns shown when the library is in L (landscape / full cover) view."
min={2}
max={4}
step={1}
value={settings.gridColumns}
onChange={(v) => set("gridColumns", v)}
format={(v) => `${v} per row`}
/>
<SliderRow
label="Grid Columns (Portrait)"
description="Number of cover columns shown when the library is in P (portrait / front-only) view."
min={4}
max={10}
step={1}
value={settings.gridColumnsPortrait}
onChange={(v) => set("gridColumnsPortrait", v)}
format={(v) => `${v} per row`}
/>
<SliderRow
label="Items Per Page"
description="Cover grid page size. Pagination + infinite scroll fetch this many at a time."
min={25}
max={500}
step={25}
value={settings.coverPageSize}
onChange={(v) => set("coverPageSize", v)}
format={(v) => `${v} per page`}
/>
<SegmentedRow
label="Pagination Behavior"
description={
settings.paginationMode === "url"
? "Prev / Next / Jump always pushes a new URL and remounts the grid. Predictable; small flash on each click."
: "Prev / Next scrolls within the loaded buffer. Forward jumps prefetch missing pages on the fly. Backward across the SSR anchor falls back to URL nav."
}
value={settings.paginationMode}
options={[
{ value: "url", label: "URL" },
{ value: "scroll", label: "Scroll" },
]}
onChange={(v) => set("paginationMode", v as "url" | "scroll")}
/>
<ToggleRow
label="Fade Transitions"
description="Fade in pages and image details when navigating."
value={settings.fadeTransitions}
onChange={(v) => set("fadeTransitions", v)}
/>
{settings.fadeTransitions && (
<SliderRow
label="Fade Duration"
description="How long the fade-in animation takes."
min={100}
max={2000}
step={50}
value={settings.fadeDurationMs}
onChange={(v) => set("fadeDurationMs", v)}
format={(v) => (v >= 1000 ? `${(v / 1000).toFixed(2)}s` : `${v}ms`)}
/>
)}
</div>
);
}
/** Trash & deletion group. */
export function TrashGroup() {
const { settings, set } = useSettings();
return (
<div className="space-y-1">
<ToggleRow
label="Use Recycle Bin"
description="Send deletes to the recycle bin instead of removing immediately. Off makes every delete permanent."
value={settings.useRecycleBin}
onChange={(v) => set("useRecycleBin", v)}
/>
{settings.useRecycleBin && (
<SliderRow
label="Trash Retention"
description="Automatically purge trashed images older than this. 0 keeps them forever."
min={0}
max={365}
step={1}
value={settings.trashRetentionDays}
onChange={(v) => set("trashRetentionDays", v)}
format={(v) => v === 0 ? "Forever" : `${v} day${v === 1 ? "" : "s"}`}
/>
)}
<ToggleRow
label="Delete Files From Disk When Emptying Trash"
description="When permanently removing an image (or emptying the bin), also delete the file and thumbnail from disk. Off keeps files on disk."
value={settings.purgeFilesOnDelete}
onChange={(v) => set("purgeFilesOnDelete", v)}
/>
<SliderRow
label="Superseded Retention"
description={`When you replace a cover via the collision dialog, the old file is moved to library/.superseded/ as a recovery snapshot. Files older than this are auto-purged on each app start. 0 = keep forever.`}
min={0}
max={365}
step={1}
value={settings.supersededRetentionDays}
onChange={(v) => set("supersededRetentionDays", v)}
format={(v) => v === 0 ? "Forever" : `${v} day${v === 1 ? "" : "s"}`}
/>
</div>
);
}
/** Maintenance buttons group. */
export function MaintenanceGroup() {
return (
<div className="divide-y divide-[var(--color-glass-border)]">
<PurgeOrphansButton />
<ReparseCodesButton />
<ReorganizeButton />
<RegenThumbnailsButton />
<UndersizedCoversButton />
<NearDupesButton />
<ClearCacheButton />
</div>
);
}
/** Backup group. */
export function BackupGroup() {
return (
<div className="space-y-1">
<BackupButtons />
</div>
);
}
/** Legacy combined view — preserved for any caller still rendering all
* groups inline. The new SettingsPanel layouts use the individual
* exports above. */
export function SettingsToggles() {
return (
<div className="space-y-5">
<Group title="Display"><DisplayGroup /></Group>
<Group title="Deletion & trash"><TrashGroup /></Group>
<Group title="Maintenance"><MaintenanceGroup /></Group>
<Group title="Backup"><BackupGroup /></Group>
</div>
);
}
function Group({ 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="border-t border-[var(--color-glass-border)] pt-1">{children}</div>
</div>
);
}
function SliderRow({
label, description, min, max, step, value, onChange, format,
}: {
label: string;
description?: string;
min: number;
max: number;
step: number;
value: number;
onChange: (v: number) => void;
format?: (v: number) => string;
}) {
return (
<div className="py-2">
<div className="flex items-start justify-between gap-4 mb-2">
<div>
<div className="text-sm font-medium">{label}</div>
{description && (
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">{description}</div>
)}
</div>
<span className="text-xs font-mono text-[var(--color-cyan)] tabular-nums whitespace-nowrap">
{format ? format(value) : value}
</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-[var(--color-cyan)]"
/>
</div>
);
}
function SegmentedRow<T extends string>({
label,
description,
value,
options,
onChange,
}: {
label: string;
description?: string;
value: T;
options: ReadonlyArray<{ value: T; label: string }>;
onChange: (v: T) => void;
}) {
return (
<div className="py-2">
<div className="flex items-start justify-between gap-4 mb-2">
<div className="min-w-0">
<div className="text-sm font-medium">{label}</div>
{description && (
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">{description}</div>
)}
</div>
<div className="flex flex-shrink-0 rounded-lg border border-[var(--color-glass-border-strong)] overflow-hidden">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"min-w-[64px] px-3 py-1 text-xs font-mono transition-colors text-center",
value === opt.value
? "bg-[var(--color-cyan)]/20 text-[var(--color-cyan)]"
: "bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]",
)}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
);
}
function ToggleRow({
label,
description,
value,
onChange,
}: {
label: string;
description?: string;
value: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{label}</div>
{description && (
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">{description}</div>
)}
</div>
<button
role="switch"
aria-checked={value}
onClick={() => onChange(!value)}
className={cn(
"relative w-11 h-6 rounded-full border transition-colors flex-shrink-0",
value
? "bg-[var(--color-cyan)]/30 border-[var(--color-cyan)]"
: "bg-[var(--color-glass)] border-[var(--color-glass-border-strong)]"
)}
>
<span
className={cn(
"absolute top-0.5 w-4 h-4 rounded-full transition-all",
value
? "left-[22px] bg-[var(--color-cyan)] shadow-[var(--shadow-glow-cyan)]"
: "left-0.5 bg-[var(--color-fg-dim)]"
)}
/>
</button>
</div>
);
}
@@ -0,0 +1,242 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Captions, Folder, FolderOpen, Loader2, Plus, Save, Trash2 } from "lucide-react";
import { setSubtitleExtraPaths, setSubtitleCacheLimitMb } from "@/app/actions/settings";
import { useSettings } from "./SettingsProvider";
/**
* Persistent recursive-scan folders for subtitle sidecars. The player
* always finds same-folder sidecars; configure these only if subtitles
* live in a separate location from videos.
*/
export function SubtitleLibraryPaths() {
const { settings } = useSettings();
const router = useRouter();
const [, start] = useTransition();
const [draftSubExtras, setDraftSubExtras] = useState<string[]>(settings.subtitleExtraPaths ?? []);
const [newSubExtra, setNewSubExtra] = useState("");
const [saving, setSaving] = useState(false);
const [picking, setPicking] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setDraftSubExtras(settings.subtitleExtraPaths ?? []);
}, [settings.subtitleExtraPaths]);
async function pickFolder(startPath: string): Promise<string | null> {
try {
const r = await fetch("/api/pick-folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ start: startPath }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error ?? `picker failed (${r.status})`);
return typeof j.path === "string" && j.path ? j.path : null;
} catch (e) {
setError((e as Error).message);
return null;
}
}
const dirty =
draftSubExtras.length !== (settings.subtitleExtraPaths ?? []).length ||
draftSubExtras.some((v, i) => v !== (settings.subtitleExtraPaths ?? [])[i]);
function addSubExtra() {
const v = newSubExtra.trim();
if (!v || draftSubExtras.includes(v)) return;
setDraftSubExtras((cur) => [...cur, v]);
setNewSubExtra("");
}
function removeSubExtra(idx: number) {
setDraftSubExtras((cur) => cur.filter((_, i) => i !== idx));
}
function updateSubExtra(idx: number, value: string) {
setDraftSubExtras((cur) => cur.map((v, i) => (i === idx ? value : v)));
}
async function browseExtra(idx: number) {
setPicking(`extra-${idx}`);
try {
const p = await pickFolder(draftSubExtras[idx] ?? "");
if (p) updateSubExtra(idx, p);
} finally {
setPicking(null);
}
}
async function browseNewExtra() {
setPicking("new");
try {
const p = await pickFolder(newSubExtra);
if (p) {
if (!draftSubExtras.includes(p)) {
setDraftSubExtras((cur) => [...cur, p]);
setNewSubExtra("");
} else {
setNewSubExtra(p);
}
}
} finally {
setPicking(null);
}
}
async function save() {
setSaving(true);
setError(null);
try {
await setSubtitleExtraPaths(draftSubExtras);
start(() => router.refresh());
} catch (e) {
setError((e as Error).message);
} finally {
setSaving(false);
}
}
return (
<div>
<div className="text-sm font-medium mb-1 flex items-center gap-1.5">
<Captions className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
Subtitle Library Folders
</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2">
Recursively scanned for subtitle sidecars matching the playing video&apos;s stem or code (depth 3).
The player always finds same-folder sidecars; configure these only if you keep subtitles in a separate location.
</div>
<div className="space-y-1.5">
{draftSubExtras.length === 0 ? (
<div className="text-xs text-[var(--color-fg-muted)] italic px-3 py-2 rounded-lg border border-dashed border-[var(--color-glass-border)]">
No subtitle folders.
</div>
) : (
draftSubExtras.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={p}
onChange={(e) => updateSubExtra(i, e.target.value)}
className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
</div>
<button
type="button"
onClick={() => browseExtra(i)}
disabled={picking !== null}
title="Browse for folder"
className="p-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === `extra-${i}` ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => removeSubExtra(i)}
title="Remove"
className="p-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))
)}
</div>
<div className="flex items-center gap-2 mt-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={newSubExtra}
onChange={(e) => setNewSubExtra(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addSubExtra(); } }}
placeholder="D:\\Subtitles"
className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
</div>
<button
type="button"
onClick={browseNewExtra}
disabled={picking !== null}
title="Browse for folder"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === "new" ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
Browse
</button>
<button
type="button"
onClick={addSubExtra}
disabled={!newSubExtra.trim()}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
<Plus className="w-4 h-4" /> Add
</button>
<button
type="button"
onClick={save}
disabled={!dirty || saving}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save
</button>
</div>
{error && (
<div className="mt-2 text-xs text-red-300 bg-red-500/5 border border-red-500/25 rounded-lg px-3 py-2">
{error}
</div>
)}
<SubtitleCacheLimit />
</div>
);
}
function SubtitleCacheLimit() {
const { settings } = useSettings();
const router = useRouter();
const [, start] = useTransition();
const [draft, setDraft] = useState<number>(settings.subtitleCacheLimitMb ?? 100);
const [saving, setSaving] = useState(false);
useEffect(() => { setDraft(settings.subtitleCacheLimitMb ?? 100); }, [settings.subtitleCacheLimitMb]);
const dirty = draft !== (settings.subtitleCacheLimitMb ?? 100);
async function save() {
setSaving(true);
try {
await setSubtitleCacheLimitMb(Math.max(0, Math.floor(draft)));
start(() => router.refresh());
} finally {
setSaving(false);
}
}
return (
<div className="mt-section pt-section border-t border-[var(--color-glass-border)]">
<div className="text-sm font-medium mb-1">Subtitle Cache Size Limit</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2 max-w-2xl">
Soft cap on <span className="font-mono">data/subtitle-cache/</span> (converted WebVTT files).
When exceeded, oldest entries get evicted until size drops below 80% of the cap.{" "}
<span className="font-mono">0</span> = unlimited.
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
value={draft}
onChange={(e) => setDraft(Math.max(0, Number(e.target.value) || 0))}
className="w-28 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
<span className="text-xs text-[var(--color-fg-dim)]">MB</span>
<button
type="button"
onClick={save}
disabled={!dirty || saving}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save
</button>
</div>
</div>
);
}
@@ -0,0 +1,132 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { Ruler, Loader2, AlertTriangle, ExternalLink } from "lucide-react";
import { scanUndersizedCovers, type UndersizedCover } from "@/app/actions/maintenance";
import { thumbUrl } from "@/lib/assetUrls";
import { useSettingsPanel } from "./SettingsPanelProvider";
type State =
| { kind: "idle" }
| { kind: "scanning" }
| { kind: "result"; rows: UndersizedCover[] };
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
export function UndersizedCoversButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [pending, start] = useTransition();
const { close: closeSettings } = useSettingsPanel();
const scan = () => {
setState({ kind: "scanning" });
start(async () => {
const rows = await scanUndersizedCovers();
setState({ kind: "result", rows });
});
};
return (
<div className="py-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium">Find Undersized Covers</div>
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
Scan top-level covers smaller than standard JAV size (default
floor is <code className="font-mono">750×500</code>; real covers are
usually <code className="font-mono">800×538</code>). Catches
thumbnails or web previews accidentally imported as covers.
</div>
{state.kind === "result" && state.rows.length === 0 && (
<div className="text-xs text-[var(--color-mint)] mt-2">
No undersized covers all top-level covers meet the size threshold.
</div>
)}
{state.kind === "result" && state.rows.length > 0 && (
<div className="text-xs text-[var(--color-coral)] mt-2 flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" />
{state.rows.length} undersized cover{state.rows.length === 1 ? "" : "s"} found.
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{state.kind === "idle" && (
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
<Ruler className="w-3.5 h-3.5" /> Scan
</button>
)}
{state.kind === "scanning" && (
<span className="flex items-center gap-1.5 text-xs text-[var(--color-fg-dim)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Scanning
</span>
)}
{state.kind === "result" && (
<>
<button
onClick={scan}
disabled={pending}
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
>
Re-scan
</button>
<button
onClick={() => setState({ kind: "idle" })}
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
Dismiss
</button>
</>
)}
</div>
</div>
{state.kind === "result" && state.rows.length > 0 && (
<div className="mt-3 max-h-72 overflow-y-auto rounded-md border border-[var(--color-glass-border)] bg-[var(--color-bg-1)]/40">
{state.rows.map((r) => (
<Link
key={r.id}
href={`/image/${r.id}`}
onClick={closeSettings}
className="flex items-center gap-3 p-2 border-b border-[var(--color-glass-border)] last:border-b-0 hover:bg-[var(--color-glass)] transition-colors"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={thumbUrl({ thumbPath: r.thumbPath, code: r.code, id: r.id })}
alt=""
className="w-12 h-12 object-contain bg-black/40 rounded shrink-0"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs">
{r.code ? (
<span className="font-mono font-bold text-[var(--color-cyan)]">{r.code}</span>
) : (
<span className="font-mono text-[var(--color-fg-muted)] italic">no code</span>
)}
<span className="font-mono text-[var(--color-coral)] tabular-nums">
{r.width}×{r.height}
</span>
<span className="font-mono text-[var(--color-fg-muted)] tabular-nums">
{fmtBytes(r.bytes)}
</span>
</div>
<div className="text-[11px] text-[var(--color-fg-dim)] truncate font-mono mt-0.5">
{r.filename}
</div>
</div>
<ExternalLink className="w-3.5 h-3.5 text-[var(--color-fg-muted)] shrink-0" />
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,384 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Folder, Loader2, RefreshCw, Save, Plus, Trash2, FolderOpen, Cpu } from "lucide-react";
import {
setVideoLibraryPath,
setVideoExtraPaths,
setTranscodeMode,
} from "@/app/actions/settings";
import { useSettings } from "./SettingsProvider";
import { PartSuffixPatterns } from "./PartSuffixPatterns";
import { dispatchVideoStatusRefresh } from "@/components/video/videoStatusEvents";
import { cn } from "@/lib/utils";
interface ScanResult {
count: number;
codes: number;
rootsScanned: string[];
elapsedMs: number;
}
export function VideoLibrarySettings() {
const { settings } = useSettings();
const [, startTranscode] = useTransition();
const [transcodeMode, setLocalTranscodeMode] = useState(settings.transcodeMode);
// Stay in sync if the server-side value changes (e.g. user edits in
// another tab and we router.refresh).
useEffect(() => { setLocalTranscodeMode(settings.transcodeMode); }, [settings.transcodeMode]);
const router = useRouter();
const [draftMain, setDraftMain] = useState(settings.videoLibraryPath);
const [draftExtras, setDraftExtras] = useState<string[]>(settings.videoExtraPaths ?? []);
const [newExtra, setNewExtra] = useState("");
const [saving, setSaving] = useState(false);
const [rescanning, setRescanning] = useState(false);
const [result, setResult] = useState<ScanResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [, start] = useTransition();
const [picking, setPicking] = useState<string | null>(null);
async function pickFolder(start: string): Promise<string | null> {
try {
const r = await fetch("/api/pick-folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ start }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error ?? `picker failed (${r.status})`);
return typeof j.path === "string" && j.path ? j.path : null;
} catch (e) {
setError((e as Error).message);
return null;
}
}
async function browseMain() {
setPicking("main");
try {
const p = await pickFolder(draftMain);
if (p) setDraftMain(p);
} finally {
setPicking(null);
}
}
async function browseExtra(idx: number) {
setPicking(`extra-${idx}`);
try {
const p = await pickFolder(draftExtras[idx] ?? "");
if (p) updateExtra(idx, p);
} finally {
setPicking(null);
}
}
async function browseNewExtra() {
setPicking("new");
try {
const p = await pickFolder(newExtra);
if (p) {
if (!draftExtras.includes(p)) {
setDraftExtras((cur) => [...cur, p]);
setNewExtra("");
} else {
setNewExtra(p);
}
}
} finally {
setPicking(null);
}
}
const mainDirty = draftMain.trim() !== (settings.videoLibraryPath ?? "");
const extrasDirty =
draftExtras.length !== (settings.videoExtraPaths ?? []).length ||
draftExtras.some((v, i) => v !== (settings.videoExtraPaths ?? [])[i]);
const dirty = mainDirty || extrasDirty;
const hasAnyConfigured = !!settings.videoLibraryPath || (settings.videoExtraPaths ?? []).length > 0;
function addExtra() {
const v = newExtra.trim();
if (!v) return;
if (draftExtras.includes(v)) return;
setDraftExtras((cur) => [...cur, v]);
setNewExtra("");
}
function removeExtra(idx: number) {
setDraftExtras((cur) => cur.filter((_, i) => i !== idx));
}
function updateExtra(idx: number, value: string) {
setDraftExtras((cur) => cur.map((v, i) => i === idx ? value : v));
}
async function save() {
setSaving(true);
setError(null);
try {
if (mainDirty) await setVideoLibraryPath(draftMain);
if (extrasDirty) await setVideoExtraPaths(draftExtras);
const r = await fetch("/api/video-rescan", { method: "POST" });
const j = await r.json();
if (!r.ok) throw new Error(j.error ?? `rescan failed (${r.status})`);
setResult(j);
dispatchVideoStatusRefresh();
start(() => router.refresh());
} catch (e) {
setError((e as Error).message);
} finally {
setSaving(false);
}
}
async function rescan(opts: { force?: boolean } = {}) {
setRescanning(true);
setError(null);
try {
const url = opts.force ? "/api/video-rescan?force=1" : "/api/video-rescan";
const r = await fetch(url, { method: "POST" });
const j = await r.json();
if (!r.ok) throw new Error(j.error ?? `rescan failed (${r.status})`);
setResult(j);
dispatchVideoStatusRefresh();
start(() => router.refresh());
} catch (e) {
setError((e as Error).message);
} finally {
setRescanning(false);
}
}
return (
<div className="py-2 space-y-5">
{/* Main folder ---------------------------------------------------- */}
<div>
<div className="text-sm font-medium mb-1">Main Library Folder</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2">
Recursive scan. Expected to follow the same letter-bucket layout as the cover library, e.g.{" "}
<span className="font-mono">D:\JAV\A-E\A\AOZ-200Z.mp4</span>.
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={draftMain}
onChange={(e) => setDraftMain(e.target.value)}
placeholder="D:\JAV"
className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
</div>
<button
type="button"
onClick={browseMain}
disabled={picking !== null}
title="Browse for folder"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === "main" ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
Browse
</button>
</div>
</div>
{/* Additional folders --------------------------------------------- */}
<div>
<div className="text-sm font-medium mb-1">Additional Folders</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2">
Flat folders where videos sit directly inside (e.g. <span className="font-mono">E:\JAV\IBW-203.mp4</span>). Subfolders are still walked. One absolute path per row.
</div>
<div className="space-y-1.5">
{draftExtras.length === 0 ? (
<div className="text-xs text-[var(--color-fg-muted)] italic px-3 py-2 rounded-lg border border-dashed border-[var(--color-glass-border)]">
No additional folders.
</div>
) : (
draftExtras.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={p}
onChange={(e) => updateExtra(i, e.target.value)}
className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
</div>
<button
type="button"
onClick={() => browseExtra(i)}
disabled={picking !== null}
title="Browse for folder"
className="p-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === `extra-${i}` ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => removeExtra(i)}
title="Remove"
className="p-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))
)}
</div>
<div className="flex items-center gap-2 mt-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={newExtra}
onChange={(e) => setNewExtra(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addExtra(); } }}
placeholder="E:\JAV"
className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
</div>
<button
type="button"
onClick={browseNewExtra}
disabled={picking !== null}
title="Browse for folder"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === "new" ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
Browse
</button>
<button
type="button"
onClick={addExtra}
disabled={!newExtra.trim()}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
<Plus className="w-4 h-4" /> Add
</button>
</div>
</div>
{/* Action row ----------------------------------------------------- */}
<div className="flex items-center gap-2 pt-3 border-t border-[var(--color-glass-border)]">
<button
type="button"
onClick={save}
disabled={!dirty || saving}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save &amp; Rescan
</button>
<button
type="button"
onClick={() => rescan()}
disabled={rescanning || saving || !hasAnyConfigured}
title={hasAnyConfigured ? "Incremental — only re-walk folders whose mtime changed" : "Configure a folder first"}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{rescanning ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Re-scan Only
</button>
<button
type="button"
onClick={() => rescan({ force: true })}
disabled={rescanning || saving || !hasAnyConfigured}
title={hasAnyConfigured ? "Force full rescan — bypass dir-mtime cache (use after content edits without rename)" : "Configure a folder first"}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-amber-400/30 text-amber-200 hover:bg-amber-400/10 disabled:opacity-40"
>
{rescanning ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Force Full
</button>
</div>
{result && (
<div className="text-xs text-[var(--color-mint)] bg-[var(--color-mint)]/5 border border-[var(--color-mint)]/25 rounded-lg px-3 py-2 space-y-0.5">
<div>
Scanned in {result.elapsedMs}ms. <strong>{result.count}</strong> file{result.count === 1 ? "" : "s"} matched, <strong>{result.codes}</strong> unique code{result.codes === 1 ? "" : "s"}.
</div>
{result.rootsScanned.length > 0 && (
<div className="text-[10px] font-mono text-[var(--color-fg-muted)]">
roots: {result.rootsScanned.join(" · ")}
</div>
)}
</div>
)}
{error && (
<div className="text-xs text-red-300 bg-red-500/5 border border-red-500/25 rounded-lg px-3 py-2">
{error}
</div>
)}
{/* Playback transcode mode --------------------------------------- */}
<div className="pt-3 border-t border-[var(--color-glass-border)] space-y-3">
<div className="space-y-1">
<div className="text-sm font-medium flex items-center gap-1.5">
<Cpu className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
Playback Transcoding (NVENC)
</div>
<div className="text-xs text-[var(--color-fg-muted)] max-w-2xl">
Live HLS re-encode with NVENC, dropping B-frames to bypass Chromium&apos;s
H.264 sink reorder bug. Requires NVIDIA GPU + ffmpeg with
<span className="font-mono"> h264_nvenc</span>. Auto modes detect
whether transcoding is needed per file.
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{([
{
value: "off",
title: "Off",
desc: "Always serve the original file. Stuttery H.264 files will stutter.",
},
{
value: "always",
title: "Always Transcode",
desc: "Every file goes through NVENC HLS. Bullet-proof, slight quality loss.",
},
{
value: "auto-predicate",
title: "Auto · Predicate",
desc: "Probe codec on first play. Transcode only H.264 files with B-frames.",
},
{
value: "auto-runtime",
title: "Auto · Runtime",
desc: "Measure dropped frames in a brief pre-roll, decide and remember per file.",
},
] as const).map((opt) => {
const active = transcodeMode === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => {
setLocalTranscodeMode(opt.value);
startTranscode(() => {
void setTranscodeMode(opt.value);
});
}}
className={cn(
"text-left rounded-lg border px-3 py-2 transition-colors",
active
? "border-[var(--color-cyan)] bg-[var(--color-cyan)]/10 shadow-[var(--shadow-glow-cyan)]"
: "border-[var(--color-glass-border)] bg-[var(--color-glass)]/30 hover:border-[var(--color-glass-border-strong)]",
)}
>
<div className={cn(
"text-sm font-medium",
active ? "text-[var(--color-cyan)]" : "text-[var(--color-fg)]",
)}>{opt.title}</div>
<div className="text-[11px] text-[var(--color-fg-muted)] mt-0.5 leading-snug">{opt.desc}</div>
</button>
);
})}
</div>
</div>
{/* Part suffix patterns ------------------------------------------ */}
<PartSuffixPatterns />
</div>
);
}
+346
View File
@@ -0,0 +1,346 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Captions, Loader2, Save, FolderOpen, CheckCircle2, AlertCircle, RefreshCw, Trash2, Sparkles } from "lucide-react";
import type { WhisperJavSettings as WhisperJavSettingsT } from "@/lib/db/appSettings";
import { setWhisperJavSettings } from "@/app/actions/settings";
import { useSettings } from "./SettingsProvider";
import { useSettingsPanel } from "./SettingsPanelProvider";
import { cn } from "@/lib/utils";
type Quality = WhisperJavSettingsT["quality"];
type SourceLang = WhisperJavSettingsT["sourceLanguage"];
type OutputMode = WhisperJavSettingsT["outputMode"];
type Sensitivity = WhisperJavSettingsT["sensitivity"];
type Location = WhisperJavSettingsT["outputLocation"];
interface VerifyResult {
ok: boolean;
version?: string;
resolvedPath?: string;
error?: string;
}
export function WhisperJavSettings() {
const { settings } = useSettings();
const w = settings.whisperjav;
const router = useRouter();
const { close: closeSettings } = useSettingsPanel();
const [, start] = useTransition();
const [draft, setDraft] = useState<WhisperJavSettingsT>(w);
const [saving, setSaving] = useState(false);
const [verifying, setVerifying] = useState(false);
const [verify, setVerify] = useState<VerifyResult | null>(null);
const [autodetected, setAutodetected] = useState(false);
useEffect(() => { setDraft(w); }, [w]);
// First-open autodetect — only when cliPath is empty AND no draft change.
useEffect(() => {
if (autodetected) return;
if (draft.cliPath) return;
setAutodetected(true);
void (async () => {
try {
const r = await fetch("/api/whisperjav-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ autodetect: true }),
});
const data = (await r.json()) as VerifyResult;
if (data.ok && data.resolvedPath) {
setDraft((cur) => (cur.cliPath ? cur : { ...cur, cliPath: data.resolvedPath! }));
setVerify(data);
}
} catch { /* no autodetect available */ }
})();
}, [autodetected, draft.cliPath]);
const dirty = JSON.stringify(draft) !== JSON.stringify(w);
async function browse() {
try {
const r = await fetch("/api/pick-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ purpose: "whisperjav" }),
});
const data = await r.json();
if (data.path) setDraft((cur) => ({ ...cur, cliPath: data.path }));
} catch { /* ignore */ }
}
async function runVerify() {
setVerifying(true);
setVerify(null);
try {
const r = await fetch("/api/whisperjav-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: draft.cliPath }),
});
const data = (await r.json()) as VerifyResult;
setVerify(data);
} catch (e) {
setVerify({ ok: false, error: (e as Error).message });
} finally {
setVerifying(false);
}
}
async function save() {
setSaving(true);
try {
await setWhisperJavSettings(draft);
start(() => router.refresh());
} finally {
setSaving(false);
}
}
return (
<div className="py-2 space-y-5">
<div>
<div className="text-sm font-medium mb-1 flex items-center gap-1.5">
<Captions className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
WhisperJAV Subtitle Generator
</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2 max-w-2xl">
Local AI subtitle generation. Configure the CLI path, then use{" "}
<span className="font-mono">Generate Subtitles</span> from the player&apos;s subtitle dropdown.
First run may download models (~12 GB).
</div>
</div>
{/* CLI path */}
<div>
<div className="text-sm font-medium mb-1">CLI Path</div>
<div className="flex items-center gap-2">
<input
type="text"
value={draft.cliPath}
onChange={(e) => setDraft({ ...draft, cliPath: e.target.value })}
placeholder="whisperjav (or absolute path)"
className="flex-1 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
<button
type="button"
onClick={browse}
title="Browse for whisperjav.exe"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
>
<FolderOpen className="w-4 h-4" /> Browse
</button>
<button
type="button"
onClick={runVerify}
disabled={verifying || !draft.cliPath.trim()}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{verifying ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Verify
</button>
</div>
{verify && (
<div className={cn(
"mt-2 text-xs rounded-lg px-3 py-2 border",
verify.ok
? "text-[var(--color-mint)] bg-[var(--color-mint)]/5 border-[var(--color-mint)]/25"
: "text-red-300 bg-red-500/5 border-red-500/25",
)}>
<div className="flex items-start gap-2">
{verify.ok
? <CheckCircle2 className="w-4 h-4 mt-0.5 shrink-0" />
: <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />}
<div className="min-w-0">
{verify.ok
? <>Detected: <strong>WhisperJAV {verify.version}</strong> at <span className="font-mono break-all">{verify.resolvedPath}</span></>
: <>Verify failed: {verify.error}</>}
</div>
</div>
</div>
)}
</div>
{/* Quality */}
<RadioGroup<Quality>
label="Quality"
value={draft.quality}
onChange={(v) => setDraft({ ...draft, quality: v })}
options={[
{ value: "fast", title: "Fast", desc: "Quickest pass; lower fidelity." },
{ value: "balanced", title: "Balanced", desc: "Default. Good speed/quality tradeoff." },
{ value: "qwen", title: "Best (Qwen)", desc: "Qwen3-ASR with JAV-tuned post-processing. Slow + heavy on VRAM." },
]}
/>
{/* Source language + Output mode */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<RadioGroup<SourceLang>
label="Source Language"
value={draft.sourceLanguage}
onChange={(v) => setDraft({ ...draft, sourceLanguage: v })}
options={[
{ value: "japanese", title: "Japanese", desc: "" },
{ value: "korean", title: "Korean", desc: "" },
{ value: "chinese", title: "Chinese", desc: "" },
{ value: "english", title: "English", desc: "" },
]}
/>
<RadioGroup<OutputMode>
label="Output Mode"
value={draft.outputMode}
onChange={(v) => setDraft({ ...draft, outputMode: v })}
options={[
{ value: "native", title: "Native", desc: "Transcribe in source language." },
{ value: "direct-to-english", title: "Direct To English", desc: "Whisper-translate to English." },
]}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<RadioGroup<Sensitivity>
label="Sensitivity"
value={draft.sensitivity}
onChange={(v) => setDraft({ ...draft, sensitivity: v })}
options={[
{ value: "conservative", title: "Conservative", desc: "" },
{ value: "balanced", title: "Balanced", desc: "" },
{ value: "aggressive", title: "Aggressive", desc: "" },
]}
/>
<RadioGroup<Location>
label="Output Location"
value={draft.outputLocation}
onChange={(v) => setDraft({ ...draft, outputLocation: v })}
options={[
{ value: "beside-video", title: "Beside Video", desc: "Falls back to data folder if read-only." },
{ value: "data-folder", title: "Data Folder", desc: "data/generated-subtitles/<code>/" },
]}
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={draft.noSignature}
onChange={(e) => setDraft({ ...draft, noSignature: e.target.checked })}
/>
Disable WhisperJAV signature cue at end of subtitles
</label>
<div>
<div className="text-sm font-medium mb-1">Job History Retention</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2 max-w-2xl">
Days to keep failed and cancelled job temp folders for debugging.
Successful job folders are deleted immediately.{" "}
<span className="font-mono">0</span> = keep forever.
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
value={draft.retentionDays}
onChange={(e) => setDraft({ ...draft, retentionDays: Math.max(0, Number(e.target.value) || 0) })}
className="w-24 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
<span className="text-xs text-[var(--color-fg-dim)]">days</span>
</div>
</div>
<div className="flex items-center gap-2 pt-3 border-t border-[var(--color-glass-border)] flex-wrap">
<button
type="button"
onClick={save}
disabled={!dirty || saving}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save
</button>
<Link
href="/subtitles/batch"
onClick={closeSettings}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
>
<Sparkles className="w-4 h-4 text-[var(--color-cyan)]" />
Open Batch Generator
</Link>
<ClearHistoryButton />
</div>
</div>
);
}
function ClearHistoryButton() {
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(null);
async function clearAll() {
if (!window.confirm("Clear all WhisperJAV job history? Temp folders for past jobs will be deleted.")) return;
setBusy(true);
setResult(null);
try {
const r = await fetch("/api/whisperjav-jobs", { method: "DELETE" });
const data = (await r.json()) as { rows?: number; dirs?: number };
setResult(`Removed ${data.rows ?? 0} row(s), ${data.dirs ?? 0} folder(s).`);
} catch (e) {
setResult(`Failed: ${(e as Error).message}`);
} finally {
setBusy(false);
}
}
return (
<>
<button
type="button"
onClick={clearAll}
disabled={busy}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 disabled:opacity-40"
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Clear Job History
</button>
{result && <span className="text-xs text-[var(--color-fg-dim)]">{result}</span>}
</>
);
}
function RadioGroup<T extends string>({ label, value, onChange, options }: {
label: string;
value: T;
onChange: (v: T) => void;
options: Array<{ value: T; title: string; desc: string }>;
}) {
return (
<div>
<div className="text-sm font-medium mb-1">{label}</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{options.map((opt) => {
const active = value === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"text-left rounded-lg border px-3 py-2 transition-colors cursor-pointer",
active
? "border-[var(--color-cyan)] bg-[var(--color-cyan)]/10 shadow-[var(--shadow-glow-cyan)]"
: "border-[var(--color-glass-border)] bg-[var(--color-glass)]/30 hover:border-[var(--color-glass-border-strong)]",
)}
>
<div className={cn(
"text-sm font-medium",
active ? "text-[var(--color-cyan)]" : "text-[var(--color-fg)]",
)}>{opt.title}</div>
{opt.desc && (
<div className="text-[11px] text-[var(--color-fg-muted)] mt-0.5 leading-snug">{opt.desc}</div>
)}
</button>
);
})}
</div>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { Disc3, Search, FolderHeart, Tag, Settings, Trash2, Users, Building2, Film, Database, ChevronDown, Layers } from "lucide-react";
import { BRAND } from "@/lib/brand";
import { useSettingsPanel } from "@/components/settings/SettingsPanelProvider";
import { useTrashPanel } from "@/components/trash/TrashPanelProvider";
import { QueueIndicator } from "@/components/queue/QueueIndicator";
import { cn } from "@/lib/utils";
import { useEffect, useRef, useState } from "react";
export function TopNav() {
const pathname = usePathname();
const router = useRouter();
const { open: settingsOpen, toggle: toggleSettings } = useSettingsPanel();
const { open: trashOpen, toggle: toggleTrash } = useTrashPanel();
const [q, setQ] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
inputRef.current?.focus();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const submit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = q.trim();
if (!trimmed) return;
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
};
const link = (href: string, label: string, Icon: React.ComponentType<{ className?: string }>) => {
const active = pathname === href || (href !== "/" && pathname.startsWith(href));
return (
<Link
href={href}
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors w-[110px]",
active ? "text-[var(--color-fg)] bg-[var(--color-glass-strong)]" : "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]",
)}
>
<Icon className="w-4 h-4" />
<span>{label}</span>
</Link>
);
};
return (
<header className="sticky top-0 z-40 border-b border-[var(--color-glass-border)] backdrop-blur-xl bg-[color-mix(in_oklch,var(--color-bg-0)_70%,transparent)]">
<div className="max-w-[1600px] mx-auto px-6 h-16 flex items-center gap-6">
<Link href="/" className="flex items-center gap-2 group">
<div className="relative">
<Disc3 className="w-5 h-5 text-[var(--color-cyan)] group-hover:rotate-90 transition-transform duration-700" />
<div className="absolute inset-0 blur-md bg-[var(--color-cyan)] opacity-50 -z-10" />
</div>
<span className="font-semibold tracking-tight text-gradient-accent text-lg">{BRAND.name}</span>
</Link>
<nav className="flex items-center gap-1">
{link("/", "Library", Disc3)}
{link("/actress", "Actress", Users)}
<DatabaseMenu pathname={pathname} />
{link("/category", "Categories", Layers)}
{link("/tag", "Tags", Tag)}
{link("/collection", "Collection", FolderHeart)}
</nav>
<div className="ml-auto flex items-center gap-2">
<form onSubmit={submit} className="w-full max-w-xs relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search Code, Title, Notes…"
className="w-full glass rounded-xl pl-10 pr-16 py-2 text-sm outline-none focus:border-[var(--color-cyan)] focus:shadow-[var(--shadow-glow-cyan)] transition-all placeholder:text-[var(--color-fg-muted)]"
/>
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] font-mono text-[var(--color-fg-muted)] glass px-1.5 py-0.5 rounded">
K
</kbd>
</form>
<QueueIndicator />
<button
onClick={toggleTrash}
aria-label="Trash"
aria-pressed={trashOpen}
title="Trash"
className={cn(
"w-9 h-9 grid place-items-center rounded-lg border transition-colors shrink-0",
trashOpen
? "bg-[var(--color-coral)]/15 border-[var(--color-coral)]/40 text-[var(--color-coral)]"
: "border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<Trash2 className="w-4 h-4" />
</button>
<button
onClick={toggleSettings}
aria-label="Settings"
aria-pressed={settingsOpen}
title="Settings"
className={cn(
"w-9 h-9 grid place-items-center rounded-lg border transition-colors shrink-0",
settingsOpen
? "bg-[var(--color-cyan)]/15 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
: "border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<Settings className="w-4 h-4" />
</button>
</div>
</div>
</header>
);
}
const DATABASE_ITEMS: Array<{ href: string; label: string; Icon: React.ComponentType<{ className?: string }> }> = [
{ href: "/studios", label: "Studios", Icon: Building2 },
{ href: "/series", label: "Series", Icon: Film },
];
function DatabaseMenu({ pathname }: { pathname: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Small close-delay so moving the cursor from the trigger button into
// the dropdown (across the 1 px positioning offset) doesn't dismiss.
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const active = DATABASE_ITEMS.some((it) => pathname === it.href || pathname.startsWith(it.href + "/"));
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
window.addEventListener("mousedown", onClick);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("mousedown", onClick);
window.removeEventListener("keydown", onKey);
};
}, [open]);
useEffect(() => {
return () => { if (closeTimer.current) clearTimeout(closeTimer.current); };
}, []);
const cancelClose = () => {
if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
};
const scheduleClose = () => {
cancelClose();
closeTimer.current = setTimeout(() => setOpen(false), 120);
};
return (
<div
ref={ref}
className="relative"
onMouseEnter={() => { cancelClose(); setOpen(true); }}
onMouseLeave={scheduleClose}
>
<button
type="button"
onClick={() => setOpen((s) => !s)}
aria-haspopup="menu"
aria-expanded={open}
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors w-[110px]",
active || open
? "text-[var(--color-fg)] bg-[var(--color-glass-strong)]"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]",
)}
>
<Database className="w-4 h-4" />
<span>Database</span>
<ChevronDown className={cn("w-3 h-3 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div
role="menu"
// pt-1 adds a hover bridge so the cursor can cross from the
// trigger to the menu items without leaving the wrapper.
className="absolute top-full left-0 pt-1 min-w-[160px] z-50"
>
<div className="rounded-lg border border-[var(--color-glass-border-strong)] bg-[var(--color-bg-0)] shadow-lg backdrop-blur-xl overflow-hidden">
{DATABASE_ITEMS.map(({ href, label, Icon }) => {
const itemActive = pathname === href || pathname.startsWith(href + "/");
return (
<Link
key={href}
href={href}
onClick={() => setOpen(false)}
role="menuitem"
className={cn(
"flex items-center gap-2 px-3 py-1.5 text-sm transition-colors",
itemActive
? "text-[var(--color-cyan)] bg-[var(--color-glass)]"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<Icon className="w-4 h-4" />
{label}
</Link>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,428 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowLeft, Captions, Loader2, Play, RefreshCw, Square, Sparkles } from "lucide-react";
import { thumbUrl } from "@/lib/assetUrls";
import { cn } from "@/lib/utils";
interface Candidate {
id: number;
code: string;
title: string | null;
thumbPath: string;
}
interface QueueState {
queued: number;
running: {
id: string;
code: string;
started_at: number | null;
stage: string | null;
stage_index: number | null;
stage_total: number | null;
} | null;
}
const PAGE_SIZE = 100;
export function BatchGeneratorClient() {
const [candidates, setCandidates] = useState<Candidate[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [queueState, setQueueState] = useState<QueueState>({ queued: 0, running: null });
const [enqueuing, setEnqueuing] = useState(false);
const [stopping, setStopping] = useState(false);
const [batchSize, setBatchSize] = useState<number>(5);
const [lastResult, setLastResult] = useState<string | null>(null);
const loadPage = useCallback(async (p: number) => {
setLoading(true);
try {
const r = await fetch(
`/api/whisperjav-candidates?limit=${PAGE_SIZE}&offset=${p * PAGE_SIZE}`,
{ cache: "no-store" },
);
const j = (await r.json()) as { candidates: Candidate[]; total: number };
setCandidates(j.candidates ?? []);
setTotal(j.total ?? 0);
} catch {
setCandidates([]);
setTotal(0);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadPage(page); }, [page, loadPage]);
// Poll queue state every 3s while the tab is visible. Hidden tabs
// pause the interval (no point hammering the API when the UI isn't
// on screen) and tick once on visibility-restore so the user sees
// fresh state immediately.
useEffect(() => {
let live = true;
let interval: ReturnType<typeof setInterval> | null = null;
const tick = async () => {
try {
const r = await fetch("/api/whisperjav-jobs/batch", { cache: "no-store" });
if (!r.ok) return;
const j = (await r.json()) as QueueState;
if (live) setQueueState(j);
} catch { /* ignore */ }
};
const start = () => {
if (interval != null) return;
interval = setInterval(tick, 3000);
};
const stop = () => {
if (interval == null) return;
clearInterval(interval);
interval = null;
};
const onVisibility = () => {
if (document.hidden) {
stop();
} else {
void tick();
start();
}
};
void tick();
if (!document.hidden) start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
live = false;
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
const toggleAll = (checked: boolean) => {
if (!checked) {
// Only clear codes from the current page so other-page selections persist.
const here = new Set(candidates.map((c) => c.code));
setSelected((prev) => {
const next = new Set(prev);
for (const c of here) next.delete(c);
return next;
});
} else {
setSelected((prev) => {
const next = new Set(prev);
for (const c of candidates) next.add(c.code);
return next;
});
}
};
const toggleOne = (code: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
});
};
const queueSelected = async (codesArg?: string[]) => {
const codes = codesArg ?? Array.from(selected);
if (codes.length === 0) return;
setEnqueuing(true);
setLastResult(null);
try {
const r = await fetch("/api/whisperjav-jobs/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codes }),
});
const j = (await r.json()) as { enqueued: number; skipped: number; errors: Array<{ code: string; error: string }> };
const summary = `Queued ${j.enqueued}${j.skipped ? `, skipped ${j.skipped} (already have subs)` : ""}${j.errors.length ? `, ${j.errors.length} failed` : ""}`;
setLastResult(summary);
// Drop just-queued codes from the selection so the user can keep
// moving down the list without manually unchecking.
setSelected((prev) => {
const next = new Set(prev);
for (const c of codes) next.delete(c);
return next;
});
// Refresh candidates so any newly-queued items can be removed
// from the list once they actually produce a subtitle.
void loadPage(page);
} catch (e) {
setLastResult(`Failed: ${(e as Error).message}`);
} finally {
setEnqueuing(false);
}
};
const queueNextN = () => {
// Walk the current page in order, skipping codes already selected
// (they'll go through queueSelected anyway) and codes already in
// the queue (we don't track them here; server-side check is the
// source of truth via alreadyExists).
const picks: string[] = [];
for (const c of candidates) {
if (picks.length >= batchSize) break;
picks.push(c.code);
}
void queueSelected(picks);
};
const stopBatch = async () => {
if (!window.confirm("Cancel all queued WhisperJAV jobs?\n\nThe currently running job is not affected — cancel it from the player.")) return;
setStopping(true);
try {
const r = await fetch("/api/whisperjav-jobs/batch", { method: "DELETE" });
const j = (await r.json()) as { cancelled: number };
setLastResult(`Cancelled ${j.cancelled} queued job(s).`);
} catch (e) {
setLastResult(`Failed: ${(e as Error).message}`);
} finally {
setStopping(false);
}
};
const allOnPageSelected = candidates.length > 0
&& candidates.every((c) => selected.has(c.code));
const noneOnPageSelected = candidates.length > 0
&& candidates.every((c) => !selected.has(c.code));
const totalPages = Math.ceil(total / PAGE_SIZE);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!queueState.running?.started_at) return;
if (typeof document !== "undefined" && document.hidden) return;
const i = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(i);
}, [queueState.running]);
const runningElapsed = useMemo(() => {
const r = queueState.running;
if (!r || !r.started_at) return null;
return Math.floor((now - r.started_at) / 1000);
}, [queueState.running, now]);
return (
<div className="space-y-section">
<div className="flex items-center justify-between gap-4">
<Link
href="/"
className="inline-flex items-center gap-1.5 text-sm text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
>
<ArrowLeft className="w-4 h-4" /> Back to library
</Link>
<button
type="button"
onClick={() => loadPage(page)}
disabled={loading}
title="Refresh candidate list"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Refresh
</button>
</div>
<div className="flex items-center gap-2.5">
<Captions className="w-5 h-5 text-[var(--color-cyan)]" />
<h1 className="text-xl font-semibold tracking-tight">Batch Subtitle Generation</h1>
</div>
<div className="text-sm text-[var(--color-fg-muted)] max-w-3xl -mt-3">
Codes with a playable video but no subtitle file. Pick a batch size
(each video is roughly 13 hours of generation time on a single GPU)
and queue it. Jobs run sequentially via the existing single-worker
WhisperJAV queue.
</div>
{/* Live queue state */}
<div className="glass rounded-2xl p-4 flex items-center gap-4">
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] shrink-0">
Queue
</div>
{queueState.running ? (
<div className="flex items-center gap-2 min-w-0">
<Loader2 className="w-4 h-4 animate-spin text-[var(--color-coral)] shrink-0" />
<span className="font-mono text-sm text-[var(--color-coral)]">
{queueState.running.code}
</span>
<span className="text-xs text-[var(--color-fg-dim)] truncate">
{queueState.running.stage
? (queueState.running.stage_index && queueState.running.stage_total
? `· Step ${queueState.running.stage_index}/${queueState.running.stage_total}: ${queueState.running.stage}`
: `· ${queueState.running.stage}`)
: "· Starting..."}
</span>
{runningElapsed != null && (
<span className="text-xs font-mono text-[var(--color-fg-dim)] shrink-0">
· {Math.floor(runningElapsed / 60)}m{(runningElapsed % 60).toString().padStart(2, "0")}s
</span>
)}
</div>
) : (
<div className="text-sm text-[var(--color-fg-dim)]">Idle</div>
)}
<div className="ml-auto flex items-center gap-2 shrink-0">
<span className="text-sm font-mono">
{queueState.queued} queued
</span>
<button
type="button"
onClick={stopBatch}
disabled={queueState.queued === 0 || stopping}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 disabled:opacity-40"
>
{stopping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Square className="w-4 h-4" />}
Stop Batch
</button>
</div>
</div>
{/* Action bar */}
<div className="glass rounded-2xl p-4 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--color-fg-dim)] uppercase tracking-wider font-mono">Batch size</span>
<input
type="number"
min={1}
max={50}
value={batchSize}
onChange={(e) => setBatchSize(Math.max(1, Math.min(50, Number(e.target.value) || 1)))}
className="w-20 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
<button
type="button"
onClick={queueNextN}
disabled={enqueuing || candidates.length === 0}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40"
>
<Sparkles className="w-4 h-4" /> Queue Next {Math.min(batchSize, candidates.length)}
</button>
</div>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-[var(--color-fg-dim)]">{selected.size} selected</span>
<button
type="button"
onClick={() => queueSelected()}
disabled={enqueuing || selected.size === 0}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{enqueuing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
Queue Selected
</button>
<button
type="button"
onClick={() => setSelected(new Set())}
disabled={selected.size === 0}
className="text-xs px-2 py-1 rounded-md text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)] disabled:opacity-40"
>
Clear
</button>
</div>
</div>
{lastResult && (
<div className="text-xs text-[var(--color-mint)] bg-[var(--color-mint)]/5 border border-[var(--color-mint)]/25 rounded-lg px-3 py-2">
{lastResult}
</div>
)}
<div className="flex items-baseline justify-between gap-3 text-xs text-[var(--color-fg-dim)] font-mono">
<span>
{total > 0
? `Showing ${page * PAGE_SIZE + 1}${Math.min((page + 1) * PAGE_SIZE, total)} of ${total} candidates`
: (loading ? "Loading..." : "No candidates — every video has a subtitle")}
</span>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0 || loading}
className="px-2 py-0.5 rounded glass glass-hover disabled:opacity-40"
>
Prev
</button>
<span className="px-2">Page {page + 1} / {totalPages}</span>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1 || loading}
className="px-2 py-0.5 rounded glass glass-hover disabled:opacity-40"
>
Next
</button>
</div>
)}
</div>
{/* Candidate table */}
<div className="glass rounded-2xl overflow-hidden">
<div className="grid grid-cols-[40px_60px_1fr_120px] gap-3 items-center px-4 py-2 border-b border-[var(--color-glass-border)] text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
<input
type="checkbox"
checked={allOnPageSelected}
ref={(el) => { if (el) el.indeterminate = !allOnPageSelected && !noneOnPageSelected; }}
onChange={(e) => toggleAll(e.target.checked)}
aria-label="Select all on this page"
/>
<span>Cover</span>
<span>Code · Title</span>
<span className="text-right">Action</span>
</div>
{loading && candidates.length === 0 && (
<div className="px-4 py-8 text-center text-[var(--color-fg-muted)]">
<Loader2 className="w-5 h-5 animate-spin mx-auto" />
</div>
)}
{!loading && candidates.length === 0 && (
<div className="px-4 py-8 text-center text-[var(--color-fg-muted)] text-sm">
No videos missing subtitles 🎉
</div>
)}
{candidates.map((c) => {
const isSelected = selected.has(c.code);
return (
<label
key={c.id}
className={cn(
"grid grid-cols-[40px_60px_1fr_120px] gap-3 items-center px-4 py-2 border-b border-[var(--color-glass-border)] cursor-pointer transition-colors",
isSelected ? "bg-[var(--color-cyan)]/10" : "hover:bg-[var(--color-glass)]",
)}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleOne(c.code)}
aria-label={`Select ${c.code}`}
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={thumbUrl({ thumbPath: c.thumbPath, code: c.code, id: c.id })}
alt=""
className="w-12 h-8 object-cover rounded"
/>
<div className="min-w-0">
<div className="font-mono text-sm font-semibold text-[var(--color-cyan)] truncate">
{c.code}
</div>
{c.title && (
<div className="text-xs text-[var(--color-fg-dim)] truncate">{c.title}</div>
)}
</div>
<div className="text-right">
<Link
href={`/id/${encodeURIComponent(c.code)}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
>
Open
</Link>
</div>
</label>
);
})}
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useState } from "react";
import { Upload } from "lucide-react";
import { TagImportModal } from "./TagImportModal";
export function TagImportButton({
existingTagNames,
existingCategoryNames,
}: {
existingTagNames: string[];
existingCategoryNames: string[];
}) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
title="Bulk import tags"
>
<Upload className="w-4 h-4" /> Import
</button>
{open && (
<TagImportModal
existingTagNames={new Set(existingTagNames.map((n) => n.toLowerCase()))}
existingCategoryNames={new Set(existingCategoryNames.map((n) => n.toLowerCase()))}
onClose={() => setOpen(false)}
/>
)}
</>
);
}
+323
View File
@@ -0,0 +1,323 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { createPortal } from "react-dom";
import { Loader2, X, FileCode, FileText, Table } from "lucide-react";
import { bulkImportTags, type BulkImportRow, type BulkImportResult } from "@/app/actions/tags";
import { cn } from "@/lib/utils";
type ParsedRow = BulkImportRow & {
status: "new" | "exists" | "error";
errorMsg?: string;
willCreateCategory?: boolean;
};
type Format = "json" | "csv" | "lines" | "empty";
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
function detectFormat(text: string): Format {
const t = text.trim();
if (!t) return "empty";
if (t.startsWith("[") || t.startsWith("{")) return "json";
// CSV if any line has a comma. Single-column CSV degrades to lines anyway.
if (t.split("\n").some((l) => l.includes(","))) return "csv";
return "lines";
}
function parseInput(text: string, format: Format): { rows: BulkImportRow[]; parseError?: string } {
if (format === "empty") return { rows: [] };
if (format === "json") {
try {
const parsed = JSON.parse(text);
const arr = Array.isArray(parsed) ? parsed : Array.isArray((parsed as { tags?: unknown }).tags) ? (parsed as { tags: unknown[] }).tags : null;
if (!arr) return { rows: [], parseError: "JSON must be an array (or object with `tags` array)." };
const rows: BulkImportRow[] = [];
for (const item of arr) {
if (typeof item === "string") {
rows.push({ name: item });
} else if (item && typeof item === "object") {
const obj = item as Record<string, unknown>;
rows.push({
name: String(obj.name ?? ""),
category: obj.category != null ? String(obj.category) : null,
color: obj.color != null ? String(obj.color) : null,
});
}
}
return { rows };
} catch (e) {
return { rows: [], parseError: `JSON parse error: ${(e as Error).message}` };
}
}
if (format === "csv") {
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return { rows: [] };
const first = splitCsvLine(lines[0]);
const looksLikeHeader = first.some((c) => /^(name|category|color)$/i.test(c));
let header: string[];
let dataLines: string[];
if (looksLikeHeader) {
header = first.map((c) => c.toLowerCase());
dataLines = lines.slice(1);
} else {
header = ["name", "category", "color"].slice(0, first.length);
dataLines = lines;
}
const nameIdx = header.indexOf("name");
const catIdx = header.indexOf("category");
const colorIdx = header.indexOf("color");
const rows: BulkImportRow[] = dataLines.map((line) => {
const cols = splitCsvLine(line);
return {
name: nameIdx >= 0 ? cols[nameIdx] ?? "" : cols[0] ?? "",
category: catIdx >= 0 ? cols[catIdx] ?? null : null,
color: colorIdx >= 0 ? cols[colorIdx] ?? null : null,
};
});
return { rows };
}
// lines
const rows: BulkImportRow[] = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
.map((name) => ({ name }));
return { rows };
}
function splitCsvLine(line: string): string[] {
// Minimal CSV splitter: respects double-quoted fields and "" escapes.
const out: string[] = [];
let cur = "";
let inQ = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQ) {
if (ch === '"') {
if (line[i + 1] === '"') { cur += '"'; i++; }
else inQ = false;
} else cur += ch;
} else {
if (ch === ",") { out.push(cur.trim()); cur = ""; }
else if (ch === '"' && cur === "") inQ = true;
else cur += ch;
}
}
out.push(cur.trim());
return out;
}
export function TagImportModal({
existingTagNames,
existingCategoryNames,
onClose,
}: {
existingTagNames: Set<string>;
existingCategoryNames: Set<string>;
onClose: () => void;
}) {
const router = useRouter();
const [text, setText] = useState("");
const [createMissingCategories, setCreateMissingCategories] = useState(true);
const [updateExisting, setUpdateExisting] = useState(false);
const [pending, startTransition] = useTransition();
const [result, setResult] = useState<BulkImportResult | null>(null);
const format = useMemo(() => detectFormat(text), [text]);
const { rows, parseError } = useMemo(() => parseInput(text, format), [text, format]);
const preview = useMemo<ParsedRow[]>(() => {
return rows.map((r) => {
const name = (r.name ?? "").trim().toLowerCase();
if (!name) return { ...r, status: "error", errorMsg: "blank name" };
if (name.length > 48) return { ...r, status: "error", errorMsg: "name too long" };
if (r.color && !COLOR_RE.test(r.color.trim())) return { ...r, status: "error", errorMsg: "bad color" };
const willCreateCategory = !!r.category && !existingCategoryNames.has(r.category.trim().toLowerCase());
const status: ParsedRow["status"] = existingTagNames.has(name) ? "exists" : "new";
return { ...r, name, status, willCreateCategory };
});
}, [rows, existingTagNames, existingCategoryNames]);
const counts = useMemo(() => {
let n = 0, dup = 0, err = 0, cats = 0;
for (const p of preview) {
if (p.status === "new") n++;
else if (p.status === "exists") dup++;
else err++;
if (p.willCreateCategory) cats++;
}
return { n, dup, err, cats };
}, [preview]);
const willImport = updateExisting ? counts.n + counts.dup : counts.n;
const canSubmit = !pending && willImport > 0 && !parseError;
function submit() {
const valid = rows.filter((_, i) => preview[i].status !== "error");
if (valid.length === 0) return;
startTransition(async () => {
const r = await bulkImportTags(valid, { createMissingCategories, updateExisting });
setResult(r);
if (r.ok) router.refresh();
});
}
async function handleFile(file: File) {
const t = await file.text();
setText(t);
}
const FormatIcon = format === "json" ? FileCode : format === "csv" ? Table : FileText;
return createPortal(
<div className="fixed inset-0 z-[100] grid place-items-center bg-black/70 backdrop-blur-sm p-4" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-[1000px] rounded-2xl bg-[var(--color-bg-1)] border border-[var(--color-glass-border-strong)] shadow-2xl overflow-hidden"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-glass-border)]">
<div className="flex items-center gap-3">
<div className="text-base font-semibold">Import tags</div>
{format !== "empty" && (
<span className="inline-flex items-center gap-1 text-[11px] uppercase tracking-wider font-mono px-2 py-0.5 rounded-full border border-[var(--color-cyan)]/40 bg-[var(--color-cyan)]/10 text-[var(--color-cyan)]">
<FormatIcon className="w-3 h-3" /> {format}
</span>
)}
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--color-glass)]" aria-label="Close">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 grid grid-cols-2 gap-4">
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="text-[11px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
Paste line, CSV, or JSON
</div>
<label className="text-[11px] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] cursor-pointer">
<input
type="file"
accept=".csv,.json,.txt,text/csv,application/json,text/plain"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
or upload file
</label>
</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={14}
placeholder={`Bondage\nCosplay\nBukkake\n\nor:\n\nname,category,color\nBondage,Fetish,#a78bfa\nCosplay,Genre,#22d3ee`}
className="w-full bg-[var(--color-bg-2)] border border-[var(--color-glass-border)] rounded-lg p-3 text-xs font-mono outline-none focus:border-[var(--color-cyan)] resize-none"
/>
<div className="mt-3 flex flex-col gap-1.5 text-xs">
<label className="inline-flex items-center gap-2">
<input type="checkbox" checked={createMissingCategories} onChange={(e) => setCreateMissingCategories(e.target.checked)} className="accent-[var(--color-cyan)]" />
Create missing categories
</label>
<label className="inline-flex items-center gap-2">
<input type="checkbox" checked={updateExisting} onChange={(e) => setUpdateExisting(e.target.checked)} className="accent-[var(--color-cyan)]" />
Update existing (color &amp; category)
</label>
</div>
</div>
<div className="min-h-0">
<div className="text-[11px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-1.5">
Preview · {preview.length} parsed
</div>
<div className="rounded-lg border border-[var(--color-glass-border)] bg-[var(--color-bg-2)] overflow-hidden max-h-[340px] overflow-y-auto">
{parseError ? (
<div className="p-4 text-xs text-red-300">{parseError}</div>
) : preview.length === 0 ? (
<div className="p-4 text-xs text-[var(--color-fg-muted)]">Paste tags to see a preview.</div>
) : (
<table className="w-full text-xs">
<thead className="text-[10px] uppercase tracking-wider text-[var(--color-fg-muted)]">
<tr>
<th className="text-left px-3 py-2 font-medium">Name</th>
<th className="text-left px-3 py-2 font-medium">Category</th>
<th className="text-left px-3 py-2 font-medium">Color</th>
<th className="text-left px-3 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{preview.map((p, i) => (
<tr key={i} className="border-t border-[var(--color-glass-border)]">
<td className="px-3 py-1.5">{p.name || <span className="text-[var(--color-fg-muted)]"></span>}</td>
<td className="px-3 py-1.5">
{p.category ? (
<>
{p.category}
{p.willCreateCategory && (
<span className="ml-1.5 text-[10px] text-[var(--color-mint)]">+ create</span>
)}
</>
) : <span className="text-[var(--color-fg-muted)]"></span>}
</td>
<td className="px-3 py-1.5">
{p.color && COLOR_RE.test(p.color) ? (
<span className="inline-block w-3.5 h-3.5 rounded align-middle" style={{ background: p.color }} />
) : <span className="text-[var(--color-fg-muted)]"></span>}
</td>
<td className="px-3 py-1.5">
{p.status === "new" && <span className="text-[var(--color-mint)]">+ new</span>}
{p.status === "exists" && <span className="text-[var(--color-amber)]"> exists</span>}
{p.status === "error" && <span className="text-red-300"> {p.errorMsg}</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{preview.length > 0 && !parseError && (
<div className="mt-3 flex items-center gap-3 text-xs text-[var(--color-fg-dim)]">
<span><strong className="text-[var(--color-fg)]">{counts.n}</strong> new</span>
<span><strong className="text-[var(--color-fg)]">{counts.dup}</strong> dup</span>
{counts.err > 0 && <span className="text-red-300"><strong>{counts.err}</strong> error</span>}
{counts.cats > 0 && <span className="text-[var(--color-violet)]"><strong>{counts.cats}</strong> cat to create</span>}
</div>
)}
</div>
</div>
{result && (
<div className={cn(
"px-5 py-3 text-xs border-t border-[var(--color-glass-border)]",
result.ok ? "text-[var(--color-mint)] bg-[var(--color-mint)]/5" : "text-red-300 bg-red-500/5",
)}>
{result.ok ? (
<>Imported. <strong>{result.added}</strong> added, <strong>{result.updated}</strong> updated, <strong>{result.skipped}</strong> skipped, <strong>{result.categoriesCreated}</strong> categories created.{result.errors.length > 0 && ` ${result.errors.length} row error(s).`}</>
) : (
<>Failed: {result.errors[0]?.message ?? "unknown error"}</>
)}
</div>
)}
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--color-glass-border)] bg-[var(--color-glass)]">
<div className="text-[11px] text-[var(--color-fg-muted)]">
Tip: drag a <code className="px-1 py-0.5 bg-[var(--color-bg-2)] rounded text-[10px]">.csv</code> / <code className="px-1 py-0.5 bg-[var(--color-bg-2)] rounded text-[10px]">.json</code> file onto the textarea.
</div>
<div className="flex items-center gap-2">
<button onClick={onClose} className="text-sm px-3 py-1.5 rounded-lg glass glass-hover">
{result?.ok ? "Done" : "Cancel"}
</button>
<button
onClick={submit}
disabled={!canSubmit}
className="inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black disabled:opacity-50 disabled:cursor-not-allowed"
>
{pending && <Loader2 className="w-4 h-4 animate-spin" />}
{pending ? "Importing…" : `Import ${willImport} tag${willImport === 1 ? "" : "s"}`}
</button>
</div>
</div>
</div>
</div>,
document.body,
);
}
+192
View File
@@ -0,0 +1,192 @@
"use client";
import { useEffect, useMemo, useState, useTransition } from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Check, Trash2, X, Loader2 } from "lucide-react";
import { bulkDeleteTags } from "@/app/actions/tags";
import { cn } from "@/lib/utils";
export type TagListItem = {
id: number;
name: string;
count: number;
categoryId: number | null;
categoryName: string | null;
categoryColor: string | null;
};
export function TagsList({ tags, sort }: { tags: TagListItem[]; sort: "az" | "count" }) {
const router = useRouter();
const [selected, setSelected] = useState<Set<number>>(new Set());
const [pending, start] = useTransition();
const anySelected = selected.size > 0;
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
function toggle(id: number) {
setSelected((cur) => {
const next = new Set(cur);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function selectAll() {
setSelected(new Set(tags.map((t) => t.id)));
}
function clear() {
setSelected(new Set());
}
function deleteSelected() {
if (selected.size === 0) return;
const totalCovers = tags.filter((t) => selected.has(t.id)).reduce((acc, t) => acc + t.count, 0);
const msg = `Delete ${selected.size} tag${selected.size === 1 ? "" : "s"}?\n\n` +
(totalCovers > 0
? `This will also remove ${totalCovers} cover-tag association${totalCovers === 1 ? "" : "s"}. Covers themselves are NOT deleted.`
: "These tags are unused.") +
"\n\nThis cannot be undone.";
if (!confirm(msg)) return;
const ids = Array.from(selected);
start(async () => {
await bulkDeleteTags(ids);
setSelected(new Set());
router.refresh();
});
}
// Group tags by category for rendering. Uncategorised pinned last.
const groups = useMemo(() => {
const m = new Map<number | null, { name: string; color: string | null; tags: TagListItem[] }>();
for (const t of tags) {
const key = t.categoryId;
if (!m.has(key)) {
m.set(key, { name: t.categoryName ?? "Uncategorised", color: t.categoryColor, tags: [] });
}
m.get(key)!.tags.push(t);
}
return Array.from(m.entries()).sort(([keyA, a], [keyB, b]) => {
if (keyA === null) return 1;
if (keyB === null) return -1;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
}, [tags]);
return (
<div className="space-y-5">
{groups.map(([key, g]) => {
const groupAllSelected = g.tags.every((t) => selected.has(t.id));
function toggleGroup() {
setSelected((cur) => {
const next = new Set(cur);
if (groupAllSelected) {
for (const t of g.tags) next.delete(t.id);
} else {
for (const t of g.tags) next.add(t.id);
}
return next;
});
}
return (
<section key={key ?? "none"}>
<div className="flex items-center gap-2 mb-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ background: g.color ?? "var(--color-fg-muted)" }}
/>
<h2 className="text-xs uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
{g.name}
</h2>
<span className="text-[10px] font-mono text-[var(--color-fg-muted)]">({g.tags.length})</span>
<button
type="button"
onClick={toggleGroup}
className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] hover:text-[var(--color-cyan)] ml-1"
>
{groupAllSelected ? "deselect all" : "select all"}
</button>
</div>
<div className="flex flex-wrap gap-2">
{g.tags.map((t) => {
const isSelected = selected.has(t.id);
return (
<div key={t.id} className="relative group">
<Link
href={`/tag/${encodeURIComponent(t.name)}`}
onClick={(e) => {
if (anySelected) {
e.preventDefault();
toggle(t.id);
}
}}
className={cn(
"flex items-center gap-2 pl-3 pr-3 py-1.5 rounded-full glass glass-hover text-sm transition-all",
isSelected && "ring-2 ring-[var(--color-cyan)] bg-[var(--color-cyan)]/10",
anySelected && !isSelected && "opacity-60 hover:opacity-100",
)}
>
<span className={cn(isSelected ? "text-[var(--color-cyan)]" : "text-[var(--color-violet)]")}>{t.name}</span>
<span className="text-xs font-mono text-[var(--color-fg-muted)]">{t.count}</span>
</Link>
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggle(t.id); }}
aria-label={isSelected ? "Deselect" : "Select"}
className={cn(
"absolute -top-1.5 -right-1.5 w-5 h-5 grid place-items-center rounded-full border-2 transition-all",
isSelected
? "bg-[var(--color-cyan)] border-[var(--color-cyan)] text-black"
: "bg-[var(--color-bg-1)] border-[var(--color-glass-border-strong)] text-transparent",
!isSelected && !anySelected && "opacity-0 group-hover:opacity-100",
)}
>
<Check className="w-3 h-3" strokeWidth={3} />
</button>
</div>
);
})}
</div>
</section>
);
})}
<p className="text-[11px] text-[var(--color-fg-muted)] pt-2 italic">
Sort {sort === "az" ? "A-Z" : "by count"} applies inside each category.
</p>
{anySelected && mounted && createPortal(
<div className="fixed bottom-[12px] left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-bg-1)] border border-[var(--color-glass-border-strong)] shadow-2xl backdrop-blur-xl">
<span className="text-sm font-mono">
<strong className="text-[var(--color-cyan)]">{selected.size}</strong> selected
</span>
<span className="w-px h-5 bg-[var(--color-glass-border)]" />
<button
onClick={selectAll}
className="text-xs px-2.5 py-1 rounded hover:bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
>
Select all ({tags.length})
</button>
<button
onClick={deleteSelected}
disabled={pending}
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1 rounded text-red-300 border border-red-500/40 hover:bg-red-500/10 disabled:opacity-50"
>
{pending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
Delete
</button>
<button
onClick={clear}
className="p-1 rounded hover:bg-[var(--color-glass)] text-[var(--color-fg-dim)]"
aria-label="Clear selection"
>
<X className="w-4 h-4" />
</button>
</div>,
document.body,
)}
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useState, useTransition } from "react";
import { X, Plus } from "lucide-react";
import { addTagToImage, removeTagFromImage } from "@/app/actions/tags";
export function TagEditor({
imageId,
initial,
}: {
imageId: number;
initial: Array<{ id: number; name: string; color: string | null }>;
}) {
const [tags, setTags] = useState(initial);
const [draft, setDraft] = useState("");
const [, start] = useTransition();
const submit = (e: React.FormEvent) => {
e.preventDefault();
const v = draft.trim().toLowerCase();
if (!v) return;
if (tags.some(t => t.name === v)) { setDraft(""); return; }
setDraft("");
setTags((cur) => [...cur, { id: -Date.now(), name: v, color: null }]);
start(async () => { await addTagToImage(imageId, v); });
};
const remove = (id: number) => {
setTags((cur) => cur.filter(t => t.id !== id));
if (id > 0) start(async () => { await removeTagFromImage(imageId, id); });
};
return (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-2">
Tags
</div>
<div className="flex flex-wrap gap-1.5">
{tags.map((t) => (
<span
key={t.id}
className="group flex items-center gap-1 px-2 py-1 rounded-full text-xs glass border-[var(--color-violet)]/30 text-[var(--color-violet)] bg-[color-mix(in_oklch,var(--color-violet)_10%,transparent)]"
>
{t.name}
<button onClick={() => remove(t.id)} className="opacity-50 hover:opacity-100">
<X className="w-3 h-3" />
</button>
</span>
))}
<form onSubmit={submit} className="flex items-center gap-1">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="add tag…"
className="bg-transparent text-xs px-2 py-1 rounded-full border border-dashed border-[var(--color-glass-border)] outline-none focus:border-[var(--color-cyan)] w-24"
/>
{draft && (
<button type="submit" className="text-[var(--color-cyan)]">
<Plus className="w-3 h-3" />
</button>
)}
</form>
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { Trash2 } from "lucide-react";
import { emptyTrash } from "@/app/actions/trash";
export function EmptyTrashButton({ count }: { count: number }) {
const [pending, start] = useTransition();
const router = useRouter();
if (count === 0) return null;
const onClick = () => {
if (!confirm(`Permanently delete ${count} image${count === 1 ? "" : "s"}? Cannot be undone.`)) return;
start(async () => {
await emptyTrash();
router.refresh();
});
};
return (
<button
onClick={onClick}
disabled={pending}
className="flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-coral)]/15 text-[var(--color-coral)] border border-[var(--color-coral)]/40 hover:bg-[var(--color-coral)]/25 disabled:opacity-50"
>
<Trash2 className="w-4 h-4" />
{pending ? "Emptying…" : "Empty trash"}
</button>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { RotateCcw, Trash2 } from "lucide-react";
import { restoreImages, purgeFromTrash } from "@/app/actions/trash";
import { relativeTime } from "@/lib/utils";
import { thumbUrl } from "@/lib/assetUrls";
export interface TrashCardImage {
id: number;
thumbPath: string;
width: number;
height: number;
code: string | null;
title: string | null;
rating: number | null;
watched: boolean;
studioName: string | null;
deletedAt: number;
}
export function TrashCard({ image }: { image: TrashCardImage }) {
const [pending, start] = useTransition();
const router = useRouter();
const onRestore = () => {
start(async () => {
await restoreImages([image.id]);
router.refresh();
});
};
const onPurge = () => {
if (!confirm("Permanently delete this cover? Cannot be undone.")) return;
start(async () => {
await purgeFromTrash([image.id]);
router.refresh();
});
};
return (
<div className="group relative block rounded-2xl overflow-hidden glass" style={{ breakInside: "avoid" }}>
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={thumbUrl({ thumbPath: image.thumbPath, code: image.code, id: image.id })}
alt={image.title ?? image.code ?? ""}
loading="lazy"
width={image.width}
height={image.height}
className="w-full h-auto block opacity-70"
/>
{image.code && (
<span className="absolute top-3 left-3 text-[10px] uppercase tracking-wider font-mono px-2 py-0.5 rounded-full glass-strong text-[var(--color-cyan)]">
{image.code}
</span>
)}
<span className="absolute top-3 right-3 text-[10px] uppercase tracking-wider font-mono px-2 py-0.5 rounded-full backdrop-blur-md border border-[var(--color-coral)]/30 text-[var(--color-coral)] bg-[var(--color-coral)]/10">
deleted {relativeTime(image.deletedAt)}
</span>
<div className="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/85 via-black/55 to-transparent flex items-center gap-2">
<button
onClick={onRestore}
disabled={pending}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/20 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/30 disabled:opacity-50"
>
<RotateCcw className="w-3.5 h-3.5" /> Restore
</button>
<button
onClick={onPurge}
disabled={pending}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-[var(--color-coral)]/15 text-[var(--color-coral)] border border-[var(--color-coral)]/40 hover:bg-[var(--color-coral)]/25 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" /> Delete forever
</button>
</div>
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { TrashCard, type TrashCardImage } from "./TrashCard";
export function TrashGrid({ images }: { images: TrashCardImage[] }) {
if (images.length === 0) return null;
return (
<div className="columns-2 sm:columns-3 md:columns-4 xl:columns-5 gap-4 [column-fill:_balance]">
{images.map((img) => (
<div key={img.id} className="mb-4">
<TrashCard image={img} />
</div>
))}
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useEffect, useRef } from "react";
import { Trash2, X } from "lucide-react";
import { useTrashPanel } from "./TrashPanelProvider";
import { TrashGrid } from "./TrashGrid";
import { EmptyTrashButton } from "./EmptyTrashButton";
import { useClickOutside } from "@/lib/hooks/useClickOutside";
import type { TrashCardImage } from "./TrashCard";
interface PanelData {
images: TrashCardImage[];
retentionDays: number;
}
export function TrashPanel({ data }: { data: PanelData }) {
const { open, close } = useTrashPanel();
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, close, open);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, close]);
if (!open) return null;
const { images, retentionDays } = data;
return (
<div
className="fixed inset-0 z-50 backdrop-blur-sm grid place-items-center p-4 sm:p-8"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 65%, transparent)" }}
>
<div
ref={ref}
className="w-full max-w-[1400px] h-[min(900px,calc(100vh-4rem))] flex flex-col rounded-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden shadow-2xl"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
<header className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-glass-border)]">
<div>
<div className="flex items-center gap-2">
<Trash2 className="w-5 h-5 text-[var(--color-coral)]" />
<h2 className="text-xl font-semibold tracking-tight">Recycle Bin</h2>
</div>
<p className="text-xs text-[var(--color-fg-dim)] mt-1">
{images.length} image{images.length === 1 ? "" : "s"}
{retentionDays > 0 && images.length > 0 && (
<span className="text-[var(--color-fg-muted)]"> · auto-purged after {retentionDays} day{retentionDays === 1 ? "" : "s"}</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
<EmptyTrashButton count={images.length} />
<button
onClick={close}
aria-label="Close trash"
className="w-8 h-8 grid place-items-center rounded-lg text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
<X className="w-4 h-4" />
</button>
</div>
</header>
<div className="flex-1 overflow-y-auto p-6">
{images.length === 0 ? (
<div className="glass rounded-2xl p-card text-center max-w-md mx-auto">
<Trash2 className="w-10 h-10 mx-auto text-[var(--color-fg-dim)] mb-label" />
<h3 className="text-xl font-medium">Trash Is Empty</h3>
<p className="text-[var(--color-fg-dim)] mt-1 text-sm">
Deleted images appear here for {retentionDays > 0 ? `${retentionDays} day${retentionDays === 1 ? "" : "s"}` : "as long as you keep them"} before they&rsquo;re gone for good.
</p>
</div>
) : (
<TrashGrid images={images} />
)}
</div>
</div>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
type Ctx = { open: boolean; toggle: () => void; close: () => void };
const C = createContext<Ctx | null>(null);
export function TrashPanelProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const toggle = useCallback(() => setOpen((o) => !o), []);
const close = useCallback(() => setOpen(false), []);
const value = useMemo<Ctx>(() => ({ open, toggle, close }), [open, toggle, close]);
return <C.Provider value={value}>{children}</C.Provider>;
}
export function useTrashPanel() {
const ctx = useContext(C);
if (!ctx) throw new Error("useTrashPanel must be used within TrashPanelProvider");
return ctx;
}
+142
View File
@@ -0,0 +1,142 @@
import { cn } from "@/lib/utils";
/**
* Building blocks for the detail / settings rhythm system. All spacing
* resolves to CSS tokens declared in `app/globals.css` under @theme:
* --spacing-card → p-card (card interior padding)
* --spacing-card-gap → gap-card-gap (gap between sibling cards)
* --spacing-section → gap-section / pt-section (intra-card section gap)
* --spacing-chip → gap-chip (chip clusters, pill grids, button bars)
* --spacing-label → mb-label (label header → content)
* --spacing-stat → mb-stat (hero-stat label → big number)
* --spacing-stat-gap → gap-stat-gap (horizontal between hero-stat cols)
*
* Use these instead of raw `p-[15px]` / `gap-[9px]` so a single token
* change ripples across every page.
*/
/** Outer card frame. Glass surface, rounded corners, uniform padding. */
export function Panel({
children,
className,
as: Tag = "div",
}: {
children: React.ReactNode;
className?: string;
/** Render as a different tag if needed (e.g. "section", "aside"). */
as?: keyof React.JSX.IntrinsicElements;
}) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const T = Tag as any;
return (
<T className={cn("glass rounded-2xl p-card", className)}>
{children}
</T>
);
}
/**
* Vertical container for sibling Panels. Replaces ad-hoc `space-y-N` on
* an aside or column wrapper — picks up the panel-to-panel rhythm.
*/
export function PanelStack({
children,
className,
as: Tag = "div",
}: {
children: React.ReactNode;
className?: string;
as?: keyof React.JSX.IntrinsicElements;
}) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const T = Tag as any;
return (
<T className={cn("flex flex-col gap-card-gap", className)}>
{children}
</T>
);
}
/**
* Vertical flow inside a Panel — pulls children apart by the
* intra-card section gap. Use for stacked blocks (header → flag pills
* → meta strip → hero stats), NOT for label→content (use PanelHeader for that).
*/
export function PanelSection({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-section", className)}>
{children}
</div>
);
}
/**
* Mono caps label, e.g. "ACTRESSES" / "TAGS". Renders the label and
* applies the standard label→content margin to whatever sits beneath.
* Pair with the chip cluster or any other content block.
*/
export function PanelHeader({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-label",
className,
)}
>
{children}
</div>
);
}
/**
* Horizontal flex-wrap chip strip used for actresses / genres / tags /
* collections / flag pill rows. Spacing is the unified `gap-chip` token.
*/
export function ChipCluster({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-wrap gap-chip", className)}>
{children}
</div>
);
}
/**
* Equal-column button row that sits OUTSIDE any Panel (Edit Metadata /
* Import / Delete pattern). Lives in the same vertical rhythm as
* Panels via PanelStack's gap-card-gap.
*/
export function ActionBar({
children,
cols = 3,
className,
}: {
children: React.ReactNode;
cols?: 2 | 3 | 4;
className?: string;
}) {
const colClass =
cols === 2 ? "grid-cols-2" : cols === 4 ? "grid-cols-4" : "grid-cols-3";
return (
<div className={cn("grid gap-chip", colClass, className)}>
{children}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
import { Play } from "lucide-react";
import { useVideoIndex } from "./VideoIndexProvider";
import { VideoPlayerModal } from "./VideoPlayerModal";
/**
* Centered play button overlay for cover images. Visible only when the
* video index has a match for this code. Same UX as the cover-grid play
* button — always visible, rectangular, opens the in-app player modal.
*/
export function CoverPlayButton({
code,
actresses,
}: {
code: string | null;
actresses?: Array<{ id: number; name: string; slug: string }>;
}) {
const idx = useVideoIndex();
const [playing, setPlaying] = useState(false);
if (!code || !idx.hasVideo(code)) return null;
return (
<>
<button
type="button"
onClick={() => setPlaying(true)}
aria-label="Play video"
title="Play video"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20 inline-flex items-center justify-center w-20 h-14 rounded-lg backdrop-blur-md text-white/95 cursor-pointer transition-colors hover:text-[var(--color-cyan)] hover:[animation:play-pulse_1.2s_ease-out_infinite] active:scale-95"
style={{
background: "rgba(20,20,28,0.75)",
border: 0,
boxShadow: "0 6px 16px rgba(0,0,0,0.55), 0 1px 2px rgba(0,0,0,0.5)",
}}
>
<Play className="w-6 h-6" style={{ fill: "currentColor" }} />
</button>
{playing && (
<VideoPlayerModal
code={code}
actresses={actresses}
onClose={() => setPlaying(false)}
/>
)}
</>
);
}
+398
View File
@@ -0,0 +1,398 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronDown, Captions, Sparkles, Loader2, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type LangIso = "eng" | "zho" | "jpn";
export interface EmbeddedEntry {
id: string;
streamIndex: number;
codec: string;
language: LangIso | null;
label: string;
renderable: boolean;
}
export interface SidecarEntry {
id: string;
abs: string;
filename: string;
ext: string;
language: LangIso | null;
label: string;
origin: "same-folder" | "library" | "browsed" | "manual";
}
export interface SubtitleSources {
embedded: EmbeddedEntry[];
sidecar: SidecarEntry[];
}
export interface RunningJobSummary {
jobId: string;
/** Wall-clock seconds since startedAt, computed by parent. */
elapsedSec: number;
/** "Step 4/6: Transcribing Scenes" or null when not yet known. */
status: string | null;
/** Estimated remaining seconds. Null when ETA can't be computed. */
etaSec: number | null;
}
interface Props {
sources: SubtitleSources | null;
selectedId: string | "off";
loading: boolean;
onChange: (id: string | "off") => void;
onBrowse: () => void;
/** When set, picker hides the Generate row and shows "Generating ..." */
runningJob?: RunningJobSummary | null;
/** Called when user clicks Generate Subtitles / Regenerate. */
onGenerate?: (overwrite: boolean) => void;
/** Called when user clicks Cancel during a running job. */
onCancel?: () => void;
/** True iff WhisperJAV CLI path is configured. Hides Generate row otherwise. */
generateEnabled?: boolean;
/** True iff a generated sidecar already exists for the current video.
* Switches "Generate Subtitles" → "Regenerate Subtitles" (overwrite=true). */
hasGenerated?: boolean;
/** Current timing offset in seconds. Positive = subs appear later. */
offsetSec?: number;
/** Called when user changes the offset (delta in seconds, signed). */
onOffsetChange?: (newOffsetSec: number) => void;
}
function shortLabel(
sources: SubtitleSources | null,
selectedId: string | "off",
loading: boolean,
): string {
// While the initial source list is in flight the sticky-pref auto-
// select hasn't fired yet — masking with "..." avoids a misleading
// "Off" flash that would flip to "EN"/"JP"/etc once the fetch lands.
if (loading && selectedId === "off") return "...";
if (selectedId === "off") return "Off";
if (!sources) return "Subtitles";
const emb = sources.embedded.find((e) => e.id === selectedId);
if (emb) return emb.language ? langShort(emb.language) : labelShort(emb.label, "Embedded");
const side = sources.sidecar.find((s) => s.id === selectedId);
if (side) return side.language ? langShort(side.language) : labelShort(side.label, "Subtitles");
return "Subtitles";
}
// For compound-language entries (lang=null, label="English/Japanese")
// derive a short code from the label so the trigger reads "EN" rather
// than the generic fallback.
function labelShort(label: string, fallback: string): string {
if (/\benglish\b/i.test(label)) return "EN";
if (/\bchinese\b/i.test(label)) return "CN";
if (/\bjapanese\b/i.test(label)) return "JP";
return fallback;
}
function langShort(iso: LangIso): string {
if (iso === "eng") return "EN";
if (iso === "zho") return "CN";
return "JP";
}
/**
* Subtitle source picker. Trigger always renders with a stable min-width
* to avoid layout shift when sources resolve. Menu groups Embedded /
* Sidecar with "Off" and "Browse..." as fixed entries.
*/
export function SubtitlePicker({
sources,
selectedId,
loading,
onChange,
onBrowse,
runningJob,
onGenerate,
onCancel,
generateEnabled,
hasGenerated,
offsetSec,
onOffsetChange,
}: Props) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDocDown = (e: MouseEvent) => {
if (!ref.current) return;
if (!ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const embedded = sources?.embedded ?? [];
const sidecar = sources?.sidecar ?? [];
const hasAny = embedded.length > 0 || sidecar.length > 0;
const trigger = shortLabel(sources, selectedId, loading);
return (
<div ref={ref} className="relative shrink-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
title="Subtitles"
className={cn(
"inline-flex items-center justify-between gap-1.5 min-w-[140px] text-xs px-3 py-1.5 rounded-lg glass glass-hover",
open && "bg-[var(--color-glass-strong)]",
)}
>
<span className="inline-flex items-center gap-1.5">
<Captions className="w-3.5 h-3.5" />
<span className="font-mono">{trigger}</span>
</span>
<ChevronDown className={cn("w-3 h-3 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div
role="menu"
className="absolute right-0 top-full mt-1 z-30 min-w-[280px] max-w-[440px] rounded-lg border border-[var(--color-glass-border-strong)] bg-[var(--color-bg-1)] shadow-2xl p-1"
>
<MenuItem
label="Off"
mono
active={selectedId === "off"}
onClick={() => { onChange("off"); setOpen(false); }}
/>
{embedded.length > 0 && <MenuHeader label="Embedded" />}
{embedded.map((e) => (
<MenuItem
key={e.id}
label={e.label}
detail={`#${e.streamIndex}`}
active={selectedId === e.id}
disabled={!e.renderable}
disabledReason="Image Subs Not Supported"
onClick={() => { onChange(e.id); setOpen(false); }}
/>
))}
{sidecar.length > 0 && <MenuHeader label="Subtitles" />}
{sidecar.map((s) => (
<MenuItem
key={s.id}
label={s.label}
detail={s.filename}
active={selectedId === s.id}
onClick={() => { onChange(s.id); setOpen(false); }}
/>
))}
{!hasAny && !loading && (
<div className="px-2 py-2 text-[11px] text-[var(--color-fg-muted)]">
No subtitles found for this video.
</div>
)}
{loading && !hasAny && (
<div className="px-2 py-2 text-[11px] text-[var(--color-fg-muted)]">
Looking for subtitles
</div>
)}
{selectedId !== "off" && onOffsetChange && (
<>
<div className="my-1 border-t border-[var(--color-glass-border)]" />
<SyncControls
offsetSec={offsetSec ?? 0}
onChange={onOffsetChange}
/>
</>
)}
<div className="my-1 border-t border-[var(--color-glass-border)]" />
<MenuItem
label="Browse..."
mono
onClick={() => { onBrowse(); setOpen(false); }}
/>
{generateEnabled && !runningJob && (
<>
<div className="my-1 border-t border-[var(--color-glass-border)]" />
<button
type="button"
role="menuitem"
onClick={() => { onGenerate?.(!!hasGenerated); setOpen(false); }}
className="w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md hover:bg-[var(--color-glass)] text-[var(--color-fg)] cursor-pointer"
>
<Sparkles className="w-3.5 h-3.5 text-[var(--color-cyan)] shrink-0" />
<span className="font-mono">{hasGenerated ? "Regenerate Subtitles" : "Generate Subtitles"}</span>
</button>
</>
)}
</div>
)}
</div>
);
}
function formatElapsed(sec: number): string {
const s = Math.max(0, Math.floor(sec));
const m = Math.floor(s / 60);
const r = s % 60;
return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`;
}
function SyncControls({
offsetSec,
onChange,
}: {
offsetSec: number;
onChange: (next: number) => void;
}) {
const adjust = (delta: number) => onChange(Math.round((offsetSec + delta) * 100) / 100);
// Local string so the user can type freely — including transient
// states like "-" or "1." — without React clobbering it on every
// keystroke. Commit to the parent on blur or Enter.
const [draft, setDraft] = useState<string>(() => formatOffset(offsetSec));
// Re-sync when the parent value changes from outside (e.g. button
// press, code change). Using a ref to avoid clobbering active typing.
const lastSyncedRef = useRef(offsetSec);
useEffect(() => {
if (lastSyncedRef.current !== offsetSec) {
setDraft(formatOffset(offsetSec));
lastSyncedRef.current = offsetSec;
}
}, [offsetSec]);
const commit = () => {
const parsed = parseFloat(draft);
if (Number.isFinite(parsed)) {
const rounded = Math.round(parsed * 100) / 100;
lastSyncedRef.current = rounded;
onChange(rounded);
setDraft(formatOffset(rounded));
} else {
// Reject garbage — revert.
setDraft(formatOffset(offsetSec));
}
};
return (
<div
className="px-2 py-2 flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mr-1">
Sync
</span>
<SyncBtn label="1s" onClick={() => adjust(-1)} title="Shift earlier 1s" />
<SyncBtn label=".1" onClick={() => adjust(-0.1)} title="Shift earlier 0.1s" />
<input
type="text"
inputMode="decimal"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") { e.currentTarget.blur(); }
else if (e.key === "Escape") { setDraft(formatOffset(offsetSec)); e.currentTarget.blur(); }
}}
title="Type a value (seconds), press Enter to apply"
className={cn(
"w-[60px] text-center text-xs font-mono px-1.5 py-0.5 rounded outline-none border",
offsetSec !== 0
? "text-[var(--color-cyan)] bg-[var(--color-cyan)]/10 border-[var(--color-cyan)]/40"
: "text-[var(--color-fg-dim)] bg-[var(--color-glass)] border-[var(--color-glass-border)]",
"focus:border-[var(--color-cyan)] focus:bg-[var(--color-cyan)]/10",
)}
/>
<SyncBtn label="+.1" onClick={() => adjust(0.1)} title="Shift later 0.1s" />
<SyncBtn label="+1s" onClick={() => adjust(1)} title="Shift later 1s" />
{offsetSec !== 0 && (
<button
type="button"
onClick={() => onChange(0)}
title="Reset to 0"
className="ml-1 text-[10px] uppercase tracking-wider font-mono px-1.5 py-0.5 rounded text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)] cursor-pointer"
>
Reset
</button>
)}
</div>
);
}
function formatOffset(sec: number): string {
if (sec === 0) return "0.0s";
return `${sec > 0 ? "+" : ""}${sec.toFixed(1)}s`;
}
function SyncBtn({ label, onClick, title }: { label: string; onClick: () => void; title: string }) {
return (
<button
type="button"
onClick={onClick}
title={title}
className="text-xs font-mono px-2 py-0.5 rounded glass glass-hover cursor-pointer"
>
{label}
</button>
);
}
function MenuHeader({ label }: { label: string }) {
return (
<div className="px-2 pt-2 pb-1 text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
{label}
</div>
);
}
function MenuItem({
label,
detail,
active,
disabled,
disabledReason,
mono,
onClick,
}: {
label: string;
detail?: string;
active?: boolean;
disabled?: boolean;
disabledReason?: string;
mono?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={disabled ? undefined : onClick}
title={disabled ? disabledReason : detail}
className={cn(
"w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md",
disabled
? "opacity-40 cursor-not-allowed"
: active
? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] cursor-pointer"
: "hover:bg-[var(--color-glass)] text-[var(--color-fg)] cursor-pointer",
)}
>
<span className={cn("shrink-0", mono && "font-mono")}>{label}</span>
{detail && (
<span className="font-mono text-[10px] text-[var(--color-fg-dim)] truncate min-w-0">
{detail}
</span>
)}
</button>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface VariantOption {
/** Stable identifier (typically the absolute partIdx). */
id: number;
/** Short label shown in the chip and menu, e.g. `original`, `fixed`, `1080p`. */
label: string;
/** Full filename, shown muted in the menu for disambiguation. */
filename: string;
}
/**
* Compact dropdown that picks between alternate encodes of one part.
* Renders nothing when there's only one option — caller can still
* mount it unconditionally.
*/
export function VariantPicker({
options,
selectedId,
onChange,
}: {
options: VariantOption[];
selectedId: number;
onChange: (id: number) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDocDown = (e: MouseEvent) => {
if (!ref.current) return;
if (!ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (options.length <= 1) return null;
const selected = options.find((o) => o.id === selectedId) ?? options[0]!;
return (
<div ref={ref} className="relative shrink-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
title={`Switch encode (${options.length} available)`}
className={cn(
"inline-flex items-center gap-1 text-xs font-mono px-2.5 py-1 rounded-md border cursor-pointer",
"border-[var(--color-glass-border)] bg-[var(--color-glass)] hover:bg-[var(--color-glass-strong)] text-[var(--color-fg)]",
open && "bg-[var(--color-glass-strong)]",
)}
>
<ChevronDown className={cn("w-3 h-3 transition-transform", open && "rotate-180")} />
<span className="truncate max-w-[140px]">{selected.label}</span>
</button>
{open && (
<div
role="menu"
className="absolute right-0 bottom-full mb-1 z-30 min-w-[260px] max-w-[420px] rounded-lg border border-[var(--color-glass-border-strong)] bg-[var(--color-bg-1)] shadow-2xl p-1"
>
{options.map((o) => (
<button
key={o.id}
type="button"
role="menuitem"
onClick={() => { onChange(o.id); setOpen(false); }}
className={cn(
"w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md cursor-pointer",
o.id === selectedId
? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)]"
: "hover:bg-[var(--color-glass)] text-[var(--color-fg)]",
)}
>
<span className="font-mono shrink-0">{o.label}</span>
<span className="font-mono text-[10px] text-[var(--color-fg-dim)] truncate min-w-0">
{o.filename}
</span>
</button>
))}
</div>
)}
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { subscribeVideoStatusRefresh } from "./videoStatusEvents";
interface Status {
codes: Set<string>;
subtitleCodes: Set<string>;
count: number;
lastScannedAt: number;
rootsScanned: string[];
}
interface Ctx extends Status {
hasVideo: (code: string | null | undefined) => boolean;
hasSubtitle: (code: string | null | undefined) => boolean;
refresh: () => Promise<void>;
}
const empty: Status = { codes: new Set(), subtitleCodes: new Set(), count: 0, lastScannedAt: 0, rootsScanned: [] };
const VideoIdxCtx = createContext<Ctx | null>(null);
export function VideoIndexProvider({ children }: { children: React.ReactNode }) {
const [status, setStatus] = useState<Status>(empty);
const inflightRef = useRef<AbortController | null>(null);
const refresh = useCallback(async () => {
// Abort any prior fetch so a slow first request can't clobber a
// newer second request's result on settle order.
inflightRef.current?.abort();
const ctrl = new AbortController();
inflightRef.current = ctrl;
try {
const r = await fetch("/api/video-status", { cache: "no-store", signal: ctrl.signal });
if (!r.ok) return;
const j = await r.json();
if (ctrl.signal.aborted) return;
setStatus({
codes: new Set<string>(Array.isArray(j.codes) ? j.codes : []),
subtitleCodes: new Set<string>(Array.isArray(j.subtitleCodes) ? j.subtitleCodes : []),
count: j.count ?? 0,
lastScannedAt: j.lastScannedAt ?? 0,
rootsScanned: Array.isArray(j.rootsScanned) ? j.rootsScanned : [],
});
} catch {
// Silent — if the endpoint fails or aborts, no badges. No user-facing error.
} finally {
if (inflightRef.current === ctrl) inflightRef.current = null;
}
}, []);
useEffect(() => { refresh(); return () => { inflightRef.current?.abort(); }; }, [refresh]);
useEffect(() => subscribeVideoStatusRefresh(() => { refresh(); }), [refresh]);
const value = useMemo<Ctx>(() => ({
...status,
hasVideo: (code) => {
if (!code) return false;
return status.codes.has(code.toUpperCase());
},
hasSubtitle: (code) => {
if (!code) return false;
return status.subtitleCodes.has(code.toUpperCase());
},
refresh,
}), [status, refresh]);
return <VideoIdxCtx.Provider value={value}>{children}</VideoIdxCtx.Provider>;
}
export function useVideoIndex(): Ctx {
const ctx = useContext(VideoIdxCtx);
if (!ctx) throw new Error("useVideoIndex must be used within VideoIndexProvider");
return ctx;
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
// Pure event-bus module separate from VideoIndexProvider.tsx so the
// provider file only exports a Component + a `use*` hook — that's the
// shape Next.js Fast Refresh requires to swap a Provider in place
// instead of forcing a full page reload.
const REFRESH_EVENT = "pinkudex:video-status-refresh";
export function dispatchVideoStatusRefresh() {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(REFRESH_EVENT));
}
export function subscribeVideoStatusRefresh(cb: () => void): () => void {
window.addEventListener(REFRESH_EVENT, cb);
return () => window.removeEventListener(REFRESH_EVENT, cb);
}