From 611a585757c75b777c8193d24c63ae60cc407a40 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 13 Jul 2026 17:35:18 -0700 Subject: [PATCH] Initial addition of the Arcade study feature. Add connections as the first working game. --- .../20260714010000_add_arcade/migration.sql | 40 + prisma/schema.prisma | 43 +- .../arcade/connections/[packId]/play/page.tsx | 18 + .../[classSlug]/arcade/connections/page.tsx | 12 + .../(protected)/[classSlug]/arcade/layout.tsx | 3 + .../(protected)/[classSlug]/arcade/page.tsx | 46 + src/app/api/arcade/import/preview/route.ts | 19 + .../api/arcade/packs/[id]/attempts/route.ts | 35 + src/app/api/arcade/packs/[id]/route.ts | 38 + src/app/api/arcade/packs/route.ts | 34 + .../api/settings/llm-instructions/route.ts | 13 +- src/app/globals.css | 328 +++ src/app/layout.tsx | 18 +- src/components/activity/ActivityBanner.tsx | 5 +- src/components/arcade/ArcadeGameShell.tsx | 36 + src/components/arcade/ArcadeImportModal.tsx | 120 ++ src/components/arcade/ConnectionsGame.tsx | 237 +++ src/components/arcade/ConnectionsHub.tsx | 123 ++ src/components/arcade/rendererRegistry.ts | 5 + src/components/import/GenerateTab.tsx | 28 +- src/components/ui/Navbar.tsx | 55 +- src/config/arcadeGames.ts | 34 + src/config/studyModes.ts | 7 +- src/generated/prisma/browser.ts | 10 + src/generated/prisma/client.ts | 10 + src/generated/prisma/internal/class.ts | 28 +- .../prisma/internal/prismaNamespace.ts | 191 +- .../prisma/internal/prismaNamespaceBrowser.ts | 39 + src/generated/prisma/models.ts | 2 + src/generated/prisma/models/ArcadeAttempt.ts | 1696 ++++++++++++++++ src/generated/prisma/models/ArcadePack.ts | 1802 +++++++++++++++++ src/generated/prisma/models/Class.ts | 138 ++ src/lib/arcade/connectionsEngine.test.ts | 63 + src/lib/arcade/connectionsEngine.ts | 110 + src/lib/arcade/connectionsImport.test.ts | 74 + src/lib/arcade/connectionsImport.ts | 170 ++ src/lib/arcade/registry.ts | 35 + src/lib/validation/activitySchemas.ts | 2 +- src/lib/validation/arcadeSchemas.ts | 61 + src/services/activityService.ts | 7 +- src/services/arcadeService.ts | 176 ++ src/services/settingsService.ts | 24 +- src/types/arcade.ts | 123 ++ 43 files changed, 5990 insertions(+), 68 deletions(-) create mode 100644 prisma/migrations/20260714010000_add_arcade/migration.sql create mode 100644 src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx create mode 100644 src/app/(protected)/[classSlug]/arcade/connections/page.tsx create mode 100644 src/app/(protected)/[classSlug]/arcade/layout.tsx create mode 100644 src/app/(protected)/[classSlug]/arcade/page.tsx create mode 100644 src/app/api/arcade/import/preview/route.ts create mode 100644 src/app/api/arcade/packs/[id]/attempts/route.ts create mode 100644 src/app/api/arcade/packs/[id]/route.ts create mode 100644 src/app/api/arcade/packs/route.ts create mode 100644 src/components/arcade/ArcadeGameShell.tsx create mode 100644 src/components/arcade/ArcadeImportModal.tsx create mode 100644 src/components/arcade/ConnectionsGame.tsx create mode 100644 src/components/arcade/ConnectionsHub.tsx create mode 100644 src/components/arcade/rendererRegistry.ts create mode 100644 src/config/arcadeGames.ts create mode 100644 src/generated/prisma/models/ArcadeAttempt.ts create mode 100644 src/generated/prisma/models/ArcadePack.ts create mode 100644 src/lib/arcade/connectionsEngine.test.ts create mode 100644 src/lib/arcade/connectionsEngine.ts create mode 100644 src/lib/arcade/connectionsImport.test.ts create mode 100644 src/lib/arcade/connectionsImport.ts create mode 100644 src/lib/arcade/registry.ts create mode 100644 src/lib/validation/arcadeSchemas.ts create mode 100644 src/services/arcadeService.ts create mode 100644 src/types/arcade.ts 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 ( +
+ +
+
+ {(["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.

+