diff --git a/prisma/migrations/20260714010000_add_arcade/migration.sql b/prisma/migrations/20260714010000_add_arcade/migration.sql
new file mode 100644
index 0000000..8c2336d
--- /dev/null
+++ b/prisma/migrations/20260714010000_add_arcade/migration.sql
@@ -0,0 +1,40 @@
+-- CreateTable
+CREATE TABLE "ArcadePack" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "classId" TEXT NOT NULL,
+ "gameType" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+ "schemaVersion" INTEGER NOT NULL,
+ "sourceJson" TEXT NOT NULL,
+ "normalizedJson" TEXT NOT NULL,
+ "validationReportJson" TEXT NOT NULL,
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL,
+ CONSTRAINT "ArcadePack_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateTable
+CREATE TABLE "ArcadeAttempt" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "arcadePackId" TEXT NOT NULL,
+ "mode" TEXT NOT NULL,
+ "score" INTEGER NOT NULL,
+ "maxScore" INTEGER NOT NULL,
+ "accuracy" REAL NOT NULL,
+ "durationSeconds" INTEGER NOT NULL,
+ "mistakes" INTEGER NOT NULL,
+ "hintsUsed" INTEGER NOT NULL,
+ "settingsJson" TEXT NOT NULL,
+ "resultsJson" TEXT NOT NULL,
+ "seed" TEXT NOT NULL,
+ "completedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "ArcadeAttempt_arcadePackId_fkey" FOREIGN KEY ("arcadePackId") REFERENCES "ArcadePack" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE INDEX "ArcadePack_classId_gameType_sortOrder_idx" ON "ArcadePack"("classId", "gameType", "sortOrder");
+
+-- CreateIndex
+CREATE INDEX "ArcadeAttempt_arcadePackId_completedAt_idx" ON "ArcadeAttempt"("arcadePackId", "completedAt");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index cfec0f4..1e031d9 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -18,6 +18,7 @@ model Class {
quizSets QuizSet[]
materialGroups MaterialGroup[]
spacedRepetitionSets SpacedRepetitionSet[]
+ arcadePacks ArcadePack[]
}
model Deck {
@@ -146,12 +147,52 @@ model Setting {
model StudyActivity {
id String @id @default(uuid())
- type String // "FLASHCARD" | "QUIZ_QUESTION"
+ type String // "FLASHCARD" | "QUIZ_QUESTION" | "ARCADE_GROUP"
occurredAt DateTime @default(now())
@@index([occurredAt])
}
+model ArcadePack {
+ id String @id @default(uuid())
+ classId String
+ gameType String
+ name String
+ description String?
+ schemaVersion Int
+ sourceJson String
+ normalizedJson String
+ validationReportJson String
+ sortOrder Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
+ attempts ArcadeAttempt[]
+
+ @@index([classId, gameType, sortOrder])
+}
+
+model ArcadeAttempt {
+ id String @id @default(uuid())
+ arcadePackId String
+ mode String
+ score Int
+ maxScore Int
+ accuracy Float
+ durationSeconds Int
+ mistakes Int
+ hintsUsed Int
+ settingsJson String
+ resultsJson String
+ seed String
+ completedAt DateTime @default(now())
+
+ arcadePack ArcadePack @relation(fields: [arcadePackId], references: [id], onDelete: Cascade)
+
+ @@index([arcadePackId, completedAt])
+}
+
model MaterialGroup {
id String @id @default(uuid())
classId String
diff --git a/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx b/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx
new file mode 100644
index 0000000..0b8db3f
--- /dev/null
+++ b/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx
@@ -0,0 +1,18 @@
+import { notFound } from "next/navigation";
+import { randomUUID } from "node:crypto";
+import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry";
+import { getArcadePack } from "@/services/arcadeService";
+
+export default async function ConnectionsPlayPage(
+ props: PageProps<"/[classSlug]/arcade/connections/[packId]/play">
+) {
+ const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]);
+ const pack = await getArcadePack(packId);
+ if (!pack || pack.class.slug !== classSlug || pack.gameType !== "connections") notFound();
+ const mistakesValue = Number(query.mistakes);
+ const allowedMistakes = Number.isInteger(mistakesValue) && mistakesValue >= 1 && mistakesValue <= 8
+ ? mistakesValue
+ : pack.normalized.settings.allowedMistakes;
+ const Renderer = ARCADE_RENDERERS.connections;
+ return ;
+}
diff --git a/src/app/(protected)/[classSlug]/arcade/connections/page.tsx b/src/app/(protected)/[classSlug]/arcade/connections/page.tsx
new file mode 100644
index 0000000..e27c052
--- /dev/null
+++ b/src/app/(protected)/[classSlug]/arcade/connections/page.tsx
@@ -0,0 +1,12 @@
+import { notFound } from "next/navigation";
+import { ConnectionsHub } from "@/components/arcade/ConnectionsHub";
+import { getClassBySlug } from "@/services/classService";
+import { listArcadePacks } from "@/services/arcadeService";
+
+export default async function ConnectionsHubPage(props: PageProps<"/[classSlug]/arcade/connections">) {
+ const { classSlug } = await props.params;
+ const classItem = await getClassBySlug(classSlug);
+ if (!classItem) notFound();
+ const packs = await listArcadePacks(classItem.id, "connections");
+ return ;
+}
diff --git a/src/app/(protected)/[classSlug]/arcade/layout.tsx b/src/app/(protected)/[classSlug]/arcade/layout.tsx
new file mode 100644
index 0000000..5718c28
--- /dev/null
+++ b/src/app/(protected)/[classSlug]/arcade/layout.tsx
@@ -0,0 +1,3 @@
+export default function ArcadeLayout({ children }: { children: React.ReactNode }) {
+ return
{children}
;
+}
diff --git a/src/app/(protected)/[classSlug]/arcade/page.tsx b/src/app/(protected)/[classSlug]/arcade/page.tsx
new file mode 100644
index 0000000..48009cc
--- /dev/null
+++ b/src/app/(protected)/[classSlug]/arcade/page.tsx
@@ -0,0 +1,46 @@
+import Link from "next/link";
+import { ARCADE_GAMES } from "@/config/arcadeGames";
+
+function ConnectionsVisual() {
+ return {Array.from({ length: 16 }, (_, index) => )}
4 hidden groups ;
+}
+
+function CrosswordVisual() {
+ const cells = ["C", "", "", "", "R", "", "O", "", "S", "T", "U", "D", "Y", "", "S", "", "", "", "W", "", "", "", "O", "", "", "", "R", "", "", "", "D", ""];
+ return {cells.map((letter, index) => {letter})}
✎ ;
+}
+
+function FallingBlocksVisual() {
+ return ↓
{Array.from({ length: 9 }, (_, index) => )}
;
+}
+
+function AsteroidVisual() {
+ return
;
+}
+
+function GameVisual({ gameKey }: { gameKey: (typeof ARCADE_GAMES)[number]["key"] }) {
+ if (gameKey === "connections") return ;
+ if (gameKey === "crossword") return ;
+ if (gameKey === "falling-blocks") return ;
+ return ;
+}
+
+export default async function ArcadePage(props: PageProps<"/[classSlug]/arcade">) {
+ const { classSlug } = await props.params;
+ return (
+
+
+
Insert curiosity
+
Choose your cabinet
+
Step away from the desk and turn your study material into a quick challenge.
+
+
+
+ {ARCADE_GAMES.map((game) => {
+ const card =
{game.estimatedMinutes}
{game.name}
{game.description}
{game.available ? "Play Connections →" : "Coming soon"};
+ return game.available ?
{card} :
{card}
;
+ })}
+
+
+ );
+}
diff --git a/src/app/api/arcade/import/preview/route.ts b/src/app/api/arcade/import/preview/route.ts
new file mode 100644
index 0000000..146d911
--- /dev/null
+++ b/src/app/api/arcade/import/preview/route.ts
@@ -0,0 +1,19 @@
+import { NextRequest, NextResponse } from "next/server";
+import { ArcadeImportError } from "@/lib/arcade/connectionsImport";
+import { arcadePreviewRequestSchema } from "@/lib/validation/arcadeSchemas";
+import * as arcadeService from "@/services/arcadeService";
+
+export async function POST(request: NextRequest) {
+ const parsed = arcadePreviewRequestSchema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) {
+ return NextResponse.json({ error: "A game type and raw JSON are required." }, { status: 400 });
+ }
+ try {
+ return NextResponse.json(arcadeService.previewArcadeImport(parsed.data.rawJson));
+ } catch (error) {
+ if (error instanceof ArcadeImportError) {
+ return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
+ }
+ throw error;
+ }
+}
diff --git a/src/app/api/arcade/packs/[id]/attempts/route.ts b/src/app/api/arcade/packs/[id]/attempts/route.ts
new file mode 100644
index 0000000..33a8ed0
--- /dev/null
+++ b/src/app/api/arcade/packs/[id]/attempts/route.ts
@@ -0,0 +1,35 @@
+import { NextRequest, NextResponse } from "next/server";
+import { arcadeAttemptCreateSchema } from "@/lib/validation/arcadeSchemas";
+import * as arcadeService from "@/services/arcadeService";
+
+export async function GET(
+ _request: NextRequest,
+ context: RouteContext<"/api/arcade/packs/[id]/attempts">
+) {
+ const { id } = await context.params;
+ const pack = await arcadeService.getArcadePack(id);
+ if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
+ return NextResponse.json(await arcadeService.listArcadeAttempts(id));
+}
+
+export async function POST(
+ request: NextRequest,
+ context: RouteContext<"/api/arcade/packs/[id]/attempts">
+) {
+ const { id } = await context.params;
+ const parsed = arcadeAttemptCreateSchema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Invalid completed attempt", details: parsed.error.issues }, { status: 400 });
+ }
+ try {
+ const attempt = await arcadeService.createArcadeAttempt(id, parsed.data);
+ return attempt
+ ? NextResponse.json(attempt, { status: 201 })
+ : NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
+ } catch (error) {
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : "Attempt could not be saved" },
+ { status: 400 }
+ );
+ }
+}
diff --git a/src/app/api/arcade/packs/[id]/route.ts b/src/app/api/arcade/packs/[id]/route.ts
new file mode 100644
index 0000000..4ad04f4
--- /dev/null
+++ b/src/app/api/arcade/packs/[id]/route.ts
@@ -0,0 +1,38 @@
+import { NextRequest, NextResponse } from "next/server";
+import { arcadePackUpdateSchema } from "@/lib/validation/arcadeSchemas";
+import * as arcadeService from "@/services/arcadeService";
+
+export async function GET(
+ _request: NextRequest,
+ context: RouteContext<"/api/arcade/packs/[id]">
+) {
+ const { id } = await context.params;
+ const pack = await arcadeService.getArcadePack(id);
+ return pack
+ ? NextResponse.json(pack)
+ : NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
+}
+
+export async function PATCH(
+ request: NextRequest,
+ context: RouteContext<"/api/arcade/packs/[id]">
+) {
+ const { id } = await context.params;
+ const parsed = arcadePackUpdateSchema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) return NextResponse.json({ error: "A valid name is required" }, { status: 400 });
+ const pack = await arcadeService.updateArcadePack(id, parsed.data.name);
+ return pack
+ ? NextResponse.json(pack)
+ : NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
+}
+
+export async function DELETE(
+ _request: NextRequest,
+ context: RouteContext<"/api/arcade/packs/[id]">
+) {
+ const { id } = await context.params;
+ const deleted = await arcadeService.deleteArcadePack(id);
+ return deleted
+ ? NextResponse.json({ success: true })
+ : NextResponse.json({ error: "Arcade pack not found" }, { status: 404 });
+}
diff --git a/src/app/api/arcade/packs/route.ts b/src/app/api/arcade/packs/route.ts
new file mode 100644
index 0000000..cadeb24
--- /dev/null
+++ b/src/app/api/arcade/packs/route.ts
@@ -0,0 +1,34 @@
+import { NextRequest, NextResponse } from "next/server";
+import { ArcadeImportError } from "@/lib/arcade/connectionsImport";
+import { arcadePackCreateSchema } from "@/lib/validation/arcadeSchemas";
+import * as arcadeService from "@/services/arcadeService";
+
+export async function GET(request: NextRequest) {
+ const params = new URL(request.url).searchParams;
+ const classId = params.get("classId");
+ const gameType = params.get("gameType");
+ if (!classId || gameType !== "connections") {
+ return NextResponse.json({ error: "Valid classId and gameType values are required." }, { status: 400 });
+ }
+ return NextResponse.json(await arcadeService.listArcadePacks(classId, gameType));
+}
+
+export async function POST(request: NextRequest) {
+ const parsed = arcadePackCreateSchema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Invalid Arcade pack request", details: parsed.error.issues }, { status: 400 });
+ }
+ try {
+ const packs = await arcadeService.createArcadePacks(parsed.data);
+ return NextResponse.json({ packs, count: packs.length }, { status: 201 });
+ } catch (error) {
+ if (error instanceof ArcadeImportError) {
+ return NextResponse.json({ error: error.message, details: error.details }, { status: 400 });
+ }
+ if (error instanceof Error) {
+ const status = error.message === "Class not found" ? 404 : 400;
+ return NextResponse.json({ error: error.message }, { status });
+ }
+ throw error;
+ }
+}
diff --git a/src/app/api/settings/llm-instructions/route.ts b/src/app/api/settings/llm-instructions/route.ts
index 1e5f7cf..83de0be 100644
--- a/src/app/api/settings/llm-instructions/route.ts
+++ b/src/app/api/settings/llm-instructions/route.ts
@@ -1,16 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import * as settingsService from "@/services/settingsService";
+function getInstructionType(request: NextRequest): settingsService.LlmInstructionType | null {
+ const type = new URL(request.url).searchParams.get("type") ?? "flashcards";
+ return type === "flashcards" || type === "quizzes" || type === "connections" ? type : null;
+}
+
export async function GET(request: NextRequest) {
- const { searchParams } = new URL(request.url);
- const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
+ const type = getInstructionType(request);
+ if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
const instructions = await settingsService.getLlmInstructions(type);
return NextResponse.json({ value: instructions });
}
export async function PATCH(request: NextRequest) {
- const { searchParams } = new URL(request.url);
- const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
+ const type = getInstructionType(request);
+ if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 });
const body = await request.json().catch(() => null);
if (!body || typeof body.value !== "string") {
diff --git a/src/app/globals.css b/src/app/globals.css
index 53aab02..9fba0c7 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -170,6 +170,334 @@
.animate-slide-up { animation: slideUp .32s var(--ease-spring); }
.animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; }
+/* Arcade routes take over the complete application shell, including navigation. */
+body:has(.arcade-route) {
+ --theme-bg-base: #070b18;
+ --theme-bg-surface: #11182a;
+ --theme-bg-surface-alt: #19233a;
+ --theme-bg-callout: #282357;
+ --theme-primary: #9a8cff;
+ --theme-primary-hover: #b5aaff;
+ --theme-text-heading: #f9f7ff;
+ --theme-text-body: #dbe2f4;
+ --theme-text-secondary: #aab6d1;
+ --theme-text-muted: #71809e;
+ --theme-border: #34415c;
+ --theme-border-light: #242f46;
+ min-height: 100vh;
+ background:
+ radial-gradient(circle at 76% 4%, rgba(116, 91, 255, .2), transparent 28rem),
+ radial-gradient(circle at 30% 92%, rgba(5, 198, 181, .11), transparent 34rem),
+ linear-gradient(145deg, #080c19, #0c1221 58%, #0b1020);
+}
+
+body:has(.arcade-route)::before {
+ content: "";
+ position: fixed;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+ opacity: .11;
+ background-image: radial-gradient(rgba(201, 211, 255, .7) .7px, transparent .7px);
+ background-size: 22px 22px;
+}
+
+body:has(.arcade-route) main { min-height: 100vh; }
+body:has(.arcade-route) .app-page { max-width: none; min-height: 100vh; }
+body:has(.arcade-route) .arcade-route { width: min(100%, 1360px); margin-inline: auto; }
+body:has(.arcade-route) .app-sidebar,
+body:has(.arcade-route) .app-mobile-header {
+ border-color: rgba(138, 157, 201, .15);
+ background: rgba(8, 13, 27, .9);
+ box-shadow: 18px 0 55px rgba(0, 0, 0, .16);
+}
+
+body:has(.connections-world) {
+ background:
+ radial-gradient(circle at 28% 0%, rgba(88, 101, 242, .34), transparent 36rem),
+ radial-gradient(circle at 96% 28%, rgba(239, 71, 111, .16), transparent 30rem),
+ linear-gradient(145deg, #0b1329, #0e1932 54%, #142440);
+}
+
+.arcade-lobby-heading { position: relative; }
+.arcade-lobby-heading::after {
+ content: "SELECT GAME";
+ position: absolute;
+ right: 0;
+ top: .5rem;
+ color: rgba(154, 140, 255, .08);
+ font-size: clamp(3rem, 8vw, 7rem);
+ font-weight: 950;
+ letter-spacing: -.06em;
+ line-height: .8;
+ pointer-events: none;
+}
+
+.arcade-cabinet {
+ position: relative;
+ display: flex;
+ min-height: 30rem;
+ flex-direction: column;
+ overflow: hidden;
+ border: 1px solid rgba(148, 163, 202, .2);
+ border-radius: 1.75rem;
+ background: #11182a;
+ box-shadow: inset 0 1px rgba(255,255,255,.06), 0 22px 55px rgba(0,0,0,.28);
+ transition: transform .24s var(--ease-spring), box-shadow .24s ease, border-color .24s ease;
+}
+.arcade-cabinet.is-playable:hover { transform: translateY(-7px) rotate(-.3deg); border-color: rgba(181,170,255,.65); box-shadow: 0 30px 70px rgba(0,0,0,.38), 0 0 45px rgba(126,103,255,.13); }
+.arcade-cabinet.is-coming { filter: saturate(.82); }
+.arcade-visual { position: relative; height: 13.5rem; overflow: hidden; border-bottom: 1px solid rgba(255,255,255,.1); }
+.arcade-cabinet-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
+.arcade-cabinet-status { font-size: .65rem; font-weight: 900; letter-spacing: .18em; text-transform: uppercase; }
+.arcade-cabinet-copy h3 { margin-top: .35rem; color: white; font-size: 1.55rem; font-weight: 900; letter-spacing: -.04em; }
+.arcade-cabinet-copy > p:not(.arcade-cabinet-status) { margin-top: .55rem; flex: 1; color: #aab6d1; font-size: .86rem; line-height: 1.65; }
+.arcade-cabinet-action { display: grid; min-height: 2.9rem; margin-top: 1.1rem; place-items: center; border: 1px solid rgba(255,255,255,.13); border-radius: .9rem; color: #8d9ab5; background: rgba(5,9,20,.3); font-size: .8rem; font-weight: 850; }
+.is-playable .arcade-cabinet-action { color: #14152a; border-color: transparent; background: linear-gradient(135deg, #a89dff, #8170f5); box-shadow: 0 10px 28px rgba(126,103,255,.28); }
+
+.connections-visual { display: grid; place-items: center; background: radial-gradient(circle at 50% 20%, rgba(180,162,255,.35), transparent 55%), linear-gradient(145deg,#342c64,#1d2042); }
+.connections-visual-grid { display: grid; width: 9.5rem; grid-template-columns: repeat(4, 1fr); gap: .38rem; transform: perspective(400px) rotateX(8deg) rotateZ(-2deg); }
+.connections-visual-grid i { aspect-ratio: 1; border-radius: .5rem; box-shadow: inset 0 1px rgba(255,255,255,.45), 0 5px 10px rgba(0,0,0,.22); }
+.connections-visual-grid i:nth-child(-n+4) { background: #ffd166; }
+.connections-visual-grid i:nth-child(n+5):nth-child(-n+8) { background: #5ee0a0; }
+.connections-visual-grid i:nth-child(n+9):nth-child(-n+12) { background: #5ac8fa; }
+.connections-visual-grid i:nth-child(n+13) { background: #b69cff; }
+.connections-visual-label { position: absolute; right: 1rem; bottom: .75rem; color: rgba(255,255,255,.6); font-size: .62rem; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
+
+.crossword-visual { display: grid; place-items: center; background: linear-gradient(150deg,#f4dca1,#c98c45); }
+.crossword-visual::before { content: ""; position: absolute; inset: 0; opacity: .12; background-image: linear-gradient(#412911 1px,transparent 1px),linear-gradient(90deg,#412911 1px,transparent 1px); background-size: 18px 18px; }
+.crossword-grid { display: grid; width: 10rem; grid-template-columns: repeat(8,1fr); gap: 2px; transform: rotate(-3deg); filter: drop-shadow(0 12px 12px rgba(71,38,10,.28)); }
+.crossword-grid i { display: grid; aspect-ratio: 1; place-items: center; background: #312419; color: transparent; font-size: .62rem; font-style: normal; font-weight: 950; }
+.crossword-grid i.has-letter { color: #322112; background: #fff9e9; }
+.crossword-pencil { position: absolute; right: 1.2rem; bottom: .55rem; color: #6d3e17; font-size: 3rem; transform: rotate(-24deg); text-shadow: 0 5px 8px rgba(64,35,10,.2); }
+.arcade-cabinet-crossword .arcade-cabinet-status { color: #f3bb62; }
+
+.blocks-visual { background: linear-gradient(#071a20,#0a3436); }
+.blocks-visual::before { content: ""; position: absolute; inset: 0; opacity: .18; background-image: linear-gradient(rgba(74,222,128,.45) 1px,transparent 1px),linear-gradient(90deg,rgba(74,222,128,.45) 1px,transparent 1px); background-size: 24px 24px; }
+.blocks-piece { position: absolute; display: grid; gap: 3px; }
+.blocks-piece i,.blocks-floor i { border: 1px solid rgba(255,255,255,.35); border-radius: 4px; box-shadow: inset 0 0 8px rgba(255,255,255,.25),0 0 13px currentColor; }
+.blocks-piece-a { left: 32%; top: 18%; grid-template-columns: repeat(2,1.8rem); color: #38e6c5; animation: arcadeBlockHover 2s ease-in-out infinite; }
+.blocks-piece-a i { width: 1.8rem; height: 1.8rem; background: #0fcbaa; }
+.blocks-piece-b { right: 18%; top: 42%; grid-template-columns: repeat(3,1.55rem); color: #ff4f91; transform: rotate(90deg); }
+.blocks-piece-b i { width: 1.55rem; height: 1.55rem; background: #ed397e; }
+.blocks-floor { position: absolute; right: 12%; bottom: 1rem; left: 12%; display: grid; grid-template-columns: repeat(6,1fr); gap: 3px; }
+.blocks-floor i { aspect-ratio: 1; color: #ffc857; background: #eaaa2e; }
+.blocks-floor i:nth-child(3n) { color: #6c7cff; background: #5868ee; transform: translateY(-1.5rem); }
+.blocks-arrow { position: absolute; left: 19%; top: 20%; color: rgba(103,232,211,.5); font-size: 2.2rem; animation: arcadeArrowDown 1.3s ease-in-out infinite; }
+.arcade-cabinet-falling-blocks .arcade-cabinet-status { color: #49ddb6; }
+
+.asteroid-visual { background: radial-gradient(circle at 72% 28%,#253c74,#101735 45%,#070b19); }
+.arcade-star { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: white; box-shadow: 0 0 8px white; }
+.star-a { left: 16%; top: 18%; }.star-b { right: 18%; top: 12%; }.star-c { left: 48%; top: 39%; }
+.asteroid { position: absolute; border: 2px solid #8c91a6; border-radius: 47% 53% 42% 58%; background: radial-gradient(circle at 32% 28%,#9299ac,#4c5268 62%,#292e41); box-shadow: inset -7px -8px 12px rgba(0,0,0,.35),0 8px 18px rgba(0,0,0,.35); }
+.asteroid::after { content: ""; position: absolute; width: 28%; height: 24%; left: 18%; top: 22%; border-radius: 50%; background: rgba(32,37,55,.45); }
+.asteroid-a { width: 3.2rem; height: 3rem; right: 14%; top: 18%; transform: rotate(18deg); }.asteroid-b { width: 2rem; height: 2rem; left: 16%; top: 27%; }.asteroid-c { width: 1.4rem; height: 1.35rem; right: 32%; bottom: 18%; }
+.space-station { position: absolute; left: 20%; bottom: 18%; width: 4rem; height: 1.4rem; border-radius: 65% 20% 35% 65%; background: linear-gradient(90deg,#b9c8ef,#6f83bd); transform: rotate(-12deg); box-shadow: 0 0 20px rgba(105,151,255,.35); }
+.space-station::before { content: ""; position: absolute; left: 1.2rem; bottom: 1rem; border-right: 1rem solid transparent; border-bottom: 1.5rem solid #53699f; border-left: .25rem solid transparent; }
+.space-station i { position: absolute; right: -.75rem; top: .3rem; width: 1rem; height: .8rem; border-radius: 50%; background: #50e6ff; box-shadow: 0 0 16px #4fdff8; }
+.laser-beam { position: absolute; width: 38%; height: 2px; left: 42%; top: 52%; background: linear-gradient(90deg,#70f4ff,transparent); transform: rotate(-20deg); transform-origin: left; box-shadow: 0 0 8px #52e7ff; }
+.shield-ring { position: absolute; left: 8%; bottom: 4%; width: 7.5rem; height: 6rem; border: 2px solid rgba(90,221,255,.25); border-radius: 50%; transform: rotate(-15deg); }
+.arcade-cabinet-asteroid-defense .arcade-cabinet-status { color: #65c9ff; }
+
+@keyframes arcadeBlockHover { 0%,100% { transform: translateY(0); } 50% { transform: translateY(8px); } }
+@keyframes arcadeArrowDown { 0%,100% { opacity: .3; transform: translateY(-5px); } 50% { opacity: .8; transform: translateY(6px); } }
+
+/* Connections has its own playful game-show visual system, independent of the desk UI. */
+.connections-world {
+ --theme-bg-surface: #182443;
+ --theme-bg-surface-alt: #223154;
+ --theme-bg-callout: #2c3d68;
+ --theme-primary: #ffd166;
+ --theme-primary-hover: #ffe29a;
+ --theme-text-heading: #f8fbff;
+ --theme-text-body: #dbe7ff;
+ --theme-text-secondary: #afc0df;
+ --theme-text-muted: #8497bd;
+ --theme-border: #52668d;
+ --theme-border-light: #33486f;
+ position: relative;
+ isolation: isolate;
+ overflow: hidden;
+ color: var(--theme-text-body);
+ background:
+ radial-gradient(circle at 10% 0%, rgba(88, 101, 242, .45), transparent 28rem),
+ radial-gradient(circle at 95% 25%, rgba(239, 71, 111, .2), transparent 25rem),
+ linear-gradient(145deg, #101a35 0%, #111d39 48%, #172846 100%);
+ box-shadow: 0 28px 80px rgba(8, 15, 34, .28);
+}
+
+.connections-hub.connections-world,
+.connections-stage.connections-world {
+ overflow: visible;
+ background: transparent;
+ box-shadow: none;
+}
+.connections-hub.connections-world::before,
+.connections-stage.connections-world::before { display: none; }
+
+.connections-world::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ opacity: .13;
+ background-image:
+ linear-gradient(rgba(255,255,255,.18) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255,255,255,.18) 1px, transparent 1px);
+ background-size: 42px 42px;
+ mask-image: linear-gradient(to bottom, black, transparent 78%);
+}
+
+.connections-world .editorial-title {
+ font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
+ font-weight: 850;
+ letter-spacing: -.045em;
+}
+
+.connections-hub-hero,
+.connections-topbar,
+.connections-status,
+.connections-setup,
+.connections-import {
+ backdrop-filter: blur(18px);
+ box-shadow: inset 0 1px rgba(255,255,255,.08), 0 18px 45px rgba(2, 8, 23, .22);
+}
+
+.connections-hub-hero {
+ background: linear-gradient(120deg, rgba(34,49,84,.94), rgba(24,36,67,.72));
+ border: 1px solid rgba(151, 174, 221, .18);
+}
+
+.connections-pack-card {
+ border-color: rgba(117, 141, 187, .28);
+ background: rgba(24, 36, 67, .74);
+ box-shadow: inset 0 1px rgba(255,255,255,.05), 0 12px 30px rgba(3, 9, 24, .16);
+}
+
+.connections-pack-card:hover {
+ transform: translateY(-2px);
+ border-color: rgba(255, 209, 102, .48);
+}
+
+.connections-pack-card.is-active {
+ border-color: #ffd166;
+ background: linear-gradient(120deg, rgba(65, 72, 132, .9), rgba(38, 55, 96, .9));
+ box-shadow: 0 0 0 1px rgba(255,209,102,.25), 0 18px 45px rgba(3,9,24,.25);
+}
+
+.connections-primary-button {
+ color: #172033;
+ background: linear-gradient(135deg, #ffd166, #ffb84d);
+ box-shadow: 0 10px 26px rgba(255, 184, 77, .24), inset 0 1px rgba(255,255,255,.5);
+ transition: transform .18s var(--ease-spring), filter .18s ease, box-shadow .18s ease;
+}
+
+.connections-primary-button:not(:disabled):hover {
+ transform: translateY(-2px) scale(1.01);
+ filter: brightness(1.06);
+ box-shadow: 0 14px 34px rgba(255, 184, 77, .32), inset 0 1px rgba(255,255,255,.6);
+}
+
+.connections-secondary-button {
+ color: #dbe7ff;
+ border: 1px solid #52668d;
+ background: rgba(24, 36, 67, .82);
+ transition: transform .18s var(--ease-spring), background .18s ease;
+}
+
+.connections-secondary-button:not(:disabled):hover { transform: translateY(-2px); background: #2c3d68; }
+
+.connections-tile {
+ color: #15213a;
+ border: 1px solid rgba(255,255,255,.75);
+ background: linear-gradient(145deg, #fffaf0, #e9effa);
+ box-shadow: 0 7px 0 #aebbd2, 0 12px 24px rgba(2,8,23,.24), inset 0 1px #fff;
+ animation: connectionsTileIn .36s var(--ease-spring) both;
+ transition: transform .16s var(--ease-spring), box-shadow .16s ease, color .16s ease, background .16s ease;
+}
+
+.connections-tile:hover { transform: translateY(-3px); box-shadow: 0 9px 0 #aebbd2, 0 16px 30px rgba(2,8,23,.28), inset 0 1px #fff; }
+.connections-tile.is-selected {
+ color: #fff;
+ border-color: #8c7cf7;
+ background: linear-gradient(145deg, #735df2, #5540ca);
+ box-shadow: 0 5px 0 #30228e, 0 12px 28px rgba(84,64,202,.4), inset 0 1px rgba(255,255,255,.28);
+ transform: translateY(2px) scale(.975);
+ animation: connectionsTileSelect .25s var(--ease-spring);
+}
+
+.connections-group,
+.connections-review-card {
+ color: #172033;
+ box-shadow: inset 0 1px rgba(255,255,255,.55), 0 10px 26px rgba(2,8,23,.2);
+}
+.connections-group .text-text-heading,
+.connections-group .text-text-secondary,
+.connections-review-card .text-text-heading,
+.connections-review-card .text-text-secondary { color: #172033; }
+.connections-group { animation: connectionsGroupReveal .56s var(--ease-spring) both; }
+.connections-group-0 { background: linear-gradient(135deg, #ffd166, #f7b944); }
+.connections-group-1 { background: linear-gradient(135deg, #70e1a1, #36bd7c); }
+.connections-group-2 { background: linear-gradient(135deg, #72d5f7, #4aa8e8); }
+.connections-group-3 { background: linear-gradient(135deg, #b9a2ff, #8d73ed); }
+
+.connections-results-hero {
+ background: linear-gradient(130deg, #5b46d8, #283d77 58%, #164e63);
+ box-shadow: inset 0 1px rgba(255,255,255,.18), 0 22px 50px rgba(2,8,23,.3);
+}
+
+.connections-completion {
+ position: relative;
+ display: grid;
+ min-height: 32rem;
+ place-content: center;
+ overflow: hidden;
+ border-radius: 1.75rem;
+ text-align: center;
+ background: radial-gradient(circle, rgba(94,234,212,.2), transparent 42%), rgba(10,18,39,.72);
+ animation: connectionsCompletionIn .55s var(--ease-spring) both;
+}
+.connections-completion-mark {
+ display: grid;
+ width: 6rem;
+ height: 6rem;
+ margin: 0 auto 1.5rem;
+ place-items: center;
+ border-radius: 999px;
+ color: #172033;
+ background: #ffd166;
+ box-shadow: 0 0 0 12px rgba(255,209,102,.12), 0 0 65px rgba(255,209,102,.46);
+ font-size: 3rem;
+ font-weight: 900;
+ animation: connectionsWinMark .8s .15s var(--ease-spring) both;
+}
+.connections-completion p { color: #8ee7d1; font-size: .75rem; font-weight: 850; letter-spacing: .2em; text-transform: uppercase; }
+.connections-completion h2 { margin-top: .35rem; color: white; font-size: clamp(2.5rem, 8vw, 5.5rem); font-weight: 900; letter-spacing: -.065em; line-height: .95; }
+.connections-completion span { margin-top: 1rem; color: #b9c8e5; font-weight: 700; }
+
+.connections-confetti { position: absolute; inset: 0; pointer-events: none; }
+.connections-confetti i {
+ position: absolute;
+ top: -8%;
+ left: var(--confetti-x);
+ width: 9px;
+ height: 17px;
+ border-radius: 2px;
+ background: var(--confetti-color);
+ animation: connectionsConfettiFall 1.45s var(--confetti-delay) cubic-bezier(.2,.7,.3,1) both;
+}
+
+@keyframes connectionsTileIn { from { opacity: 0; transform: translateY(16px) scale(.94); } to { opacity: 1; transform: translateY(0) scale(1); } }
+@keyframes connectionsTileSelect { 0% { transform: scale(1); } 55% { transform: translateY(3px) scale(.94); } 100% { transform: translateY(2px) scale(.975); } }
+@keyframes connectionsShake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } }
+@keyframes connectionsGroupReveal { from { opacity: 0; transform: scale(.9) translateY(18px); filter: brightness(1.6); } 65% { transform: scale(1.025) translateY(-2px); } to { opacity: 1; transform: scale(1) translateY(0); filter: brightness(1); } }
+@keyframes connectionsCompletionIn { from { opacity: 0; transform: scale(.94); } to { opacity: 1; transform: scale(1); } }
+@keyframes connectionsWinMark { from { opacity: 0; transform: scale(.2) rotate(-25deg); } 70% { transform: scale(1.12) rotate(4deg); } to { opacity: 1; transform: scale(1) rotate(0); } }
+@keyframes connectionsConfettiFall { 0% { opacity: 0; transform: translateY(-1rem) rotate(0); } 10% { opacity: 1; } 100% { opacity: 1; transform: translateY(38rem) rotate(var(--confetti-spin)); } }
+@keyframes connectionsResultsIn { from { opacity: 0; transform: translateY(22px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
+.animate-connections-shake { animation: connectionsShake .42s ease-in-out; }
+.animate-connections-results-in { animation: connectionsResultsIn .52s var(--ease-spring) both; }
+
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); }
.markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
.markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index f92c5fb..6fbaecf 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import { Manrope, Newsreader } from "next/font/google";
-import Script from "next/script";
import "./globals.css";
const manrope = Manrope({
@@ -26,10 +25,12 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
-
-
+ `,
+ }}
+ />
+
+
{children}
diff --git a/src/components/activity/ActivityBanner.tsx b/src/components/activity/ActivityBanner.tsx
index eea45cf..0f15abb 100644
--- a/src/components/activity/ActivityBanner.tsx
+++ b/src/components/activity/ActivityBanner.tsx
@@ -6,6 +6,7 @@ interface DailyActivity {
date: string;
flashcards: number;
questions: number;
+ arcade: number;
total: number;
level: 0 | 1 | 2 | 3 | 4;
}
@@ -152,7 +153,7 @@ function DayTooltip({ day }: { day: DailyActivity }) {
return (
{formatted} · {day.total} total
-
{day.flashcards} flashcards · {day.questions} questions
+
{day.flashcards} flashcards · {day.questions} questions · {day.arcade} arcade groups
);
}
@@ -183,5 +184,5 @@ function getMonthLabel(week: DailyActivity[], previousWeek?: DailyActivity[]) {
}
function getDayAriaLabel(day: DailyActivity) {
- return `${day.date}: ${day.flashcards} flashcards and ${day.questions} questions, ${day.total} total activities`;
+ return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, and ${day.arcade} arcade groups, ${day.total} total activities`;
}
diff --git a/src/components/arcade/ArcadeGameShell.tsx b/src/components/arcade/ArcadeGameShell.tsx
new file mode 100644
index 0000000..5c10a7b
--- /dev/null
+++ b/src/components/arcade/ArcadeGameShell.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { useRouter } from "next/navigation";
+
+export function ArcadeGameShell({
+ title,
+ exitHref,
+ elapsedSeconds,
+ complete,
+ children,
+}: {
+ title: string;
+ exitHref: string;
+ elapsedSeconds: number;
+ complete: boolean;
+ children: ReactNode;
+}) {
+ const router = useRouter();
+ function exitGame() {
+ if (!complete && !window.confirm("Leave this round? Your progress will not be saved.")) return;
+ router.push(exitHref);
+ }
+ const minutes = Math.floor(elapsedSeconds / 60);
+ const seconds = String(elapsedSeconds % 60).padStart(2, "0");
+ return (
+
+
+
+ {title}
+ {minutes}:{seconds}
+
+ {children}
+
+ );
+}
diff --git a/src/components/arcade/ArcadeImportModal.tsx b/src/components/arcade/ArcadeImportModal.tsx
new file mode 100644
index 0000000..95284c8
--- /dev/null
+++ b/src/components/arcade/ArcadeImportModal.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useState } from "react";
+import { GenerateTab } from "@/components/import/GenerateTab";
+import type { ArcadeImportBatchPreview } from "@/types/arcade";
+
+export function ArcadeImportModal({
+ classId,
+ onClose,
+ onImported,
+}: {
+ classId: string;
+ onClose: () => void;
+ onImported: () => void;
+}) {
+ const [tab, setTab] = useState<"generate" | "import">("import");
+ const [rawJson, setRawJson] = useState("");
+ const [names, setNames] = useState([]);
+ const [preview, setPreview] = useState(null);
+ const [error, setError] = useState("");
+ const [details, setDetails] = useState([]);
+ 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: "connections", 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: "connections",
+ rawJson,
+ names: names.map((name) => name.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 (
+
+
+
+
+
Import Connections pack
+
+
+
+ {(["generate", "import"] as const).map((item) => (
+
+ ))}
+
+
+ {tab === "generate" ?
: (
+
+
Paste one pack object or an array of up to ten packs. Every board is validated before anything is saved.
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/arcade/ConnectionsGame.tsx b/src/components/arcade/ConnectionsGame.tsx
new file mode 100644
index 0000000..20407cd
--- /dev/null
+++ b/src/components/arcade/ConnectionsGame.tsx
@@ -0,0 +1,237 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
+import { ArcadeGameShell } from "@/components/arcade/ArcadeGameShell";
+import { deterministicShuffle, replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
+import type {
+ ArcadeRoundResult,
+ ArcadeSessionSettings,
+ ConnectionsSubmission,
+ NormalizedConnectionsPack,
+} from "@/types/arcade";
+
+export function ConnectionsGame({
+ seed,
+ packId,
+ packName,
+ classSlug,
+ pack,
+ settings,
+}: {
+ seed: string;
+ packId: string;
+ packName: string;
+ classSlug: string;
+ pack: NormalizedConnectionsPack;
+ settings: ArcadeSessionSettings;
+}) {
+ const allItems = useMemo(() => pack.content.flatMap((group) => group.items), [pack]);
+ const itemById = useMemo(() => new Map(allItems.map((item) => [item.id, item])), [allItems]);
+ const [tileOrder, setTileOrder] = useState(() => deterministicShuffle(allItems.map((item) => item.id), seed));
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [submissions, setSubmissions] = useState([]);
+ const [feedback, setFeedback] = useState("Select four related tiles.");
+ const [result, setResult] = useState(null);
+ const [finishing, setFinishing] = useState(null);
+ const [feedbackKind, setFeedbackKind] = useState<"idle" | "correct" | "wrong">("idle");
+ const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
+ const shuffleCount = useRef(0);
+ const startedAt = useRef(0);
+ const replay = useMemo(
+ () => replayConnectionsAttempt(pack, submissions, settings, elapsedSeconds),
+ [elapsedSeconds, pack, settings, submissions]
+ );
+ const solvedGroupIds = useMemo(
+ () => new Set(replay.result.groups.filter((group) => group.solved).map((group) => group.groupId)),
+ [replay.result.groups]
+ );
+ const solvedItemIds = useMemo(
+ () => new Set(pack.content.filter((group) => solvedGroupIds.has(group.id)).flatMap((group) => group.items.map((item) => item.id))),
+ [pack, solvedGroupIds]
+ );
+ const unresolvedOrder = tileOrder.filter((id) => !solvedItemIds.has(id));
+
+ useEffect(() => {
+ if (startedAt.current === 0) startedAt.current = Date.now();
+ if (result) return;
+ const interval = window.setInterval(() => setElapsedSeconds(Math.floor((Date.now() - startedAt.current) / 1000)), 1000);
+ return () => window.clearInterval(interval);
+ }, [result]);
+
+ function toggleTile(id: string) {
+ if (result || solvedItemIds.has(id)) return;
+ setSelectedIds((current) => current.includes(id) ? current.filter((itemId) => itemId !== id) : current.length < 4 ? [...current, id] : current);
+ }
+
+ async function persistAttempt(nextSubmissions: ConnectionsSubmission[], roundResult: ArcadeRoundResult) {
+ setSaveState("saving");
+ const response = await fetch(`/api/arcade/packs/${packId}/attempts`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ mode: "CLASSIC",
+ durationSeconds: roundResult.durationSeconds,
+ seed,
+ settings,
+ submissions: nextSubmissions,
+ }),
+ });
+ if (!response.ok) {
+ setSaveState("error");
+ return;
+ }
+ await response.json();
+ setSaveState("saved");
+ }
+
+ function submitSelection() {
+ if (selectedIds.length !== 4 || result) return;
+ const previousSolved = solvedGroupIds.size;
+ const nextSubmissions = [...submissions, { itemIds: selectedIds, elapsedMs: Date.now() - startedAt.current }];
+ const duration = Math.floor((Date.now() - startedAt.current) / 1000);
+ const nextReplay = replayConnectionsAttempt(pack, nextSubmissions, settings, duration);
+ setSubmissions(nextSubmissions);
+ setSelectedIds([]);
+ if (nextReplay.result.groups.filter((group) => group.solved).length > previousSolved) {
+ setFeedback("Correct group found.");
+ setFeedbackKind("correct");
+ } else if (nextReplay.result.hintsUsed > replay.result.hintsUsed) {
+ setFeedback("One away — three of those tiles belong together.");
+ setFeedbackKind("wrong");
+ } else {
+ setFeedback("Not a group. Try another combination.");
+ setFeedbackKind("wrong");
+ }
+ if (nextReplay.complete) {
+ setElapsedSeconds(duration);
+ setFinishing(nextReplay.result);
+ window.setTimeout(() => {
+ setResult(nextReplay.result);
+ setFinishing(null);
+ }, nextReplay.result.outcome === "WON" ? 1700 : 850);
+ void persistAttempt(nextSubmissions, nextReplay.result);
+ }
+ }
+
+ function shuffleTiles() {
+ shuffleCount.current += 1;
+ const shuffled = deterministicShuffle(unresolvedOrder, `${seed}-shuffle-${shuffleCount.current}`);
+ setTileOrder([...tileOrder.filter((id) => solvedItemIds.has(id)), ...shuffled]);
+ setSelectedIds([]);
+ setFeedback("Unsolved tiles shuffled.");
+ setFeedbackKind("idle");
+ }
+
+ function handleTileKey(event: React.KeyboardEvent, id: string) {
+ const moves: Record = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -4, ArrowDown: 4 };
+ const move = moves[event.key];
+ if (!move) return;
+ event.preventDefault();
+ const index = unresolvedOrder.indexOf(id);
+ const next = unresolvedOrder[(index + move + unresolvedOrder.length) % unresolvedOrder.length];
+ document.querySelector(`[data-arcade-tile="${next}"]`)?.focus();
+ }
+
+ if (result) {
+ return (
+
+ window.location.reload()} exitHref={`/${classSlug}/arcade/connections`} />
+
+ );
+ }
+
+ if (finishing) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
Mistakes remaining
{Array.from({ length: settings.allowedMistakes }, (_, index) => )}
+
{feedback}
+
+
+ {pack.content.filter((group) => solvedGroupIds.has(group.id)).map((group, index) => (
+
+
{group.category}
+
{group.items.map((item) => item.text).join(" · ")}
+
+ ))}
+
+
+ {unresolvedOrder.map((id) => {
+ const item = itemById.get(id);
+ if (!item) return null;
+ const selected = selectedIds.includes(id);
+ return ;
+ })}
+
+
+
+
+
+
+
+
+ );
+}
+
+function ConnectionsResults({
+ result,
+ itemById,
+ saveState,
+ onReplay,
+ exitHref,
+}: {
+ result: ArcadeRoundResult;
+ itemById: Map;
+ saveState: "idle" | "saving" | "saved" | "error";
+ onReplay: () => void;
+ exitHref: string;
+}) {
+ const orderedGroups = [...result.groups].sort((a, b) => Number(a.solved) - Number(b.solved));
+ return (
+
+
+ Round complete
+ {result.outcome === "WON" ? "Board cleared" : "Groups revealed"}
+ {[["Score", `${result.score}/${result.maxScore}`], ["Accuracy", `${Math.round(result.accuracy * 100)}%`], ["Mistakes", result.mistakes], ["Time", `${result.durationSeconds}s`]].map(([label, value]) =>
)}
+ {saveState === "saving" ? "Saving attempt…" : saveState === "error" ? "Attempt could not be saved." : "Attempt saved."}
+
+
{orderedGroups.map((group, index) =>
{group.solved ? "Solved" : "Revealed"}
{group.category}
{group.items.join(" · ")}
{group.explanation}
)}
+ {result.incorrectSelections.length > 0 &&
Incorrect selections
{result.incorrectSelections.map((selection, index) => - {selection.map((id) => itemById.get(id)?.text ?? id).join(" · ")}
)}
}
+
+
+ );
+}
+
+function CompletionTransition({ result }: { result: ArcadeRoundResult }) {
+ const won = result.outcome === "WON";
+ return (
+
+ {won &&
}
+
{won ? "✓" : "!"}
+
{won ? "Perfect connection" : "Round complete"}
+
{won ? "Board cleared!" : "Groups revealed"}
+
{won ? `${result.score} points` : "Let’s review the board"}
+
+ );
+}
+
+function ConfettiBurst() {
+ const colors = ["#ffd166", "#ef476f", "#06d6a0", "#4cc9f0", "#a78bfa"];
+ return {Array.from({ length: 52 }, (_, index) => {
+ const style = {
+ "--confetti-x": `${(index * 37) % 100}%`,
+ "--confetti-delay": `${(index % 13) * 0.035}s`,
+ "--confetti-spin": `${180 + (index % 7) * 80}deg`,
+ "--confetti-color": colors[index % colors.length],
+ } as CSSProperties;
+ return ;
+ })}
;
+}
diff --git a/src/components/arcade/ConnectionsHub.tsx b/src/components/arcade/ConnectionsHub.tsx
new file mode 100644
index 0000000..40d623a
--- /dev/null
+++ b/src/components/arcade/ConnectionsHub.tsx
@@ -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([]);
+ 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 (
+
+
+
+
← Back to Arcade
+
The pattern room
+
Connections
+
Choose a pack, tune the mistake limit, and find the four hidden groups.
+
+
+
+
+ {initialPacks.length === 0 ? (
+
+
{["bg-amber-400", "bg-emerald-500", "bg-sky-500", "bg-violet-500"].map((color) => )}
+
Import your first board
+
Connections uses purpose-built packs with four groups of four terms.
+
+
+ ) : (
+
+
+ Study packs
+
+ {initialPacks.map((pack) => {
+ const active = pack.id === effectiveSelectedId;
+ return (
+
+
+
+
+ );
+ })}
+
+
+
+
+
+ )}
+ {showImport &&
setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
+
+ );
+}
diff --git a/src/components/arcade/rendererRegistry.ts b/src/components/arcade/rendererRegistry.ts
new file mode 100644
index 0000000..dec231f
--- /dev/null
+++ b/src/components/arcade/rendererRegistry.ts
@@ -0,0 +1,5 @@
+import { ConnectionsGame } from "@/components/arcade/ConnectionsGame";
+
+export const ARCADE_RENDERERS = {
+ connections: ConnectionsGame,
+} as const;
diff --git a/src/components/import/GenerateTab.tsx b/src/components/import/GenerateTab.tsx
index c2f58eb..6383d10 100644
--- a/src/components/import/GenerateTab.tsx
+++ b/src/components/import/GenerateTab.tsx
@@ -2,12 +2,18 @@
import { useState, useEffect } from "react";
-export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" }) {
+export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" }) {
const [instructions, setInstructions] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
const [copied, setCopied] = useState(false);
+ const [packCount, setPackCount] = useState(1);
+
+ const batchOverride = importType === "connections" && packCount > 1
+ ? `TEMPORARY BATCH OVERRIDE:\nGenerate exactly ${packCount} distinct Connections packs. Return one raw JSON array containing exactly ${packCount} objects that each follow the schema below. Make the packs meaningfully different from one another. This override takes precedence over any later instruction to return one object.\n\n`
+ : "";
+ const displayedInstructions = `${batchOverride}${instructions}`;
useEffect(() => {
fetch(`/api/settings/llm-instructions?type=${importType}`)
@@ -52,7 +58,7 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
}
async function handleCopy() {
- await navigator.clipboard.writeText(instructions);
+ await navigator.clipboard.writeText(displayedInstructions);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
@@ -67,9 +73,23 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
Copy these instructions and paste them into an LLM chat along with your study material. The LLM will generate JSON you can paste into the Import tab.
+ {importType === "connections" && (
+
+
+
+
+
This temporarily updates the copied prompt. Your saved instructions stay unchanged.
+
+
+
+
setPackCount(Number(event.target.value))} className="mt-4 w-full accent-primary" />
+
1510
+
+ )}
+