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

+29 -10
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { flushSync } from "react-dom";
import { useTranslation } from "react-i18next";
import {
useNavigate,
useLocation,
@@ -46,6 +47,7 @@ const withNotes = (state: ExamState): ExamState => ({
});
function AppInner() {
const { t, i18n } = useTranslation();
const { user, loading: authLoading, needsSetup, refreshUser, logout } = useAuth();
if (needsSetup) {
@@ -271,7 +273,7 @@ function AppInner() {
};
const clearAllHistory = () => {
if (confirm("Clear all past attempts?")) {
if (confirm(t('landing.clearHistoryConfirm'))) {
clearHistory();
setHistory([]);
}
@@ -318,7 +320,7 @@ function AppInner() {
<button
className="brand-name"
onClick={exit}
aria-label="Home"
aria-label={t('common.backToHome')}
style={{
background: "none",
border: "none",
@@ -332,7 +334,7 @@ function AppInner() {
BrainWave
</button>
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>
/ practice
/ {t('common.appTagline')}
</span>
</div>
<div className="topbar-meta">
@@ -342,7 +344,7 @@ function AppInner() {
{exam.title}
</span>
<button className="btn btn-ghost btn-sm" onClick={exit}>
Exit
{t('exam.exit')}
</button>
</>
)}
@@ -350,17 +352,34 @@ function AppInner() {
className="btn btn-ghost btn-sm"
onClick={() => setTweaksOpen((o) => !o)}
style={{ marginLeft: 8 }}
aria-label="Tweaks"
aria-label={t('tweaks.title')}
>
⚙
</button>
<div style={{ display: 'flex', gap: 4, marginLeft: 8, alignItems: 'center' }}>
<button
className="btn btn-ghost btn-sm"
onClick={() => i18n.changeLanguage('en')}
style={{ fontWeight: i18n.language === 'en' ? 700 : 400, fontSize: 12 }}
>
EN
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => i18n.changeLanguage('nl')}
style={{ fontWeight: i18n.language === 'nl' ? 700 : 400, fontSize: 12 }}
>
NL
</button>
</div>
{!authLoading && !user && (
<button
className="btn btn-primary btn-sm"
onClick={() => setAuthModalOpen(true)}
>
Sign In
{t('auth.signIn')}
</button>
)}
{!authLoading && user && (
@@ -368,7 +387,7 @@ function AppInner() {
<button
className="user-avatar"
onClick={() => setUserMenuOpen((o) => !o)}
aria-label="User menu"
aria-label={t('auth.userMenu')}
>
{user.displayName.charAt(0).toUpperCase()}
</button>
@@ -386,7 +405,7 @@ function AppInner() {
navigate("/workspace");
}}
>
My Workspace
{t('auth.myWorkspace')}
</button>
{user.role === "admin" && (
<button
@@ -396,7 +415,7 @@ function AppInner() {
navigate("/admin");
}}
>
Admin Dashboard
{t('auth.adminDashboard')}
</button>
)}
<button
@@ -406,7 +425,7 @@ function AppInner() {
logout();
}}
>
Sign Out
{t('auth.signOut')}
</button>
</div>
)}
+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>
+369
View File
@@ -0,0 +1,369 @@
{
"common": {
"appName": "BrainWave",
"appTagline": "practice exam platform",
"cancel": "Cancel",
"confirm": "Confirm",
"close": "Close",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"back": "Back",
"backToHome": "Back to home",
"loading": "Loading…",
"error": "Error",
"success": "Success",
"yes": "Yes",
"no": "No",
"pts": "pts",
"pt": "pt",
"questions": "questions",
"question": "question"
},
"landing": {
"eyebrow": "BrainWave · practice exam platform",
"title": "Upload an exam. ",
"titleEmphasis": "Practice anything.",
"subtitle": "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.",
"dropzoneTitle": "Drop your exam JSON here",
"dropzoneSub": "or click to browse · max 5 MB",
"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",
"trySample": "Try a sample",
"resumeTitle": "Resume in-progress exam",
"resumeProgress": "{{answered}}/{{total}} answered",
"resumeButton": "Resume →",
"pastAttempts": "Past attempts",
"clearHistory": "Clear history",
"dateTimeFormat": "{{date}} · {{correct}}/{{graded}} correct",
"review": "Review",
"loadError": "Could not load: {{message}}",
"loadSampleError": "Could not load sample: {{message}}",
"clearHistoryConfirm": "Clear all past attempts?"
},
"auth": {
"signIn": "Sign In",
"signOut": "Sign Out",
"register": "Register",
"welcomeBack": "Welcome back",
"createAccount": "Create account",
"signInSubtitle": "Sign in to access your saved exams and results",
"registerSubtitle": "Create an account to save your progress",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password",
"displayName": "Display Name",
"forgotPassword": "Forgot your password?",
"signingIn": "Signing in…",
"creatingAccount": "Creating account…",
"loginFailed": "Login failed",
"registrationFailed": "Registration failed",
"passwordsDoNotMatch": "Passwords do not match",
"myWorkspace": "My Workspace",
"adminDashboard": "Admin Dashboard",
"userMenu": "User menu",
"resetPassword": "Reset Password",
"resetPasswordDesc": "Enter your email address and we'll send you a link to reset your password.",
"checkYourEmail": "Check Your Email",
"setNewPassword": "Set New Password",
"enterNewPassword": "Enter your new password below.",
"newPassword": "New Password",
"resetting": "Resetting…",
"passwordUpdated": "Password Updated",
"passwordUpdatedDesc": "Your password has been reset successfully. You can now sign in with your new password.",
"requestFailed": "Request failed",
"passwordResetFailed": "Password reset failed",
"sendResetLink": "Send Reset Link",
"sending": "Sending…",
"backToSignIn": "Back to sign in"
},
"exam": {
"submitExam": "Submit exam",
"exitToHome": "Exit to home",
"questionsLabel": "Questions",
"progressLabel": "Progress",
"progressText": "{{answered}} of {{total}} answered",
"questionHeader": "Question {{current}} / {{total}}",
"correct": "Correct",
"notQuite": "Not quite",
"answerLabel": "Answer:",
"previous": "← Previous",
"flagged": "⚑ Flagged",
"flagForReview": "⚐ Flag for review",
"checkAnswer": "Check answer",
"next": "Next →",
"elapsed": "Elapsed",
"remaining": "Remaining",
"pointsPossible": "Points possible",
"mode": "Mode",
"immediateFeedback": "Immediate feedback",
"gradeAtEnd": "Grade at end",
"notes": "Notes",
"notesPlaceholder": "Add short notes while you work…",
"author": "Author",
"navigateHint": "navigate",
"flagHint": "flag",
"selectHint": "select",
"exit": "Exit",
"untimed": "Untimed"
},
"results": {
"reviewing": "Reviewing past attempt",
"examComplete": "Exam complete",
"scored": "You scored ",
"abovePassing": "Above the {{passing}}% passing mark.",
"belowPassing": "Below the {{passing}}% passing mark. Keep practicing.",
"points": "Points",
"correct": "Correct",
"time": "Time",
"byCategory": "By category",
"uncategorized": "Uncategorized",
"review": "Review",
"explanation": "Explanation",
"rubric": "Rubric (self-grade)",
"retakeExam": "Retake exam",
"backToHome": "Back to home"
},
"tweaks": {
"title": "Tweaks",
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"textSize": "Text size",
"small": "S",
"medium": "M",
"large": "L",
"feedback": "Feedback",
"auto": "Auto",
"immediate": "Immediate",
"end": "End"
},
"workspace": {
"myWorkspace": "My Workspace",
"myExams": "My Exams",
"results": "Results",
"settings": "Settings",
"newExam": "New Exam",
"noSavedExams": "No saved exams yet",
"noSavedExamsDesc": "Upload an exam from the home page, or create a new one to get started.",
"createFirstExam": "Create Your First Exam",
"noAttempts": "No attempts yet",
"noAttemptsDesc": "Take an exam to see your results here.",
"attempts": "attempt",
"attemptsPlural": "attempts",
"updated": "Updated",
"profile": "Profile",
"security": "Security",
"displayNameLabel": "Display Name",
"displayNameDesc": "Your name shown throughout the app",
"emailLabel": "Email",
"emailDesc": "Your email address",
"saveProfile": "Save Profile",
"saving": "Saving…",
"displayNameUpdated": "Display name updated",
"displayNameRequired": "Display name is required (max 100 characters)",
"currentPassword": "Current Password",
"newPassword": "New Password",
"newPasswordDesc": "Minimum 8 characters",
"confirmNewPassword": "Confirm New Password",
"changePassword": "Change Password",
"changing": "Changing…",
"passwordChanged": "Password changed successfully",
"passwordMinLength": "New password must be at least 8 characters",
"passwordsDoNotMatch": "Passwords do not match"
},
"admin": {
"administration": "Administration",
"dashboard": "Dashboard",
"backToSite": "← Back to site",
"overview": "Overview",
"settings": "Settings",
"users": "Users",
"totalUsers": "Total Users",
"savedExams": "Saved Exams",
"examsTaken": "Exams Taken",
"avgScore": "Avg. Score",
"recentActivity": "Recent Activity",
"noRecentActivity": "No recent activity yet",
"general": "General",
"siteName": "Site Name",
"siteNameDesc": "The name displayed throughout the application",
"maxUpload": "Max Upload Size (MB)",
"maxUploadDesc": "Maximum file size for exam JSON uploads",
"defaultTheme": "Default Theme",
"defaultThemeDesc": "Default theme for new visitors",
"accessControl": "Access Control",
"allowGuestMode": "Allow Guest Mode",
"allowGuestModeDesc": "Let unauthenticated users take exams without signing in",
"registrationEnabled": "Registration Enabled",
"registrationEnabledDesc": "Allow new users to create accounts",
"emailSmtp": "Email / SMTP",
"appUrl": "App URL",
"appUrlDesc": "Base URL used in email links (e.g. https://brainwave.example.com)",
"smtpHost": "SMTP Host",
"smtpHostDesc": "Hostname of your SMTP server",
"smtpPort": "SMTP Port",
"smtpPortDesc": "Common ports: 587 (STARTTLS), 465 (SSL), 25 (plain)",
"username": "Username",
"usernameDesc": "SMTP authentication username",
"password": "Password",
"passwordDesc": "SMTP authentication password",
"fromAddress": "From Address",
"fromAddressDesc": "The sender name and address shown in emails",
"tlsMode": "TLS Mode",
"tlsModeDesc": "Auto detects from port (465→SSL). Use explicit override if needed.",
"auto": "Auto",
"forceTls": "Force TLS",
"off": "Off",
"sendTestEmail": "Send Test Email",
"sendTestEmailDesc": "Sends a test email using the settings above (uses draft values, no need to save first)",
"sendTest": "Send Test",
"sending": "Sending…",
"maintenance": "Maintenance",
"maintenanceMode": "Maintenance Mode",
"maintenanceModeDesc": "Take the site offline for non-admin users",
"maintenanceMessage": "Maintenance Message",
"maintenanceMessageDesc": "Message displayed to visitors during maintenance",
"discardChanges": "Discard Changes",
"saveSettings": "Save Settings",
"settingsSaved": "Settings saved successfully",
"searchByNameOrEmail": "Search by name or email…",
"search": "Search",
"user": "User",
"role": "Role",
"verified": "Verified",
"exams": "Exams",
"joined": "Joined",
"actions": "Actions",
"noUsersFound": "No users found",
"testEmailSent": "Test email sent successfully! Check your inbox."
},
"editor": {
"back": "← Back",
"examTitlePlaceholder": "Exam title",
"unsavedChanges": "Unsaved changes",
"saving": "Saving…",
"createExam": "Create Exam",
"save": "Save",
"examCreated": "Exam created successfully",
"examSaved": "Exam saved",
"questionsLabel": "Questions",
"addQuestion": "Add Question",
"noQuestions": "No questions yet",
"noQuestionsDesc": "Select a question type from the sidebar to add your first question.",
"questionEditor": "Question Editor",
"prompt": "Prompt",
"promptPlaceholder": "Enter the question prompt…",
"categoryOptional": "Category (optional)",
"categoryPlaceholder": "e.g. Algebra",
"points": "Points",
"explanationOptional": "Explanation (optional)",
"explanationPlaceholder": "Explanation shown after grading…",
"choices": "Choices",
"markAsCorrect": "Mark as correct",
"addChoice": "+ Add Choice",
"remove": "Remove",
"multipleChoice": "Multiple Choice",
"multipleSelect": "Multiple Select",
"shortAnswer": "Short Answer",
"translation": "Translation",
"mathInput": "Math Input",
"essay": "Essay",
"match": "Match",
"order": "Order",
"correctAnswer": "Correct Answer",
"correctAnswerPlaceholder": "Enter the correct answer",
"acceptedForms": "Accepted Forms (optional alternatives)",
"alternativeAnswer": "Alternative answer",
"addAlternative": "+ Add Alternative",
"caseSensitive": "Case sensitive",
"ignorePunctuation": "Ignore punctuation",
"ignoreAccents": "Ignore accents",
"minWords": "Min Words",
"rubricCriteria": "Rubric Criteria",
"criterion": "Criterion",
"addCriterion": "+ Add Criterion",
"leftColumn": "Left Column",
"rightColumn": "Right Column",
"addLeft": "+ Add Left",
"addRight": "+ Add Right",
"correctPairings": "Correct Pairings",
"select": "— Select —",
"items": "Items (drag not implemented, use arrows to reorder)",
"itemPlaceholder": "Item {{index}}",
"correctOrder": "Correct Order",
"correctOrderHint": "The correct order is the order shown above. Reorder items to set the correct sequence.",
"setCurrentOrder": "Set Current Order as Correct",
"examTitleRequired": "Exam title is required",
"atLeastOneQuestion": "Add at least one question",
"sourceLanguage": "Source Language",
"sourceLanguagePlaceholder": "e.g. English",
"targetLanguage": "Target Language",
"targetLanguagePlaceholder": "e.g. Spanish",
"choicesCheckCorrect": "Choices (check correct answers)",
"choicePlaceholder": "Choice {{id}}",
"addItem": "+ Add Item",
"leftItemPlaceholder": "Left item {{index}}",
"rightItemPlaceholder": "Right item {{index}}"
},
"setup": {
"welcome": "Welcome to BrainWave",
"subtitle": "Let's get your instance set up. This only takes a minute.",
"adminAccount": "Admin Account",
"emailSettings": "Email Settings",
"email": "Email",
"displayName": "Display Name",
"password": "Password",
"confirmPassword": "Confirm Password",
"continue": "Continue →",
"smtpHost": "SMTP Host",
"smtpPort": "SMTP Port",
"smtpUsername": "SMTP Username",
"smtpPassword": "SMTP Password",
"fromAddress": "From Address",
"security": "Security",
"appUrl": "App URL",
"back": "← Back",
"completeSetup": "Complete Setup",
"settingUp": "Setting up…",
"setupHint1": "Configure your outgoing email server so BrainWave can send verification and password-reset emails.",
"setupHint2": "You can leave these blank and configure them later in the admin dashboard.",
"autoSsl": "Auto (use SSL on port 465)",
"sslTls": "SSL/TLS",
"noneStarttls": "None / STARTTLS",
"validEmailRequired": "Please enter a valid email address",
"displayNameRequired": "Display name is required",
"passwordMinLength": "Password must be at least 8 characters",
"passwordsDoNotMatch": "Passwords do not match",
"setupFailed": "Setup failed. Please try again."
},
"verify": {
"verifying": "Verifying your email…",
"pleaseWait": "Please wait while we confirm your email address.",
"emailVerified": "Email Verified",
"signIn": "Sign In →",
"verificationFailed": "Verification Failed",
"backToHome": "Back to Home",
"noToken": "No verification token found."
},
"resetPassword": {
"resetPassword": "Reset Password",
"enterEmail": "Enter your email address and we'll send you a link to reset your password.",
"sendResetLink": "Send Reset Link",
"sending": "Sending…",
"backToSignIn": "← Back to sign in",
"checkEmail": "Check Your Email",
"setNewPassword": "Set New Password",
"enterNewPassword": "Enter your new password below.",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"resetting": "Resetting…",
"resetPasswordBtn": "Reset Password",
"passwordUpdated": "Password Updated",
"passwordUpdatedDesc": "Your password has been reset successfully. You can now sign in with your new password.",
"requestFailed": "Request failed",
"passwordResetFailed": "Password reset failed"
}
}
+27
View File
@@ -0,0 +1,27 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import en from './en.json'
import nl from './nl.json'
const resources = {
en: { translation: en },
nl: { translation: nl },
}
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
})
export default i18n
+368
View File
@@ -0,0 +1,368 @@
{
"common": {
"appName": "BrainWave",
"appTagline": "oefenexamenplatform",
"cancel": "Annuleren",
"confirm": "Bevestigen",
"close": "Sluiten",
"save": "Opslaan",
"delete": "Verwijderen",
"edit": "Bewerken",
"back": "Terug",
"backToHome": "Terug naar home",
"loading": "Laden…",
"error": "Fout",
"success": "Succes",
"yes": "Ja",
"no": "Nee",
"pts": "pnt",
"pt": "pnt",
"questions": "vragen",
"question": "vraag"
},
"landing": {
"eyebrow": "BrainWave · oefenexamenplatform",
"title": "Upload een examen. ",
"titleEmphasis": "Oefen alles.",
"subtitle": "Sleep een JSON-bestand naar binnen om een oefenexamen te starten — wiskunde met volledige LaTeX, taalvertaling, meerkeuze, koppelen, rangschikken, opstellen, en meer. Voortgang wordt automatisch opgeslagen.",
"dropzoneTitle": "Sleep je examen-JSON hierheen",
"dropzoneSub": "of klik om te bladeren · max 5 MB",
"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",
"trySample": "Probeer een voorbeeld",
"resumeTitle": "Hervat lopend examen",
"resumeProgress": "{{answered}}/{{total}} beantwoord",
"resumeButton": "Hervat →",
"pastAttempts": "Eerdere pogingen",
"clearHistory": "Wis geschiedenis",
"dateTimeFormat": "{{date}} · {{correct}}/{{graded}} correct",
"review": "Nakijken",
"loadError": "Kon niet laden: {{message}}",
"loadSampleError": "Kon voorbeeld niet laden: {{message}}",
"clearHistoryConfirm": "Alle eerdere pogingen wissen?"
},
"auth": {
"signIn": "Inloggen",
"signOut": "Uitloggen",
"register": "Registreren",
"welcomeBack": "Welkom terug",
"createAccount": "Account aanmaken",
"signInSubtitle": "Log in om toegang te krijgen tot je opgeslagen examens en resultaten",
"registerSubtitle": "Maak een account aan om je voortgang op te slaan",
"email": "E-mail",
"password": "Wachtwoord",
"confirmPassword": "Wachtwoord bevestigen",
"displayName": "Weergavenaam",
"forgotPassword": "Wachtwoord vergeten?",
"signingIn": "Inloggen…",
"creatingAccount": "Account aanmaken…",
"loginFailed": "Inloggen mislukt",
"registrationFailed": "Registratie mislukt",
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
"myWorkspace": "Mijn Werkruimte",
"adminDashboard": "Admin Dashboard",
"userMenu": "Gebruikersmenu",
"resetPassword": "Wachtwoord resetten",
"resetPasswordDesc": "Voer je e-mailadres in en we sturen je een link om je wachtwoord te resetten.",
"checkYourEmail": "Controleer je e-mail",
"setNewPassword": "Nieuw wachtwoord instellen",
"enterNewPassword": "Voer hieronder je nieuwe wachtwoord in.",
"newPassword": "Nieuw wachtwoord",
"resetting": "Resetten…",
"passwordUpdated": "Wachtwoord bijgewerkt",
"passwordUpdatedDesc": "Je wachtwoord is succesvol gereset. Je kunt nu inloggen met je nieuwe wachtwoord.",
"requestFailed": "Verzoek mislukt",
"passwordResetFailed": "Wachtwoordreset mislukt",
"sendResetLink": "Resetlink versturen",
"sending": "Versturen…",
"backToSignIn": "Terug naar inloggen"
},
"exam": {
"submitExam": "Examen inleveren",
"exitToHome": "Terug naar home",
"questionsLabel": "Vragen",
"progressLabel": "Voortgang",
"progressText": "{{answered}} van {{total}} beantwoord",
"questionHeader": "Vraag {{current}} / {{total}}",
"correct": "Correct",
"notQuite": "Niet helemaal",
"answerLabel": "Antwoord:",
"previous": "← Vorige",
"flagged": "⚑ Gemarkeerd",
"flagForReview": "⚐ Markeer voor controle",
"checkAnswer": "Controleer antwoord",
"next": "Volgende →",
"elapsed": "Verstreken",
"remaining": "Resterend",
"pointsPossible": "Mogelijke punten",
"mode": "Modus",
"immediateFeedback": "Directe feedback",
"gradeAtEnd": "Beoordeling aan het eind",
"notes": "Notities",
"notesPlaceholder": "Voeg korte notities toe terwijl je werkt…",
"author": "Auteur",
"navigateHint": "navigeren",
"flagHint": "markeren",
"selectHint": "selecteren",
"exit": "Afsluiten",
"untimed": "Ongetimed"
},
"results": {
"reviewing": "Eerdere poging bekijken",
"examComplete": "Examen voltooid",
"scored": "Je score: ",
"abovePassing": "Boven de {{passing}}% slaaggrens.",
"belowPassing": "Onder de {{passing}}% slaaggrens. Blijf oefenen.",
"points": "Punten",
"correct": "Correct",
"time": "Tijd",
"byCategory": "Per categorie",
"uncategorized": "Geen categorie",
"review": "Nakijken",
"explanation": "Uitleg",
"rubric": "Rubric (zelfbeoordeling)",
"retakeExam": "Examen opnieuw maken",
"backToHome": "Terug naar home"
},
"tweaks": {
"title": "Instellingen",
"theme": "Thema",
"light": "Licht",
"dark": "Donker",
"textSize": "Tekstgrootte",
"small": "K",
"medium": "M",
"large": "G",
"feedback": "Feedback",
"auto": "Auto",
"immediate": "Direct",
"end": "Einde"
},
"workspace": {
"myWorkspace": "Mijn Werkruimte",
"myExams": "Mijn Examens",
"results": "Resultaten",
"settings": "Instellingen",
"newExam": "Nieuw Examen",
"noSavedExams": "Nog geen opgeslagen examens",
"noSavedExamsDesc": "Upload een examen vanaf de homepagina, of maak een nieuw examen aan om te beginnen.",
"createFirstExam": "Maak je eerste examen",
"noAttempts": "Nog geen pogingen",
"noAttemptsDesc": "Maak een examen om hier je resultaten te zien.",
"attempts": "poging",
"attemptsPlural": "pogingen",
"updated": "Bijgewerkt",
"profile": "Profiel",
"security": "Beveiliging",
"displayNameLabel": "Weergavenaam",
"displayNameDesc": "Je naam die door de app wordt getoond",
"emailLabel": "E-mail",
"emailDesc": "Je e-mailadres",
"saveProfile": "Profiel opslaan",
"saving": "Opslaan…",
"displayNameUpdated": "Weergavenaam bijgewerkt",
"displayNameRequired": "Weergavenaam is verplicht (max 100 tekens)",
"currentPassword": "Huidig wachtwoord",
"newPassword": "Nieuw wachtwoord",
"newPasswordDesc": "Minimaal 8 tekens",
"confirmNewPassword": "Bevestig nieuw wachtwoord",
"changePassword": "Wachtwoord wijzigen",
"changing": "Wijzigen…",
"passwordChanged": "Wachtwoord succesvol gewijzigd",
"passwordMinLength": "Nieuw wachtwoord moet minimaal 8 tekens bevatten",
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen"
},
"admin": {
"administration": "Beheer",
"dashboard": "Dashboard",
"backToSite": "← Terug naar site",
"overview": "Overzicht",
"settings": "Instellingen",
"users": "Gebruikers",
"totalUsers": "Totaal gebruikers",
"savedExams": "Opgeslagen examens",
"examsTaken": "Gemaakte examens",
"avgScore": "Gem. score",
"recentActivity": "Recente activiteit",
"noRecentActivity": "Nog geen recente activiteit",
"general": "Algemeen",
"siteName": "Sitenaam",
"siteNameDesc": "De naam die door de applicatie wordt getoond",
"maxUpload": "Max uploadgrootte (MB)",
"maxUploadDesc": "Maximale bestandsgrootte voor examen-JSON uploads",
"defaultTheme": "Standaardthema",
"defaultThemeDesc": "Standaardthema voor nieuwe bezoekers",
"accessControl": "Toegangsbeheer",
"allowGuestMode": "Gastmodus toestaan",
"allowGuestModeDesc": "Laat niet-ingelogde gebruikers examens maken zonder in te loggen",
"registrationEnabled": "Registratie ingeschakeld",
"registrationEnabledDesc": "Sta nieuwe gebruikers toe om accounts aan te maken",
"emailSmtp": "E-mail / SMTP",
"appUrl": "App-URL",
"appUrlDesc": "Basis-URL die in e-maillinks wordt gebruikt (bijv. https://brainwave.example.com)",
"smtpHost": "SMTP-host",
"smtpHostDesc": "Hostnaam van je SMTP-server",
"smtpPort": "SMTP-poort",
"smtpPortDesc": "Gebruikelijke poorten: 587 (STARTTLS), 465 (SSL), 25 (platte tekst)",
"username": "Gebruikersnaam",
"usernameDesc": "SMTP-authenticatie gebruikersnaam",
"password": "Wachtwoord",
"passwordDesc": "SMTP-authenticatie wachtwoord",
"fromAddress": "Afzenderadres",
"fromAddressDesc": "De afzendernaam en -adres die in e-mails worden getoond",
"tlsMode": "TLS-modus",
"tlsModeDesc": "Auto detecteert vanaf poort (465→SSL). Gebruik expliciete overschrijving indien nodig.",
"auto": "Auto",
"forceTls": "Forceer TLS",
"off": "Uit",
"sendTestEmail": "Test-e-mail versturen",
"sendTestEmailDesc": "Verstuurt een test-e-mail met bovenstaande instellingen (gebruikt conceptwaarden, opslaan niet nodig)",
"sendTest": "Verstuur test",
"sending": "Versturen…",
"maintenance": "Onderhoud",
"maintenanceMode": "Onderhoudsmodus",
"maintenanceModeDesc": "Zet de site offline voor niet-admin gebruikers",
"maintenanceMessage": "Onderhoudsbericht",
"maintenanceMessageDesc": "Bericht dat aan bezoekers wordt getoond tijdens onderhoud",
"discardChanges": "Wijzigingen negeren",
"saveSettings": "Instellingen opslaan",
"settingsSaved": "Instellingen succesvol opgeslagen",
"searchByNameOrEmail": "Zoek op naam of e-mail…",
"search": "Zoeken",
"role": "Rol",
"verified": "Geverifieerd",
"exams": "Examens",
"joined": "Lid sinds",
"actions": "Acties",
"noUsersFound": "Geen gebruikers gevonden",
"testEmailSent": "Test-e-mail succesvol verstuurd! Controleer je inbox."
},
"editor": {
"back": "← Terug",
"examTitlePlaceholder": "Examennaam",
"unsavedChanges": "Niet-opgeslagen wijzigingen",
"saving": "Opslaan…",
"createExam": "Examen aanmaken",
"save": "Opslaan",
"examCreated": "Examen succesvol aangemaakt",
"examSaved": "Examen opgeslagen",
"questionsLabel": "Vragen",
"addQuestion": "Vraag toevoegen",
"noQuestions": "Nog geen vragen",
"noQuestionsDesc": "Selecteer een vraagtype in de zijbalk om je eerste vraag toe te voegen.",
"questionEditor": "Vraagbewerker",
"prompt": "Vraagtekst",
"promptPlaceholder": "Voer de vraagtekst in…",
"categoryOptional": "Categorie (optioneel)",
"categoryPlaceholder": "bijv. Algebra",
"points": "Punten",
"explanationOptional": "Uitleg (optioneel)",
"explanationPlaceholder": "Uitleg die na beoordeling wordt getoond…",
"choices": "Keuzes",
"markAsCorrect": "Markeer als correct",
"addChoice": "+ Keuze toevoegen",
"remove": "Verwijderen",
"multipleChoice": "Meerkeuze",
"multipleSelect": "Meervoudige selectie",
"shortAnswer": "Kort antwoord",
"translation": "Vertaling",
"mathInput": "Wiskunde-invoer",
"essay": "Opstel",
"match": "Koppelen",
"order": "Rangschikken",
"correctAnswer": "Correct antwoord",
"correctAnswerPlaceholder": "Voer het correcte antwoord in",
"acceptedForms": "Geaccepteerde vormen (optionele alternatieven)",
"alternativeAnswer": "Alternatief antwoord",
"addAlternative": "+ Alternatief toevoegen",
"caseSensitive": "Hoofdlettergevoelig",
"ignorePunctuation": "Negeer leestekens",
"ignoreAccents": "Negeer accenten",
"minWords": "Min. woorden",
"rubricCriteria": "Rubric-criteria",
"criterion": "Criterium",
"addCriterion": "+ Criterium toevoegen",
"leftColumn": "Linkerkolom",
"rightColumn": "Rechterkolom",
"addLeft": "+ Links toevoegen",
"addRight": "+ Rechts toevoegen",
"correctPairings": "Correcte koppelingen",
"select": "— Selecteer —",
"items": "Items (slepen niet geïmplementeerd, gebruik pijlen om te rangschikken)",
"itemPlaceholder": "Item {{index}}",
"correctOrder": "Correcte volgorde",
"correctOrderHint": "De correcte volgorde is de volgorde hierboven. Rangschik de items om de juiste volgorde in te stellen.",
"setCurrentOrder": "Huidige volgorde instellen als correct",
"examTitleRequired": "Examennaam is verplicht",
"atLeastOneQuestion": "Voeg minimaal één vraag toe",
"sourceLanguage": "Brontaal",
"sourceLanguagePlaceholder": "bijv. Engels",
"targetLanguage": "Doeltaal",
"targetLanguagePlaceholder": "bijv. Spaans",
"choicesCheckCorrect": "Keuzes (vink correcte antwoorden aan)",
"choicePlaceholder": "Keuze {{id}}",
"addItem": "+ Item toevoegen",
"leftItemPlaceholder": "Link item {{index}}",
"rightItemPlaceholder": "Recht item {{index}}"
},
"setup": {
"welcome": "Welkom bij BrainWave",
"subtitle": "Laten we je instantie instellen. Dit duurt maar een minuut.",
"adminAccount": "Admin-account",
"emailSettings": "E-mailinstellingen",
"email": "E-mail",
"displayName": "Weergavenaam",
"password": "Wachtwoord",
"confirmPassword": "Wachtwoord bevestigen",
"continue": "Doorgaan →",
"smtpHost": "SMTP-host",
"smtpPort": "SMTP-poort",
"smtpUsername": "SMTP-gebruikersnaam",
"smtpPassword": "SMTP-wachtwoord",
"fromAddress": "Afzenderadres",
"security": "Beveiliging",
"appUrl": "App-URL",
"back": "← Terug",
"completeSetup": "Installatie voltooien",
"settingUp": "Bezig met instellen…",
"setupHint1": "Configureer je uitgaande e-mailserver zodat BrainWave verificatie- en wachtwoordreset-e-mails kan versturen.",
"setupHint2": "Je kunt deze velden leeglaten en later configureren in het admin dashboard.",
"autoSsl": "Auto (gebruik SSL op poort 465)",
"sslTls": "SSL/TLS",
"noneStarttls": "Geen / STARTTLS",
"validEmailRequired": "Voer een geldig e-mailadres in",
"displayNameRequired": "Weergavenaam is verplicht",
"passwordMinLength": "Wachtwoord moet minimaal 8 tekens bevatten",
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
"setupFailed": "Installatie mislukt. Probeer het opnieuw."
},
"verify": {
"verifying": "E-mail verifiëren…",
"pleaseWait": "Een moment geduld terwijl we je e-mailadres bevestigen.",
"emailVerified": "E-mail geverifieerd",
"signIn": "Inloggen →",
"verificationFailed": "Verificatie mislukt",
"backToHome": "Terug naar home",
"noToken": "Geen verificatietoken gevonden."
},
"resetPassword": {
"resetPassword": "Wachtwoord resetten",
"enterEmail": "Voer je e-mailadres in en we sturen je een link om je wachtwoord te resetten.",
"sendResetLink": "Resetlink versturen",
"sending": "Versturen…",
"backToSignIn": "← Terug naar inloggen",
"checkEmail": "Controleer je e-mail",
"setNewPassword": "Nieuw wachtwoord instellen",
"enterNewPassword": "Voer hieronder je nieuwe wachtwoord in.",
"newPassword": "Nieuw wachtwoord",
"confirmPassword": "Wachtwoord bevestigen",
"resetting": "Resetten…",
"resetPasswordBtn": "Wachtwoord resetten",
"passwordUpdated": "Wachtwoord bijgewerkt",
"passwordUpdatedDesc": "Je wachtwoord is succesvol gereset. Je kunt nu inloggen met je nieuwe wachtwoord.",
"requestFailed": "Verzoek mislukt",
"passwordResetFailed": "Wachtwoordreset mislukt"
}
}
+1
View File
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "katex/dist/katex.min.css";
import "./index.css";
import "./i18n";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(