All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m1s
152 lines
11 KiB
TypeScript
152 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { ArcadeImportModal } from "@/components/arcade/ArcadeImportModal";
|
||
import { CROSSWORD_SIZE_TARGETS } from "@/lib/arcade/crosswordEngine";
|
||
import type { ArcadeAttemptSummary, ArcadePackSummary, CrosswordLayout, CrosswordSize } from "@/types/arcade";
|
||
|
||
const SIZES: { value: CrosswordSize; label: string; note: string }[] = [
|
||
{ value: "mini", label: "Mini", note: "Up to 15 words" },
|
||
{ value: "standard", label: "Standard", note: "Up to 30 words" },
|
||
{ value: "large", label: "Large", note: "Up to 50 words" },
|
||
{ value: "extra-large", label: "Extra Large", note: "Up to 80 words" },
|
||
];
|
||
|
||
export function CrosswordHub({ classId, classSlug, initialPacks }: { classId: string; classSlug: string; initialPacks: ArcadePackSummary[] }) {
|
||
const router = useRouter();
|
||
const [selectedId, setSelectedId] = useState(initialPacks[0]?.id ?? "");
|
||
const [showImport, setShowImport] = useState(false);
|
||
const [size, setSize] = useState<CrosswordSize>("standard");
|
||
const [instantCheck, setInstantCheck] = useState(initialPacks[0]?.defaultInstantCheck ?? false);
|
||
const [allowHints, setAllowHints] = useState(initialPacks[0]?.defaultAllowHints ?? true);
|
||
const [attempts, setAttempts] = useState<ArcadeAttemptSummary[]>([]);
|
||
const [attemptsLoading, setAttemptsLoading] = useState(initialPacks.length > 0);
|
||
const [previewLayout, setPreviewLayout] = useState<CrosswordLayout | null>(null);
|
||
const [previewLoading, setPreviewLoading] = useState(initialPacks.length > 0);
|
||
const effectiveSelectedId = initialPacks.some((pack) => pack.id === selectedId) ? selectedId : initialPacks[0]?.id ?? "";
|
||
const selected = useMemo(() => initialPacks.find((pack) => pack.id === effectiveSelectedId) ?? null, [effectiveSelectedId, initialPacks]);
|
||
|
||
useEffect(() => {
|
||
if (!selected) return;
|
||
fetch(`/api/arcade/packs/${selected.id}/attempts`)
|
||
.then((response) => response.ok ? response.json() : [])
|
||
.then(setAttempts)
|
||
.finally(() => setAttemptsLoading(false));
|
||
}, [selected]);
|
||
|
||
useEffect(() => {
|
||
if (!selected) return;
|
||
let current = true;
|
||
fetch(`/api/arcade/packs/${selected.id}/layout?size=${size}`)
|
||
.then((response) => response.ok ? response.json() : null)
|
||
.then((layout) => { if (current) setPreviewLayout(layout); })
|
||
.finally(() => { if (current) setPreviewLoading(false); });
|
||
return () => { current = false; };
|
||
}, [selected, size]);
|
||
|
||
function selectPack(pack: ArcadePackSummary) {
|
||
if (pack.id === effectiveSelectedId) return;
|
||
setSelectedId(pack.id);
|
||
setInstantCheck(pack.defaultInstantCheck);
|
||
setAllowHints(pack.defaultAllowHints);
|
||
setAttempts([]);
|
||
setAttemptsLoading(true);
|
||
setPreviewLayout(null);
|
||
setPreviewLoading(true);
|
||
}
|
||
|
||
function selectSize(nextSize: CrosswordSize) {
|
||
if (nextSize === size) return;
|
||
setSize(nextSize);
|
||
setPreviewLayout(null);
|
||
setPreviewLoading(true);
|
||
}
|
||
|
||
async function renamePack(pack: ArcadePackSummary) {
|
||
const name = window.prompt("Rename Crossword pack", pack.name)?.trim();
|
||
if (!name || name === pack.name) return;
|
||
await fetch(`/api/arcade/packs/${pack.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) });
|
||
router.refresh();
|
||
}
|
||
|
||
async function deletePack(pack: ArcadePackSummary) {
|
||
if (!window.confirm(`Delete “${pack.name}” and its attempt history?`)) return;
|
||
await fetch(`/api/arcade/packs/${pack.id}`, { method: "DELETE" });
|
||
if (selectedId === pack.id) setSelectedId("");
|
||
router.refresh();
|
||
}
|
||
|
||
const playHref = selected
|
||
? `/${classSlug}/arcade/crossword/${selected.id}/play?size=${size}&instant=${instantCheck ? "1" : "0"}&hints=${allowHints ? "1" : "0"}`
|
||
: "#";
|
||
|
||
return (
|
||
<div className="crossword-world crossword-hub p-1 pb-20 sm:p-3">
|
||
<section className="crossword-hero mb-7 rounded-3xl p-5 sm:flex sm:items-end sm:justify-between sm:p-7">
|
||
<div>
|
||
<Link href={`/${classSlug}/arcade`} className="mb-3 inline-flex min-h-10 items-center text-sm font-bold text-text-muted hover:text-primary">← Back to Arcade</Link>
|
||
<p className="crossword-kicker">The evening edition</p>
|
||
<h2 className="editorial-title mt-1 text-4xl text-text-heading">Crossword</h2>
|
||
<p className="mt-2 max-w-xl text-sm leading-6 text-text-secondary">Choose a terminology bank, set the edition size, and work the clues at your own pace.</p>
|
||
</div>
|
||
<button onClick={() => setShowImport(true)} className="crossword-primary mt-5 min-h-12 rounded-xl px-5 text-sm font-bold sm:mt-0">Import puzzle bank</button>
|
||
</section>
|
||
|
||
{initialPacks.length === 0 ? (
|
||
<section className="crossword-paper rounded-3xl px-6 py-16 text-center">
|
||
<div className="mx-auto mb-5 grid w-24 grid-cols-5 gap-1" aria-hidden>{Array.from({ length: 25 }, (_, index) => <i key={index} className={index % 3 === 0 ? "bg-[#2d2117]" : "bg-[#fff8e6]"} />)}</div>
|
||
<h3 className="editorial-title text-3xl text-text-heading">Print your first edition</h3>
|
||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-text-secondary">Import one JSON object containing exactly 80 clue-and-answer entries.</p>
|
||
<button onClick={() => setShowImport(true)} className="crossword-primary mt-6 min-h-11 rounded-xl px-5 text-sm font-bold">Import Crossword JSON</button>
|
||
</section>
|
||
) : (
|
||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(22rem,.9fr)]">
|
||
<section>
|
||
<h3 className="crossword-section-label">Puzzle banks</h3>
|
||
<div className="space-y-3">
|
||
{initialPacks.map((pack) => {
|
||
const active = pack.id === effectiveSelectedId;
|
||
return <article key={pack.id} className={`crossword-pack rounded-2xl border p-4 ${active ? "is-active" : ""}`}>
|
||
<button onClick={() => selectPack(pack)} className="w-full text-left">
|
||
<div className="flex items-start justify-between gap-3"><div><h4 className="text-xl font-extrabold text-text-heading">{pack.name}</h4>{pack.description && <p className="mt-1 text-sm leading-5 text-text-secondary">{pack.description}</p>}</div><span className={`mt-1 h-4 w-4 rounded-full border-4 ${active ? "border-primary bg-white" : "border-border bg-bg-surface"}`} aria-hidden /></div>
|
||
<div className="mt-3 flex flex-wrap gap-2 text-xs font-bold text-text-muted"><span>80 entries</span><span>Best {pack.bestScore ?? "—"}</span><span>Latest {pack.latestAttempt?.score ?? "—"}</span></div>
|
||
</button>
|
||
<div className="mt-3 flex justify-end gap-2 border-t border-border-light pt-3"><button onClick={() => renamePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-bg-surface-alt">Rename</button><button onClick={() => deletePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-error-bg hover:text-error">Delete</button></div>
|
||
</article>;
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
<aside className="crossword-setup h-fit rounded-3xl p-5 lg:sticky lg:top-6">
|
||
<h3 className="editorial-title text-2xl text-text-heading">Choose an edition</h3>
|
||
{selected ? <>
|
||
<p className="mt-1 text-sm font-bold text-primary">{selected.name}</p>
|
||
<fieldset className="mt-5"><legend className="crossword-section-label">Board size</legend><div className="grid grid-cols-2 gap-2">{SIZES.map((option) => <button type="button" key={option.value} aria-pressed={size === option.value} onClick={() => selectSize(option.value)} className={`crossword-size rounded-xl p-3 text-left ${size === option.value ? "is-active" : ""}`}><strong className="block text-sm">{option.label}</strong><span className="text-xs">{option.note}</span></button>)}</div></fieldset>
|
||
<CrosswordBoardPreview layout={previewLayout} loading={previewLoading} />
|
||
<div className="mt-5 space-y-2">
|
||
<label className="crossword-option"><input type="checkbox" checked={instantCheck} onChange={(event) => setInstantCheck(event.target.checked)} /><span><strong>Instant word checks</strong><small>Check only after a word is filled.</small></span></label>
|
||
<label className="crossword-option"><input type="checkbox" checked={allowHints} onChange={(event) => setAllowHints(event.target.checked)} /><span><strong>Allow hints</strong><small>Alternate clues and reveals stay available.</small></span></label>
|
||
</div>
|
||
<div className="mt-5 flex justify-between border-y border-border-light py-3 text-sm"><span className="text-text-secondary">Target words</span><strong>{CROSSWORD_SIZE_TARGETS[size]}</strong></div>
|
||
<Link href={playHref} className="crossword-primary mt-5 flex min-h-12 items-center justify-center rounded-xl px-5 text-sm font-extrabold">Open the puzzle</Link>
|
||
<div className="mt-7 border-t border-border-light pt-5"><h4 className="crossword-section-label">Recent editions</h4>{attemptsLoading ? <p className="mt-3 text-sm text-text-muted">Loading history…</p> : attempts.length === 0 ? <p className="mt-3 text-sm text-text-muted">No attempts yet.</p> : <div className="mt-3 space-y-2">{attempts.slice(0, 5).map((attempt) => <div key={attempt.id} className="flex items-center justify-between rounded-xl bg-bg-surface-alt px-3 py-2 text-sm"><strong>{attempt.score}/{attempt.maxScore}</strong><span className="text-text-muted">{Math.round(attempt.accuracy * 100)}% · {attempt.durationSeconds}s</span></div>)}</div>}</div>
|
||
</> : <p className="mt-3 text-sm text-text-muted">Select a pack to configure a puzzle.</p>}
|
||
</aside>
|
||
</div>
|
||
)}
|
||
{showImport && <ArcadeImportModal classId={classId} gameType="crossword" onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CrosswordBoardPreview({ layout, loading }: { layout: CrosswordLayout | null; loading: boolean }) {
|
||
const cellSize = layout ? Math.max(3, Math.min(8, Math.floor(190 / Math.max(layout.rows, layout.columns)))) : 6;
|
||
return <section className="crossword-board-preview mt-4" aria-label="Selected size board preview" aria-busy={loading}>
|
||
<div className="crossword-preview-heading"><span>Layout preview</span>{layout && <small>{layout.entries.length} words · {layout.columns} × {layout.rows}</small>}</div>
|
||
<div className="crossword-preview-canvas">
|
||
{loading ? <div className="crossword-preview-loading">Composing puzzle…</div> : layout ? <div className="crossword-preview-grid" style={{ gridTemplateColumns: `repeat(${layout.columns}, ${cellSize}px)`, gridTemplateRows: `repeat(${layout.rows}, ${cellSize}px)` }}>{layout.cells.map((cell) => <i key={cell.key} style={{ gridColumn: cell.column + 1, gridRow: cell.row + 1 }} />)}</div> : <p>Preview unavailable</p>}
|
||
</div>
|
||
</section>;
|
||
}
|