From 5bec95fb301296542965e44dcb469c3391839f0b Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 14 Jul 2026 19:32:41 -0700 Subject: [PATCH] Initial crossword addition and arcade redesign --- .../arcade/connections/[packId]/play/page.tsx | 7 +- .../arcade/crossword/[packId]/play/page.tsx | 21 ++ .../[classSlug]/arcade/crossword/page.tsx | 12 + .../(protected)/[classSlug]/arcade/page.tsx | 2 +- src/app/api/arcade/import/preview/route.ts | 4 +- .../api/arcade/packs/[id]/attempts/route.ts | 13 +- src/app/api/arcade/packs/[id]/layout/route.ts | 23 ++ src/app/api/arcade/packs/route.ts | 4 +- .../api/settings/llm-instructions/route.ts | 2 +- src/app/globals.css | 200 +++++++++++++ src/components/arcade/ArcadeGameShell.tsx | 6 +- src/components/arcade/ArcadeImportModal.tsx | 29 +- src/components/arcade/ConnectionsHub.tsx | 2 +- src/components/arcade/CrosswordGame.tsx | 264 +++++++++++++++++ src/components/arcade/CrosswordHub.tsx | 150 ++++++++++ src/components/arcade/rendererRegistry.ts | 2 + src/components/import/GenerateTab.tsx | 2 +- src/config/arcadeGames.ts | 4 +- src/lib/arcade/arcadeImport.ts | 5 + src/lib/arcade/connectionsImport.ts | 12 +- src/lib/arcade/crosswordAttempt.test.ts | 34 +++ src/lib/arcade/crosswordAttempt.ts | 116 ++++++++ src/lib/arcade/crosswordEngine.test.ts | 26 ++ src/lib/arcade/crosswordEngine.ts | 266 ++++++++++++++++++ src/lib/arcade/crosswordImport.test.ts | 47 ++++ src/lib/arcade/crosswordImport.ts | 126 +++++++++ src/lib/arcade/crosswordTestData.ts | 22 ++ src/lib/arcade/registry.ts | 27 +- src/lib/validation/arcadeSchemas.ts | 50 +++- src/services/arcadeService.ts | 70 ++++- src/services/settingsService.ts | 5 +- src/types/arcade.ts | 138 ++++++++- 32 files changed, 1635 insertions(+), 56 deletions(-) create mode 100644 src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx create mode 100644 src/app/(protected)/[classSlug]/arcade/crossword/page.tsx create mode 100644 src/app/api/arcade/packs/[id]/layout/route.ts create mode 100644 src/components/arcade/CrosswordGame.tsx create mode 100644 src/components/arcade/CrosswordHub.tsx create mode 100644 src/lib/arcade/arcadeImport.ts create mode 100644 src/lib/arcade/crosswordAttempt.test.ts create mode 100644 src/lib/arcade/crosswordAttempt.ts create mode 100644 src/lib/arcade/crosswordEngine.test.ts create mode 100644 src/lib/arcade/crosswordEngine.ts create mode 100644 src/lib/arcade/crosswordImport.test.ts create mode 100644 src/lib/arcade/crosswordImport.ts create mode 100644 src/lib/arcade/crosswordTestData.ts diff --git a/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx b/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx index 0b8db3f..423ee89 100644 --- a/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx +++ b/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx @@ -8,11 +8,12 @@ export default async function ConnectionsPlayPage( ) { 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(); + if (!pack || pack.class.slug !== classSlug || pack.gameType !== "connections" || pack.normalized.type !== "connections") notFound(); + const normalized = pack.normalized; const mistakesValue = Number(query.mistakes); const allowedMistakes = Number.isInteger(mistakesValue) && mistakesValue >= 1 && mistakesValue <= 8 ? mistakesValue - : pack.normalized.settings.allowedMistakes; + : normalized.settings.allowedMistakes; const Renderer = ARCADE_RENDERERS.connections; - return ; + return ; } diff --git a/src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx b/src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx new file mode 100644 index 0000000..e197665 --- /dev/null +++ b/src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx @@ -0,0 +1,21 @@ +import { randomUUID } from "node:crypto"; +import { notFound } from "next/navigation"; +import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry"; +import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine"; +import type { CrosswordSize, NormalizedCrosswordPack } from "@/types/arcade"; +import { getArcadePack } from "@/services/arcadeService"; + +const SIZES = new Set(["mini", "standard", "large", "extra-large"]); + +export default async function CrosswordPlayPage(props: { params: Promise<{ classSlug: string; packId: string }>; searchParams: Promise> }) { + const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]); + const pack = await getArcadePack(packId); + if (!pack || pack.class.slug !== classSlug || pack.gameType !== "crossword" || pack.normalized.type !== "crossword") notFound(); + const requestedSize = typeof query.size === "string" ? query.size : "standard"; + const size: CrosswordSize = SIZES.has(requestedSize as CrosswordSize) ? requestedSize as CrosswordSize : "standard"; + const seed = randomUUID(); + const normalized = pack.normalized as NormalizedCrosswordPack; + const layout = generateCrosswordLayout(normalized, size, seed); + const Renderer = ARCADE_RENDERERS.crossword; + return ; +} diff --git a/src/app/(protected)/[classSlug]/arcade/crossword/page.tsx b/src/app/(protected)/[classSlug]/arcade/crossword/page.tsx new file mode 100644 index 0000000..f96fcba --- /dev/null +++ b/src/app/(protected)/[classSlug]/arcade/crossword/page.tsx @@ -0,0 +1,12 @@ +import { notFound } from "next/navigation"; +import { CrosswordHub } from "@/components/arcade/CrosswordHub"; +import { getClassBySlug } from "@/services/classService"; +import { listArcadePacks } from "@/services/arcadeService"; + +export default async function CrosswordHubPage(props: { params: Promise<{ classSlug: string }> }) { + const { classSlug } = await props.params; + const classItem = await getClassBySlug(classSlug); + if (!classItem) notFound(); + const packs = await listArcadePacks(classItem.id, "crossword"); + return ; +} diff --git a/src/app/(protected)/[classSlug]/arcade/page.tsx b/src/app/(protected)/[classSlug]/arcade/page.tsx index 48009cc..27aa1b4 100644 --- a/src/app/(protected)/[classSlug]/arcade/page.tsx +++ b/src/app/(protected)/[classSlug]/arcade/page.tsx @@ -37,7 +37,7 @@ export default async function ArcadePage(props: PageProps<"/[classSlug]/arcade">
{ARCADE_GAMES.map((game) => { - const card =

{game.estimatedMinutes}

{game.name}

{game.description}

{game.available ? "Play Connections →" : "Coming soon"}
; + const card =

{game.estimatedMinutes}

{game.name}

{game.description}

{game.available ? `Play ${game.name} →` : "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 index 146d911..89370dd 100644 --- a/src/app/api/arcade/import/preview/route.ts +++ b/src/app/api/arcade/import/preview/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { ArcadeImportError } from "@/lib/arcade/connectionsImport"; +import { ArcadeImportError } from "@/lib/arcade/arcadeImport"; import { arcadePreviewRequestSchema } from "@/lib/validation/arcadeSchemas"; import * as arcadeService from "@/services/arcadeService"; @@ -9,7 +9,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "A game type and raw JSON are required." }, { status: 400 }); } try { - return NextResponse.json(arcadeService.previewArcadeImport(parsed.data.rawJson)); + return NextResponse.json(arcadeService.previewArcadeImport(parsed.data.gameType, parsed.data.rawJson)); } catch (error) { if (error instanceof ArcadeImportError) { return NextResponse.json({ error: error.message, details: error.details }, { status: 400 }); diff --git a/src/app/api/arcade/packs/[id]/attempts/route.ts b/src/app/api/arcade/packs/[id]/attempts/route.ts index 33a8ed0..f36cf38 100644 --- a/src/app/api/arcade/packs/[id]/attempts/route.ts +++ b/src/app/api/arcade/packs/[id]/attempts/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { arcadeAttemptCreateSchema } from "@/lib/validation/arcadeSchemas"; +import { arcadeAttemptCreateSchema, crosswordAttemptCreateSchema } from "@/lib/validation/arcadeSchemas"; import * as arcadeService from "@/services/arcadeService"; export async function GET( @@ -17,12 +17,19 @@ export async function POST( context: RouteContext<"/api/arcade/packs/[id]/attempts"> ) { const { id } = await context.params; - const parsed = arcadeAttemptCreateSchema.safeParse(await request.json().catch(() => null)); + const pack = await arcadeService.getArcadePack(id); + if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 }); + const body = await request.json().catch(() => null); + const parsed = pack.gameType === "crossword" + ? crosswordAttemptCreateSchema.safeParse(body) + : arcadeAttemptCreateSchema.safeParse(body); 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); + const attempt = pack.gameType === "crossword" + ? await arcadeService.createCrosswordAttempt(id, parsed.data as Parameters[1]) + : await arcadeService.createArcadeAttempt(id, parsed.data as Parameters[1]); return attempt ? NextResponse.json(attempt, { status: 201 }) : NextResponse.json({ error: "Arcade pack not found" }, { status: 404 }); diff --git a/src/app/api/arcade/packs/[id]/layout/route.ts b/src/app/api/arcade/packs/[id]/layout/route.ts new file mode 100644 index 0000000..db4cd23 --- /dev/null +++ b/src/app/api/arcade/packs/[id]/layout/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine"; +import type { CrosswordSize } from "@/types/arcade"; +import { getArcadePack } from "@/services/arcadeService"; + +const CROSSWORD_SIZES = new Set(["mini", "standard", "large", "extra-large"]); + +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params; + const sizeValue = request.nextUrl.searchParams.get("size") ?? "standard"; + if (!CROSSWORD_SIZES.has(sizeValue as CrosswordSize)) { + return NextResponse.json({ error: "A valid Crossword size is required." }, { status: 400 }); + } + const pack = await getArcadePack(id); + if (!pack || pack.gameType !== "crossword" || pack.normalized.type !== "crossword") { + return NextResponse.json({ error: "Crossword pack not found" }, { status: 404 }); + } + const size = sizeValue as CrosswordSize; + return NextResponse.json(generateCrosswordLayout(pack.normalized, size, `crossword-hub-preview-v1:${id}:${size}`)); +} diff --git a/src/app/api/arcade/packs/route.ts b/src/app/api/arcade/packs/route.ts index cadeb24..e86938e 100644 --- a/src/app/api/arcade/packs/route.ts +++ b/src/app/api/arcade/packs/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { ArcadeImportError } from "@/lib/arcade/connectionsImport"; +import { ArcadeImportError } from "@/lib/arcade/arcadeImport"; import { arcadePackCreateSchema } from "@/lib/validation/arcadeSchemas"; import * as arcadeService from "@/services/arcadeService"; @@ -7,7 +7,7 @@ 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") { + if (!classId || (gameType !== "connections" && gameType !== "crossword")) { return NextResponse.json({ error: "Valid classId and gameType values are required." }, { status: 400 }); } return NextResponse.json(await arcadeService.listArcadePacks(classId, gameType)); diff --git a/src/app/api/settings/llm-instructions/route.ts b/src/app/api/settings/llm-instructions/route.ts index 83de0be..87d57cf 100644 --- a/src/app/api/settings/llm-instructions/route.ts +++ b/src/app/api/settings/llm-instructions/route.ts @@ -3,7 +3,7 @@ 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; + return type === "flashcards" || type === "quizzes" || type === "connections" || type === "crossword" ? type : null; } export async function GET(request: NextRequest) { diff --git a/src/app/globals.css b/src/app/globals.css index 9fba0c7..5d7692f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -498,6 +498,206 @@ body:has(.connections-world) { .animate-connections-shake { animation: connectionsShake .42s ease-in-out; } .animate-connections-results-in { animation: connectionsResultsIn .52s var(--ease-spring) both; } +/* Crossword turns the full arcade shell into a warm, ink-and-paper newsroom. */ +body:has(.crossword-world) { + --theme-bg-base: #20170f; + --theme-bg-surface: #f6ecd2; + --theme-bg-surface-alt: #ead9b5; + --theme-bg-callout: #dfc795; + --theme-primary: #9a5a24; + --theme-primary-hover: #7b431b; + --theme-text-heading: #2d2117; + --theme-text-body: #493725; + --theme-text-secondary: #68523a; + --theme-text-muted: #8a7052; + --theme-border: #a9865d; + --theme-border-light: #cfb88e; + background: + radial-gradient(circle at 18% 8%, rgba(230, 181, 88, .18), transparent 28rem), + linear-gradient(135deg, #1d140d, #3c2818 55%, #21160e); +} +body:has(.crossword-world)::before { + opacity: .16; + background-image: repeating-linear-gradient(0deg, transparent 0 3px, rgba(255,236,195,.16) 4px); +} +body:has(.crossword-world) .app-sidebar, +body:has(.crossword-world) .app-mobile-header { + --theme-bg-surface-alt: #3a2819; + --theme-bg-callout: #e4cc98; + --theme-primary: #8a4f20; + --theme-text-heading: #fff3d8; + --theme-text-secondary: #d7c3a2; + --theme-text-muted: #aa9170; + --theme-border-light: #5b4028; + color: #f8ead0; + border-color: rgba(224, 190, 126, .22); + background: rgba(35, 23, 14, .94); +} + +.crossword-world { + position: relative; + color: var(--theme-text-body); +} +.crossword-hub.crossword-world, +.crossword-stage.crossword-world { overflow: visible; } +.crossword-hero, +.crossword-setup, +.crossword-active-clue, +.crossword-clues, +.crossword-import, +.crossword-paper { + border: 1px solid rgba(100, 68, 35, .24); + background: + linear-gradient(rgba(255,255,255,.18), rgba(255,255,255,0)), + repeating-linear-gradient(0deg, rgba(83,54,24,.025) 0 1px, transparent 1px 4px), + #f4e7c9; + box-shadow: inset 0 1px rgba(255,255,255,.7), 0 18px 48px rgba(15,9,4,.25); +} +.crossword-hero { position: relative; overflow: hidden; } +.crossword-hero > * { position: relative; z-index: 1; } +.crossword-hero::after { + content: "DAILY STUDY"; + position: absolute; + right: 1rem; + top: .35rem; + color: rgba(77,51,28,.07); + font-family: Georgia, serif; + font-size: clamp(2.75rem, 5vw, 5rem); + font-weight: 900; + letter-spacing: -.06em; + line-height: 1; + pointer-events: none; +} +.crossword-kicker, +.crossword-section-label { + color: #925623; + font-size: .68rem; + font-weight: 900; + letter-spacing: .18em; + text-transform: uppercase; +} +.crossword-section-label { display: block; margin-bottom: .7rem; color: var(--theme-text-muted); } +.crossword-primary { + color: #fff8e7; + background: linear-gradient(135deg, #a86429, #744018); + box-shadow: inset 0 1px rgba(255,255,255,.24), 0 7px 0 #4a2912, 0 12px 24px rgba(49,27,12,.22); + transition: transform .16s var(--ease-spring), filter .16s ease; +} +.crossword-primary:hover { transform: translateY(-2px); filter: brightness(1.08); } +.crossword-primary:active { transform: translateY(4px); box-shadow: inset 0 1px rgba(255,255,255,.2), 0 3px 0 #4a2912; } +.crossword-pack { + border-color: rgba(111,76,43,.25); + background: rgba(244,231,201,.92); + box-shadow: 0 10px 26px rgba(20,12,6,.16), inset 0 1px rgba(255,255,255,.65); + transition: transform .18s ease, border-color .18s ease; +} +.crossword-pack:hover { transform: translateY(-2px); border-color: #a86429; } +.crossword-pack.is-active { border-color: #8d511f; box-shadow: 0 0 0 2px rgba(168,100,41,.2), 0 14px 30px rgba(20,12,6,.2); } +.crossword-paper > div i { aspect-ratio: 1; border: 1px solid rgba(45,33,23,.3); } +.crossword-size { + color: var(--theme-text-secondary); + border: 1px solid var(--theme-border-light); + background: rgba(255,250,235,.48); +} +.crossword-size.is-active { color: #2d2117; border-color: #955720; background: #e4c993; box-shadow: inset 0 0 0 2px rgba(255,255,255,.35); } +.crossword-board-preview { overflow: hidden; border: 1px solid #c9aa78; border-radius: .9rem; background: rgba(255,250,235,.48); } +.crossword-preview-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .65rem .8rem; border-bottom: 1px solid #d5bd93; color: #5d4630; } +.crossword-preview-heading span { font-size: .68rem; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; } +.crossword-preview-heading small { color: #8a7052; font-size: .68rem; } +.crossword-preview-canvas { display: grid; min-height: 9rem; place-items: center; overflow: hidden; padding: .75rem; background: #322116; } +.crossword-preview-grid { display: grid; filter: drop-shadow(0 6px 9px rgba(0,0,0,.35)); } +.crossword-preview-grid i { border: .5px solid #9e8058; background: #fff6de; } +.crossword-preview-loading,.crossword-preview-canvas > p { color: #d7c3a2; font-size: .75rem; font-weight: 800; } +.crossword-option { display: flex; align-items: flex-start; gap: .7rem; padding: .75rem; border-radius: .75rem; background: rgba(224,202,157,.38); } +.crossword-option input { margin-top: .2rem; accent-color: #925623; } +.crossword-option strong,.crossword-option small { display: block; } +.crossword-option small { margin-top: .12rem; color: var(--theme-text-muted); } +.crossword-active-clue { display: grid; gap: .2rem; } +.crossword-active-clue span { color: #925623; font-size: .68rem; font-weight: 900; letter-spacing: .15em; text-transform: uppercase; } +.crossword-active-clue strong { color: #2d2117; font-family: Georgia, serif; font-size: 1.05rem; } +.crossword-active-clue small { color: var(--theme-text-muted); } +.crossword-toolbar { color: #f4e7c9; background: rgba(35,23,14,.9); box-shadow: 0 8px 24px rgba(13,8,4,.24); } +.crossword-toolbar > div { display: flex; align-items: center; gap: .35rem; } +.crossword-toolbar button { display: grid; width: 2rem; height: 2rem; place-items: center; border: 1px solid rgba(245,222,179,.25); border-radius: .45rem; font-weight: 900; } +.crossword-toolbar span { min-width: 3rem; text-align: center; font-size: .75rem; font-weight: 800; } +.crossword-board-viewport { + max-height: min(68vh, 46rem); + overflow: auto; + padding: 1rem; + overscroll-behavior: contain; + background: #2a1d13; + box-shadow: inset 0 0 0 5px #4c321e, inset 0 0 28px rgba(0,0,0,.7), 0 16px 38px rgba(13,8,4,.3); +} +.crossword-board { display: grid; width: max-content; min-width: 100%; min-height: 20rem; place-content: center; } +.crossword-cell { + position: relative; + display: grid; + width: var(--crossword-cell); + height: var(--crossword-cell); + place-items: center; + color: #25190f; + border: 1px solid #6f5335; + background: #fff8e6; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + transition: background .12s ease, box-shadow .12s ease; +} +.crossword-cell small { position: absolute; left: 2px; top: 0; font-size: max(7px, calc(var(--crossword-cell) * .22)); line-height: 1; } +.crossword-cell strong { font-size: max(12px, calc(var(--crossword-cell) * .56)); } +.crossword-cell.is-word { background: #f2d99f; } +.crossword-cell.is-active { z-index: 2; background: #ffe27e; box-shadow: inset 0 0 0 3px #925623; } +.crossword-cell.is-wrong::after { content: ""; position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border: 2px solid #a62e25; border-radius: 50%; } +.crossword-cell.is-revealed strong { color: #8a5a24; text-decoration: underline dotted; } +.crossword-secondary { + min-height: 2.75rem; + padding: .6rem .9rem; + border: 1px solid #9f7a50; + border-radius: .7rem; + color: #392718; + background: #ead6ae; + font-size: .78rem; + font-weight: 850; + box-shadow: 0 3px 0 #aa895f; +} +.crossword-secondary:hover { background: #f5e5c4; } +.crossword-clues { max-height: 72vh; overflow: hidden; } +.crossword-clues > div button { min-height: 2.5rem; border-bottom: 2px solid transparent; color: var(--theme-text-muted); font-size: .75rem; font-weight: 900; text-transform: uppercase; } +.crossword-clues > div button.is-active { color: #7d461d; border-color: #9a5a24; } +.crossword-clues ol { max-height: calc(72vh - 5rem); overflow-y: auto; padding-right: .25rem; } +.crossword-clues li button { display: grid; width: 100%; grid-template-columns: 2rem 1fr; gap: .4rem; padding: .55rem; border-radius: .5rem; text-align: left; color: #4b3826; } +.crossword-clues li button:hover,.crossword-clues li button.is-active { background: #dec38c; } +.crossword-clues li b { color: #8a4f20; } +.crossword-mobile-input { position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; } +.crossword-results-hero { color: #f8ead0; background: linear-gradient(135deg, #3b2819, #6d421f); box-shadow: 0 18px 45px rgba(15,9,4,.3); } +.crossword-results-hero > div > div { background: rgba(255,244,220,.1); } +.crossword-results-hero small,.crossword-results-hero strong { display: block; } +.crossword-results-hero small { color: rgba(255,244,220,.6); font-size: .65rem; font-weight: 900; text-transform: uppercase; } +.crossword-results-hero strong { margin-top: .2rem; font-size: 1.25rem; } +.crossword-final-board { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 12px 32px rgba(20,12,6,.18); } +.crossword-final-legend { display: flex; flex-wrap: wrap; gap: .8rem; color: #6a5139; font-size: .72rem; font-weight: 800; } +.crossword-final-legend span { display: inline-flex; align-items: center; gap: .35rem; } +.crossword-final-legend i { width: .8rem; height: .8rem; border: 1px solid currentColor; border-radius: .15rem; } +.crossword-final-legend i.is-correct { color: #356b3b; background: #bfe0b9; } +.crossword-final-legend i.is-incorrect { color: #923a30; background: #efb9af; } +.crossword-final-viewport { overflow: auto; padding: 1rem; border-radius: 1rem; background: #2a1d13; box-shadow: inset 0 0 22px rgba(0,0,0,.55); } +.crossword-final-grid { display: grid; width: max-content; min-width: 100%; place-content: center; } +.crossword-final-cell { position: relative; display: grid; place-items: center; color: #25190f; border: 1px solid #62482f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.crossword-final-cell.is-correct { background: #c7e6bf; box-shadow: inset 0 0 0 1px rgba(47,108,58,.35); } +.crossword-final-cell.is-incorrect { background: #efbbb1; box-shadow: inset 0 0 0 2px rgba(145,47,39,.45); } +.crossword-final-cell small { position: absolute; left: 2px; top: 1px; font-size: 7px; line-height: 1; } +.crossword-final-cell strong { font-size: clamp(10px, 2vw, 15px); } +.crossword-review { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 8px 22px rgba(20,12,6,.15); } +.crossword-review.is-incorrect { border-left: 6px solid #a63a2e; } +.crossword-review.is-correct { border-left: 6px solid #4f7a4f; } +.crossword-review.is-omitted { opacity: .75; border-left: 6px solid #8c765a; } + +@media (max-width: 639px) { + .crossword-board-viewport { margin-inline: -.25rem; padding: .6rem; } + .crossword-board { place-content: start; min-height: 24rem; } + .crossword-clues { max-height: 28rem; } + .crossword-hero::after { top: .75rem; right: .75rem; font-size: 2.75rem; } + .crossword-final-viewport { margin-inline: -.25rem; padding: .65rem; } +} + .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/components/arcade/ArcadeGameShell.tsx b/src/components/arcade/ArcadeGameShell.tsx index 5c10a7b..bc63241 100644 --- a/src/components/arcade/ArcadeGameShell.tsx +++ b/src/components/arcade/ArcadeGameShell.tsx @@ -8,12 +8,14 @@ export function ArcadeGameShell({ exitHref, elapsedSeconds, complete, + worldClassName = "connections-world connections-stage", children, }: { title: string; exitHref: string; elapsedSeconds: number; complete: boolean; + worldClassName?: string; children: ReactNode; }) { const router = useRouter(); @@ -24,8 +26,8 @@ export function ArcadeGameShell({ const minutes = Math.floor(elapsedSeconds / 60); const seconds = String(elapsedSeconds % 60).padStart(2, "0"); return ( -
-
+
+

{title}

{minutes}:{seconds}
diff --git a/src/components/arcade/ArcadeImportModal.tsx b/src/components/arcade/ArcadeImportModal.tsx index 95284c8..6378d34 100644 --- a/src/components/arcade/ArcadeImportModal.tsx +++ b/src/components/arcade/ArcadeImportModal.tsx @@ -2,14 +2,16 @@ import { useState } from "react"; import { GenerateTab } from "@/components/import/GenerateTab"; -import type { ArcadeImportBatchPreview } from "@/types/arcade"; +import type { ArcadeGameKey, ArcadeImportBatchPreview } from "@/types/arcade"; export function ArcadeImportModal({ classId, + gameType, onClose, onImported, }: { classId: string; + gameType: ArcadeGameKey; onClose: () => void; onImported: () => void; }) { @@ -31,7 +33,7 @@ export function ArcadeImportModal({ const response = await fetch("/api/arcade/import/preview", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ gameType: "connections", rawJson }), + body: JSON.stringify({ gameType, rawJson }), }); const data = await response.json(); if (!response.ok) { @@ -56,9 +58,9 @@ export function ArcadeImportModal({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ classId, - gameType: "connections", + gameType, rawJson, - names: names.map((name) => name.trim()), + ...(gameType === "connections" ? { names: names.map((name) => name.trim()) } : { name: names[0]?.trim() }), warningsAcknowledged: acknowledged, }), }); @@ -77,9 +79,9 @@ export function ArcadeImportModal({ return (
@@ -88,10 +90,10 @@ export function ArcadeImportModal({ ))}
- {tab === "generate" ? : ( + {tab === "generate" ? : (
-

Paste one pack object or an array of up to ten packs. Every board is validated before anything is saved.

-