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