diff --git a/CHANGELOG.md b/CHANGELOG.md index f98b55b..518c192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.5] - 2026-02-09 + +### Added + +- Class-aware state foundations: `Classroom` model support, class utilities (`lib/classes.ts`), and class-focused storage/test coverage. +- Shared `ClassSelector` component for class selection across students, generator, play, breakout rooms, and projects. +- Unified quiz editor test coverage for form + import workflows (`components/quizzes/__tests__/quiz-editor-form.test.tsx`). +- Shared select wrapper tests for popup alignment defaults (`components/ui/__tests__/select.test.tsx`). + +### Changed + +- Student workflow expanded to class-first management: create/select classes, import full class rosters, and manage students in the active class. +- Generator, quiz play, breakout groups, and project lists now run with active-class scoping. +- Quiz editing/import flow consolidated into `QuizEditorForm`; legacy `quiz-import-card` removed. +- Quiz question list card behavior refined for better overflow/height handling. +- Shared `SelectContent` now defaults to popper-style content-fit behavior and supports explicit trigger-aligned positioning via `alignItemWithTrigger={true}` where needed. +- App version updated to `1.1.5` in `package.json`. +- Sidebar version label now reads version dynamically from `package.json` via server layout prop wiring. +- Dependency/version refresh from the `main..HEAD` baseline commits (including `package.json` and `bun.lock` updates from `e9d61c2`). + +### Tests + +- Expanded reducer/storage/type-guard coverage for class-aware persistence and migration paths. +- Added class utility tests (`lib/__tests__/classes.test.ts`) and updated student/reducer expectations. +- Added select alignment behavior tests in `components/ui/__tests__/select.test.tsx`. + +### Documentation + +- Updated README and project docs for class-scoped workflows and unified quiz editing/import. +- Updated component docs to describe select popup auto-sizing defaults and trigger-alignment override. + ## [1.1.4] - 2026-02-05 ### Changed diff --git a/README.md b/README.md index 1eb9fc2..ad6cfe1 100644 --- a/README.md +++ b/README.md @@ -36,16 +36,18 @@ Open [http://localhost:3000](http://localhost:3000) and use the dashboard to nav | Route | Description | | ----------------- | ---------------------------------------------------- | | `/` | Dashboard with links to all features | -| `/students` | Add, import, edit, and manage student roster | -| `/generator` | Draw random students with no-repeat logic | +| `/students` | Manage classes and class-scoped student rosters | +| `/generator` | Draw random students with no-repeat logic per class | | `/quizzes` | Build and edit quiz question sets | -| `/play` | Run live quiz sessions with question/student pairing | -| `/breakout-rooms` | Generate random student groups | -| `/projects` | Create and manage project lists | +| `/play` | Run live quiz sessions scoped to selected class | +| `/breakout-rooms` | Generate random student groups per class | +| `/projects` | Create and manage project lists per class | ### Usage Examples -- **Add students**: Go to `/students`, type names (comma-separated for bulk) or import a `.txt` file. +- **Import full classes**: Go to `/students` and import `.txt` with `Class Name: Student A, Student B` lines, or `.json` with `{ className, students }` objects. +- **Quick-add students**: In `/students`, use the student input or student `.txt` import to add names to the currently selected class. +- **Class-aware tools**: `/generator`, `/breakout-rooms`, `/projects`, and `/play` all use the active class selected in the class dropdown. - **Random draw**: Go to `/generator` and click "Draw" to select a random active student (no repeats until reset). - **Quiz play**: In `/play`, select a quiz, then draw question/student pairs and click to reveal answers. - **Timer**: Use the **timer in the header** (on every page): set time, start countdown; alerts at 10min, 5min, 1min, and 0 (with optional sound). @@ -73,6 +75,7 @@ teacherbuddy/ │ ├── navigation/ # Sidebar navigation │ ├── dashboard/ # Dashboard cards (server component) │ ├── students/ # Student management +│ ├── classes/ # Class selector │ ├── quizzes/ # Quiz builder │ ├── play/ # Quiz play + timer card │ ├── breakout/ # Breakout groups diff --git a/app/breakout-rooms/page.tsx b/app/breakout-rooms/page.tsx index 2b2ac2f..c317ad4 100644 --- a/app/breakout-rooms/page.tsx +++ b/app/breakout-rooms/page.tsx @@ -14,7 +14,7 @@ export const metadata: Metadata = { */ export default function Page() { return ( -
+
); diff --git a/app/generator/page.tsx b/app/generator/page.tsx index 0263e42..b2bbe4c 100644 --- a/app/generator/page.tsx +++ b/app/generator/page.tsx @@ -1,3 +1,5 @@ +import React from 'react'; + import { Metadata } from 'next'; import GeneratorCard from '@/components/generator/generator-card'; @@ -15,7 +17,7 @@ export const metadata: Metadata = { */ export default function Page() { return ( -
+
} />
); diff --git a/app/layout.tsx b/app/layout.tsx index 40f8491..28c4285 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,8 +1,10 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; +import { cookies } from 'next/headers'; import './globals.css'; +import packageJson from '@/package.json'; import AppShell from '@/components/app-shell'; import Footer from '@/components/footer'; @@ -57,11 +59,20 @@ export const metadata: Metadata = { * Renders the shared HTML shell and providers for all application routes. * Wrap page content in this layout so theme, state, sidebar, and footer stay consistent. */ -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode; }) { + const appVersion = packageJson.version; + const cookieStore = await cookies(); + const sidebarCookieValue = + cookieStore.get('teacherbuddy_sidebar_state')?.value ?? + cookieStore.get('sidebar_state')?.value ?? + cookieStore.get('teacherbuddy:sidebar_state')?.value; + const defaultSidebarOpen = + sidebarCookieValue === undefined ? true : sidebarCookieValue === 'true'; + return ( @@ -91,7 +102,12 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`}> - }>{children} + }> + {children} + diff --git a/app/play/page.tsx b/app/play/page.tsx index d146b70..b28287a 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -14,7 +14,7 @@ export const metadata: Metadata = { */ export default function Page() { return ( -
+
} />
); diff --git a/app/projects/page.tsx b/app/projects/page.tsx index fb6ee3e..b278730 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -15,8 +15,8 @@ export const metadata: Metadata = { export default function Page() { return (
- +
); } diff --git a/app/quizzes/page.tsx b/app/quizzes/page.tsx index 43197d2..7c9fa91 100644 --- a/app/quizzes/page.tsx +++ b/app/quizzes/page.tsx @@ -13,9 +13,5 @@ export const metadata: Metadata = { * Provides the editor workflow with a skeleton shown before hydration. */ export default function Page() { - return ( -
- } /> -
- ); + return } />; } diff --git a/app/students/page.tsx b/app/students/page.tsx index ac08413..72eb599 100644 --- a/app/students/page.tsx +++ b/app/students/page.tsx @@ -17,7 +17,7 @@ export const metadata: Metadata = { */ export default function Page() { return ( -
+
} /> } />
diff --git a/bun.lock b/bun.lock index dfe1b22..3a868b9 100644 --- a/bun.lock +++ b/bun.lock @@ -13,31 +13,31 @@ "next-themes": "^0.4.6", "react": "19.2.3", "react-dom": "19.2.3", - "shadcn": "^3.8.2", + "shadcn": "^3.8.4", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0", }, "devDependencies": { - "@ianvs/prettier-plugin-sort-imports": "^4.7.0", - "@tailwindcss/postcss": "^4", + "@ianvs/prettier-plugin-sort-imports": "^4.7.1", + "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", + "@types/node": "^20.19.33", + "@types/react": "^19.2.13", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.3", "@vitest/coverage-v8": "^4.0.18", "@vitest/ui": "^4.0.18", "babel-plugin-react-compiler": "1.0.0", - "eslint": "^9", + "eslint": "^9.39.2", "eslint-config-next": "16.1.6", "jsdom": "^28.0.0", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", - "tailwindcss": "^4", - "typescript": "^5", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", "vitest": "^4.0.18", }, }, @@ -241,7 +241,7 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - "@ianvs/prettier-plugin-sort-imports": ["@ianvs/prettier-plugin-sort-imports@4.7.0", "", { "dependencies": { "@babel/generator": "^7.26.2", "@babel/parser": "^7.26.2", "@babel/traverse": "^7.25.9", "@babel/types": "^7.26.0", "semver": "^7.5.2" }, "peerDependencies": { "@prettier/plugin-oxc": "^0.0.4", "@vue/compiler-sfc": "2.7.x || 3.x", "content-tag": "^4.0.0", "prettier": "2 || 3 || ^4.0.0-0", "prettier-plugin-ember-template-tag": "^2.1.0" }, "optionalPeers": ["@prettier/plugin-oxc", "@vue/compiler-sfc", "content-tag", "prettier-plugin-ember-template-tag"] }, "sha512-soa2bPUJAFruLL4z/CnMfSEKGznm5ebz29fIa9PxYtu8HHyLKNE1NXAs6dylfw1jn/ilEIfO2oLLN6uAafb7DA=="], + "@ianvs/prettier-plugin-sort-imports": ["@ianvs/prettier-plugin-sort-imports@4.7.1", "", { "dependencies": { "@babel/generator": "^7.26.2", "@babel/parser": "^7.26.2", "@babel/traverse": "^7.25.9", "@babel/types": "^7.26.0", "semver": "^7.5.2" }, "peerDependencies": { "@prettier/plugin-oxc": "^0.0.4 || ^0.1.0", "@vue/compiler-sfc": "2.7.x || 3.x", "content-tag": "^4.0.0", "prettier": "2 || 3 || ^4.0.0-0", "prettier-plugin-ember-template-tag": "^2.1.0" }, "optionalPeers": ["@prettier/plugin-oxc", "@vue/compiler-sfc", "content-tag", "prettier-plugin-ember-template-tag"] }, "sha512-jmTNYGlg95tlsoG3JLCcuC4BrFELJtLirLAkQW/71lXSyOhVt/Xj7xWbbGcuVbNq1gwWgSyMrPjJc9Z30hynVw=="], "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], @@ -317,7 +317,7 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], "@mswjs/interceptors": ["@mswjs/interceptors@0.40.0", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ=="], @@ -489,9 +489,9 @@ "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], - "@types/node": ["@types/node@20.19.31", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-5jsi0wpncvTD33Sh1UCgacK37FFwDn+EG7wCmEvs62fCvBL+n8/76cAYDok21NF6+jaVWIqKwCZyX7Vbu8eB3A=="], + "@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="], - "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], + "@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -855,7 +855,7 @@ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -991,6 +991,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], @@ -1399,7 +1401,7 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shadcn": ["shadcn@3.8.2", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.17.2", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-4iqRkfmDmChg4lC7qBImi0KWrCKJbb0rNSs8QuocHmKAlX9U3s0ZcYUbth+2sv1ueferzfjD2hswCOm9Ng4ceA=="], + "shadcn": ["shadcn@3.8.4", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-pSad/m1+PGzB0aLsRBV0EkyGg9al1nJqYUuucg6d8v8xZspPZ5/ehGNEp5M4b1KQYqdO5/gGPbkhVbgmXqG9Pw=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], diff --git a/components/app-shell.tsx b/components/app-shell.tsx index acda6d1..088bc76 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -30,11 +30,17 @@ import Header from './header'; /** * Global app shell that renders the sidebar, header, and page content. * Styled to match the design-6 command center aesthetic with phase-colored navigation. + * Provide `appVersion` from server layout so the sidebar version stays in sync with package metadata. + * Provide `defaultSidebarOpen` from server cookie state so refreshes preserve sidebar preference. */ export default function AppShell({ + appVersion, + defaultSidebarOpen, children, footer, }: { + appVersion: string; + defaultSidebarOpen: boolean; children: React.ReactNode; footer?: React.ReactNode; }) { @@ -46,7 +52,7 @@ export default function AppShell({ }; return ( - + @@ -86,7 +92,7 @@ export default function AppShell({
- v1.1.4 + v{appVersion} Classroom @@ -100,7 +106,7 @@ export default function AppShell({ meta={meta} info={{ currentPath: pathname, pages: PAGE_INFOS }} /> -
+
{children}
{footer ?? null} diff --git a/components/breakout/breakout-groups-card.tsx b/components/breakout/breakout-groups-card.tsx index df90140..9714877 100644 --- a/components/breakout/breakout-groups-card.tsx +++ b/components/breakout/breakout-groups-card.tsx @@ -1,210 +1,212 @@ -'use client'; +'use client' -import type { Student } from '@/lib/models'; +import type { Student } from '@/lib/models' -import { formatStudentName } from '@/lib/students'; +import { formatStudentName } from '@/lib/students' -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react' -import { CheckIcon, CopyIcon } from 'lucide-react'; -import { toast } from 'sonner'; +import { CheckIcon, CopyIcon } from 'lucide-react' +import { toast } from 'sonner' -import GeneratorCardSkeleton from '@/components/loading/generator-card-skeleton'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; +import ClassSelector from '@/components/classes/class-selector' +import GeneratorCardSkeleton from '@/components/loading/generator-card-skeleton' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle, -} from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { useAppStore } from '@/context/app-store'; -import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; -import { useTheme } from '@/hooks/use-theme'; +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { useAppStore } from '@/context/app-store' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' +import { useTheme } from '@/hooks/use-theme' -const DEFAULT_GROUP_SIZE = 3; +const DEFAULT_GROUP_SIZE = 3 /** - * Renders breakout room generation controls and the resulting student groups. - * Uses active students from app state and supports copying full or per-group output. + * Renders breakout room generation controls and the resulting class-scoped student groups. */ export default function BreakoutGroupsCard() { - const { theme } = useTheme(); - const { state, actions } = useAppStore(); - const [groupSizeOverride, setGroupSizeOverride] = useState( - null, - ); + const { theme } = useTheme() + const { state, actions } = useAppStore() + const [groupSizeOverride, setGroupSizeOverride] = useState(null) const { copy: copyAll, isCopied: isAllCopied, reset: resetAllCopy, - } = useCopyToClipboard(); - const { copy: copyGroup, reset: resetGroupCopy } = useCopyToClipboard(); - const [copiedGroupIndex, setCopiedGroupIndex] = useState(null); - const copyGroupTimeoutRef = useRef | null>(null); + } = useCopyToClipboard() + const { copy: copyGroup, reset: resetGroupCopy } = useCopyToClipboard() + const [copiedGroupIndex, setCopiedGroupIndex] = useState(null) + const copyGroupTimeoutRef = useRef | null>(null) + + const activeClassId = state.persisted.activeClassId useEffect(() => { return () => { if (copyGroupTimeoutRef.current !== null) { - clearTimeout(copyGroupTimeoutRef.current); + clearTimeout(copyGroupTimeoutRef.current) } - }; - }, []); + } + }, []) const activeStudents = useMemo( () => - state.persisted.students.filter((student) => student.status === 'active'), - [state.persisted.students], - ); + state.persisted.students.filter( + (student) => student.classId === activeClassId && student.status === 'active' + ), + [activeClassId, state.persisted.students] + ) + + const persistedGroups = activeClassId + ? state.persisted.breakoutGroupsByClass[activeClassId] ?? null + : null - const persistedGroups = state.persisted.breakoutGroups; const groupSizeToUse = - groupSizeOverride ?? persistedGroups?.groupSize ?? DEFAULT_GROUP_SIZE; - const canGenerateGroups = activeStudents.length > 0 && groupSizeToUse > 0; + groupSizeOverride ?? persistedGroups?.groupSize ?? DEFAULT_GROUP_SIZE + const canGenerateGroups = !!activeClassId && activeStudents.length > 0 && groupSizeToUse > 0 const studentById = useMemo(() => { - return new Map( - state.persisted.students.map((student) => [student.id, student]), - ); - }, [state.persisted.students]); + return new Map(activeStudents.map((student) => [student.id, student])) + }, [activeStudents]) const groups = useMemo((): Student[][] => { - if (!persistedGroups) return []; + if (!persistedGroups) return [] return persistedGroups.groupIds - .map( - (group: string[]) => - group - .map((id: string) => studentById.get(id)) - .filter(Boolean) as Student[], + .map((group) => + group + .map((id) => studentById.get(id)) + .filter(Boolean) as Student[] ) - .filter((group: Student[]) => group.length > 0); - }, [persistedGroups, studentById]); + .filter((group) => group.length > 0) + }, [persistedGroups, studentById]) const groupSummary = useMemo(() => { return groups - .map((group: Student[], index: number) => { - const names = group - .map((student: Student) => formatStudentName(student.name)) - .join(', '); - return `Group ${index + 1}: ${names}`; + .map((group, index) => { + const names = group.map((student) => formatStudentName(student.name)).join(', ') + return `Group ${index + 1}: ${names}` }) - .join('\n'); - }, [groups]); + .join('\n') + }, [groups]) + /** + * Randomizes students and chunks them into equally-sized groups. + */ const buildGroups = (students: Student[], size: number) => { - const shuffled = [...students]; + const shuffled = [...students] for (let index = shuffled.length - 1; index > 0; index -= 1) { - const swapIndex = Math.floor(Math.random() * (index + 1)); - [shuffled[index], shuffled[swapIndex]] = [ - shuffled[swapIndex], - shuffled[index], - ]; + const swapIndex = Math.floor(Math.random() * (index + 1)) + ;[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]] } - const nextGroups: Student[][] = []; + const nextGroups: Student[][] = [] for (let index = 0; index < shuffled.length; index += size) { - nextGroups.push(shuffled.slice(index, index + size)); + nextGroups.push(shuffled.slice(index, index + size)) } - return nextGroups; - }; + return nextGroups + } if (!state.ui.isHydrated) { - return ; + return } return ( - +
- -
- Breakout Rooms - - Shuffle active students into randomized breakout groups. - + +
+
+ Breakout Rooms + + Shuffle active students into randomized breakout groups. + +
+ + {activeStudents.length} students +
- - {activeStudents.length} students - +
- -
-