diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml
index 7abe151..105c385 100644
--- a/.forgejo/workflows/build.yml
+++ b/.forgejo/workflows/build.yml
@@ -11,6 +11,9 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@v4
+ with:
+ # The changed-file lint step compares the pushed commit with its parent.
+ fetch-depth: 2
- name: Log into Local Registry
run: |
@@ -33,24 +36,35 @@ jobs:
- name: Lint changed source files
run: |
- FILES=$(git diff --name-only HEAD^ HEAD -- '*.ts' '*.tsx' '*.js' '*.mjs')
- if [ -n "$FILES" ]; then npx eslint $FILES; fi
+ if BASE=$(git rev-parse HEAD^ 2>/dev/null); then
+ git diff --name-only --diff-filter=ACMR -z "$BASE" HEAD -- '*.ts' '*.tsx' '*.js' '*.mjs' |
+ xargs -0 -r npx eslint
+ else
+ git ls-files -z -- '*.ts' '*.tsx' '*.js' '*.mjs' |
+ xargs -0 -r npx eslint
+ fi
- name: Build and smoke production image
run: |
IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')
IMAGE_SHA="$IMAGE_PATH:${{ github.sha }}"
CI_SECRET=$(openssl rand -hex 32)
+ SMOKE_CONTAINER="study-smoke-$(openssl rand -hex 8)"
+ cleanup_smoke() {
+ docker rm -f "$SMOKE_CONTAINER" > /dev/null 2>&1 || true
+ }
+ trap cleanup_smoke EXIT
docker build -t "$IMAGE_SHA" .
- docker run -d --name study-smoke -p 3000:3726 -e SESSION_SECRET="$CI_SECRET" -e ALLOW_INITIAL_SETUP=true "$IMAGE_SHA"
+ docker run -d --name "$SMOKE_CONTAINER" -e SESSION_SECRET="$CI_SECRET" -e ALLOW_INITIAL_SETUP=true "$IMAGE_SHA"
for attempt in $(seq 1 30); do
- if [ "$(docker inspect --format='{{.State.Health.Status}}' study-smoke)" = "healthy" ]; then break; fi
+ if [ "$(docker inspect --format='{{.State.Health.Status}}' "$SMOKE_CONTAINER")" = "healthy" ]; then break; fi
sleep 2
done
- test "$(docker inspect --format='{{.State.Health.Status}}' study-smoke)" = "healthy"
- curl --fail http://127.0.0.1:3000/login > /dev/null
- curl --fail http://127.0.0.1:3000/api/auth/setup-status | grep '"setupRequired":true'
- docker rm -f study-smoke
+ test "$(docker inspect --format='{{.State.Health.Status}}' "$SMOKE_CONTAINER")" = "healthy"
+ docker exec "$SMOKE_CONTAINER" node -e "fetch('http://127.0.0.1:3726/login').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"
+ docker exec "$SMOKE_CONTAINER" node -e "fetch('http://127.0.0.1:3726/api/auth/setup-status').then(async r => { const body = await r.json(); if (!r.ok || body.setupRequired !== true) process.exit(1) }).catch(() => process.exit(1))"
+ cleanup_smoke
+ trap - EXIT
docker tag "$IMAGE_SHA" "$IMAGE_PATH:latest"
docker push "$IMAGE_SHA"
docker push "$IMAGE_PATH:latest"
diff --git a/README.md b/README.md
index 17eac58..459a4a2 100644
--- a/README.md
+++ b/README.md
@@ -61,12 +61,18 @@ The material-group preflight reports one of four states:
- `APPLY`: migration history is complete and the group schema is absent; run
`prisma migrate deploy` normally.
- `ADOPT`: prior migrations are tracked and the database exactly matches the
- intended group schema. After reviewing the verified backup, explicitly run
+ intended schema of one or more unrecorded committed migrations. This includes
+ recovery from P3018 when an already schema-pushed table or column exists.
+ After reviewing the verified backup, explicitly run
`npm run db:preflight -- --resolve --backup `.
- `CURRENT`: migration and schema already agree.
- `CONFLICT`: partial or unknown state. Stop and recover manually; do not use
`db push`, reset, edit applied migrations, or mark anything applied.
+The production entrypoint runs this preflight before `prisma migrate deploy`.
+It refuses `ADOPT` and `CONFLICT` states without changing migration history;
+adoption always remains an explicit backup-gated operator action.
+
Restore drill: stop Study Desk, keep the damaged database as evidence, restore
the verified backup to a new path, run SQLite `integrity_check` plus
`npm run db:preflight`, start the same prior application image against the
diff --git a/benchmarks/remediationBenchmark.test.ts b/benchmarks/remediationBenchmark.test.ts
index 25eb390..73657c3 100644
--- a/benchmarks/remediationBenchmark.test.ts
+++ b/benchmarks/remediationBenchmark.test.ts
@@ -56,11 +56,7 @@ function seedRealApplicationSchema() {
for (let index = 0; index < ACTIVITY_ROWS; index += 1) {
const ageDays = index % 730;
- const type = index % 3 === 0
- ? "FLASHCARD"
- : index % 3 === 1
- ? "QUIZ_QUESTION"
- : "ARCADE_GROUP";
+ const type = index % 2 === 0 ? "FLASHCARD" : "QUIZ_QUESTION";
insertActivity.run(
`activity-${index}`,
type,
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index 0678c2c..dcba759 100644
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -8,5 +8,6 @@ if [ -z "$TRIMMED_SECRET" ] || [ "${#TRIMMED_SECRET}" -lt 32 ] || [ "$TRIMMED_SE
exit 1
fi
+node scripts/migration-preflight.mjs --startup
./node_modules/.bin/prisma migrate deploy
exec node server.js
diff --git a/prisma/migrations/20260808100000_remove_arcade/migration.sql b/prisma/migrations/20260808100000_remove_arcade/migration.sql
new file mode 100644
index 0000000..35d0d98
--- /dev/null
+++ b/prisma/migrations/20260808100000_remove_arcade/migration.sql
@@ -0,0 +1,10 @@
+-- Remove Arcade-generated activity and saved instruction settings.
+DELETE FROM "StudyActivity"
+WHERE "type" IN ('ARCADE_GROUP', 'ARCADE_WORD');
+
+DELETE FROM "Setting"
+WHERE "key" IN ('llmInstructionsConnections', 'llmInstructionsCrossword');
+
+-- Drop the dependent table before its parent while SQLite foreign keys are enabled.
+DROP TABLE IF EXISTS "ArcadeAttempt";
+DROP TABLE IF EXISTS "ArcadePack";
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 45d61ec..5d7bfda 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -18,7 +18,6 @@ model Class {
quizSets QuizSet[]
materialGroups MaterialGroup[]
spacedRepetitionSets SpacedRepetitionSet[]
- arcadePacks ArcadePack[]
}
model Deck {
@@ -150,52 +149,12 @@ model Setting {
model StudyActivity {
id String @id @default(uuid())
- type String // "FLASHCARD" | "QUIZ_QUESTION" | "ARCADE_GROUP"
+ type String // "FLASHCARD" | "QUIZ_QUESTION"
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/scripts/migration-preflight.mjs b/scripts/migration-preflight.mjs
index a058181..4a762e8 100644
--- a/scripts/migration-preflight.mjs
+++ b/scripts/migration-preflight.mjs
@@ -38,10 +38,14 @@ function hasUniqueIndex(database, table, indexName, column) {
return columns.length === 1 && columns[0].name === column;
}
-function schemaSnapshot(database) {
+function schemaSnapshot(database, includeMigrationTable = false) {
const tables = database
.prepare(
- "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_prisma_migrations' ORDER BY name"
+ `SELECT name FROM sqlite_master
+ WHERE type = 'table'
+ AND name NOT LIKE 'sqlite_%'
+ ${includeMigrationTable ? "" : "AND name <> '_prisma_migrations'"}
+ ORDER BY name`
)
.all()
.map((row) => row.name);
@@ -113,14 +117,20 @@ export function inspectMigrationState(databasePath) {
}
const database = new Database(databasePath, { readonly: true, fileMustExist: true });
try {
- const migrationNames = tableExists(database, "_prisma_migrations")
+ const migrationRecords = tableExists(database, "_prisma_migrations")
? database
.prepare(
- 'SELECT "migration_name" FROM "_prisma_migrations" WHERE "finished_at" IS NOT NULL AND "rolled_back_at" IS NULL'
+ 'SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations"'
)
.all()
- .map((row) => row.migration_name)
: [];
+ const activeRecords = migrationRecords.filter((row) => row.rolled_back_at === null);
+ const migrationNames = activeRecords
+ .filter((row) => row.finished_at !== null)
+ .map((row) => row.migration_name);
+ const failedMigrationNames = activeRecords
+ .filter((row) => row.finished_at === null)
+ .map((row) => row.migration_name);
const committedMigrations = readdirSync(
path.join(process.cwd(), "prisma", "migrations"),
{ withFileTypes: true }
@@ -131,9 +141,7 @@ export function inspectMigrationState(databasePath) {
const priorMigrations = committedMigrations.filter(
(name) => name < GROUP_MIGRATION
);
- const laterMigrations = committedMigrations.filter(
- (name) => name > GROUP_MIGRATION
- );
+ const groupMigrationIndex = committedMigrations.indexOf(GROUP_MIGRATION);
const artifacts = {
materialGroup: tableExists(database, "MaterialGroup"),
@@ -168,19 +176,12 @@ export function inspectMigrationState(databasePath) {
"groupId"
),
};
- const values = Object.values(artifacts);
- const groupArtifactsPresent = values.every(Boolean);
- const groupArtifactsAbsent = values.every((value) => !value);
- const groupMigrationApplied = migrationNames.includes(GROUP_MIGRATION);
const priorHistoryComplete = priorMigrations.every((name) =>
migrationNames.includes(name)
);
const knownMigrationNames = new Set(committedMigrations);
- const historyHasUnknown = migrationNames.some(
- (name) => !knownMigrationNames.has(name)
- );
- const laterHistoryPresent = laterMigrations.some((name) =>
- migrationNames.includes(name)
+ const historyHasUnknown = activeRecords.some(
+ (row) => !knownMigrationNames.has(row.migration_name)
);
const actualSchema = schemaSnapshot(database);
const matches = (expected) =>
@@ -189,42 +190,66 @@ export function inspectMigrationState(databasePath) {
const adoptedGroupSchemaExact = matches(
expectedSchemaSnapshot([...priorMigrations, GROUP_MIGRATION])
);
- const appliedPrefix = committedMigrations.filter((name, index) =>
- committedMigrations.slice(0, index + 1).every((candidate) =>
- migrationNames.includes(candidate)
- )
- );
- const appliedHistoryExact =
+ let historyPrefixLength = 0;
+ while (
+ historyPrefixLength < committedMigrations.length &&
+ migrationNames.includes(committedMigrations[historyPrefixLength])
+ ) {
+ historyPrefixLength += 1;
+ }
+ const historyIsExactPrefix =
!historyHasUnknown &&
- migrationNames.length === appliedPrefix.length &&
- migrationNames.every((name) => appliedPrefix.includes(name));
+ migrationNames.length === historyPrefixLength &&
+ migrationNames.every((name) =>
+ committedMigrations.slice(0, historyPrefixLength).includes(name)
+ );
const trackedSchemaExact =
- appliedHistoryExact && matches(expectedSchemaSnapshot(appliedPrefix));
+ historyIsExactPrefix &&
+ matches(expectedSchemaSnapshot(committedMigrations.slice(0, historyPrefixLength)));
+ let schemaPrefixLength = -1;
+ for (let index = 0; index <= committedMigrations.length; index += 1) {
+ if (matches(expectedSchemaSnapshot(committedMigrations.slice(0, index)))) {
+ schemaPrefixLength = index;
+ break;
+ }
+ }
+ const migrationsToAdopt =
+ schemaPrefixLength > historyPrefixLength
+ ? committedMigrations.slice(historyPrefixLength, schemaPrefixLength)
+ : [];
+ const failuresAreAdoptable = failedMigrationNames.every((name) =>
+ migrationsToAdopt.includes(name)
+ );
let classification = "CONFLICT";
- if (groupMigrationApplied && trackedSchemaExact) classification = "CURRENT";
- else if (
- !groupMigrationApplied &&
- !laterHistoryPresent &&
+ if (
+ historyIsExactPrefix &&
+ failedMigrationNames.length === 0 &&
+ schemaPrefixLength === historyPrefixLength
+ ) {
+ classification =
+ historyPrefixLength === committedMigrations.length ? "CURRENT" : "APPLY";
+ } else if (
+ historyIsExactPrefix &&
priorHistoryComplete &&
- migrationNames.length === priorMigrations.length &&
- groupArtifactsAbsent &&
- preGroupSchemaExact
- ) classification = "APPLY";
- else if (
- !groupMigrationApplied &&
- !laterHistoryPresent &&
- priorHistoryComplete &&
- migrationNames.length === priorMigrations.length &&
- groupArtifactsPresent &&
- adoptedGroupSchemaExact
- ) classification = "ADOPT";
+ historyPrefixLength >= groupMigrationIndex &&
+ schemaPrefixLength > historyPrefixLength &&
+ failuresAreAdoptable
+ ) {
+ classification = "ADOPT";
+ }
return {
classification,
artifacts,
migrationNames,
+ failedMigrationNames,
+ migrationsToAdopt,
priorHistoryComplete,
+ schemaMigrationPrefix:
+ schemaPrefixLength >= 0
+ ? committedMigrations[schemaPrefixLength - 1] ?? null
+ : null,
exactSchema: {
preGroup: preGroupSchemaExact,
adoptedGroup: adoptedGroupSchemaExact,
@@ -249,17 +274,20 @@ function verifyBackup(backupPath, sourcePath) {
if (backup.pragma("integrity_check", { simple: true }) !== "ok") {
throw new Error("Backup failed SQLite integrity_check");
}
- if (JSON.stringify(schemaSnapshot(backup)) !== JSON.stringify(schemaSnapshot(source))) {
+ if (
+ JSON.stringify(schemaSnapshot(backup, true)) !==
+ JSON.stringify(schemaSnapshot(source, true))
+ ) {
throw new Error("Backup schema does not match the source database");
}
const sourceCounts = Object.fromEntries(
- schemaSnapshot(source).map(({ table }) => [
+ schemaSnapshot(source, true).map(({ table }) => [
table,
source.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
])
);
const backupCounts = Object.fromEntries(
- schemaSnapshot(backup).map(({ table }) => [
+ schemaSnapshot(backup, true).map(({ table }) => [
table,
backup.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
])
@@ -273,11 +301,11 @@ function verifyBackup(backupPath, sourcePath) {
}
}
-function resolveMigration(databaseUrl) {
+function resolveMigration(databaseUrl, migrationName) {
const cli = path.join(process.cwd(), "node_modules", "prisma", "build", "index.js");
const result = spawnSync(
process.execPath,
- [cli, "migrate", "resolve", "--applied", GROUP_MIGRATION],
+ [cli, "migrate", "resolve", "--applied", migrationName],
{
cwd: process.cwd(),
env: { ...process.env, DATABASE_URL: databaseUrl },
@@ -292,7 +320,16 @@ function resolveMigration(databaseUrl) {
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const databaseUrl = process.env.DATABASE_URL;
const databasePath = databasePathFromUrl(databaseUrl);
- const state = inspectMigrationState(databasePath);
+ const startup = process.argv.includes("--startup");
+ const state =
+ startup && !existsSync(databasePath)
+ ? {
+ classification: "EMPTY",
+ migrationNames: [],
+ failedMigrationNames: [],
+ migrationsToAdopt: [],
+ }
+ : inspectMigrationState(databasePath);
console.info(JSON.stringify(state, null, 2));
if (process.argv.includes("--resolve")) {
@@ -304,8 +341,17 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
backupIndex >= 0 ? process.argv[backupIndex + 1] : undefined,
databasePath
);
- resolveMigration(databaseUrl);
- console.info(`Marked ${GROUP_MIGRATION} applied after exact-schema preflight.`);
+ for (const migrationName of state.migrationsToAdopt) {
+ resolveMigration(databaseUrl, migrationName);
+ }
+ console.info(
+ `Marked ${state.migrationsToAdopt.join(", ")} applied after exact-schema preflight.`
+ );
+ } else if (startup && state.classification === "ADOPT") {
+ console.error(
+ "Database schema requires explicit migration adoption. Stop, create and verify a backup, then run db:preflight -- --resolve --backup ."
+ );
+ process.exitCode = 3;
} else if (state.classification === "CONFLICT") {
process.exitCode = 2;
}
diff --git a/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx b/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx
deleted file mode 100644
index 423ee89..0000000
--- a/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-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" || pack.normalized.type !== "connections") notFound();
- const normalized = pack.normalized;
- const mistakesValue = Number(query.mistakes);
- const allowedMistakes = Number.isInteger(mistakesValue) && mistakesValue >= 1 && mistakesValue <= 8
- ? mistakesValue
- : 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
deleted file mode 100644
index e27c052..0000000
--- a/src/app/(protected)/[classSlug]/arcade/connections/page.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-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/crossword/[packId]/play/page.tsx b/src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx
deleted file mode 100644
index e197665..0000000
--- a/src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-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
deleted file mode 100644
index f96fcba..0000000
--- a/src/app/(protected)/[classSlug]/arcade/crossword/page.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-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/layout.tsx b/src/app/(protected)/[classSlug]/arcade/layout.tsx
deleted file mode 100644
index 5718c28..0000000
--- a/src/app/(protected)/[classSlug]/arcade/layout.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-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
deleted file mode 100644
index 27aa1b4..0000000
--- a/src/app/(protected)/[classSlug]/arcade/page.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-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 ${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
deleted file mode 100644
index 89370dd..0000000
--- a/src/app/api/arcade/import/preview/route.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
-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.gameType, 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
deleted file mode 100644
index f36cf38..0000000
--- a/src/app/api/arcade/packs/[id]/attempts/route.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { arcadeAttemptCreateSchema, crosswordAttemptCreateSchema } 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 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 = 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 });
- } 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]/layout/route.ts b/src/app/api/arcade/packs/[id]/layout/route.ts
deleted file mode 100644
index db4cd23..0000000
--- a/src/app/api/arcade/packs/[id]/layout/route.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-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/[id]/route.ts b/src/app/api/arcade/packs/[id]/route.ts
deleted file mode 100644
index 4ad04f4..0000000
--- a/src/app/api/arcade/packs/[id]/route.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-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
deleted file mode 100644
index e86938e..0000000
--- a/src/app/api/arcade/packs/route.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { ArcadeImportError } from "@/lib/arcade/arcadeImport";
-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" && gameType !== "crossword")) {
- 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 87d57cf..527a94b 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 === "crossword" ? type : null;
+ return type === "flashcards" || type === "quizzes" ? type : null;
}
export async function GET(request: NextRequest) {
diff --git a/src/app/globals.css b/src/app/globals.css
index c95f558..bfb7cad 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -231,537 +231,6 @@
color: transparent;
}
-/* 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; }
-
-/* 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-assisted { color: #8a631c; background: #f2d47e; }
-.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-assisted { background: #f2d47e; box-shadow: inset 0 0 0 2px rgba(138,99,28,.42); }
-.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-assisted { border-left: 6px solid #b58627; }
-.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/activity/ActivityBanner.tsx b/src/components/activity/ActivityBanner.tsx
index b2e2f7e..a058535 100644
--- a/src/components/activity/ActivityBanner.tsx
+++ b/src/components/activity/ActivityBanner.tsx
@@ -6,7 +6,6 @@ interface DailyActivity {
date: string;
flashcards: number;
questions: number;
- arcade: number;
total: number;
level: 0 | 1 | 2 | 3 | 4;
}
@@ -154,7 +153,7 @@ function DayTooltip({ day }: { day: DailyActivity }) {
return (
{formatted} · {day.total} total
-
{day.flashcards} flashcards · {day.questions} questions · {day.arcade} arcade groups
+
{day.flashcards} flashcards · {day.questions} questions
);
}
@@ -185,5 +184,5 @@ function getMonthLabel(week: DailyActivity[], previousWeek?: DailyActivity[]) {
}
function getDayAriaLabel(day: DailyActivity) {
- return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, and ${day.arcade} arcade groups, ${day.total} total activities`;
+ return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, ${day.total} total activities`;
}
diff --git a/src/components/arcade/ArcadeGameShell.tsx b/src/components/arcade/ArcadeGameShell.tsx
deleted file mode 100644
index bc63241..0000000
--- a/src/components/arcade/ArcadeGameShell.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-"use client";
-
-import type { ReactNode } from "react";
-import { useRouter } from "next/navigation";
-
-export function ArcadeGameShell({
- title,
- exitHref,
- elapsedSeconds,
- complete,
- worldClassName = "connections-world connections-stage",
- children,
-}: {
- title: string;
- exitHref: string;
- elapsedSeconds: number;
- complete: boolean;
- worldClassName?: string;
- 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 (
-
-
- ← Exit
- {title}
- {minutes}:{seconds}
-
- {children}
-
- );
-}
diff --git a/src/components/arcade/ArcadeImportModal.tsx b/src/components/arcade/ArcadeImportModal.tsx
deleted file mode 100644
index 6378d34..0000000
--- a/src/components/arcade/ArcadeImportModal.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { GenerateTab } from "@/components/import/GenerateTab";
-import type { ArcadeGameKey, ArcadeImportBatchPreview } from "@/types/arcade";
-
-export function ArcadeImportModal({
- classId,
- gameType,
- onClose,
- onImported,
-}: {
- classId: string;
- gameType: ArcadeGameKey;
- 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, 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,
- rawJson,
- ...(gameType === "connections" ? { names: names.map((name) => name.trim()) } : { name: names[0]?.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 {gameType === "connections" ? "Connections" : "Crossword"} pack
- ×
-
-
- {(["generate", "import"] as const).map((item) => (
- setTab(item)} className={`border-b-2 px-4 py-3 text-sm font-bold capitalize ${tab === item ? "border-primary text-primary" : "border-transparent text-text-muted"}`}>{item}
- ))}
-
-
- {tab === "generate" ?
: (
-
-
{gameType === "connections" ? "Paste one pack object or an array of up to ten packs." : "Paste one Crossword pack containing exactly 80 entries."} Every board is validated before anything is saved.
-
- )}
-
-
-
- );
-}
diff --git a/src/components/arcade/ConnectionsGame.tsx b/src/components/arcade/ConnectionsGame.tsx
deleted file mode 100644
index 20407cd..0000000
--- a/src/components/arcade/ConnectionsGame.tsx
+++ /dev/null
@@ -1,237 +0,0 @@
-"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 handleTileKey(event, id)} onClick={() => toggleTile(id)} className={`connections-tile min-h-20 break-words rounded-xl px-1.5 py-2 text-[11px] font-extrabold leading-tight sm:min-h-24 sm:px-3 sm:text-sm ${selected ? "is-selected" : ""}`}>{item.text} ;
- })}
-
-
-
- Shuffle
- setSelectedIds([])} disabled={selectedIds.length === 0} className="connections-secondary-button min-h-11 rounded-xl px-5 text-sm font-bold disabled:opacity-40">Clear
- Submit group ({selectedIds.length}/4)
-
-
- );
-}
-
-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
deleted file mode 100644
index ed0d8f0..0000000
--- a/src/components/arcade/ConnectionsHub.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-"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.
-
-
setShowImport(true)} className="connections-primary-button min-h-12 rounded-xl px-5 text-sm font-bold">Import pack
-
-
- {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.
-
setShowImport(true)} className="mt-6 min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white">Import Connections JSON
-
- ) : (
-
-
- Study packs
-
- {initialPacks.map((pack) => {
- const active = pack.id === effectiveSelectedId;
- return (
-
- selectPack(pack)} className="w-full text-left">
- {pack.name} {pack.description &&
{pack.description}
}
- 16 tiles Best {pack.bestScore ?? "—"}/400 Latest {pack.latestAttempt?.score ?? "—"}
-
- renamePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-bg-surface-alt hover:text-text-heading">Rename deletePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-error-bg hover:text-error">Delete
-
- );
- })}
-
-
-
-
- Round setup
- {selected ? <>
- {selected.name}
- Allowed mistakes {allowedMistakes}
- setAllowedMistakes(Number(event.target.value))} className="mt-2 w-full accent-primary" />
- setOneAwayFeedback(event.target.checked)} className="mt-1" />One-away feedback Tell me when three selected tiles belong together.
- Board 4 × 4
Possible score 400
- Enter the board
- Recent attempts {attemptsLoading ?
Loading history…
: attempts.length === 0 ?
No attempts yet.
:
{attempts.slice(0, 5).map((attempt) =>
{attempt.score}/400 {Math.round(attempt.accuracy * 100)}% · {attempt.durationSeconds}s
)}
}
- > : Select a pack to configure the round.
}
-
-
- )}
- {showImport &&
setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
-
- );
-}
diff --git a/src/components/arcade/CrosswordGame.tsx b/src/components/arcade/CrosswordGame.tsx
deleted file mode 100644
index e320d83..0000000
--- a/src/components/arcade/CrosswordGame.tsx
+++ /dev/null
@@ -1,269 +0,0 @@
-"use client";
-
-import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
-import { ArcadeGameShell } from "@/components/arcade/ArcadeGameShell";
-import type {
- CrosswordAction,
- CrosswordLayout,
- CrosswordPlacedEntry,
- CrosswordRoundResult,
- CrosswordSessionSettings,
- NormalizedCrosswordPack,
-} from "@/types/arcade";
-
-function entryValue(entry: CrosswordPlacedEntry, answers: Record) {
- return entry.cellKeys.map((cellKey) => answers[cellKey] ?? "").join("");
-}
-
-export function CrosswordGame({ seed, packId, packName, classSlug, layout, settings }: {
- seed: string;
- packId: string;
- packName: string;
- classSlug: string;
- pack: NormalizedCrosswordPack;
- layout: CrosswordLayout;
- settings: CrosswordSessionSettings;
-}) {
- const orderedEntries = useMemo(() => [...layout.entries].sort((a, b) => a.number - b.number || a.direction.localeCompare(b.direction)), [layout.entries]);
- const entryById = useMemo(() => new Map(orderedEntries.map((entry) => [entry.id, entry])), [orderedEntries]);
- const cellByKey = useMemo(() => new Map(layout.cells.map((cell) => [cell.key, cell])), [layout.cells]);
- const [answers, setAnswers] = useState>({});
- const [activeEntryId, setActiveEntryId] = useState(orderedEntries[0]?.id ?? "");
- const [activeCellKey, setActiveCellKey] = useState(orderedEntries[0]?.cellKeys[0] ?? "");
- const [clueTab, setClueTab] = useState<"across" | "down">(orderedEntries[0]?.direction ?? "across");
- const [actions, setActions] = useState([]);
- const [checkedWrong, setCheckedWrong] = useState>(new Set());
- const [revealedCells, setRevealedCells] = useState>(new Set());
- const [alternateClues, setAlternateClues] = useState>(new Set());
- const [feedback, setFeedback] = useState(`${layout.entries.length} of ${layout.targetCount} target words placed.`);
- const [zoom, setZoom] = useState(1);
- const [elapsedSeconds, setElapsedSeconds] = useState(0);
- const [result, setResult] = useState(null);
- const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
- const startedAt = useRef(0);
- const mobileInput = useRef(null);
- const activeEntry = entryById.get(activeEntryId) ?? orderedEntries[0];
-
- useEffect(() => {
- if (startedAt.current === 0) startedAt.current = Date.now();
- if (result) return;
- const timer = window.setInterval(() => setElapsedSeconds(Math.floor((Date.now() - startedAt.current) / 1000)), 1000);
- return () => window.clearInterval(timer);
- }, [result]);
-
- function selectEntry(entry: CrosswordPlacedEntry, cellKey = entry.cellKeys[0]) {
- setActiveEntryId(entry.id);
- setActiveCellKey(cellKey);
- setClueTab(entry.direction);
- }
-
- function selectCell(cellKey: string) {
- const cell = cellByKey.get(cellKey);
- if (!cell) return;
- let nextId = cell.entryIds[0];
- if (cell.entryIds.includes(activeEntryId) && cell.entryIds.length > 1 && activeCellKey === cellKey) {
- nextId = cell.entryIds.find((id) => id !== activeEntryId) ?? nextId;
- } else if (!cell.entryIds.includes(activeEntryId)) {
- nextId = cell.entryIds.find((id) => entryById.get(id)?.direction === clueTab) ?? nextId;
- } else {
- nextId = activeEntryId;
- }
- const entry = entryById.get(nextId);
- if (entry) selectEntry(entry, cellKey);
- mobileInput.current?.focus({ preventScroll: true });
- }
-
- function moveWithinEntry(offset: number) {
- if (!activeEntry) return;
- const index = Math.max(0, activeEntry.cellKeys.indexOf(activeCellKey));
- setActiveCellKey(activeEntry.cellKeys[Math.max(0, Math.min(activeEntry.cellKeys.length - 1, index + offset))]);
- }
-
- function writeLetter(letter: string) {
- if (!activeEntry || !activeCellKey || result || revealedCells.has(activeCellKey)) return;
- const upper = letter.replace(/[^A-Za-z]/g, "").slice(-1).toUpperCase();
- if (!upper) return;
- const nextAnswers = { ...answers, [activeCellKey]: upper };
- setAnswers(nextAnswers);
- if (settings.instantCheck) {
- const value = activeEntry.cellKeys.map((cellKey) => nextAnswers[cellKey] ?? "").join("");
- if (value.length === activeEntry.answer.length) {
- setCheckedWrong((checked) => {
- const updated = new Set(checked);
- activeEntry.cellKeys.forEach((cellKey, index) => {
- if ((nextAnswers[cellKey] ?? "") === activeEntry.answer[index]) updated.delete(cellKey);
- else updated.add(cellKey);
- });
- return updated;
- });
- }
- }
- setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
- moveWithinEntry(1);
- }
-
- function clearLetter() {
- if (!activeCellKey || revealedCells.has(activeCellKey)) return;
- if (answers[activeCellKey]) {
- setAnswers((current) => { const next = { ...current }; delete next[activeCellKey]; return next; });
- setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
- } else {
- moveWithinEntry(-1);
- }
- }
-
- function cycleEntry(offset: number) {
- const index = Math.max(0, orderedEntries.findIndex((entry) => entry.id === activeEntryId));
- const entry = orderedEntries[(index + offset + orderedEntries.length) % orderedEntries.length];
- selectEntry(entry);
- }
-
- function handleKeyDown(event: React.KeyboardEvent) {
- if (/^[a-zA-Z]$/.test(event.key)) { event.preventDefault(); writeLetter(event.key); return; }
- if (event.key === "Backspace" || event.key === "Delete") { event.preventDefault(); clearLetter(); return; }
- if (event.key === "Tab") { event.preventDefault(); cycleEntry(event.shiftKey ? -1 : 1); return; }
- if (event.key === "Enter") { event.preventDefault(); checkWord(); return; }
- const cell = cellByKey.get(activeCellKey);
- if (!cell || !event.key.startsWith("Arrow")) return;
- event.preventDefault();
- const delta = event.key === "ArrowLeft" ? [0, -1] : event.key === "ArrowRight" ? [0, 1] : event.key === "ArrowUp" ? [-1, 0] : [1, 0];
- const next = cellByKey.get(`${cell.row + delta[0]}:${cell.column + delta[1]}`);
- if (next) selectCell(next.key);
- }
-
- function incorrectCells(entry: CrosswordPlacedEntry) {
- return entry.cellKeys.filter((cellKey, index) => (answers[cellKey] ?? "") !== entry.answer[index]);
- }
-
- function checkWord() {
- if (!activeEntry) return;
- const wrong = incorrectCells(activeEntry);
- setCheckedWrong((current) => new Set([...current, ...wrong]));
- setActions((current) => [...current, { type: "CHECK_WORD", entryId: activeEntry.id, value: entryValue(activeEntry, answers), elapsedMs: Date.now() - startedAt.current }]);
- setFeedback(wrong.length === 0 ? `${activeEntry.number} ${activeEntry.direction} is correct.` : `${wrong.length} cell${wrong.length === 1 ? " is" : "s are"} incorrect in this word.`);
- }
-
- function checkPuzzle() {
- const wrong = orderedEntries.flatMap(incorrectCells);
- setCheckedWrong(new Set(wrong));
- setActions((current) => [...current, { type: "CHECK_PUZZLE", answers: Object.fromEntries(orderedEntries.map((entry) => [entry.id, entryValue(entry, answers)])), elapsedMs: Date.now() - startedAt.current }]);
- setFeedback(wrong.length === 0 ? "Every filled answer is correct." : `${wrong.length} cells need another look.`);
- }
-
- function showAlternateClue() {
- if (!activeEntry || !settings.allowHints) return;
- setAlternateClues((current) => new Set(current).add(activeEntry.id));
- setActions((current) => [...current, { type: "ALTERNATE_CLUE", entryId: activeEntry.id, elapsedMs: Date.now() - startedAt.current }]);
- }
-
- function revealLetter() {
- if (!activeEntry || !activeCellKey || !settings.allowHints) return;
- const index = activeEntry.cellKeys.indexOf(activeCellKey);
- setAnswers((current) => ({ ...current, [activeCellKey]: activeEntry.answer[index] }));
- setRevealedCells((current) => new Set(current).add(activeCellKey));
- setCheckedWrong((current) => { const next = new Set(current); next.delete(activeCellKey); return next; });
- setActions((current) => [...current, { type: "REVEAL_LETTER", cellKey: activeCellKey, elapsedMs: Date.now() - startedAt.current }]);
- moveWithinEntry(1);
- }
-
- function revealWord() {
- if (!activeEntry || !settings.allowHints) return;
- setAnswers((current) => ({ ...current, ...Object.fromEntries(activeEntry.cellKeys.map((cellKey, index) => [cellKey, activeEntry.answer[index]])) }));
- setRevealedCells((current) => new Set([...current, ...activeEntry.cellKeys]));
- setCheckedWrong((current) => { const next = new Set(current); activeEntry.cellKeys.forEach((cellKey) => next.delete(cellKey)); return next; });
- setActions((current) => [...current, { type: "REVEAL_WORD", entryId: activeEntry.id, elapsedMs: Date.now() - startedAt.current }]);
- setFeedback(`${activeEntry.number} ${activeEntry.direction} was revealed.`);
- }
-
- async function finish(gaveUp: boolean) {
- if (saveState === "saving") return;
- if (!gaveUp && !window.confirm("Submit this puzzle and reveal the results?")) return;
- if (gaveUp && !window.confirm("Give up and reveal every remaining answer?")) return;
- setSaveState("saving");
- const durationSeconds = Math.floor((Date.now() - startedAt.current) / 1000);
- const response = await fetch(`/api/arcade/packs/${packId}/attempts`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- mode: "CROSSWORD",
- durationSeconds,
- seed,
- settings,
- finalAnswers: Object.fromEntries(orderedEntries.map((entry) => [entry.id, entryValue(entry, answers)])),
- actions,
- gaveUp,
- }),
- });
- const data = await response.json();
- if (!response.ok || !data.results) { setSaveState("error"); setFeedback(data.error ?? "Attempt could not be saved."); return; }
- setElapsedSeconds(durationSeconds);
- setResult(data.results);
- }
-
- if (result) {
- return ;
- }
-
- const activeKeys = new Set(activeEntry?.cellKeys ?? []);
- const visibleClues = orderedEntries.filter((entry) => entry.direction === clueTab);
- const cellSize = Math.round(34 * zoom);
- return (
-
- writeLetter(event.target.value)} className="crossword-mobile-input" aria-label="Type crossword letter" autoCapitalize="characters" inputMode="text" />
- {activeEntry?.number} {activeEntry?.direction} {alternateClues.has(activeEntry?.id ?? "") ? activeEntry?.alternateClue : activeEntry?.clue} {feedback}
-
-
-
setZoom((value) => Math.max(.7, value - .1))} aria-label="Zoom out">− {Math.round(zoom * 100)}% setZoom((value) => Math.min(1.5, value + .1))} aria-label="Zoom in">+
{layout.entries.length} placed · {layout.omittedEntries.length} omitted
-
-
- {layout.cells.map((cell) => selectCell(cell.key)} aria-label={`Row ${cell.row + 1}, column ${cell.column + 1}${answers[cell.key] ? `, ${answers[cell.key]}` : ""}`} className={`crossword-cell ${activeKeys.has(cell.key) ? "is-word" : ""} ${activeCellKey === cell.key ? "is-active" : ""} ${checkedWrong.has(cell.key) ? "is-wrong" : ""} ${revealedCells.has(cell.key) ? "is-revealed" : ""}`} style={{ gridRow: cell.row + 1, gridColumn: cell.column + 1 }}>{cell.number} {answers[cell.key] ?? ""} )}
-
-
-
- Check word Check puzzle
- {settings.allowHints && <>Alternate clue Reveal letter Reveal word >}
-
-
void finish(true)} className="crossword-secondary min-h-12">Give up void finish(false)} className="crossword-primary min-h-12 rounded-xl font-extrabold">Submit puzzle
- {saveState === "error" &&
The attempt could not be saved. Your puzzle is still open.
}
-
-
- {(["across", "down"] as const).map((tab) => setClueTab(tab)} className={clueTab === tab ? "is-active" : ""}>{tab} )}
- {visibleClues.map((entry) => selectEntry(entry)} className={entry.id === activeEntryId ? "is-active" : ""}>{entry.number} {alternateClues.has(entry.id) ? entry.alternateClue : entry.clue} )}
-
-
-
- );
-}
-
-function CrosswordResults({ result, layout, answers, revealedCells, exitHref }: { result: CrosswordRoundResult; layout: CrosswordLayout; answers: Record; revealedCells: Set; exitHref: string }) {
- const ordered = result.entries.filter((entry) => !entry.omitted).sort((left, right) => {
- const rank = (entry: typeof left) => !entry.correct ? 0 : entry.revealedWord || entry.revealedLetters > 0 || entry.alternateClueUsed ? 1 : 2;
- return rank(left) - rank(right);
- });
- return
-
Final edition
{result.outcome === "GAVE_UP" ? "Answers revealed" : "Puzzle submitted"} {[["Score", `${result.score}/${result.maxScore}`], ["Accuracy", `${Math.round(result.accuracy * 100)}%`], ["Hints", result.hintsUsed], ["Words", result.placedCount]].map(([label, value]) =>
{label} {value}
)}
-
-
{ordered.map((entry) => {
- const assisted = entry.revealedWord || entry.revealedLetters > 0;
- return
{assisted ? "Assisted" : entry.correct ? "Correct" : "Incorrect"}
{entry.answer} Clue: {entry.clue}
Your answer: {entry.playerAnswer || "No answer"}
{entry.explanation}
;
- })}
-
window.location.reload()} className="crossword-primary min-h-12 rounded-xl font-extrabold">New layout Back to banks
-
;
-}
-
-function FinalCrosswordBoard({ layout, answers, revealedCells }: { layout: CrosswordLayout; answers: Record; revealedCells: Set }) {
- const cellSize = Math.max(18, Math.min(30, Math.floor(640 / Math.max(layout.rows, layout.columns))));
- return
- Completed grid
Your final board Correct Revealed or assisted Incorrect or blank
-
-
- {layout.cells.map((cell) => {
- const assisted = revealedCells.has(cell.key);
- const correct = answers[cell.key] === cell.answer;
- const state = assisted ? "assisted" : correct ? "correct" : "incorrect";
- return
{cell.number} {cell.answer}
;
- })}
-
-
- ;
-}
diff --git a/src/components/arcade/CrosswordHub.tsx b/src/components/arcade/CrosswordHub.tsx
deleted file mode 100644
index 74fa5e9..0000000
--- a/src/components/arcade/CrosswordHub.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { useEffect, useMemo, useState } from "react";
-import { useRouter } from "next/navigation";
-import { ArcadeImportModal } from "@/components/arcade/ArcadeImportModal";
-import { CROSSWORD_SIZE_TARGETS } from "@/lib/arcade/crosswordEngine";
-import type { ArcadeAttemptSummary, ArcadePackSummary, CrosswordLayout, CrosswordSize } from "@/types/arcade";
-
-const SIZES: { value: CrosswordSize; label: string; note: string }[] = [
- { value: "mini", label: "Mini", note: "Up to 15 words" },
- { value: "standard", label: "Standard", note: "Up to 30 words" },
- { value: "large", label: "Large", note: "Up to 50 words" },
- { value: "extra-large", label: "Extra Large", note: "Up to 80 words" },
-];
-
-export function CrosswordHub({ classId, classSlug, initialPacks }: { classId: string; classSlug: string; initialPacks: ArcadePackSummary[] }) {
- const router = useRouter();
- const [selectedId, setSelectedId] = useState(initialPacks[0]?.id ?? "");
- const [showImport, setShowImport] = useState(false);
- const [size, setSize] = useState("standard");
- const [instantCheck, setInstantCheck] = useState(initialPacks[0]?.defaultInstantCheck ?? false);
- const [allowHints, setAllowHints] = useState(initialPacks[0]?.defaultAllowHints ?? true);
- const [attempts, setAttempts] = useState([]);
- const [attemptsLoading, setAttemptsLoading] = useState(initialPacks.length > 0);
- const [previewLayout, setPreviewLayout] = useState(null);
- const [previewLoading, setPreviewLoading] = useState(initialPacks.length > 0);
- const effectiveSelectedId = initialPacks.some((pack) => pack.id === selectedId) ? selectedId : initialPacks[0]?.id ?? "";
- const selected = useMemo(() => initialPacks.find((pack) => pack.id === effectiveSelectedId) ?? null, [effectiveSelectedId, initialPacks]);
-
- useEffect(() => {
- if (!selected) return;
- fetch(`/api/arcade/packs/${selected.id}/attempts`)
- .then((response) => response.ok ? response.json() : [])
- .then(setAttempts)
- .finally(() => setAttemptsLoading(false));
- }, [selected]);
-
- useEffect(() => {
- if (!selected) return;
- let current = true;
- fetch(`/api/arcade/packs/${selected.id}/layout?size=${size}`)
- .then((response) => response.ok ? response.json() : null)
- .then((layout) => { if (current) setPreviewLayout(layout); })
- .finally(() => { if (current) setPreviewLoading(false); });
- return () => { current = false; };
- }, [selected, size]);
-
- function selectPack(pack: ArcadePackSummary) {
- if (pack.id === effectiveSelectedId) return;
- setSelectedId(pack.id);
- setInstantCheck(pack.defaultInstantCheck);
- setAllowHints(pack.defaultAllowHints);
- setAttempts([]);
- setAttemptsLoading(true);
- setPreviewLayout(null);
- setPreviewLoading(true);
- }
-
- function selectSize(nextSize: CrosswordSize) {
- if (nextSize === size) return;
- setSize(nextSize);
- setPreviewLayout(null);
- setPreviewLoading(true);
- }
-
- async function renamePack(pack: ArcadePackSummary) {
- const name = window.prompt("Rename Crossword pack", pack.name)?.trim();
- if (!name || name === pack.name) return;
- await fetch(`/api/arcade/packs/${pack.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) });
- router.refresh();
- }
-
- async function deletePack(pack: ArcadePackSummary) {
- if (!window.confirm(`Delete “${pack.name}” and its attempt history?`)) return;
- await fetch(`/api/arcade/packs/${pack.id}`, { method: "DELETE" });
- if (selectedId === pack.id) setSelectedId("");
- router.refresh();
- }
-
- const playHref = selected
- ? `/${classSlug}/arcade/crossword/${selected.id}/play?size=${size}&instant=${instantCheck ? "1" : "0"}&hints=${allowHints ? "1" : "0"}`
- : "#";
-
- return (
-
-
-
-
← Back to Arcade
-
The evening edition
-
Crossword
-
Choose a terminology bank, set the edition size, and work the clues at your own pace.
-
- setShowImport(true)} className="crossword-primary mt-5 min-h-12 rounded-xl px-5 text-sm font-bold sm:mt-0">Import puzzle bank
-
-
- {initialPacks.length === 0 ? (
-
- {Array.from({ length: 25 }, (_, index) => )}
- Print your first edition
- Import one JSON object containing exactly 80 clue-and-answer entries.
- setShowImport(true)} className="crossword-primary mt-6 min-h-11 rounded-xl px-5 text-sm font-bold">Import Crossword JSON
-
- ) : (
-
-
- Puzzle banks
-
- {initialPacks.map((pack) => {
- const active = pack.id === effectiveSelectedId;
- return
- selectPack(pack)} className="w-full text-left">
- {pack.name} {pack.description &&
{pack.description}
}
- 80 entries Best {pack.bestScore ?? "—"} Latest {pack.latestAttempt?.score ?? "—"}
-
- renamePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-bg-surface-alt">Rename deletePack(pack)} className="min-h-9 rounded-lg px-3 text-xs font-bold text-text-muted hover:bg-error-bg hover:text-error">Delete
- ;
- })}
-
-
-
-
- Choose an edition
- {selected ? <>
- {selected.name}
- Board size {SIZES.map((option) => selectSize(option.value)} className={`crossword-size rounded-xl p-3 text-left ${size === option.value ? "is-active" : ""}`}>{option.label} {option.note} )}
-
-
- setInstantCheck(event.target.checked)} />Instant word checks Check only after a word is filled.
- setAllowHints(event.target.checked)} />Allow hints Alternate clues and reveals stay available.
-
- Target words {CROSSWORD_SIZE_TARGETS[size]}
- Open the puzzle
- Recent editions {attemptsLoading ?
Loading history…
: attempts.length === 0 ?
No attempts yet.
:
{attempts.slice(0, 5).map((attempt) =>
{attempt.score}/{attempt.maxScore} {Math.round(attempt.accuracy * 100)}% · {attempt.durationSeconds}s
)}
}
- > : Select a pack to configure a puzzle.
}
-
-
- )}
- {showImport &&
setShowImport(false)} onImported={() => { setShowImport(false); router.refresh(); }} />}
-
- );
-}
-
-function CrosswordBoardPreview({ layout, loading }: { layout: CrosswordLayout | null; loading: boolean }) {
- const cellSize = layout ? Math.max(3, Math.min(8, Math.floor(190 / Math.max(layout.rows, layout.columns)))) : 6;
- return
- Layout preview {layout && {layout.entries.length} words · {layout.columns} × {layout.rows} }
-
- {loading ?
Composing puzzle…
: layout ?
{layout.cells.map((cell) => )}
:
Preview unavailable
}
-
- ;
-}
diff --git a/src/components/arcade/rendererRegistry.ts b/src/components/arcade/rendererRegistry.ts
deleted file mode 100644
index 9cad5b6..0000000
--- a/src/components/arcade/rendererRegistry.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { ConnectionsGame } from "@/components/arcade/ConnectionsGame";
-import { CrosswordGame } from "@/components/arcade/CrosswordGame";
-
-export const ARCADE_RENDERERS = {
- connections: ConnectionsGame,
- crossword: CrosswordGame,
-} as const;
diff --git a/src/components/import/GenerateTab.tsx b/src/components/import/GenerateTab.tsx
index 71a8b35..59bbe1e 100644
--- a/src/components/import/GenerateTab.tsx
+++ b/src/components/import/GenerateTab.tsx
@@ -2,20 +2,14 @@
import { useState, useEffect, useCallback } from "react";
-export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" | "crossword" }) {
+export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" }) {
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 [loadError, setLoadError] = useState(null);
- 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}`;
-
const loadInstructions = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setLoadError(null);
@@ -84,7 +78,7 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
async function handleCopy() {
try {
- await navigator.clipboard.writeText(displayedInstructions);
+ await navigator.clipboard.writeText(instructions);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
@@ -112,23 +106,9 @@ 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" && (
-
-
-
-
Number of game packs
-
This temporarily updates the copied prompt. Your saved instructions stay unchanged.
-
-
{packCount}
-
-
setPackCount(Number(event.target.value))} className="mt-4 w-full accent-primary" />
-
1 5 10
-
- )}
-