refactor(exam): flat sidebar list, weight badges, UI cleanup
Signed-off-by: Abhinav Sinha <[email protected]>
This commit is contained in:
@@ -2,11 +2,18 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import styles from './ExamStartModal.module.css'
|
||||
|
||||
export default function ExamStartModal({ bundle, onStart, onCancel }) {
|
||||
const totalScenarios = bundle?.scenario_ids?.length || 0
|
||||
const [minutes, setMinutes] = useState(bundle?.exam_minutes || 120)
|
||||
const [scenarioCount, setScenarioCount] = useState(totalScenarios)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
// Keep scenarioCount in sync if bundle changes
|
||||
useEffect(() => {
|
||||
// Focus input on open
|
||||
setScenarioCount(bundle?.scenario_ids?.length || 0)
|
||||
}, [bundle])
|
||||
|
||||
useEffect(() => {
|
||||
// Focus duration input on open
|
||||
const t = setTimeout(() => inputRef.current?.select(), 60)
|
||||
return () => clearTimeout(t)
|
||||
}, [])
|
||||
@@ -14,11 +21,14 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
|
||||
if (!bundle) return null
|
||||
|
||||
const numMinutes = Number(minutes)
|
||||
const isValid = numMinutes >= 5 && numMinutes <= 300
|
||||
const numScenarios = Number(scenarioCount)
|
||||
const isMinutesValid = numMinutes >= 5 && numMinutes <= 300
|
||||
const isScenariosValid = numScenarios >= 1 && numScenarios <= totalScenarios
|
||||
const isValid = isMinutesValid && isScenariosValid
|
||||
|
||||
const handleStart = () => {
|
||||
if (!isValid) return
|
||||
onStart(numMinutes)
|
||||
onStart(numMinutes, numScenarios)
|
||||
}
|
||||
|
||||
const presets = [
|
||||
@@ -42,9 +52,10 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
|
||||
<div className={styles.body}>
|
||||
<div className={styles.info}>
|
||||
<span>📋</span>
|
||||
<span>{bundle.scenario_ids?.length || '?'} scenarios · Recommended: <strong>{bundle.exam_minutes} min</strong></span>
|
||||
<span>{totalScenarios} scenarios · Recommended: <strong>{bundle.exam_minutes} min</strong></span>
|
||||
</div>
|
||||
|
||||
{/* Duration field */}
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Exam Duration</label>
|
||||
<div className={styles.presets}>
|
||||
@@ -73,13 +84,46 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
|
||||
<span className={styles.unit}>minutes</span>
|
||||
</div>
|
||||
<div className={styles.hint}>
|
||||
{!isValid ? (
|
||||
{!isMinutesValid ? (
|
||||
<span style={{ color: 'var(--red)' }}>⚠ Duration must be between 5 and 300 minutes</span>
|
||||
) : numMinutes < 60 ? '⚡ Speed run mode' :
|
||||
numMinutes <= 120 ? '🎯 Realistic exam timing' :
|
||||
'🧘 Relaxed practice pace'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scenario count field */}
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Number of Scenarios</label>
|
||||
<div className={styles.scenarioRow}>
|
||||
<input
|
||||
type="range"
|
||||
className={styles.slider}
|
||||
value={numScenarios}
|
||||
min={1}
|
||||
max={totalScenarios}
|
||||
step={1}
|
||||
onChange={e => setScenarioCount(Number(e.target.value))}
|
||||
style={{ '--bcolor': bundle.color }}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.input}
|
||||
value={scenarioCount}
|
||||
min={1}
|
||||
max={totalScenarios}
|
||||
onChange={e => setScenarioCount(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleStart()}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.hint}>
|
||||
{!isScenariosValid ? (
|
||||
<span style={{ color: 'var(--red)' }}>⚠ Must be between 1 and {totalScenarios}</span>
|
||||
) : numScenarios === totalScenarios
|
||||
? `📚 Full exam — all ${totalScenarios} scenarios`
|
||||
: `🎯 ${numScenarios} randomly selected scenario${numScenarios > 1 ? 's' : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
|
||||
@@ -116,6 +116,24 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scenarioRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border2);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
accent-color: var(--bcolor, var(--blue));
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 90px;
|
||||
padding: 8px 12px;
|
||||
|
||||
@@ -24,7 +24,7 @@ async function resetProgress(scope, opts) {
|
||||
})
|
||||
}
|
||||
|
||||
export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode, examProgress }) {
|
||||
export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode, examProgress, totalExamWeight }) {
|
||||
const [tab, setTab] = useState('problem')
|
||||
const [setupState, setSetupState] = useState('idle') // idle | running | done | error
|
||||
const [validating, setValidating] = useState(false)
|
||||
@@ -208,6 +208,11 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
)}
|
||||
<span className={styles.typeTag}>{scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'}</span>
|
||||
{!isExamMode && <span className={styles.weight}>{scenario.weight} pts</span>}
|
||||
{isExamMode && totalExamWeight > 0 && (
|
||||
<span className={styles.examWeight}>
|
||||
{Math.round((scenario.weight / totalExamWeight) * 100)}% weight
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.scenarioTitle}>{scenario.title}</div>
|
||||
|
||||
@@ -76,6 +76,19 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.examWeight {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--teal);
|
||||
background: var(--teal-dim);
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid color-mix(in srgb, var(--teal) 30%, transparent);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
.scenarioTitle {
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function Sidebar({
|
||||
scenarios, activeId, onSelect, loading,
|
||||
collapsed, onToggleCollapse, width,
|
||||
activeBundleId, onProgressUpdate,
|
||||
isExamMode, examProgress,
|
||||
isExamMode, examProgress, totalExamWeight,
|
||||
}) {
|
||||
const [filterDiff, setFilterDiff] = useState('All')
|
||||
const [filterType, setFilterType] = useState('All')
|
||||
@@ -106,21 +106,19 @@ export default function Sidebar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
{!collapsed && (
|
||||
{/* Filter bar — hidden in exam mode */}
|
||||
{!collapsed && !isExamMode && (
|
||||
<div className={styles.filterBar}>
|
||||
{!isExamMode && (
|
||||
<select
|
||||
value={filterDiff}
|
||||
onChange={e => setFilterDiff(e.target.value)}
|
||||
className={`${styles.selectFilter} ${filterDiff !== 'All' ? styles[DIFF_COLOR[filterDiff]] : ''}`}
|
||||
>
|
||||
<option value="All">All Difficulties</option>
|
||||
<option value="Easy">Easy</option>
|
||||
<option value="Medium">Medium</option>
|
||||
<option value="Hard">Hard</option>
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
value={filterDiff}
|
||||
onChange={e => setFilterDiff(e.target.value)}
|
||||
className={`${styles.selectFilter} ${filterDiff !== 'All' ? styles[DIFF_COLOR[filterDiff]] : ''}`}
|
||||
>
|
||||
<option value="All">All Difficulties</option>
|
||||
<option value="Easy">Easy</option>
|
||||
<option value="Medium">Medium</option>
|
||||
<option value="Hard">Hard</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={e => setFilterType(e.target.value)}
|
||||
@@ -144,7 +142,42 @@ export default function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && Object.entries(groups).map(([cat, items]) => {
|
||||
{!loading && isExamMode && (
|
||||
/* ── Exam mode: flat numbered list ─────────────────── */
|
||||
<div className={styles.flatList}>
|
||||
{scenarios.map((s, idx) => {
|
||||
const examDone = examProgress?.[s.id]?.status === 'completed'
|
||||
const active = s.id === activeId
|
||||
const hasAttempts = (examProgress?.[s.id]?.attempts || 0) > 0
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`${styles.item} ${active ? styles.active : ''} ${examDone ? styles.done : ''}`}
|
||||
onClick={() => onSelect(s.id)}
|
||||
>
|
||||
<div className={styles.itemTop}>
|
||||
<span className={styles.itemNum}>{idx + 1}</span>
|
||||
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
|
||||
<span className={styles.itemTitle}>{s.title}</span>
|
||||
</div>
|
||||
<div className={styles.itemMeta}>
|
||||
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
|
||||
{totalExamWeight > 0 && (
|
||||
<span className={styles.examWeightBadge}>
|
||||
{Math.round((s.weight / totalExamWeight) * 100)}% wt
|
||||
</span>
|
||||
)}
|
||||
{examDone && (
|
||||
<span className={styles.examCompletedTag}>✓ Completed</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !isExamMode && Object.entries(groups).map(([cat, items]) => {
|
||||
const catDone = items.filter(s => s.progress?.status === 'completed').length
|
||||
const isOpen = open[cat] !== false
|
||||
const hasCatProgress = items.some(s => s.progress?.attempts > 0)
|
||||
@@ -158,9 +191,7 @@ export default function Sidebar({
|
||||
</div>
|
||||
<div className={styles.accordionRight}>
|
||||
<span className={styles.catCount}>
|
||||
{isExamMode
|
||||
? `${items.filter(s => examProgress?.[s.id]?.status === 'completed').length}/${items.length}`
|
||||
: `${catDone}/${items.length}`}
|
||||
{`${catDone}/${items.length}`}
|
||||
</span>
|
||||
{hasCatProgress && (
|
||||
<button
|
||||
@@ -180,24 +211,20 @@ export default function Sidebar({
|
||||
{isOpen && (
|
||||
<div className={styles.itemsBox}>
|
||||
{items.map(s => {
|
||||
const examDone = isExamMode && examProgress?.[s.id]?.status === 'completed'
|
||||
const done = !isExamMode && s.progress?.status === 'completed'
|
||||
const done = s.progress?.status === 'completed'
|
||||
const active = s.id === activeId
|
||||
const hasAttempts = isExamMode
|
||||
? (examProgress?.[s.id]?.attempts || 0) > 0
|
||||
: s.progress?.attempts > 0
|
||||
const hasAttempts = s.progress?.attempts > 0
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`${styles.item} ${active ? styles.active : ''} ${(done || examDone) ? styles.done : ''}`}
|
||||
className={`${styles.item} ${active ? styles.active : ''} ${done ? styles.done : ''}`}
|
||||
onClick={() => onSelect(s.id)}
|
||||
>
|
||||
<div className={styles.itemTop}>
|
||||
<span className={styles.itemNum}>{scenarioIndex[s.id]}</span>
|
||||
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
|
||||
<span className={styles.itemTitle}>{s.title}</span>
|
||||
{(done || examDone) && <span className={styles.checkmark}>✓</span>}
|
||||
{/* Per-scenario reset — shown when item has attempts */}
|
||||
{done && <span className={styles.checkmark}>✓</span>}
|
||||
{hasAttempts && (
|
||||
<button
|
||||
className={styles.itemResetBtn}
|
||||
@@ -212,16 +239,11 @@ export default function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.itemMeta}>
|
||||
{!isExamMode && (
|
||||
<span className={`${styles.diff} ${styles[DIFF_COLOR[s.difficulty]]}`}>
|
||||
{s.difficulty}
|
||||
</span>
|
||||
)}
|
||||
<span className={`${styles.diff} ${styles[DIFF_COLOR[s.difficulty]]}`}>
|
||||
{s.difficulty}
|
||||
</span>
|
||||
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
|
||||
{!isExamMode && <span className={styles.weight}>{s.weight}pt</span>}
|
||||
{isExamMode && examDone && (
|
||||
<span className={styles.examCompletedTag}>✓ Completed</span>
|
||||
)}
|
||||
<span className={styles.weight}>{s.weight}pt</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -70,6 +70,22 @@
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.flatList {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 6px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.flatList .item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
|
||||
/* Filters */
|
||||
.filterBar {
|
||||
display: flex;
|
||||
@@ -338,6 +354,18 @@
|
||||
|
||||
@keyframes slideIn { from{opacity:0;transform:translateX(-4px)} to{opacity:1;transform:translateX(0)} }
|
||||
|
||||
.examWeightBadge {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--teal-dim);
|
||||
color: var(--teal);
|
||||
border: 1px solid color-mix(in srgb, var(--teal) 30%, transparent);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.examCompletedTag {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
|
||||
Reference in New Issue
Block a user