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