All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m41s
127 lines
8.2 KiB
TypeScript
127 lines
8.2 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { GenerateTab } from "@/components/import/GenerateTab";
|
||
import type { ArcadeGameKey, ArcadeImportBatchPreview } from "@/types/arcade";
|
||
|
||
export function ArcadeImportModal({
|
||
classId,
|
||
gameType,
|
||
onClose,
|
||
onImported,
|
||
}: {
|
||
classId: string;
|
||
gameType: ArcadeGameKey;
|
||
onClose: () => void;
|
||
onImported: () => void;
|
||
}) {
|
||
const [tab, setTab] = useState<"generate" | "import">("import");
|
||
const [rawJson, setRawJson] = useState("");
|
||
const [names, setNames] = useState<string[]>([]);
|
||
const [preview, setPreview] = useState<ArcadeImportBatchPreview | null>(null);
|
||
const [error, setError] = useState("");
|
||
const [details, setDetails] = useState<string[]>([]);
|
||
const [acknowledged, setAcknowledged] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
async function previewImport() {
|
||
setBusy(true);
|
||
setError("");
|
||
setDetails([]);
|
||
setPreview(null);
|
||
try {
|
||
const response = await fetch("/api/arcade/import/preview", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ gameType, rawJson }),
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
setError(data.error ?? "Import preview failed");
|
||
setDetails(data.details ?? []);
|
||
return;
|
||
}
|
||
setPreview(data);
|
||
setNames(data.packs.map((pack: { name: string }) => pack.name));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function saveImport() {
|
||
if (!preview || names.some((name) => !name.trim())) return;
|
||
setBusy(true);
|
||
setError("");
|
||
try {
|
||
const response = await fetch("/api/arcade/packs", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
classId,
|
||
gameType,
|
||
rawJson,
|
||
...(gameType === "connections" ? { names: names.map((name) => name.trim()) } : { name: names[0]?.trim() }),
|
||
warningsAcknowledged: acknowledged,
|
||
}),
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
setError(data.error ?? "Import failed");
|
||
setDetails(data.details ?? []);
|
||
return;
|
||
}
|
||
onImported();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-4">
|
||
<button className="absolute inset-0 bg-black/45 backdrop-blur-sm" onClick={onClose} aria-label="Close import dialog" />
|
||
<section role="dialog" aria-modal="true" aria-labelledby="arcade-import-title" className={`${gameType === "connections" ? "connections-world connections-import" : "crossword-world crossword-import"} relative flex max-h-[92vh] w-full max-w-2xl flex-col rounded-t-3xl border border-border-light bg-bg-surface shadow-[var(--shadow-modal)] sm:rounded-3xl`}>
|
||
<div className="flex items-center justify-between px-6 pt-5">
|
||
<h2 id="arcade-import-title" className="editorial-title text-2xl text-text-heading">Import {gameType === "connections" ? "Connections" : "Crossword"} pack</h2>
|
||
<button onClick={onClose} className="grid h-10 w-10 place-items-center rounded-xl text-xl text-text-muted hover:bg-bg-surface-alt" aria-label="Close">×</button>
|
||
</div>
|
||
<div className="flex gap-1 border-b border-border-light px-6 pt-4">
|
||
{(["generate", "import"] as const).map((item) => (
|
||
<button key={item} onClick={() => setTab(item)} className={`border-b-2 px-4 py-3 text-sm font-bold capitalize ${tab === item ? "border-primary text-primary" : "border-transparent text-text-muted"}`}>{item}</button>
|
||
))}
|
||
</div>
|
||
<div className="overflow-y-auto p-6">
|
||
{tab === "generate" ? <GenerateTab importType={gameType} /> : (
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-text-secondary">{gameType === "connections" ? "Paste one pack object or an array of up to ten packs." : "Paste one Crossword pack containing exactly 80 entries."} Every board is validated before anything is saved.</p>
|
||
<textarea value={rawJson} onChange={(event) => { setRawJson(event.target.value); setPreview(null); setError(""); }} rows={11} placeholder={`Paste ${gameType === "connections" ? "Connections" : "Crossword"} JSON here…`} className="w-full resize-y rounded-xl border border-border bg-bg-surface-alt/60 px-4 py-3 font-mono text-sm text-text-heading focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" />
|
||
{!preview && <button onClick={previewImport} disabled={!rawJson.trim() || busy} className="w-full min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white disabled:opacity-45">{busy ? "Checking…" : "Preview pack(s)"}</button>}
|
||
{error && <div role="alert" className="rounded-xl border border-error/20 bg-error-bg p-4 text-sm text-error"><p className="font-bold">{error}</p>{details.length > 0 && <ul className="mt-2 list-disc space-y-1 pl-5">{details.map((detail) => <li key={detail}>{detail}</li>)}</ul>}</div>}
|
||
{preview && (
|
||
<div className="space-y-4 rounded-2xl border border-primary/15 bg-bg-callout p-4">
|
||
<div className="flex items-center justify-between"><p className="font-extrabold text-text-heading">{preview.count} {preview.count === 1 ? "pack" : "packs"} ready</p>{preview.wasRepaired && <span className="text-xs font-bold text-primary">JSON syntax repaired</span>}</div>
|
||
<div className="space-y-3">
|
||
{preview.packs.map((pack, index) => (
|
||
<article key={`${pack.name}-${index}`} className="rounded-xl border border-border-light bg-bg-surface p-3">
|
||
<label className="mb-1 block text-xs font-bold uppercase tracking-wide text-text-muted">Pack {index + 1} name</label>
|
||
<input value={names[index] ?? ""} onChange={(event) => setNames((current) => current.map((name, nameIndex) => nameIndex === index ? event.target.value : name))} className="w-full rounded-lg border border-border bg-bg-surface-alt/50 px-3 py-2 font-bold text-text-heading" />
|
||
{gameType === "connections" ? <>
|
||
<div className="mt-2 flex flex-wrap gap-1.5">{pack.categories.map((category) => <span key={category} className="rounded-full bg-bg-surface-alt px-2 py-1 text-[11px] font-bold text-text-secondary">{category}</span>)}</div>
|
||
<p className="mt-2 text-xs text-text-muted">16 tiles · 4 groups</p>
|
||
</> : <>
|
||
<p className="mt-2 text-xs text-text-muted">80 entries · four board sizes</p>
|
||
<div className="mt-2 grid grid-cols-2 gap-2 text-xs text-text-secondary">{pack.layoutPreviews?.map((layout) => <span key={layout.size} className="rounded-lg bg-bg-surface-alt px-2 py-1.5 capitalize">{layout.size}: {layout.placedCount}/{layout.targetCount}</span>)}</div>
|
||
</>}
|
||
</article>
|
||
))}
|
||
</div>
|
||
{preview.packs.some((pack) => pack.warnings.length > 0) && <div className="rounded-xl border border-amber-400/30 bg-amber-400/10 p-3 text-sm text-text-secondary"><p className="font-bold text-text-heading">Review warnings</p><ul className="mt-1 list-disc space-y-1 pl-5">{preview.packs.flatMap((pack, index) => pack.warnings.map((warning) => `Pack ${index + 1}: ${warning}`)).map((warning) => <li key={warning}>{warning}</li>)}</ul><label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} className="mt-1" /><span>I reviewed these warnings and want to import the packs.</span></label></div>}
|
||
<button onClick={saveImport} disabled={busy || names.some((name) => !name.trim()) || (preview.packs.some((pack) => pack.warnings.length > 0) && !acknowledged)} className="w-full min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white disabled:opacity-45">{busy ? "Importing…" : `Import ${preview.count} ${preview.count === 1 ? "pack" : "packs"}`}</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|