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