Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a37209f72 | ||
|
|
25e590639a | ||
|
|
7849c295ef | ||
|
|
f6d97baeb5 | ||
|
|
b58500901a | ||
|
|
e906951b06 | ||
|
|
a78fb8efc3 |
No files matched your search
@@ -20,6 +20,14 @@ const generateSchema = z.object({
|
||||
'essay',
|
||||
'match',
|
||||
'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),
|
||||
language: z.string().min(1).max(20),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { validateBody } from '../middleware/validate.js'
|
||||
import { validateQuery } from '../middleware/validate.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import * as authService from '../services/authService.js'
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
updateProfileSchema,
|
||||
changePasswordSchema,
|
||||
verifyEmailSchema,
|
||||
resendVerificationSchema,
|
||||
resetPasswordSchema,
|
||||
forgotPasswordSchema,
|
||||
} from '../validators/authSchemas.js'
|
||||
@@ -28,6 +30,14 @@ const authLimiter = rateLimit({
|
||||
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
|
||||
router.post(
|
||||
'/register',
|
||||
@@ -86,13 +96,24 @@ router.post(
|
||||
// GET /api/auth/verify
|
||||
router.get(
|
||||
'/verify',
|
||||
validateBody(verifyEmailSchema),
|
||||
validateQuery(verifyEmailSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
await authService.verifyEmail(req.body)
|
||||
await authService.verifyEmail(req.query as { token: string })
|
||||
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
|
||||
router.post(
|
||||
'/forgot-password',
|
||||
|
||||
@@ -10,6 +10,14 @@ export type QuestionType =
|
||||
| 'essay'
|
||||
| 'match'
|
||||
| 'order'
|
||||
| 'fill-in-the-blanks'
|
||||
| 'true-false'
|
||||
| 'code-submission'
|
||||
| 'dropdown-completion'
|
||||
| 'numeric-range'
|
||||
| 'dictation'
|
||||
| 'grammar-correction'
|
||||
| 'flashcard'
|
||||
|
||||
const choiceSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
@@ -31,6 +39,14 @@ const generatedQuestionSchema = z.object({
|
||||
'essay',
|
||||
'match',
|
||||
'order',
|
||||
'fill-in-the-blanks',
|
||||
'true-false',
|
||||
'code-submission',
|
||||
'dropdown-completion',
|
||||
'numeric-range',
|
||||
'dictation',
|
||||
'grammar-correction',
|
||||
'flashcard',
|
||||
]),
|
||||
prompt: z.string().min(1),
|
||||
category: z.string().optional(),
|
||||
@@ -47,6 +63,35 @@ const generatedQuestionSchema = z.object({
|
||||
left: z.array(matchItemSchema).optional(),
|
||||
right: 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>
|
||||
@@ -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).`
|
||||
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).`
|
||||
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).`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
if (!email) {
|
||||
throw new AppError(400, 'Email is required')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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', () => {
|
||||
it('accepts valid registration', () => {
|
||||
@@ -11,6 +11,15 @@ describe('registerSchema', () => {
|
||||
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', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
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', () => {
|
||||
it('accepts valid reset', () => {
|
||||
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'NewPass123!' })
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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({
|
||||
email: z.string().email().max(255),
|
||||
password: z.string()
|
||||
.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),
|
||||
})
|
||||
|
||||
@@ -19,11 +19,15 @@ export const verifyEmailSchema = z.object({
|
||||
token: z.string().min(1, 'Token is required'),
|
||||
})
|
||||
|
||||
export const resendVerificationSchema = z.object({
|
||||
email: z.string().email(),
|
||||
})
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
token: z.string().min(1, 'Token is required'),
|
||||
newPassword: z.string()
|
||||
.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({
|
||||
@@ -34,7 +38,7 @@ export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string(),
|
||||
newPassword: z.string()
|
||||
.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({
|
||||
@@ -44,6 +48,7 @@ export const forgotPasswordSchema = z.object({
|
||||
export type RegisterInput = z.infer<typeof registerSchema>
|
||||
export type LoginInput = z.infer<typeof loginSchema>
|
||||
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>
|
||||
export type ResendVerificationInput = z.infer<typeof resendVerificationSchema>
|
||||
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>
|
||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>
|
||||
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>
|
||||
@@ -62,6 +62,9 @@ describe('examSchema', () => {
|
||||
const types = [
|
||||
'multiple-choice', 'multiple-select', 'short-answer', 'translation',
|
||||
'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) {
|
||||
const result = examSchema.safeParse({
|
||||
@@ -75,7 +78,7 @@ describe('examSchema', () => {
|
||||
it('rejects unsupported question type', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
questions: [{ id: '1', type: 'true-false', prompt: 'Q' }],
|
||||
questions: [{ id: '1', type: 'file-upload', prompt: 'Q' }],
|
||||
})
|
||||
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({
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -30,6 +30,33 @@ const questionTableSchema = z.object({
|
||||
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({
|
||||
id: z.string(),
|
||||
type: z.enum([
|
||||
@@ -41,6 +68,14 @@ const questionSchema = z.object({
|
||||
'essay',
|
||||
'match',
|
||||
'order',
|
||||
'fill-in-the-blanks',
|
||||
'true-false',
|
||||
'code-submission',
|
||||
'dropdown-completion',
|
||||
'numeric-range',
|
||||
'dictation',
|
||||
'grammar-correction',
|
||||
'flashcard',
|
||||
]),
|
||||
category: z.string().optional(),
|
||||
points: z.number().optional(),
|
||||
@@ -51,7 +86,7 @@ const questionSchema = z.object({
|
||||
code: questionCodeSchema.optional(),
|
||||
table: questionTableSchema.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(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
ignorePunctuation: z.boolean().optional(),
|
||||
@@ -64,6 +99,12 @@ const questionSchema = z.object({
|
||||
left: z.array(matchItemSchema).optional(),
|
||||
right: 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({
|
||||
|
||||
@@ -13,6 +13,11 @@ describe('setupSchema', () => {
|
||||
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', () => {
|
||||
const result = setupSchema.safeParse({
|
||||
...baseValid,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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({
|
||||
email: z.string().email().max(255),
|
||||
password: z.string()
|
||||
.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),
|
||||
siteTitle: z.string().min(1, 'Site title is required').max(255).optional(),
|
||||
language: z.enum(['en', 'nl']).optional(),
|
||||
|
||||
Generated
+13
@@ -12,6 +12,7 @@
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"katex": "^0.16.45",
|
||||
"marked": "^18.0.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-i18next": "^15.4.1",
|
||||
@@ -2898,6 +2899,18 @@
|
||||
"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": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"katex": "^0.16.45",
|
||||
"marked": "^18.0.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-i18next": "^15.4.1",
|
||||
|
||||
@@ -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 |
@@ -38,7 +38,11 @@ export function changePassword(data: { currentPassword: string; newPassword: str
|
||||
}
|
||||
|
||||
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 }> {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { ApiError } from '../utils/api'
|
||||
import * as authApi from '../api/authApi'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
@@ -10,6 +11,9 @@ interface Props {
|
||||
|
||||
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>) {
|
||||
const { t } = useTranslation()
|
||||
const { login, register } = useAuth()
|
||||
@@ -20,22 +24,50 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
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) => {
|
||||
setTab(t)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
setNotice(null)
|
||||
setVerificationEmail('')
|
||||
setVerificationResendAvailableAt(null)
|
||||
}
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(email, password)
|
||||
onClose()
|
||||
} 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'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
@@ -45,6 +77,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('auth.passwordsDoNotMatch'))
|
||||
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 (
|
||||
<div className="auth-overlay" onClick={onClose}>
|
||||
<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>}
|
||||
{success && <div className="auth-success">{success}</div>}
|
||||
{notice && <div className="auth-success">{notice}</div>}
|
||||
|
||||
{!success && tab === 'login' && (
|
||||
<form onSubmit={(e) => void handleLogin(e)} className="auth-form">
|
||||
@@ -133,6 +184,25 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
>
|
||||
{t('auth.forgotPassword')}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@ const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [
|
||||
{ value: 'essay', labelKey: 'editor.essay' },
|
||||
{ value: 'match', labelKey: 'editor.match' },
|
||||
{ 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 = {
|
||||
@@ -40,6 +48,22 @@ function makeBlankQuestion(type: QuestionType, index: number): Question {
|
||||
return { ...base, left: [], right: [], answer: {} }
|
||||
case 'order':
|
||||
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:
|
||||
return base
|
||||
}
|
||||
@@ -529,6 +553,14 @@ function QuestionEditor({ question, onChange }: QEProps) {
|
||||
{question.type === 'essay' && <EssayEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'match' && <MatchEditor 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>
|
||||
)
|
||||
}
|
||||
@@ -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 ─── */
|
||||
|
||||
function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
@@ -973,3 +1322,62 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -242,7 +242,7 @@ export default function ExamRunner({
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<Tex tag="div" className="q-prompt">
|
||||
<Tex tag="div" className="q-prompt" markdown>
|
||||
{q.prompt}
|
||||
</Tex>
|
||||
<QuestionMedia q={q} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Exam, HistoryEntry } from '../types'
|
||||
import { validateExam, parseExam } from '../utils/validation'
|
||||
import { loadState } from '../utils/storage'
|
||||
import { formatDate, formatTime } from '../utils/formatting'
|
||||
import { downloadInstructions } from '../utils/instructions'
|
||||
|
||||
interface Props {
|
||||
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()
|
||||
|
||||
return (
|
||||
|
||||
@@ -200,7 +200,7 @@ export default function Results({ exam, state, onRestart, onExit, onRedoFailed,
|
||||
{openIdx === i && (
|
||||
<div className="review-body">
|
||||
<div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}>
|
||||
<Tex>{q.prompt}</Tex>
|
||||
<Tex markdown>{q.prompt}</Tex>
|
||||
</div>
|
||||
<QuestionMedia q={q} />
|
||||
<QuestionRenderer key={q.id} q={q} value={answers[q.id]} onChange={() => {}} locked graded />
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import type React from 'react'
|
||||
import { renderLatex } from '../utils/latex'
|
||||
import { renderMarkdown } from '../utils/markdown'
|
||||
|
||||
interface TexProps {
|
||||
children?: string | null
|
||||
tag?: keyof React.JSX.IntrinsicElements
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
markdown?: boolean
|
||||
}
|
||||
|
||||
// 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 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 }} />
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import * as examApi from '../api/examApi'
|
||||
import * as resultApi from '../api/resultApi'
|
||||
import { downloadInstructions } from '../utils/instructions'
|
||||
import type { Exam } from '../types'
|
||||
import MyExams from './MyExams'
|
||||
import MyResults from './MyResults'
|
||||
@@ -96,6 +97,20 @@ export default function Workspace({ onLoadExam }: Props) {
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
|
||||
<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 Match from './Match'
|
||||
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 {
|
||||
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} />
|
||||
case 'order':
|
||||
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:
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
"downloadInstructions": "Download Instructions — How to create your own exam JSON",
|
||||
"downloadInstructionsIcon": "↓",
|
||||
"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",
|
||||
"resumeTitle": "Resume in-progress exam",
|
||||
"resumeProgress": "{{answered}}/{{total}} answered",
|
||||
@@ -62,6 +62,10 @@
|
||||
"loginFailed": "Login failed",
|
||||
"registrationFailed": "Registration failed",
|
||||
"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",
|
||||
"adminDashboard": "Admin Dashboard",
|
||||
"userMenu": "User menu",
|
||||
@@ -198,6 +202,7 @@
|
||||
"importing": "Importing…",
|
||||
"viewAllResults": "View all results",
|
||||
"recentResults": "Recent Results",
|
||||
"downloadInstructions": "Download Instructions",
|
||||
"viewAll": "View all",
|
||||
"noAttempts": "No attempts yet",
|
||||
"noAttemptsDesc": "Take an exam to see your results here.",
|
||||
@@ -380,6 +385,23 @@
|
||||
"essay": "Essay",
|
||||
"match": "Match",
|
||||
"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",
|
||||
"correctAnswerPlaceholder": "Enter the correct answer",
|
||||
"acceptedForms": "Accepted Forms (optional alternatives)",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"downloadInstructions": "Download Instructies — Hoe maak je je eigen examen-JSON",
|
||||
"downloadInstructionsIcon": "↓",
|
||||
"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",
|
||||
"resumeTitle": "Hervat lopend examen",
|
||||
"resumeProgress": "{{answered}}/{{total}} beantwoord",
|
||||
@@ -62,6 +62,10 @@
|
||||
"loginFailed": "Inloggen mislukt",
|
||||
"registrationFailed": "Registratie mislukt",
|
||||
"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",
|
||||
"adminDashboard": "Admin Dashboard",
|
||||
"userMenu": "Gebruikersmenu",
|
||||
@@ -197,6 +201,7 @@
|
||||
"importing": "Importeren…",
|
||||
"viewAllResults": "Alle resultaten bekijken",
|
||||
"recentResults": "Recente Resultaten",
|
||||
"downloadInstructions": "Download Instructies",
|
||||
"viewAll": "Alles bekijken",
|
||||
"noAttempts": "Nog geen pogingen",
|
||||
"noAttemptsDesc": "Maak een examen om hier je resultaten te zien.",
|
||||
@@ -378,6 +383,23 @@
|
||||
"essay": "Opstel",
|
||||
"match": "Koppelen",
|
||||
"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",
|
||||
"correctAnswerPlaceholder": "Voer het correcte antwoord in",
|
||||
"acceptedForms": "Geaccepteerde vormen (optionele alternatieven)",
|
||||
|
||||
@@ -993,6 +993,185 @@ select {
|
||||
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 ---------- */
|
||||
.answer-input-wrap {
|
||||
display: flex;
|
||||
@@ -2350,6 +2529,25 @@ select {
|
||||
top: 57px;
|
||||
height: calc(100vh - 57px);
|
||||
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 {
|
||||
@@ -3215,3 +3413,156 @@ select {
|
||||
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
@@ -8,7 +8,42 @@ export type QuestionType =
|
||||
| "math-input"
|
||||
| "essay"
|
||||
| "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 {
|
||||
id: string;
|
||||
@@ -52,7 +87,7 @@ export interface Question {
|
||||
code?: QuestionCode;
|
||||
table?: QuestionTable;
|
||||
svg?: string;
|
||||
answer?: string | string[] | Record<string, string>;
|
||||
answer?: string | string[] | Record<string, string> | boolean | number;
|
||||
acceptedForms?: string[];
|
||||
caseSensitive?: boolean;
|
||||
ignorePunctuation?: boolean;
|
||||
@@ -65,6 +100,12 @@ export interface Question {
|
||||
left?: MatchItem[];
|
||||
right?: MatchItem[];
|
||||
items?: MatchItem[];
|
||||
textSegments?: string[];
|
||||
blanks?: Blank[];
|
||||
dropdowns?: Dropdown[];
|
||||
codeConfig?: CodeSubmissionConfig;
|
||||
numericConfig?: NumericConfig;
|
||||
textToCorrect?: string;
|
||||
}
|
||||
|
||||
export interface Exam {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ApiError } from './api'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { apiFetch, ApiError } from './api'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('ApiError', () => {
|
||||
it('extends Error', () => {
|
||||
@@ -18,3 +22,19 @@ describe('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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -40,7 +40,8 @@ export async function apiFetch<T = unknown>(
|
||||
|
||||
if (!res.ok) {
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Question, GradeResult } from '../types'
|
||||
import type { Question, GradeResult, Blank } from '../types'
|
||||
|
||||
interface NormalizeOpts {
|
||||
caseSensitive?: boolean
|
||||
@@ -6,6 +6,12 @@ interface NormalizeOpts {
|
||||
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 => {
|
||||
if (s == null) return ''
|
||||
let out = String(s)
|
||||
@@ -100,6 +106,73 @@ export const gradeQuestion = (q: Question, userAnswer: unknown): GradeResult =>
|
||||
}
|
||||
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:
|
||||
return { status: 'unanswered', correct: false, points: 0 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Generated
+15
@@ -7,10 +7,25 @@
|
||||
"": {
|
||||
"name": "brainwave",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"marked": "^18.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"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": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"version": "0.0.1",
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^18.0.3"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user