fix: bank stylesheet was never imported; drop quiz categories; rail and sessions

QuestionBankPage.css was not imported, so none of the filter-bar styling applied
— which is why "⚙ Filters" and the question count ran together with no card
behind them. Importing it fixes the whole bar, not just the spacing.

Quiz/test categories removed. PREP lives in study plans now, so the Categories
tab, the category selector in the session menu, and the handlers behind them
were managing a concept that no longer exists.

Sessions list shows the first six with "Show all" and a link to the full history
on the analysis page, instead of printing every session inline.

The analysis session rail now runs the full height of the viewport against the
left edge rather than sitting in a boxed card, so a long list is one continuous
column. Below 1100px it returns to a card above the content.

Tests: 136 frontend green, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
This commit is contained in:
Daniel 2026-09-10 04:04:28 +02:00
parent e0dc3a5da5
commit beedb76afb
4 changed files with 52 additions and 94 deletions

View file

@ -1,12 +1,19 @@
/* Session rail beside the analysis, as in a Qbank analysis view. */
.an-layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 24px; align-items: start; }
.an-layout.rail-closed { grid-template-columns: 52px minmax(0, 1fr); }
.an-rail { position: sticky; top: 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
/* The rail runs the full height of the viewport against the left edge, rather
than sitting in a card, so a long session list is one continuous column. */
.an-layout { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 28px; align-items: start; }
.an-layout.rail-closed { grid-template-columns: 48px minmax(0, 1fr); }
.an-rail {
position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column;
background: var(--card-bg); border-right: 1px solid var(--border);
margin-left: calc(50% - 50vw); padding-left: max(0px, calc(50vw - 50%));
box-sizing: content-box;
}
.an-rail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
.an-rail-head h2 { margin: 0; font-size: .92rem; font-weight: 650; white-space: nowrap; }
.an-layout.rail-closed .an-rail-head h2 { display: none; }
.an-rail-head button { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 1.1rem; line-height: 1; padding: 2px 4px; }
.an-rail-list { list-style: none; margin: 0; padding: 0; max-height: 70vh; overflow-y: auto; }
.an-rail-list { list-style: none; margin: 0; padding: 0; flex: 1; overflow-y: auto; }
.an-rail-list > li { border-bottom: 1px solid var(--border); }
.an-rail-list > li:last-child { border-bottom: 0; }
.an-rail-list a { display: flex; flex-direction: column; gap: 7px; padding: 12px 14px; text-decoration: none; color: var(--text); }
@ -17,7 +24,10 @@
@media (max-width: 1100px) {
.an-layout, .an-layout.rail-closed { grid-template-columns: 1fr; }
.an-rail { position: static; }
.an-rail {
position: static; height: auto; margin-left: 0; padding-left: 0;
border-right: 0; border: 1px solid var(--border); border-radius: 12px;
}
.an-rail-list { max-height: 260px; }
}

View file

@ -6,6 +6,7 @@ import Dialog from '../components/Dialog'
import CategoryTree from '../components/CategoryTree'
import FacetPicker, { FacetRow } from '../components/FacetPicker'
import './CustomQuizPage.css' // facet row + picker panel styles
import './QuestionBankPage.css'
import { useDialog } from '../hooks/useDialog'
const TeachChat = lazy(() => import('../components/TeachChat'))

View file

@ -261,3 +261,6 @@
.qz-grid { grid-template-columns: 1fr; }
}
.qz-seemore { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin: 6px 0 4px; }
@media (max-width: 640px) { .qz-seemore .btn { flex: 1; text-align: center; } }

View file

@ -202,7 +202,7 @@ function QuestionStudyModal({ question, query, onClose }) {
}
/* ── One management row: progress, primary action, and the action menu ── */
function SessionRow({ row, categories, isModerator, onChanged, onDelete }) {
function SessionRow({ row, isModerator, onChanged, onDelete }) {
const [menuOpen, setMenuOpen] = useState(false)
const [renaming, setRenaming] = useState(false)
const [draftTitle, setDraftTitle] = useState(row.title)
@ -254,16 +254,6 @@ function SessionRow({ row, categories, isModerator, onChanged, onDelete }) {
finally { setBusy(false) }
}
const assignCategory = async (value) => {
closeMenu(); setBusy(true); setError('')
const catId = value === '' ? null : Number(value)
try {
await api.patch(`/categories/quizzes/${row.quiz_id}`, null, { params: catId == null ? {} : { category_id: catId } })
onChanged(row.quiz_id, { category_id: catId, category_name: categories.find(c => c.id === catId)?.name ?? null })
} catch (err) { setError(err.response?.data?.detail || 'Could not move this test') }
finally { setBusy(false) }
}
return (
<div className="qz-session">
<div className="qz-session-main">
@ -347,15 +337,6 @@ function SessionRow({ row, categories, isModerator, onChanged, onDelete }) {
<button className="qz-menu-item" role="menuitem" disabled={busy} onClick={togglePublish}>
{row.is_published === 0 ? 'Publish' : 'Hide from learners'}
</button>
<label className="qz-menu-item" style={{ display: 'block' }}>
<span style={{ display: 'block', fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: 4 }}>Category</span>
<select value={row.category_id ?? ''} aria-label={`Category for ${row.title}`}
onChange={e => assignCategory(e.target.value)}
style={{ width: '100%', padding: '5px 7px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.82rem' }}>
<option value="">Uncategorized</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</label>
</>}
{canManage && <>
<div className="qz-menu-sep" />
@ -398,18 +379,18 @@ function QuizCard({ quiz, isModerator, onOpenSessions }) {
export default function QuizzesPage() {
const [rows, setRows] = useState([])
const [categories, setCategories] = useState([])
const [loading, setLoading] = useState(true)
const [tab, setTab] = useState('sessions')
const [stateFilter, setStateFilter] = useState('all')
// The full history lives on the analysis page; this is a launcher.
const [showAllSessions, setShowAllSessions] = useState(false)
const SESSION_PREVIEW = 6
const [searchQuery, setSearchQuery] = useState('')
const [searchMode, setSearchMode] = useState('all')
const [searchResults, setSearchResults] = useState(null)
const [searching, setSearching] = useState(false)
const [expandedSearch, setExpandedSearch] = useState(false)
const [studyQuestion, setStudyQuestion] = useState(null)
const [newCatName, setNewCatName] = useState('')
const [addingCat, setAddingCat] = useState(false)
const { dialogProps, openAlert } = useDialog()
const debounceRef = useRef(null)
const { user } = useAuth()
@ -417,13 +398,8 @@ export default function QuizzesPage() {
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
useEffect(() => {
Promise.all([
api.get('/quizzes/sessions'),
api.get('/categories/'),
]).then(([sRes, cRes]) => {
setRows(Array.isArray(sRes.data) ? sRes.data : [])
setCategories(Array.isArray(cRes.data) ? cRes.data : [])
}).catch(console.error).finally(() => setLoading(false))
api.get('/quizzes/sessions')
.then(res => setRows(Array.isArray(res.data) ? res.data : [])).catch(console.error).finally(() => setLoading(false))
}, [])
useEffect(() => {
@ -451,24 +427,6 @@ export default function QuizzesPage() {
const patchRow = (quizId, patch) =>
setRows(prev => prev.map(r => (r.quiz_id === quizId ? { ...r, ...patch } : r)))
const addCategory = async () => {
if (!newCatName.trim()) return
try {
const res = await api.post('/categories/', { name: newCatName.trim() })
setCategories(prev => [...prev, res.data])
setNewCatName('')
setAddingCat(false)
} catch (err) { await openAlert(err.response?.data?.detail || 'Failed to create category', { title: 'Error' }) }
}
const deleteCategory = async (catId) => {
try {
await api.delete(`/categories/${catId}`)
setCategories(prev => prev.filter(c => c.id !== catId))
setRows(prev => prev.map(r => (r.category_id === catId ? { ...r, category_id: null, category_name: null } : r)))
} catch { }
}
const counts = useMemo(() => ({
all: rows.length,
in_progress: rows.filter(r => r.state === 'in_progress').length,
@ -481,16 +439,21 @@ export default function QuizzesPage() {
[rows, stateFilter],
)
const previewRows = useMemo(
() => (showAllSessions ? visibleRows : visibleRows.slice(0, SESSION_PREVIEW)),
[visibleRows, showAllSessions],
)
const dayGroups = useMemo(() => {
const groups = []
for (const row of visibleRows) {
for (const row of previewRows) {
const label = dayLabel(row.last_activity)
const last = groups[groups.length - 1]
if (last && last.label === label) last.rows.push(row)
else groups.push({ label, rows: [row] })
}
return groups
}, [visibleRows])
}, [previewRows])
const libraryGroups = useMemo(() => {
const byCat = new Map()
@ -619,11 +582,6 @@ export default function QuizzesPage() {
<button className="qz-tab" role="tab" aria-selected={tab === 'library'} onClick={() => setTab('library')}>
Library<span className="qz-tab-count">{libraryGroups.length}</span>
</button>
{isModerator && (
<button className="qz-tab" role="tab" aria-selected={tab === 'manage'} onClick={() => setTab('manage')}>
Categories<span className="qz-tab-count">{categories.length}</span>
</button>
)}
</div>
{tab === 'sessions' && (
@ -649,13 +607,32 @@ export default function QuizzesPage() {
<h2 className="qz-dayhead">{group.label}</h2>
<div className="qz-sessions">
{group.rows.map(row => (
<SessionRow key={row.quiz_id} row={row} categories={categories}
<SessionRow key={row.quiz_id} row={row}
isModerator={isModerator} onChanged={patchRow} onDelete={deleteQuiz} />
))}
</div>
</div>
))}
{visibleRows.length > SESSION_PREVIEW && (
<div className="qz-seemore">
{showAllSessions ? (
<button className="btn btn-secondary btn-sm" onClick={() => setShowAllSessions(false)}>
Show fewer
</button>
) : (
<>
<button className="btn btn-secondary btn-sm" onClick={() => setShowAllSessions(true)}>
Show all {visibleRows.length}
</button>
<Link className="btn btn-secondary btn-sm" to="/analysis">
Full session history
</Link>
</>
)}
</div>
)}
<PastAttemptsSection />
</>
)
@ -677,39 +654,6 @@ export default function QuizzesPage() {
))
)}
{tab === 'manage' && isModerator && (
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10, marginBottom: 12, flexWrap: 'wrap' }}>
<h2 style={{ fontSize: '1rem', margin: 0 }}>Test categories</h2>
<button className="btn btn-sm btn-secondary" onClick={() => setAddingCat(v => !v)}>+ Add category</button>
</div>
{addingCat && (
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<input type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)}
placeholder="Category name…" aria-label="New category name"
onKeyDown={e => e.key === 'Enter' && addCategory()}
style={{ flex: 1, minWidth: 170, padding: '7px 12px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.875rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
<button className="btn btn-primary btn-sm" onClick={addCategory}>Add</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setAddingCat(false); setNewCatName('') }}>Cancel</button>
</div>
)}
{categories.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.87rem', margin: 0 }}>No categories yet.</p>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{categories.map(cat => (
<div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 11px', background: 'var(--bg)', borderRadius: 20, border: '1px solid var(--border)', fontSize: '0.82rem' }}>
<span>{cat.name}</span>
<span style={{ color: 'var(--text-muted)' }}>({cat.quiz_count})</span>
<ConfirmButton label="✕" confirmLabel="Delete?" cancelLabel="✕"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-subtle)', fontSize: '0.8rem', padding: '0 2px', lineHeight: 1 }}
onConfirm={() => deleteCategory(cat.id)} />
</div>
))}
</div>
)}
</div>
)}
</>
)}
</div>