Initial commit
This commit is contained in:
@@ -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