feat: implement tweaks context for user preferences and remove TweaksPanel component
Test / backend (push) Successful in 12s
Test / frontend (push) Successful in 23s

This commit is contained in:
LaurensBartelds committed 2026-04-26 23:16:21 +02:00
1 parent 92c1de650a
commit 50efba6f34
8 files changed
+212 -147

No files matched your search

+6 -40
View File
@@ -8,7 +8,7 @@ import {
Route,
Navigate,
} from "react-router-dom";
import type { Exam, ExamState, HistoryEntry, Tweaks } from "./types";
import type { Exam, ExamState, HistoryEntry } from "./types";
import { gradeQuestion } from "./utils/grading";
import {
loadState,
@@ -23,11 +23,11 @@ import * as resultApi from "./api/resultApi";
import * as examApi from "./api/examApi";
import { getExamUrlId } from "./utils/slug";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
import { TweaksProvider, useTweaks } from "./contexts/TweaksContext";
import { ErrorBoundary } from "./components/ErrorBoundary";
import Landing from "./components/Landing";
import ExamRoute from "./components/ExamRoute";
import Results from "./components/Results";
import TweaksPanel from "./components/TweaksPanel";
import AuthModal from "./components/AuthModal";
import EmailVerify from "./components/EmailVerify";
import ResetPassword from "./components/ResetPassword";
@@ -36,12 +36,6 @@ import SetupWizard from "./components/SetupWizard";
import Workspace from "./components/Workspace";
import ExamEditor from "./components/ExamEditor";
const DEFAULT_TWEAKS: Tweaks = {
theme: "light",
fontSize: "medium",
feedbackMode: "auto",
};
const withNotes = (state: ExamState): ExamState => ({
...state,
notes: state.notes ?? "",
@@ -50,13 +44,12 @@ const withNotes = (state: ExamState): ExamState => ({
function AppInner() {
const { t } = useTranslation();
const { user, loading: authLoading, needsSetup, refreshUser, logout } = useAuth();
const { tweaks } = useTweaks();
const navigate = useNavigate();
const { pathname } = useLocation();
const [exam, setExam] = useState<Exam | null>(null);
const [examState, setExamState] = useState<ExamState | null>(null);
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory());
const [tweaks, setTweaks] = useState<Tweaks>(DEFAULT_TWEAKS);
const [tweaksOpen, setTweaksOpen] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const savedExamIdRef = useRef<number | null>(null);
@@ -94,16 +87,6 @@ function AppInner() {
}
}, [user, authLoading]);
useEffect(() => {
document.documentElement.dataset["theme"] = tweaks.theme;
const sizeMap: Record<string, string> = {
small: "14px",
medium: "15px",
large: "17px",
};
document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? "15px";
}, [tweaks]);
useEffect(() => {
if (pathname.startsWith("/exam/") && exam && examState)
saveState({ exam, state: examState });
@@ -129,9 +112,6 @@ function AppInner() {
}
}, [pathname, exam]);
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
setTweaks((t) => ({ ...t, [key]: value }));
const setExamStateStrict = useCallback<React.Dispatch<React.SetStateAction<ExamState>>>(
(nextState) => {
setExamState((prev) => {
@@ -383,15 +363,6 @@ function AppInner() {
</button>
</>
)}
<button
className="btn btn-ghost btn-sm"
onClick={() => setTweaksOpen((o) => !o)}
style={{ marginLeft: 8 }}
aria-label={t('tweaks.title')}
>
⚙
</button>
{!authLoading && !user && (
<button
className="btn btn-primary btn-sm"
@@ -528,13 +499,6 @@ function AppInner() {
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
{tweaksOpen && (
<TweaksPanel
tweaks={tweaks}
update={updateTweak}
onClose={() => setTweaksOpen(false)}
/>
)}
{authModalOpen && (
<AuthModal
onClose={() => setAuthModalOpen(false)}
@@ -549,7 +513,9 @@ export default function App() {
return (
<ErrorBoundary>
<AuthProvider>
<AppInner />
<TweaksProvider>
<AppInner />
</TweaksProvider>
</AuthProvider>
</ErrorBoundary>
);
+22
View File
@@ -353,6 +353,28 @@ export default function ExamRunner({
<div style={{ fontSize: 14 }}>{exam.author}</div>
</div>
)}
<div className="meta-block" style={{ marginTop: 'auto' }}>
<div className="meta-label">{t('tweaks.feedback')}</div>
<div className="tweak-control">
<button
className={`tweak-seg${feedbackMode === 'immediate' ? ' active' : ''}`}
onClick={() =>
setState((s) => ({ ...s, feedbackMode: 'immediate' }))
}
>
{t('tweaks.immediate')}
</button>
<button
className={`tweak-seg${feedbackMode === 'end' ? ' active' : ''}`}
onClick={() =>
setState((s) => ({ ...s, feedbackMode: 'end' }))
}
>
{t('tweaks.end')}
</button>
</div>
</div>
</aside>
</div>
);
+86 -2
View File
@@ -1,11 +1,35 @@
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
import { useTweaks } from '../contexts/TweaksContext'
import * as authApi from '../api/authApi'
import type { ThemeMode, FontSize, FeedbackTweak } from '../types'
function Seg<K extends string>({ value, options, onChange }: {
value: K
options: { value: K; label: string }[]
onChange: (v: K) => void
}) {
return (
<div className="tweak-control">
{options.map((opt) => (
<button
key={opt.value}
className={`tweak-seg${value === opt.value ? ' active' : ''}`}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
)
}
export default function Settings() {
const { t, i18n } = useTranslation()
const { user, refreshUser } = useAuth()
const { tweaks, updateTweak } = useTweaks()
const [displayName, setDisplayName] = useState(user?.displayName ?? '')
const [savingName, setSavingName] = useState(false)
const [nameMsg, setNameMsg] = useState<{ ok: boolean; text: string } | null>(null)
@@ -188,14 +212,14 @@ export default function Settings() {
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
className="btn btn-ghost btn-sm"
onClick={() => i18n.changeLanguage('en')}
onClick={() => void i18n.changeLanguage('en')}
style={{ fontWeight: i18n.language === 'en' ? 700 : 400, fontSize: 13 }}
>
English
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => i18n.changeLanguage('nl')}
onClick={() => void i18n.changeLanguage('nl')}
style={{ fontWeight: i18n.language === 'nl' ? 700 : 400, fontSize: 13 }}
>
Nederlands
@@ -204,6 +228,66 @@ export default function Settings() {
</div>
</div>
</div>
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>{t('workspace.appearance')}</h3>
</div>
<div className="workspace-panel-body workspace-settings-form">
<div className="workspace-setting-row">
<label className="workspace-setting-label">
<span className="workspace-setting-title">{t('workspace.themeLabel')}</span>
<span className="workspace-setting-desc">{t('workspace.themeDesc')}</span>
</label>
<Seg
value={tweaks.theme}
options={[
{ value: 'light' as ThemeMode, label: t('tweaks.light') },
{ value: 'dark' as ThemeMode, label: t('tweaks.dark') },
]}
onChange={(v) => updateTweak('theme', v)}
/>
</div>
<div className="workspace-setting-row">
<label className="workspace-setting-label">
<span className="workspace-setting-title">{t('workspace.textSizeLabel')}</span>
<span className="workspace-setting-desc">{t('workspace.textSizeDesc')}</span>
</label>
<Seg
value={tweaks.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') },
]}
onChange={(v) => updateTweak('fontSize', v)}
/>
</div>
</div>
</div>
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>{t('workspace.defaultFeedback')}</h3>
</div>
<div className="workspace-panel-body workspace-settings-form">
<div className="workspace-setting-row">
<label className="workspace-setting-label">
<span className="workspace-setting-title">{t('workspace.defaultFeedbackLabel')}</span>
<span className="workspace-setting-desc">{t('workspace.defaultFeedbackDesc')}</span>
</label>
<Seg
value={tweaks.feedbackMode}
options={[
{ value: 'auto' as FeedbackTweak, label: t('tweaks.auto') },
{ value: 'immediate' as FeedbackTweak, label: t('tweaks.immediate') },
{ value: 'end' as FeedbackTweak, label: t('tweaks.end') },
]}
onChange={(v) => updateTweak('feedbackMode', v)}
/>
</div>
</div>
</div>
</div>
)
}
-59
View File
@@ -1,59 +0,0 @@
import { useTranslation } from 'react-i18next'
import type { Tweaks, ThemeMode, FontSize, FeedbackTweak } from '../types'
interface Props {
tweaks: Tweaks
update: <K extends keyof Tweaks>(key: K, value: Tweaks[K]) => void
onClose: () => void
}
function Seg({ tkey, options, tweaks, update }: {
tkey: keyof Tweaks
options: { value: string; label: string }[]
tweaks: Tweaks
update: Props['update']
}) {
return (
<div className="tweak-control">
{options.map((opt) => (
<button
key={opt.value}
className={`tweak-seg${tweaks[tkey] === opt.value ? ' active' : ''}`}
onClick={() => update(tkey, opt.value as never)}
>
{opt.label}
</button>
))}
</div>
)
}
export default function TweaksPanel({ tweaks, update, onClose }: Props) {
const { t } = useTranslation()
return (
<div className="tweaks-panel">
<div className="tweaks-header">
<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">{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">{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">{t('tweaks.feedback')}</div>
<Seg tkey="feedbackMode" options={[
{ 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>
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
import type { Tweaks, ThemeMode, FontSize, FeedbackTweak } from '../types'
const TWEAKS_KEY = 'brainwave.tweaks'
const DEFAULT_TWEAKS: Tweaks = {
theme: 'light',
fontSize: 'medium',
feedbackMode: 'auto',
}
const getSystemTheme = (): ThemeMode =>
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
const loadTweaks = (): Tweaks => {
try {
const raw = localStorage.getItem(TWEAKS_KEY)
if (!raw) {
return { ...DEFAULT_TWEAKS, theme: getSystemTheme() }
}
const parsed = JSON.parse(raw) as Partial<Tweaks>
return {
theme: (parsed.theme as ThemeMode) ?? getSystemTheme(),
fontSize: (parsed.fontSize as FontSize) ?? DEFAULT_TWEAKS.fontSize,
feedbackMode: (parsed.feedbackMode as FeedbackTweak) ?? DEFAULT_TWEAKS.feedbackMode,
}
} catch {
return { ...DEFAULT_TWEAKS, theme: getSystemTheme() }
}
}
const saveTweaks = (tweaks: Tweaks): void => {
try { localStorage.setItem(TWEAKS_KEY, JSON.stringify(tweaks)) } catch { /* quota */ }
}
interface TweaksContextValue {
tweaks: Tweaks
updateTweak: <K extends keyof Tweaks>(key: K, value: Tweaks[K]) => void
}
const TweaksContext = createContext<TweaksContextValue | null>(null)
export function TweaksProvider({ children }: { children: React.ReactNode }) {
const [tweaks, setTweaks] = useState<Tweaks>(loadTweaks)
const updateTweak = useCallback(<K extends keyof Tweaks>(key: K, value: Tweaks[K]) => {
setTweaks((prev) => {
const next = { ...prev, [key]: value }
saveTweaks(next)
return next
})
}, [])
useEffect(() => {
document.documentElement.dataset['theme'] = tweaks.theme
const sizeMap: Record<string, string> = {
small: '14px',
medium: '15px',
large: '17px',
}
document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? '15px'
}, [tweaks.theme, tweaks.fontSize])
return (
<TweaksContext.Provider value={{ tweaks, updateTweak }}>
{children}
</TweaksContext.Provider>
)
}
export function useTweaks(): TweaksContextValue {
const ctx = useContext(TweaksContext)
if (!ctx) throw new Error('useTweaks must be used within TweaksProvider')
return ctx
}
+9 -1
View File
@@ -175,7 +175,15 @@
"passwordsDoNotMatch": "Passwords do not match",
"language": "Language",
"languageLabel": "Language",
"languageDesc": "Select your preferred display language"
"languageDesc": "Select your preferred display language",
"appearance": "Appearance",
"themeLabel": "Theme",
"themeDesc": "Choose between light and dark mode",
"textSizeLabel": "Text size",
"textSizeDesc": "Adjust the base font size for readability",
"defaultFeedback": "Default Feedback",
"defaultFeedbackLabel": "Feedback mode",
"defaultFeedbackDesc": "Choose when to see answer feedback during exams"
},
"admin": {
"administration": "Administration",
+9 -1
View File
@@ -175,7 +175,15 @@
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
"language": "Taal",
"languageLabel": "Taal",
"languageDesc": "Selecteer je voorkeurstaal voor de interface"
"languageDesc": "Selecteer je voorkeurstaal voor de interface",
"appearance": "Weergave",
"themeLabel": "Thema",
"themeDesc": "Kies tussen licht en donker",
"textSizeLabel": "Tekstgrootte",
"textSizeDesc": "Pas de basislettergrootte aan voor leesbaarheid",
"defaultFeedback": "Standaard Feedback",
"defaultFeedbackLabel": "Feedbackmodus",
"defaultFeedbackDesc": "Kies wanneer je antwoordfeedback ziet tijdens examens"
},
"admin": {
"administration": "Beheer",
+3 -44
View File
@@ -569,6 +569,8 @@ select {
height: calc(100vh - 57px);
max-height: calc(100vh - 57px);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.meta-block {
margin-bottom: 28px;
@@ -1236,50 +1238,7 @@ select {
font-size: 14px;
}
/* ---------- Tweaks panel ---------- */
.tweaks-panel {
position: fixed;
bottom: 20px;
right: 20px;
width: 280px;
background: var(--paper);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
box-shadow: var(--shadow-md);
z-index: 100;
overflow: hidden;
}
.tweaks-header {
padding: 12px 16px;
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 500;
color: var(--ink-2);
}
.tweaks-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.tweak-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.tweak-label {
font-size: 12px;
color: var(--ink-3);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.08em;
}
/* ---------- Segmented control ---------- */
.tweak-control {
display: flex;
gap: 4px;