Compare commits

...
7 Commits
Author SHA1 Message Date
LaurensBarteldsandCopilot 1a37209f72 feat: add resend verification email functionality and related schemas
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 27s
Release Pipeline / build-and-release (push) Successful in 5m55s
Co-authored-by: Copilot <copilot@github.com>
2026-05-02 21:03:56 +02:00
LaurensBartelds 25e590639a fix: update email verification endpoint and improve error handling in apiFetch 2026-05-02 20:58:31 +02:00
LaurensBartelds 7849c295ef feat: update password validation to accept hash as special character in registration and setup schemas
Test / backend (push) Successful in 14s
Test / frontend (push) Successful in 27s
2026-05-02 20:50:28 +02:00
LaurensBartelds f6d97baeb5 fix: rename graded prop to avoid naming conflict in GrammarCorrection component
Test / backend (push) Successful in 14s
Test / frontend (push) Successful in 28s
Release Pipeline / build-and-release (push) Successful in 5m55s
2026-05-02 20:01:27 +02:00
LaurensBartelds b58500901a feat: add markdown rendering support with marked and integrate into Tex component
Test / backend (push) Successful in 13s
Test / frontend (push) Failing after 21s
2026-05-02 19:57:42 +02:00
LaurensBartelds e906951b06 feat: add new question types and components for fill-in-the-blanks, true/false, code submission, dropdown completion, numeric range, dictation, grammar correction, and flashcards
Test / backend (push) Successful in 13s
Test / frontend (push) Failing after 19s
- Updated QuestionRenderer to support new question types.
- Added FillInTheBlanks, TrueFalse, CodeSubmission, DropdownCompletion, NumericRange, Dictation, GrammarCorrection, and Flashcard components.
- Enhanced grading logic to handle new question types.
- Updated translations for new question types in English and Dutch.
- Added CSS styles for new components and question types.
- Extended Question and related types to accommodate new configurations and answers.
2026-05-02 19:25:28 +02:00
LaurensBartelds a78fb8efc3 feat: add download instructions functionality and UI integration in Workspace component
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 26s
2026-05-02 18:25:03 +02:00
41 changed files with 2167 additions and 349 deletions

No files matched your search

+8
View File
@@ -20,6 +20,14 @@ const generateSchema = z.object({
'essay', 'essay',
'match', 'match',
'order', 'order',
'fill-in-the-blanks',
'true-false',
'code-submission',
'dropdown-completion',
'numeric-range',
'dictation',
'grammar-correction',
'flashcard',
]), ]),
count: z.number().int().min(1).max(10), count: z.number().int().min(1).max(10),
language: z.string().min(1).max(20), language: z.string().min(1).max(20),
+23 -2
View File
@@ -1,6 +1,7 @@
import { Router } from 'express' import { Router } from 'express'
import rateLimit from 'express-rate-limit' import rateLimit from 'express-rate-limit'
import { validateBody } from '../middleware/validate.js' import { validateBody } from '../middleware/validate.js'
import { validateQuery } from '../middleware/validate.js'
import { requireAuth } from '../middleware/auth.js' import { requireAuth } from '../middleware/auth.js'
import { asyncHandler } from '../utils/asyncHandler.js' import { asyncHandler } from '../utils/asyncHandler.js'
import * as authService from '../services/authService.js' import * as authService from '../services/authService.js'
@@ -13,6 +14,7 @@ import {
updateProfileSchema, updateProfileSchema,
changePasswordSchema, changePasswordSchema,
verifyEmailSchema, verifyEmailSchema,
resendVerificationSchema,
resetPasswordSchema, resetPasswordSchema,
forgotPasswordSchema, forgotPasswordSchema,
} from '../validators/authSchemas.js' } from '../validators/authSchemas.js'
@@ -28,6 +30,14 @@ const authLimiter = rateLimit({
skipSuccessfulRequests: false, skipSuccessfulRequests: false,
}) })
const resendVerificationLimiter = rateLimit({
windowMs: 60 * 1000,
max: 1,
message: { error: 'Please wait a minute before requesting another verification email' },
standardHeaders: true,
skipSuccessfulRequests: false,
})
// POST /api/auth/register // POST /api/auth/register
router.post( router.post(
'/register', '/register',
@@ -86,13 +96,24 @@ router.post(
// GET /api/auth/verify // GET /api/auth/verify
router.get( router.get(
'/verify', '/verify',
validateBody(verifyEmailSchema), validateQuery(verifyEmailSchema),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
await authService.verifyEmail(req.body) await authService.verifyEmail(req.query as { token: string })
res.json({ message: 'Email verified successfully' }) res.json({ message: 'Email verified successfully' })
}) })
) )
// POST /api/auth/resend-verification
router.post(
'/resend-verification',
resendVerificationLimiter,
validateBody(resendVerificationSchema),
asyncHandler(async (req, res) => {
await authService.resendVerification(req.body.email)
res.json({ message: 'If the account exists, a new verification link has been sent' })
})
)
// POST /api/auth/forgot-password // POST /api/auth/forgot-password
router.post( router.post(
'/forgot-password', '/forgot-password',
+61
View File
@@ -10,6 +10,14 @@ export type QuestionType =
| 'essay' | 'essay'
| 'match' | 'match'
| 'order' | 'order'
| 'fill-in-the-blanks'
| 'true-false'
| 'code-submission'
| 'dropdown-completion'
| 'numeric-range'
| 'dictation'
| 'grammar-correction'
| 'flashcard'
const choiceSchema = z.object({ const choiceSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
@@ -31,6 +39,14 @@ const generatedQuestionSchema = z.object({
'essay', 'essay',
'match', 'match',
'order', 'order',
'fill-in-the-blanks',
'true-false',
'code-submission',
'dropdown-completion',
'numeric-range',
'dictation',
'grammar-correction',
'flashcard',
]), ]),
prompt: z.string().min(1), prompt: z.string().min(1),
category: z.string().optional(), category: z.string().optional(),
@@ -47,6 +63,35 @@ const generatedQuestionSchema = z.object({
left: z.array(matchItemSchema).optional(), left: z.array(matchItemSchema).optional(),
right: z.array(matchItemSchema).optional(), right: z.array(matchItemSchema).optional(),
items: z.array(matchItemSchema).optional(), items: z.array(matchItemSchema).optional(),
textSegments: z.array(z.string()).optional(),
blanks: z.array(
z.object({
id: z.string().min(1),
acceptedAnswers: z.array(z.string()),
acceptedForms: z.array(z.string()).optional(),
caseSensitive: z.boolean().optional(),
ignorePunctuation: z.boolean().optional(),
ignoreAccents: z.boolean().optional(),
}),
).optional(),
dropdowns: z.array(
z.object({
id: z.string().min(1),
options: z.array(choiceSchema),
correctOptionId: z.string(),
}),
).optional(),
codeConfig: z.object({
language: z.string(),
starterCode: z.string().optional(),
}).optional(),
numericConfig: z.object({
answer: z.number(),
tolerance: z.number(),
min: z.number().optional(),
max: z.number().optional(),
}).optional(),
textToCorrect: z.string().optional(),
}) })
export type GeneratedQuestion = z.infer<typeof generatedQuestionSchema> export type GeneratedQuestion = z.infer<typeof generatedQuestionSchema>
@@ -77,6 +122,22 @@ function shapeHintForType(type: QuestionType): string {
return `Required fields: prompt (string), left (array of {id, text} 3-5 items), right (array of {id, text} 3-5 items), answer (object mapping left.id -> right.id).` return `Required fields: prompt (string), left (array of {id, text} 3-5 items), right (array of {id, text} 3-5 items), answer (object mapping left.id -> right.id).`
case 'order': case 'order':
return `Required fields: prompt (string), items (array of {id, text} 3-6 items in any order), answer (array of item ids in the correct order).` return `Required fields: prompt (string), items (array of {id, text} 3-6 items in any order), answer (array of item ids in the correct order).`
case 'fill-in-the-blanks':
return `Required fields: prompt (string), textSegments (array of text strings to interleave with blanks), blanks (array of {id, acceptedAnswers} where acceptedAnswers is an array of correct strings for that blank). The number of textSegments must be blanks.length + 1.`
case 'true-false':
return `Required fields: prompt (string), answer (boolean, true or false).`
case 'code-submission':
return `Required fields: prompt (string), codeConfig ({language: string, starterCode?: string}), rubric (array of 3-5 short rubric criteria strings).`
case 'dropdown-completion':
return `Required fields: prompt (string), textSegments (array of text strings to interleave with dropdowns), dropdowns (array of {id, options: [{id, text}], correctOptionId}). The number of textSegments must be dropdowns.length + 1.`
case 'numeric-range':
return `Required fields: prompt (string), numericConfig ({answer: number, tolerance: number, min?: number, max?: number}), answer (same number as numericConfig.answer).`
case 'dictation':
return `Required fields: prompt (string), audio ({src: string, a URL to an audio file}), answer (the correct text), acceptedForms (array of acceptable alternate strings), caseSensitive (boolean, usually false).`
case 'grammar-correction':
return `Required fields: prompt (string), textToCorrect (the sentence with errors to fix), answer (the corrected sentence), acceptedForms (array of acceptable corrected forms), caseSensitive (boolean, usually false).`
case 'flashcard':
return `Required fields: prompt (the front of the card, e.g. a word or phrase), answer (the back of the card, e.g. translation or definition), explanation (optional, extra info to show after reveal).`
} }
} }
+18
View File
@@ -145,6 +145,24 @@ export async function verifyEmail(input: VerifyEmailInput) {
}) })
} }
export async function resendVerification(email: string) {
const user = userRepo.getUserByEmail(email)
if (!user || user.email_verified) {
return
}
const rawVerifyToken = generateVerificationToken()
const verifyTokenHash = hashToken(rawVerifyToken)
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
userRepo.updateUser(user.id, {
verifyToken: verifyTokenHash,
verifyExpires,
})
sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
}
export async function forgotPassword(email?: string) { export async function forgotPassword(email?: string) {
if (!email) { if (!email) {
throw new AppError(400, 'Email is required') throw new AppError(400, 'Email is required')
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { registerSchema, loginSchema, verifyEmailSchema, resetPasswordSchema, updateProfileSchema, changePasswordSchema, forgotPasswordSchema } from './authSchemas' import { registerSchema, loginSchema, verifyEmailSchema, resendVerificationSchema, resetPasswordSchema, updateProfileSchema, changePasswordSchema, forgotPasswordSchema } from './authSchemas'
describe('registerSchema', () => { describe('registerSchema', () => {
it('accepts valid registration', () => { it('accepts valid registration', () => {
@@ -11,6 +11,15 @@ describe('registerSchema', () => {
expect(result.success).toBe(true) expect(result.success).toBe(true)
}) })
it('accepts passwords with hash as special character', () => {
const result = registerSchema.safeParse({
email: 'test@example.com',
password: 'Password123#',
displayName: 'Test User',
})
expect(result.success).toBe(true)
})
it('rejects invalid email', () => { it('rejects invalid email', () => {
const result = registerSchema.safeParse({ const result = registerSchema.safeParse({
email: 'not-an-email', email: 'not-an-email',
@@ -63,6 +72,13 @@ describe('verifyEmailSchema', () => {
}) })
}) })
describe('resendVerificationSchema', () => {
it('accepts valid email', () => {
const result = resendVerificationSchema.safeParse({ email: 'test@example.com' })
expect(result.success).toBe(true)
})
})
describe('resetPasswordSchema', () => { describe('resetPasswordSchema', () => {
it('accepts valid reset', () => { it('accepts valid reset', () => {
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'NewPass123!' }) const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'NewPass123!' })
+9 -4
View File
@@ -1,12 +1,12 @@
import { z } from 'zod' import { z } from 'zod'
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/ const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,}$/
export const registerSchema = z.object({ export const registerSchema = z.object({
email: z.string().email().max(255), email: z.string().email().max(255),
password: z.string() password: z.string()
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'), .regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
displayName: z.string().min(1, 'Display name is required').max(100), displayName: z.string().min(1, 'Display name is required').max(100),
}) })
@@ -19,11 +19,15 @@ export const verifyEmailSchema = z.object({
token: z.string().min(1, 'Token is required'), token: z.string().min(1, 'Token is required'),
}) })
export const resendVerificationSchema = z.object({
email: z.string().email(),
})
export const resetPasswordSchema = z.object({ export const resetPasswordSchema = z.object({
token: z.string().min(1, 'Token is required'), token: z.string().min(1, 'Token is required'),
newPassword: z.string() newPassword: z.string()
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'), .regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
}) })
export const updateProfileSchema = z.object({ export const updateProfileSchema = z.object({
@@ -34,7 +38,7 @@ export const changePasswordSchema = z.object({
currentPassword: z.string(), currentPassword: z.string(),
newPassword: z.string() newPassword: z.string()
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'), .regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
}) })
export const forgotPasswordSchema = z.object({ export const forgotPasswordSchema = z.object({
@@ -44,6 +48,7 @@ export const forgotPasswordSchema = z.object({
export type RegisterInput = z.infer<typeof registerSchema> export type RegisterInput = z.infer<typeof registerSchema>
export type LoginInput = z.infer<typeof loginSchema> export type LoginInput = z.infer<typeof loginSchema>
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema> export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>
export type ResendVerificationInput = z.infer<typeof resendVerificationSchema>
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema> export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>
export type UpdateProfileInput = z.infer<typeof updateProfileSchema> export type UpdateProfileInput = z.infer<typeof updateProfileSchema>
export type ChangePasswordInput = z.infer<typeof changePasswordSchema> export type ChangePasswordInput = z.infer<typeof changePasswordSchema>
+6 -3
View File
@@ -62,6 +62,9 @@ describe('examSchema', () => {
const types = [ const types = [
'multiple-choice', 'multiple-select', 'short-answer', 'translation', 'multiple-choice', 'multiple-select', 'short-answer', 'translation',
'math-input', 'essay', 'match', 'order', 'math-input', 'essay', 'match', 'order',
'fill-in-the-blanks', 'true-false', 'code-submission',
'dropdown-completion', 'numeric-range',
'dictation', 'grammar-correction', 'flashcard',
] ]
for (const type of types) { for (const type of types) {
const result = examSchema.safeParse({ const result = examSchema.safeParse({
@@ -75,7 +78,7 @@ describe('examSchema', () => {
it('rejects unsupported question type', () => { it('rejects unsupported question type', () => {
const result = examSchema.safeParse({ const result = examSchema.safeParse({
title: 'X', title: 'X',
questions: [{ id: '1', type: 'true-false', prompt: 'Q' }], questions: [{ id: '1', type: 'file-upload', prompt: 'Q' }],
}) })
expect(result.success).toBe(false) expect(result.success).toBe(false)
}) })
@@ -91,10 +94,10 @@ describe('examSchema', () => {
} }
}) })
it('rejects non-string/array/record answer', () => { it('rejects non-string/array/record/number/boolean answer', () => {
const result = examSchema.safeParse({ const result = examSchema.safeParse({
title: 'X', title: 'X',
questions: [{ id: '1', type: 'short-answer', prompt: 'Q', answer: 42 }], questions: [{ id: '1', type: 'short-answer', prompt: 'Q', answer: null }],
}) })
expect(result.success).toBe(false) expect(result.success).toBe(false)
}) })
+42 -1
View File
@@ -30,6 +30,33 @@ const questionTableSchema = z.object({
rows: z.array(z.array(z.string())), rows: z.array(z.array(z.string())),
}) })
const blankSchema = z.object({
id: z.string(),
acceptedAnswers: z.array(z.string()),
acceptedForms: z.array(z.string()).optional(),
caseSensitive: z.boolean().optional(),
ignorePunctuation: z.boolean().optional(),
ignoreAccents: z.boolean().optional(),
})
const dropdownSchema = z.object({
id: z.string(),
options: z.array(choiceSchema),
correctOptionId: z.string(),
})
const codeConfigSchema = z.object({
language: z.string(),
starterCode: z.string().optional(),
})
const numericConfigSchema = z.object({
answer: z.number(),
tolerance: z.number(),
min: z.number().optional(),
max: z.number().optional(),
})
const questionSchema = z.object({ const questionSchema = z.object({
id: z.string(), id: z.string(),
type: z.enum([ type: z.enum([
@@ -41,6 +68,14 @@ const questionSchema = z.object({
'essay', 'essay',
'match', 'match',
'order', 'order',
'fill-in-the-blanks',
'true-false',
'code-submission',
'dropdown-completion',
'numeric-range',
'dictation',
'grammar-correction',
'flashcard',
]), ]),
category: z.string().optional(), category: z.string().optional(),
points: z.number().optional(), points: z.number().optional(),
@@ -51,7 +86,7 @@ const questionSchema = z.object({
code: questionCodeSchema.optional(), code: questionCodeSchema.optional(),
table: questionTableSchema.optional(), table: questionTableSchema.optional(),
svg: z.string().optional(), svg: z.string().optional(),
answer: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.string())]).optional(), answer: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.string()), z.boolean(), z.number()]).optional(),
acceptedForms: z.array(z.string()).optional(), acceptedForms: z.array(z.string()).optional(),
caseSensitive: z.boolean().optional(), caseSensitive: z.boolean().optional(),
ignorePunctuation: z.boolean().optional(), ignorePunctuation: z.boolean().optional(),
@@ -64,6 +99,12 @@ const questionSchema = z.object({
left: z.array(matchItemSchema).optional(), left: z.array(matchItemSchema).optional(),
right: z.array(matchItemSchema).optional(), right: z.array(matchItemSchema).optional(),
items: z.array(matchItemSchema).optional(), items: z.array(matchItemSchema).optional(),
textSegments: z.array(z.string()).optional(),
blanks: z.array(blankSchema).optional(),
dropdowns: z.array(dropdownSchema).optional(),
codeConfig: codeConfigSchema.optional(),
numericConfig: numericConfigSchema.optional(),
textToCorrect: z.string().optional(),
}) })
export const examSchema = z.object({ export const examSchema = z.object({
@@ -13,6 +13,11 @@ describe('setupSchema', () => {
expect(result.success).toBe(true) expect(result.success).toBe(true)
}) })
it('accepts passwords with hash as special character', () => {
const result = setupSchema.safeParse({ ...baseValid, password: 'AdminPass123#' })
expect(result.success).toBe(true)
})
it('accepts full setup including SMTP fields', () => { it('accepts full setup including SMTP fields', () => {
const result = setupSchema.safeParse({ const result = setupSchema.safeParse({
...baseValid, ...baseValid,
+2 -2
View File
@@ -1,12 +1,12 @@
import { z } from 'zod' import { z } from 'zod'
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/ const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,}$/
export const setupSchema = z.object({ export const setupSchema = z.object({
email: z.string().email().max(255), email: z.string().email().max(255),
password: z.string() password: z.string()
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'), .regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
displayName: z.string().min(1, 'Display name is required').max(100), displayName: z.string().min(1, 'Display name is required').max(100),
siteTitle: z.string().min(1, 'Site title is required').max(255).optional(), siteTitle: z.string().min(1, 'Site title is required').max(255).optional(),
language: z.enum(['en', 'nl']).optional(), language: z.enum(['en', 'nl']).optional(),
+13
View File
@@ -12,6 +12,7 @@
"i18next": "^23.16.8", "i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",
"katex": "^0.16.45", "katex": "^0.16.45",
"marked": "^18.0.3",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
@@ -2898,6 +2899,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/marked": {
"version": "18.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
"integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+1
View File
@@ -16,6 +16,7 @@
"i18next": "^23.16.8", "i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",
"katex": "^0.16.45", "katex": "^0.16.45",
"marked": "^18.0.3",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
+4 -1
View File
@@ -1 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="BrainWave">
<rect x="2" y="2" width="20" height="20" rx="5" fill="none" stroke="#1f1f24" stroke-width="2" />
<rect x="6" y="6" width="10" height="12" rx="1.5" fill="#1f1f24" />
</svg>

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 271 B

+5 -1
View File
@@ -38,7 +38,11 @@ export function changePassword(data: { currentPassword: string; newPassword: str
} }
export function verifyEmailToken(token: string): Promise<{ message: string }> { export function verifyEmailToken(token: string): Promise<{ message: string }> {
return apiFetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`) return apiFetch(`/api/auth/verify?token=${encodeURIComponent(token)}`)
}
export function resendVerification(email: string): Promise<{ message: string }> {
return apiFetch('/api/auth/resend-verification', { method: 'POST', body: JSON.stringify({ email }) })
} }
export function forgotPassword(email?: string): Promise<{ message: string }> { export function forgotPassword(email?: string): Promise<{ message: string }> {
+71 -1
View File
@@ -1,7 +1,8 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { ApiError } from '../utils/api' import { ApiError } from '../utils/api'
import * as authApi from '../api/authApi'
interface Props { interface Props {
onClose: () => void onClose: () => void
@@ -10,6 +11,9 @@ interface Props {
type Tab = 'login' | 'register' type Tab = 'login' | 'register'
const VERIFICATION_RESEND_DELAY_MS = 60_000
const VERIFICATION_REQUIRED_MESSAGE = 'Please verify your email before logging in'
export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>) { export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>) {
const { t } = useTranslation() const { t } = useTranslation()
const { login, register } = useAuth() const { login, register } = useAuth()
@@ -20,22 +24,50 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
const [displayName, setDisplayName] = useState('') const [displayName, setDisplayName] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [resendingVerification, setResendingVerification] = useState(false)
const [verificationEmail, setVerificationEmail] = useState('')
const [verificationResendAvailableAt, setVerificationResendAvailableAt] = useState<number | null>(null)
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!verificationResendAvailableAt) return undefined
const timer = window.setInterval(() => {
setNow(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [verificationResendAvailableAt])
const verificationResendSeconds = verificationResendAvailableAt
? Math.max(0, Math.ceil((verificationResendAvailableAt - now) / 1000))
: 0
const canResendVerification = verificationEmail.length > 0 && verificationResendSeconds === 0 && !resendingVerification
const switchTab = (t: Tab) => { const switchTab = (t: Tab) => {
setTab(t) setTab(t)
setError(null) setError(null)
setSuccess(null) setSuccess(null)
setNotice(null)
setVerificationEmail('')
setVerificationResendAvailableAt(null)
} }
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError(null) setError(null)
setNotice(null)
setSubmitting(true) setSubmitting(true)
try { try {
await login(email, password) await login(email, password)
onClose() onClose()
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 403 && err.message === VERIFICATION_REQUIRED_MESSAGE) {
setVerificationEmail(email)
setVerificationResendAvailableAt(Date.now() + VERIFICATION_RESEND_DELAY_MS)
}
setError(err instanceof ApiError ? err.message : t('auth.loginFailed')) setError(err instanceof ApiError ? err.message : t('auth.loginFailed'))
} finally { } finally {
setSubmitting(false) setSubmitting(false)
@@ -45,6 +77,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
const handleRegister = async (e: React.FormEvent) => { const handleRegister = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError(null) setError(null)
setNotice(null)
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError(t('auth.passwordsDoNotMatch')) setError(t('auth.passwordsDoNotMatch'))
return return
@@ -60,6 +93,23 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
} }
} }
const handleResendVerification = async () => {
if (!canResendVerification) return
setResendingVerification(true)
setNotice(null)
setError(null)
try {
const response = await authApi.resendVerification(verificationEmail)
setNotice(response.message)
setVerificationResendAvailableAt(Date.now() + VERIFICATION_RESEND_DELAY_MS)
} catch (err) {
setError(err instanceof ApiError ? err.message : t('auth.requestFailed'))
} finally {
setResendingVerification(false)
}
}
return ( return (
<div className="auth-overlay" onClick={onClose}> <div className="auth-overlay" onClick={onClose}>
<div className="auth-modal" onClick={(e) => e.stopPropagation()}> <div className="auth-modal" onClick={(e) => e.stopPropagation()}>
@@ -94,6 +144,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
{error && <div className="auth-error">{error}</div>} {error && <div className="auth-error">{error}</div>}
{success && <div className="auth-success">{success}</div>} {success && <div className="auth-success">{success}</div>}
{notice && <div className="auth-success">{notice}</div>}
{!success && tab === 'login' && ( {!success && tab === 'login' && (
<form onSubmit={(e) => void handleLogin(e)} className="auth-form"> <form onSubmit={(e) => void handleLogin(e)} className="auth-form">
@@ -133,6 +184,25 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
> >
{t('auth.forgotPassword')} {t('auth.forgotPassword')}
</button> </button>
{verificationEmail && error === VERIFICATION_REQUIRED_MESSAGE && (
<div className="auth-field" style={{ marginTop: 8 }}>
<p className="auth-subtitle" style={{ marginBottom: 8 }}>
{t('auth.verificationResendHint')}
</p>
<button
type="button"
className="btn"
onClick={() => void handleResendVerification()}
disabled={!canResendVerification}
>
{resendingVerification
? t('auth.sending')
: verificationResendSeconds > 0
? t('auth.resendVerificationLinkIn', { seconds: verificationResendSeconds })
: t('auth.resendVerificationLink')}
</button>
</div>
)}
</form> </form>
)} )}
+408
View File
@@ -14,6 +14,14 @@ const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [
{ value: 'essay', labelKey: 'editor.essay' }, { value: 'essay', labelKey: 'editor.essay' },
{ value: 'match', labelKey: 'editor.match' }, { value: 'match', labelKey: 'editor.match' },
{ value: 'order', labelKey: 'editor.order' }, { value: 'order', labelKey: 'editor.order' },
{ value: 'fill-in-the-blanks', labelKey: 'editor.fillInTheBlanks' },
{ value: 'true-false', labelKey: 'editor.trueFalse' },
{ value: 'code-submission', labelKey: 'editor.codeSubmission' },
{ value: 'dropdown-completion', labelKey: 'editor.dropdownCompletion' },
{ value: 'numeric-range', labelKey: 'editor.numericRange' },
{ value: 'dictation', labelKey: 'editor.dictation' },
{ value: 'grammar-correction', labelKey: 'editor.grammarCorrection' },
{ value: 'flashcard', labelKey: 'editor.flashcard' },
] ]
const BLANK_EXAM: Exam = { const BLANK_EXAM: Exam = {
@@ -40,6 +48,22 @@ function makeBlankQuestion(type: QuestionType, index: number): Question {
return { ...base, left: [], right: [], answer: {} } return { ...base, left: [], right: [], answer: {} }
case 'order': case 'order':
return { ...base, items: [], answer: [] } return { ...base, items: [], answer: [] }
case 'true-false':
return { ...base, answer: true }
case 'numeric-range':
return { ...base, numericConfig: { answer: 0, tolerance: 0 } }
case 'fill-in-the-blanks':
return { ...base, textSegments: ['The capital of France is ', '.'], blanks: [{ id: 'b1', acceptedAnswers: ['Paris'] }] }
case 'dropdown-completion':
return { ...base, textSegments: ['The sky is ', ' during the day.'], dropdowns: [{ id: 'd1', options: [{ id: 'a', text: 'blue' }, { id: 'b', text: 'green' }], correctOptionId: 'a' }] }
case 'code-submission':
return { ...base, codeConfig: { language: 'python', starterCode: '' }, rubric: [] }
case 'dictation':
return { ...base, audio: { src: '' }, answer: '', acceptedForms: [], caseSensitive: false }
case 'grammar-correction':
return { ...base, textToCorrect: '', answer: '', acceptedForms: [], caseSensitive: false }
case 'flashcard':
return { ...base, answer: '' }
default: default:
return base return base
} }
@@ -529,6 +553,14 @@ function QuestionEditor({ question, onChange }: QEProps) {
{question.type === 'essay' && <EssayEditor q={question} onChange={onChange} />} {question.type === 'essay' && <EssayEditor q={question} onChange={onChange} />}
{question.type === 'match' && <MatchEditor q={question} onChange={onChange} />} {question.type === 'match' && <MatchEditor q={question} onChange={onChange} />}
{question.type === 'order' && <OrderEditor q={question} onChange={onChange} />} {question.type === 'order' && <OrderEditor q={question} onChange={onChange} />}
{question.type === 'true-false' && <TrueFalseEditor q={question} onChange={onChange} />}
{question.type === 'numeric-range' && <NumericRangeEditor q={question} onChange={onChange} />}
{question.type === 'fill-in-the-blanks' && <FillInTheBlanksEditor q={question} onChange={onChange} />}
{question.type === 'dropdown-completion' && <DropdownCompletionEditor q={question} onChange={onChange} />}
{question.type === 'code-submission' && <CodeSubmissionEditor q={question} onChange={onChange} />}
{question.type === 'dictation' && <DictationEditor q={question} onChange={onChange} />}
{question.type === 'grammar-correction' && <GrammarCorrectionEditor q={question} onChange={onChange} />}
{question.type === 'flashcard' && <FlashcardEditor q={question} onChange={onChange} />}
</div> </div>
) )
} }
@@ -895,6 +927,323 @@ function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
) )
} }
/* ─── True/False ─── */
function TrueFalseEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
return (
<div className="question-form-section">
<label className="question-form-label">{t('editor.correctAnswer')}</label>
<div className="choice-editor-row">
<button
className={`btn btn-sm${q.answer === true ? ' btn-primary' : ''}`}
onClick={() => onChange((q) => { q.answer = true })}
type="button"
>
True
</button>
<button
className={`btn btn-sm${q.answer === false ? ' btn-primary' : ''}`}
onClick={() => onChange((q) => { q.answer = false })}
type="button"
>
False
</button>
</div>
</div>
)
}
/* ─── Numeric Range ─── */
function NumericRangeEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
const cfg = q.numericConfig ?? { answer: 0, tolerance: 0 }
return (
<div className="question-form-section">
<div className="question-form-row-inline">
<div>
<label className="question-form-label">{t('editor.correctAnswer')}</label>
<input
className="input"
type="number"
step="any"
value={cfg.answer}
onChange={(e) => onChange((q) => { q.numericConfig = { ...cfg, answer: Number(e.target.value) } })}
/>
</div>
<div style={{ width: 120 }}>
<label className="question-form-label">Tolerance</label>
<input
className="input"
type="number"
step="any"
min={0}
value={cfg.tolerance}
onChange={(e) => onChange((q) => { q.numericConfig = { ...cfg, tolerance: Math.max(0, Number(e.target.value)) } })}
/>
</div>
<div style={{ width: 120 }}>
<label className="question-form-label">Min (opt)</label>
<input
className="input"
type="number"
step="any"
value={cfg.min ?? ''}
onChange={(e) => onChange((q) => { q.numericConfig = { ...cfg, min: e.target.value ? Number(e.target.value) : undefined } })}
/>
</div>
<div style={{ width: 120 }}>
<label className="question-form-label">Max (opt)</label>
<input
className="input"
type="number"
step="any"
value={cfg.max ?? ''}
onChange={(e) => onChange((q) => { q.numericConfig = { ...cfg, max: e.target.value ? Number(e.target.value) : undefined } })}
/>
</div>
</div>
</div>
)
}
/* ─── Fill in the Blanks ─── */
function FillInTheBlanksEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const segments = q.textSegments ?? []
const blanks = q.blanks ?? []
const updateSegment = (i: number, text: string) => {
const next = [...segments]
next[i] = text
onChange((q) => { q.textSegments = next })
}
const addBlank = () => {
const id = `b${blanks.length + 1}`
onChange((q) => {
q.textSegments = [...(q.textSegments ?? []), '']
q.blanks = [...(q.blanks ?? []), { id, acceptedAnswers: [''] }]
})
}
const removeBlank = (idx: number) => {
onChange((q) => {
q.textSegments = segments.filter((_, i) => i !== idx)
q.blanks = blanks.filter((_, i) => i !== idx)
})
}
return (
<div className="question-form-section">
<label className="question-form-label">Text segments & blanks</label>
<p className="faint" style={{ fontSize: 12 }}>Text before blank 1, then blank 1 answer, then text before blank 2, etc.</p>
{segments.map((seg, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<div className="choice-editor-row" style={{ marginBottom: 6 }}>
<span className="mono faint" style={{ fontSize: 12, width: 60 }}>Text {i + 1}</span>
<input
className="input"
value={seg}
onChange={(e) => updateSegment(i, e.target.value)}
placeholder={`Text before blank ${i + 1}`}
/>
</div>
{i < blanks.length && (
<div className="choice-editor-row" style={{ marginBottom: 6, paddingLeft: 20 }}>
<span className="mono faint" style={{ fontSize: 12, width: 60 }}>Blank {i + 1}</span>
<input
className="input"
value={blanks[i].acceptedAnswers[0] ?? ''}
onChange={(e) => {
const next = [...blanks]
next[i] = { ...next[i], acceptedAnswers: [e.target.value] }
onChange((q) => { q.blanks = next })
}}
placeholder="Accepted answer"
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeBlank(i)}>✕</button>
</div>
)}
</div>
))}
<button className="btn btn-sm" onClick={addBlank} style={{ marginTop: 4 }}>
+ Add Blank
</button>
</div>
)
}
/* ─── Dropdown Completion ─── */
function DropdownCompletionEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const segments = q.textSegments ?? []
const dropdowns = q.dropdowns ?? []
const updateSegment = (i: number, text: string) => {
const next = [...segments]
next[i] = text
onChange((q) => { q.textSegments = next })
}
const addDropdown = () => {
const id = `d${dropdowns.length + 1}`
onChange((q) => {
q.textSegments = [...(q.textSegments ?? []), '']
q.dropdowns = [...(q.dropdowns ?? []), { id, options: [{ id: 'a', text: '' }, { id: 'b', text: '' }], correctOptionId: 'a' }]
})
}
const removeDropdown = (idx: number) => {
onChange((q) => {
q.textSegments = segments.filter((_, i) => i !== idx)
q.dropdowns = dropdowns.filter((_, i) => i !== idx)
})
}
return (
<div className="question-form-section">
<label className="question-form-label">Text segments & dropdowns</label>
<p className="faint" style={{ fontSize: 12 }}>Text before dropdown 1, then dropdown 1 options, etc.</p>
{segments.map((seg, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<div className="choice-editor-row" style={{ marginBottom: 6 }}>
<span className="mono faint" style={{ fontSize: 12, width: 80 }}>Text {i + 1}</span>
<input
className="input"
value={seg}
onChange={(e) => updateSegment(i, e.target.value)}
placeholder={`Text before dropdown ${i + 1}`}
/>
</div>
{i < dropdowns.length && (
<div style={{ paddingLeft: 20, marginBottom: 6 }}>
<div className="choice-editor-row" style={{ marginBottom: 4 }}>
<span className="mono faint" style={{ fontSize: 12, width: 80 }}>Dropdown {i + 1}</span>
<button className="btn btn-ghost btn-sm" onClick={() => removeDropdown(i)}>✕</button>
</div>
{dropdowns[i].options.map((opt, j) => (
<div key={opt.id} className="choice-editor-row" style={{ marginBottom: 4, paddingLeft: 20 }}>
<input
type="radio"
name={`dd-correct-${i}`}
checked={dropdowns[i].correctOptionId === opt.id}
onChange={() => {
const next = [...dropdowns]
next[i] = { ...next[i], correctOptionId: opt.id }
onChange((q) => { q.dropdowns = next })
}}
title="Mark as correct"
/>
<span className="mono faint" style={{ fontSize: 13, width: 20 }}>{opt.id}</span>
<input
className="input"
value={opt.text}
onChange={(e) => {
const next = [...dropdowns]
const opts = [...next[i].options]
opts[j] = { ...opts[j], text: e.target.value }
next[i] = { ...next[i], options: opts }
onChange((q) => { q.dropdowns = next })
}}
placeholder={`Option ${opt.id}`}
/>
</div>
))}
<button
className="btn btn-sm"
style={{ marginLeft: 20, marginTop: 4 }}
onClick={() => {
const next = [...dropdowns]
const opts = [...next[i].options]
const nextId = String.fromCharCode(97 + opts.length)
opts.push({ id: nextId, text: '' })
next[i] = { ...next[i], options: opts }
onChange((q) => { q.dropdowns = next })
}}
>
+ Add Option
</button>
</div>
)}
</div>
))}
<button className="btn btn-sm" onClick={addDropdown} style={{ marginTop: 4 }}>
+ Add Dropdown
</button>
</div>
)
}
/* ─── Code Submission ─── */
function CodeSubmissionEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
const cfg = q.codeConfig ?? { language: 'python', starterCode: '' }
const rubric = q.rubric ?? []
return (
<div className="question-form-section">
<div className="question-form-row-inline">
<div style={{ width: 160 }}>
<label className="question-form-label">Language</label>
<input
className="input"
value={cfg.language}
onChange={(e) => onChange((q) => { q.codeConfig = { ...cfg, language: e.target.value } })}
placeholder="e.g. python, javascript"
/>
</div>
</div>
<div className="question-form-row">
<label className="question-form-label">Starter Code</label>
<textarea
className="textarea mono"
value={cfg.starterCode ?? ''}
onChange={(e) => onChange((q) => { q.codeConfig = { ...cfg, starterCode: e.target.value } })}
rows={6}
placeholder="Code shown to the student at start…"
spellCheck={false}
/>
</div>
<div className="question-form-row">
<label className="question-form-label">{t('editor.rubricCriteria')}</label>
{rubric.map((item, i) => (
<div key={i} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
className="input"
value={item}
onChange={(e) => {
const next = [...rubric]
next[i] = e.target.value
onChange((q) => { q.rubric = next })
}}
placeholder={t('editor.criterion')}
/>
<button
className="btn btn-ghost btn-sm"
onClick={() => {
const next = rubric.filter((_, idx) => idx !== i)
onChange((q) => { q.rubric = next })
}}
>
✕
</button>
</div>
))}
<button
className="btn btn-sm"
onClick={() => onChange((q) => { q.rubric = [...(q.rubric ?? []), ''] })}
style={{ marginTop: 4 }}
>
{t('editor.addCriterion')}
</button>
</div>
</div>
)
}
/* ─── Order ─── */ /* ─── Order ─── */
function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) { function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
@@ -973,3 +1322,62 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
</div> </div>
) )
} }
/* ─── Dictation ─── */
function DictationEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
return (
<div className="question-form-section">
<div className="question-form-row">
<label className="question-form-label">{t('editor.dictation')}</label>
<input
className="input"
value={q.audio?.src ?? ''}
onChange={(e) => onChange((q) => { q.audio = { src: e.target.value } })}
placeholder="https://example.com/audio.mp3"
/>
</div>
<TextAnswerEditor q={q} onChange={onChange} />
</div>
)
}
/* ─── Grammar Correction ─── */
function GrammarCorrectionEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
return (
<div className="question-form-section">
<div className="question-form-row">
<label className="question-form-label">{t('editor.textToCorrect')}</label>
<input
className="input"
value={q.textToCorrect ?? ''}
onChange={(e) => onChange((q) => { q.textToCorrect = e.target.value })}
placeholder={t('editor.textToCorrectPlaceholder')}
/>
</div>
<TextAnswerEditor q={q} onChange={onChange} />
</div>
)
}
/* ─── Flashcard ─── */
function FlashcardEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
return (
<div className="question-form-section">
<div className="question-form-row">
<label className="question-form-label">{t('editor.correctAnswer')}</label>
<input
className="input"
value={(q.answer as string) ?? ''}
onChange={(e) => onChange((q) => { q.answer = e.target.value })}
placeholder="The answer revealed on the back of the card"
/>
</div>
</div>
)
}
+1 -1
View File
@@ -242,7 +242,7 @@ export default function ExamRunner({
</span> </span>
</header> </header>
<Tex tag="div" className="q-prompt"> <Tex tag="div" className="q-prompt" markdown>
{q.prompt} {q.prompt}
</Tex> </Tex>
<QuestionMedia q={q} /> <QuestionMedia q={q} />
+1 -321
View File
@@ -4,6 +4,7 @@ import type { Exam, HistoryEntry } from '../types'
import { validateExam, parseExam } from '../utils/validation' import { validateExam, parseExam } from '../utils/validation'
import { loadState } from '../utils/storage' import { loadState } from '../utils/storage'
import { formatDate, formatTime } from '../utils/formatting' import { formatDate, formatTime } from '../utils/formatting'
import { downloadInstructions } from '../utils/instructions'
interface Props { interface Props {
onLoadExam: (exam: Exam, saveToWorkspace?: boolean) => void onLoadExam: (exam: Exam, saveToWorkspace?: boolean) => void
@@ -53,327 +54,6 @@ export default function Landing({ onLoadExam, history, onResume, onClearHistory,
} }
} }
const downloadInstructions = () => {
const content = `BRAINWAVE EXAM JSON — FORMAT GUIDE
====================================
Give this document to your preferred LLM (ChatGPT, Claude, Gemini, etc.) along with your exam content,
and ask it to produce a valid JSON file you can upload to BrainWave.
────────────────────────────────────
TOP-LEVEL STRUCTURE
────────────────────────────────────
{
"title": "string (REQUIRED) — The name of the exam",
"subtitle": "string (optional) — A short description shown under the title",
"author": "string (optional) — Who created the exam",
"timeLimitMinutes": number | null (optional) — Time limit in minutes, or null/omit for untimed,
"feedbackMode": "end" | "immediate" (optional, default: "end")
- "end": students see all feedback after submitting the full exam
- "immediate": feedback is shown after each question is answered,
"passingScore": number (optional) — Passing percentage, e.g. 70 means 70%,
"categories": ["string", ...] (optional) — Category labels for organizing questions,
"questions": [ ... ] (REQUIRED) — Array of question objects (see below)
}
────────────────────────────────────
QUESTION OBJECT (common fields)
────────────────────────────────────
Every question MUST have these fields:
"id": "string" — Unique identifier, e.g. "q1", "q2", etc.
"type": "string" — One of the supported types listed below
"prompt": "string" — The question text (supports LaTeX with $...$ or $$...$$)
Optional fields available on ALL question types:
"category": "string" — Which category this question belongs to
"points": number — Point value (default: 1)
"explanation": "string" — Explanation shown after grading (supports LaTeX)
"image": { "src": "URL", "alt": "string", "caption": "string" } — Attach an image
"audio": { "src": "URL" } — Attach an audio clip
"code": { "language": "string", "content": "string" } — Show a code block
"table": { "headers": ["Col1", "Col2"], "rows": [["a","b"], ["c","d"]] } — Show a data table
"svg": "string" — Raw SVG markup to display
────────────────────────────────────
QUESTION TYPES
────────────────────────────────────
1. MULTIPLE CHOICE ("multiple-choice")
Student picks ONE correct answer from a list.
Required fields:
"choices": [{ "id": "a", "text": "Option A" }, { "id": "b", "text": "Option B" }, ...]
"answer": "a" ← the id of the correct choice
Example:
{
"id": "q1",
"type": "multiple-choice",
"prompt": "What is the capital of France?",
"choices": [
{ "id": "a", "text": "London" },
{ "id": "b", "text": "Paris" },
{ "id": "c", "text": "Berlin" }
],
"answer": "b"
}
2. MULTIPLE SELECT ("multiple-select")
Student selects ALL correct answers (checkboxes).
Required fields:
"choices": [{ "id": "a", "text": "Option A" }, ...]
"answer": ["a", "c"] ← array of correct choice ids
Example:
{
"id": "q2",
"type": "multiple-select",
"prompt": "Which are prime numbers?",
"choices": [
{ "id": "a", "text": "2" },
{ "id": "b", "text": "4" },
{ "id": "c", "text": "7" },
{ "id": "d", "text": "9" }
],
"answer": ["a", "c"]
}
3. SHORT ANSWER ("short-answer")
Student types a short text answer.
Required fields:
"answer": "string" — The correct answer
Optional fields:
"acceptedForms": ["alt1", "alt2"] — Other accepted answers
"caseSensitive": true|false (default: false)
"ignorePunctuation": true|false (default: false)
"ignoreAccents": true|false (default: false)
Example:
{
"id": "q3",
"type": "short-answer",
"prompt": "What element has the chemical symbol 'O'?",
"answer": "Oxygen",
"caseSensitive": false
}
4. TRANSLATION ("translation")
Student translates text between languages.
Required fields:
"answer": "string" — The correct translation
"sourceLanguage": "string" — e.g. "English"
"targetLanguage": "string" — e.g. "Spanish"
Optional fields:
"acceptedForms": ["alt translation 1", "alt translation 2"]
"caseSensitive": true|false
"ignorePunctuation": true|false
"ignoreAccents": true|false
Example:
{
"id": "q4",
"type": "translation",
"sourceLanguage": "English",
"targetLanguage": "Spanish",
"prompt": "Translate: \\"Good morning, how are you?\\"",
"answer": "Buenos días, ¿cómo estás?",
"acceptedForms": ["Buenos dias, como estas"],
"caseSensitive": false,
"ignorePunctuation": true,
"ignoreAccents": true
}
5. MATH INPUT ("math-input")
Student enters a LaTeX math expression.
Required fields:
"answer": "string" — Correct LaTeX expression
Optional fields:
"acceptedForms": ["alt LaTeX 1", "alt LaTeX 2"]
Example:
{
"id": "q5",
"type": "math-input",
"prompt": "Compute the derivative of $x^3$.",
"answer": "3x^2",
"acceptedForms": ["3x^{2}", "3 x^2"]
}
6. ESSAY ("essay")
Student writes a long-form response. Graded manually (always marked "ungraded").
Optional fields:
"minWords": number — Minimum word count required
"rubric": ["criterion 1", "criterion 2"] — Grading rubric shown during review
Example:
{
"id": "q6",
"type": "essay",
"prompt": "Discuss the causes of World War I.",
"points": 10,
"minWords": 100,
"rubric": [
"Mentions alliance systems",
"Discusses assassination of Archduke Franz Ferdinand",
"Addresses nationalism and imperialism",
"Well-structured argument"
]
}
7. MATCH ("match")
Student matches items from a left column to items in a right column.
Required fields:
"left": [{ "id": "l1", "text": "Item A" }, { "id": "l2", "text": "Item B" }, ...]
"right": [{ "id": "r1", "text": "Match 1" }, { "id": "r2", "text": "Match 2" }, ...]
"answer": { "l1": "r2", "l2": "r1" } ← maps each left id to its correct right id
Example:
{
"id": "q7",
"type": "match",
"prompt": "Match the country to its capital.",
"left": [
{ "id": "l1", "text": "France" },
{ "id": "l2", "text": "Japan" },
{ "id": "l3", "text": "Brazil" }
],
"right": [
{ "id": "r1", "text": "Brasília" },
{ "id": "r2", "text": "Tokyo" },
{ "id": "r3", "text": "Paris" }
],
"answer": { "l1": "r3", "l2": "r2", "l3": "r1" }
}
8. ORDER ("order")
Student arranges items in the correct sequence (drag & drop).
Required fields:
"items": [{ "id": "s1", "text": "Step 1" }, { "id": "s2", "text": "Step 2" }, ...]
"answer": ["s1", "s2", ...] ← array of item ids in the correct order
Example:
{
"id": "q8",
"type": "order",
"prompt": "Put these historical events in chronological order.",
"items": [
{ "id": "e1", "text": "Moon landing (1969)" },
{ "id": "e2", "text": "Fall of the Berlin Wall (1989)" },
{ "id": "e3", "text": "World Wide Web invented (1991)" },
{ "id": "e4", "text": "First iPhone released (2007)" }
],
"answer": ["e1", "e2", "e3", "e4"]
}
────────────────────────────────────
LATEX SUPPORT
────────────────────────────────────
Use LaTeX math in prompts, choices, and explanations:
- Inline math: $x^2 + y^2 = z^2$
- Display math: $$\\\\int_0^1 x^2 \\\\, dx = \\\\frac{1}{3}$$
Remember to double-escape backslashes in JSON strings:
"prompt": "Evaluate $\\\\frac{d}{dx} x^2$"
────────────────────────────────────
MEDIA ATTACHMENTS
────────────────────────────────────
You can attach images, audio, code blocks, tables, and SVGs to any question.
Use publicly accessible URLs for image and audio sources.
Image: "image": { "src": "https://example.com/photo.jpg", "alt": "Description", "caption": "Optional caption" }
Audio: "audio": { "src": "https://example.com/clip.mp3" }
Code: "code": { "language": "python", "content": "def hello():\\n print('hi')" }
Table: "table": { "headers": ["Name", "Value"], "rows": [["A", "1"], ["B", "2"]] }
────────────────────────────────────
COMPLETE MINI EXAMPLE
────────────────────────────────────
{
"title": "Biology 101 — Cell Structure Quiz",
"subtitle": "Chapters 3-4 review",
"author": "Prof. Smith",
"timeLimitMinutes": 15,
"feedbackMode": "end",
"passingScore": 70,
"categories": ["Cells", "Organelles"],
"questions": [
{
"id": "q1",
"type": "multiple-choice",
"category": "Cells",
"points": 2,
"prompt": "Which organelle is known as the 'powerhouse of the cell'?",
"choices": [
{ "id": "a", "text": "Nucleus" },
{ "id": "b", "text": "Mitochondria" },
{ "id": "c", "text": "Ribosome" },
{ "id": "d", "text": "Golgi apparatus" }
],
"answer": "b",
"explanation": "Mitochondria produce ATP through cellular respiration."
},
{
"id": "q2",
"type": "short-answer",
"category": "Organelles",
"points": 1,
"prompt": "What is the gel-like fluid inside a cell called?",
"answer": "cytoplasm",
"acceptedForms": ["Cytoplasm", "cytosol"],
"caseSensitive": false
},
{
"id": "q3",
"type": "order",
"category": "Cells",
"points": 3,
"prompt": "Order these from smallest to largest:",
"items": [
{ "id": "a", "text": "Atom" },
{ "id": "b", "text": "Molecule" },
{ "id": "c", "text": "Organelle" },
{ "id": "d", "text": "Cell" }
],
"answer": ["a", "b", "c", "d"]
}
]
}
────────────────────────────────────
TIPS FOR LLM PROMPTING
────────────────────────────────────
When asking your LLM to create an exam, include:
1. This document (paste it in full)
2. The subject matter or source material
3. How many questions you want
4. Which question types to use
5. Desired difficulty level
6. Whether to include a time limit
Example prompt: "Using the attached format guide, create a 10-question exam on
World War II. Use a mix of multiple-choice, short-answer, and ordering questions.
Set a 20-minute time limit. Include explanations for each answer."
`
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'brainwave-exam-format-guide.txt'
a.click()
URL.revokeObjectURL(url)
}
const resumable = loadState() const resumable = loadState()
return ( return (
+1 -1
View File
@@ -200,7 +200,7 @@ export default function Results({ exam, state, onRestart, onExit, onRedoFailed,
{openIdx === i && ( {openIdx === i && (
<div className="review-body"> <div className="review-body">
<div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}> <div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}>
<Tex>{q.prompt}</Tex> <Tex markdown>{q.prompt}</Tex>
</div> </div>
<QuestionMedia q={q} /> <QuestionMedia q={q} />
<QuestionRenderer key={q.id} q={q} value={answers[q.id]} onChange={() => {}} locked graded /> <QuestionRenderer key={q.id} q={q} value={answers[q.id]} onChange={() => {}} locked graded />
+8 -2
View File
@@ -1,16 +1,22 @@
import type React from 'react' import type React from 'react'
import { renderLatex } from '../utils/latex' import { renderLatex } from '../utils/latex'
import { renderMarkdown } from '../utils/markdown'
interface TexProps { interface TexProps {
children?: string | null children?: string | null
tag?: keyof React.JSX.IntrinsicElements tag?: keyof React.JSX.IntrinsicElements
className?: string className?: string
style?: React.CSSProperties style?: React.CSSProperties
markdown?: boolean
} }
// Safe: renderLatex only produces KaTeX-controlled HTML + HTML-escaped plain text. // Safe: renderLatex only produces KaTeX-controlled HTML + HTML-escaped plain text.
export default function Tex({ children, tag = 'span', className, style }: Readonly<TexProps>) { // When markdown is true, uses renderMarkdown which applies marked + KaTeX + DOMPurify.
export default function Tex({ children, tag = 'span', className, style, markdown }: Readonly<TexProps>) {
const Tag = tag as React.ElementType const Tag = tag as React.ElementType
const html = String(children ?? '').split('\n').map(renderLatex).join('<br/>') const text = String(children ?? '')
const html = markdown
? renderMarkdown(text)
: text.split('\n').map(renderLatex).join('<br/>')
return <Tag className={className} style={style} dangerouslySetInnerHTML={{ __html: html }} /> return <Tag className={className} style={style} dangerouslySetInnerHTML={{ __html: html }} />
} }
+15
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import * as examApi from '../api/examApi' import * as examApi from '../api/examApi'
import * as resultApi from '../api/resultApi' import * as resultApi from '../api/resultApi'
import { downloadInstructions } from '../utils/instructions'
import type { Exam } from '../types' import type { Exam } from '../types'
import MyExams from './MyExams' import MyExams from './MyExams'
import MyResults from './MyResults' import MyResults from './MyResults'
@@ -96,6 +97,20 @@ export default function Workspace({ onLoadExam }: Props) {
</button> </button>
))} ))}
</nav> </nav>
<div className="workspace-sidebar-footer">
<button
type="button"
className="btn btn-ghost"
onClick={downloadInstructions}
title={t('workspace.downloadInstructions')}
>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 18, height: 18, border: '1.5px solid currentColor', borderRadius: 5, fontSize: 12, lineHeight: 1, flexShrink: 0 }}>
{t('landing.downloadInstructionsIcon')}
</span>
{t('workspace.downloadInstructions')}
</button>
</div>
</aside> </aside>
<main className="workspace-main"> <main className="workspace-main">
@@ -0,0 +1,56 @@
import type { Question } from '../../types'
interface CodeValue {
code?: string
selfGrade?: number
[key: string]: unknown
}
interface Props {
q: Question
value: CodeValue | string | null | undefined
onChange: (val: CodeValue) => void
locked?: boolean
}
export default function CodeSubmission({ q, value, onChange, locked }: Props) {
const cfg = q.codeConfig
const language = cfg?.language ?? 'text'
const starterCode = cfg?.starterCode ?? ''
const val = typeof value === 'string' ? { code: value } : ((value as CodeValue) ?? {})
const code = val.code ?? starterCode
return (
<div className="code-submission-wrap" style={{ maxWidth: 720 }}>
<div className="code-submission-bar">
<span className="code-lang-badge">{language}</span>
{cfg?.starterCode && (
<button
className="btn btn-ghost btn-sm"
onClick={() => onChange({ ...val, code: cfg.starterCode })}
disabled={locked}
type="button"
>
Reset code
</button>
)}
</div>
<textarea
className="code-submission-area"
value={code}
onChange={(e) => onChange({ ...val, code: e.target.value })}
placeholder="Write your code here…"
disabled={locked}
rows={12}
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
/>
<div className="code-submission-hint mono faint">
Self-graded · see rubric on review
</div>
</div>
)
}
@@ -0,0 +1,77 @@
import { useRef, useState } from 'react'
import type { Question } from '../../types'
interface Props {
q: Question
value: string | null | undefined
onChange: (val: string) => void
locked?: boolean
}
export default function Dictation({ q, value = '', onChange, locked }: Props) {
const audioRef = useRef<HTMLAudioElement>(null)
const [playing, setPlaying] = useState(false)
const [playbackRate, setPlaybackRate] = useState(1)
const togglePlay = () => {
if (!audioRef.current) return
if (playing) {
audioRef.current.pause()
} else {
audioRef.current.playbackRate = playbackRate
audioRef.current.play()
}
}
return (
<div className="answer-input-wrap">
{q.audio?.src && (
<div className="dictation-audio-wrap">
<audio
ref={audioRef}
src={q.audio.src}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
preload="metadata"
/>
<button
className={`btn btn-sm dictation-play-btn${playing ? ' playing' : ''}`}
onClick={togglePlay}
type="button"
disabled={locked}
aria-label={playing ? 'Pause' : 'Play'}
>
{playing ? '⏸ Pause' : '▶ Play'}
</button>
<div className="dictation-speed">
<label className="faint" style={{ fontSize: 11 }}>Speed</label>
{[0.5, 0.75, 1, 1.25, 1.5].map((rate) => (
<button
key={rate}
className={`btn btn-ghost btn-sm dictation-speed-btn${playbackRate === rate ? ' active' : ''}`}
onClick={() => {
setPlaybackRate(rate)
if (audioRef.current) audioRef.current.playbackRate = rate
}}
type="button"
disabled={locked}
>
{rate}x
</button>
))}
</div>
</div>
)}
<input
className="input"
type="text"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder="Type what you hear…"
disabled={locked}
autoComplete="off"
/>
</div>
)
}
@@ -0,0 +1,75 @@
import type { Question } from '../../types'
interface Props {
q: Question
value: Record<string, string> | null | undefined
onChange: (val: Record<string, string>) => void
locked?: boolean
graded?: boolean
}
export default function DropdownCompletion({ q, value, onChange, locked, graded }: Props) {
const current = value ?? {}
const segments = q.textSegments ?? []
const dropdowns = q.dropdowns ?? []
const handleChange = (id: string, optionId: string) => {
onChange({ ...current, [id]: optionId })
}
return (
<div className="cloze-text" style={{ lineHeight: 2.2 }}>
{segments.map((segment, i) => (
<span key={`s-${i}`}>
<span>{segment}</span>
{i < dropdowns.length && (
<DropdownSelect
dropdown={dropdowns[i]}
value={current[dropdowns[i].id] ?? ''}
onChange={(v) => handleChange(dropdowns[i].id, v)}
locked={locked}
graded={graded}
/>
)}
</span>
))}
</div>
)
}
function DropdownSelect({
dropdown,
value,
onChange,
locked,
graded,
}: {
dropdown: { id: string; options: Array<{ id: string; text: string }>; correctOptionId: string }
value: string
onChange: (v: string) => void
locked?: boolean
graded?: boolean
}) {
let cls = 'cloze-dropdown'
if (graded) {
cls += value === dropdown.correctOptionId ? ' correct' : value ? ' wrong' : ''
}
return (
<select
className={cls}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={locked}
>
<option value="" disabled>
—
</option>
{dropdown.options.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.text}
</option>
))}
</select>
)
}
@@ -0,0 +1,76 @@
import type { Question } from '../../types'
interface Props {
q: Question
value: Record<string, string> | null | undefined
onChange: (val: Record<string, string>) => void
locked?: boolean
graded?: boolean
}
export default function FillInTheBlanks({ q, value, onChange, locked, graded }: Props) {
const current = value ?? {}
const blanks = q.blanks ?? []
const segments = q.textSegments ?? []
const handleChange = (blankId: string, text: string) => {
onChange({ ...current, [blankId]: text })
}
return (
<div className="cloze-text" style={{ lineHeight: 2.2 }}>
{segments.map((segment, i) => (
<span key={`s-${i}`}>
<span>{segment}</span>
{i < blanks.length && (
<BlankInput
blank={blanks[i]}
value={current[blanks[i].id] ?? ''}
onChange={(v) => handleChange(blanks[i].id, v)}
locked={locked}
graded={graded}
/>
)}
</span>
))}
</div>
)
}
function BlankInput({
blank,
value,
onChange,
locked,
graded,
}: {
blank: { id: string; acceptedAnswers: string[] }
value: string
onChange: (v: string) => void
locked?: boolean
graded?: boolean
}) {
let cls = 'cloze-blank'
if (graded) {
// Simple check: if the normalized value matches any accepted answer
const normalized = value.toLowerCase().trim()
const ok = blank.acceptedAnswers.some((a) => a.toLowerCase().trim() === normalized)
cls += ok ? ' correct' : ' wrong'
}
return (
<input
className={cls}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={locked}
autoComplete="off"
spellCheck={false}
style={{
width: Math.max(80, (value.length + 2) * 10),
minWidth: 80,
}}
/>
)
}
@@ -0,0 +1,73 @@
import Tex from '../Tex'
import type { Question } from '../../types'
interface Props {
q: Question
value: { revealed?: boolean; selfGrade?: number } | null | undefined
onChange: (val: { revealed?: boolean; selfGrade?: number }) => void
locked?: boolean
}
export default function Flashcard({ q, value, onChange, locked }: Props) {
const { revealed = false, selfGrade } = value ?? {}
const handleReveal = () => {
if (locked) return
onChange({ ...value, revealed: true })
}
const handleGrade = (grade: number) => {
if (locked) return
onChange({ revealed: true, selfGrade: grade })
}
return (
<div className="flashcard-wrap">
<div className="flashcard-prompt">
<Tex markdown>{q.prompt}</Tex>
</div>
{!revealed ? (
<button
className="btn flashcard-reveal-btn"
onClick={handleReveal}
disabled={locked}
type="button"
>
Reveal Answer
</button>
) : (
<>
<div className="flashcard-answer">
{q.answer as string}
</div>
{q.explanation && (
<div className="flashcard-explanation">
{q.explanation}
</div>
)}
{selfGrade == null ? (
<div className="flashcard-grade">
<div className="faint" style={{ fontSize: 12, marginBottom: 6 }}>How well did you know this?</div>
<div className="flashcard-grade-btns">
<button className="btn btn-sm flashcard-grade-wrong" onClick={() => handleGrade(0)} type="button">
Didn't know
</button>
<button className="btn btn-sm flashcard-grade-hard" onClick={() => handleGrade(0.5)} type="button">
Hard
</button>
<button className="btn btn-sm flashcard-grade-good" onClick={() => handleGrade(1)} type="button">
Good
</button>
</div>
</div>
) : (
<div className={`flashcard-grade-result ${selfGrade >= 0.5 ? 'correct' : 'wrong'}`}>
{selfGrade >= 0.5 ? '✓ Marked as known' : '✗ Marked as needs practice'}
</div>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,36 @@
import Tex from '../Tex'
import type { Question } from '../../types'
interface Props {
q: Question
value: string | null | undefined
onChange: (val: string) => void
locked?: boolean
graded?: boolean
}
export default function GrammarCorrection({ q, value = '', onChange, locked, graded: _graded }: Props) {
const displayText = q.textToCorrect || q.prompt
return (
<div className="answer-input-wrap">
<div className="grammar-text-wrap">
<div className="grammar-label faint" style={{ fontSize: 12, marginBottom: 4 }}>
Sentence to correct:
</div>
<div className="grammar-sentence">
<Tex markdown>{displayText}</Tex>
</div>
</div>
<input
className="input"
type="text"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder="Type the corrected sentence…"
disabled={locked}
autoComplete="off"
spellCheck
/>
</div>
)
}
@@ -0,0 +1,51 @@
import type { Question } from '../../types'
interface Props {
q: Question
value: number | string | null | undefined
onChange: (val: number) => void
locked?: boolean
graded?: boolean
}
export default function NumericRange({ q, value, onChange, locked, graded }: Props) {
const cfg = q.numericConfig
if (!cfg) {
return <div className="muted">Numeric config missing</div>
}
const min = cfg.min
const max = cfg.max
const numValue = value === '' || value == null ? '' : Number(value)
let cls = 'input numeric-input'
if (graded) {
const ok = typeof numValue === 'number' && !Number.isNaN(numValue) && Math.abs(numValue - cfg.answer) <= cfg.tolerance
cls += ok ? ' correct' : ' wrong'
}
return (
<div className="answer-input-wrap">
<input
className={cls}
type="number"
value={numValue}
onChange={(e) => {
const v = e.target.value
if (v === '') return
const n = Number(v)
if (!Number.isNaN(n)) onChange(n)
}}
placeholder="Enter number…"
disabled={locked}
min={min}
max={max}
step="any"
/>
<div className="mono faint" style={{ fontSize: 11, marginTop: 4 }}>
Tolerance: ±{cfg.tolerance}
{min != null && max != null ? ` · Range: ${min} – ${max}` : ''}
</div>
</div>
)
}
@@ -6,6 +6,14 @@ import MathInput from './MathInput'
import Essay from './Essay' import Essay from './Essay'
import Match from './Match' import Match from './Match'
import OrderQuestion from './OrderQuestion' import OrderQuestion from './OrderQuestion'
import FillInTheBlanks from './FillInTheBlanks'
import TrueFalse from './TrueFalse'
import CodeSubmission from './CodeSubmission'
import DropdownCompletion from './DropdownCompletion'
import NumericRange from './NumericRange'
import Dictation from './Dictation'
import GrammarCorrection from './GrammarCorrection'
import Flashcard from './Flashcard'
interface Props { interface Props {
q: Question q: Question
@@ -32,6 +40,22 @@ export default function QuestionRenderer({ q, value, onChange, locked, graded }:
return <Match q={q} value={value as Record<string, string>} onChange={onChange as (v: Record<string, string>) => void} locked={locked} graded={graded} /> return <Match q={q} value={value as Record<string, string>} onChange={onChange as (v: Record<string, string>) => void} locked={locked} graded={graded} />
case 'order': case 'order':
return <OrderQuestion q={q} value={value as string[]} onChange={onChange as (v: string[]) => void} locked={locked} graded={graded} /> return <OrderQuestion q={q} value={value as string[]} onChange={onChange as (v: string[]) => void} locked={locked} graded={graded} />
case 'fill-in-the-blanks':
return <FillInTheBlanks q={q} value={value as Record<string, string>} onChange={onChange as (v: Record<string, string>) => void} locked={locked} graded={graded} />
case 'true-false':
return <TrueFalse q={q} value={value as boolean} onChange={onChange as (v: boolean) => void} locked={locked} graded={graded} />
case 'code-submission':
return <CodeSubmission q={q} value={value as Record<string, unknown>} onChange={onChange as (v: Record<string, unknown>) => void} locked={locked} />
case 'dropdown-completion':
return <DropdownCompletion q={q} value={value as Record<string, string>} onChange={onChange as (v: Record<string, string>) => void} locked={locked} graded={graded} />
case 'numeric-range':
return <NumericRange q={q} value={value as number} onChange={onChange as (v: number) => void} locked={locked} graded={graded} />
case 'dictation':
return <Dictation q={q} value={value as string} onChange={onChange as (v: string) => void} locked={locked} />
case 'grammar-correction':
return <GrammarCorrection q={q} value={value as string} onChange={onChange as (v: string) => void} locked={locked} graded={graded} />
case 'flashcard':
return <Flashcard q={q} value={value as { revealed?: boolean; selfGrade?: number }} onChange={onChange as (v: { revealed?: boolean; selfGrade?: number }) => void} locked={locked} />
default: default:
return <div className="muted">Unsupported question type: <code>{q.type}</code></div> return <div className="muted">Unsupported question type: <code>{q.type}</code></div>
} }
@@ -0,0 +1,39 @@
import type { Question } from '../../types'
interface Props {
q: Question
value: boolean | null | undefined
onChange: (val: boolean) => void
locked?: boolean
graded?: boolean
}
export default function TrueFalse({ q, value, onChange, locked, graded }: Props) {
const correctAnswer = q.answer as boolean
return (
<div className="tf-group" role="radiogroup">
{[true, false].map((val) => {
const isSelected = value === val
let cls = 'tf-btn'
if (isSelected) cls += ' selected'
if (graded) {
if (val === correctAnswer) cls += ' correct'
else if (isSelected) cls += ' wrong'
}
return (
<button
key={val ? 't' : 'f'}
className={cls}
onClick={() => !locked && onChange(val)}
disabled={locked}
role="radio"
aria-checked={isSelected}
>
<span className="tf-label">{val ? 'True' : 'False'}</span>
</button>
)
})}
</div>
)
}
+23 -1
View File
@@ -30,7 +30,7 @@
"downloadInstructions": "Download Instructions — How to create your own exam JSON", "downloadInstructions": "Download Instructions — How to create your own exam JSON",
"downloadInstructionsIcon": "↓", "downloadInstructionsIcon": "↓",
"supportedTypes": "Supported types", "supportedTypes": "Supported types",
"supportedTypesList": "multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order", "supportedTypesList": "multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order · fill-in-the-blanks · true-false · code-submission · dropdown-completion · numeric-range",
"trySample": "Try a sample", "trySample": "Try a sample",
"resumeTitle": "Resume in-progress exam", "resumeTitle": "Resume in-progress exam",
"resumeProgress": "{{answered}}/{{total}} answered", "resumeProgress": "{{answered}}/{{total}} answered",
@@ -62,6 +62,10 @@
"loginFailed": "Login failed", "loginFailed": "Login failed",
"registrationFailed": "Registration failed", "registrationFailed": "Registration failed",
"passwordsDoNotMatch": "Passwords do not match", "passwordsDoNotMatch": "Passwords do not match",
"verificationResendHint": "Didn’t get the email? You can resend the verification link after the countdown finishes.",
"resendVerificationLink": "Resend verification link",
"resendVerificationLinkIn": "Resend verification link in {{seconds}}s",
"verificationLinkSent": "Verification link sent. Please check your email.",
"myWorkspace": "My Workspace", "myWorkspace": "My Workspace",
"adminDashboard": "Admin Dashboard", "adminDashboard": "Admin Dashboard",
"userMenu": "User menu", "userMenu": "User menu",
@@ -198,6 +202,7 @@
"importing": "Importing…", "importing": "Importing…",
"viewAllResults": "View all results", "viewAllResults": "View all results",
"recentResults": "Recent Results", "recentResults": "Recent Results",
"downloadInstructions": "Download Instructions",
"viewAll": "View all", "viewAll": "View all",
"noAttempts": "No attempts yet", "noAttempts": "No attempts yet",
"noAttemptsDesc": "Take an exam to see your results here.", "noAttemptsDesc": "Take an exam to see your results here.",
@@ -380,6 +385,23 @@
"essay": "Essay", "essay": "Essay",
"match": "Match", "match": "Match",
"order": "Order", "order": "Order",
"fillInTheBlanks": "Fill in the Blanks",
"trueFalse": "True / False",
"codeSubmission": "Code Submission",
"dropdownCompletion": "Dropdown Completion",
"numericRange": "Numeric Range",
"dictation": "Dictation",
"grammarCorrection": "Grammar Correction",
"flashcard": "Flashcard",
"textToCorrect": "Text to Correct",
"textToCorrectPlaceholder": "Enter the grammatically incorrect sentence…",
"playbackSpeed": "Speed",
"revealAnswer": "Reveal Answer",
"didntKnow": "Didn't know",
"hard": "Hard",
"good": "Good",
"flashcardFront": "Prompt (front of card)",
"flashcardFrontPlaceholder": "Enter the prompt…",
"correctAnswer": "Correct Answer", "correctAnswer": "Correct Answer",
"correctAnswerPlaceholder": "Enter the correct answer", "correctAnswerPlaceholder": "Enter the correct answer",
"acceptedForms": "Accepted Forms (optional alternatives)", "acceptedForms": "Accepted Forms (optional alternatives)",
+23 -1
View File
@@ -30,7 +30,7 @@
"downloadInstructions": "Download Instructies — Hoe maak je je eigen examen-JSON", "downloadInstructions": "Download Instructies — Hoe maak je je eigen examen-JSON",
"downloadInstructionsIcon": "↓", "downloadInstructionsIcon": "↓",
"supportedTypes": "Ondersteunde types", "supportedTypes": "Ondersteunde types",
"supportedTypesList": "meerkeuze · meervoudige-selectie · kort-antwoord · vertaling · wiskunde-invoer (LaTeX) · opstel · koppelen · rangschikken", "supportedTypesList": "meerkeuze · meervoudige-selectie · kort-antwoord · vertaling · wiskunde-invoer (LaTeX) · opstel · koppelen · rangschikken · invulvragen · waar-onwaar · code-inzending · dropdown-vervolledigen · numeriek-bereik",
"trySample": "Probeer een voorbeeld", "trySample": "Probeer een voorbeeld",
"resumeTitle": "Hervat lopend examen", "resumeTitle": "Hervat lopend examen",
"resumeProgress": "{{answered}}/{{total}} beantwoord", "resumeProgress": "{{answered}}/{{total}} beantwoord",
@@ -62,6 +62,10 @@
"loginFailed": "Inloggen mislukt", "loginFailed": "Inloggen mislukt",
"registrationFailed": "Registratie mislukt", "registrationFailed": "Registratie mislukt",
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen", "passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
"verificationResendHint": "Geen e-mail ontvangen? Je kunt de verificatielink opnieuw versturen zodra de aftelling voorbij is.",
"resendVerificationLink": "Verificatielink opnieuw versturen",
"resendVerificationLinkIn": "Verificatielink opnieuw versturen over {{seconds}}s",
"verificationLinkSent": "Verificatielink verzonden. Controleer je e-mail.",
"myWorkspace": "Mijn Werkruimte", "myWorkspace": "Mijn Werkruimte",
"adminDashboard": "Admin Dashboard", "adminDashboard": "Admin Dashboard",
"userMenu": "Gebruikersmenu", "userMenu": "Gebruikersmenu",
@@ -197,6 +201,7 @@
"importing": "Importeren…", "importing": "Importeren…",
"viewAllResults": "Alle resultaten bekijken", "viewAllResults": "Alle resultaten bekijken",
"recentResults": "Recente Resultaten", "recentResults": "Recente Resultaten",
"downloadInstructions": "Download Instructies",
"viewAll": "Alles bekijken", "viewAll": "Alles bekijken",
"noAttempts": "Nog geen pogingen", "noAttempts": "Nog geen pogingen",
"noAttemptsDesc": "Maak een examen om hier je resultaten te zien.", "noAttemptsDesc": "Maak een examen om hier je resultaten te zien.",
@@ -378,6 +383,23 @@
"essay": "Opstel", "essay": "Opstel",
"match": "Koppelen", "match": "Koppelen",
"order": "Rangschikken", "order": "Rangschikken",
"fillInTheBlanks": "Invulvragen",
"trueFalse": "Waar / Onwaar",
"codeSubmission": "Code Inzending",
"dropdownCompletion": "Dropdown Vervolledigen",
"numericRange": "Numeriek Bereik",
"dictation": "Dictee",
"grammarCorrection": "Grammaticacorrectie",
"flashcard": "Flashcard",
"textToCorrect": "Tekst te corrigeren",
"textToCorrectPlaceholder": "Voer de grammaticaal incorrecte zin in…",
"playbackSpeed": "Snelheid",
"revealAnswer": "Toon antwoord",
"didntKnow": "Wist ik niet",
"hard": "Moeilijk",
"good": "Goed",
"flashcardFront": "Prompt (voorkant kaart)",
"flashcardFrontPlaceholder": "Voer de prompt in…",
"correctAnswer": "Correct antwoord", "correctAnswer": "Correct antwoord",
"correctAnswerPlaceholder": "Voer het correcte antwoord in", "correctAnswerPlaceholder": "Voer het correcte antwoord in",
"acceptedForms": "Geaccepteerde vormen (optionele alternatieven)", "acceptedForms": "Geaccepteerde vormen (optionele alternatieven)",
+351
View File
@@ -993,6 +993,185 @@ select {
background: var(--wrong-weak); background: var(--wrong-weak);
} }
/* ---------- True / False ---------- */
.tf-group {
display: flex;
gap: 12px;
max-width: 560px;
}
.tf-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 18px 20px;
border: 1px solid var(--line-2);
border-radius: var(--r-md);
background: var(--paper);
color: var(--ink);
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 120ms ease;
text-align: center;
}
.tf-btn:hover {
border-color: var(--line-3);
background: var(--paper-2);
}
.tf-btn.selected {
border-color: var(--ink);
background: var(--paper-2);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.tf-btn.correct {
border-color: var(--correct);
background: var(--correct-weak);
}
.tf-btn.wrong {
border-color: var(--wrong);
background: var(--wrong-weak);
}
/* ---------- Cloze (Fill-in-the-blanks & Dropdown) ---------- */
.cloze-text {
font-family: var(--font-serif);
font-size: 18px;
line-height: 2.2;
color: var(--ink);
}
.cloze-blank {
display: inline-block;
padding: 4px 10px;
margin: 0 4px;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--paper);
color: var(--ink);
font-family: var(--font-sans);
font-size: 14px;
text-align: center;
outline: none;
transition:
border-color 120ms ease,
box-shadow 120ms ease,
width 120ms ease;
vertical-align: middle;
}
.cloze-blank:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.cloze-blank.correct {
border-color: var(--correct);
background: var(--correct-weak);
}
.cloze-blank.wrong {
border-color: var(--wrong);
background: var(--wrong-weak);
}
.cloze-dropdown {
display: inline-block;
padding: 5px 28px 5px 12px;
margin: 0 4px;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--paper);
color: var(--ink);
font-family: var(--font-sans);
font-size: 14px;
outline: none;
cursor: pointer;
transition:
border-color 120ms ease,
box-shadow 120ms ease;
vertical-align: middle;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
}
.cloze-dropdown:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.cloze-dropdown.correct {
border-color: var(--correct);
background-color: var(--correct-weak);
}
.cloze-dropdown.wrong {
border-color: var(--wrong);
background-color: var(--wrong-weak);
}
/* ---------- Numeric Range ---------- */
.numeric-input {
max-width: 200px;
font-family: var(--font-mono);
font-size: 15px;
}
.numeric-input.correct {
border-color: var(--correct);
background: var(--correct-weak);
box-shadow: 0 0 0 3px var(--correct-weak);
}
.numeric-input.wrong {
border-color: var(--wrong);
background: var(--wrong-weak);
box-shadow: 0 0 0 3px var(--wrong-weak);
}
/* ---------- Code Submission ---------- */
.code-submission-wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.code-submission-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.code-lang-badge {
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 3px 10px;
border-radius: 4px;
background: var(--paper-3);
color: var(--ink-3);
border: 1px solid var(--line);
}
.code-submission-area {
width: 100%;
padding: 16px 20px;
border: 1px solid var(--line-2);
border-radius: var(--r-md);
background: var(--paper-2);
color: var(--ink);
font-family: var(--font-mono);
font-size: 13.5px;
line-height: 1.6;
outline: none;
resize: vertical;
min-height: 180px;
transition:
border-color 120ms ease,
box-shadow 120ms ease;
tab-size: 4;
}
.code-submission-area:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.code-submission-hint {
font-size: 12px;
margin-top: 4px;
}
/* ---------- Short / Math input ---------- */ /* ---------- Short / Math input ---------- */
.answer-input-wrap { .answer-input-wrap {
display: flex; display: flex;
@@ -2350,6 +2529,25 @@ select {
top: 57px; top: 57px;
height: calc(100vh - 57px); height: calc(100vh - 57px);
overflow-y: auto; overflow-y: auto;
display: flex;
flex-direction: column;
}
.workspace-sidebar-footer {
margin-top: auto;
padding-top: 16px;
}
.workspace-sidebar-footer .btn {
width: 100%;
justify-content: center;
font-size: 12.5px;
color: var(--ink-3);
}
.workspace-sidebar-footer .btn:hover {
color: var(--accent);
background: var(--accent-weak);
} }
.workspace-profile { .workspace-profile {
@@ -3215,3 +3413,156 @@ select {
width: 100%; width: 100%;
} }
} }
/* ---------- Dictation ---------- */
.dictation-audio-wrap {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
padding: 16px 18px;
background: var(--paper-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
margin-bottom: 16px;
}
.dictation-play-btn {
min-width: 90px;
justify-content: center;
}
.dictation-play-btn.playing {
background: var(--accent-weak);
border-color: var(--accent);
color: var(--accent-ink);
}
.dictation-speed {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.dictation-speed-btn {
padding: 4px 8px;
font-size: 12px;
min-width: 0;
}
.dictation-speed-btn.active {
background: var(--ink);
color: var(--paper);
border-color: var(--ink);
}
/* ---------- Grammar Correction ---------- */
.grammar-text-wrap {
padding: 14px 18px;
background: var(--paper-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
margin-bottom: 16px;
}
.grammar-label {
text-transform: uppercase;
letter-spacing: 0.08em;
}
.grammar-sentence {
font-family: var(--font-serif);
font-size: 18px;
line-height: 1.5;
color: var(--ink);
}
/* ---------- Flashcard ---------- */
.flashcard-wrap {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 560px;
}
.flashcard-prompt {
font-family: var(--font-serif);
font-size: 22px;
line-height: 1.4;
color: var(--ink);
padding: 20px 24px;
background: var(--paper-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
text-align: center;
}
.flashcard-reveal-btn {
align-self: center;
padding: 10px 20px;
}
.flashcard-answer {
font-family: var(--font-serif);
font-size: 20px;
line-height: 1.4;
color: var(--accent-ink);
padding: 16px 20px;
background: var(--accent-weak);
border: 1px solid var(--accent);
border-radius: var(--r-md);
text-align: center;
}
.flashcard-explanation {
font-size: 13.5px;
color: var(--ink-2);
line-height: 1.5;
padding: 12px 16px;
background: var(--paper-2);
border-radius: var(--r-sm);
}
.flashcard-grade {
padding: 14px 18px;
background: var(--paper-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.flashcard-grade-btns {
display: flex;
gap: 8px;
}
.flashcard-grade-wrong {
flex: 1;
justify-content: center;
}
.flashcard-grade-wrong:hover {
background: var(--wrong-weak);
border-color: var(--wrong);
color: var(--wrong);
}
.flashcard-grade-hard {
flex: 1;
justify-content: center;
}
.flashcard-grade-hard:hover {
background: var(--warn-weak);
border-color: var(--warn);
color: var(--warn);
}
.flashcard-grade-good {
flex: 1;
justify-content: center;
}
.flashcard-grade-good:hover {
background: var(--correct-weak);
border-color: var(--correct);
color: var(--correct);
}
.flashcard-grade-result {
padding: 10px 16px;
border-radius: var(--r-md);
font-size: 13.5px;
font-weight: 500;
text-align: center;
}
.flashcard-grade-result.correct {
background: var(--correct-weak);
color: var(--correct);
border: 1px solid var(--correct);
}
.flashcard-grade-result.wrong {
background: var(--wrong-weak);
color: var(--wrong);
border: 1px solid var(--wrong);
}
+43 -2
View File
@@ -8,7 +8,42 @@ export type QuestionType =
| "math-input" | "math-input"
| "essay" | "essay"
| "match" | "match"
| "order"; | "order"
| "fill-in-the-blanks"
| "true-false"
| "code-submission"
| "dropdown-completion"
| "numeric-range"
| "dictation"
| "grammar-correction"
| "flashcard";
export interface Blank {
id: string;
acceptedAnswers: string[];
acceptedForms?: string[];
caseSensitive?: boolean;
ignorePunctuation?: boolean;
ignoreAccents?: boolean;
}
export interface Dropdown {
id: string;
options: Choice[];
correctOptionId: string;
}
export interface CodeSubmissionConfig {
language: string;
starterCode?: string;
}
export interface NumericConfig {
answer: number;
tolerance: number;
min?: number;
max?: number;
}
export interface Choice { export interface Choice {
id: string; id: string;
@@ -52,7 +87,7 @@ export interface Question {
code?: QuestionCode; code?: QuestionCode;
table?: QuestionTable; table?: QuestionTable;
svg?: string; svg?: string;
answer?: string | string[] | Record<string, string>; answer?: string | string[] | Record<string, string> | boolean | number;
acceptedForms?: string[]; acceptedForms?: string[];
caseSensitive?: boolean; caseSensitive?: boolean;
ignorePunctuation?: boolean; ignorePunctuation?: boolean;
@@ -65,6 +100,12 @@ export interface Question {
left?: MatchItem[]; left?: MatchItem[];
right?: MatchItem[]; right?: MatchItem[];
items?: MatchItem[]; items?: MatchItem[];
textSegments?: string[];
blanks?: Blank[];
dropdowns?: Dropdown[];
codeConfig?: CodeSubmissionConfig;
numericConfig?: NumericConfig;
textToCorrect?: string;
} }
export interface Exam { export interface Exam {
+22 -2
View File
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect, vi, afterEach } from 'vitest'
import { ApiError } from './api' import { apiFetch, ApiError } from './api'
afterEach(() => {
vi.restoreAllMocks()
})
describe('ApiError', () => { describe('ApiError', () => {
it('extends Error', () => { it('extends Error', () => {
@@ -18,3 +22,19 @@ describe('ApiError', () => {
expect(err.name).toBe('ApiError') expect(err.name).toBe('ApiError')
}) })
}) })
describe('apiFetch', () => {
it('uses message from error responses', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
json: vi.fn().mockResolvedValue({ message: 'Please verify your email before logging in' }),
}))
await expect(apiFetch('/api/auth/login')).rejects.toMatchObject({
status: 403,
message: 'Please verify your email before logging in',
})
})
})
+2 -1
View File
@@ -40,7 +40,8 @@ export async function apiFetch<T = unknown>(
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText })) const body = await res.json().catch(() => ({ error: res.statusText }))
throw new ApiError(res.status, (body as { error?: string }).error ?? 'Request failed') const parsedBody = body as { error?: string; message?: string }
throw new ApiError(res.status, parsedBody.error ?? parsedBody.message ?? 'Request failed')
} }
return res.json() as Promise<T> return res.json() as Promise<T>
+74 -1
View File
@@ -1,4 +1,4 @@
import type { Question, GradeResult } from '../types' import type { Question, GradeResult, Blank } from '../types'
interface NormalizeOpts { interface NormalizeOpts {
caseSensitive?: boolean caseSensitive?: boolean
@@ -6,6 +6,12 @@ interface NormalizeOpts {
ignorePunctuation?: boolean ignorePunctuation?: boolean
} }
const blankOpts = (b: Blank): NormalizeOpts => ({
caseSensitive: b.caseSensitive === true,
ignorePunctuation: b.ignorePunctuation !== false,
ignoreAccents: b.ignoreAccents !== false,
})
export const normalizeString = (s: unknown, opts: NormalizeOpts = {}): string => { export const normalizeString = (s: unknown, opts: NormalizeOpts = {}): string => {
if (s == null) return '' if (s == null) return ''
let out = String(s) let out = String(s)
@@ -100,6 +106,73 @@ export const gradeQuestion = (q: Question, userAnswer: unknown): GradeResult =>
} }
return { status: 'ungraded', correct: false, points: 0 } return { status: 'ungraded', correct: false, points: 0 }
} }
case 'true-false': {
const ok = userAnswer === q.answer
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? pts : 0 }
}
case 'numeric-range': {
const cfg = q.numericConfig
if (!cfg) return { status: 'unanswered', correct: false, points: 0 }
const num = Number(userAnswer)
if (Number.isNaN(num)) return { status: 'wrong', correct: false, points: 0 }
const ok = Math.abs(num - cfg.answer) <= cfg.tolerance
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? pts : 0 }
}
case 'dropdown-completion': {
const correct = (q.dropdowns ?? []).reduce<Record<string, string>>((acc, d) => {
acc[d.id] = d.correctOptionId
return acc
}, {})
const given = (userAnswer as Record<string, string>) ?? {}
const keys = Object.keys(correct)
if (keys.length === 0) return { status: 'unanswered', correct: false, points: 0 }
const allOk = keys.every((k) => given[k] === correct[k])
return { status: allOk ? 'correct' : 'wrong', correct: allOk, points: allOk ? pts : 0 }
}
case 'fill-in-the-blanks': {
const blanks = q.blanks ?? []
if (blanks.length === 0) return { status: 'unanswered', correct: false, points: 0 }
const given = (userAnswer as Record<string, string>) ?? {}
let allOk = true
for (const b of blanks) {
const target = normalizeString(given[b.id], blankOpts(b))
const accepted = [...b.acceptedAnswers, ...(b.acceptedForms ?? [])]
.filter(Boolean)
.map((a) => normalizeString(a, blankOpts(b)))
if (!accepted.includes(target)) allOk = false
}
return { status: allOk ? 'correct' : 'wrong', correct: allOk, points: allOk ? pts : 0 }
}
case 'code-submission': {
const val = userAnswer as { code?: string; selfGrade?: number }
if (val?.selfGrade != null) {
const g = val.selfGrade
return { status: g >= 0.5 ? 'correct' : 'wrong', correct: g >= 0.5, points: Math.round(pts * g) }
}
return { status: 'ungraded', correct: false, points: 0 }
}
case 'dictation':
case 'grammar-correction': {
const opts: NormalizeOpts = {
caseSensitive: q.caseSensitive === true,
ignorePunctuation: q.ignorePunctuation !== false,
ignoreAccents: q.ignoreAccents !== false,
}
const target = normalizeString(userAnswer, opts)
const accepted = [q.answer as string, ...(q.acceptedForms ?? [])]
.filter(Boolean)
.map((a) => normalizeString(a, opts))
const ok = accepted.includes(target)
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? pts : 0 }
}
case 'flashcard': {
const val = userAnswer as { selfGrade?: number }
if (val?.selfGrade != null) {
const g = val.selfGrade
return { status: g >= 0.5 ? 'correct' : 'wrong', correct: g >= 0.5, points: Math.round(pts * g) }
}
return { status: 'ungraded', correct: false, points: 0 }
}
default: default:
return { status: 'unanswered', correct: false, points: 0 } return { status: 'unanswered', correct: false, points: 0 }
} }
+320
View File
@@ -0,0 +1,320 @@
export function downloadInstructions() {
const content = `BRAINWAVE EXAM JSON — FORMAT GUIDE
====================================
Give this document to your preferred LLM (ChatGPT, Claude, Gemini, etc.) along with your exam content,
and ask it to produce a valid JSON file you can upload to BrainWave.
────────────────────────────────────
TOP-LEVEL STRUCTURE
────────────────────────────────────
{
"title": "string (REQUIRED) — The name of the exam",
"subtitle": "string (optional) — A short description shown under the title",
"author": "string (optional) — Who created the exam",
"timeLimitMinutes": number | null (optional) — Time limit in minutes, or null/omit for untimed,
"feedbackMode": "end" | "immediate" (optional, default: "end")
- "end": students see all feedback after submitting the full exam
- "immediate": feedback is shown after each question is answered,
"passingScore": number (optional) — Passing percentage, e.g. 70 means 70%,
"categories": ["string", ...] (optional) — Category labels for organizing questions,
"questions": [ ... ] (REQUIRED) — Array of question objects (see below)
}
────────────────────────────────────
QUESTION OBJECT (common fields)
────────────────────────────────────
Every question MUST have these fields:
"id": "string" — Unique identifier, e.g. "q1", "q2", etc.
"type": "string" — One of the supported types listed below
"prompt": "string" — The question text (supports LaTeX with $...$ or $$...$$)
Optional fields available on ALL question types:
"category": "string" — Which category this question belongs to
"points": number — Point value (default: 1)
"explanation": "string" — Explanation shown after grading (supports LaTeX)
"image": { "src": "URL", "alt": "string", "caption": "string" } — Attach an image
"audio": { "src": "URL" } — Attach an audio clip
"code": { "language": "string", "content": "string" } — Show a code block
"table": { "headers": ["Col1", "Col2"], "rows": [["a","b"], ["c","d"]] } — Show a data table
"svg": "string" — Raw SVG markup to display
────────────────────────────────────
QUESTION TYPES
────────────────────────────────────
1. MULTIPLE CHOICE ("multiple-choice")
Student picks ONE correct answer from a list.
Required fields:
"choices": [{ "id": "a", "text": "Option A" }, { "id": "b", "text": "Option B" }, ...]
"answer": "a" ← the id of the correct choice
Example:
{
"id": "q1",
"type": "multiple-choice",
"prompt": "What is the capital of France?",
"choices": [
{ "id": "a", "text": "London" },
{ "id": "b", "text": "Paris" },
{ "id": "c", "text": "Berlin" }
],
"answer": "b"
}
2. MULTIPLE SELECT ("multiple-select")
Student selects ALL correct answers (checkboxes).
Required fields:
"choices": [{ "id": "a", "text": "Option A" }, ...]
"answer": ["a", "c"] ← array of correct choice ids
Example:
{
"id": "q2",
"type": "multiple-select",
"prompt": "Which are prime numbers?",
"choices": [
{ "id": "a", "text": "2" },
{ "id": "b", "text": "4" },
{ "id": "c", "text": "7" },
{ "id": "d", "text": "9" }
],
"answer": ["a", "c"]
}
3. SHORT ANSWER ("short-answer")
Student types a short text answer.
Required fields:
"answer": "string" — The correct answer
Optional fields:
"acceptedForms": ["alt1", "alt2"] — Other accepted answers
"caseSensitive": true|false (default: false)
"ignorePunctuation": true|false (default: false)
"ignoreAccents": true|false (default: false)
Example:
{
"id": "q3",
"type": "short-answer",
"prompt": "What element has the chemical symbol 'O'?",
"answer": "Oxygen",
"caseSensitive": false
}
4. TRANSLATION ("translation")
Student translates text between languages.
Required fields:
"answer": "string" — The correct translation
"sourceLanguage": "string" — e.g. "English"
"targetLanguage": "string" — e.g. "Spanish"
Optional fields:
"acceptedForms": ["alt translation 1", "alt translation 2"]
"caseSensitive": true|false
"ignorePunctuation": true|false
"ignoreAccents": true|false
Example:
{
"id": "q4",
"type": "translation",
"sourceLanguage": "English",
"targetLanguage": "Spanish",
"prompt": "Translate: \\"Good morning, how are you?\\"",
"answer": "Buenos días, ¿cómo estás?",
"acceptedForms": ["Buenos dias, como estas"],
"caseSensitive": false,
"ignorePunctuation": true,
"ignoreAccents": true
}
5. MATH INPUT ("math-input")
Student enters a LaTeX math expression.
Required fields:
"answer": "string" — Correct LaTeX expression
Optional fields:
"acceptedForms": ["alt LaTeX 1", "alt LaTeX 2"]
Example:
{
"id": "q5",
"type": "math-input",
"prompt": "Compute the derivative of $x^3$.",
"answer": "3x^2",
"acceptedForms": ["3x^{2}", "3 x^2"]
}
6. ESSAY ("essay")
Student writes a long-form response. Graded manually (always marked "ungraded").
Optional fields:
"minWords": number — Minimum word count required
"rubric": ["criterion 1", "criterion 2"] — Grading rubric shown during review
Example:
{
"id": "q6",
"type": "essay",
"prompt": "Discuss the causes of World War I.",
"points": 10,
"minWords": 100,
"rubric": [
"Mentions alliance systems",
"Discusses assassination of Archduke Franz Ferdinand",
"Addresses nationalism and imperialism",
"Well-structured argument"
]
}
7. MATCH ("match")
Student matches items from a left column to items in a right column.
Required fields:
"left": [{ "id": "l1", "text": "Item A" }, { "id": "l2", "text": "Item B" }, ...]
"right": [{ "id": "r1", "text": "Match 1" }, { "id": "r2", "text": "Match 2" }, ...]
"answer": { "l1": "r2", "l2": "r1" } ← maps each left id to its correct right id
Example:
{
"id": "q7",
"type": "match",
"prompt": "Match the country to its capital.",
"left": [
{ "id": "l1", "text": "France" },
{ "id": "l2", "text": "Japan" },
{ "id": "l3", "text": "Brazil" }
],
"right": [
{ "id": "r1", "text": "Brasília" },
{ "id": "r2", "text": "Tokyo" },
{ "id": "r3", "text": "Paris" }
],
"answer": { "l1": "r3", "l2": "r2", "l3": "r1" }
}
8. ORDER ("order")
Student arranges items in the correct sequence (drag & drop).
Required fields:
"items": [{ "id": "s1", "text": "Step 1" }, { "id": "s2", "text": "Step 2" }, ...]
"answer": ["s1", "s2", ...] ← array of item ids in the correct order
Example:
{
"id": "q8",
"type": "order",
"prompt": "Put these historical events in chronological order.",
"items": [
{ "id": "e1", "text": "Moon landing (1969)" },
{ "id": "e2", "text": "Fall of the Berlin Wall (1989)" },
{ "id": "e3", "text": "World Wide Web invented (1991)" },
{ "id": "e4", "text": "First iPhone released (2007)" }
],
"answer": ["e1", "e2", "e3", "e4"]
}
────────────────────────────────────
LATEX SUPPORT
────────────────────────────────────
Use LaTeX math in prompts, choices, and explanations:
- Inline math: $x^2 + y^2 = z^2$
- Display math: $$\\\\int_0^1 x^2 \\\\, dx = \\\\frac{1}{3}$$
Remember to double-escape backslashes in JSON strings:
"prompt": "Evaluate $\\\\frac{d}{dx} x^2$"
────────────────────────────────────
MEDIA ATTACHMENTS
────────────────────────────────────
You can attach images, audio, code blocks, tables, and SVGs to any question.
Use publicly accessible URLs for image and audio sources.
Image: "image": { "src": "https://example.com/photo.jpg", "alt": "Description", "caption": "Optional caption" }
Audio: "audio": { "src": "https://example.com/clip.mp3" }
Code: "code": { "language": "python", "content": "def hello():\\n print('hi')" }
Table: "table": { "headers": ["Name", "Value"], "rows": [["A", "1"], ["B", "2"]] }
────────────────────────────────────
COMPLETE MINI EXAMPLE
────────────────────────────────────
{
"title": "Biology 101 — Cell Structure Quiz",
"subtitle": "Chapters 3-4 review",
"author": "Prof. Smith",
"timeLimitMinutes": 15,
"feedbackMode": "end",
"passingScore": 70,
"categories": ["Cells", "Organelles"],
"questions": [
{
"id": "q1",
"type": "multiple-choice",
"category": "Cells",
"points": 2,
"prompt": "Which organelle is known as the 'powerhouse of the cell'?",
"choices": [
{ "id": "a", "text": "Nucleus" },
{ "id": "b", "text": "Mitochondria" },
{ "id": "c", "text": "Ribosome" },
{ "id": "d", "text": "Golgi apparatus" }
],
"answer": "b",
"explanation": "Mitochondria produce ATP through cellular respiration."
},
{
"id": "q2",
"type": "short-answer",
"category": "Organelles",
"points": 1,
"prompt": "What is the gel-like fluid inside a cell called?",
"answer": "cytoplasm",
"acceptedForms": ["Cytoplasm", "cytosol"],
"caseSensitive": false
},
{
"id": "q3",
"type": "order",
"category": "Cells",
"points": 3,
"prompt": "Order these from smallest to largest:",
"items": [
{ "id": "a", "text": "Atom" },
{ "id": "b", "text": "Molecule" },
{ "id": "c", "text": "Organelle" },
{ "id": "d", "text": "Cell" }
],
"answer": ["a", "b", "c", "d"]
}
]
}
────────────────────────────────────
TIPS FOR LLM PROMPTING
────────────────────────────────────
When asking your LLM to create an exam, include:
1. This document (paste it in full)
2. The subject matter or source material
3. How many questions you want
4. Which question types to use
5. Desired difficulty level
6. Whether to include a time limit
Example prompt: "Using the attached format guide, create a 10-question exam on
World War II. Use a mix of multiple-choice, short-answer, and ordering questions.
Set a 20-minute time limit. Include explanations for each answer."
`
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'brainwave-exam-format-guide.txt'
a.click()
URL.revokeObjectURL(url)
}
+65
View File
@@ -0,0 +1,65 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import katex from 'katex'
import { escapeHtml } from './latex'
marked.use({
breaks: true,
gfm: true,
})
const ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'a',
'span', 'div', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr',
'sub', 'sup', 'del', 's', 'ins',
]
const ALLOWED_ATTR = ['href', 'src', 'alt', 'title', 'class', 'style']
/**
* Render markdown text to HTML, preserving LaTeX math expressions.
* Math ($inline$ and $$block$$) is extracted before markdown parsing
* and rendered with KaTeX afterwards. The final HTML is sanitized
* with DOMPurify.
*/
export function renderMarkdown(text: string): string {
if (!text) return ''
let rest = String(text)
const mathPlaceholders: { type: 'block' | 'inline'; tex: string }[] = []
// Extract block math $$...$$
rest = rest.replace(/\$\$([\s\S]+?)\$\$/g, (_, tex: string) => {
const idx = mathPlaceholders.length
mathPlaceholders.push({ type: 'block', tex })
return `\x00MATH${idx}\x00`
})
// Extract inline math $...$
rest = rest.replace(/\$([^$\n]+?)\$/g, (_, tex: string) => {
const idx = mathPlaceholders.length
mathPlaceholders.push({ type: 'inline', tex })
return `\x00MATH${idx}\x00`
})
// Render markdown (synchronous)
const mdHtml = marked.parse(rest, { async: false }) as string
// Replace placeholders with rendered math
let result = mdHtml.replace(/\x00MATH(\d+)\x00/g, (_, i) => {
const { type, tex } = mathPlaceholders[Number(i)]
try {
const opts = { displayMode: type === 'block', throwOnError: false }
return katex.renderToString(tex, opts)
} catch {
return `<code>${escapeHtml(tex)}</code>`
}
})
// Sanitize the final HTML
return DOMPurify.sanitize(result, {
ALLOWED_TAGS,
ALLOWED_ATTR,
})
}
+15
View File
@@ -7,10 +7,25 @@
"": { "": {
"name": "brainwave", "name": "brainwave",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"marked": "^18.0.3"
},
"devDependencies": { "devDependencies": {
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }
}, },
"node_modules/marked": {
"version": "18.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
"integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+3
View File
@@ -4,5 +4,8 @@
"version": "0.0.1", "version": "0.0.1",
"devDependencies": { "devDependencies": {
"typescript": "^6.0.3" "typescript": "^6.0.3"
},
"dependencies": {
"marked": "^18.0.3"
} }
} }