Public Access
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:
1 parent
c64cdcd9e3
commit
6277826472
19 files changed
+1193
-369
No files matched your search
+29
-10
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user