From 522af7181c1079a4c665798b5af61b3e38b6bd0d Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 18:31:37 +0200 Subject: [PATCH] feat: a real objective picker, and a section bar that fits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The study objective was a ` in the header, which had to be capped at 220px and so + * clipped "Pediatrics Boards (2948)" mid-word, and which offered objectives + * with no questions behind them as though they were ready to use. + * + * Now it is what it actually is: a choice between a dozen exams, grouped by + * the family they belong to, searchable, with what each one covers stated — + * and confirmed rather than applied the instant the pointer passes over an + * option. The choice is stored on the user, so it follows them between + * devices rather than living in this browser. */ export default function ExamSwitcher({ onChange }) { const [exams, setExams] = useState([]) const [activeId, setActiveId] = useState(null) + const [open, setOpen] = useState(false) + const [chosen, setChosen] = useState(null) + const [query, setQuery] = useState('') const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const search = useRef(null) + + const load = () => api.get('/exams/') + .then(res => { setExams(res.data.exams || []); setActiveId(res.data.active_exam_id ?? null) }) + .catch(() => setExams([])) + + useEffect(() => { load() }, []) useEffect(() => { - api.get('/exams/') - .then(res => { setExams(res.data.exams || []); setActiveId(res.data.active_exam_id ?? null) }) - .catch(() => setExams([])) - }, []) + if (!open) return undefined + setChosen(activeId) + setQuery('') + setError('') + search.current?.focus() + const onKey = e => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [open, activeId]) - const choose = async (value) => { - const examId = value === '' ? null : Number(value) - setBusy(true) + // Grouped by family, in the order the server sorted them. + const families = useMemo(() => { + const needle = query.trim().toLowerCase() + const groups = new Map() + for (const exam of exams) { + if (needle && !`${exam.name} ${exam.family}`.toLowerCase().includes(needle)) continue + const key = exam.family || 'Other' + if (!groups.has(key)) groups.set(key, []) + groups.get(key).push(exam) + } + return [...groups.entries()] + }, [exams, query]) + + const save = async () => { + setBusy(true); setError('') try { - await api.put('/exams/active', { exam_id: examId }) - setActiveId(examId) - onChange?.(examId) - } catch { /* leave the previous selection showing */ } + await api.put('/exams/active', { exam_id: chosen }) + setActiveId(chosen) + setOpen(false) + onChange?.(chosen) + } catch { setError('Could not change your study objective') } finally { setBusy(false) } } if (exams.length === 0) return null + const active = exams.find(e => e.id === activeId) return ( - + <> + + + {open && ( +
e.target === e.currentTarget && setOpen(false)}> +
+
+

Current study objective

+ +
+

+ Scopes the question bank, the filters and your performance analysis. + Material linked to no exam stays visible whichever you pick. +

+ + setQuery(e.target.value)} /> + +
+ + + {families.map(([family, list]) => ( +
+

{family}

+
+ {list.map(exam => { + // An objective with nothing behind it would empty the + // bank. Shown, so an educator can see it exists, but not + // selectable until it has questions. + const empty = exam.question_count === 0 + return ( + + ) + })} +
+
+ ))} + {families.length === 0 &&

Nothing matches that.

} +
+ + {error &&

{error}

} +
+ + +
+
+
+ )} + ) } diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 2d39dbf..7a6b561 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useEffect, useRef, useState } from 'react' import { Link, useLocation } from 'react-router-dom' import ScrollStrip from './ScrollStrip' import { useAuth } from '../context/AuthContext' @@ -61,6 +61,56 @@ function JobsBadge({ jobs }) { ) } +/** + * You, rather than the material: dashboard, account, settings, sign out. + * + * These were four more entries in the section bar, which is what made it + * outrun its width. They belong together and none of them is a place you go to + * study, so they sit behind the one control that is about the person. + */ +function AccountMenu({ user, onLogout }) { + const [open, setOpen] = useState(false) + const wrap = useRef(null) + + useEffect(() => { + if (!open) return undefined + const away = e => { if (!wrap.current?.contains(e.target)) setOpen(false) } + const onKey = e => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('mousedown', away) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', away) + document.removeEventListener('keydown', onKey) + } + }, [open]) + + const initial = (user?.name || user?.email || '?').trim().charAt(0).toUpperCase() + + return ( +
+ + {open && ( +
+
+ {user?.name} + {user?.email} +
+ setOpen(false)}>Dashboard + setOpen(false)}>Account + setOpen(false)}>Settings + +
+ )} +
+ ) +} + + export default function Navbar({ onSignIn, onRegister }) { const { user, logout } = useAuth() const [menuOpen, setMenuOpen] = useState(false) @@ -104,9 +154,11 @@ export default function Navbar({ onSignIn, onRegister }) { }, [user]) + // Where you go to study. Home is the signed-out landing page, so it is not + // one of these; Dashboard, Account and Settings are about you rather than + // about the material, and live under the account menu — which is what keeps + // this row short enough not to need scrolling in the first place. const navLinks = user ? [ - { to: '/home', label: 'Home' }, - { to: '/', label: 'Dashboard' }, { to: '/ai', label: 'AI Mode' }, // One entry. Sessions and analysis are the same subject — the list of what // you have sat and the reading of how it went — so they are one page. @@ -119,7 +171,6 @@ export default function Navbar({ onSignIn, onRegister }) { { to: '/articles', label: 'Reading' }, { to: '/flashcards', label: 'Cards' }, { to: '/courses', label: 'Courses' }, - { to: '/settings', label: '⚙ Settings' }, ] : [] return ( @@ -134,7 +185,7 @@ export default function Navbar({ onSignIn, onRegister }) { {user ? (
- +