-
+
+
+
Selected Student
-
+
{currentStudent ? formatStudentName(currentStudent.name) : '—'}
- {!activeStudents.length ? (
-
+ {!activeClassId ? (
+
+ Add and select a class to begin.
+
+ ) : !activeStudents.length ? (
+
Add or re-enable students to begin.
) : null}
-
+
+ className='h-9 font-semibold text-base sm:min-w-32'>
Generate Student
+ className='h-9 font-semibold text-base sm:min-w-32'>
Reset Generator
- );
+ )
}
diff --git a/components/loading/quiz-editor-skeleton.tsx b/components/loading/quiz-editor-skeleton.tsx
index bbe54d7..d27f3e2 100644
--- a/components/loading/quiz-editor-skeleton.tsx
+++ b/components/loading/quiz-editor-skeleton.tsx
@@ -1,22 +1,32 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
-function PlaceholderCard({ titleWidth }: { titleWidth: string }) {
+/**
+ * Renders one placeholder card matching the merged quiz builder form card.
+ */
+function BuilderPlaceholderCard() {
return (
-
+
-
+
+
+
+
+
+
+
+
)
@@ -28,12 +38,8 @@ function PlaceholderCard({ titleWidth }: { titleWidth: string }) {
*/
export default function QuizEditorSkeleton() {
return (
-
-
+
+
@@ -45,7 +51,7 @@ export default function QuizEditorSkeleton() {
- {Array.from({ length: 5 }).map((_, index) => (
+ {Array.from({ length: 7 }).map((_, index) => (
))}
diff --git a/components/play/quiz-play-card.tsx b/components/play/quiz-play-card.tsx
index 1f64346..6268aff 100644
--- a/components/play/quiz-play-card.tsx
+++ b/components/play/quiz-play-card.tsx
@@ -4,6 +4,7 @@ import { formatStudentName } from '@/lib/students';
import { useMemo } from 'react';
+import ClassSelector from '@/components/classes/class-selector';
import QuizSelector from '@/components/quizzes/quiz-selector';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -27,14 +28,17 @@ export default function QuizPlayCard({
skeleton: React.ReactNode;
}) {
const { state, actions } = useAppStore();
+ const activeClassId = state.persisted.activeClassId;
const selectedQuizId = state.domain.quizPlay.selectedQuizId;
const quiz = selectedQuizId ? state.persisted.quizzes[selectedQuizId] : null;
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 availableQuestionIds = useMemo(() => {
@@ -59,7 +63,9 @@ export default function QuizPlayCard({
(question) => question.id === state.domain.quizPlay.currentQuestionId,
);
const currentStudent = state.persisted.students.find(
- (student) => student.id === state.domain.quizPlay.currentStudentId,
+ (student) =>
+ student.id === state.domain.quizPlay.currentStudentId &&
+ student.classId === activeClassId,
);
const canDraw =
@@ -82,6 +88,7 @@ export default function QuizPlayCard({
+
-
+
+ className="h-9 w-full font-semibold text-base sm:min-w-32 sm:w-auto">
Draw Student + Question
+ className="h-9 w-full font-semibold text-base sm:min-w-32 sm:w-auto">
Reveal Answer
+ className="h-9 w-full font-semibold text-base sm:min-w-32 sm:w-auto">
Reset Round
diff --git a/components/projects/project-list-builder.tsx b/components/projects/project-list-builder.tsx
index d8b5165..fdfa835 100644
--- a/components/projects/project-list-builder.tsx
+++ b/components/projects/project-list-builder.tsx
@@ -9,6 +9,7 @@ import Link from 'next/link';
import { UsersIcon } from 'lucide-react';
import { toast } from 'sonner';
+import ClassSelector from '@/components/classes/class-selector';
import { Badge } from '@/components/ui/badge';
import { Button, buttonVariants } from '@/components/ui/button';
import {
@@ -56,6 +57,7 @@ type GroupMode = 'none' | 'grouped';
*/
export default function ProjectListBuilder() {
const { state, actions } = useAppStore();
+ const activeClassId = state.persisted.activeClassId;
const isMobile = useIsMobile();
const [selectedIds, setSelectedIds] = useState
([]);
const [name, setName] = useState('');
@@ -70,10 +72,12 @@ export default function ProjectListBuilder() {
const students = useMemo(
() =>
- [...state.persisted.students].sort((a, b) =>
+ [...state.persisted.students]
+ .filter((student) => student.classId === activeClassId)
+ .sort((a, b) =>
a.name.localeCompare(b.name),
- ),
- [state.persisted.students],
+ ),
+ [activeClassId, state.persisted.students],
);
const visibleStudents = useMemo(
@@ -104,6 +108,12 @@ export default function ProjectListBuilder() {
};
const handleCreateList = () => {
+ if (!activeClassId) {
+ const message = 'Create and select a class before building a project list.';
+ setError(message);
+ toast.error(message);
+ return;
+ }
const trimmedName = name.trim();
const trimmedType = projectType.trim();
if (!trimmedName || !trimmedType) {
@@ -182,6 +192,7 @@ export default function ProjectListBuilder() {
Choose students from your roster and save them as a project-ready list
or group set.
+
@@ -492,7 +503,7 @@ export default function ProjectListBuilder() {
+ className="h-9 w-full font-semibold text-base sm:w-auto">
Save Project List
diff --git a/components/projects/project-list-view.tsx b/components/projects/project-list-view.tsx
index 99b88be..026b5a8 100644
--- a/components/projects/project-list-view.tsx
+++ b/components/projects/project-list-view.tsx
@@ -9,6 +9,7 @@ import { useTheme } from 'next-themes';
import { UsersIcon } from 'lucide-react';
+import ClassSelector from '@/components/classes/class-selector';
import {
AlertDialog,
AlertDialogAction,
@@ -57,6 +58,7 @@ import { useIsMobile } from '@/hooks/use-mobile';
export default function ProjectListView() {
const { theme } = useTheme();
const { state, actions } = useAppStore();
+ const activeClassId = state.persisted.activeClassId;
const isMobile = useIsMobile();
const [editingListId, setEditingListId] = useState(null);
const [addStudentSheetOpen, setAddStudentSheetOpen] = useState(false);
@@ -75,6 +77,10 @@ export default function ProjectListView() {
),
[state.persisted.projectLists],
);
+ const visibleProjectLists = useMemo(
+ () => projectLists.filter((list) => list.classId === activeClassId),
+ [activeClassId, projectLists],
+ );
const studentMap = useMemo(() => {
return new Map(
@@ -186,7 +192,7 @@ export default function ProjectListView() {
return null;
}
- if (!projectLists.length) {
+ if (!visibleProjectLists.length) {
return (
Saved project lists
+
Your saved project lists will appear here.
@@ -205,7 +212,8 @@ export default function ProjectListView() {
return (
- {projectLists.map((list) => {
+
+ {visibleProjectLists.map((list) => {
const createdAt = new Date(list.createdAt).toLocaleDateString();
const isEditing = editingListId === list.id;
const isGroupedList = list.groups.length > 0;
diff --git a/components/quizzes/__tests__/quiz-editor-form.test.tsx b/components/quizzes/__tests__/quiz-editor-form.test.tsx
new file mode 100644
index 0000000..2902e24
--- /dev/null
+++ b/components/quizzes/__tests__/quiz-editor-form.test.tsx
@@ -0,0 +1,150 @@
+import userEvent from '@testing-library/user-event';
+import { screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { toast } from 'sonner';
+
+import { renderWithProvider } from '@/__tests__/test-utils';
+import QuizEditorForm from '@/components/quizzes/quiz-editor-form';
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+describe('QuizEditorForm', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ });
+
+ /**
+ * Renders the quiz editor form with a fresh quiz draft.
+ *
+ * @returns Testing-library utilities and mounted form content.
+ */
+ const renderForm = () =>
+ renderWithProvider(
);
+
+ it('renders a unified builder card with question and import controls', () => {
+ const { container } = renderForm();
+
+ expect(screen.getByText('Quiz Builder')).toBeInTheDocument();
+ expect(screen.getByLabelText(/quiz title/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/quiz description/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/add question/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/import quiz from json/i)).toBeInTheDocument();
+ expect(container.querySelector('.overflow-y-auto')).not.toBeInTheDocument();
+ });
+
+ it('imports a valid json file into the current draft', async () => {
+ const user = userEvent.setup();
+ const { container } = renderForm();
+
+ const fileInput = screen.getByLabelText(/import quiz from json/i);
+ const file = new File(
+ [
+ JSON.stringify({
+ title: 'Math Quiz',
+ description: 'optional',
+ questions: [
+ { prompt: 'What is 2 + 2?', answer: '4' },
+ { prompt: 'What is 3 + 3?', answer: '6' },
+ ],
+ }),
+ ],
+ 'quiz.json',
+ { type: 'application/json' },
+ );
+
+ await user.upload(fileInput, file);
+
+ await waitFor(() => {
+ expect(screen.getByDisplayValue('Math Quiz')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('optional')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Loaded 2 questions into the draft. Click "Save Quiz" to keep it.',
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getAllByText('What is 2 + 2?').length).toBeGreaterThan(0);
+ expect(container.querySelector('.overflow-y-auto')).toBeInTheDocument();
+ });
+ });
+
+ it('shows an error for invalid json files', async () => {
+ const user = userEvent.setup();
+ renderForm();
+
+ const fileInput = screen.getByLabelText(/import quiz from json/i);
+ const file = new File(['{invalid json'], 'broken.json', {
+ type: 'application/json',
+ });
+
+ await user.upload(fileInput, file);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText('Invalid JSON file. Please check the format and try again.'),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('shows a schema error for missing required json fields', async () => {
+ const user = userEvent.setup();
+ renderForm();
+
+ const fileInput = screen.getByLabelText(/import quiz from json/i);
+ const file = new File([JSON.stringify({ title: 'Missing questions' })], 'invalid-schema.json', {
+ type: 'application/json',
+ });
+
+ await user.upload(fileInput, file);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(
+ 'Import must match quiz objects with title and questions[{prompt,answer}].',
+ ),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('imports multiple quizzes from a json array', async () => {
+ const user = userEvent.setup();
+ renderForm();
+
+ const fileInput = screen.getByLabelText(/import quiz from json/i);
+ const file = new File(
+ [
+ JSON.stringify([
+ {
+ title: 'Quiz A',
+ description: 'Set A',
+ questions: [{ prompt: '1+1?', answer: '2' }],
+ },
+ {
+ title: 'Quiz B',
+ description: 'Set B',
+ questions: [{ prompt: '2+2?', answer: '4' }],
+ },
+ ]),
+ ],
+ 'bulk.json',
+ { type: 'application/json' },
+ );
+
+ await user.upload(fileInput, file);
+
+ await waitFor(() => {
+ expect(
+ vi.mocked(toast.success).mock.calls.some(
+ ([message]) =>
+ message ===
+ 'Saved 2 quizzes from file. The last imported quiz is selected.',
+ ),
+ ).toBe(true);
+ });
+ });
+});
diff --git a/components/quizzes/quiz-editor-form.tsx b/components/quizzes/quiz-editor-form.tsx
index 4cb7055..d659a04 100644
--- a/components/quizzes/quiz-editor-form.tsx
+++ b/components/quizzes/quiz-editor-form.tsx
@@ -2,7 +2,7 @@
import type { Question, Quiz } from '@/lib/models';
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react';
import { toast } from 'sonner';
@@ -33,43 +33,213 @@ import {
FieldDescription,
FieldError,
FieldLabel,
+ FieldSeparator,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea';
import { useAppStore } from '@/context/app-store';
type QuizEditorFormProps = {
quiz: Quiz | null;
quizId: string | null;
- importCard: React.ReactNode;
+};
+
+type JsonPrimitive = boolean | null | number | string;
+type JsonValue = JsonArray | JsonObject | JsonPrimitive;
+type JsonArray = JsonValue[];
+type JsonObject = { [key: string]: JsonValue };
+type ImportedQuestionDraft = {
+ prompt: string;
+ answer: string;
+};
+type ImportedQuizDraft = {
+ title: string;
+ description?: string;
+ questions: ImportedQuestionDraft[];
+};
+type QuizImportResult =
+ | { status: 'success'; drafts: ImportedQuizDraft[] }
+ | { status: 'invalid-json' }
+ | { status: 'invalid-schema' };
+type InputChangeEvent = Parameters<
+ NonNullable
['onChange']>
+>[0];
+
+/**
+ * Checks whether a JSON value is a plain object.
+ *
+ * @param value - Parsed JSON value to inspect.
+ * @returns `true` when the value can be safely accessed by object keys.
+ */
+const isJsonObject = (value: JsonValue): value is JsonObject =>
+ typeof value === 'object' && value !== null && !Array.isArray(value);
+
+/**
+ * Safely reads a string field from a JSON object.
+ *
+ * @param object - Parsed JSON object.
+ * @param key - Field name to read.
+ * @returns A string when the field exists and is a string, otherwise `null`.
+ */
+const getJsonStringField = (object: JsonObject, key: string): string | null => {
+ const value = object[key];
+ return typeof value === 'string' ? value : null;
+};
+
+/**
+ * Extracts valid prompt/answer pairs from a JSON value.
+ *
+ * @param value - Raw `questions` value from parsed JSON.
+ * @returns Question drafts with trimmed prompt/answer values.
+ */
+const parseImportedQuestions = (value: JsonValue): ImportedQuestionDraft[] => {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ const parsedQuestions: ImportedQuestionDraft[] = [];
+ for (const questionValue of value) {
+ if (!isJsonObject(questionValue)) continue;
+ const promptValue = getJsonStringField(questionValue, 'prompt');
+ const answerValue = getJsonStringField(questionValue, 'answer');
+ const prompt = promptValue?.trim() ?? '';
+ const answer = answerValue?.trim() ?? '';
+ if (!prompt || !answer) continue;
+ parsedQuestions.push({ prompt, answer });
+ }
+
+ return parsedQuestions;
+};
+
+/**
+ * Parses one quiz object from JSON into a normalized draft.
+ *
+ * @param value - Raw JSON value expected to match one quiz payload.
+ * @returns A normalized draft when valid, otherwise `null`.
+ */
+const parseImportedQuizDraft = (value: JsonValue): ImportedQuizDraft | null => {
+ if (!isJsonObject(value)) {
+ return null;
+ }
+
+ const rawTitle = getJsonStringField(value, 'title');
+ const descriptionValue = value.description;
+ if (descriptionValue !== undefined && typeof descriptionValue !== 'string') {
+ return null;
+ }
+ const questionsValue = value.questions;
+ if (!rawTitle || !questionsValue) {
+ return null;
+ }
+
+ const title = rawTitle.trim();
+ const description =
+ typeof descriptionValue === 'string' ? descriptionValue.trim() : '';
+ if (!title) {
+ return null;
+ }
+
+ const questions = parseImportedQuestions(questionsValue);
+ if (!questions.length) {
+ return null;
+ }
+
+ return {
+ title,
+ ...(description ? { description } : {}),
+ questions,
+ };
+};
+
+/**
+ * Validates raw import text against the quiz import schema.
+ *
+ * Expected shapes:
+ * 1) `{ "title": string, "description"?: string, "questions": [{ "prompt": string, "answer": string }] }`
+ * 2) An array of the same quiz object shape for bulk import.
+ *
+ * @param payload - UTF-8 text read from the uploaded JSON file.
+ * @returns A discriminated result describing success or validation failure.
+ */
+const parseQuizImportPayload = (payload: string): QuizImportResult => {
+ let parsed: JsonValue;
+ try {
+ parsed = JSON.parse(payload) as JsonValue;
+ } catch {
+ return { status: 'invalid-json' };
+ }
+
+ if (Array.isArray(parsed)) {
+ if (!parsed.length) {
+ return { status: 'invalid-schema' };
+ }
+ const drafts: ImportedQuizDraft[] = [];
+ for (const entry of parsed) {
+ const parsedDraft = parseImportedQuizDraft(entry);
+ if (!parsedDraft) {
+ return { status: 'invalid-schema' };
+ }
+ drafts.push(parsedDraft);
+ }
+ return { status: 'success', drafts };
+ }
+
+ const parsedDraft = parseImportedQuizDraft(parsed);
+ if (!parsedDraft) {
+ return { status: 'invalid-schema' };
+ }
+ return { status: 'success', drafts: [parsedDraft] };
+};
+
+/**
+ * Reads a selected file as UTF-8 text.
+ * Uses `File.text()` when available and falls back to `FileReader` for
+ * environments that do not implement the modern file API.
+ *
+ * @param file - Browser file object selected from the import control.
+ * @returns Promise resolving to the file contents as text.
+ */
+const readFileAsText = (file: File): Promise => {
+ if (typeof file.text === 'function') {
+ return file.text();
+ }
+
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const result = reader.result;
+ if (typeof result === 'string') {
+ resolve(result);
+ return;
+ }
+ reject(new Error('Unable to read file contents.'));
+ };
+ reader.onerror = () => reject(new Error('Unable to read file contents.'));
+ reader.readAsText(file);
+ });
};
/**
* Renders quiz editing controls for quiz metadata and question management.
* Accepts an optional existing quiz and emits store actions for create/update/delete flows.
*/
-export default function QuizEditorForm({
- quiz,
- quizId,
- importCard,
-}: QuizEditorFormProps) {
+export default function QuizEditorForm({ quiz, quizId }: QuizEditorFormProps) {
const { state, actions } = useAppStore();
+ const builderCardRef = useRef(null);
// Form state - initialized from props, reset via key pattern in parent
const [title, setTitle] = useState(quiz?.title ?? '');
+ const [description, setDescription] = useState(quiz?.description ?? '');
const [questions, setQuestions] = useState(quiz?.questions ?? []);
const [prompt, setPrompt] = useState('');
const [answer, setAnswer] = useState('');
const [quizError, setQuizError] = useState(null);
const [questionError, setQuestionError] = useState(null);
+ const [importError, setImportError] = useState(null);
+ const [importNotice, setImportNotice] = useState(null);
+ const [builderCardHeight, setBuilderCardHeight] = useState(
+ null,
+ );
const [editingQuestionId, setEditingQuestionId] = useState(
null,
);
@@ -80,6 +250,41 @@ export default function QuizEditorForm({
[questions, editingQuestionId],
);
+ /**
+ * Tracks builder card height so the questions card can scroll instead of
+ * growing taller than the builder panel.
+ */
+ useEffect(() => {
+ const element = builderCardRef.current;
+ if (!element) {
+ return;
+ }
+
+ const measureHeight = () => {
+ const nextHeight = Math.round(element.getBoundingClientRect().height);
+ setBuilderCardHeight(nextHeight);
+ };
+
+ measureHeight();
+ const rafId = window.requestAnimationFrame(measureHeight);
+
+ if (typeof ResizeObserver !== 'undefined') {
+ const observer = new ResizeObserver(measureHeight);
+ observer.observe(element);
+ return () => {
+ window.cancelAnimationFrame(rafId);
+ observer.disconnect();
+ };
+ }
+
+ window.addEventListener('resize', measureHeight);
+
+ return () => {
+ window.cancelAnimationFrame(rafId);
+ window.removeEventListener('resize', measureHeight);
+ };
+ }, []);
+
/**
* Validates quiz metadata, saves changes, and confirms with a toast.
*/
@@ -95,10 +300,10 @@ export default function QuizEditorForm({
}
if (quizId) {
- actions.updateQuiz(quizId, trimmed, questions);
+ actions.updateQuiz(quizId, trimmed, questions, description);
toast.success('Quiz updated.');
} else {
- actions.createQuiz(trimmed, questions);
+ actions.createQuiz(trimmed, questions, description);
toast.success('Quiz created.');
}
setQuizError(null);
@@ -139,9 +344,8 @@ export default function QuizEditorForm({
setAnswer('');
setEditingQuestionId(null);
setQuestionError(null);
- toast.success(
- editingQuestionId ? 'Question updated.' : 'Question added.',
- );
+ setImportNotice(null);
+ toast.success(editingQuestionId ? 'Question updated.' : 'Question added.');
};
/**
@@ -201,176 +405,304 @@ export default function QuizEditorForm({
toast.success('Quiz deleted.');
};
+ /**
+ * Imports a quiz JSON file into the current draft title and question list.
+ *
+ * @param event - File input change event carrying the selected `.json` file.
+ * @returns A promise that resolves after file validation and draft updates.
+ */
+ const handleQuizFileImport = async (event: InputChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ try {
+ const payload = await readFileAsText(file);
+ const result = parseQuizImportPayload(payload);
+ if (result.status === 'invalid-json') {
+ const message =
+ 'Invalid JSON file. Please check the format and try again.';
+ setImportError(message);
+ setImportNotice(null);
+ toast.error(message);
+ return;
+ }
+ if (result.status === 'invalid-schema') {
+ const message =
+ 'Import must match quiz objects with title and questions[{prompt,answer}].';
+ setImportError(message);
+ setImportNotice(null);
+ toast.error(message);
+ return;
+ }
+ if (result.drafts.length > 1) {
+ for (const draft of result.drafts) {
+ const nextQuestions: Question[] = draft.questions.map((question) => ({
+ id: crypto.randomUUID(),
+ prompt: question.prompt,
+ answer: question.answer,
+ }));
+ actions.createQuiz(draft.title, nextQuestions, draft.description);
+ }
+ const message = `Saved ${result.drafts.length} quizzes from file. The last imported quiz is selected.`;
+ setImportError(null);
+ setImportNotice(message);
+ toast.success(message);
+ return;
+ }
+
+ const singleDraft = result.drafts[0];
+ const importedQuestions: Question[] = singleDraft.questions.map(
+ (question) => ({
+ id: crypto.randomUUID(),
+ prompt: question.prompt,
+ answer: question.answer,
+ }),
+ );
+
+ setTitle(singleDraft.title);
+ setDescription(singleDraft.description ?? '');
+ setQuestions(importedQuestions);
+ setPrompt('');
+ setAnswer('');
+ setQuizError(null);
+ setQuestionError(null);
+ setEditingQuestionId(null);
+ setImportError(null);
+ const draftMessage = `Loaded ${importedQuestions.length} question${importedQuestions.length === 1 ? '' : 's'} into the draft. Click "Save Quiz" to keep it.`;
+ setImportNotice(draftMessage);
+ toast.success('Loaded quiz into draft. Not saved yet.');
+ } catch (error) {
+ console.error('Failed to import quiz file', error);
+ const message = 'Could not read the file. Please try again.';
+ setImportError(message);
+ setImportNotice(null);
+ toast.error(message);
+ } finally {
+ event.target.value = '';
+ }
+ };
+
return (
<>
-
-
-
+
+
+
+
+ Quiz Builder
+
+
+ Select a saved quiz, build new questions, or import JSON into the
+ current draft.
+
+
+
+
-
- Quiz Details
-
- Select an existing quiz or start a new one.
-
-
-
-
-
-
- Quiz title
-
-
- setTitle(event.target.value)}
- className="text-base/relaxed h-9 placeholder:text-muted-foreground/70 placeholder:text-base/relaxed"
- placeholder="e.g. Geography Review"
- />
-
- Titles are display-only and can be edited later.
-
-
-
- {quizError ? {quizError} : null}
-
-
- Save Quiz
-
+
+
+ Quiz title
+
+
+ setTitle(event.target.value)}
+ className="h-9 text-base/relaxed placeholder:text-base/relaxed placeholder:text-muted-foreground/70"
+ placeholder="e.g. Geography Review"
+ />
+
+ Titles are display-only and can be edited later.
+
+
+
+
+
+ Quiz description (optional)
+
+
+
+
+ {quizError ?
{quizError} : null}
+
+
+ Save Quiz
+
+
+ New Quiz
+
+
+ {quizId ? (
+
+
+ }>
+ Delete Quiz
+
+
+
+ Delete this quiz?
+
+ This will remove the quiz and its questions from local
+ storage.
+
+
+
+ Cancel
+
+ Delete
+
+
+
+
+ ) : null}
+
+
Questions
+
+
+
+ {editingQuestion ? 'Edit question' : 'Add question'}
+
+
+ setPrompt(event.target.value)}
+ placeholder="What is the capital of France?"
+ className="h-9 text-base/relaxed placeholder:text-base/relaxed placeholder:text-muted-foreground/70"
+ />
+
+
+
+
+ Answer
+
+
+
+
+ {questionError ?
{questionError} : null}
+
+
+
+ {editingQuestion ? 'Update Question' : 'Add Question'}
+
+ {editingQuestion ? (
- New Quiz
+ Cancel Edit
-
- {quizId ? (
-
-
- }>
- Delete Quiz
-
-
-
- Delete this quiz?
-
- This will remove the quiz and its questions from local
- storage.
-
-
-
- Cancel
-
- Delete
-
-
-
-
) : null}
-
-
+
-
-
-
-
- {editingQuestion ? 'Edit Question' : 'Add Question'}
-
-
- Add prompts and answers before saving the quiz.
-
-
-
-
-
- Question
-
-
- setPrompt(event.target.value)}
- placeholder="What is the capital of France?"
- className="text-base/relaxed h-9 placeholder:text-muted-foreground/70 placeholder:text-base/relaxed"
- />
-
-
-
-
- Answer
-
-
-
-
- {questionError ? {questionError} : null}
-
-
-
- {editingQuestion ? 'Update Question' : 'Add Question'}
-
- {editingQuestion ? (
-
- Cancel Edit
-
- ) : null}
-
-
-
+ Import
- {importCard}
+
+
+ Import quiz from JSON
+
+
+
+
+ Import one quiz object or an array of quiz objects. Each quiz
+ needs title, optional description, and{' '}
+ questions with prompt and{' '}
+ answer. Single quiz files load into your draft
+ (not saved until you click Save Quiz). Arrays save quizzes right
+ away.
+
+
+ {`{
+ "title": "Math Quiz",
+ "description": "Optional",
+ "questions": [
+ { "prompt": "What is 2 + 2?", "answer": "4" }
+ ]
+}`}
+
+ {importError ? {importError} : null}
+ {importNotice ? (
+
+ {importNotice}
+
+ ) : null}
+
+
+
+
-
+
- Questions
+
+ Questions
+
{questions.length
? `${questions.length} question${questions.length === 1 ? '' : 's'}`
: 'Add questions to build your quiz.'}
-
+
{questions.length ? (
- <>
- {/* Mobile question cards */}
-
+
+
{questions.map((question) => (
+ className="rounded-lg border border-border/60 bg-background/40 p-3">
{question.prompt}
@@ -394,56 +726,13 @@ export default function QuizEditorForm({
-
+
{question.answer}
))}
-
- {/* Desktop table */}
-
-
-
- Prompt
- Answer
-
- Actions
-
-
-
-
- {questions.map((question) => (
-
-
- {question.prompt}
-
-
- {question.answer}
-
-
-
-
handleEditQuestion(question.id)}>
-
- Edit
-
-
handleRemoveQuestion(question.id)}>
-
- Remove
-
-
-
-
- ))}
-
-
- >
+
) : (
No questions yet. Add your first question to get started.
diff --git a/components/quizzes/quiz-editor.tsx b/components/quizzes/quiz-editor.tsx
index cc8ea24..8035cfd 100644
--- a/components/quizzes/quiz-editor.tsx
+++ b/components/quizzes/quiz-editor.tsx
@@ -2,7 +2,6 @@
import { useAppStore } from '@/context/app-store';
import QuizEditorForm from '@/components/quizzes/quiz-editor-form';
-import QuizImportCard from '@/components/quizzes/quiz-import-card';
/**
* Quiz editor wrapper: shows server-rendered skeleton until hydrated, then the editor.
@@ -22,13 +21,12 @@ export default function QuizEditor({
}
return (
-
+
{/* Key pattern: reset all form state when quiz changes */}
}
/>
)
diff --git a/components/quizzes/quiz-import-card.tsx b/components/quizzes/quiz-import-card.tsx
deleted file mode 100644
index 32b439c..0000000
--- a/components/quizzes/quiz-import-card.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-'use client';
-
-import type { Question } from '@/lib/models';
-
-import { useState } from 'react';
-
-import { toast } from 'sonner';
-
-import { Button } from '@/components/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card';
-import {
- Field,
- FieldContent,
- FieldDescription,
- FieldError,
- FieldLabel,
-} from '@/components/ui/field';
-import { Textarea } from '@/components/ui/textarea';
-import { useAppStore } from '@/context/app-store';
-
-/**
- * Renders JSON import controls for creating one or more quizzes in bulk.
- * Expects quiz payloads with `title` and `questions[]` containing `prompt` and `answer`.
- */
-export default function QuizImportCard() {
- const { actions } = useAppStore();
- const [importPayload, setImportPayload] = useState('');
- const [importError, setImportError] = useState
(null);
- const [importNotice, setImportNotice] = useState(null);
-
- /**
- * Parses JSON input, creates quizzes, and summarizes the import result.
- */
- const handleImportQuiz = () => {
- if (!importPayload.trim()) {
- toast.error('Paste a JSON quiz payload to import.');
- setImportError('Paste a JSON quiz payload to import.');
- setImportNotice(null);
- return;
- }
-
- try {
- const parsed = JSON.parse(importPayload);
- const inputs = Array.isArray(parsed) ? parsed : [parsed];
- const drafts = inputs
- .map((entry) => {
- if (!entry || typeof entry !== 'object') return null;
- const titleValue = (entry as { title?: unknown }).title;
- const title = typeof titleValue === 'string' ? titleValue.trim() : '';
- const questionsValue = (entry as { questions?: unknown }).questions;
- if (!title || !Array.isArray(questionsValue)) return null;
-
- const questions = questionsValue
- .map((question) => {
- if (!question || typeof question !== 'object') return null;
- const promptValue = (question as { prompt?: unknown }).prompt;
- const answerValue = (question as { answer?: unknown }).answer;
- const promptText =
- typeof promptValue === 'string' ? promptValue.trim() : '';
- const answerText =
- typeof answerValue === 'string' ? answerValue.trim() : '';
- if (!promptText || !answerText) return null;
- return {
- id: crypto.randomUUID(),
- prompt: promptText,
- answer: answerText,
- };
- })
- .filter(Boolean) as Question[];
-
- if (!questions.length) return null;
- return {
- title,
- questions,
- };
- })
- .filter(Boolean) as Array<{ title: string; questions: Question[] }>;
-
- if (!drafts.length) {
- const message = 'No valid quiz data found in that JSON.';
- setImportError(message);
- setImportNotice(null);
- toast.error(message);
- return;
- }
-
- drafts.forEach((draft) =>
- actions.createQuiz(draft.title, draft.questions),
- );
- setImportNotice(
- `Imported ${drafts.length} quiz${drafts.length === 1 ? '' : 'zes'}.`,
- );
- toast.success(
- `Imported ${drafts.length} quiz${drafts.length === 1 ? '' : 'zes'}.`,
- );
- setImportError(null);
- setImportPayload('');
- } catch {
- const message = 'Invalid JSON. Please check the format and try again.';
- setImportError(message);
- setImportNotice(null);
- toast.error(message);
- }
- };
-
- return (
-
-
-
- Import Quiz JSON
-
- Paste a JSON object or array that matches the quiz format.
-
-
-
-
-
- Quiz JSON
-
-
-
-
-
- Import Quiz
-
-
-
- );
-}
diff --git a/components/students/__tests__/student-form.test.tsx b/components/students/__tests__/student-form.test.tsx
index 9fe01e0..6573180 100644
--- a/components/students/__tests__/student-form.test.tsx
+++ b/components/students/__tests__/student-form.test.tsx
@@ -145,9 +145,11 @@ describe("StudentForm", () => {
)
await waitFor(() => {
- expect(screen.getByLabelText(/import from/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/import students from/i)).toBeInTheDocument()
})
- expect(screen.getByText(/upload a .txt file/i)).toBeInTheDocument()
+ expect(
+ screen.getByLabelText(/import students from \.txt/i)
+ ).toBeInTheDocument()
})
})
diff --git a/components/students/student-form.tsx b/components/students/student-form.tsx
index b2ad29b..4bc3c4f 100644
--- a/components/students/student-form.tsx
+++ b/components/students/student-form.tsx
@@ -1,19 +1,32 @@
-'use client';
+'use client'
-import { normalizeStudentName, studentNameKey } from '@/lib/students';
+import { parseClassImportJson, parseClassImportText, parseClassNamesFromText } from '@/lib/classes'
+import { normalizeStudentName, studentNameKey } from '@/lib/students'
-import { useState } from 'react';
+import { useMemo, useState } from 'react'
-import { toast } from 'sonner';
+import { toast } from 'sonner'
-import { Button } from '@/components/ui/button';
+import ClassSelector from '@/components/classes/class-selector'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
-} from '@/components/ui/card';
+} from '@/components/ui/card'
import {
Field,
FieldContent,
@@ -21,199 +34,508 @@ import {
FieldError,
FieldLabel,
FieldSeparator,
-} from '@/components/ui/field';
-import { Input } from '@/components/ui/input';
-import { useAppStore } from '@/context/app-store';
+} from '@/components/ui/field'
+import { Input } from '@/components/ui/input'
+import { useAppStore } from '@/context/app-store'
/** Event type for form submit; avoids deprecated React.FormEvent. */
type FormSubmitEvent = Parameters<
NonNullable['onSubmit']>
->[0];
+>[0]
/** Event type for input change; avoids deprecated React.ChangeEvent. */
type InputChangeEvent = Parameters<
NonNullable['onChange']>
->[0];
+>[0]
/**
- * Add/import students form. Shows server-rendered skeleton until hydrated.
- * Skeleton is passed from the page (RSC) so it runs as a server component.
+ * Add/import classes and students form. Shows server-rendered skeleton until hydrated.
+ * Class imports support text and JSON, while student imports target the selected class.
*/
export default function StudentForm({
skeleton,
}: {
- skeleton: React.ReactNode;
+ skeleton: React.ReactNode
}) {
- const { state, actions } = useAppStore();
- const [name, setName] = useState('');
- const [error, setError] = useState(null);
- const [importError, setImportError] = useState(null);
- const [importNotice, setImportNotice] = useState(null);
+ const { state, actions } = useAppStore()
+
+ const [classNameInput, setClassNameInput] = useState('')
+ const [classError, setClassError] = useState(null)
+ const [classImportError, setClassImportError] = useState(null)
+ const [classImportNotice, setClassImportNotice] = useState(null)
+
+ const [studentNameInput, setStudentNameInput] = useState('')
+ const [studentError, setStudentError] = useState(null)
+ const [studentImportError, setStudentImportError] = useState(null)
+ const [studentImportNotice, setStudentImportNotice] = useState(null)
+
+ const activeClassId = state.persisted.activeClassId
+ const activeClass = state.persisted.classes.find((entry) => entry.id === activeClassId) ?? null
+
+ const studentsInActiveClass = useMemo(
+ () => state.persisted.students.filter((student) => student.classId === activeClassId),
+ [activeClassId, state.persisted.students]
+ )
/**
- * Parses a raw comma-separated list, adds unique students, and returns a count.
- * Uses persisted students to de-dupe against existing roster entries.
- *
- * @param raw - Comma-separated student names to add.
- * @returns The number of new students added to state.
+ * Adds normalized class names while preventing duplicate class titles.
+ * Accepts comma/newline-delimited text and returns the number of classes created.
*/
- const addNames = (raw: string) => {
+ const addClassNames = (raw: string) => {
+ const existing = new Set(
+ state.persisted.classes.map((entry) => entry.name.trim().toLocaleLowerCase())
+ )
+
+ const names = parseClassNamesFromText(raw)
+ const uniqueNames: string[] = []
+
+ for (const name of names) {
+ const key = name.toLocaleLowerCase()
+ if (existing.has(key)) continue
+ existing.add(key)
+ uniqueNames.push(name)
+ }
+
+ for (const name of uniqueNames) {
+ actions.addClass(name)
+ }
+
+ return uniqueNames.length
+ }
+
+ /**
+ * Adds normalized student names into the currently selected class.
+ * Returns how many new students were created.
+ */
+ const addStudentsToActiveClass = (raw: string) => {
+ if (!activeClassId) {
+ setStudentError('Create and select a class first.')
+ return 0
+ }
+
const existingKeys = new Set(
- state.persisted.students.map((student) => studentNameKey(student.name)),
- );
+ studentsInActiveClass.map((student) => studentNameKey(student.name))
+ )
const names = raw
.split(',')
.map((entry) => normalizeStudentName(entry))
- .filter(Boolean);
+ .filter(Boolean)
- const uniqueNames: string[] = [];
+ const uniqueNames: string[] = []
for (const entry of names) {
- const key = studentNameKey(entry);
- if (existingKeys.has(key)) continue;
- existingKeys.add(key);
- uniqueNames.push(entry);
+ const key = studentNameKey(entry)
+ if (existingKeys.has(key)) continue
+ existingKeys.add(key)
+ uniqueNames.push(entry)
}
- if (!uniqueNames.length) {
- return 0;
+ for (const entry of uniqueNames) {
+ actions.addStudent(entry)
}
- for (const entry of uniqueNames) {
- actions.addStudent(entry);
+ return uniqueNames.length
+ }
+
+ /**
+ * Handles manual class creation from input text.
+ */
+ const handleClassSubmit = (event: FormSubmitEvent) => {
+ event.preventDefault()
+ const added = addClassNames(classNameInput)
+ if (!added) {
+ setClassError('Enter at least one new class name.')
+ toast.error('Enter at least one new class name.')
+ return
}
- return uniqueNames.length;
- };
+ setClassNameInput('')
+ setClassError(null)
+ setClassImportError(null)
+ setClassImportNotice(`Added ${added} class${added === 1 ? '' : 'es'}.`)
+ toast.success(`Added ${added} class${added === 1 ? '' : 'es'}.`)
+ }
+
+ /**
+ * Deletes a class and all students assigned to it.
+ */
+ const handleDeleteClass = (classId: string, className: string) => {
+ actions.deleteClass(classId)
+ toast.success(`Deleted class ${className}.`)
+ }
+
+ /**
+ * Deletes every class and all class-scoped student/group/project data.
+ */
+ const handleDeleteAllClasses = () => {
+ actions.clearClasses()
+ toast.success('Deleted all classes.')
+ }
+
+ /**
+ * Builds a concise import summary including skipped records when present.
+ */
+ const buildClassImportSummary = (
+ classCount: number,
+ studentCount: number,
+ skippedClasses: number,
+ skippedStudents: number
+ ) => {
+ const summary = `Imported ${classCount} class${classCount === 1 ? '' : 'es'} and ${studentCount} student${studentCount === 1 ? '' : 's'}.`
+ const skippedBits = [
+ skippedClasses ? `${skippedClasses} invalid class line${skippedClasses === 1 ? '' : 's'} skipped` : null,
+ skippedStudents
+ ? `${skippedStudents} invalid or duplicate student${skippedStudents === 1 ? '' : 's'} skipped`
+ : null,
+ ].filter((entry): entry is string => entry !== null)
+
+ if (!skippedBits.length) {
+ return summary
+ }
+ return `${summary} ${skippedBits.join('; ')}.`
+ }
/**
- * Handles manual student entry submission and triggers a toast on success.
- *
- * @param event - Form submit event from the student name form.
+ * Imports class data from .txt or .json files.
*/
- const handleSubmit = (event: FormSubmitEvent) => {
- event.preventDefault();
- const addedCount = addNames(name);
+ const handleClassFileImport = async (event: InputChangeEvent) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ try {
+ const text = await file.text()
+ const isJsonFile =
+ file.name.toLocaleLowerCase().endsWith('.json') ||
+ file.type.toLocaleLowerCase().includes('json')
+
+ const parsed = isJsonFile ? parseClassImportJson(text) : parseClassImportText(text)
+ if (!parsed.ok) {
+ setClassImportError(parsed.error)
+ setClassImportNotice(null)
+ toast.error(parsed.error)
+ return
+ }
+
+ actions.importClassRecords(parsed.classes)
+ const studentCount = parsed.classes.reduce(
+ (count, classRecord) => count + classRecord.students.length,
+ 0
+ )
+ const summary = buildClassImportSummary(
+ parsed.classes.length,
+ studentCount,
+ parsed.skippedClasses,
+ parsed.skippedStudents
+ )
+ setClassImportError(null)
+ setClassImportNotice(
+ `${summary} Matching class names overwrite that class roster with imported students.`
+ )
+ toast.success(summary)
+ } catch (error) {
+ console.error('Failed to import classes', error)
+ const message = 'Could not read the class file. Please try again.'
+ setClassImportError(message)
+ setClassImportNotice(null)
+ toast.error(message)
+ } finally {
+ event.target.value = ''
+ }
+ }
+
+ /**
+ * Handles manual student entry submission for the selected class.
+ */
+ const handleStudentSubmit = (event: FormSubmitEvent) => {
+ event.preventDefault()
+ const addedCount = addStudentsToActiveClass(studentNameInput)
if (!addedCount) {
- toast.error('Enter at least one new student name.');
- setError('Enter at least one new student name.');
- return;
+ toast.error('Enter at least one new student name.')
+ setStudentError('Enter at least one new student name.')
+ return
}
- setName('');
- setError(null);
- setImportNotice(null);
- toast.success(`Added ${addedCount} student${addedCount === 1 ? '' : 's'}.`);
- };
+
+ setStudentNameInput('')
+ setStudentError(null)
+ setStudentImportNotice(null)
+ toast.success(`Added ${addedCount} student${addedCount === 1 ? '' : 's'}.`)
+ }
/**
- * Reads a .txt upload, imports students, and confirms the result with a toast.
- *
- * @param event - File input change event containing the uploaded file.
- * @returns A promise that resolves after the file is processed.
+ * Imports students from text into the selected class.
*/
- const handleFileImport = async (event: InputChangeEvent) => {
- const file = event.target.files?.[0];
- if (!file) return;
+ const handleStudentFileImport = async (event: InputChangeEvent) => {
+ const file = event.target.files?.[0]
+ if (!file) return
try {
- const text = await file.text();
- const addedCount = addNames(text);
+ const text = await file.text()
+ const addedCount = addStudentsToActiveClass(text)
if (!addedCount) {
- toast.error('No new students were found in that file.');
- setImportError('No new students were found in that file.');
- setImportNotice(null);
+ const message = 'No new students were found in that file.'
+ setStudentImportError(message)
+ setStudentImportNotice(null)
+ toast.error(message)
} else {
- setImportError(null);
- setImportNotice(
- `Imported ${addedCount} student${addedCount === 1 ? '' : 's'}.`,
- );
- toast.success(
- `Imported ${addedCount} student${addedCount === 1 ? '' : 's'}.`,
- );
+ setStudentImportError(null)
+ setStudentImportNotice(
+ `Imported ${addedCount} student${addedCount === 1 ? '' : 's'} into ${activeClass?.name ?? 'the selected class'}.`
+ )
+ toast.success(`Imported ${addedCount} student${addedCount === 1 ? '' : 's'}.`)
}
} catch (error) {
- console.error('Failed to import students', error);
- const message = 'Could not read the file. Please try again.';
- setImportError(message);
- setImportNotice(null);
- toast.error(message);
+ console.error('Failed to import students', error)
+ const message = 'Could not read the student file. Please try again.'
+ setStudentImportError(message)
+ setStudentImportNotice(null)
+ toast.error(message)
} finally {
- event.target.value = '';
+ event.target.value = ''
}
- };
+ }
if (!state.ui.isHydrated) {
- return <>{skeleton}>;
+ return <>{skeleton}>
}
return (
-
+
-
- Add Students
-
- Add students manually or paste a comma-separated list. You can also
- import a comma-separated .txt file below.
+
+ Classes and Students
+
+ Create classes first, then add or import students into the selected class.
-
-