diff --git a/frontend/src/components/CategoryColumns.css b/frontend/src/components/CategoryColumns.css
new file mode 100644
index 0000000..d796a3a
--- /dev/null
+++ b/frontend/src/components/CategoryColumns.css
@@ -0,0 +1,74 @@
+/* Side-by-side category columns, as in a library browser.
+ Under 720px there is no room for two, so it becomes one column plus a back
+ button — the same navigation drawn for the width available. */
+
+.cc-wrap { display: flex; flex-direction: column; gap: 8px; }
+
+.cc-back {
+ align-self: flex-start;
+ display: none;
+ align-items: center;
+ gap: 6px;
+ min-height: 44px;
+ padding: 8px 12px;
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ font: inherit;
+ font-size: 0.86rem;
+ color: var(--primary);
+ cursor: pointer;
+}
+
+.cc-columns { display: flex; gap: 14px; align-items: flex-start; overflow-x: auto; }
+.cc-column-slot { flex: 0 0 clamp(220px, 32%, 320px); min-width: 0; }
+
+.cc-column {
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ overflow: hidden;
+}
+.cc-column-head {
+ padding: 11px 14px;
+ border-bottom: 1px solid var(--border);
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--text-subtle);
+ overflow-wrap: anywhere;
+}
+.cc-column ul { list-style: none; margin: 0; padding: 0; max-height: 420px; overflow-y: auto; }
+.cc-column li + li .cc-row { border-top: 1px solid var(--border); }
+
+.cc-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ min-height: 44px;
+ padding: 10px 14px;
+ background: none;
+ border: none;
+ font: inherit;
+ text-align: left;
+ color: var(--text);
+ cursor: pointer;
+}
+.cc-row:hover { background: var(--bg); }
+.cc-row.is-open { background: var(--option-sel-bg); }
+.cc-row.is-selected { font-weight: 650; color: var(--primary); }
+
+.cc-name { flex: 1; min-width: 0; font-size: 0.89rem; line-height: 1.35; overflow-wrap: anywhere; }
+.cc-count { flex-shrink: 0; font-size: 0.76rem; color: var(--text-subtle); font-variant-numeric: tabular-nums; }
+.cc-chevron { flex-shrink: 0; color: var(--text-subtle); font-size: 1.1rem; line-height: 1; }
+
+@media (max-width: 720px) {
+ .cc-back { display: inline-flex; }
+ /* Only the column you are looking at; the trail is the back button. */
+ .cc-column-slot { display: none; flex: 1 1 100%; }
+ .cc-column-slot.is-deepest { display: block; }
+ .cc-columns { overflow-x: visible; }
+ .cc-column ul { max-height: none; }
+}
diff --git a/frontend/src/components/CategoryColumns.jsx b/frontend/src/components/CategoryColumns.jsx
new file mode 100644
index 0000000..b55971b
--- /dev/null
+++ b/frontend/src/components/CategoryColumns.jsx
@@ -0,0 +1,109 @@
+import { useMemo, useState } from 'react'
+import './CategoryColumns.css'
+
+/**
+ * Browse the category tree as side-by-side columns.
+ *
+ * Picking a row in one column opens its children in the next, so the trail you
+ * took stays on screen — you can see where you are and step back a level without
+ * losing your place. A single dropdown of 780 names shows none of that.
+ *
+ * On a phone there is no room for two columns, so it becomes one column with a
+ * back button: the same navigation, drawn for the width available, rather than
+ * two columns squeezed until neither is readable.
+ */
+export default function CategoryColumns({ categories, selectedId, onSelect, allLabel = 'All categories' }) {
+ // The chain of opened parents, root first.
+ const [path, setPath] = useState([])
+
+ const childrenOf = useMemo(() => {
+ const map = {}
+ for (const cat of categories) (map[cat.parent_id || 0] ||= []).push(cat)
+ for (const list of Object.values(map)) list.sort((a, b) => a.name.localeCompare(b.name))
+ return map
+ }, [categories])
+
+ const rollup = useMemo(() => {
+ const totals = {}
+ const walk = (cat) => {
+ let sum = cat.question_count || 0
+ for (const child of childrenOf[cat.id] || []) sum += walk(child)
+ totals[cat.id] = sum
+ return sum
+ }
+ for (const root of childrenOf[0] || []) walk(root)
+ return totals
+ }, [childrenOf])
+
+ const byId = useMemo(() => Object.fromEntries(categories.map(c => [c.id, c])), [categories])
+
+ // One column per opened level: the roots, then each opened parent's children.
+ const columns = [{ parentId: 0, items: childrenOf[0] || [] }]
+ for (const id of path) {
+ const kids = childrenOf[id] || []
+ if (!kids.length) break
+ columns.push({ parentId: id, items: kids })
+ }
+
+ const openAt = (level, cat) => {
+ const next = [...path.slice(0, level), cat.id]
+ setPath(childrenOf[cat.id]?.length ? next : path.slice(0, level))
+ onSelect(cat.id)
+ }
+
+ const renderColumn = (column, level) => (
+
+
+ {level === 0 ? allLabel : byId[column.parentId]?.name}
+
+
+ {level === 0 && (
+
+ { setPath([]); onSelect(null) }}>
+ {allLabel}
+
+
+ )}
+ {column.items.map(cat => {
+ const kids = childrenOf[cat.id] || []
+ const isOpen = path[level] === cat.id
+ return (
+
+ openAt(level, cat)}>
+ {cat.name}
+ {rollup[cat.id] ?? cat.question_count}
+ {kids.length > 0 && › }
+
+
+ )
+ })}
+
+
+ )
+
+ // Narrow screens show the deepest column only, with a way back up.
+ const deepest = columns.length - 1
+
+ return (
+
+ {path.length > 0 && (
+
setPath(path.slice(0, -1))}>
+ ‹ {byId[path[path.length - 2]]?.name || allLabel}
+
+ )}
+
+ {columns.map((column, level) => (
+
+ {renderColumn(column, level)}
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/components/CategoryDrilldown.css b/frontend/src/components/CategoryDrilldown.css
new file mode 100644
index 0000000..797fc7c
--- /dev/null
+++ b/frontend/src/components/CategoryDrilldown.css
@@ -0,0 +1,91 @@
+/* Drill-down category picker.
+ Touch targets stay at least 44px tall, and the expand control is separate from
+ the checkbox so neither is a near-miss for the other on a phone. */
+
+.cd-level { list-style: none; margin: 0; padding: 0; }
+.cd-level.cd-depth-1,
+.cd-level.cd-depth-2 {
+ margin-left: 10px;
+ padding-left: 10px;
+ border-left: 2px solid var(--border);
+}
+
+.cd-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 44px;
+ padding: 4px 2px;
+ border-radius: 8px;
+}
+.cd-row:hover { background: var(--bg); }
+.cd-row.is-open { background: var(--option-sel-bg); }
+
+.cd-row > input[type='checkbox'] {
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+ margin: 0;
+ cursor: pointer;
+}
+
+.cd-label {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ cursor: pointer;
+ padding: 6px 0;
+}
+.cd-name { font-size: 0.9rem; line-height: 1.35; overflow-wrap: anywhere; }
+.cd-trail { font-size: 0.73rem; color: var(--text-subtle); overflow-wrap: anywhere; }
+
+.cd-count {
+ flex-shrink: 0;
+ font-size: 0.78rem;
+ color: var(--text-subtle);
+ font-variant-numeric: tabular-nums;
+ min-width: 30px;
+ text-align: right;
+}
+
+/* Expanding is a separate control from selecting: opening a branch must never
+ silently apply a filter. */
+/* Left of the checkbox, so the eye reads: open this / choose this / what it is. */
+.cd-expand {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ min-height: 44px;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ color: var(--text-muted);
+ cursor: pointer;
+ font: inherit;
+}
+.cd-expand:hover { background: var(--card-bg); border-color: var(--border); color: var(--primary); }
+.cd-expand:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
+
+.cd-chevron {
+ display: inline-block;
+ font-size: 1.15rem;
+ line-height: 1;
+ transition: transform 0.15s ease;
+}
+.cd-expand[aria-expanded='true'] .cd-chevron { transform: rotate(90deg); }
+.cd-expand.is-leaf { pointer-events: none; }
+
+.cd-results { display: flex; flex-direction: column; }
+
+@media (max-width: 640px) {
+ /* Indentation costs width that long condition names need. */
+ .cd-level.cd-depth-1,
+ .cd-level.cd-depth-2 { margin-left: 5px; padding-left: 7px; }
+ .cd-name { font-size: 0.92rem; }
+ .cd-count { min-width: 26px; }
+ .cd-expand { width: 28px; }
+}
diff --git a/frontend/src/components/CategoryDrilldown.jsx b/frontend/src/components/CategoryDrilldown.jsx
new file mode 100644
index 0000000..9e41672
--- /dev/null
+++ b/frontend/src/components/CategoryDrilldown.jsx
@@ -0,0 +1,124 @@
+import { useMemo, useState } from 'react'
+import './CategoryDrilldown.css'
+
+/**
+ * Pick categories by walking the tree, one level at a time.
+ *
+ * The tree is three deep and about 780 rows, so a flat list of breadcrumb
+ * strings ("Neurology › Seizures & Epilepsy › Absence Seizure") is unreadable —
+ * you cannot see the shape, and everything competes for attention at once.
+ *
+ * Rules, deliberately:
+ * * nothing is expanded on load;
+ * * expanding is a separate control from selecting, so opening a branch never
+ * silently filters;
+ * * one branch open per level — opening a sibling closes the previous one, so
+ * the list never grows into the same wall it replaced;
+ * * searching flattens to matches with their trail, because when you type a
+ * name you want the row, not the path to it.
+ *
+ * Selecting a parent means "and everything beneath it"; the count shown is the
+ * roll-up, so the number matches what the filter will actually return.
+ */
+export default function CategoryDrilldown({ categories, selectedIds, onToggle, query = '' }) {
+ // One open branch per parent, keyed by that parent's id.
+ const [openAt, setOpenAt] = useState({})
+
+ const childrenOf = useMemo(() => {
+ const map = {}
+ for (const cat of categories) (map[cat.parent_id || 0] ||= []).push(cat)
+ for (const list of Object.values(map)) list.sort((a, b) => a.name.localeCompare(b.name))
+ return map
+ }, [categories])
+
+ const rollup = useMemo(() => {
+ const totals = {}
+ const walk = (cat) => {
+ let sum = cat.question_count || 0
+ for (const child of childrenOf[cat.id] || []) sum += walk(child)
+ totals[cat.id] = sum
+ return sum
+ }
+ for (const root of childrenOf[0] || []) walk(root)
+ return totals
+ }, [childrenOf])
+
+ const trail = (cat) => {
+ const parts = []
+ let cursor = cat
+ let guard = 0
+ while (cursor && guard++ < 6) {
+ parts.unshift(cursor.name)
+ cursor = categories.find(c => c.id === cursor.parent_id)
+ }
+ return parts
+ }
+
+ // Typing wants the matching row, not the branch it lives in.
+ if (query) {
+ const hits = categories.filter(c => c.name.toLowerCase().includes(query))
+ if (!hits.length) return Nothing matches that search.
+ return (
+
+ {hits.slice(0, 80).map(cat => {
+ const path = trail(cat)
+ return (
+
+ onToggle(cat.id, e.target.checked)} />
+
+ {cat.name}
+ {path.length > 1 && {path.slice(0, -1).join(' › ')} }
+
+ {rollup[cat.id] ?? cat.question_count}
+
+ )
+ })}
+ {hits.length > 80 && (
+
{hits.length - 80} more — keep typing to narrow.
+ )}
+
+ )
+ }
+
+ const renderLevel = (parentId, depth) => {
+ const branch = childrenOf[parentId] || []
+ if (!branch.length) return null
+ return (
+
+ )
+ }
+
+ return renderLevel(0, 0) || No categories yet.
+}
diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css
index 0131a1d..8495fdb 100644
--- a/frontend/src/pages/ArticlesPage.css
+++ b/frontend/src/pages/ArticlesPage.css
@@ -137,3 +137,13 @@
.article-section { margin-top: 20px; }
.article-section > h2 { font-size: 1.06rem; }
}
+
+/* Category browser on the articles page */
+.articles-browse {
+ display: inline-flex; align-items: center; gap: 8px; min-height: 42px;
+ padding: 9px 14px; background: var(--card-bg); border: 1px solid var(--border);
+ border-radius: 8px; font: inherit; font-size: .88rem; color: var(--text); cursor: pointer;
+}
+.articles-browse:hover { border-color: var(--primary); color: var(--primary); }
+.articles-browse[aria-expanded='true'] { border-color: var(--primary); color: var(--primary); }
+.articles-browser { margin-bottom: 16px; }
diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx
index 6ef8765..1642d87 100644
--- a/frontend/src/pages/ArticlesPage.jsx
+++ b/frontend/src/pages/ArticlesPage.jsx
@@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import RichEditor from '../components/RichEditor'
+import CategoryColumns from '../components/CategoryColumns'
import CommentSection from '../components/CommentSection'
import PractiseTopic from '../components/PractiseTopic'
import { markdownImageUrl } from '../utils/uploads'
@@ -34,6 +35,7 @@ export default function ArticlesPage() {
const [articles, setArticles] = useState([])
const [categories, setCategories] = useState([])
const [categoryId, setCategoryId] = useState('')
+ const [browseOpen, setBrowseOpen] = useState(false)
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
@@ -44,6 +46,7 @@ export default function ArticlesPage() {
const [aiTopic, setAiTopic] = useState('')
const [aiInstructions, setAiInstructions] = useState('')
const [aiStatus, setAiStatus] = useState('')
+ const categoryName = categories.find(c => String(c.id) === String(categoryId))?.name
const navigate = useNavigate()
const load = useCallback(() => {
@@ -131,11 +134,20 @@ export default function ArticlesPage() {
)}
setQuery(e.target.value)} placeholder="Search articles" aria-label="Search articles" />
- setCategoryId(e.target.value)} aria-label="Filter by category">
- All categories
- {categories.map(c => {c.name} )}
-
+ setBrowseOpen(v => !v)}>
+ {categoryName || 'All categories'} {browseOpen ? '▲' : '▼'}
+
+
+ {/* Columns rather than one long dropdown: the trail you took stays on
+ screen, so you can see where you are and step back a level. */}
+ {browseOpen && (
+
+ setCategoryId(id == null ? '' : String(id))} />
+
+ )}
{loading ? : articles.length === 0 ? (
No articles yet. Educators add and refine articles gradually.
) : (
diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx
index ea4e06b..4de0b27 100644
--- a/frontend/src/pages/CustomQuizPage.jsx
+++ b/frontend/src/pages/CustomQuizPage.jsx
@@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import FacetPicker, { FacetRow } from '../components/FacetPicker'
+import CategoryDrilldown from '../components/CategoryDrilldown'
import './CustomQuizPage.css'
const STATE_LABEL = { all: 'All', unused: 'Unused', incorrect: 'Incorrect', bookmarked: 'Saved' }
@@ -373,8 +374,11 @@ export default function CustomQuizPage() {
{/* ── Facet pickers ────────────────────────────────────────── */}
setOpenFacet(null)}
onReset={() => setCategoryIds([])}
- helper="By default, all systems are included unless filters are selected. A parent includes its subcategories.">
- {query => renderTree(0, query) || Nothing matches that search.
}
+ helper="Open a system to see what is under it. Choosing one includes everything beneath.">
+ {query => (
+ toggleCategory(id, on)} />
+ )}
setOpenFacet(null)}
diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx
index d188cb4..2709599 100644
--- a/frontend/src/pages/QuestionBankPage.jsx
+++ b/frontend/src/pages/QuestionBankPage.jsx
@@ -5,6 +5,7 @@ import api from '../api/client'
import Dialog from '../components/Dialog'
import CategoryTree from '../components/CategoryTree'
import FacetPicker, { FacetRow } from '../components/FacetPicker'
+import CategoryDrilldown from '../components/CategoryDrilldown'
import './CustomQuizPage.css' // facet row + picker panel styles
import './QuestionBankPage.css'
import { useDialog } from '../hooks/useDialog'
@@ -908,19 +909,11 @@ export default function QuestionBankPage() {
setOpenFacet(null)}
onReset={() => setFilterCatIds([])}
- helper="By default all systems are included unless filters are selected.">
- {query => {
- const shown = categories.filter(c => !query || c.name.toLowerCase().includes(query))
- if (!shown.length) return Nothing matches that search.
- return shown.map(cat => (
-
- setFilterCatIds(ids => e.target.checked ? [...ids, cat.id] : ids.filter(id => id !== cat.id))} />
- {(cat.breadcrumbs || []).map(c => c.name).join(' › ') || cat.name}
- {cat.question_count}
-
- ))
- }}
+ helper="Open a system to see what is under it. Choosing one includes everything beneath.">
+ {query => (
+ setFilterCatIds(ids => on ? [...ids, id] : ids.filter(c => c !== id))} />
+ )}
setOpenFacet(null)}
diff --git a/frontend/src/pages/QuestionEditPage.css b/frontend/src/pages/QuestionEditPage.css
index e73dd62..87d0963 100644
--- a/frontend/src/pages/QuestionEditPage.css
+++ b/frontend/src/pages/QuestionEditPage.css
@@ -57,7 +57,8 @@
width: 100%; padding: 8px 11px; font-size: 0.86rem; margin-bottom: 8px;
border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text);
}
-.qe-cat-list { max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; }
+.qe-cat-heading { display: block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle); margin: 4px 0 6px; }
+.qe-cat-list { max-height: 300px; overflow-y: auto; }
.qe-cat-list label {
display: flex; align-items: center; gap: 9px; padding: 7px 4px;
font-size: 0.86rem; cursor: pointer; border-radius: 6px;
diff --git a/frontend/src/pages/QuestionEditPage.jsx b/frontend/src/pages/QuestionEditPage.jsx
index 3c510a5..a063d26 100644
--- a/frontend/src/pages/QuestionEditPage.jsx
+++ b/frontend/src/pages/QuestionEditPage.jsx
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../api/client'
+import CategoryDrilldown from '../components/CategoryDrilldown'
import './QuestionEditPage.css'
const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
@@ -96,12 +97,6 @@ export default function QuestionEditPage({ mode = 'edit' }) {
}
})
- const visibleCategories = useMemo(() => {
- const needle = catQuery.trim().toLowerCase()
- if (!needle) return categories
- return categories.filter(c =>
- [c.name, ...(c.breadcrumbs || []).map(b => b.name)].join(' ').toLowerCase().includes(needle))
- }, [categories, catQuery])
const nameOf = (categoryId) => categories.find(c => c.id === categoryId)?.name
@@ -286,25 +281,13 @@ export default function QuestionEditPage({ mode = 'edit' }) {
- Also appears in
+ Also appears in
setCatQuery(e.target.value)} />
- {visibleCategories.map(c => (
-
- toggleExtra(c.id)} />
-
- {c.name}
- {(c.breadcrumbs || []).length > 1 && (
- · {(c.breadcrumbs || []).slice(0, -1).map(b => b.name).join(' › ')}
- )}
-
- {c.question_count}
-
- ))}
- {visibleCategories.length === 0 &&
Nothing matches that search.
}
+
toggleExtra(id)} />
{form.extraCategoryIds.length > 0 && (