Initial addition of the Arcade study feature. Add connections as the first working game.
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m4s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m4s
This commit is contained in:
parent
7b90409f2e
commit
611a585757
43 changed files with 5990 additions and 68 deletions
123
src/components/arcade/ConnectionsHub.tsx
Normal file
123
src/components/arcade/ConnectionsHub.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"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 type { ArcadeAttemptSummary, ArcadePackSummary } from "@/types/arcade";
|
||||
|
||||
export function ConnectionsHub({
|
||||
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 [allowedMistakes, setAllowedMistakes] = useState(initialPacks[0]?.defaultAllowedMistakes ?? 4);
|
||||
const [oneAwayFeedback, setOneAwayFeedback] = useState(true);
|
||||
const [attempts, setAttempts] = useState<ArcadeAttemptSummary[]>([]);
|
||||
const [attemptsLoading, setAttemptsLoading] = 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]);
|
||||
|
||||
function selectPack(pack: ArcadePackSummary) {
|
||||
setSelectedId(pack.id);
|
||||
setAllowedMistakes(pack.defaultAllowedMistakes);
|
||||
setAttempts([]);
|
||||
setAttemptsLoading(true);
|
||||
}
|
||||
|
||||
async function renamePack(pack: ArcadePackSummary) {
|
||||
const name = window.prompt("Rename Connections 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/connections/${selected.id}/play?mistakes=${allowedMistakes}&oneAway=${oneAwayFeedback ? "1" : "0"}`
|
||||
: "#";
|
||||
|
||||
return (
|
||||
<div className="connections-world connections-hub p-1 pb-20 sm:p-3">
|
||||
<div className="connections-hub-hero mb-7 flex flex-col gap-4 rounded-3xl p-5 sm:flex-row 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="text-xs font-bold uppercase tracking-[0.18em] text-primary">The pattern room</p>
|
||||
<h2 className="editorial-title mt-1 text-4xl text-text-heading">Connections</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-text-secondary">Choose a pack, tune the mistake limit, and find the four hidden groups.</p>
|
||||
</div>
|
||||
<button onClick={() => setShowImport(true)} className="connections-primary-button min-h-12 rounded-xl px-5 text-sm font-bold">Import pack</button>
|
||||
</div>
|
||||
|
||||
{initialPacks.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
|
||||
<div className="mx-auto mb-4 grid w-fit grid-cols-2 gap-1.5" aria-hidden>{["bg-amber-400", "bg-emerald-500", "bg-sky-500", "bg-violet-500"].map((color) => <span key={color} className={`h-8 w-8 rounded-lg ${color}`} />)}</div>
|
||||
<h3 className="editorial-title text-2xl text-text-heading">Import your first board</h3>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-text-secondary">Connections uses purpose-built packs with four groups of four terms.</p>
|
||||
<button onClick={() => setShowImport(true)} className="mt-6 min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white">Import Connections JSON</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(20rem,.85fr)]">
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-extrabold uppercase tracking-[0.14em] text-text-muted">Study packs</h3>
|
||||
<div className="space-y-3">
|
||||
{initialPacks.map((pack) => {
|
||||
const active = pack.id === effectiveSelectedId;
|
||||
return (
|
||||
<article key={pack.id} className={`connections-pack-card rounded-2xl border p-4 transition-all ${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-lg 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 shrink-0 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 className="rounded-full bg-bg-surface-alt px-2.5 py-1">16 tiles</span><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">Best {pack.bestScore ?? "—"}/400</span><span className="rounded-full bg-bg-surface-alt px-2.5 py-1">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 hover:text-text-heading">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="connections-setup h-fit rounded-3xl border border-border-light bg-bg-surface p-5 lg:sticky lg:top-6">
|
||||
<h3 className="editorial-title text-2xl text-text-heading">Round setup</h3>
|
||||
{selected ? <>
|
||||
<p className="mt-1 text-sm font-bold text-primary">{selected.name}</p>
|
||||
<label className="mt-5 block text-sm font-bold text-text-heading">Allowed mistakes <span className="text-primary">{allowedMistakes}</span></label>
|
||||
<input type="range" min={1} max={8} value={allowedMistakes} onChange={(event) => setAllowedMistakes(Number(event.target.value))} className="mt-2 w-full accent-primary" />
|
||||
<label className="mt-5 flex items-start gap-3 rounded-xl bg-bg-surface-alt p-3 text-sm text-text-secondary"><input type="checkbox" checked={oneAwayFeedback} onChange={(event) => setOneAwayFeedback(event.target.checked)} className="mt-1" /><span><strong className="block text-text-heading">One-away feedback</strong>Tell me when three selected tiles belong together.</span></label>
|
||||
<div className="mt-5 rounded-xl border border-border-light p-3 text-sm text-text-secondary"><div className="flex justify-between"><span>Board</span><strong className="text-text-heading">4 × 4</strong></div><div className="mt-2 flex justify-between"><span>Possible score</span><strong className="text-text-heading">400</strong></div></div>
|
||||
<Link href={playHref} className="connections-primary-button mt-5 flex min-h-12 items-center justify-center rounded-xl px-5 text-sm font-extrabold">Enter the board</Link>
|
||||
<div className="mt-7 border-t border-border-light pt-5"><h4 className="text-sm font-extrabold uppercase tracking-wide text-text-muted">Recent attempts</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"><span className="font-bold text-text-heading">{attempt.score}/400</span><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 the round.</p>}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
{showImport && <ArcadeImportModal classId={classId} onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue