Add internationalization support with English and Dutch translations

- Integrated i18next for language detection and translation support.
- Added English and Dutch JSON translation files for various app sections.
- Updated TweaksPanel and Workspace components to use translated strings.
- Imported i18n configuration in main entry file.
This commit is contained in:
LaurensBartelds committed 2026-04-26 21:19:47 +02:00
1 parent c64cdcd9e3
commit 6277826472
19 files changed
+1193 -369

No files matched your search

+90 -80
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import * as adminApi from "../api/adminApi";
import type { DashboardStats, SiteSettings, AdminUser } from "../api/adminApi";
@@ -9,6 +10,7 @@ interface Props {
}
export default function AdminDashboard({ onExit }: Readonly<Props>) {
const { t } = useTranslation();
const [tab, setTab] = useState<AdminTab>("overview");
const [stats, setStats] = useState<DashboardStats | null>(null);
const [settings, setSettings] = useState<SiteSettings | null>(null);
@@ -69,7 +71,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
try {
await adminApi.updateSettings(settingsDraft);
setSettings(settingsDraft);
setSaveMsg("Settings saved successfully");
setSaveMsg(t("admin.settingsSaved"));
setTimeout(() => setSaveMsg(null), 3000);
} catch (e) {
setError((e as Error).message);
@@ -113,7 +115,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
});
setTestEmailMsg({
ok: true,
text: "Test email sent successfully! Check your inbox.",
text: t("admin.testEmailSent"),
});
} catch (e) {
setTestEmailMsg({ ok: false, text: (e as Error).message });
@@ -139,22 +141,33 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
const formatDate = (ts: number) => {
const d = new Date(ts * 1000);
return d.toLocaleDateString("en-US", {
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
};
const tabLabel = (tName: AdminTab) => {
switch (tName) {
case "overview":
return t("admin.overview");
case "settings":
return t("admin.settings");
case "users":
return t("admin.users");
}
};
return (
<div className="admin">
<div className="admin-header">
<div className="admin-header-left">
<div className="admin-eyebrow">Administration</div>
<h1 className="admin-title">Dashboard</h1>
<div className="admin-eyebrow">{t("admin.administration")}</div>
<h1 className="admin-title">{t("admin.dashboard")}</h1>
</div>
<button className="btn btn-ghost btn-sm" onClick={onExit}>
← Back to site
{t("admin.backToSite")}
</button>
</div>
@@ -168,16 +181,16 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
)}
<div className="admin-tabs">
{(["overview", "settings", "users"] as AdminTab[]).map((t) => (
{(["overview", "settings", "users"] as AdminTab[]).map((tName) => (
<button
key={t}
className={`admin-tab${tab === t ? " active" : ""}`}
onClick={() => setTab(t)}
key={tName}
className={`admin-tab${tab === tName ? " active" : ""}`}
onClick={() => setTab(tName)}
>
<span className="admin-tab-icon">
{t === "overview" ? "◈" : t === "settings" ? "⚙" : "⊕"}
{tName === "overview" ? "◈" : tName === "settings" ? "⚙" : "⊕"}
</span>
{t.charAt(0).toUpperCase() + t.slice(1)}
{tabLabel(tName)}
</button>
))}
</div>
@@ -192,7 +205,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-stat-value">
{stats?.totalUsers ?? "—"}
</div>
<div className="admin-stat-label">Total Users</div>
<div className="admin-stat-label">{t("admin.totalUsers")}</div>
</div>
</div>
<div className="admin-stat-card">
@@ -201,7 +214,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-stat-value">
{stats?.totalExams ?? "—"}
</div>
<div className="admin-stat-label">Saved Exams</div>
<div className="admin-stat-label">{t("admin.savedExams")}</div>
</div>
</div>
<div className="admin-stat-card">
@@ -210,7 +223,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-stat-value">
{stats?.totalResults ?? "—"}
</div>
<div className="admin-stat-label">Exams Taken</div>
<div className="admin-stat-label">{t("admin.examsTaken")}</div>
</div>
</div>
<div className="admin-stat-card">
@@ -219,18 +232,18 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-stat-value">
{stats ? `${stats.averageScore}%` : "—"}
</div>
<div className="admin-stat-label">Avg. Score</div>
<div className="admin-stat-label">{t("admin.avgScore")}</div>
</div>
</div>
</div>
<div className="admin-panel">
<div className="admin-panel-header">
<h3>Recent Activity</h3>
<h3>{t("admin.recentActivity")}</h3>
</div>
<div className="admin-panel-body">
{!stats?.recentResults?.length && (
<div className="admin-empty">No recent activity yet</div>
<div className="admin-empty">{t("admin.noRecentActivity")}</div>
)}
{stats?.recentResults?.map((r) => {
const pct = r.total_points
@@ -275,7 +288,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-panel">
<div className="admin-panel-header">
<h3>General</h3>
<h3>{t("admin.general")}</h3>
</div>
<div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row">
@@ -283,9 +296,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-site-name"
>
<span className="admin-setting-title">Site Name</span>
<span className="admin-setting-title">{t("admin.siteName")}</span>
<span className="admin-setting-desc">
The name displayed throughout the application
{t("admin.siteNameDesc")}
</span>
</label>
<input
@@ -301,10 +314,10 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
htmlFor="setting-max-upload"
>
<span className="admin-setting-title">
Max Upload Size (MB)
{t("admin.maxUpload")}
</span>
<span className="admin-setting-desc">
Maximum file size for exam JSON uploads
{t("admin.maxUploadDesc")}
</span>
</label>
<input
@@ -322,9 +335,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-default-theme"
>
<span className="admin-setting-title">Default Theme</span>
<span className="admin-setting-title">{t("admin.defaultTheme")}</span>
<span className="admin-setting-desc">
Default theme for new visitors
{t("admin.defaultThemeDesc")}
</span>
</label>
<div className="admin-setting-toggle-group">
@@ -332,13 +345,13 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className={`admin-toggle-btn${settingsDraft.default_theme === "light" ? " active" : ""}`}
onClick={() => updateDraft("default_theme", "light")}
>
☀ Light
☀ {t("tweaks.light")}
</button>
<button
className={`admin-toggle-btn${settingsDraft.default_theme === "dark" ? " active" : ""}`}
onClick={() => updateDraft("default_theme", "dark")}
>
☾ Dark
☾ {t("tweaks.dark")}
</button>
</div>
</div>
@@ -347,14 +360,14 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-panel">
<div className="admin-panel-header">
<h3>Access Control</h3>
<h3>{t("admin.accessControl")}</h3>
</div>
<div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row">
<label className="admin-setting-label">
<span className="admin-setting-title">Allow Guest Mode</span>
<span className="admin-setting-title">{t("admin.allowGuestMode")}</span>
<span className="admin-setting-desc">
Let unauthenticated users take exams without signing in
{t("admin.allowGuestModeDesc")}
</span>
</label>
<button
@@ -376,10 +389,10 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-setting-row">
<label className="admin-setting-label">
<span className="admin-setting-title">
Registration Enabled
{t("admin.registrationEnabled")}
</span>
<span className="admin-setting-desc">
Allow new users to create accounts
{t("admin.registrationEnabledDesc")}
</span>
</label>
<button
@@ -403,7 +416,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-panel">
<div className="admin-panel-header">
<h3>Email / SMTP</h3>
<h3>{t("admin.emailSmtp")}</h3>
</div>
<div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row">
@@ -411,10 +424,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-app-url"
>
<span className="admin-setting-title">App URL</span>
<span className="admin-setting-title">{t("admin.appUrl")}</span>
<span className="admin-setting-desc">
Base URL used in email links (e.g.
https://brainwave.example.com)
{t("admin.appUrlDesc")}
</span>
</label>
<input
@@ -429,9 +441,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-smtp-host"
>
<span className="admin-setting-title">SMTP Host</span>
<span className="admin-setting-title">{t("admin.smtpHost")}</span>
<span className="admin-setting-desc">
Hostname of your SMTP server
{t("admin.smtpHostDesc")}
</span>
</label>
<input
@@ -447,9 +459,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-smtp-port"
>
<span className="admin-setting-title">SMTP Port</span>
<span className="admin-setting-title">{t("admin.smtpPort")}</span>
<span className="admin-setting-desc">
Common ports: 587 (STARTTLS), 465 (SSL), 25 (plain)
{t("admin.smtpPortDesc")}
</span>
</label>
<input
@@ -467,9 +479,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-smtp-user"
>
<span className="admin-setting-title">Username</span>
<span className="admin-setting-title">{t("admin.username")}</span>
<span className="admin-setting-desc">
SMTP authentication username
{t("admin.usernameDesc")}
</span>
</label>
<input
@@ -485,9 +497,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-smtp-pass"
>
<span className="admin-setting-title">Password</span>
<span className="admin-setting-title">{t("admin.password")}</span>
<span className="admin-setting-desc">
SMTP authentication password
{t("admin.passwordDesc")}
</span>
</label>
<input
@@ -504,9 +516,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-smtp-from"
>
<span className="admin-setting-title">From Address</span>
<span className="admin-setting-title">{t("admin.fromAddress")}</span>
<span className="admin-setting-desc">
The sender name and address shown in emails
{t("admin.fromAddressDesc")}
</span>
</label>
<input
@@ -519,10 +531,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div>
<div className="admin-setting-row">
<label className="admin-setting-label">
<span className="admin-setting-title">TLS Mode</span>
<span className="admin-setting-title">{t("admin.tlsMode")}</span>
<span className="admin-setting-desc">
Auto detects from port (465→SSL). Use explicit override if
needed.
{t("admin.tlsModeDesc")}
</span>
</label>
<div className="admin-setting-toggle-group">
@@ -530,34 +541,33 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className={`admin-toggle-btn${settingsDraft.smtp_secure === "auto" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "auto")}
>
Auto
{t("admin.auto")}
</button>
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "true" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "true")}
>
Force TLS
{t("admin.forceTls")}
</button>
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "false" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "false")}
>
Off
{t("admin.off")}
</button>
</div>
</div>
<div className="admin-setting-row admin-setting-row-stacked">
<div className="admin-setting-label">
<span className="admin-setting-title">Send Test Email</span>
<span className="admin-setting-title">{t("admin.sendTestEmail")}</span>
<span className="admin-setting-desc">
Sends a test email using the settings above (uses draft
values, no need to save first)
{t("admin.sendTestEmailDesc")}
</span>
</div>
<div className="admin-test-email-row">
<input
className="input admin-setting-input"
placeholder="Recipient address (defaults to your account email)"
placeholder={t("admin.sendTestEmailDesc")}
value={testEmailTo}
onChange={(e) => setTestEmailTo(e.target.value)}
type="email"
@@ -567,7 +577,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
onClick={() => void sendTestEmail()}
disabled={testEmailSending || !settingsDraft?.smtp_host}
>
{testEmailSending ? "Sending…" : "Send Test"}
{testEmailSending ? t("admin.sending") : t("admin.sendTest")}
</button>
</div>
{testEmailMsg && (
@@ -583,14 +593,14 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-panel">
<div className="admin-panel-header">
<h3>Maintenance</h3>
<h3>{t("admin.maintenance")}</h3>
</div>
<div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row">
<label className="admin-setting-label">
<span className="admin-setting-title">Maintenance Mode</span>
<span className="admin-setting-title">{t("admin.maintenanceMode")}</span>
<span className="admin-setting-desc">
Take the site offline for non-admin users
{t("admin.maintenanceModeDesc")}
</span>
</label>
<button
@@ -616,10 +626,10 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
htmlFor="setting-maint-msg"
>
<span className="admin-setting-title">
Maintenance Message
{t("admin.maintenanceMessage")}
</span>
<span className="admin-setting-desc">
Message displayed to visitors during maintenance
{t("admin.maintenanceMessageDesc")}
</span>
</label>
<textarea
@@ -642,14 +652,14 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
onClick={() => setSettingsDraft(settings)}
disabled={!hasChanges}
>
Discard Changes
{t("admin.discardChanges")}
</button>
<button
className="btn btn-primary"
onClick={() => void saveSettings()}
disabled={saving || !hasChanges}
>
{saving ? "Saving…" : "Save Settings"}
{saving ? t("admin.sending") : t("admin.saveSettings")}
</button>
</div>
</div>
@@ -662,16 +672,16 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<form onSubmit={handleUserSearch} className="admin-search-form">
<input
className="input admin-search-input"
placeholder="Search by name or email…"
placeholder={t("admin.searchByNameOrEmail")}
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
/>
<button className="btn btn-sm" type="submit">
Search
{t("admin.search")}
</button>
</form>
<div className="admin-users-count mono">
{usersTotal} user{usersTotal !== 1 ? "s" : ""}
{usersTotal} {usersTotal !== 1 ? t("admin.users") : t("admin.user")}
</div>
</div>
@@ -680,13 +690,13 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<table className="admin-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Verified</th>
<th>Exams</th>
<th>Results</th>
<th>Joined</th>
<th>Actions</th>
<th>{t("admin.user")}</th>
<th>{t("admin.role")}</th>
<th>{t("admin.verified")}</th>
<th>{t("admin.exams")}</th>
<th>{t("results.points")}</th>
<th>{t("admin.joined")}</th>
<th>{t("admin.actions")}</th>
</tr>
</thead>
<tbody>
@@ -732,8 +742,8 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
)
}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="user">{t("admin.user")}</option>
<option value="admin">{t("admin.role")}</option>
</select>
{deleteConfirm === u.id ? (
<div className="admin-delete-confirm">
@@ -741,20 +751,20 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="btn btn-sm admin-btn-danger"
onClick={() => void handleDeleteUser(u.id)}
>
Confirm
{t("common.confirm")}
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setDeleteConfirm(null)}
>
Cancel
{t("common.cancel")}
</button>
</div>
) : (
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(u.id)}
title="Delete user"
title={t("common.delete")}
>
✕
</button>
@@ -766,7 +776,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{users.length === 0 && (
<tr>
<td colSpan={7} className="admin-empty">
No users found
{t("admin.noUsersFound")}
</td>
</tr>
)}
+20 -18
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
import { ApiError } from '../utils/api'
@@ -10,6 +11,7 @@ interface Props {
type Tab = 'login' | 'register'
export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>) {
const { t } = useTranslation()
const { login, register } = useAuth()
const [tab, setTab] = useState<Tab>('login')
const [email, setEmail] = useState('')
@@ -34,7 +36,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
await login(email, password)
onClose()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Login failed')
setError(err instanceof ApiError ? err.message : t('auth.loginFailed'))
} finally {
setSubmitting(false)
}
@@ -44,7 +46,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
e.preventDefault()
setError(null)
if (password !== confirmPassword) {
setError('Passwords do not match')
setError(t('auth.passwordsDoNotMatch'))
return
}
setSubmitting(true)
@@ -52,7 +54,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
const message = await register(email, password, displayName)
setSuccess(message)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Registration failed')
setError(err instanceof ApiError ? err.message : t('auth.registrationFailed'))
} finally {
setSubmitting(false)
}
@@ -61,17 +63,17 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
return (
<div className="auth-overlay" onClick={onClose}>
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
<button className="auth-close" onClick={onClose} aria-label="Close">×</button>
<button className="auth-close" onClick={onClose} aria-label={t('common.close')}>×</button>
<div className="auth-header">
<div className="brand-mark" />
<h2 className="auth-title">
{tab === 'login' ? 'Welcome back' : 'Create account'}
{tab === 'login' ? t('auth.welcomeBack') : t('auth.createAccount')}
</h2>
<p className="auth-subtitle">
{tab === 'login'
? 'Sign in to access your saved exams and results'
: 'Create an account to save your progress'}
? t('auth.signInSubtitle')
: t('auth.registerSubtitle')}
</p>
</div>
@@ -80,13 +82,13 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
className={`auth-tab ${tab === 'login' ? 'active' : ''}`}
onClick={() => switchTab('login')}
>
Sign In
{t('auth.signIn')}
</button>
<button
className={`auth-tab ${tab === 'register' ? 'active' : ''}`}
onClick={() => switchTab('register')}
>
Register
{t('auth.register')}
</button>
</div>
@@ -96,7 +98,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
{!success && tab === 'login' && (
<form onSubmit={(e) => void handleLogin(e)} className="auth-form">
<div className="auth-field">
<label className="auth-label" htmlFor="login-email">Email</label>
<label className="auth-label" htmlFor="login-email">{t('auth.email')}</label>
<input
id="login-email"
className="input"
@@ -109,7 +111,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="login-password">Password</label>
<label className="auth-label" htmlFor="login-password">{t('auth.password')}</label>
<input
id="login-password"
className="input"
@@ -122,14 +124,14 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/>
</div>
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign In'}
{submitting ? t('auth.signingIn') : t('auth.signIn')}
</button>
<button
type="button"
className="auth-forgot"
onClick={() => { onClose(); onForgotPassword() }}
>
Forgot your password?
{t('auth.forgotPassword')}
</button>
</form>
)}
@@ -137,7 +139,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
{!success && tab === 'register' && (
<form onSubmit={(e) => void handleRegister(e)} className="auth-form">
<div className="auth-field">
<label className="auth-label" htmlFor="reg-name">Display Name</label>
<label className="auth-label" htmlFor="reg-name">{t('auth.displayName')}</label>
<input
id="reg-name"
className="input"
@@ -150,7 +152,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="reg-email">Email</label>
<label className="auth-label" htmlFor="reg-email">{t('auth.email')}</label>
<input
id="reg-email"
className="input"
@@ -163,7 +165,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="reg-password">Password</label>
<label className="auth-label" htmlFor="reg-password">{t('auth.password')}</label>
<input
id="reg-password"
className="input"
@@ -177,7 +179,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="reg-confirm">Confirm Password</label>
<label className="auth-label" htmlFor="reg-confirm">{t('auth.confirmPassword')}</label>
<input
id="reg-confirm"
className="input"
@@ -191,7 +193,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/>
</div>
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
{submitting ? 'Creating account…' : 'Create Account'}
{submitting ? t('auth.creatingAccount') : t('auth.register')}
</button>
</form>
)}
+11 -9
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiError } from '../utils/api'
import * as authApi from '../api/authApi'
@@ -7,6 +8,7 @@ interface Props {
}
export default function EmailVerify({ onDone }: Readonly<Props>) {
const { t } = useTranslation()
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [message, setMessage] = useState('')
const called = useRef(false)
@@ -20,7 +22,7 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
if (!token) {
setStatus('error')
setMessage('No verification token found.')
setMessage(t('verify.noToken'))
return
}
@@ -33,9 +35,9 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
})
.catch((err) => {
setStatus('error')
setMessage(err instanceof ApiError ? err.message : 'Verification failed')
setMessage(err instanceof ApiError ? err.message : t('verify.verificationFailed'))
})
}, [])
}, [t])
return (
<div className="verify-page">
@@ -43,27 +45,27 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
{status === 'loading' && (
<>
<div className="verify-icon">⏳</div>
<h2 className="verify-title">Verifying your email…</h2>
<p className="verify-message">Please wait while we confirm your email address.</p>
<h2 className="verify-title">{t('verify.verifying')}</h2>
<p className="verify-message">{t('verify.pleaseWait')}</p>
</>
)}
{status === 'success' && (
<>
<div className="verify-icon success">✓</div>
<h2 className="verify-title">Email Verified</h2>
<h2 className="verify-title">{t('verify.emailVerified')}</h2>
<p className="verify-message">{message}</p>
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
Sign In →
{t('verify.signIn')}
</button>
</>
)}
{status === 'error' && (
<>
<div className="verify-icon error">✗</div>
<h2 className="verify-title">Verification Failed</h2>
<h2 className="verify-title">{t('verify.verificationFailed')}</h2>
<p className="verify-message">{message}</p>
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
Back to Home
{t('verify.backToHome')}
</button>
</>
)}
+84 -75
View File
@@ -1,21 +1,22 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import * as examApi from '../api/examApi'
import type { Exam, Question, QuestionType, Choice } from '../types'
const QUESTION_TYPES: { value: QuestionType; label: string }[] = [
{ value: 'multiple-choice', label: 'Multiple Choice' },
{ value: 'multiple-select', label: 'Multiple Select' },
{ value: 'short-answer', label: 'Short Answer' },
{ value: 'translation', label: 'Translation' },
{ value: 'math-input', label: 'Math Input' },
{ value: 'essay', label: 'Essay' },
{ value: 'match', label: 'Match' },
{ value: 'order', label: 'Order' },
const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [
{ value: 'multiple-choice', labelKey: 'editor.multipleChoice' },
{ value: 'multiple-select', labelKey: 'editor.multipleSelect' },
{ value: 'short-answer', labelKey: 'editor.shortAnswer' },
{ value: 'translation', labelKey: 'editor.translation' },
{ value: 'math-input', labelKey: 'editor.mathInput' },
{ value: 'essay', labelKey: 'editor.essay' },
{ value: 'match', labelKey: 'editor.match' },
{ value: 'order', labelKey: 'editor.order' },
]
const BLANK_EXAM: Exam = {
title: 'Untitled Exam',
title: '',
subtitle: '',
questions: [],
}
@@ -44,6 +45,7 @@ function makeBlankQuestion(type: QuestionType, index: number): Question {
}
export default function ExamEditor() {
const { t } = useTranslation()
const { id: paramId } = useParams<{ id: string }>()
const navigate = useNavigate()
const isNew = paramId === 'new'
@@ -99,11 +101,11 @@ export default function ExamEditor() {
const saveExam = async () => {
if (!exam.title.trim()) {
setError('Exam title is required')
setError(t('editor.examTitleRequired'))
return
}
if (exam.questions.length === 0) {
setError('Add at least one question')
setError(t('editor.atLeastOneQuestion'))
return
}
@@ -112,12 +114,12 @@ export default function ExamEditor() {
try {
if (isNew) {
const data = await examApi.createExam(exam)
setSaveMsg('Exam created successfully')
setSaveMsg(t('editor.examCreated'))
setHasChanges(false)
navigate(`/exam-editor/${data.id}`, { replace: true })
} else {
await examApi.updateExam(examId!, exam)
setSaveMsg('Exam saved')
setSaveMsg(t('editor.examSaved'))
setHasChanges(false)
}
setTimeout(() => setSaveMsg(null), 3000)
@@ -138,20 +140,20 @@ export default function ExamEditor() {
<header className="exam-editor-header">
<div className="exam-editor-header-left">
<button className="btn btn-ghost btn-sm" onClick={() => navigate('/workspace/exams')}>
← Back
{t('editor.back')}
</button>
<input
className="exam-editor-title-input"
value={exam.title}
onChange={(e) => updateExam((d) => { d.title = e.target.value })}
placeholder="Exam title"
placeholder={t('editor.examTitlePlaceholder')}
/>
</div>
<div className="exam-editor-header-right">
{saveMsg && <span className="exam-editor-save-msg">{saveMsg}</span>}
{hasChanges && <span className="exam-editor-unsaved">Unsaved changes</span>}
{hasChanges && <span className="exam-editor-unsaved">{t('editor.unsavedChanges')}</span>}
<button className="btn btn-primary" onClick={() => void saveExam()} disabled={saving}>
{saving ? 'Saving…' : isNew ? 'Create Exam' : 'Save'}
{saving ? t('editor.saving') : isNew ? t('editor.createExam') : t('editor.save')}
</button>
</div>
</header>
@@ -164,12 +166,12 @@ export default function ExamEditor() {
)}
{loading ? (
<div className="exam-editor-loading">Loading…</div>
<div className="exam-editor-loading">{t('common.loading')}</div>
) : (
<div className="exam-editor-body">
{/* Sidebar */}
<aside className="exam-editor-sidebar">
<div className="sidebar-section-label">Questions</div>
<div className="sidebar-section-label">{t('editor.questionsLabel')}</div>
<div className="nav-list">
{exam.questions.map((q, i) => (
<button
@@ -182,7 +184,7 @@ export default function ExamEditor() {
<span
className="exam-editor-q-remove"
onClick={(e) => { e.stopPropagation(); removeQuestion(i) }}
title="Remove"
title={t('common.delete')}
>
✕
</span>
@@ -191,15 +193,15 @@ export default function ExamEditor() {
</div>
<div className="exam-editor-add">
<div className="sidebar-section-label" style={{ marginTop: 24 }}>Add Question</div>
<div className="sidebar-section-label" style={{ marginTop: 24 }}>{t('editor.addQuestion')}</div>
<div className="exam-editor-type-grid">
{QUESTION_TYPES.map((t) => (
{QUESTION_TYPES.map((tType) => (
<button
key={t.value}
key={tType.value}
className="exam-editor-type-btn"
onClick={() => addQuestion(t.value)}
onClick={() => addQuestion(tType.value)}
>
{t.label}
{t(tType.labelKey)}
</button>
))}
</div>
@@ -221,8 +223,8 @@ export default function ExamEditor() {
) : (
<div className="workspace-empty">
<div className="workspace-empty-icon">◇</div>
<div className="workspace-empty-title">No questions yet</div>
<div className="workspace-empty-desc">Select a question type from the sidebar to add your first question.</div>
<div className="workspace-empty-title">{t('editor.noQuestions')}</div>
<div className="workspace-empty-desc">{t('editor.noQuestionsDesc')}</div>
</div>
)}
</main>
@@ -240,6 +242,7 @@ interface QEProps {
}
function QuestionEditor({ question, onChange }: QEProps) {
const { t } = useTranslation()
const update = <K extends keyof Question>(key: K, value: Question[K]) => {
onChange((q) => { q[key] = value })
}
@@ -247,33 +250,33 @@ function QuestionEditor({ question, onChange }: QEProps) {
return (
<div className="question-form">
<div className="question-form-header">
<span className="mono faint" style={{ fontSize: 12 }}>Question Editor</span>
<span className="mono faint" style={{ fontSize: 12 }}>{t('editor.questionEditor')}</span>
<span className="q-category" style={{ marginLeft: 'auto' }}>{question.type}</span>
</div>
<div className="question-form-row">
<label className="question-form-label">Prompt</label>
<label className="question-form-label">{t('editor.prompt')}</label>
<textarea
className="textarea"
value={question.prompt}
onChange={(e) => update('prompt', e.target.value)}
rows={3}
placeholder="Enter the question prompt..."
placeholder={t('editor.promptPlaceholder')}
/>
</div>
<div className="question-form-row-inline">
<div>
<label className="question-form-label">Category (optional)</label>
<label className="question-form-label">{t('editor.categoryOptional')}</label>
<input
className="input"
value={question.category ?? ''}
onChange={(e) => update('category', e.target.value || undefined)}
placeholder="e.g. Algebra"
placeholder={t('editor.categoryPlaceholder')}
/>
</div>
<div style={{ width: 100 }}>
<label className="question-form-label">Points</label>
<label className="question-form-label">{t('editor.points')}</label>
<input
className="input"
type="number"
@@ -285,13 +288,13 @@ function QuestionEditor({ question, onChange }: QEProps) {
</div>
<div className="question-form-row">
<label className="question-form-label">Explanation (optional)</label>
<label className="question-form-label">{t('editor.explanationOptional')}</label>
<textarea
className="textarea"
value={question.explanation ?? ''}
onChange={(e) => update('explanation', e.target.value || undefined)}
rows={2}
placeholder="Explanation shown after grading..."
placeholder={t('editor.explanationPlaceholder')}
/>
</div>
@@ -304,12 +307,12 @@ function QuestionEditor({ question, onChange }: QEProps) {
{question.type === 'translation' && (
<div className="question-form-row-inline">
<div>
<label className="question-form-label">Source Language</label>
<input className="input" value={question.sourceLanguage ?? ''} onChange={(e) => update('sourceLanguage', e.target.value || undefined)} placeholder="e.g. English" />
<label className="question-form-label">{t('editor.sourceLanguage')}</label>
<input className="input" value={question.sourceLanguage ?? ''} onChange={(e) => update('sourceLanguage', e.target.value || undefined)} placeholder={t('editor.sourceLanguagePlaceholder')} />
</div>
<div>
<label className="question-form-label">Target Language</label>
<input className="input" value={question.targetLanguage ?? ''} onChange={(e) => update('targetLanguage', e.target.value || undefined)} placeholder="e.g. Spanish" />
<label className="question-form-label">{t('editor.targetLanguage')}</label>
<input className="input" value={question.targetLanguage ?? ''} onChange={(e) => update('targetLanguage', e.target.value || undefined)} placeholder={t('editor.targetLanguagePlaceholder')} />
</div>
</div>
)}
@@ -323,6 +326,7 @@ function QuestionEditor({ question, onChange }: QEProps) {
/* ─── Multiple Choice ─── */
function MultipleChoiceEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
const choices = q.choices ?? []
const setChoices = (c: Choice[]) => onChange((q) => { q.choices = c })
const setAnswer = (a: string) => onChange((q) => { q.answer = a })
@@ -348,7 +352,7 @@ function MultipleChoiceEditor({ q, onChange }: { q: Question; onChange: QEProps[
return (
<div className="question-form-section">
<label className="question-form-label">Choices</label>
<label className="question-form-label">{t('editor.choices')}</label>
<div className="choice-editor-list">
{choices.map((c, i) => (
<div key={c.id} className="choice-editor-row">
@@ -357,21 +361,21 @@ function MultipleChoiceEditor({ q, onChange }: { q: Question; onChange: QEProps[
name="mc-answer"
checked={q.answer === c.id}
onChange={() => setAnswer(c.id)}
title="Mark as correct"
title={t('editor.markAsCorrect')}
/>
<span className="mono faint" style={{ fontSize: 13, width: 20 }}>{c.id}</span>
<input
className="input"
value={c.text}
onChange={(e) => updateChoice(i, e.target.value)}
placeholder={`Choice ${c.id}`}
placeholder={t('editor.choicePlaceholder', { id: c.id })}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title="Remove">✕</button>
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title={t('common.delete')}>✕</button>
</div>
))}
</div>
<button className="btn btn-sm" onClick={addChoice} style={{ marginTop: 8 }}>
+ Add Choice
{t('editor.addChoice')}
</button>
</div>
)
@@ -380,6 +384,7 @@ function MultipleChoiceEditor({ q, onChange }: { q: Question; onChange: QEProps[
/* ─── Multiple Select ─── */
function MultipleSelectEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
const choices = q.choices ?? []
const answer = (q.answer as string[]) ?? []
const setChoices = (c: Choice[]) => onChange((q) => { q.choices = c })
@@ -409,7 +414,7 @@ function MultipleSelectEditor({ q, onChange }: { q: Question; onChange: QEProps[
return (
<div className="question-form-section">
<label className="question-form-label">Choices (check correct answers)</label>
<label className="question-form-label">{t('editor.choicesCheckCorrect')}</label>
<div className="choice-editor-list">
{choices.map((c, i) => (
<div key={c.id} className="choice-editor-row">
@@ -423,14 +428,14 @@ function MultipleSelectEditor({ q, onChange }: { q: Question; onChange: QEProps[
className="input"
value={c.text}
onChange={(e) => updateChoice(i, e.target.value)}
placeholder={`Choice ${c.id}`}
placeholder={t('editor.choicePlaceholder', { id: c.id })}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title="Remove">✕</button>
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title={t('common.delete')}>✕</button>
</div>
))}
</div>
<button className="btn btn-sm" onClick={addChoice} style={{ marginTop: 8 }}>
+ Add Choice
{t('editor.addChoice')}
</button>
</div>
)
@@ -439,20 +444,21 @@ function MultipleSelectEditor({ q, onChange }: { q: Question; onChange: QEProps[
/* ─── Text Answer (short, translation, math) ─── */
function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
const accepted = q.acceptedForms ?? []
return (
<div className="question-form-section">
<div className="question-form-row">
<label className="question-form-label">Correct Answer</label>
<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="Enter the correct answer"
placeholder={t('editor.correctAnswerPlaceholder')}
/>
</div>
<div className="question-form-row">
<label className="question-form-label">Accepted Forms (optional alternatives)</label>
<label className="question-form-label">{t('editor.acceptedForms')}</label>
{accepted.map((form, i) => (
<div key={i} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
@@ -463,7 +469,7 @@ function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onC
next[i] = e.target.value
onChange((q) => { q.acceptedForms = next })
}}
placeholder="Alternative answer"
placeholder={t('editor.alternativeAnswer')}
/>
<button
className="btn btn-ghost btn-sm"
@@ -481,7 +487,7 @@ function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onC
onClick={() => onChange((q) => { q.acceptedForms = [...(q.acceptedForms ?? []), ''] })}
style={{ marginTop: 4 }}
>
+ Add Alternative
{t('editor.addAlternative')}
</button>
</div>
<div className="question-form-row-inline" style={{ marginTop: 8 }}>
@@ -491,7 +497,7 @@ function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onC
checked={q.caseSensitive ?? false}
onChange={(e) => onChange((q) => { q.caseSensitive = e.target.checked })}
/>
Case sensitive
{t('editor.caseSensitive')}
</label>
<label className="question-form-check">
<input
@@ -499,7 +505,7 @@ function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onC
checked={q.ignorePunctuation ?? false}
onChange={(e) => onChange((q) => { q.ignorePunctuation = e.target.checked })}
/>
Ignore punctuation
{t('editor.ignorePunctuation')}
</label>
<label className="question-form-check">
<input
@@ -507,7 +513,7 @@ function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onC
checked={q.ignoreAccents ?? false}
onChange={(e) => onChange((q) => { q.ignoreAccents = e.target.checked })}
/>
Ignore accents
{t('editor.ignoreAccents')}
</label>
</div>
</div>
@@ -517,12 +523,13 @@ function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onC
/* ─── Essay ─── */
function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
const rubric = q.rubric ?? []
return (
<div className="question-form-section">
<div className="question-form-row-inline">
<div style={{ width: 140 }}>
<label className="question-form-label">Min Words</label>
<label className="question-form-label">{t('editor.minWords')}</label>
<input
className="input"
type="number"
@@ -533,7 +540,7 @@ function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
</div>
</div>
<div className="question-form-row">
<label className="question-form-label">Rubric Criteria</label>
<label className="question-form-label">{t('editor.rubricCriteria')}</label>
{rubric.map((item, i) => (
<div key={i} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
@@ -544,7 +551,7 @@ function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
next[i] = e.target.value
onChange((q) => { q.rubric = next })
}}
placeholder="Criterion"
placeholder={t('editor.criterion')}
/>
<button
className="btn btn-ghost btn-sm"
@@ -562,7 +569,7 @@ function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
onClick={() => onChange((q) => { q.rubric = [...(q.rubric ?? []), ''] })}
style={{ marginTop: 4 }}
>
+ Add Criterion
{t('editor.addCriterion')}
</button>
</div>
</div>
@@ -572,6 +579,7 @@ function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
/* ─── Match ─── */
function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
const left = q.left ?? []
const right = q.right ?? []
const answer = (q.answer as Record<string, string>) ?? {}
@@ -609,7 +617,7 @@ function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
<div className="question-form-section">
<div className="match-editor-grid">
<div>
<label className="question-form-label">Left Column</label>
<label className="question-form-label">{t('editor.leftColumn')}</label>
{left.map((item, i) => (
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
@@ -620,15 +628,15 @@ function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
next[i] = { ...next[i], text: e.target.value }
onChange((q) => { q.left = next })
}}
placeholder={`Left item ${i + 1}`}
placeholder={t('editor.leftItemPlaceholder', { index: i + 1 })}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeLeft(i)}>✕</button>
</div>
))}
<button className="btn btn-sm" onClick={addLeft} style={{ marginTop: 4 }}>+ Add Left</button>
<button className="btn btn-sm" onClick={addLeft} style={{ marginTop: 4 }}>{t('editor.addLeft')}</button>
</div>
<div>
<label className="question-form-label">Right Column</label>
<label className="question-form-label">{t('editor.rightColumn')}</label>
{right.map((item, i) => (
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
@@ -639,18 +647,18 @@ function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
next[i] = { ...next[i], text: e.target.value }
onChange((q) => { q.right = next })
}}
placeholder={`Right item ${i + 1}`}
placeholder={t('editor.rightItemPlaceholder', { index: i + 1 })}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeRight(i)}>✕</button>
</div>
))}
<button className="btn btn-sm" onClick={addRight} style={{ marginTop: 4 }}>+ Add Right</button>
<button className="btn btn-sm" onClick={addRight} style={{ marginTop: 4 }}>{t('editor.addRight')}</button>
</div>
</div>
{left.length > 0 && right.length > 0 && (
<div style={{ marginTop: 16 }}>
<label className="question-form-label">Correct Pairings</label>
<label className="question-form-label">{t('editor.correctPairings')}</label>
{left.map((l) => (
<div key={l.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
<span style={{ minWidth: 120, fontSize: 14 }}>{l.text || l.id}</span>
@@ -665,7 +673,7 @@ function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
onChange((q) => { q.answer = next })
}}
>
<option value="">— Select —</option>
<option value="">{t('editor.select')}</option>
{right.map((r) => (
<option key={r.id} value={r.id}>{r.text || r.id}</option>
))}
@@ -681,6 +689,7 @@ function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
/* ─── Order ─── */
function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
const items = q.items ?? []
const answer = (q.answer as string[]) ?? []
@@ -707,7 +716,7 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
return (
<div className="question-form-section">
<label className="question-form-label">Items (drag not implemented, use arrows to reorder)</label>
<label className="question-form-label">{t('editor.items')}</label>
{items.map((item, i) => (
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
<span className="mono faint" style={{ fontSize: 12, width: 24 }}>{i + 1}</span>
@@ -719,7 +728,7 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
next[i] = { ...next[i], text: e.target.value }
onChange((q) => { q.items = next })
}}
placeholder={`Item ${i + 1}`}
placeholder={t('editor.itemPlaceholder', { index: i + 1 })}
/>
<div className="order-editor-controls">
<button className="btn btn-ghost btn-sm" disabled={i === 0} onClick={() => moveItem(i, -1)}>↑</button>
@@ -728,11 +737,11 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
</div>
</div>
))}
<button className="btn btn-sm" onClick={addItem} style={{ marginTop: 4 }}>+ Add Item</button>
<button className="btn btn-sm" onClick={addItem} style={{ marginTop: 4 }}>{t('editor.addItem')}</button>
{items.length > 0 && (
<div style={{ marginTop: 16 }}>
<label className="question-form-label">Correct Order</label>
<label className="question-form-label">{t('editor.correctOrder')}</label>
<div className="choice-editor-row">
{items.map((item, i) => (
<span key={item.id} className="mono faint" style={{ fontSize: 13, marginRight: 4 }}>
@@ -741,7 +750,7 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
))}
</div>
<p className="faint" style={{ fontSize: 12, marginTop: 4 }}>
The correct order is the order shown above. Reorder items to set the correct sequence.
{t('editor.correctOrderHint')}
</p>
{/* Sync answer to current item order */}
<button
@@ -749,7 +758,7 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
style={{ marginTop: 8 }}
onClick={() => onChange((q) => { q.answer = items.map((it) => it.id) })}
>
Set Current Order as Correct
{t('editor.setCurrentOrder')}
</button>
</div>
)}
+29 -28
View File
@@ -1,5 +1,6 @@
"use no memo";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from 'react-i18next'
import type { Exam, ExamState, Question } from "../types";
import { gradeQuestion } from "../utils/grading";
import { formatTime } from "../utils/formatting";
@@ -26,6 +27,7 @@ export default function ExamRunner({
onNavigate,
n,
}: Readonly<Props>) {
const { t } = useTranslation()
const urlIdx = Math.max(0, (Number((n ?? "").replace(/^q/, "")) || 1) - 1);
const { answers, flags, startedAt, feedbackMode } = state;
const currentIdx = urlIdx;
@@ -151,7 +153,7 @@ export default function ExamRunner({
<h2 className="sidebar-title">{exam.title}</h2>
{exam.subtitle && <p className="sidebar-sub">{exam.subtitle}</p>}
<div className="sidebar-section-label">Questions</div>
<div className="sidebar-section-label">{t('exam.questionsLabel')}</div>
<div className="nav-list">
{exam.questions.map((qq, i) => {
const st = navStatus(qq);
@@ -174,9 +176,9 @@ export default function ExamRunner({
})}
</div>
<div className="sidebar-section-label">Progress</div>
<div className="sidebar-section-label">{t('exam.progressLabel')}</div>
<div className="mono" style={{ fontSize: 13, color: "var(--ink-2)" }}>
{answeredCount} of {total} answered
{t('exam.progressText', { answered: answeredCount, total })}
</div>
<div className="progress-bar">
<div style={{ width: `${(answeredCount / total) * 100}%` }} />
@@ -191,10 +193,10 @@ export default function ExamRunner({
}}
>
<button className="btn btn-primary" onClick={onSubmit}>
Submit exam
{t('exam.submitExam')}
</button>
<button className="btn btn-ghost btn-sm" onClick={onExit}>
Exit to home
{t('exam.exitToHome')}
</button>
</div>
</aside>
@@ -202,12 +204,11 @@ export default function ExamRunner({
<main className="main">
<header className="question-header">
<span className="q-index">
Question {String(currentIdx + 1).padStart(2, "0")} /{" "}
{String(total).padStart(2, "0")}
{t('exam.questionHeader', { current: String(currentIdx + 1).padStart(2, "0"), total: String(total).padStart(2, "0") })}
</span>
{q.category && <span className="q-category">{q.category}</span>}
<span className="q-points">
{q.points ?? 1} {(q.points ?? 1) === 1 ? "pt" : "pts"}
{q.points ?? 1} {(q.points ?? 1) === 1 ? t('common.pt') : t('common.pts')}
</span>
</header>
@@ -227,7 +228,7 @@ export default function ExamRunner({
{graded && result && q.type !== "essay" && (
<div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
<div className="feedback-label">
{result.correct ? "Correct" : "Not quite"}
{result.correct ? t('exam.correct') : t('exam.notQuite')}
</div>
<div className="feedback-body">
{!result.correct &&
@@ -235,7 +236,7 @@ export default function ExamRunner({
q.type !== "match" &&
q.type !== "order" && (
<p>
<strong>Answer:</strong> <Tex>{answerText}</Tex>
<strong>{t('exam.answerLabel')}</strong> <Tex>{answerText}</Tex>
</p>
)}
{q.explanation && (
@@ -253,10 +254,10 @@ export default function ExamRunner({
onClick={() => goTo(currentIdx - 1)}
disabled={currentIdx === 0}
>
← Previous
{t('exam.previous')}
</button>
<button className="btn btn-ghost" onClick={toggleFlag}>
{flags[q.id] ? "⚑ Flagged" : "⚐ Flag for review"}
{flags[q.id] ? t('exam.flagged') : t('exam.flagForReview')}
</button>
<span className="spacer" />
{feedbackMode === "immediate" &&
@@ -264,7 +265,7 @@ export default function ExamRunner({
q.type !== "essay" &&
answered && (
<button className="btn btn-primary" onClick={checkAnswer}>
Check answer
{t('exam.checkAnswer')}
</button>
)}
{currentIdx < total - 1 ? (
@@ -272,11 +273,11 @@ export default function ExamRunner({
className="btn btn-primary"
onClick={() => goTo(currentIdx + 1)}
>
Next →
{t('exam.next')}
</button>
) : (
<button className="btn btn-primary" onClick={onSubmit}>
Submit exam
{t('exam.submitExam')}
</button>
)}
</footer>
@@ -293,15 +294,15 @@ export default function ExamRunner({
>
<span>
<span className="kbd">←</span> <span className="kbd">→</span>{" "}
navigate
{t('exam.navigateHint')}
</span>
<span>
<span className="kbd">F</span> flag
<span className="kbd">F</span> {t('exam.flagHint')}
</span>
{q.type === "multiple-choice" && q.choices && (
<span>
<span className="kbd">1</span>–
<span className="kbd">{q.choices.length}</span> select
<span className="kbd">{q.choices.length}</span> {t('exam.selectHint')}
</span>
)}
</div>
@@ -309,12 +310,12 @@ export default function ExamRunner({
<aside className="meta-rail">
<div className="meta-block">
<div className="meta-label">Elapsed</div>
<div className="meta-label">{t('exam.elapsed')}</div>
<div className="meta-value mono">{formatTime(elapsedMs)}</div>
</div>
{remainingMs != null && (
<div className="meta-block">
<div className="meta-label">Remaining</div>
<div className="meta-label">{t('exam.remaining')}</div>
<div
className={`meta-value mono${timeUrgent ? " timer-urgent" : ""}`}
>
@@ -323,32 +324,32 @@ export default function ExamRunner({
</div>
)}
<div className="meta-block">
<div className="meta-label">Points possible</div>
<div className="meta-label">{t('exam.pointsPossible')}</div>
<div className="meta-value mono">{totalPossible}</div>
</div>
<div className="meta-block">
<div className="meta-label">Mode</div>
<div className="meta-label">{t('exam.mode')}</div>
<div className="meta-value mono" style={{ fontSize: 13 }}>
{feedbackMode === "immediate"
? "Immediate feedback"
: "Grade at end"}
? t('exam.immediateFeedback')
: t('exam.gradeAtEnd')}
</div>
</div>
<div className="meta-block notes-block">
<div className="meta-label">Notes</div>
<div className="meta-label">{t('exam.notes')}</div>
<textarea
className="textarea notes-pad"
value={notes}
onChange={(e) => updateNotes(e.target.value)}
placeholder="Add short notes while you work..."
placeholder={t('exam.notesPlaceholder')}
maxLength={1000}
aria-label="Exam notes"
aria-label={t('exam.notes')}
/>
<div className="notes-count mono">{notes.length}/1000</div>
</div>
{exam.author && (
<div className="meta-block">
<div className="meta-label">Author</div>
<div className="meta-label">{t('exam.author')}</div>
<div style={{ fontSize: 14 }}>{exam.author}</div>
</div>
)}
+21 -19
View File
@@ -1,4 +1,5 @@
import { useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { Exam, HistoryEntry } from '../types'
import { validateExam, parseExam } from '../utils/validation'
import { loadState } from '../utils/storage'
@@ -19,6 +20,7 @@ const SAMPLES = [
]
export default function Landing({ onLoadExam, history, onResume, onClearHistory, onReviewAttempt }: Readonly<Props>) {
const { t } = useTranslation()
const [err, setErr] = useState<string | null>(null)
const [dragOver, setDragOver] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
@@ -32,7 +34,7 @@ export default function Landing({ onLoadExam, history, onResume, onClearHistory,
if (msg) throw new Error(msg)
onLoadExam(parseExam(data))
} catch (e) {
setErr(`Could not load: ${(e as Error).message}`)
setErr(t('landing.loadError', { message: (e as Error).message }))
}
}
@@ -46,7 +48,7 @@ export default function Landing({ onLoadExam, history, onResume, onClearHistory,
if (msg) throw new Error(msg)
onLoadExam(parseExam(data))
} catch (e) {
setErr(`Could not load sample: ${(e as Error).message}`)
setErr(t('landing.loadSampleError', { message: (e as Error).message }))
}
}
@@ -375,10 +377,10 @@ Set a 20-minute time limit. Include explanations for each answer."
return (
<div className="landing">
<div className="landing-eyebrow">BrainWave · practice exam platform</div>
<h1 className="landing-title">Upload an exam. <em>Practice anything.</em></h1>
<div className="landing-eyebrow">{t('landing.eyebrow')}</div>
<h1 className="landing-title">{t('landing.title')}<em>{t('landing.titleEmphasis')}</em></h1>
<p className="landing-sub">
Drop in a JSON file to run a practice exam — math with full LaTeX, language translation, multiple choice, matching, ordering, essays, and more. Progress saves automatically.
{t('landing.subtitle')}
</p>
<div className="landing-grid">
@@ -400,8 +402,8 @@ Set a 20-minute time limit. Include explanations for each answer."
<path d="M12 3v12M7 8l5-5 5 5M5 21h14" />
</svg>
</div>
<h3>Drop your exam JSON here</h3>
<p>or click to browse · max 5 MB</p>
<h3>{t('landing.dropzoneTitle')}</h3>
<p>{t('landing.dropzoneSub')}</p>
<input
ref={fileRef}
type="file"
@@ -417,8 +419,8 @@ Set a 20-minute time limit. Include explanations for each answer."
onClick={downloadInstructions}
id="download-instructions"
>
<span className="download-instructions-icon">↓</span>
Download Instructions — How to create your own exam JSON
<span className="download-instructions-icon">{t('landing.downloadInstructionsIcon')}</span>
{t('landing.downloadInstructions')}
</button>
{err && (
@@ -430,23 +432,23 @@ Set a 20-minute time limit. Include explanations for each answer."
{resumable && (
<div style={{ marginTop: 20, padding: 16, border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)', display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, fontSize: 14 }}>Resume in-progress exam</div>
<div style={{ fontWeight: 500, fontSize: 14 }}>{t('landing.resumeTitle')}</div>
<div className="muted" style={{ fontSize: 12.5, marginTop: 2 }}>
{resumable.exam.title} · {Object.keys(resumable.state.answers ?? {}).length}/{resumable.exam.questions.length} answered
{resumable.exam.title} · {t('landing.resumeProgress', { answered: Object.keys(resumable.state.answers ?? {}).length, total: resumable.exam.questions.length })}
</div>
</div>
<button className="btn btn-primary btn-sm" onClick={onResume}>Resume →</button>
<button className="btn btn-primary btn-sm" onClick={onResume}>{t('landing.resumeButton')}</button>
</div>
)}
<div style={{ marginTop: 20, fontSize: 12.5, color: 'var(--ink-3)', lineHeight: 1.7 }}>
<div className="mono" style={{ textTransform: 'uppercase', letterSpacing: '0.12em', fontSize: 10.5, marginBottom: 6 }}>Supported types</div>
multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order
<div className="mono" style={{ textTransform: 'uppercase', letterSpacing: '0.12em', fontSize: 10.5, marginBottom: 6 }}>{t('landing.supportedTypes')}</div>
{t('landing.supportedTypesList')}
</div>
</div>
<div className="samples">
<h4>Try a sample</h4>
<h4>{t('landing.trySample')}</h4>
{SAMPLES.map((s) => (
<button key={s.path} type="button" className="sample" onClick={() => void loadSample(s.path)}>
<div className="sample-mark serif" style={{ fontSize: 18 }}>{s.mark}</div>
@@ -464,9 +466,9 @@ Set a 20-minute time limit. Include explanations for each answer."
<div className="history">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 16 }}>
<h4 style={{ fontSize: 11, fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', margin: 0, fontWeight: 500 }}>
Past attempts
{t('landing.pastAttempts')}
</h4>
<button className="btn btn-ghost btn-sm" onClick={onClearHistory}>Clear history</button>
<button className="btn btn-ghost btn-sm" onClick={onClearHistory}>{t('landing.clearHistory')}</button>
</div>
{history.map((h) => {
const pct = h.totalPoints ? Math.round((h.scorePoints / h.totalPoints) * 100) : 0
@@ -476,12 +478,12 @@ Set a 20-minute time limit. Include explanations for each answer."
<div>
<div className="history-title">{h.title}</div>
<div className="muted" style={{ fontSize: 12 }}>
{formatDate(h.completedAt)} · {h.correctCount}/{h.gradedCount} correct
{t('landing.dateTimeFormat', { date: formatDate(h.completedAt), correct: h.correctCount, graded: h.gradedCount })}
</div>
</div>
<div className="mono muted" style={{ fontSize: 12 }}>{formatTime(h.elapsedMs)}</div>
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
<button className="btn btn-ghost btn-sm" onClick={() => onReviewAttempt(h)}>Review</button>
<button className="btn btn-ghost btn-sm" onClick={() => onReviewAttempt(h)}>{t('landing.review')}</button>
</div>
)
})}
+15 -13
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import * as examApi from '../api/examApi'
interface SavedExam {
@@ -12,6 +13,7 @@ interface SavedExam {
}
export default function MyExams() {
const { t } = useTranslation()
const navigate = useNavigate()
const [exams, setExams] = useState<SavedExam[]>([])
const [loading, setLoading] = useState(true)
@@ -58,11 +60,11 @@ export default function MyExams() {
<div className="workspace-section fade-in">
<div className="workspace-section-header">
<div>
<div className="workspace-section-eyebrow">My Workspace</div>
<h2 className="workspace-section-title">My Exams</h2>
<div className="workspace-section-eyebrow">{t('workspace.myWorkspace')}</div>
<h2 className="workspace-section-title">{t('workspace.myExams')}</h2>
</div>
<button className="btn btn-primary btn-sm" onClick={createNewExam}>
+ New Exam
+ {t('workspace.newExam')}
</button>
</div>
@@ -74,16 +76,16 @@ export default function MyExams() {
)}
{loading ? (
<div className="workspace-empty">Loading…</div>
<div className="workspace-empty">{t('common.loading')}</div>
) : exams.length === 0 ? (
<div className="workspace-empty">
<div className="workspace-empty-icon">◇</div>
<div className="workspace-empty-title">No saved exams yet</div>
<div className="workspace-empty-title">{t('workspace.noSavedExams')}</div>
<div className="workspace-empty-desc">
Upload an exam from the home page, or create a new one to get started.
{t('workspace.noSavedExamsDesc')}
</div>
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
Create Your First Exam
{t('workspace.createFirstExam')}
</button>
</div>
) : (
@@ -96,9 +98,9 @@ export default function MyExams() {
<div className="workspace-card-sub">{exam.subtitle}</div>
)}
<div className="workspace-card-meta">
<span>{exam.question_count} question{exam.question_count !== 1 ? 's' : ''}</span>
<span>{exam.question_count} {exam.question_count === 1 ? t('common.question') : t('common.questions')}</span>
<span>·</span>
<span>Updated {formatDate(exam.updated_at)}</span>
<span>{t('workspace.updated')} {formatDate(exam.updated_at)}</span>
</div>
</div>
<div className="workspace-card-actions">
@@ -106,22 +108,22 @@ export default function MyExams() {
className="btn btn-sm"
onClick={() => navigate(`/exam-editor/${exam.id}`)}
>
Edit
{t('common.edit')}
</button>
{deleteConfirm === exam.id ? (
<div className="workspace-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
Confirm
{t('common.confirm')}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
Cancel
{t('common.cancel')}
</button>
</div>
) : (
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(exam.id)}
title="Delete exam"
title={t('common.delete')}
>
✕
</button>
+13 -11
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import * as resultApi from '../api/resultApi'
import { getExamUrlId } from '../utils/slug'
@@ -17,6 +18,7 @@ interface ResultItem {
}
export default function MyResults() {
const { t } = useTranslation()
const navigate = useNavigate()
const [results, setResults] = useState<ResultItem[]>([])
const [total, setTotal] = useState(0)
@@ -85,10 +87,10 @@ export default function MyResults() {
<div className="workspace-section fade-in">
<div className="workspace-section-header">
<div>
<div className="workspace-section-eyebrow">My Workspace</div>
<h2 className="workspace-section-title">Results</h2>
<div className="workspace-section-eyebrow">{t('workspace.myWorkspace')}</div>
<h2 className="workspace-section-title">{t('workspace.results')}</h2>
</div>
<div className="mono muted" style={{ fontSize: 13 }}>{total} attempt{total !== 1 ? 's' : ''}</div>
<div className="mono muted" style={{ fontSize: 13 }}>{total} {total === 1 ? t('workspace.attempts') : t('workspace.attemptsPlural')}</div>
</div>
{error && (
@@ -99,13 +101,13 @@ export default function MyResults() {
)}
{loading ? (
<div className="workspace-empty">Loading…</div>
<div className="workspace-empty">{t('common.loading')}</div>
) : results.length === 0 ? (
<div className="workspace-empty">
<div className="workspace-empty-icon">✦</div>
<div className="workspace-empty-title">No attempts yet</div>
<div className="workspace-empty-title">{t('workspace.noAttempts')}</div>
<div className="workspace-empty-desc">
Take an exam to see your results here.
{t('workspace.noAttemptsDesc')}
</div>
</div>
) : (
@@ -122,28 +124,28 @@ export default function MyResults() {
<span>·</span>
<span>{formatTime(r.elapsed_ms)}</span>
<span>·</span>
<span>{r.correct_count}/{r.graded_count} correct</span>
<span>{r.correct_count}/{r.graded_count} {t('results.correct')}</span>
</div>
</div>
<div className="workspace-card-actions" style={{ gap: 8 }}>
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
<button className="btn btn-sm" onClick={() => void reviewResult(r)}>
Review
{t('common.edit')}
</button>
{deleteConfirm === r.id ? (
<div className="workspace-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteResult(r.id)}>
Confirm
{t('common.confirm')}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
Cancel
{t('common.cancel')}
</button>
</div>
) : (
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(r.id)}
title="Delete result"
title={t('common.delete')}
>
✕
</button>
+23 -21
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiError } from '../utils/api'
import * as authApi from '../api/authApi'
@@ -9,6 +10,7 @@ interface Props {
type Step = 'request' | 'reset' | 'success'
export default function ResetPassword({ onDone }: Readonly<Props>) {
const { t } = useTranslation()
const [step, setStep] = useState<Step>('request')
const [email, setEmail] = useState('')
const [newPassword, setNewPassword] = useState('')
@@ -20,9 +22,9 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const t = params.get('token')
if (t) {
setToken(t)
const tk = params.get('token')
if (tk) {
setToken(tk)
setStep('reset')
window.history.replaceState({}, '', '/reset-password')
}
@@ -36,7 +38,7 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
const data = await authApi.forgotPassword(email)
setMessage(data.message)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Request failed')
setError(err instanceof ApiError ? err.message : t('resetPassword.requestFailed'))
} finally {
setSubmitting(false)
}
@@ -46,7 +48,7 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
e.preventDefault()
setError(null)
if (newPassword !== confirmPassword) {
setError('Passwords do not match')
setError(t('resetPassword.passwordsDoNotMatch'))
return
}
setSubmitting(true)
@@ -54,7 +56,7 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
await authApi.resetPassword({ token: token!, newPassword })
setStep('success')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Password reset failed')
setError(err instanceof ApiError ? err.message : t('resetPassword.passwordResetFailed'))
} finally {
setSubmitting(false)
}
@@ -66,12 +68,12 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
{step === 'request' && !message && (
<>
<div className="verify-icon">🔑</div>
<h2 className="verify-title">Reset Password</h2>
<p className="verify-message">Enter your email address and we'll send you a link to reset your password.</p>
<h2 className="verify-title">{t('auth.resetPassword')}</h2>
<p className="verify-message">{t('auth.resetPasswordDesc')}</p>
{error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>}
<form onSubmit={(e) => void handleRequestReset(e)} className="auth-form" style={{ marginTop: 16 }}>
<div className="auth-field">
<label className="auth-label" htmlFor="reset-email">Email</label>
<label className="auth-label" htmlFor="reset-email">{t('auth.emailLabel')}</label>
<input
id="reset-email"
className="input"
@@ -83,10 +85,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
/>
</div>
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
{submitting ? 'Sending…' : 'Send Reset Link'}
{submitting ? t('auth.sending') : t('auth.sendResetLink')}
</button>
<button type="button" className="auth-forgot" onClick={onDone}>
← Back to sign in
← {t('auth.backToSignIn')}
</button>
</form>
</>
@@ -95,10 +97,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
{step === 'request' && message && (
<>
<div className="verify-icon success">📧</div>
<h2 className="verify-title">Check Your Email</h2>
<h2 className="verify-title">{t('auth.checkYourEmail')}</h2>
<p className="verify-message">{message}</p>
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
Back to Home
{t('auth.backToHome')}
</button>
</>
)}
@@ -106,12 +108,12 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
{step === 'reset' && (
<>
<div className="verify-icon">🔐</div>
<h2 className="verify-title">Set New Password</h2>
<p className="verify-message">Enter your new password below.</p>
<h2 className="verify-title">{t('auth.setNewPassword')}</h2>
<p className="verify-message">{t('auth.enterNewPassword')}</p>
{error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>}
<form onSubmit={(e) => void handleReset(e)} className="auth-form" style={{ marginTop: 16 }}>
<div className="auth-field">
<label className="auth-label" htmlFor="new-password">New Password</label>
<label className="auth-label" htmlFor="new-password">{t('auth.newPassword')}</label>
<input
id="new-password"
className="input"
@@ -124,7 +126,7 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="confirm-new-password">Confirm Password</label>
<label className="auth-label" htmlFor="confirm-new-password">{t('auth.confirmPassword')}</label>
<input
id="confirm-new-password"
className="input"
@@ -137,7 +139,7 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
/>
</div>
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
{submitting ? 'Resetting…' : 'Reset Password'}
{submitting ? t('auth.resetting') : t('auth.resetPassword')}
</button>
</form>
</>
@@ -146,10 +148,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
{step === 'success' && (
<>
<div className="verify-icon success">✓</div>
<h2 className="verify-title">Password Updated</h2>
<p className="verify-message">Your password has been reset successfully. You can now sign in with your new password.</p>
<h2 className="verify-title">{t('auth.passwordUpdated')}</h2>
<p className="verify-message">{t('auth.passwordUpdatedDesc')}</p>
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
Sign In →
{t('auth.signIn')} →
</button>
</>
)}
+15 -13
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Exam, ExamState } from '../types'
import { gradeQuestion } from '../utils/grading'
import { formatTime } from '../utils/formatting'
@@ -15,6 +16,7 @@ interface Props {
}
export default function Results({ exam, state, onRestart, onExit, reviewOnly }: Props) {
const { t } = useTranslation()
const { answers, startedAt, completedAt } = state
const elapsedMs = (completedAt ?? Date.now()) - startedAt
@@ -47,28 +49,28 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
return (
<div className="results">
<div className="results-eyebrow">
{reviewOnly ? 'Reviewing past attempt' : 'Exam complete'} · {exam.title}
{reviewOnly ? t('results.reviewing') : t('results.examComplete')} · {exam.title}
</div>
<h1 className="results-title">
You scored <em>{pct}%</em><br />
{t('results.scored')} <em>{pct}%</em><br />
<span style={{ fontSize: '0.5em', color: 'var(--ink-3)' }}>
{passed
? `Above the ${passing}% passing mark.`
: `Below the ${passing}% passing mark. Keep practicing.`}
? t('results.abovePassing', { passing })
: t('results.belowPassing', { passing })}
</span>
</h1>
<div className="score-grid">
<div className="score-cell">
<div className="score-cell-label">Points</div>
<div className="score-cell-label">{t('results.points')}</div>
<div className="score-cell-value">{scorePoints}<span className="score-cell-unit">/ {totalPoints}</span></div>
</div>
<div className="score-cell" style={{ paddingLeft: 24 }}>
<div className="score-cell-label">Correct</div>
<div className="score-cell-label">{t('results.correct')}</div>
<div className="score-cell-value">{correctCount}<span className="score-cell-unit">/ {gradedCount}</span></div>
</div>
<div className="score-cell" style={{ paddingLeft: 24 }}>
<div className="score-cell-label">Time</div>
<div className="score-cell-label">{t('results.time')}</div>
<div className="score-cell-value mono" style={{ fontSize: 36 }}>{formatTime(elapsedMs)}</div>
</div>
</div>
@@ -76,7 +78,7 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
{Object.keys(byCat).length > 1 && (
<div className="category-breakdown">
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', marginBottom: 16, fontWeight: 500 }}>
By category
{t('results.byCategory')}
</div>
{Object.entries(byCat).map(([cat, c]) => {
const catPct = c.possible ? Math.round((c.earned / c.possible) * 100) : 0
@@ -91,7 +93,7 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
</div>
)}
<h2 className="review-heading">Review</h2>
<h2 className="review-heading">{t('results.review')}</h2>
{graded.map((g, i) => {
const promptText = g.q.prompt
.replace(/\$\$([^$]+)\$\$/g, '[math]')
@@ -116,13 +118,13 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
<QuestionRenderer key={g.q.id} q={g.q} value={g.userAnswer} onChange={() => {}} locked graded />
{g.q.explanation && (
<div className="feedback" style={{ marginTop: 24 }}>
<div className="feedback-label">Explanation</div>
<div className="feedback-label">{t('results.explanation')}</div>
<div className="feedback-body"><Tex>{g.q.explanation}</Tex></div>
</div>
)}
{g.q.rubric && (
<div style={{ marginTop: 20, fontSize: 13.5 }}>
<div className="meta-label" style={{ marginBottom: 8 }}>Rubric (self-grade)</div>
<div className="meta-label" style={{ marginBottom: 8 }}>{t('results.rubric')}</div>
<ul style={{ paddingLeft: 18, margin: 0, lineHeight: 1.7, color: 'var(--ink-2)' }}>
{g.q.rubric.map((r, j) => <li key={j}><Tex>{r}</Tex></li>)}
</ul>
@@ -135,8 +137,8 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
})}
<div style={{ marginTop: 48, display: 'flex', gap: 12 }}>
{!reviewOnly && <button className="btn btn-primary" onClick={onRestart}>Retake exam</button>}
<button className="btn" onClick={onExit}>Back to home</button>
{!reviewOnly && <button className="btn btn-primary" onClick={onRestart}>{t('results.retakeExam')}</button>}
<button className="btn" onClick={onExit}>{t('results.backToHome')}</button>
</div>
</div>
)
+23 -21
View File
@@ -1,8 +1,10 @@
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
import * as authApi from '../api/authApi'
export default function Settings() {
const { t } = useTranslation()
const { user, refreshUser } = useAuth()
const [displayName, setDisplayName] = useState(user?.displayName ?? '')
const [savingName, setSavingName] = useState(false)
@@ -17,36 +19,36 @@ export default function Settings() {
const updateDisplayName = useCallback(async () => {
const trimmed = displayName.trim()
if (!trimmed || trimmed.length > 100) {
setNameMsg({ ok: false, text: 'Display name is required (max 100 characters)' })
setNameMsg({ ok: false, text: t('workspace.displayNameRequired') })
return
}
setSavingName(true)
setNameMsg(null)
try {
await authApi.updateProfile({ displayName: trimmed })
setNameMsg({ ok: true, text: 'Display name updated' })
setNameMsg({ ok: true, text: t('workspace.displayNameUpdated') })
await refreshUser()
} catch (e) {
setNameMsg({ ok: false, text: (e as Error).message })
} finally {
setSavingName(false)
}
}, [displayName, refreshUser])
}, [displayName, refreshUser, t])
const changePassword = useCallback(async () => {
if (newPassword.length < 8) {
setPasswordMsg({ ok: false, text: 'New password must be at least 8 characters' })
setPasswordMsg({ ok: false, text: t('workspace.passwordMinLength') })
return
}
if (newPassword !== confirmPassword) {
setPasswordMsg({ ok: false, text: 'Passwords do not match' })
setPasswordMsg({ ok: false, text: t('workspace.passwordsDoNotMatch') })
return
}
setSavingPassword(true)
setPasswordMsg(null)
try {
await authApi.changePassword({ currentPassword, newPassword })
setPasswordMsg({ ok: true, text: 'Password changed successfully' })
setPasswordMsg({ ok: true, text: t('workspace.passwordChanged') })
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
@@ -55,26 +57,26 @@ export default function Settings() {
} finally {
setSavingPassword(false)
}
}, [currentPassword, newPassword, confirmPassword])
}, [currentPassword, newPassword, confirmPassword, t])
return (
<div className="workspace-section fade-in">
<div className="workspace-section-header">
<div>
<div className="workspace-section-eyebrow">My Workspace</div>
<h2 className="workspace-section-title">Settings</h2>
<div className="workspace-section-eyebrow">{t('workspace.myWorkspace')}</div>
<h2 className="workspace-section-title">{t('workspace.settings')}</h2>
</div>
</div>
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>Profile</h3>
<h3>{t('workspace.profile')}</h3>
</div>
<div className="workspace-panel-body workspace-settings-form">
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-display-name">
<span className="workspace-setting-title">Display Name</span>
<span className="workspace-setting-desc">Your name shown throughout the app</span>
<span className="workspace-setting-title">{t('workspace.displayNameLabel')}</span>
<span className="workspace-setting-desc">{t('workspace.displayNameDesc')}</span>
</label>
<input
id="settings-display-name"
@@ -85,8 +87,8 @@ export default function Settings() {
</div>
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-email">
<span className="workspace-setting-title">Email</span>
<span className="workspace-setting-desc">Your email address</span>
<span className="workspace-setting-title">{t('workspace.emailLabel')}</span>
<span className="workspace-setting-desc">{t('workspace.emailDesc')}</span>
</label>
<input
id="settings-email"
@@ -108,7 +110,7 @@ export default function Settings() {
onClick={() => void updateDisplayName()}
disabled={savingName || displayName.trim() === user?.displayName}
>
{savingName ? 'Saving…' : 'Save Profile'}
{savingName ? t('workspace.saving') : t('workspace.saveProfile')}
</button>
</div>
</div>
@@ -116,12 +118,12 @@ export default function Settings() {
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>Security</h3>
<h3>{t('workspace.security')}</h3>
</div>
<div className="workspace-panel-body workspace-settings-form">
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-current-password">
<span className="workspace-setting-title">Current Password</span>
<span className="workspace-setting-title">{t('workspace.currentPassword')}</span>
</label>
<input
id="settings-current-password"
@@ -133,8 +135,8 @@ export default function Settings() {
</div>
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-new-password">
<span className="workspace-setting-title">New Password</span>
<span className="workspace-setting-desc">Minimum 8 characters</span>
<span className="workspace-setting-title">{t('workspace.newPassword')}</span>
<span className="workspace-setting-desc">{t('workspace.newPasswordDesc')}</span>
</label>
<input
id="settings-new-password"
@@ -146,7 +148,7 @@ export default function Settings() {
</div>
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-confirm-password">
<span className="workspace-setting-title">Confirm New Password</span>
<span className="workspace-setting-title">{t('workspace.confirmNewPassword')}</span>
</label>
<input
id="settings-confirm-password"
@@ -167,7 +169,7 @@ export default function Settings() {
onClick={() => void changePassword()}
disabled={savingPassword || !currentPassword || !newPassword || !confirmPassword}
>
{savingPassword ? 'Changing…' : 'Change Password'}
{savingPassword ? t('workspace.changing') : t('workspace.changePassword')}
</button>
</div>
</div>
+30 -30
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { setToken } from '../utils/api'
import * as setupApi from '../api/setupApi'
@@ -7,6 +8,7 @@ interface Props {
}
export default function SetupWizard({ onComplete }: Readonly<Props>) {
const { t } = useTranslation()
const [step, setStep] = useState(1)
const [email, setEmail] = useState('')
const [displayName, setDisplayName] = useState('')
@@ -24,19 +26,19 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
const validateStep1 = () => {
if (!email.trim() || !email.includes('@')) {
setError('Please enter a valid email address')
setError(t('setup.validEmailRequired'))
return false
}
if (!displayName.trim()) {
setError('Display name is required')
setError(t('setup.displayNameRequired'))
return false
}
if (password.length < 8) {
setError('Password must be at least 8 characters')
setError(t('setup.passwordMinLength'))
return false
}
if (password !== confirmPassword) {
setError('Passwords do not match')
setError(t('setup.passwordsDoNotMatch'))
return false
}
return true
@@ -68,7 +70,7 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
onComplete()
} catch (err) {
setError(
err instanceof Error ? err.message : 'Setup failed. Please try again.',
err instanceof Error ? err.message : t('setup.setupFailed'),
)
} finally {
setSubmitting(false)
@@ -79,20 +81,20 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
<div className="setup-page">
<div className="setup-card">
<div className="brand-mark" style={{ margin: '0 auto 16px' }} />
<h1 className="setup-title">Welcome to BrainWave</h1>
<h1 className="setup-title">{t('setup.welcome')}</h1>
<p className="setup-subtitle">
Let's get your instance set up. This only takes a minute.
{t('setup.subtitle')}
</p>
<div className="setup-steps">
<div className={`setup-step ${step >= 1 ? 'active' : ''}`}>
<span className="setup-step-num">1</span>
<span className="setup-step-label">Admin Account</span>
<span className="setup-step-label">{t('setup.adminAccount')}</span>
</div>
<div className="setup-step-line" />
<div className={`setup-step ${step >= 2 ? 'active' : ''}`}>
<span className="setup-step-num">2</span>
<span className="setup-step-label">Email Settings</span>
<span className="setup-step-label">{t('setup.emailSettings')}</span>
</div>
</div>
@@ -102,7 +104,7 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
<>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-email">
Email
{t('setup.email')}
</label>
<input
id="setup-email"
@@ -117,7 +119,7 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-name">
Display Name
{t('setup.displayName')}
</label>
<input
id="setup-name"
@@ -132,7 +134,7 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-password">
Password
{t('setup.password')}
</label>
<input
id="setup-password"
@@ -148,7 +150,7 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-confirm">
Confirm Password
{t('setup.confirmPassword')}
</label>
<input
id="setup-confirm"
@@ -167,7 +169,7 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
onClick={handleNext}
style={{ marginTop: 8 }}
>
Continue →
{t('setup.continue')}
</button>
</>
)}
@@ -175,17 +177,15 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
{step === 2 && (
<form onSubmit={(e) => void handleSubmit(e)}>
<p className="setup-hint">
Configure your outgoing email server so BrainWave can send
verification and password-reset emails.
{t('setup.setupHint1')}
</p>
<p className="setup-hint">
You can leave these blank and configure them later in the admin
dashboard.
{t('setup.setupHint2')}
</p>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-host">
SMTP Host
{t('setup.smtpHost')}
</label>
<input
id="smtp-host"
@@ -198,7 +198,7 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-port">
SMTP Port
{t('setup.smtpPort')}
</label>
<input
id="smtp-port"
@@ -211,7 +211,7 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-user">
SMTP Username
{t('setup.smtpUsername')}
</label>
<input
id="smtp-user"
@@ -224,7 +224,7 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-pass">
SMTP Password
{t('setup.smtpPassword')}
</label>
<input
id="smtp-pass"
@@ -237,7 +237,7 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-from">
From Address
{t('setup.fromAddress')}
</label>
<input
id="smtp-from"
@@ -250,7 +250,7 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-secure">
Security
{t('setup.security')}
</label>
<select
id="smtp-secure"
@@ -258,14 +258,14 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
value={smtpSecure}
onChange={(e) => setSmtpSecure(e.target.value)}
>
<option value="auto">Auto (use SSL on port 465)</option>
<option value="true">SSL/TLS</option>
<option value="false">None / STARTTLS</option>
<option value="auto">{t('setup.autoSsl')}</option>
<option value="true">{t('setup.sslTls')}</option>
<option value="false">{t('setup.noneStarttls')}</option>
</select>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="app-url">
App URL
{t('setup.appUrl')}
</label>
<input
id="app-url"
@@ -284,14 +284,14 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
className="btn btn-ghost"
onClick={() => setStep(1)}
>
← Back
{t('setup.back')}
</button>
<button
className="btn btn-primary auth-submit"
type="submit"
disabled={submitting}
>
{submitting ? 'Setting up…' : 'Complete Setup'}
{submitting ? t('setup.settingUp') : t('setup.completeSetup')}
</button>
</div>
</form>
+11 -9
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import type { Tweaks, ThemeMode, FontSize, FeedbackTweak } from '../types'
interface Props {
@@ -28,27 +29,28 @@ function Seg({ tkey, options, tweaks, update }: {
}
export default function TweaksPanel({ tweaks, update, onClose }: Props) {
const { t } = useTranslation()
return (
<div className="tweaks-panel">
<div className="tweaks-header">
<span>Tweaks</span>
<span>{t('tweaks.title')}</span>
<button className="btn btn-ghost btn-sm" onClick={onClose} style={{ padding: '2px 6px' }}>×</button>
</div>
<div className="tweaks-body">
<div className="tweak-row">
<div className="tweak-label">Theme</div>
<Seg tkey="theme" options={[{ value: 'light' as ThemeMode, label: 'Light' }, { value: 'dark' as ThemeMode, label: 'Dark' }]} tweaks={tweaks} update={update} />
<div className="tweak-label">{t('tweaks.theme')}</div>
<Seg tkey="theme" options={[{ value: 'light' as ThemeMode, label: t('tweaks.light') }, { value: 'dark' as ThemeMode, label: t('tweaks.dark') }]} tweaks={tweaks} update={update} />
</div>
<div className="tweak-row">
<div className="tweak-label">Text size</div>
<Seg tkey="fontSize" options={[{ value: 'small' as FontSize, label: 'S' }, { value: 'medium' as FontSize, label: 'M' }, { value: 'large' as FontSize, label: 'L' }]} tweaks={tweaks} update={update} />
<div className="tweak-label">{t('tweaks.textSize')}</div>
<Seg tkey="fontSize" options={[{ value: 'small' as FontSize, label: t('tweaks.small') }, { value: 'medium' as FontSize, label: t('tweaks.medium') }, { value: 'large' as FontSize, label: t('tweaks.large') }]} tweaks={tweaks} update={update} />
</div>
<div className="tweak-row">
<div className="tweak-label">Feedback</div>
<div className="tweak-label">{t('tweaks.feedback')}</div>
<Seg tkey="feedbackMode" options={[
{ value: 'auto' as FeedbackTweak, label: 'Auto' },
{ value: 'immediate' as FeedbackTweak, label: 'Immediate' },
{ value: 'end' as FeedbackTweak, label: 'End' },
{ value: 'auto' as FeedbackTweak, label: t('tweaks.auto') },
{ value: 'immediate' as FeedbackTweak, label: t('tweaks.immediate') },
{ value: 'end' as FeedbackTweak, label: t('tweaks.end') },
]} tweaks={tweaks} update={update} />
</div>
</div>
+14 -12
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useParams, Navigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
import * as examApi from '../api/examApi'
import * as resultApi from '../api/resultApi'
@@ -9,13 +10,14 @@ import Settings from './Settings'
type TabKey = 'exams' | 'results' | 'settings'
const TABS: { key: TabKey; label: string; icon: string }[] = [
{ key: 'exams', label: 'My Exams', icon: '◇' },
{ key: 'results', label: 'Results', icon: '✦' },
{ key: 'settings', label: 'Settings', icon: '⚙' },
const TAB_KEYS: { key: TabKey; labelKey: string; icon: string }[] = [
{ key: 'exams', labelKey: 'workspace.myExams', icon: '◇' },
{ key: 'results', labelKey: 'workspace.results', icon: '✦' },
{ key: 'settings', labelKey: 'workspace.settings', icon: '⚙' },
]
export default function Workspace() {
const { t } = useTranslation()
const { user, loading: authLoading } = useAuth()
const navigate = useNavigate()
const { tab } = useParams<{ tab: string }>()
@@ -55,18 +57,18 @@ export default function Workspace() {
</div>
<nav className="workspace-nav">
{TABS.map((t) => (
{TAB_KEYS.map((tabItem) => (
<button
key={t.key}
className={`workspace-nav-item${activeTab === t.key ? ' active' : ''}`}
onClick={() => setTab(t.key)}
key={tabItem.key}
className={`workspace-nav-item${activeTab === tabItem.key ? ' active' : ''}`}
onClick={() => setTab(tabItem.key)}
>
<span className="workspace-nav-icon">{t.icon}</span>
<span className="workspace-nav-label">{t.label}</span>
{t.key === 'exams' && examCount > 0 && (
<span className="workspace-nav-icon">{tabItem.icon}</span>
<span className="workspace-nav-label">{t(tabItem.labelKey)}</span>
{tabItem.key === 'exams' && examCount > 0 && (
<span className="workspace-nav-badge">{examCount}</span>
)}
{t.key === 'results' && resultCount > 0 && (
{tabItem.key === 'results' && resultCount > 0 && (
<span className="workspace-nav-badge">{resultCount}</span>
)}
</button>