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} +
+ +
+ ) + + // Narrow screens show the deepest column only, with a way back up. + const deepest = columns.length - 1 + + return ( +
+ {path.length > 0 && ( + + )} +
+ {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 ( + + ) + })} + {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" /> - +
+ + {/* 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 => ( - - )) - }} + 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 => ( - - ))} - {visibleCategories.length === 0 &&

Nothing matches that search.

} + toggleExtra(id)} />
{form.extraCategoryIds.length > 0 && (