Study/src/components/arcade/ArcadeImportModal.tsx
Elijah 5bec95fb30
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m41s
Initial crossword addition and arcade redesign
2026-07-14 19:32:41 -07:00

127 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}