diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 29aca0d..a5144c9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,7 +15,6 @@ const DocumentDetailPage = lazyPage(() => import('./pages/DocumentDetailPage')) const QuizPage = lazyPage(() => import('./pages/QuizPage')) const CustomQuizPage = lazyPage(() => import('./pages/CustomQuizPage')) const ResultsPage = lazyPage(() => import('./pages/ResultsPage')) -const AccountPage = lazyPage(() => import('./pages/AccountPage')) const SettingsPage = lazyPage(() => import('./pages/SettingsPage')) const QuestionBankPage = lazyPage(() => import('./pages/QuestionBankPage')) const QuestionManagerPage = lazyPage(() => import('./pages/QuestionManagerPage')) @@ -133,7 +132,8 @@ function AppRoutes() { } /> } /> } /> - } /> + {/* Settings opens on the account; one door, not two. */} + } /> } /> } /> } /> diff --git a/frontend/src/components/ExamSwitcher.css b/frontend/src/components/ExamSwitcher.css index 796886a..85d3195 100644 --- a/frontend/src/components/ExamSwitcher.css +++ b/frontend/src/components/ExamSwitcher.css @@ -22,10 +22,13 @@ /* ── The short menu ─────────────────────────────────────────────────── What you are on, where you have been lately, and a way to the full list — so changing objective does not start with a full-screen dialog. */ -.exam-switcher { position: relative; display: inline-flex; } +.exam-switcher { display: inline-flex; } .exm { - position: absolute; top: calc(100% + 6px); left: 0; z-index: 1090; - min-width: 260px; max-width: min(320px, calc(100vw - 24px)); + /* Fixed, not absolute. The section bar it lives in is 46px tall with + overflow hidden, which clipped an absolutely positioned menu out of + existence; the component measures the button and places this itself. */ + position: fixed; top: 0; left: 0; z-index: 1090; + width: 280px; max-width: calc(100vw - 24px); padding: 6px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 14px 34px rgba(15, 23, 42, 0.18); @@ -55,10 +58,23 @@ } .exm-error { margin: 6px 11px 2px; font-size: 0.78rem; color: var(--wrong-fg); } -/* On a phone the menu is the width of the screen rather than of the button. */ -@media (max-width: 520px) { - .exm { left: auto; right: 0; min-width: 240px; } +/* ── In a settings panel ────────────────────────────────────────────── + Plain, full width, and no pop-out menu: the panel clips anything that + hangs out of it, and there is room to just say what the objective is. */ +.exam-switcher-inline { + display: flex; align-items: center; justify-content: space-between; + gap: 16px; flex-wrap: wrap; + padding: 14px 16px; + border: 1px solid var(--border); border-radius: 10px; + background: var(--bg); } +.exam-switcher-inline small { + display: block; + font-size: 0.64rem; font-weight: 700; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--text-subtle); +} +.exam-switcher-inline strong { display: block; margin-top: 3px; font-size: 1rem; font-weight: 650; } +.exam-switcher-inline > div > span { font-size: 0.8rem; color: var(--text-muted); } /* ── The dialog ───────────────────────────────────────────────────── */ .exo-overlay { diff --git a/frontend/src/components/ExamSwitcher.jsx b/frontend/src/components/ExamSwitcher.jsx index 287e836..b94c2ef 100644 --- a/frontend/src/components/ExamSwitcher.jsx +++ b/frontend/src/components/ExamSwitcher.jsx @@ -37,7 +37,7 @@ function rememberRecent(id) { localStorage.setItem(RECENT_KEY, JSON.stringify(next)) } catch { /* private browsing, or storage turned off */ } } -export default function ExamSwitcher({ onChange }) { +export default function ExamSwitcher({ onChange, inline = false }) { const [exams, setExams] = useState([]) const [activeId, setActiveId] = useState(null) const [menu, setMenu] = useState(false) @@ -48,6 +48,8 @@ export default function ExamSwitcher({ onChange }) { const [error, setError] = useState('') const search = useRef(null) const wrap = useRef(null) + const trigger = useRef(null) + const [anchor, setAnchor] = useState(null) const load = () => api.get('/exams/') .then(res => { setExams(res.data.exams || []); setActiveId(res.data.active_exam_id ?? null) }) @@ -57,11 +59,29 @@ export default function ExamSwitcher({ onChange }) { useEffect(() => { if (!menu) return undefined + // The menu is fixed rather than absolute. It lives inside the section bar, + // which is 46px tall with overflow hidden — an absolutely positioned menu + // is clipped to that strip and simply never appears. Fixed positioning + // escapes every overflowing ancestor, so it has to be told where to go. + const place = () => { + const box = trigger.current?.getBoundingClientRect() + if (!box) return + const width = 280 + setAnchor({ + top: Math.round(box.bottom + 6), + left: Math.round(Math.min(box.left, window.innerWidth - width - 12)), + }) + } + place() + window.addEventListener('resize', place) + window.addEventListener('scroll', place, true) const away = e => { if (!wrap.current?.contains(e.target)) setMenu(false) } const onKey = e => { if (e.key === 'Escape') setMenu(false) } document.addEventListener('mousedown', away) document.addEventListener('keydown', onKey) return () => { + window.removeEventListener('resize', place) + window.removeEventListener('scroll', place, true) document.removeEventListener('mousedown', away) document.removeEventListener('keydown', onKey) } @@ -114,9 +134,88 @@ export default function ExamSwitcher({ onChange }) { .filter(e => e && e.question_count > 0) .slice(0, RECENT_MAX) + // The full picker. Shared by both shapes: an overlay is fixed to the + // viewport, so nothing that clips the trigger can clip it. + const dialog = 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)} /> + +
+ {/* There is no unscoped choice. Studying for nothing in + particular is not an objective, and the whole bank at once + makes the filters and the analysis mean less, not more. */} + {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}

} +
+ + +
+
+
+ ) : null + + // In a settings panel there is room to say the thing plainly, and the panel + // clips anything that hangs out of it — so the short menu, which is a + // navbar affordance, is skipped and the picker opens directly. + if (inline) { + return ( +
+
+ Current objective + {active ? active.name : 'None chosen'} + {active && {active.question_count} questions} +
+ + {dialog} +
+ ) + } + return (
- {menu && ( -
+
{active ? (
Current objective @@ -158,63 +258,7 @@ export default function ExamSwitcher({ onChange }) {
)} - {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)} /> - -
- {/* There is no unscoped choice. Studying for nothing in - particular is not an objective, and the whole bank at once - makes the filters and the analysis mean less, not more. */} - {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}

} -
- - -
-
-
- )} + {dialog}
) } diff --git a/frontend/src/components/ExamSwitcher.test.jsx b/frontend/src/components/ExamSwitcher.test.jsx index 76df576..0874428 100644 --- a/frontend/src/components/ExamSwitcher.test.jsx +++ b/frontend/src/components/ExamSwitcher.test.jsx @@ -44,6 +44,30 @@ describe('ExamSwitcher', () => { expect(api.put).toHaveBeenCalledWith('/exams/active', { exam_id: 2 }) }) + it('places the menu itself, because the bar it sits in clips its own overflow', async () => { + // .navbar-sections is 46px tall with overflow:hidden. An absolutely + // positioned menu was clipped to that strip and never appeared at all. + await openMenu() + const menu = screen.getByRole('menu') + // The stylesheet is not loaded here, so `position: fixed` cannot be read + // back. What can be checked is the part the component owns: it measured + // the trigger and wrote coordinates, which is only needed because the + // menu is taken out of the clipped flow. + expect(menu.style.top).not.toBe('') + expect(menu.style.left).not.toBe('') + }) + + it('says the objective plainly in a settings panel, with no menu to clip', async () => { + render() + expect(await screen.findByText('Pediatrics Boards')).toBeInTheDocument() + expect(screen.getByText('2948 questions')).toBeInTheDocument() + // No pop-out at all: the panel has overflow:hidden, so Change opens the + // full picker, which is an overlay fixed to the viewport. + expect(screen.queryByRole('menu')).toBeNull() + await userEvent.click(screen.getByRole('button', { name: 'Change' })) + expect(await screen.findByRole('dialog', { name: 'Current study objective' })).toBeInTheDocument() + }) + it('offers no unscoped choice, and no objective with nothing behind it', async () => { await openMenu() await userEvent.click(screen.getByRole('menuitem', { name: 'Choose a new study objective' })) diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 497f45c..41632b4 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -164,8 +164,14 @@ function AccountMenu({ user, onLogout }) { {user?.email}
setOpen(false)}>Dashboard - setOpen(false)}>Account + {/* Settings opens on the account, so a separate Account entry was + two doors to the same room. */} setOpen(false)}>Settings + {user?.role === 'admin' && ( + setOpen(false)}> + Administration + + )}
diff --git a/frontend/src/components/Navbar.test.jsx b/frontend/src/components/Navbar.test.jsx index 56b0135..328eb9a 100644 --- a/frontend/src/components/Navbar.test.jsx +++ b/frontend/src/components/Navbar.test.jsx @@ -125,7 +125,9 @@ describe('two-bar header', () => { const menu = screen.getByRole('menu') expect(within(menu).getByRole('menuitem', { name: 'Dashboard' })).toHaveAttribute('href', '/') expect(within(menu).getByRole('menuitem', { name: 'Settings' })).toHaveAttribute('href', '/settings') - expect(within(menu).getByRole('menuitem', { name: 'Account' })).toHaveAttribute('href', '/account') + // Settings opens on the account, so there is no second door to it. + expect(within(menu).queryByRole('menuitem', { name: 'Account' })).toBeNull() + expect(within(menu).getByRole('menuitem', { name: 'Settings' })).toHaveAttribute('href', '/settings') expect(within(menu).getByRole('menuitem', { name: 'Sign out' })).toBeInTheDocument() }) diff --git a/frontend/src/components/SiteFooter.jsx b/frontend/src/components/SiteFooter.jsx index d3fb007..6801483 100644 --- a/frontend/src/components/SiteFooter.jsx +++ b/frontend/src/components/SiteFooter.jsx @@ -38,7 +38,6 @@ const COLUMNS = [ links: [ { to: '/home', label: 'About' }, { to: '/home#contact', label: 'Contact' }, - { to: '/account', label: 'Account' }, { to: '/settings', label: 'Settings' }, ], }, diff --git a/frontend/src/components/SiteFooter.test.jsx b/frontend/src/components/SiteFooter.test.jsx index 67f1e67..ccc569e 100644 --- a/frontend/src/components/SiteFooter.test.jsx +++ b/frontend/src/components/SiteFooter.test.jsx @@ -5,7 +5,7 @@ import SiteFooter from './SiteFooter' const ROUTES = ['/home', '/login', '/register', '/', '/sessions', '/question-bank', '/questions/manage', '/flashcards', '/search', '/ai', '/media', '/study-plans', '/articles', - '/courses', '/account', '/settings', '/categories', '/editorial'] + '/courses', '/settings', '/categories', '/editorial'] describe('site footer', () => { it('is a way around, grouped by what you are trying to do', () => { diff --git a/frontend/src/pages/AccountPage.jsx b/frontend/src/pages/AccountPage.jsx deleted file mode 100644 index 72b235f..0000000 --- a/frontend/src/pages/AccountPage.jsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useState } from 'react' -import { useAuth } from '../context/AuthContext' -import api from '../api/client' - -export default function AccountPage() { - const { user, login } = useAuth() - const [name, setName] = useState(user?.name || '') - const [currentPassword, setCurrentPassword] = useState('') - const [newPassword, setNewPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - const [success, setSuccess] = useState('') - - const handleSubmit = async (e) => { - e.preventDefault() - setError('') - setSuccess('') - - if (newPassword && newPassword !== confirmPassword) { - setError('New passwords do not match') - return - } - if (newPassword && newPassword.length < 8) { - setError('New password must be at least 8 characters') - return - } - - setLoading(true) - try { - const payload = {} - if (name !== user.name) payload.name = name - if (newPassword) { - payload.current_password = currentPassword - payload.new_password = newPassword - } - - if (!Object.keys(payload).length) { - setError('No changes to save') - setLoading(false) - return - } - - await api.put('/auth/me', payload) - setSuccess('Account updated successfully') - setCurrentPassword('') - setNewPassword('') - setConfirmPassword('') - // Refresh user info - await api.get('/auth/me') - // Update local token display by refreshing - window.location.reload() - } catch (err) { - setError(err.response?.data?.detail || 'Failed to update account') - } finally { - setLoading(false) - } - } - - return ( -
-
-

My Account

-

- {user?.email} · - {user?.role} -

-
- - {error &&
{error}
} - {success &&
{success}
} - -
-
-
- - setName(e.target.value)} - required - /> -
- -
-

- Change Password (leave blank to keep current) -

- -
- - setCurrentPassword(e.target.value)} - placeholder="Required to change password" - /> -
-
- - setNewPassword(e.target.value)} - placeholder="At least 8 characters" - /> -
-
- - setConfirmPassword(e.target.value)} - /> -
- - -
-
-
- ) -} diff --git a/frontend/src/pages/CategoriesPage.jsx b/frontend/src/pages/CategoriesPage.jsx index c7cf1e4..b2c34fe 100644 --- a/frontend/src/pages/CategoriesPage.jsx +++ b/frontend/src/pages/CategoriesPage.jsx @@ -57,12 +57,6 @@ export default function CategoriesPage() { const [newParent, setNewParent] = useState('') const [busy, setBusy] = useState(false) - // Attaching questions to whichever entry is open for it. - const [attaching, setAttaching] = useState(null) - const [attachQuery, setAttachQuery] = useState('') - const [attachResults, setAttachResults] = useState([]) - const [attachPicked, setAttachPicked] = useState([]) - const [attachSearching, setAttachSearching] = useState(false) const facet = FACETS.find(f => f.key === facetKey) || FACETS[0] @@ -90,7 +84,7 @@ export default function CategoriesPage() { // search does not follow you onto a different axis. useEffect(() => { setQuery(''); setExpanded({}); setEditing(null); setDeleting(null) - setCreating(false); setAttaching(null); setError(''); setNotice('') + setCreating(false); setError(''); setNotice('') }, [facetKey]) /** Rows for the open facet, normalised to one shape whichever table they came from. */ @@ -173,7 +167,7 @@ export default function CategoriesPage() { const endpoint = facet.source === 'category' ? '/question-categories' : '/tags' const startEdit = (row) => { - setDeleting(null); setAttaching(null) + setDeleting(null) setEditing(row.id); setDraftName(row.name); setDraftParent(row.parent_id ?? '') } @@ -221,37 +215,6 @@ export default function CategoriesPage() { finally { setBusy(false) } } - const startAttach = (row) => { - setEditing(null); setDeleting(null) - setAttaching(row.id); setAttachQuery(''); setAttachResults([]); setAttachPicked([]) - } - - const searchQuestions = async () => { - if (!attachQuery.trim()) return - setAttachSearching(true); setError('') - try { - const res = await api.get('/questions/bank', { params: { q: attachQuery.trim(), limit: 25 } }) - setAttachResults(res.data?.questions || res.data?.items || []) - } catch (err) { setError(apiError(err, 'Could not search questions')) } - finally { setAttachSearching(false) } - } - - const attach = async (row) => { - if (!attachPicked.length) return - setBusy(true); setError('') - try { - if (facet.source === 'category') { - await api.post('/questions/bulk-category', - { question_ids: attachPicked, category_id: row.id }) - } else { - await api.post(`/tags/${row.id}/questions`, { question_ids: attachPicked }) - } - setNotice(`Added ${attachPicked.length} question${attachPicked.length === 1 ? '' : 's'} to “${row.name}”.`) - setAttaching(null); setAttachPicked([]); setAttachResults([]) - load() - } catch (err) { setError(apiError(err, 'Could not attach those questions')) } - finally { setBusy(false) } - } // Deleting a category with subcategories is refused server-side; tags instead // pull their children up a level, so only one facet needs the guard. @@ -268,11 +231,9 @@ export default function CategoriesPage() { <> - + onClick={() => { setEditing(null); setDeleting(row.id); setMoveTo('') }}>Delete )} @@ -309,38 +270,6 @@ export default function CategoriesPage() { )} - {attaching === row.id && ( -
-
- setAttachQuery(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') searchQuestions() }} /> - - -
- {attachResults.length > 0 && ( -
    - {attachResults.map(q => ( -
  • - -
  • - ))} -
- )} - {attachPicked.length > 0 && ( - - )} -
- )} ) diff --git a/frontend/src/pages/CategoriesPage.test.jsx b/frontend/src/pages/CategoriesPage.test.jsx index b4f8dcd..2ec4348 100644 --- a/frontend/src/pages/CategoriesPage.test.jsx +++ b/frontend/src/pages/CategoriesPage.test.jsx @@ -213,38 +213,11 @@ it('deletes a tag, saying its children rise rather than refusing', async () => { await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/tags/21', { params: {} })) }) -it('attaches searched questions to a tag', async () => { +it('does not search the question bank from here', async () => { mount() await screen.findByText('Root') - await openFacet('Diseases') - api.get.mockResolvedValueOnce({ data: { questions: [ - { id: 5, question_text: 'A 4-year-old with wheeze…' }, - { id: 6, question_text: 'An infant with stridor…' }, - ] } }) - api.post.mockResolvedValue({ data: { added: 1 } }) - - await userEvent.click(await screen.findByRole('button', { name: 'Add questions to Asthma' })) - await userEvent.type(screen.getByLabelText('Search questions to add to Asthma'), 'wheeze') - await userEvent.click(screen.getByRole('button', { name: 'Search' })) - - await userEvent.click(await screen.findByRole('checkbox', { name: /wheeze/ })) - await userEvent.click(screen.getByRole('button', { name: 'Add 1 question' })) - - await waitFor(() => expect(api.post).toHaveBeenCalledWith('/tags/30/questions', { question_ids: [5] })) -}) - -it('files questions into a topic through the category endpoint instead', async () => { - mount() - await screen.findByText('Root') - api.get.mockResolvedValueOnce({ data: { questions: [{ id: 5, question_text: 'A newborn…' }] } }) - api.post.mockResolvedValue({ data: { updated: 1 } }) - - await userEvent.click(screen.getByRole('button', { name: 'Add questions to Root' })) - await userEvent.type(screen.getByLabelText('Search questions to add to Root'), 'newborn') - await userEvent.click(screen.getByRole('button', { name: 'Search' })) - await userEvent.click(await screen.findByRole('checkbox', { name: /newborn/ })) - await userEvent.click(screen.getByRole('button', { name: 'Add 1 question' })) - - await waitFor(() => expect(api.post).toHaveBeenCalledWith('/questions/bulk-category', - { question_ids: [5], category_id: 1 })) + // Filing a question is done where the question is. Searching the whole + // bank from a taxonomy row was a second, worse question bank. + expect(screen.queryByRole('button', { name: /Add questions to/ })).toBeNull() + expect(screen.queryByPlaceholderText('Search questions…')).toBeNull() }) diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 504ad99..9a83230 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -123,7 +123,7 @@ function StudySection() { return (
- +
) } @@ -461,7 +461,6 @@ export default function SettingsPage() {

Settings

-

Your account, how the site looks, and — if it is yours to set — how the site behaves.