feat(exam): add exam history and improve scenario timer
Signed-off-by: Abhinav Sinha <[email protected]>
This commit is contained in:
+123
-7
@@ -67,6 +67,19 @@ function getDb() {
|
|||||||
snapshot TEXT
|
snapshot TEXT
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Separate exam-session progress table — tracks completions per session
|
||||||
|
_db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS exam_progress (
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
scenario_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'in_progress',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
completed_at TEXT,
|
||||||
|
PRIMARY KEY (session_id, scenario_id),
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
return _db;
|
return _db;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,27 +271,85 @@ app.get('/api/sessions/active', (req, res) => {
|
|||||||
const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get();
|
const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get();
|
||||||
if (!session) return res.json(null);
|
if (!session) return res.json(null);
|
||||||
const bundle = loadBundles().find(b => b.id === session.bundle_id);
|
const bundle = loadBundles().find(b => b.id === session.bundle_id);
|
||||||
const progress = loadProgress();
|
|
||||||
const scenarioIds = bundle?.scenario_ids || [];
|
const scenarioIds = bundle?.scenario_ids || [];
|
||||||
const completed = scenarioIds.filter(id => progress[id]?.status === 'completed').length;
|
// Count completions from exam_progress (exam-specific), not global progress
|
||||||
|
const completed = db.prepare(
|
||||||
|
`SELECT COUNT(*) as cnt FROM exam_progress WHERE session_id=? AND status='completed'`
|
||||||
|
).get(session.id)?.cnt || 0;
|
||||||
res.json({ ...session, scenarioCount: scenarioIds.length, completedCount: completed });
|
res.json({ ...session, scenarioCount: scenarioIds.length, completedCount: completed });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/sessions/:id/exam-progress — return per-scenario progress for an exam session
|
||||||
|
app.get('/api/sessions/:id/exam-progress', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.prepare(`SELECT * FROM exam_progress WHERE session_id=?`).all(req.params.id);
|
||||||
|
// Return as a map: scenario_id -> { status, attempts, completed_at }
|
||||||
|
const result = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
result[r.scenario_id] = { status: r.status, attempts: r.attempts, completed_at: r.completed_at };
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/sessions/history — list all completed/abandoned exam sessions (newest first)
|
||||||
|
app.get('/api/sessions/history', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sessions = db.prepare(
|
||||||
|
`SELECT * FROM sessions WHERE status != 'active' ORDER BY started_at DESC`
|
||||||
|
).all();
|
||||||
|
const bundles = loadBundles();
|
||||||
|
|
||||||
|
const result = sessions.map(s => {
|
||||||
|
const bundle = bundles.find(b => b.id === s.bundle_id);
|
||||||
|
let snapshot = [];
|
||||||
|
try { snapshot = s.snapshot ? JSON.parse(s.snapshot) : []; } catch (_) {}
|
||||||
|
const completed = snapshot.filter(x => x.status === 'completed');
|
||||||
|
const totalWeight = snapshot.reduce((a, x) => a + (x.weight || 0), 0);
|
||||||
|
const earnedWeight = completed.reduce((a, x) => a + (x.weight || 0), 0);
|
||||||
|
const pct = totalWeight > 0 ? Math.round((earnedWeight / totalWeight) * 100) : 0;
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
bundle_id: s.bundle_id,
|
||||||
|
bundle_name: bundle?.name || s.bundle_id,
|
||||||
|
bundle_icon: bundle?.icon || '🎓',
|
||||||
|
status: s.status,
|
||||||
|
started_at: s.started_at,
|
||||||
|
submitted_at: s.submitted_at,
|
||||||
|
exam_minutes: s.exam_minutes,
|
||||||
|
duration_secs: s.duration_secs,
|
||||||
|
scenarioCount: snapshot.length,
|
||||||
|
completedCount: completed.length,
|
||||||
|
totalWeight,
|
||||||
|
earnedWeight,
|
||||||
|
pct,
|
||||||
|
passed: pct >= 66,
|
||||||
|
snapshot,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/sessions/:id/submit — submit the exam session
|
// POST /api/sessions/:id/submit — submit the exam session
|
||||||
app.post('/api/sessions/:id/submit', (req, res) => {
|
app.post('/api/sessions/:id/submit', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const session = db.prepare(`SELECT * FROM sessions WHERE id=?`).get(req.params.id);
|
const session = db.prepare(`SELECT * FROM sessions WHERE id=?`).get(req.params.id);
|
||||||
if (!session) return res.status(404).json({ error: 'Session not found' });
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
||||||
const bundle = loadBundles().find(b => b.id === session.bundle_id);
|
const bundle = loadBundles().find(b => b.id === session.bundle_id);
|
||||||
const progress = loadProgress();
|
|
||||||
const scenarios = loadScenarios();
|
const scenarios = loadScenarios();
|
||||||
const bundleScenarios = scenarios.filter(s => bundle?.scenario_ids?.includes(s.id));
|
const bundleScenarios = scenarios.filter(s => bundle?.scenario_ids?.includes(s.id));
|
||||||
|
|
||||||
|
// Build snapshot from exam_progress (exam-specific), falling back to 'not_started'
|
||||||
|
const examProgressRows = db.prepare(`SELECT * FROM exam_progress WHERE session_id=?`).all(req.params.id);
|
||||||
|
const examProgressMap = {};
|
||||||
|
for (const r of examProgressRows) examProgressMap[r.scenario_id] = r;
|
||||||
|
|
||||||
const snapshot = bundleScenarios.map(s => ({
|
const snapshot = bundleScenarios.map(s => ({
|
||||||
id: s.id, title: s.title, weight: s.weight,
|
id: s.id, title: s.title, weight: s.weight,
|
||||||
category: s.category, type: s.type, difficulty: s.difficulty,
|
category: s.category, type: s.type, difficulty: s.difficulty,
|
||||||
status: progress[s.id]?.status || 'not_started',
|
status: examProgressMap[s.id]?.status || 'not_started',
|
||||||
completed_at: progress[s.id]?.completed_at || null,
|
completed_at: examProgressMap[s.id]?.completed_at || null,
|
||||||
attempts: progress[s.id]?.attempts || 0,
|
attempts: examProgressMap[s.id]?.attempts || 0,
|
||||||
}));
|
}));
|
||||||
const startedAt = new Date(session.started_at + 'Z');
|
const startedAt = new Date(session.started_at + 'Z');
|
||||||
const durationSecs = Math.round((Date.now() - startedAt.getTime()) / 1000);
|
const durationSecs = Math.round((Date.now() - startedAt.getTime()) / 1000);
|
||||||
@@ -474,7 +545,7 @@ app.post('/api/scenarios/:id/validate', async (req, res) => {
|
|||||||
if (!passed) allPassed = false;
|
if (!passed) allPassed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update progress
|
// Update global practice progress
|
||||||
const progress = loadProgress();
|
const progress = loadProgress();
|
||||||
const prev = progress[scenario.id] || { attempts: 0 };
|
const prev = progress[scenario.id] || { attempts: 0 };
|
||||||
progress[scenario.id] = {
|
progress[scenario.id] = {
|
||||||
@@ -486,6 +557,28 @@ app.post('/api/scenarios/:id/validate', async (req, res) => {
|
|||||||
};
|
};
|
||||||
saveProgress(progress);
|
saveProgress(progress);
|
||||||
|
|
||||||
|
// Also update exam_progress if there's an active session
|
||||||
|
const db = getDb();
|
||||||
|
const activeSession = db.prepare(`SELECT id FROM sessions WHERE status='active' LIMIT 1`).get();
|
||||||
|
if (activeSession) {
|
||||||
|
const ep = db.prepare(`SELECT * FROM exam_progress WHERE session_id=? AND scenario_id=?`)
|
||||||
|
.get(activeSession.id, scenario.id);
|
||||||
|
const epAttempts = (ep?.attempts || 0) + 1;
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO exam_progress (session_id, scenario_id, status, attempts, completed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_id, scenario_id) DO UPDATE SET
|
||||||
|
status = excluded.status,
|
||||||
|
attempts = excluded.attempts,
|
||||||
|
completed_at = COALESCE(excluded.completed_at, exam_progress.completed_at)
|
||||||
|
`).run(
|
||||||
|
activeSession.id, scenario.id,
|
||||||
|
allPassed ? 'completed' : 'in_progress',
|
||||||
|
epAttempts,
|
||||||
|
allPassed ? new Date().toISOString() : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ passed: allPassed, checks, attempts: progress[scenario.id].attempts });
|
res.json({ passed: allPassed, checks, attempts: progress[scenario.id].attempts });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -499,6 +592,7 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
|
|||||||
|
|
||||||
const correct = selected === scenario.correct_option;
|
const correct = selected === scenario.correct_option;
|
||||||
|
|
||||||
|
// Update global practice progress
|
||||||
const progress = loadProgress();
|
const progress = loadProgress();
|
||||||
const prev = progress[scenario.id] || { attempts: 0 };
|
const prev = progress[scenario.id] || { attempts: 0 };
|
||||||
progress[scenario.id] = {
|
progress[scenario.id] = {
|
||||||
@@ -511,6 +605,28 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
|
|||||||
};
|
};
|
||||||
saveProgress(progress);
|
saveProgress(progress);
|
||||||
|
|
||||||
|
// Also update exam_progress if there's an active session
|
||||||
|
const db = getDb();
|
||||||
|
const activeSession = db.prepare(`SELECT id FROM sessions WHERE status='active' LIMIT 1`).get();
|
||||||
|
if (activeSession) {
|
||||||
|
const ep = db.prepare(`SELECT * FROM exam_progress WHERE session_id=? AND scenario_id=?`)
|
||||||
|
.get(activeSession.id, scenario.id);
|
||||||
|
const epAttempts = (ep?.attempts || 0) + 1;
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO exam_progress (session_id, scenario_id, status, attempts, completed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_id, scenario_id) DO UPDATE SET
|
||||||
|
status = excluded.status,
|
||||||
|
attempts = excluded.attempts,
|
||||||
|
completed_at = COALESCE(excluded.completed_at, exam_progress.completed_at)
|
||||||
|
`).run(
|
||||||
|
activeSession.id, scenario.id,
|
||||||
|
correct ? 'completed' : 'in_progress',
|
||||||
|
epAttempts,
|
||||||
|
correct ? new Date().toISOString() : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
correct,
|
correct,
|
||||||
correct_option: scenario.correct_option,
|
correct_option: scenario.correct_option,
|
||||||
|
|||||||
+24
-4
@@ -7,6 +7,7 @@ import BundleNav from './components/BundleNav'
|
|||||||
import ExamTimer from './components/ExamTimer'
|
import ExamTimer from './components/ExamTimer'
|
||||||
import ExamReport from './components/ExamReport'
|
import ExamReport from './components/ExamReport'
|
||||||
import ExamStartModal from './components/ExamStartModal'
|
import ExamStartModal from './components/ExamStartModal'
|
||||||
|
import ExamHistory from './components/ExamHistory'
|
||||||
import styles from './App.module.css'
|
import styles from './App.module.css'
|
||||||
|
|
||||||
const MIN_SIDEBAR_W = 180
|
const MIN_SIDEBAR_W = 180
|
||||||
@@ -35,6 +36,8 @@ export default function App() {
|
|||||||
const [examSession, setExamSession] = useState(null) // active session object
|
const [examSession, setExamSession] = useState(null) // active session object
|
||||||
const [examReport, setExamReport] = useState(null) // submitted report
|
const [examReport, setExamReport] = useState(null) // submitted report
|
||||||
const [examModalBundle, setExamModalBundle] = useState(null) // bundle for start/retry modal
|
const [examModalBundle, setExamModalBundle] = useState(null) // bundle for start/retry modal
|
||||||
|
const [examProgress, setExamProgress] = useState({}) // exam-specific per-scenario progress
|
||||||
|
const [showHistory, setShowHistory] = useState(false) // exam history modal
|
||||||
|
|
||||||
// Sidebar resize / collapse
|
// Sidebar resize / collapse
|
||||||
const [sidebarW, setSidebarW] = useState(DEFAULT_SIDEBAR_W)
|
const [sidebarW, setSidebarW] = useState(DEFAULT_SIDEBAR_W)
|
||||||
@@ -82,10 +85,13 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/sessions/active')
|
fetch('/api/sessions/active')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(s => {
|
.then(async s => {
|
||||||
if (s) {
|
if (s) {
|
||||||
setExamSession(s)
|
setExamSession(s)
|
||||||
setActiveBundleId(s.bundle_id)
|
setActiveBundleId(s.bundle_id)
|
||||||
|
// Load exam-specific progress
|
||||||
|
const ep = await fetch(`/api/sessions/${s.id}/exam-progress`).then(r => r.json()).catch(() => ({}))
|
||||||
|
setExamProgress(ep || {})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { })
|
.catch(() => { })
|
||||||
@@ -142,10 +148,14 @@ export default function App() {
|
|||||||
const d2 = await fetch(`/api/scenarios/${activeId}`).then(r => r.json())
|
const d2 = await fetch(`/api/scenarios/${activeId}`).then(r => r.json())
|
||||||
setScenario(d2)
|
setScenario(d2)
|
||||||
}
|
}
|
||||||
// Refresh exam session completion count
|
// Refresh exam session completion count + exam-specific progress
|
||||||
if (examSession) {
|
if (examSession) {
|
||||||
const updated = await fetch('/api/sessions/active').then(r => r.json()).catch(() => null)
|
const updated = await fetch('/api/sessions/active').then(r => r.json()).catch(() => null)
|
||||||
if (updated) setExamSession(updated)
|
if (updated) {
|
||||||
|
setExamSession(updated)
|
||||||
|
const ep = await fetch(`/api/sessions/${updated.id}/exam-progress`).then(r => r.json()).catch(() => ({}))
|
||||||
|
setExamProgress(ep || {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [activeBundleId, activeId, examSession])
|
}, [activeBundleId, activeId, examSession])
|
||||||
|
|
||||||
@@ -171,6 +181,7 @@ export default function App() {
|
|||||||
const bundle = bundles.find(b => b.id === examSession.bundle_id)
|
const bundle = bundles.find(b => b.id === examSession.bundle_id)
|
||||||
setExamReport({ ...result, bundle })
|
setExamReport({ ...result, bundle })
|
||||||
setExamSession(null)
|
setExamSession(null)
|
||||||
|
setExamProgress({})
|
||||||
refreshProgress()
|
refreshProgress()
|
||||||
}, [examSession, bundles, refreshProgress])
|
}, [examSession, bundles, refreshProgress])
|
||||||
|
|
||||||
@@ -178,6 +189,7 @@ export default function App() {
|
|||||||
if (!examSession) return
|
if (!examSession) return
|
||||||
await fetch(`/api/sessions/${examSession.id}/abandon`, { method: 'POST' }).catch(() => { })
|
await fetch(`/api/sessions/${examSession.id}/abandon`, { method: 'POST' }).catch(() => { })
|
||||||
setExamSession(null)
|
setExamSession(null)
|
||||||
|
setExamProgress({})
|
||||||
refreshProgress()
|
refreshProgress()
|
||||||
}, [examSession, refreshProgress])
|
}, [examSession, refreshProgress])
|
||||||
|
|
||||||
@@ -247,7 +259,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
<Header clusterReady={clusterReady} />
|
<Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} />
|
||||||
|
|
||||||
{/* Bundle navigation bar */}
|
{/* Bundle navigation bar */}
|
||||||
<BundleNav
|
<BundleNav
|
||||||
@@ -287,6 +299,8 @@ export default function App() {
|
|||||||
width={currentSidebarW}
|
width={currentSidebarW}
|
||||||
activeBundleId={activeBundleId}
|
activeBundleId={activeBundleId}
|
||||||
onProgressUpdate={refreshProgress}
|
onProgressUpdate={refreshProgress}
|
||||||
|
isExamMode={!!examSession}
|
||||||
|
examProgress={examProgress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sidebar resize handle */}
|
{/* Sidebar resize handle */}
|
||||||
@@ -300,6 +314,7 @@ export default function App() {
|
|||||||
onProgressUpdate={refreshProgress}
|
onProgressUpdate={refreshProgress}
|
||||||
onScenarioStart={handleScenarioStart}
|
onScenarioStart={handleScenarioStart}
|
||||||
isExamMode={!!examSession}
|
isExamMode={!!examSession}
|
||||||
|
examProgress={examProgress}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -342,6 +357,11 @@ export default function App() {
|
|||||||
onCancel={() => setExamModalBundle(null)}
|
onCancel={() => setExamModalBundle(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Exam history modal */}
|
||||||
|
{showHistory && (
|
||||||
|
<ExamHistory onClose={() => setShowHistory(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
<footer className={styles.footer}>
|
<footer className={styles.footer}>
|
||||||
<div>© {new Date().getFullYear()} The KubeKosh Project • All rights reserved</div>
|
<div>© {new Date().getFullYear()} The KubeKosh Project • All rights reserved</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -83,8 +83,12 @@ export default function BundleNav({
|
|||||||
<span className={styles.tagline}>{b.tagline}</span>
|
<span className={styles.tagline}>{b.tagline}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.countWrap}>
|
<div className={styles.countWrap}>
|
||||||
|
{!examSession && (
|
||||||
|
<>
|
||||||
<span className={styles.countNum}>{b.stats.completed}/{b.stats.total}</span>
|
<span className={styles.countNum}>{b.stats.completed}/{b.stats.total}</span>
|
||||||
<span className={styles.countPct}>{pct}%</span>
|
<span className={styles.countPct}>{pct}%</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Start Exam button — shown on hover when not in exam mode */}
|
{/* Start Exam button — shown on hover when not in exam mode */}
|
||||||
@@ -118,15 +122,20 @@ export default function BundleNav({
|
|||||||
onProgressUpdate?.()
|
onProgressUpdate?.()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
↺
|
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M23 4v6h-6" />
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inline progress track */}
|
{/* Inline progress track */}
|
||||||
|
{!examSession && (
|
||||||
<div className={styles.progressTrack}>
|
<div className={styles.progressTrack}>
|
||||||
<div className={styles.progressFill} style={{ width: `${pct}%` }} />
|
<div className={styles.progressFill} style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import styles from './ExamHistory.module.css'
|
||||||
|
|
||||||
|
const DIFF_COLOR = { Easy: 'var(--green)', Medium: 'var(--amber)', Hard: 'var(--red)' }
|
||||||
|
|
||||||
|
function formatDuration(secs) {
|
||||||
|
if (!secs && secs !== 0) return '—'
|
||||||
|
const h = Math.floor(secs / 3600)
|
||||||
|
const m = Math.floor((secs % 3600) / 60)
|
||||||
|
const s = secs % 60
|
||||||
|
if (h > 0) return `${h}h ${m}m ${s}s`
|
||||||
|
if (m > 0) return `${m}m ${s}s`
|
||||||
|
return `${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z')
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
month: 'short', day: 'numeric', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreRing({ pct, passed, size = 72 }) {
|
||||||
|
const r = (size / 2) - 7
|
||||||
|
const circ = 2 * Math.PI * r
|
||||||
|
return (
|
||||||
|
<div className={styles.ringWrap} style={{ width: size, height: size }}>
|
||||||
|
<svg viewBox={`0 0 ${size} ${size}`} width={size} height={size} style={{ transform: 'rotate(-90deg)' }}>
|
||||||
|
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke="var(--surface3)" strokeWidth="6" />
|
||||||
|
<circle
|
||||||
|
cx={size/2} cy={size/2} r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={passed ? 'var(--green)' : pct > 0 ? 'var(--red)' : 'var(--surface3)'}
|
||||||
|
strokeWidth="6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circ}
|
||||||
|
strokeDashoffset={circ * (1 - pct / 100)}
|
||||||
|
style={{ transition: 'stroke-dashoffset 0.6s ease' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className={styles.ringPct}>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttemptListItem({ attempt, active, onClick }) {
|
||||||
|
const isAbandoned = attempt.status === 'abandoned'
|
||||||
|
const hasSnapshot = attempt.snapshot?.length > 0
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${styles.listItem} ${active ? styles.listItemActive : ''} ${isAbandoned ? styles.listItemAbandoned : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className={styles.listItemLeft}>
|
||||||
|
<span className={styles.listIcon}>{attempt.bundle_icon}</span>
|
||||||
|
<div className={styles.listMeta}>
|
||||||
|
<span className={styles.listBundle}>{attempt.bundle_name}</span>
|
||||||
|
<span className={styles.listDate}>{formatDate(attempt.started_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.listItemRight}>
|
||||||
|
{isAbandoned ? (
|
||||||
|
<span className={styles.badgeAbandoned}>Abandoned</span>
|
||||||
|
) : hasSnapshot ? (
|
||||||
|
<>
|
||||||
|
<span className={attempt.passed ? styles.badgePass : styles.badgeFail}>
|
||||||
|
{attempt.passed ? 'Passed' : 'Failed'}
|
||||||
|
</span>
|
||||||
|
<span className={styles.listPct}>{attempt.pct}%</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={styles.badgeAbandoned}>No data</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttemptDetail({ attempt }) {
|
||||||
|
if (!attempt) {
|
||||||
|
return (
|
||||||
|
<div className={styles.detailEmpty}>
|
||||||
|
<div className={styles.detailEmptyIcon}>📋</div>
|
||||||
|
<div className={styles.detailEmptyText}>Select an attempt to see its report</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAbandoned = attempt.status === 'abandoned'
|
||||||
|
const hasSnapshot = attempt.snapshot?.length > 0
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const byCategory = (attempt.snapshot || []).reduce((acc, s) => {
|
||||||
|
;(acc[s.category] = acc[s.category] || []).push(s)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.detail}>
|
||||||
|
{/* Detail header */}
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<span className={styles.detailIcon}>{attempt.bundle_icon}</span>
|
||||||
|
<div>
|
||||||
|
<div className={styles.detailLabel}>Exam Report</div>
|
||||||
|
<div className={styles.detailBundle}>{attempt.bundle_name}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailDates}>
|
||||||
|
<span className={styles.detailDateRow}>
|
||||||
|
<span className={styles.detailDateLabel}>Started</span>
|
||||||
|
<span className={styles.detailDateVal}>{formatDate(attempt.started_at)}</span>
|
||||||
|
</span>
|
||||||
|
<span className={styles.detailDateRow}>
|
||||||
|
<span className={styles.detailDateLabel}>Ended</span>
|
||||||
|
<span className={styles.detailDateVal}>{formatDate(attempt.submitted_at)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Score hero */}
|
||||||
|
{isAbandoned ? (
|
||||||
|
<div className={styles.abandonedHero}>
|
||||||
|
<span className={styles.abandonedIcon}>🚫</span>
|
||||||
|
<div>
|
||||||
|
<div className={styles.abandonedTitle}>Exam Abandoned</div>
|
||||||
|
<div className={styles.abandonedSub}>
|
||||||
|
{hasSnapshot
|
||||||
|
? `${attempt.completedCount} of ${attempt.scenarioCount} scenarios were completed before abandoning`
|
||||||
|
: 'No progress was recorded'}
|
||||||
|
{attempt.duration_secs
|
||||||
|
? ` · ${formatDuration(attempt.duration_secs)} elapsed`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`${styles.hero} ${attempt.passed ? styles.heroPassed : styles.heroFailed}`}>
|
||||||
|
<ScoreRing pct={attempt.pct} passed={attempt.passed} />
|
||||||
|
<div className={styles.heroMeta}>
|
||||||
|
<div className={`${styles.verdict} ${attempt.passed ? styles.verdictPass : styles.verdictFail}`}>
|
||||||
|
{attempt.passed ? '✅ Passed' : '❌ Not Yet Passing'}
|
||||||
|
</div>
|
||||||
|
<div className={styles.heroStats}>
|
||||||
|
<span>{attempt.completedCount}/{attempt.scenarioCount} scenarios</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{attempt.earnedWeight}/{attempt.totalWeight} pts</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>⏱ {formatDuration(attempt.duration_secs)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.passMark}>Pass mark: 66%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scenario breakdown */}
|
||||||
|
{hasSnapshot && (
|
||||||
|
<div className={styles.breakdown}>
|
||||||
|
{Object.entries(byCategory).map(([cat, items]) => (
|
||||||
|
<div key={cat} className={styles.catGroup}>
|
||||||
|
<div className={styles.catTitle}>{cat}</div>
|
||||||
|
{items.map(s => (
|
||||||
|
<div key={s.id} className={`${styles.row} ${s.status === 'completed' ? styles.rowDone : ''}`}>
|
||||||
|
<span className={styles.rowIcon}>{s.status === 'completed' ? '✅' : '⬜'}</span>
|
||||||
|
<span className={styles.rowTitle}>{s.title}</span>
|
||||||
|
<span className={styles.rowDiff} style={{ color: DIFF_COLOR[s.difficulty] }}>
|
||||||
|
{s.difficulty}
|
||||||
|
</span>
|
||||||
|
{s.attempts > 0 && (
|
||||||
|
<span className={styles.rowAttempts}>{s.attempts} attempt{s.attempts > 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
|
<span className={styles.rowPts}>
|
||||||
|
{s.status === 'completed' ? s.weight : 0}/{s.weight} pts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasSnapshot && (
|
||||||
|
<div className={styles.noSnapshot}>No scenario data recorded for this session.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExamHistory({ onClose }) {
|
||||||
|
const [history, setHistory] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selected, setSelected] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/sessions/history')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
setHistory(data)
|
||||||
|
if (data.length > 0) setSelected(data[0])
|
||||||
|
})
|
||||||
|
.catch(() => setHistory([]))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
{/* Modal header */}
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<div className={styles.modalTitle}>
|
||||||
|
<span className={styles.modalTitleIcon}>📋</span>
|
||||||
|
Exam History
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalMeta}>
|
||||||
|
{!loading && (
|
||||||
|
<span className={styles.attemptCount}>
|
||||||
|
{history.length} attempt{history.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className={styles.closeBtn} onClick={onClose} aria-label="Close exam history">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body: two-panel layout */}
|
||||||
|
<div className={styles.body}>
|
||||||
|
{/* Left: attempt list */}
|
||||||
|
<div className={styles.list}>
|
||||||
|
{loading && (
|
||||||
|
<div className={styles.loadingWrap}>
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className={styles.skeleton} style={{ animationDelay: `${i * 0.1}s` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && history.length === 0 && (
|
||||||
|
<div className={styles.emptyList}>
|
||||||
|
<div className={styles.emptyIcon}>🎓</div>
|
||||||
|
<div className={styles.emptyText}>No exam attempts yet</div>
|
||||||
|
<div className={styles.emptySub}>Complete or abandon an exam to see it here</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && history.map(a => (
|
||||||
|
<AttemptListItem
|
||||||
|
key={a.id}
|
||||||
|
attempt={a}
|
||||||
|
active={selected?.id === a.id}
|
||||||
|
onClick={() => setSelected(a)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: detail */}
|
||||||
|
<div className={styles.detailWrap}>
|
||||||
|
<AttemptDetail attempt={selected} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
/* ── Overlay & modal shell ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: min(1000px, 96vw);
|
||||||
|
height: min(680px, 92vh);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 32px 80px rgba(0,0,0,0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal header ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.modalHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalTitleIcon { font-size: 18px; line-height: 1; }
|
||||||
|
|
||||||
|
.modalMeta { margin-left: 4px; }
|
||||||
|
|
||||||
|
.attemptCount {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.closeBtn:hover { color: var(--text); background: var(--surface3); }
|
||||||
|
|
||||||
|
/* ── Two-panel body ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Left: attempt list ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.list {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--sans);
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s, border-color 0.12s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listItem:hover { background: var(--surface2); border-color: var(--border); }
|
||||||
|
.listItemActive {
|
||||||
|
background: rgba(57,217,138,0.07);
|
||||||
|
border-color: rgba(57,217,138,0.25);
|
||||||
|
}
|
||||||
|
.listItemAbandoned { opacity: 0.75; }
|
||||||
|
|
||||||
|
.listItemLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listIcon { font-size: 22px; flex-shrink: 0; line-height: 1; }
|
||||||
|
|
||||||
|
.listMeta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listBundle {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listDate {
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--text-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listItemRight {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listPct {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Badges ────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.badgePass, .badgeFail, .badgeAbandoned {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--mono);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgePass {
|
||||||
|
background: var(--green-dim);
|
||||||
|
color: var(--green);
|
||||||
|
border-color: rgba(57,217,138,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeFail {
|
||||||
|
background: var(--red-dim);
|
||||||
|
color: var(--red);
|
||||||
|
border-color: rgba(255,80,80,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeAbandoned {
|
||||||
|
background: var(--surface3);
|
||||||
|
color: var(--text-3);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading skeleton ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.loadingWrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(90deg, var(--surface2) 25%, var(--surface3) 50%, var(--surface2) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
|
||||||
|
|
||||||
|
/* ── Empty state ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.emptyList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon { font-size: 36px; }
|
||||||
|
.emptyText { font-size: 13px; font-weight: 700; color: var(--text-2); }
|
||||||
|
.emptySub { font-size: 11px; color: var(--text-3); line-height: 1.5; }
|
||||||
|
|
||||||
|
/* ── Right: detail panel ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.detailWrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailEmpty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailEmptyIcon { font-size: 40px; }
|
||||||
|
.detailEmptyText { font-size: 13px; }
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail header */
|
||||||
|
.detailHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px 22px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailIcon { font-size: 30px; line-height: 1; }
|
||||||
|
.detailLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.detailBundle { font-size: 16px; font-weight: 700; color: var(--text); }
|
||||||
|
|
||||||
|
.detailDates {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailDateRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailDateLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-3);
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailDateVal {
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Score hero */
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroPassed { background: var(--green-dim); }
|
||||||
|
.heroFailed { background: var(--red-dim); }
|
||||||
|
|
||||||
|
.ringWrap {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ringPct {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroMeta { flex: 1; }
|
||||||
|
|
||||||
|
.verdict {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.verdictPass { color: var(--green); }
|
||||||
|
.verdictFail { color: var(--red); }
|
||||||
|
|
||||||
|
.heroStats {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passMark { font-size: 11px; color: var(--text-3); }
|
||||||
|
|
||||||
|
/* Abandoned hero */
|
||||||
|
.abandonedHero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abandonedIcon { font-size: 32px; }
|
||||||
|
.abandonedTitle { font-size: 16px; font-weight: 700; color: var(--text-2); margin-bottom: 4px; }
|
||||||
|
.abandonedSub { font-size: 12px; color: var(--text-3); }
|
||||||
|
|
||||||
|
/* Breakdown */
|
||||||
|
.breakdown {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 22px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catGroup { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
|
||||||
|
.catTitle {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover { background: var(--surface2); }
|
||||||
|
.rowDone { color: var(--text); }
|
||||||
|
.rowIcon { font-size: 13px; flex-shrink: 0; }
|
||||||
|
.rowTitle { flex: 1; font-size: 12px; }
|
||||||
|
.rowDiff { font-size: 10px; font-weight: 600; flex-shrink: 0; }
|
||||||
|
.rowAttempts {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
background: var(--surface3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.rowPts {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noSnapshot {
|
||||||
|
padding: 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -1,10 +1,71 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import styles from './Header.module.css'
|
import styles from './Header.module.css'
|
||||||
|
|
||||||
export default function Header({ clusterReady }) {
|
// ── Inline Reload Cache Modal ─────────────────────────────────────────────────
|
||||||
|
function ReloadModal({ state, data, error, onClose, onReload }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.modalOverlay} onClick={e => e.target === e.currentTarget && state !== 'loading' && onClose()}>
|
||||||
|
<div className={styles.reloadModal}>
|
||||||
|
{state === 'loading' && (
|
||||||
|
<>
|
||||||
|
<div className={styles.reloadModalIcon}>
|
||||||
|
<span className={styles.reloadSpinner} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.reloadModalTitle}>Reloading Cache…</div>
|
||||||
|
<div className={styles.reloadModalSub}>Fetching latest scenarios and bundles from disk.</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'success' && (
|
||||||
|
<>
|
||||||
|
<div className={`${styles.reloadModalIcon} ${styles.reloadIconSuccess}`}>✓</div>
|
||||||
|
<div className={styles.reloadModalTitle}>Cache Reloaded</div>
|
||||||
|
<div className={styles.reloadModalSub}>{data?.message}</div>
|
||||||
|
<div className={styles.reloadStats}>
|
||||||
|
<div className={styles.reloadStat}>
|
||||||
|
<span className={styles.reloadStatNum}>{data?.scenarios_count ?? '—'}</span>
|
||||||
|
<span className={styles.reloadStatLabel}>Scenarios</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.reloadStatDivider} />
|
||||||
|
<div className={styles.reloadStat}>
|
||||||
|
<span className={styles.reloadStatNum}>{data?.bundles_count ?? '—'}</span>
|
||||||
|
<span className={styles.reloadStatLabel}>Bundles</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.reloadModalActions}>
|
||||||
|
<button className={styles.reloadPageBtn} onClick={onReload}>
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 6, verticalAlign: 'middle' }}>
|
||||||
|
<path d="M23 4v6h-6" />
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||||
|
</svg>
|
||||||
|
Reload Page
|
||||||
|
</button>
|
||||||
|
<button className={styles.reloadCloseBtn} onClick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'error' && (
|
||||||
|
<>
|
||||||
|
<div className={`${styles.reloadModalIcon} ${styles.reloadIconError}`}>✕</div>
|
||||||
|
<div className={styles.reloadModalTitle}>Reload Failed</div>
|
||||||
|
<div className={styles.reloadModalError}>{error}</div>
|
||||||
|
<div className={styles.reloadModalActions}>
|
||||||
|
<button className={styles.reloadCloseBtn} onClick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Header ────────────────────────────────────────────────────────────────────
|
||||||
|
export default function Header({ clusterReady, onShowHistory }) {
|
||||||
const [theme, setTheme] = useState(
|
const [theme, setTheme] = useState(
|
||||||
() => localStorage.getItem('kubekosh-theme') || 'dark'
|
() => localStorage.getItem('kubekosh-theme') || 'dark'
|
||||||
)
|
)
|
||||||
|
const [reloadModal, setReloadModal] = useState(null) // null | { state, data, error }
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', theme)
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
@@ -14,31 +75,26 @@ export default function Header({ clusterReady }) {
|
|||||||
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
|
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
|
||||||
|
|
||||||
const handleReloadCache = async () => {
|
const handleReloadCache = async () => {
|
||||||
const sendReloadRequest = async () => {
|
setReloadModal({ state: 'loading', data: null, error: null })
|
||||||
return fetch('/api/cache/reload', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response = await sendReloadRequest();
|
const response = await fetch('/api/cache/reload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json()
|
||||||
alert(`Success: ${data.message}\nScenarios: ${data.scenarios_count}\nBundles: ${data.bundles_count}`);
|
setReloadModal({ state: 'success', data, error: null })
|
||||||
window.location.reload();
|
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}))
|
||||||
alert(`Error reloading cache: ${data.error || 'Unknown error'}`);
|
setReloadModal({ state: 'error', data: null, error: data.error || 'Unknown error' })
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Network error reloading cache: ${err.message}`);
|
setReloadModal({ state: 'error', data: null, error: `Network error: ${err.message}` })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div className={styles.brand}>
|
<div className={styles.brand}>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
@@ -51,28 +107,33 @@ export default function Header({ clusterReady }) {
|
|||||||
|
|
||||||
<div className={styles.right}>
|
<div className={styles.right}>
|
||||||
{/* GitHub link */}
|
{/* GitHub link */}
|
||||||
|
<div className={styles.githubBtnContainer}>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/zeborg/kubekosh"
|
href="https://github.com/zeborg/kubekosh"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={styles.githubBtn}
|
className={styles.githubBtn}
|
||||||
title="Visit GitHub repository"
|
|
||||||
aria-label="GitHub Repository"
|
aria-label="GitHub Repository"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 512 512" width="16" height="16" fill="currentColor">
|
<svg viewBox="0 0 512 512" width="16" height="16" fill="currentColor">
|
||||||
<path d="M256 6.3C114.6 6.3 0 120.9 0 262.3c0 113.3 73.3 209 175 242.9 12.8 2.2 17.6-5.4 17.6-12.2 0-6.1-.3-26.2-.3-47.7-64.3 11.8-81-15.7-86.1-30.1-2.9-7.4-15.4-30.1-26.2-36.2-9-4.8-21.8-16.6-.3-17 20.2-.3 34.6 18.6 39.4 26.2 23 38.7 59.8 27.8 74.6 21.1 2.2-16.6 9-27.8 16.3-34.2-57-6.4-116.5-28.5-116.5-126.4 0-27.8 9.9-50.9 26.2-68.8-2.6-6.4-11.5-32.6 2.6-67.8 0 0 21.4-6.7 70.4 26.2 20.5-5.8 42.2-8.6 64-8.6s43.5 2.9 64 8.6c49-33.3 70.4-26.2 70.4-26.2 14.1 35.2 5.1 61.4 2.6 67.8 16.3 17.9 26.2 40.6 26.2 68.8 0 98.2-59.8 120-116.8 126.4 9.3 8 17.3 23.4 17.3 47.4 0 34.2-.3 61.8-.3 70.4 0 6.7 4.8 14.7 17.6 12.2C438.7 471.3 512 375.3 512 262.3c0-141.4-114.6-256-256-256" fillRule="evenodd" clipRule="evenodd"/>
|
<path d="M256 6.3C114.6 6.3 0 120.9 0 262.3c0 113.3 73.3 209 175 242.9 12.8 2.2 17.6-5.4 17.6-12.2 0-6.1-.3-26.2-.3-47.7-64.3 11.8-81-15.7-86.1-30.1-2.9-7.4-15.4-30.1-26.2-36.2-9-4.8-21.8-16.6-.3-17 20.2-.3 34.6 18.6 39.4 26.2 23 38.7 59.8 27.8 74.6 21.1 2.2-16.6 9-27.8 16.3-34.2-57-6.4-116.5-28.5-116.5-126.4 0-27.8 9.9-50.9 26.2-68.8-2.6-6.4-11.5-32.6 2.6-67.8 0 0 21.4-6.7 70.4 26.2 20.5-5.8 42.2-8.6 64-8.6s43.5 2.9 64 8.6c49-33.3 70.4-26.2 70.4-26.2 14.1 35.2 5.1 61.4 2.6 67.8 16.3 17.9 26.2 40.6 26.2 68.8 0 98.2-59.8 120-116.8 126.4 9.3 8 17.3 23.4 17.3 47.4 0 34.2-.3 61.8-.3 70.4 0 6.7 4.8 14.7 17.6 12.2C438.7 471.3 512 375.3 512 262.3c0-141.4-114.6-256-256-256" fillRule="evenodd" clipRule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Theme toggle */}
|
{/* Theme toggle */}
|
||||||
|
<div
|
||||||
|
className={styles.themeBtnContainer}
|
||||||
|
data-tooltip={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
className={styles.themeBtn}
|
className={styles.themeBtn}
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
title={theme === 'dark' ? 'Switch to Light mode' : 'Switch to Dark mode'}
|
|
||||||
aria-label="Toggle theme"
|
aria-label="Toggle theme"
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? '☀️' : '🌙'}
|
{theme === 'dark' ? '☀️' : '🌙'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Cluster status */}
|
{/* Cluster status */}
|
||||||
<div className={`${styles.clusterBadge} ${clusterReady ? styles.ready : styles.notReady}`}>
|
<div className={`${styles.clusterBadge} ${clusterReady ? styles.ready : styles.notReady}`}>
|
||||||
@@ -80,11 +141,30 @@ export default function Header({ clusterReady }) {
|
|||||||
<span>{clusterReady ? 'Cluster Ready' : 'Connecting…'}</span>
|
<span>{clusterReady ? 'Cluster Ready' : 'Connecting…'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Exam history */}
|
||||||
|
<div className={styles.historyBtnContainer}>
|
||||||
|
<button
|
||||||
|
className={styles.historyBtn}
|
||||||
|
onClick={onShowHistory}
|
||||||
|
aria-label="View exam history"
|
||||||
|
id="exam-history-btn"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10 9 9 9 8 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Reload cache */}
|
{/* Reload cache */}
|
||||||
<div className={styles.reloadBtnContainer}>
|
<div className={styles.reloadBtnContainer}>
|
||||||
<button
|
<button
|
||||||
className={styles.reloadBtn}
|
className={`${styles.reloadBtn} ${reloadModal?.state === 'loading' ? styles.reloadBtnSpinning : ''}`}
|
||||||
onClick={handleReloadCache}
|
onClick={handleReloadCache}
|
||||||
|
disabled={reloadModal?.state === 'loading'}
|
||||||
aria-label="Reload scenario cache"
|
aria-label="Reload scenario cache"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -95,5 +175,17 @@ export default function Header({ clusterReady }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Reload cache modal */}
|
||||||
|
{reloadModal && (
|
||||||
|
<ReloadModal
|
||||||
|
state={reloadModal.state}
|
||||||
|
data={reloadModal.data}
|
||||||
|
error={reloadModal.error}
|
||||||
|
onClose={() => setReloadModal(null)}
|
||||||
|
onReload={() => window.location.reload()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,36 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.githubBtnContainer {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.githubBtnContainer::after {
|
||||||
|
content: "View on GitHub";
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
background: var(--surface3);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.1s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.githubBtnContainer:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.githubBtn {
|
.githubBtn {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -87,6 +117,36 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.themeBtnContainer {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeBtnContainer::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
background: var(--surface3);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.1s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeBtnContainer:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.themeBtn {
|
.themeBtn {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -159,6 +219,58 @@
|
|||||||
transform: rotate(12deg);
|
transform: rotate(12deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Exam history button */
|
||||||
|
.historyBtnContainer {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyBtnContainer::after {
|
||||||
|
content: "Exam History";
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
background: var(--surface3);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.1s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyBtnContainer:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyBtn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
color: var(--text-2);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.historyBtn:hover {
|
||||||
|
background: var(--surface2);
|
||||||
|
border-color: var(--border2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.clusterBadge {
|
.clusterBadge {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -195,3 +307,188 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||||
|
|
||||||
|
/* Reload button spinning state */
|
||||||
|
.reloadBtnSpinning svg {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg) } }
|
||||||
|
|
||||||
|
/* ── Reload Cache Modal ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.modalOverlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
animation: modalFadeIn 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalFadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
|
||||||
|
.reloadModal {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: min(400px, 92vw);
|
||||||
|
padding: 32px 28px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
box-shadow: 0 24px 60px rgba(0,0,0,0.45);
|
||||||
|
animation: modalSlideIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from { opacity: 0; transform: translateY(-12px) scale(0.97) }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadModalIcon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadIconSuccess {
|
||||||
|
background: var(--green-dim);
|
||||||
|
border-color: rgba(57,217,138,0.3);
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadIconError {
|
||||||
|
background: var(--red-dim);
|
||||||
|
border-color: rgba(255,80,80,0.3);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadSpinner {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 3px solid var(--border2);
|
||||||
|
border-top-color: var(--text-2);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.75s linear infinite;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadModalTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadModalSub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadModalError {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--red);
|
||||||
|
background: var(--red-dim);
|
||||||
|
border: 1px solid rgba(255,80,80,0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadStats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadStat {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadStatDivider {
|
||||||
|
width: 1px;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadStatNum {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadStatLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadModalActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadPageBtn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 9px 0;
|
||||||
|
background: var(--green);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--sans);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.reloadPageBtn:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
.reloadCloseBtn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 9px 0;
|
||||||
|
background: var(--surface3);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--sans);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.reloadCloseBtn:hover { background: var(--border); }
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ async function resetProgress(scope, opts) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode }) {
|
export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode, examProgress }) {
|
||||||
const [tab, setTab] = useState('problem')
|
const [tab, setTab] = useState('problem')
|
||||||
const [setupState, setSetupState] = useState('idle') // idle | running | done | error
|
const [setupState, setSetupState] = useState('idle') // idle | running | done | error
|
||||||
const [validating, setValidating] = useState(false)
|
const [validating, setValidating] = useState(false)
|
||||||
@@ -74,7 +74,9 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
|||||||
}, [isExamMode])
|
}, [isExamMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExamMode || !scenario || scenario.progress?.status === 'completed') return
|
// Only track time in exam mode, and only if the scenario isn't already completed in the exam
|
||||||
|
const examCompleted = isExamMode && examProgress?.[scenario?.id]?.status === 'completed'
|
||||||
|
if (!isExamMode || !scenario || examCompleted) return
|
||||||
|
|
||||||
// Begin tracking immediately: if progress database has no started_at record yet, initialize it
|
// Begin tracking immediately: if progress database has no started_at record yet, initialize it
|
||||||
if (!scenario.progress?.started_at) {
|
if (!scenario.progress?.started_at) {
|
||||||
@@ -108,7 +110,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
|||||||
keepalive: true
|
keepalive: true
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
}, [scenario?.id, scenario?.progress?.status, isExamMode, onProgressUpdate])
|
}, [scenario?.id, scenario?.progress?.status, isExamMode, examProgress, onProgressUpdate])
|
||||||
|
|
||||||
async function runSetup() {
|
async function runSetup() {
|
||||||
setSetupState('running')
|
setSetupState('running')
|
||||||
@@ -190,6 +192,8 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isCompleted = scenario.progress?.status === 'completed'
|
const isCompleted = scenario.progress?.status === 'completed'
|
||||||
|
// In exam mode, use exam-specific completion status for the banner
|
||||||
|
const isExamCompleted = isExamMode && examProgress?.[scenario.id]?.status === 'completed'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
@@ -201,7 +205,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
|||||||
{scenario.difficulty}
|
{scenario.difficulty}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.typeTag}>{scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'}</span>
|
<span className={styles.typeTag}>{scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'}</span>
|
||||||
<span className={styles.weight}>{scenario.weight} pts</span>
|
{!isExamMode && <span className={styles.weight}>{scenario.weight} pts</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.titleRow}>
|
<div className={styles.titleRow}>
|
||||||
<div className={styles.scenarioTitle}>{scenario.title}</div>
|
<div className={styles.scenarioTitle}>{scenario.title}</div>
|
||||||
@@ -248,20 +252,29 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
↺ Reset
|
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 5, verticalAlign: 'middle' }}>
|
||||||
|
<path d="M23 4v6h-6" />
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||||
|
</svg>
|
||||||
|
Reset
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isCompleted && (
|
{(isCompleted && !isExamMode) && (
|
||||||
<div className={styles.completedBanner}>
|
<div className={styles.completedBanner}>
|
||||||
<span>✓</span> Scenario completed
|
<span>✓</span> Scenario completed
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isExamCompleted && (
|
||||||
|
<div className={styles.completedBanner}>
|
||||||
|
<span>✓</span> Completed in this exam
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className={styles.tabs}>
|
<div className={styles.tabs}>
|
||||||
{['problem', ...(isExamMode ? [] : ['hints']), ...(scenario.type === 'task' && !isExamMode ? ['validate'] : [])].map(t => (
|
{['problem', ...(isExamMode ? [] : ['hints']), ...(scenario.type === 'task' ? ['validate'] : [])].map(t => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
className={`${styles.tab} ${tab === t ? styles.activeTab : ''}`}
|
className={`${styles.tab} ${tab === t ? styles.activeTab : ''}`}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default function Sidebar({
|
|||||||
scenarios, activeId, onSelect, loading,
|
scenarios, activeId, onSelect, loading,
|
||||||
collapsed, onToggleCollapse, width,
|
collapsed, onToggleCollapse, width,
|
||||||
activeBundleId, onProgressUpdate,
|
activeBundleId, onProgressUpdate,
|
||||||
|
isExamMode, examProgress,
|
||||||
}) {
|
}) {
|
||||||
const [filterDiff, setFilterDiff] = useState('All')
|
const [filterDiff, setFilterDiff] = useState('All')
|
||||||
const [filterType, setFilterType] = useState('All')
|
const [filterType, setFilterType] = useState('All')
|
||||||
@@ -64,7 +65,10 @@ export default function Sidebar({
|
|||||||
}, [activeId, scenarios])
|
}, [activeId, scenarios])
|
||||||
|
|
||||||
const toggle = cat => setOpen(o => ({ ...o, [cat]: !o[cat] }))
|
const toggle = cat => setOpen(o => ({ ...o, [cat]: !o[cat] }))
|
||||||
const totalDone = scenarios.filter(s => s.progress?.status === 'completed').length
|
|
||||||
|
const totalDone = isExamMode
|
||||||
|
? scenarios.filter(s => examProgress?.[s.id]?.status === 'completed').length
|
||||||
|
: scenarios.filter(s => s.progress?.status === 'completed').length
|
||||||
|
|
||||||
const handleCategoryReset = async (e, cat) => {
|
const handleCategoryReset = async (e, cat) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -88,7 +92,11 @@ export default function Sidebar({
|
|||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<div className={styles.sidebarTop}>
|
<div className={styles.sidebarTop}>
|
||||||
{!collapsed && <span className={styles.sidebarTitle}>Scenarios</span>}
|
{!collapsed && <span className={styles.sidebarTitle}>Scenarios</span>}
|
||||||
{!collapsed && <span className={styles.sidebarCount}>{totalDone}/{scenarios.length}</span>}
|
{!collapsed && (
|
||||||
|
<span className={styles.sidebarCount}>
|
||||||
|
{totalDone}/{scenarios.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className={styles.collapseBtn}
|
className={styles.collapseBtn}
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
@@ -147,14 +155,21 @@ export default function Sidebar({
|
|||||||
<span className={styles.catName}>{cat}</span>
|
<span className={styles.catName}>{cat}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.accordionRight}>
|
<div className={styles.accordionRight}>
|
||||||
<span className={styles.catCount}>{catDone}/{items.length}</span>
|
<span className={styles.catCount}>
|
||||||
|
{isExamMode
|
||||||
|
? `${items.filter(s => examProgress?.[s.id]?.status === 'completed').length}/${items.length}`
|
||||||
|
: `${catDone}/${items.length}`}
|
||||||
|
</span>
|
||||||
{hasCatProgress && (
|
{hasCatProgress && (
|
||||||
<button
|
<button
|
||||||
className={styles.catResetBtn}
|
className={styles.catResetBtn}
|
||||||
title={`Reset all progress in "${cat}"`}
|
title={`Reset all progress in "${cat}"`}
|
||||||
onClick={e => handleCategoryReset(e, cat)}
|
onClick={e => handleCategoryReset(e, cat)}
|
||||||
>
|
>
|
||||||
↺
|
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M23 4v6h-6" />
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -163,20 +178,23 @@ export default function Sidebar({
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className={styles.itemsBox}>
|
<div className={styles.itemsBox}>
|
||||||
{items.map(s => {
|
{items.map(s => {
|
||||||
const done = s.progress?.status === 'completed'
|
const examDone = isExamMode && examProgress?.[s.id]?.status === 'completed'
|
||||||
|
const done = !isExamMode && s.progress?.status === 'completed'
|
||||||
const active = s.id === activeId
|
const active = s.id === activeId
|
||||||
const hasAttempts = s.progress?.attempts > 0
|
const hasAttempts = isExamMode
|
||||||
|
? (examProgress?.[s.id]?.attempts || 0) > 0
|
||||||
|
: s.progress?.attempts > 0
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
key={s.id}
|
||||||
className={`${styles.item} ${active ? styles.active : ''} ${done ? styles.done : ''}`}
|
className={`${styles.item} ${active ? styles.active : ''} ${(done || examDone) ? styles.done : ''}`}
|
||||||
onClick={() => onSelect(s.id)}
|
onClick={() => onSelect(s.id)}
|
||||||
>
|
>
|
||||||
<div className={styles.itemTop}>
|
<div className={styles.itemTop}>
|
||||||
<span className={styles.itemNum}>{scenarioIndex[s.id]}</span>
|
<span className={styles.itemNum}>{scenarioIndex[s.id]}</span>
|
||||||
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
|
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
|
||||||
<span className={styles.itemTitle}>{s.title}</span>
|
<span className={styles.itemTitle}>{s.title}</span>
|
||||||
{done && <span className={styles.checkmark}>✓</span>}
|
{(done || examDone) && <span className={styles.checkmark}>✓</span>}
|
||||||
{/* Per-scenario reset — shown when item has attempts */}
|
{/* Per-scenario reset — shown when item has attempts */}
|
||||||
{hasAttempts && (
|
{hasAttempts && (
|
||||||
<button
|
<button
|
||||||
@@ -184,7 +202,10 @@ export default function Sidebar({
|
|||||||
title="Reset this scenario's progress"
|
title="Reset this scenario's progress"
|
||||||
onClick={e => handleScenarioReset(e, s.id, s.title)}
|
onClick={e => handleScenarioReset(e, s.id, s.title)}
|
||||||
>
|
>
|
||||||
↺
|
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M23 4v6h-6" />
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +214,10 @@ export default function Sidebar({
|
|||||||
{s.difficulty}
|
{s.difficulty}
|
||||||
</span>
|
</span>
|
||||||
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
|
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
|
||||||
<span className={styles.weight}>{s.weight}pt</span>
|
{!isExamMode && <span className={styles.weight}>{s.weight}pt</span>}
|
||||||
|
{isExamMode && examDone && (
|
||||||
|
<span className={styles.examCompletedTag}>✓ Completed</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -337,3 +337,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideIn { from{opacity:0;transform:translateX(-4px)} to{opacity:1;transform:translateX(0)} }
|
@keyframes slideIn { from{opacity:0;transform:translateX(-4px)} to{opacity:1;transform:translateX(0)} }
|
||||||
|
|
||||||
|
.examCompletedTag {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--green-dim);
|
||||||
|
color: var(--green);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--green) 30%, transparent);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -152,7 +152,11 @@ export default function TerminalComponent({ collapsed, onToggleCollapse }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.barRight}>
|
<div className={styles.barRight}>
|
||||||
<button className={styles.barBtn} onClick={connect} title="Reconnect terminal">
|
<button className={styles.barBtn} onClick={connect} title="Reconnect terminal">
|
||||||
↺ Reconnect
|
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 5, verticalAlign: 'middle' }}>
|
||||||
|
<path d="M23 4v6h-6" />
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||||
|
</svg>
|
||||||
|
Reconnect
|
||||||
</button>
|
</button>
|
||||||
<button className={styles.barBtn} onClick={onToggleCollapse} title={collapsed ? 'Expand terminal' : 'Collapse terminal'}>
|
<button className={styles.barBtn} onClick={onToggleCollapse} title={collapsed ? 'Expand terminal' : 'Collapse terminal'}>
|
||||||
{collapsed ? '▲' : '▼'}
|
{collapsed ? '▲' : '▼'}
|
||||||
|
|||||||
Reference in New Issue
Block a user