Public Access
feat: implement tweaks context for user preferences and remove TweaksPanel component
This commit is contained in:
1 parent
92c1de650a
commit
50efba6f34
8 files changed
+212
-147
No files matched your search
+6
-40
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user