Initial commit

This commit is contained in:
Abhinav Sinha
2026-05-26 20:18:08 +05:30
committed by GitHub
commit c26c0122ce
34 changed files with 8866 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
import { useState, useRef } from 'react'
import styles from './BundleNav.module.css'
async function resetProgress(scope, opts) {
await fetch('/api/progress/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scope, ...opts }),
})
}
export default function BundleNav({
bundles, activeBundleId, examSession, onSelect, onProgressUpdate, onStartExam, collapsed, onToggleCollapse
}) {
const trackRef = useRef(null)
const [isDragging, setIsDragging] = useState(false)
const [startX, setStartX] = useState(0)
const [scrollLeft, setScrollLeft] = useState(0)
const [dragDist, setDragDist] = useState(0)
const handleMouseDown = (e) => {
if (!trackRef.current) return
setIsDragging(true)
setDragDist(0)
setStartX(e.pageX - trackRef.current.offsetLeft)
setScrollLeft(trackRef.current.scrollLeft)
}
const handleMouseLeaveOrUp = () => {
setIsDragging(false)
}
const handleMouseMove = (e) => {
if (!isDragging || !trackRef.current) return
e.preventDefault()
const x = e.pageX - trackRef.current.offsetLeft
const walk = x - startX
if (Math.abs(walk) > 5) setDragDist(Math.abs(walk))
trackRef.current.scrollLeft = scrollLeft - walk
}
return (
<>
<nav className={`${styles.nav} ${collapsed ? styles.collapsed : ''}`} aria-label="Scenario bundles">
{!collapsed && (
<div
className={`${styles.track} ${(isDragging && dragDist > 5) ? styles.dragging : ''}`}
ref={trackRef}
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseLeaveOrUp}
onMouseUp={handleMouseLeaveOrUp}
onMouseMove={handleMouseMove}
>
{bundles.map(b => {
const active = b.id === activeBundleId
const isExamBundle = examSession?.bundle_id === b.id
const lockedByExam = examSession && !isExamBundle
const pct = b.stats.total > 0
? Math.round((b.stats.completed / b.stats.total) * 100)
: 0
return (
<button
key={b.id}
className={`${styles.tab} ${active ? styles.active : ''} ${lockedByExam ? styles.locked : ''}`}
style={{ '--bcolor': b.color, '--bdim': b.colorDim }}
onClick={(e) => {
if (dragDist > 5) {
e.preventDefault()
e.stopPropagation()
return
}
if (!lockedByExam) onSelect(b.id)
}}
aria-pressed={active}
title={lockedByExam ? 'Abandon current exam to switch bundles' : b.tagline}
>
{/* Top row: icon + text + count + actions */}
<div className={styles.tabTop}>
<span className={styles.icon}>{b.icon}</span>
<div className={styles.text}>
<span className={styles.name}>{b.name}</span>
<span className={styles.tagline}>{b.tagline}</span>
</div>
<div className={styles.countWrap}>
<span className={styles.countNum}>{b.stats.completed}/{b.stats.total}</span>
<span className={styles.countPct}>{pct}%</span>
</div>
{/* Start Exam button — shown on hover when not in exam mode */}
{!examSession && !lockedByExam && (
<button
className={styles.examBtn}
title={`Start timed exam for "${b.name}" (${b.exam_minutes ?? 120} min recommended)`}
onClick={e => {
e.stopPropagation()
onStartExam?.(b)
}}
>
Exam
</button>
)}
{/* In-exam indicator */}
{isExamBundle && (
<span className={styles.examBadge}>🏁 In Progress</span>
)}
{/* Reset button */}
{b.stats.completed > 0 && !examSession && (
<button
className={styles.bundleResetBtn}
title={`Reset all progress in "${b.name}"`}
onClick={async e => {
e.stopPropagation()
if (!window.confirm(`Reset all progress in "${b.name}"?`)) return
await resetProgress('bundle', { bundleId: b.id })
onProgressUpdate?.()
}}
>
</button>
)}
</div>
{/* Inline progress track */}
<div className={styles.progressTrack}>
<div className={styles.progressFill} style={{ width: `${pct}%` }} />
</div>
</button>
)
})}
</div>
)}
{!collapsed && <div className={styles.fadeOverlay} />}
<div
className={styles.collapseWrap}
onClick={onToggleCollapse}
title={collapsed ? "Show Bundles" : "Hide Bundles"}
>
{collapsed ? '▼' : '▲'}
</div>
</nav>
</>
)
}
@@ -0,0 +1,241 @@
.nav {
flex-shrink: 0;
background: var(--surface);
border-bottom: 1px solid var(--border);
overflow: hidden;
display: flex;
align-items: center;
justify-content: flex-end;
position: relative;
}
.fadeOverlay {
position: absolute;
top: 0;
right: 42px; /* 8px margin + 26px width + 8px gap */
height: 100%;
width: 40px;
background: linear-gradient(to right, transparent, var(--surface));
pointer-events: none;
z-index: 5;
}
/* Horizontal scroll container — no visible scrollbar */
.track {
display: flex;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
gap: 4px;
padding: 8px 10px;
}
.track::-webkit-scrollbar { display: none; }
.track {
cursor: grab;
}
.track.dragging {
cursor: grabbing;
}
.track.dragging * {
pointer-events: none; /* Prevent text selection and hover states while dragging */
}
.collapseWrap {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
margin: 0 8px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
color: var(--text-3);
font-size: 11px;
transition: background 0.15s, color 0.15s;
user-select: none;
}
.collapseWrap:hover {
background: var(--surface2);
color: var(--text);
}
/* ── Tab button ────────────────────────────────────────────────────────────── */
.tab {
position: relative;
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 8px;
padding: 9px 16px 10px;
min-width: 210px;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
color: var(--text-2);
font-family: var(--sans);
text-align: left;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.tab:hover {
background: var(--surface2);
border-color: var(--border2);
}
.tab.active {
background: var(--bdim, rgba(63,185,80,0.10));
border-color: color-mix(in srgb, var(--bcolor) 45%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--bcolor) 20%, transparent) inset;
}
/* ── Top row ───────────────────────────────────────────────────────────────── */
.tabTop {
display: flex;
align-items: flex-start;
gap: 10px;
}
.icon {
font-size: 18px;
line-height: 1;
flex-shrink: 0;
margin-top: 1px;
}
.text {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.name {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.1px;
color: var(--text);
}
.tab.active .name { color: var(--bcolor, var(--green)); }
.tagline {
font-size: 11px;
color: var(--text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Count badge: stacked number + percentage */
.countWrap {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
flex-shrink: 0;
}
.countNum {
font-family: var(--mono);
font-size: 12px;
font-weight: 700;
color: var(--bcolor, var(--text-2));
opacity: 0.9;
}
.countPct {
font-family: var(--mono);
font-size: 10px;
color: var(--text-3);
}
.tab.active .countPct { color: var(--bcolor, var(--text-3)); opacity: 0.7; }
/* Bundle reset button */
.bundleResetBtn {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: var(--text-3);
padding: 2px 5px;
border-radius: 4px;
line-height: 1;
margin-left: 2px;
transition: color 0.12s, background 0.12s;
}
.bundleResetBtn:hover {
color: var(--red);
background: var(--red-dim);
}
/* ── Inline progress track ─────────────────────────────────────────────────── */
.progressTrack {
width: 100%;
height: 3px;
background: var(--surface3);
border-radius: 2px;
overflow: hidden;
}
.tab.active .progressTrack {
background: color-mix(in srgb, var(--bcolor) 25%, var(--border2));
}
.progressFill {
height: 100%;
background: var(--bcolor, var(--green));
border-radius: 2px;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Glow on active tab's fill */
.tab.active .progressFill {
box-shadow: 0 0 6px color-mix(in srgb, var(--bcolor) 60%, transparent);
}
/* Locked tab during exam */
.tab.locked {
opacity: 0.4;
cursor: not-allowed;
filter: grayscale(0.5);
}
/* Start Exam button */
.examBtn {
flex-shrink: 0;
background: var(--blue-dim);
border: 1px solid color-mix(in srgb, var(--blue) 35%, transparent);
color: var(--blue);
border-radius: 5px;
padding: 3px 8px;
font-size: 11px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, transform 0.1s;
white-space: nowrap;
}
.tab:hover .examBtn { opacity: 1; }
.examBtn:hover { background: var(--blue); color: #fff; transform: scale(1.05); }
/* In-exam badge */
.examBadge {
flex-shrink: 0;
background: rgba(252,196,25,0.15);
border: 1px solid rgba(252,196,25,0.35);
color: var(--amber);
border-radius: 5px;
padding: 3px 8px;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
+104
View File
@@ -0,0 +1,104 @@
import styles from './ExamReport.module.css'
function formatDuration(secs) {
if (!secs) 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`
}
const DIFF_COLOR = { Easy: 'var(--green)', Medium: 'var(--amber)', Hard: 'var(--red)' }
export default function ExamReport({ report, bundle, onClose, onRetry }) {
if (!report) return null
const { snapshot, durationSecs } = report
const completed = snapshot.filter(s => s.status === 'completed')
const totalWeight = snapshot.reduce((a, s) => a + (s.weight || 0), 0)
const earnedWeight = completed.reduce((a, s) => a + (s.weight || 0), 0)
const pct = totalWeight > 0 ? Math.round((earnedWeight / totalWeight) * 100) : 0
// Group by category
const byCategory = snapshot.reduce((acc, s) => {
;(acc[s.category] = acc[s.category] || []).push(s)
return acc
}, {})
const passed = pct >= 66
return (
<div className={styles.overlay} onClick={e => e.target === e.currentTarget && onClose()}>
<div className={styles.modal}>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.bundleIcon}>{bundle?.icon || '🎓'}</span>
<div>
<div className={styles.examLabel}>Exam Report</div>
<div className={styles.bundleName}>{bundle?.name}</div>
</div>
</div>
<button className={styles.closeBtn} onClick={onClose}></button>
</div>
{/* Score hero */}
<div className={`${styles.hero} ${passed ? styles.passed : styles.failed}`}>
<div className={styles.scoreRing}>
<svg viewBox="0 0 80 80" className={styles.ring}>
<circle cx="40" cy="40" r="34" className={styles.ringTrack} />
<circle
cx="40" cy="40" r="34"
className={styles.ringFill}
strokeDasharray={`${2 * Math.PI * 34}`}
strokeDashoffset={`${2 * Math.PI * 34 * (1 - pct / 100)}`}
style={{ stroke: passed ? 'var(--green)' : 'var(--red)' }}
/>
</svg>
<span className={styles.pctText}>{pct}%</span>
</div>
<div className={styles.heroMeta}>
<div className={`${styles.verdict} ${passed ? styles.verdictPass : styles.verdictFail}`}>
{passed ? '✅ Passed' : '❌ Not Yet Passing'}
</div>
<div className={styles.heroStats}>
<span>{completed.length}/{snapshot.length} scenarios</span>
<span>·</span>
<span>{earnedWeight}/{totalWeight} points</span>
<span>·</span>
<span> {formatDuration(durationSecs)}</span>
</div>
<div className={styles.passMark}>Pass mark: 66%</div>
</div>
</div>
{/* Breakdown by category */}
<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>
<span className={styles.rowPts}>{s.status === 'completed' ? s.weight : 0}/{s.weight} pts</span>
</div>
))}
</div>
))}
</div>
{/* Actions */}
<div className={styles.actions}>
<button className={styles.retryBtn} onClick={onRetry}>🔄 Start New Exam</button>
<button className={styles.closeBtn2} onClick={onClose}>Close</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,127 @@
.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.2s ease;
}
.modal {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-lg);
width: min(680px, 95vw);
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 24px 60px rgba(0,0,0,0.4);
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px 14px;
border-bottom: 1px solid var(--border);
}
.headerLeft { display: flex; align-items: center; gap: 12px; }
.bundleIcon { font-size: 28px; }
.examLabel { font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; color: var(--text-3); }
.bundleName { font-size: 16px; font-weight: 700; color: var(--text); }
.closeBtn {
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;
}
.closeBtn:hover { color: var(--text); background: var(--surface3); }
/* Score hero */
.hero {
display: flex;
align-items: center;
gap: 24px;
padding: 24px 28px;
border-bottom: 1px solid var(--border);
}
.passed { background: var(--green-dim); }
.failed { background: var(--red-dim); }
.scoreRing { position: relative; width: 80px; height: 80px; flex-shrink: 0; }
.ring { width: 80px; height: 80px; transform: rotate(-90deg); }
.ringTrack { fill: none; stroke: var(--surface3); stroke-width: 6; }
.ringFill {
fill: none; stroke-width: 6; stroke-linecap: round;
transition: stroke-dashoffset 1s ease;
}
.pctText {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-family: var(--mono); font-size: 16px; font-weight: 700; color: var(--text);
}
.heroMeta { flex: 1; }
.verdict { font-size: 20px; font-weight: 800; margin-bottom: 6px; }
.verdictPass { color: var(--green); }
.verdictFail { color: var(--red); }
.heroStats { display: flex; gap: 8px; font-size: 13px; color: var(--text-2); margin-bottom: 4px; }
.passMark { font-size: 11px; 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: 13px; color: var(--text-2);
transition: background 0.1s;
}
.row:hover { background: var(--surface2); }
.rowDone { color: var(--text); }
.rowIcon { font-size: 14px; flex-shrink: 0; }
.rowTitle { flex: 1; }
.rowDiff { font-size: 11px; font-weight: 600; flex-shrink: 0; }
.rowPts { font-family: var(--mono); font-size: 12px; color: var(--text-3); flex-shrink: 0; min-width: 60px; text-align: right; }
/* Actions */
.actions {
display: flex; gap: 10px; justify-content: flex-end;
padding: 14px 22px;
border-top: 1px solid var(--border);
}
.retryBtn {
padding: 8px 18px;
background: var(--blue); color: #fff;
border: none; border-radius: 8px;
font-size: 13px; font-weight: 700; font-family: var(--sans);
cursor: pointer; transition: opacity 0.15s;
}
.retryBtn:hover { opacity: 0.85; }
.closeBtn2 {
padding: 8px 18px;
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;
}
.closeBtn2:hover { background: var(--border); }
@@ -0,0 +1,99 @@
import { useState, useEffect, useRef } from 'react'
import styles from './ExamStartModal.module.css'
export default function ExamStartModal({ bundle, onStart, onCancel }) {
const [minutes, setMinutes] = useState(bundle?.exam_minutes || 120)
const inputRef = useRef(null)
useEffect(() => {
// Focus input on open
const t = setTimeout(() => inputRef.current?.select(), 60)
return () => clearTimeout(t)
}, [])
if (!bundle) return null
const numMinutes = Number(minutes)
const isValid = numMinutes >= 5 && numMinutes <= 300
const handleStart = () => {
if (!isValid) return
onStart(numMinutes)
}
const presets = [
{ label: '30 min', value: 30 },
{ label: '60 min', value: 60 },
{ label: '90 min', value: 90 },
{ label: '120 min', value: 120 },
]
return (
<div className={styles.overlay} onClick={e => e.target === e.currentTarget && onCancel()}>
<div className={styles.modal}>
<div className={styles.header}>
<span className={styles.icon}>{bundle.icon}</span>
<div>
<div className={styles.title}>Start Exam</div>
<div className={styles.bundleName}>{bundle.name}</div>
</div>
</div>
<div className={styles.body}>
<div className={styles.info}>
<span>📋</span>
<span>{bundle.scenario_ids?.length || '?'} scenarios · Recommended: <strong>{bundle.exam_minutes} min</strong></span>
</div>
<div className={styles.field}>
<label className={styles.label}>Exam Duration</label>
<div className={styles.presets}>
{presets.map(p => (
<button
key={p.value}
className={`${styles.preset} ${minutes === p.value ? styles.presetActive : ''}`}
onClick={() => setMinutes(p.value)}
style={{ '--bcolor': bundle.color }}
>
{p.label}
</button>
))}
</div>
<div className={styles.customRow}>
<input
ref={inputRef}
type="number"
className={styles.input}
value={minutes}
min={5}
max={300}
onChange={e => setMinutes(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleStart()}
/>
<span className={styles.unit}>minutes</span>
</div>
<div className={styles.hint}>
{!isValid ? (
<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>
</div>
<div className={styles.actions}>
<button className={styles.cancelBtn} onClick={onCancel}>Cancel</button>
<button
className={styles.startBtn}
onClick={handleStart}
disabled={!isValid}
style={{ background: bundle.color }}
>
Start Exam
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,186 @@
.overlay {
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: 900;
animation: fadeIn 0.15s ease;
}
.modal {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-lg);
width: min(420px, 94vw);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
animation: slideUp 0.2s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.header {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 22px 14px;
border-bottom: 1px solid var(--border);
}
.icon { font-size: 30px; }
.title {
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-3);
}
.bundleName {
font-size: 17px;
font-weight: 800;
color: var(--text);
}
.body {
padding: 18px 22px;
display: flex;
flex-direction: column;
gap: 16px;
}
.info {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-2);
background: var(--surface2);
padding: 8px 12px;
border-radius: 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 10px;
}
.label {
font-size: 12px;
font-weight: 700;
color: var(--text-2);
letter-spacing: 0.3px;
}
.presets {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.preset {
padding: 5px 12px;
border: 1px solid var(--border);
border-radius: 20px;
background: var(--surface2);
color: var(--text-2);
font-size: 12px;
font-weight: 600;
font-family: var(--sans);
cursor: pointer;
transition: all 0.15s;
}
.preset:hover {
border-color: var(--bcolor, var(--border2));
color: var(--text);
}
.preset.presetActive {
background: color-mix(in srgb, var(--bcolor, var(--blue)) 15%, transparent);
border-color: var(--bcolor, var(--blue));
color: var(--bcolor, var(--blue));
}
.customRow {
display: flex;
align-items: center;
gap: 8px;
}
.input {
width: 90px;
padding: 8px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 16px;
font-family: var(--mono);
font-weight: 700;
text-align: center;
transition: border-color 0.15s;
outline: none;
}
.input:focus { border-color: var(--blue); }
.unit {
font-size: 13px;
color: var(--text-3);
}
.hint {
font-size: 12px;
color: var(--text-3);
font-style: italic;
}
.actions {
display: flex;
gap: 10px;
justify-content: flex-end;
padding: 14px 22px;
border-top: 1px solid var(--border);
}
.cancelBtn {
padding: 8px 18px;
background: var(--surface3);
color: var(--text-2);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
font-weight: 600;
font-family: var(--sans);
cursor: pointer;
transition: background 0.15s;
}
.cancelBtn:hover { background: var(--border); color: var(--text); }
.startBtn {
padding: 8px 22px;
border: none;
border-radius: 8px;
color: #fff;
font-size: 13px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.startBtn:hover:not(:disabled) { opacity: 0.88; transform: scale(1.03); }
.startBtn:active:not(:disabled) { transform: scale(0.97); }
.startBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
filter: grayscale(1);
}
+91
View File
@@ -0,0 +1,91 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import styles from './ExamTimer.module.css'
function formatTime(secs) {
if (secs < 0) secs = 0
const h = Math.floor(secs / 3600)
const m = Math.floor((secs % 3600) / 60)
const s = secs % 60
if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
}
export default function ExamTimer({ session, bundle, onSubmit, onAbandon }) {
const [elapsed, setElapsed] = useState(0)
const autoSubmitted = useRef(false)
// session.exam_minutes is set at start time with the user's custom value
const durationSecs = (session?.exam_minutes || bundle?.exam_minutes || 120) * 60
useEffect(() => {
if (!session) return
autoSubmitted.current = false
const startedAt = new Date(session.started_at + (session.started_at.endsWith('Z') ? '' : 'Z'))
const tick = () => {
const el = Math.floor((Date.now() - startedAt.getTime()) / 1000)
if (el >= durationSecs && !autoSubmitted.current) {
autoSubmitted.current = true
setElapsed(durationSecs)
onSubmit()
} else if (!autoSubmitted.current) {
setElapsed(el)
}
}
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [session, durationSecs, onSubmit])
const remaining = durationSecs - elapsed
const pct = Math.min(100, (elapsed / durationSecs) * 100)
const urgent = remaining < 600 // < 10 min
const handleSubmit = useCallback(async () => {
if (!window.confirm(`Submit exam now?\n\n${session.completedCount || 0} of ${session.scenarioCount || '?'} scenarios completed.`)) return
onSubmit()
}, [session, onSubmit])
const handleAbandon = useCallback(() => {
if (!window.confirm('Abandon this exam?\n\nYour progress will be saved but no score report will be generated.')) return
onAbandon()
}, [onAbandon])
if (!session) return null
return (
<div className={`${styles.timer} ${urgent ? styles.urgent : ''}`}>
<div className={styles.left}>
<span className={styles.icon}></span>
<div className={styles.meta}>
<span className={styles.label}>EXAM MODE</span>
<span className={styles.bundleName}>{bundle?.name}</span>
</div>
</div>
<div className={styles.center}>
<div className={styles.timeDisplay}>
<span className={styles.elapsed}>{formatTime(elapsed)}</span>
<span className={styles.sep}>/</span>
<span className={styles.total}>{formatTime(durationSecs)}</span>
{urgent && <span className={styles.urgentTag}> Running out of time</span>}
</div>
<div className={styles.bar}>
<div className={styles.fill} style={{ width: `${pct}%` }} />
</div>
<div className={styles.progress}>
{session.completedCount || 0} / {session.scenarioCount || '?'} completed
</div>
</div>
<div className={styles.right}>
<button className={styles.abandonBtn} onClick={handleAbandon} title="Abandon exam (no score report)">
Abandon
</button>
<button className={styles.submitBtn} onClick={handleSubmit}>
Submit Exam
</button>
</div>
</div>
)
}
@@ -0,0 +1,146 @@
.timer {
display: flex;
align-items: center;
gap: 20px;
padding: 10px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
animation: slideIn 0.3s ease;
transition: background 0.25s;
}
.timer.urgent {
background: rgba(255, 107, 107, 0.08);
border-bottom-color: rgba(255, 107, 107, 0.35);
}
.timer.urgent .elapsed { color: var(--red); }
.timer.urgent .fill { background: var(--red); }
.left {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.icon { font-size: 20px; }
.meta {
display: flex;
flex-direction: column;
gap: 1px;
}
.label {
font-size: 9px;
font-weight: 800;
letter-spacing: 1px;
color: var(--blue);
text-transform: uppercase;
}
.bundleName {
font-size: 12px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.center {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.timeDisplay {
display: flex;
align-items: baseline;
gap: 4px;
}
.elapsed {
font-family: var(--mono);
font-size: 18px;
font-weight: 700;
color: var(--text);
line-height: 1;
}
.sep {
font-size: 14px;
color: var(--text-3);
}
.total {
font-family: var(--mono);
font-size: 13px;
color: var(--text-3);
}
.bar {
height: 4px;
background: var(--surface3);
border-radius: 2px;
overflow: hidden;
}
.fill {
height: 100%;
background: var(--blue);
border-radius: 2px;
transition: width 1s linear;
}
.progress {
font-size: 11px;
color: var(--text-3);
}
.right {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
}
.abandonBtn {
padding: 7px 14px;
background: none;
color: var(--red);
border: 1px solid color-mix(in srgb, var(--red) 40%, transparent);
border-radius: 8px;
font-size: 12px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
transition: background 0.15s, transform 0.1s;
white-space: nowrap;
}
.abandonBtn:hover { background: var(--red-dim); transform: scale(1.03); }
.abandonBtn:active { transform: scale(0.97); }
.submitBtn {
padding: 7px 18px;
background: var(--blue);
color: #fff;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
white-space: nowrap;
}
.submitBtn:hover { opacity: 0.85; transform: scale(1.03); }
.submitBtn:active { transform: scale(0.97); }
.urgentTag {
font-size: 11px;
font-weight: 700;
color: var(--red);
animation: pulse 1.5s infinite;
margin-left: 6px;
}
+60
View File
@@ -0,0 +1,60 @@
import { useState, useEffect } from 'react'
import styles from './Header.module.css'
export default function Header({ clusterReady }) {
const [theme, setTheme] = useState(
() => localStorage.getItem('kubekosh-theme') || 'dark'
)
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('kubekosh-theme', theme)
}, [theme])
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
return (
<header className={styles.header}>
<div className={styles.brand}>
<div className={styles.logo}>
<img src="/logo.svg" alt="KubeKosh Logo" className={styles.logoImage} />
<span className={styles.logoText}>KubeKosh</span>
<span className={styles.version}>v0.1.0</span>
</div>
<span className={styles.tagline}>Interactive Kubernetes Playground</span>
</div>
<div className={styles.right}>
{/* GitHub link */}
<a
href="https://github.com/zeborg/kubekosh"
target="_blank"
rel="noopener noreferrer"
className={styles.githubBtn}
title="Visit GitHub repository"
aria-label="GitHub Repository"
>
<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"/>
</svg>
</a>
{/* Theme toggle */}
<button
className={styles.themeBtn}
onClick={toggleTheme}
title={theme === 'dark' ? 'Switch to Light mode' : 'Switch to Dark mode'}
aria-label="Toggle theme"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
{/* Cluster status */}
<div className={`${styles.clusterBadge} ${clusterReady ? styles.ready : styles.notReady}`}>
<span className={styles.dot} />
<span>{clusterReady ? 'Cluster Ready' : 'Connecting…'}</span>
</div>
</div>
</header>
)
}
+145
View File
@@ -0,0 +1,145 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 52px;
padding: 0 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 24px;
z-index: 100;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.logo {
display: flex;
align-items: center;
gap: 8px;
}
.logoImage {
width: 22px;
height: 22px;
display: block;
}
.logoText {
font-family: var(--sans);
font-weight: 900;
font-size: 19px;
letter-spacing: -0.5px;
color: var(--text);
}
.version {
font-size: 11px;
font-family: var(--mono);
color: var(--text-2);
background: var(--surface2);
border: 1px solid var(--border);
padding: 1px 5px;
border-radius: 4px;
margin-left: 2px;
}
.tagline {
font-size: 12px;
color: var(--text-3);
font-family: var(--mono);
border-left: 1px solid var(--border2);
padding-left: 12px;
}
.right {
flex-shrink: 0;
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
.githubBtn {
background: none;
border: 1px solid var(--border);
border-radius: 8px;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.2s, color 0.15s;
color: var(--text-2);
line-height: 1;
text-decoration: none;
}
.githubBtn:hover {
background: var(--surface2);
border-color: var(--border2);
transform: rotate(12deg);
color: var(--text);
}
.themeBtn {
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, transform 0.2s;
line-height: 1;
}
.themeBtn:hover {
background: var(--surface2);
border-color: var(--border2);
transform: rotate(12deg);
}
.clusterBadge {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 12px;
border-radius: 20px;
font-family: var(--mono);
font-size: 12px;
font-weight: 500;
border: 1px solid;
}
.clusterBadge.ready {
background: var(--green-dim);
border-color: rgba(57,217,138,0.3);
color: var(--green);
}
.clusterBadge.notReady {
background: var(--amber-dim);
border-color: rgba(252,196,25,0.3);
color: var(--amber);
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.ready .dot {
animation: pulse 2s infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
+376
View File
@@ -0,0 +1,376 @@
import { useState, useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import styles from './ScenarioPanel.module.css'
// Inline markdown: renders without a wrapping <p> — safe for buttons/spans
const inlineComponents = {
p: ({ children }) => <>{children}</>,
code: ({ children }) => <code className="inline-code">{children}</code>,
}
function InlineMd({ children }) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={inlineComponents}>
{children}
</ReactMarkdown>
)
}
async function resetProgress(scope, opts) {
await fetch('/api/progress/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scope, ...opts }),
})
}
export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode }) {
const [tab, setTab] = useState('problem')
const [setupState, setSetupState] = useState('idle') // idle | running | done | error
const [validating, setValidating] = useState(false)
const [validResult, setValidResult] = useState(null)
const [selectedOption, setSelectedOption] = useState(null)
const [mcqResult, setMcqResult] = useState(null)
const [submitting, setSubmitting] = useState(false)
const [hintsRevealed, setHintsRevealed] = useState([])
const [copiedCmd, setCopiedCmd] = useState(null)
// Reset state when scenario changes
useEffect(() => {
setTab('problem')
setSetupState('idle')
setValidResult(null)
setSelectedOption(null)
setMcqResult(null)
setHintsRevealed([])
if (scenario?.progress?.status === 'completed') {
setSetupState('done')
}
}, [scenario?.id])
async function runSetup() {
setSetupState('running')
try {
// Feature 2: teardown first to ensure clean cluster state
await fetch(`/api/scenarios/${scenario.id}/teardown`, { method: 'POST' }).catch(() => {})
await onScenarioStart?.(scenario.id)
await fetch(`/api/scenarios/${scenario.id}/setup`, { method: 'POST' })
setSetupState('done')
} catch {
setSetupState('error')
}
}
async function validate() {
setValidating(true)
setValidResult(null)
try {
const r = await fetch(`/api/scenarios/${scenario.id}/validate`, { method: 'POST' })
const d = await r.json()
setValidResult(d)
onProgressUpdate()
} catch {
setValidResult({ error: true })
}
setValidating(false)
}
async function submitMCQ() {
if (!selectedOption) return
setSubmitting(true)
try {
const r = await fetch(`/api/scenarios/${scenario.id}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selected: selectedOption })
})
const d = await r.json()
setMcqResult(d)
onProgressUpdate()
} catch {}
setSubmitting(false)
}
function copyCmd(cmd, idx) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(cmd).then(() => {
setCopiedCmd(idx)
setTimeout(() => setCopiedCmd(null), 1800)
})
} else {
const textArea = document.createElement("textarea")
textArea.value = cmd
textArea.style.position = "fixed"
textArea.style.left = "-999999px"
textArea.style.top = "-999999px"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
setCopiedCmd(idx)
setTimeout(() => setCopiedCmd(null), 1800)
} catch (err) {
console.error('Fallback copy failed', err)
}
textArea.remove()
}
}
if (!scenario) {
return (
<div className={styles.empty}>
<div className={styles.emptyIcon}></div>
<div className={styles.emptyTitle}>Select a scenario</div>
<div className={styles.emptySub}>Choose from the left panel to start practising</div>
</div>
)
}
const isCompleted = scenario.progress?.status === 'completed'
return (
<div className={styles.panel}>
{/* Scenario header */}
<div className={styles.scenarioHeader}>
<div className={styles.scenarioMeta}>
<span className={styles.category}>{scenario.category}</span>
<span className={`${styles.diff} ${styles[scenario.difficulty?.toLowerCase()]}`}>
{scenario.difficulty}
</span>
<span className={styles.typeTag}>{scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'}</span>
<span className={styles.weight}>{scenario.weight} pts</span>
</div>
<div className={styles.titleRow}>
<div className={styles.scenarioTitle}>{scenario.title}</div>
{scenario.progress?.status !== 'not_started' && scenario.progress?.attempts > 0 && (
<button
className={styles.resetBtn}
title="Reset progress for this scenario"
onClick={async () => {
if (!window.confirm(`Reset progress for "${scenario.title}"?`)) return
await resetProgress('scenario', { scenarioId: scenario.id })
setSelectedOption(null)
setMcqResult(null)
setValidResult(null)
setSetupState('idle')
setHintsRevealed([])
onProgressUpdate()
}}
>
Reset
</button>
)}
</div>
{isCompleted && (
<div className={styles.completedBanner}>
<span></span> Scenario completed
</div>
)}
</div>
{/* Tabs */}
<div className={styles.tabs}>
{['problem', ...(isExamMode ? [] : ['hints']), ...(scenario.type === 'task' && !isExamMode ? ['validate'] : [])].map(t => (
<button
key={t}
className={`${styles.tab} ${tab === t ? styles.activeTab : ''}`}
onClick={() => setTab(t)}
>
{t === 'problem' ? '📄 Problem'
: t === 'hints' ? `💡 Hints (${scenario.hints?.length || 0})`
: '✓ Validate'}
</button>
))}
</div>
{/* Tab content */}
<div className={styles.content}>
{/* PROBLEM TAB */}
{tab === 'problem' && (
<div className={styles.tabPane} style={{ animation: 'fadeIn 0.2s ease' }}>
{/* Setup section (if setup commands exist) */}
{scenario.setup_commands?.length > 0 && (
<div className={styles.setupBox}>
<div className={styles.setupHeader}>
<div className={styles.setupLabel}>
<span></span> Ready to start?
</div>
{setupState === 'idle' && (
<button className={styles.setupBtn} onClick={runSetup}>
Start Scenario
</button>
)}
{setupState === 'running' && (
<div className={styles.setupRunning}>
<span className={styles.spinner} />Setting up
</div>
)}
{setupState === 'done' && (
<span className={styles.setupDone}> Environment ready</span>
)}
{setupState === 'error' && (
<button className={styles.setupBtnRetry} onClick={runSetup}> Retry</button>
)}
</div>
<div className={styles.setupNote}>
Click <strong>Start Scenario</strong> to provision the lab environment, then solve the challenge below.
</div>
</div>
)}
{/* Problem description */}
<div className="md">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{scenario.description}</ReactMarkdown>
</div>
{/* MCQ options */}
{scenario.type === 'mcq' && (
<div className={styles.mcqSection}>
<div className={styles.mcqLabel}>Select your answer:</div>
<div className={styles.options}>
{scenario.options?.map(opt => {
const isSelected = selectedOption === opt.id
const showCorrect = mcqResult && opt.id === mcqResult.correct_option
const showWrong = mcqResult && isSelected && !mcqResult.correct
return (
<button
key={opt.id}
className={`${styles.option}
${isSelected ? styles.optionSelected : ''}
${showCorrect ? styles.optionCorrect : ''}
${showWrong ? styles.optionWrong : ''}
`}
onClick={() => !mcqResult && setSelectedOption(opt.id)}
disabled={!!mcqResult}
>
<span className={styles.optionLetter}>{opt.id.toUpperCase()}</span>
<span className={styles.optionText}>
<InlineMd>{opt.text}</InlineMd>
</span>
{showCorrect && <span className={styles.optionMark}></span>}
{showWrong && <span className={styles.optionMark}></span>}
</button>
)
})}
</div>
{!mcqResult ? (
<button
className={styles.submitBtn}
onClick={submitMCQ}
disabled={!selectedOption || submitting}
>
{submitting ? 'Checking…' : 'Submit Answer'}
</button>
) : (
<div className={`${styles.mcqResult} ${mcqResult.correct ? styles.mcqCorrect : styles.mcqWrong}`}>
<div className={styles.mcqResultTitle}>
{mcqResult.correct ? '✓ Correct!' : '✗ Incorrect — see the highlighted answer above'}
</div>
{mcqResult.explanation && (
<div className={styles.mcqExplanation}>
<InlineMd>{mcqResult.explanation}</InlineMd>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* HINTS TAB */}
{tab === 'hints' && (
<div className={styles.tabPane} style={{ animation: 'fadeIn 0.2s ease' }}>
{scenario.hints?.length === 0 && (
<div className={styles.noHints}>No hints available for this scenario.</div>
)}
{scenario.hints?.map((hint, i) => {
const revealed = hintsRevealed.includes(i)
return (
<div key={i} className={styles.hintCard}>
<div className={styles.hintHeader} onClick={() => setHintsRevealed(h => revealed ? h.filter(x => x !== i) : [...h, i])}>
<div className={styles.hintLeft}>
<span className={styles.hintNum}>Hint {i + 1}</span>
<span className={styles.hintTitle}>{hint.title}</span>
</div>
<span className={styles.hintChevron}>{revealed ? '▾' : '▸'}</span>
</div>
{revealed && (
<div className={styles.hintBody} style={{ animation: 'fadeIn 0.15s ease' }}>
<p className={styles.hintText}>
<InlineMd>{hint.body}</InlineMd>
</p>
{hint.command && (
<div className={styles.cmdBlock}>
<pre className={styles.cmdPre}>{hint.command}</pre>
<button
className={styles.copyBtn}
onClick={() => copyCmd(hint.command, i)}
>
{copiedCmd === i ? '✓ Copied' : 'Copy'}
</button>
</div>
)}
</div>
)}
</div>
)
})}
</div>
)}
{/* VALIDATE TAB */}
{tab === 'validate' && scenario.type === 'task' && (
<div className={styles.tabPane} style={{ animation: 'fadeIn 0.2s ease' }}>
<div className={styles.validateHeader}>
<div className={styles.validateDesc}>
{scenario.validation?.description}
</div>
<button
className={styles.validateBtn}
onClick={validate}
disabled={validating}
>
{validating
? <><span className={styles.spinner} /> Running checks</>
: '▶ Run Validation'}
</button>
</div>
{validResult && !validResult.error && (
<div className={styles.checks}>
<div className={`${styles.checksSummary} ${validResult.passed ? styles.allPassed : styles.someFailed}`}>
{validResult.passed
? `✓ All ${validResult.checks.length} checks passed!`
: `${validResult.checks.filter(c => !c.passed).length} of ${validResult.checks.length} checks failed`}
<span className={styles.attempts}>Attempt #{validResult.attempts}</span>
</div>
{validResult.checks.map((c, i) => (
<div key={i} className={`${styles.check} ${c.passed ? styles.checkPass : styles.checkFail}`}>
<span className={styles.checkIcon}>{c.passed ? '✓' : '✗'}</span>
<div className={styles.checkContent}>
<div className={styles.checkDesc}>{c.description}</div>
{!c.passed && (
<div className={styles.checkDetail}>
<span>Expected: <code>{c.expected}</code></span>
<span>Got: <code>{c.actual || '(empty)'}</code></span>
</div>
)}
</div>
</div>
))}
</div>
)}
{validResult?.error && (
<div className={styles.validateError}> Validation failed to run. Is the cluster reachable?</div>
)}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,516 @@
.panel {
display: flex;
flex-direction: column;
flex: 1; /* fill scenarioWrap entirely */
min-height: 0;
overflow: hidden;
background: var(--bg);
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-3);
}
.emptyIcon { font-size: 40px; opacity: 0.2; }
.emptyTitle { font-size: 16px; font-weight: 700; color: var(--text-2); }
.emptySub { font-size: 13px; }
/* Header */
.scenarioHeader {
padding: 14px 20px 10px;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.scenarioMeta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.category {
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-3);
}
.diff {
font-family: var(--mono);
font-size: 10px;
font-weight: 600;
padding: 2px 7px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
border: 1px solid transparent;
}
.diff.easy { background: var(--green-dim); color: var(--green); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.diff.medium { background: var(--amber-dim); color: var(--amber); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.diff.hard { background: var(--red-dim); color: var(--red); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.typeTag {
font-family: var(--mono);
font-size: 10px;
color: var(--blue);
background: var(--blue-dim);
padding: 2px 7px;
border-radius: 3px;
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
}
.weight {
font-family: var(--mono);
font-size: 11px;
color: var(--text-3);
margin-left: auto;
}
.scenarioTitle {
font-size: 19px;
font-weight: 800;
color: var(--text);
letter-spacing: -0.4px;
line-height: 1.3;
flex: 1;
}
.titleRow {
display: flex;
align-items: flex-start;
gap: 12px;
margin-top: 2px;
}
.resetBtn {
flex-shrink: 0;
background: var(--amber-dim);
border: 1px solid color-mix(in srgb, var(--amber) 40%, transparent);
border-radius: 6px;
color: var(--amber);
font-size: 12px;
font-family: var(--sans);
font-weight: 700;
padding: 5px 12px;
cursor: pointer;
margin-top: 2px;
white-space: nowrap;
letter-spacing: 0.2px;
transition: color 0.12s, border-color 0.12s, background 0.12s;
}
.resetBtn:hover {
color: var(--red);
border-color: color-mix(in srgb, var(--red) 50%, transparent);
background: var(--red-dim);
}
.completedBanner {
margin-top: 8px;
font-size: 12px;
font-weight: 600;
color: var(--green);
background: var(--green-dim);
border: 1px solid rgba(57,217,138,0.25);
padding: 4px 10px;
border-radius: 5px;
display: inline-flex;
align-items: center;
gap: 6px;
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
padding: 0 16px;
}
.tab {
padding: 10px 14px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-family: var(--sans);
font-size: 13px;
font-weight: 600;
color: var(--text-3);
transition: all 0.12s;
margin-bottom: -1px;
}
.tab:hover { color: var(--text-2); }
.activeTab {
color: var(--text);
border-bottom-color: var(--green);
}
/* Content */
.content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.tabPane { animation: fadeIn 0.2s ease; }
/* Setup box */
.setupBox {
background: var(--amber-dim);
border: 1px solid rgba(252,196,25,0.25);
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 20px;
}
.setupHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.setupLabel {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 700;
color: var(--amber);
}
.setupBtn {
background: var(--amber);
color: #000;
border: none;
border-radius: 6px;
padding: 6px 14px;
font-size: 12px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
transition: opacity 0.15s;
}
.setupBtn:hover { opacity: 0.85; }
.setupBtnRetry {
composes: setupBtn;
background: var(--red);
color: white;
}
.setupRunning {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--amber);
}
.setupDone { font-size: 12px; color: var(--green); font-weight: 600; }
.setupNote { font-size: 12px; color: var(--text-2); }
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
/* MCQ */
.mcqSection { margin-top: 24px; }
.mcqLabel {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--text-3);
margin-bottom: 12px;
}
.options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.option {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
font-family: var(--sans);
font-size: 14px;
color: var(--text);
text-align: left;
transition: all 0.12s;
}
.option:hover:not(:disabled) { border-color: var(--border2); background: var(--surface2); }
.option:disabled { cursor: default; }
.optionSelected { border-color: var(--blue); background: var(--blue-dim); }
.optionCorrect { border-color: var(--green) !important; background: var(--green-dim) !important; }
.optionWrong { border-color: var(--red) !important; background: var(--red-dim) !important; }
.optionLetter {
width: 26px;
height: 26px;
border-radius: 50%;
border: 1.5px solid var(--border2);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
color: var(--text-2);
flex-shrink: 0;
}
.optionSelected .optionLetter { border-color: var(--blue); color: var(--blue); background: var(--blue-dim); }
.optionCorrect .optionLetter { border-color: var(--green); color: var(--green); }
.optionWrong .optionLetter { border-color: var(--red); color: var(--red); }
.optionText { flex: 1; line-height: 1.5; color: var(--text-2); }
.optionMark { font-size: 14px; font-weight: 700; flex-shrink: 0; }
.optionCorrect .optionMark { color: var(--green); }
.optionWrong .optionMark { color: var(--red); }
.submitBtn {
background: var(--green);
color: #000;
border: none;
border-radius: var(--radius);
padding: 10px 22px;
font-size: 14px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
transition: opacity 0.15s;
}
.submitBtn:hover:not(:disabled) { opacity: 0.85; }
.submitBtn:disabled { opacity: 0.4; cursor: not-allowed; }
.mcqResult {
margin-top: 16px;
padding: 14px 16px;
border-radius: var(--radius);
border: 1px solid;
}
.mcqCorrect { background: var(--green-dim); border-color: rgba(57,217,138,0.3); }
.mcqWrong { background: var(--red-dim); border-color: rgba(255,107,107,0.3); }
.mcqResultTitle {
font-size: 14px;
font-weight: 700;
margin-bottom: 8px;
color: var(--text);
}
.mcqExplanation { font-size: 13px; color: var(--text-2); line-height: 1.6; }
/* Hints */
.hintCard {
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 8px;
overflow: hidden;
background: var(--surface);
}
.hintHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
cursor: pointer;
transition: background 0.12s;
}
.hintHeader:hover { background: var(--surface2); }
.hintLeft { display: flex; align-items: center; gap: 10px; }
.hintNum {
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
color: var(--amber);
background: var(--amber-dim);
padding: 2px 7px;
border-radius: 3px;
}
.hintTitle { font-size: 13px; font-weight: 600; color: var(--text); }
.hintChevron { color: var(--text-3); font-size: 12px; }
.hintBody {
padding: 4px 14px 14px;
border-top: 1px solid var(--border);
background: var(--bg);
}
.hintText { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-bottom: 10px; margin-top: 10px; }
.noHints { font-size: 13px; color: var(--text-3); padding: 20px 0; text-align: center; }
.cmdBlock {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.cmdPre {
padding: 12px 14px;
font-family: var(--mono);
font-size: 12px;
color: var(--green);
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
padding-right: 70px;
}
.copyBtn {
position: absolute;
top: 8px;
right: 8px;
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 5px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
color: var(--text-2);
cursor: pointer;
font-family: var(--sans);
transition: all 0.12s;
}
.copyBtn:hover { color: var(--text); border-color: var(--green); }
/* Validate */
.validateHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 20px;
}
.validateDesc { font-size: 13px; color: var(--text-2); line-height: 1.6; flex: 1; }
.validateBtn {
display: flex;
align-items: center;
gap: 8px;
background: var(--green);
color: #000;
border: none;
border-radius: var(--radius);
padding: 9px 18px;
font-size: 13px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.15s;
white-space: nowrap;
}
.validateBtn:hover:not(:disabled) { opacity: 0.85; }
.validateBtn:disabled { opacity: 0.5; cursor: not-allowed; }
.checks { display: flex; flex-direction: column; gap: 6px; }
.checksSummary {
padding: 10px 14px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.allPassed { background: var(--green-dim); color: var(--green); border: 1px solid rgba(57,217,138,0.3); }
.someFailed { background: var(--red-dim); color: var(--red); border: 1px solid rgba(255,107,107,0.3); }
.attempts { font-family: var(--mono); font-size: 11px; opacity: 0.7; }
.check {
display: flex;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
align-items: flex-start;
}
.checkPass { border-color: rgba(57,217,138,0.2); }
.checkFail { border-color: rgba(255,107,107,0.2); background: rgba(255,107,107,0.03); }
.checkIcon {
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
margin-top: 1px;
}
.checkPass .checkIcon { color: var(--green); }
.checkFail .checkIcon { color: var(--red); }
.checkContent { flex: 1; min-width: 0; }
.checkDesc { font-size: 13px; color: var(--text); margin-bottom: 4px; }
.checkDetail {
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 12px;
color: var(--text-3);
}
.checkDetail code {
font-family: var(--mono);
background: var(--surface3);
padding: 1px 5px;
border-radius: 3px;
color: var(--amber);
font-size: 11px;
}
.validateError {
padding: 12px 14px;
background: var(--amber-dim);
border: 1px solid rgba(252,196,25,0.3);
border-radius: var(--radius);
font-size: 13px;
color: var(--amber);
}
@keyframes fadeIn { from{opacity:0;transform:translateY(4px)} to{opacity:1;transform:none} }
@keyframes spin { to{transform:rotate(360deg)} }
+210
View File
@@ -0,0 +1,210 @@
import { useState, useMemo } from 'react'
import styles from './Sidebar.module.css'
const DIFF_COLOR = { Easy: 'green', Medium: 'amber', Hard: 'red' }
const TYPE_ICON = { task: '⚙', mcq: '◉' }
async function resetProgress(scope, opts) {
await fetch('/api/progress/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scope, ...opts }),
})
}
export default function Sidebar({
scenarios, activeId, onSelect, loading,
collapsed, onToggleCollapse, width,
activeBundleId, onProgressUpdate,
}) {
const [filterDiff, setFilterDiff] = useState('All')
const [filterType, setFilterType] = useState('All')
const filteredScenarios = useMemo(() => {
return scenarios.filter(s => {
if (filterDiff !== 'All' && s.difficulty !== filterDiff) return false
if (filterType !== 'All' && s.type !== filterType) return false
return true
})
}, [scenarios, filterDiff, filterType])
const groups = useMemo(() => {
const map = {}
filteredScenarios.forEach(s => {
if (!map[s.category]) map[s.category] = []
map[s.category].push(s)
})
return map
}, [filteredScenarios])
// Calculate index based on ALL scenarios so numbers stay absolute
const scenarioIndex = useMemo(() => {
const map = {}
scenarios.forEach(s => {
if (!map[s.category]) map[s.category] = []
map[s.category].push(s)
})
const idx = {}
let counter = 1
Object.values(map).forEach(items => {
items.forEach(s => { idx[s.id] = counter++ })
})
return idx
}, [scenarios])
// Number scenarios in accordion display order (category by category, then by position within category)
const [open, setOpen] = useState({})
useMemo(() => {
if (!activeId) return
const s = scenarios.find(x => x.id === activeId)
if (s) setOpen(o => ({ ...o, [s.category]: true }))
}, [activeId, scenarios])
const toggle = cat => setOpen(o => ({ ...o, [cat]: !o[cat] }))
const totalDone = scenarios.filter(s => s.progress?.status === 'completed').length
const handleCategoryReset = async (e, cat) => {
e.stopPropagation()
if (!window.confirm(`Reset all progress in "${cat}"?`)) return
await resetProgress('category', { category: cat })
onProgressUpdate?.()
}
const handleScenarioReset = async (e, scenarioId, title) => {
e.stopPropagation()
if (!window.confirm(`Reset progress for "${title}"?`)) return
await resetProgress('scenario', { scenarioId })
onProgressUpdate?.()
}
return (
<aside
className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}
style={{ width, minWidth: width }}
>
{/* Top bar */}
<div className={styles.sidebarTop}>
{!collapsed && <span className={styles.sidebarTitle}>Scenarios</span>}
{!collapsed && <span className={styles.sidebarCount}>{totalDone}/{scenarios.length}</span>}
<button
className={styles.collapseBtn}
onClick={onToggleCollapse}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? '' : ''}
</button>
</div>
{/* Filter bar */}
{!collapsed && (
<div className={styles.filterBar}>
<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)}
className={`${styles.selectFilter} ${filterType !== 'All' ? styles[filterType] : ''}`}
>
<option value="All">All Types</option>
<option value="task">Task</option>
<option value="mcq">MCQ</option>
</select>
</div>
)}
{/* List */}
{!collapsed && (
<div className={styles.list}>
{loading && (
<div className={styles.loadingWrap}>
{[...Array(5)].map((_, i) => (
<div key={i} className={styles.skeleton} style={{ animationDelay: `${i * 0.1}s` }} />
))}
</div>
)}
{!loading && 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)
return (
<div key={cat} className={styles.group}>
<button className={styles.accordion} onClick={() => toggle(cat)}>
<div className={styles.accordionLeft}>
<span className={`${styles.chevron} ${isOpen ? styles.open : ''}`}></span>
<span className={styles.catName}>{cat}</span>
</div>
<div className={styles.accordionRight}>
<span className={styles.catCount}>{catDone}/{items.length}</span>
{hasCatProgress && (
<button
className={styles.catResetBtn}
title={`Reset all progress in "${cat}"`}
onClick={e => handleCategoryReset(e, cat)}
>
</button>
)}
</div>
</button>
{isOpen && (
<div className={styles.itemsBox}>
{items.map(s => {
const done = s.progress?.status === 'completed'
const active = s.id === activeId
const hasAttempts = s.progress?.attempts > 0
return (
<button
key={s.id}
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 && <span className={styles.checkmark}></span>}
{/* Per-scenario reset — shown when item has attempts */}
{hasAttempts && (
<button
className={styles.itemResetBtn}
title="Reset this scenario's progress"
onClick={e => handleScenarioReset(e, s.id, s.title)}
>
</button>
)}
</div>
<div className={styles.itemMeta}>
<span className={`${styles.diff} ${styles[DIFF_COLOR[s.difficulty]]}`}>
{s.difficulty}
</span>
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
<span className={styles.weight}>{s.weight}pt</span>
</div>
</button>
)
})}
</div>
)}
</div>
)
})}
</div>
)}
</aside>
)
}
+339
View File
@@ -0,0 +1,339 @@
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
/* width/min-width set via inline style from App.jsx */
}
.sidebar.collapsed { overflow: hidden; }
.sidebarTop {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 10px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 8px;
}
.collapseBtn {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-3);
cursor: pointer;
font-size: 18px;
width: 24px;
height: 24px;
padding: 0;
border-radius: 4px;
flex-shrink: 0;
transition: color 0.12s, background 0.12s;
margin-left: auto;
}
.collapseBtn:hover { color: var(--text); background: var(--surface2); }
.sidebar.collapsed .sidebarTop {
padding: 14px 0 12px;
justify-content: center;
}
.sidebar.collapsed .collapseBtn {
margin-left: 0;
}
.sidebarTitle {
font-size: 11px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-3);
}
.sidebarCount {
font-family: var(--mono);
font-size: 11px;
color: var(--green);
background: var(--green-dim);
padding: 2px 8px;
border-radius: 10px;
}
.list {
overflow-y: auto;
flex: 1;
padding: 8px 0;
}
/* Filters */
.filterBar {
display: flex;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.selectFilter {
flex: 1;
background: var(--surface2);
color: var(--text-2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 6px;
font-size: 11px;
font-family: var(--sans);
outline: none;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.selectFilter:hover {
background: var(--surface3);
color: var(--text);
}
.selectFilter.green { background: var(--green-dim); color: var(--green); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.selectFilter.amber { background: var(--amber-dim); color: var(--amber); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.selectFilter.red { background: var(--red-dim); color: var(--red); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.selectFilter.task { background: var(--blue-dim); color: var(--blue); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.selectFilter.mcq { background: var(--purple-dim); color: var(--purple); border-color: color-mix(in srgb, currentColor 30%, transparent); }
/* Skeleton loading */
.loadingWrap { padding: 8px 12px; display: flex; flex-direction: column; gap: 6px; }
.skeleton {
height: 54px;
border-radius: var(--radius);
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} }
/* Accordion */
.group { margin-bottom: 2px; }
.accordion {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: none;
border: none;
cursor: pointer;
color: var(--text-2);
font-family: var(--sans);
}
.accordion:hover { background: var(--surface2); }
.accordion:hover .catResetBtn { opacity: 1; }
.accordionLeft {
display: flex;
align-items: center;
gap: 8px;
}
.chevron {
font-size: 16px;
color: var(--text-3);
transition: transform 0.15s;
display: inline-block;
line-height: 1;
}
.chevron.open { transform: rotate(90deg); }
.catName {
font-size: 13px;
font-weight: 800;
letter-spacing: 0.4px;
text-transform: uppercase;
color: var(--text);
}
.accordionRight {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.catCount {
font-family: var(--mono);
font-size: 11px;
color: var(--text-3);
}
.catResetBtn {
background: none;
border: none;
cursor: pointer;
font-size: 13px;
color: var(--text-3);
padding: 1px 4px;
border-radius: 4px;
line-height: 1;
transition: color 0.12s, background 0.12s;
flex-shrink: 0;
}
.catResetBtn:hover {
color: var(--red);
background: var(--red-dim);
}
/* Show catResetBtn on accordion hover too */
.accordion:hover .catResetBtn { color: var(--text-2); }
/* Items card — subtle rounded border wrapping each category's scenarios */
.itemsBox {
margin: 0 6px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--surface);
}
.item {
width: 100%;
text-align: left;
background: none;
border: none;
border-bottom: 1px solid var(--border);
border-radius: 0;
padding: 10px 12px;
cursor: pointer;
color: var(--text);
font-family: var(--sans);
transition: background 0.12s;
animation: slideIn 0.15s ease both;
}
.item:last-child { border-bottom: none; }
.item:hover { background: var(--surface2); }
.item.active { background: rgba(57,217,138,0.07); }
.item.done .itemTitle { color: var(--text-2); }
.itemTop {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 6px;
}
.itemNum {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
color: var(--text-3);
min-width: 18px;
text-align: right;
flex-shrink: 0;
opacity: 0.7;
}
.item.active .itemNum { color: var(--green); opacity: 1; }
.typeIcon {
font-size: 12px;
color: var(--text-3);
flex-shrink: 0;
margin-top: 1px;
}
.item.active .typeIcon { color: var(--green); }
.itemTitle {
font-size: 13px;
font-weight: 500;
line-height: 1.4;
flex: 1;
color: var(--text);
}
.checkmark {
color: var(--green);
font-size: 12px;
flex-shrink: 0;
font-weight: 700;
}
.itemMeta {
display: flex;
align-items: center;
gap: 6px;
padding-left: 20px;
}
/* Per-scenario inline reset button */
.itemResetBtn {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
font-size: 12px;
color: var(--text-3);
padding: 1px 4px;
border-radius: 3px;
line-height: 1;
opacity: 0;
transition: opacity 0.12s, color 0.12s, background 0.12s;
margin-left: auto;
}
.item:hover .itemResetBtn { opacity: 1; }
.itemResetBtn:hover {
color: var(--red);
background: var(--red-dim);
opacity: 1;
}
.diff {
font-family: var(--mono);
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
border: 1px solid transparent;
}
.diff.green { background: var(--green-dim); color: var(--green); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.diff.amber { background: var(--amber-dim); color: var(--amber); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.diff.red { background: var(--red-dim); color: var(--red); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.type {
font-family: var(--mono);
font-size: 10px;
color: var(--text-3);
background: var(--surface3);
padding: 1px 5px;
border-radius: 3px;
font-weight: 600;
border: 1px solid transparent;
}
.type.task { background: var(--blue-dim); color: var(--blue); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.type.mcq { background: var(--purple-dim); color: var(--purple); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.weight {
font-family: var(--mono);
font-size: 10px;
color: var(--text-3);
margin-left: auto;
}
@keyframes slideIn { from{opacity:0;transform:translateX(-4px)} to{opacity:1;transform:translateX(0)} }
+173
View File
@@ -0,0 +1,173 @@
import { useEffect, useRef, useCallback } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
import styles from './Terminal.module.css'
// ── xterm.js themes ──────────────────────────────────────────────────────────
const TERM_THEMES = {
dark: {
background: '#0d1117',
foreground: '#e6edf3',
cursor: '#58a6ff',
cursorAccent: '#0d1117',
selectionBackground:'#264f78',
black: '#0d1117', brightBlack: '#6e7681',
red: '#ff7b72', brightRed: '#ffa198',
green: '#3fb950', brightGreen: '#56d364',
yellow: '#d29922', brightYellow: '#e3b341',
blue: '#58a6ff', brightBlue: '#79c0ff',
magenta: '#bc8cff', brightMagenta: '#d2a8ff',
cyan: '#39c5cf', brightCyan: '#56d4dd',
white: '#e6edf3', brightWhite: '#ffffff',
},
light: {
background: '#f6f8fa',
foreground: '#1f2328',
cursor: '#0969da',
cursorAccent: '#f6f8fa',
selectionBackground:'rgba(84,174,255,0.35)',
black: '#24292f', brightBlack: '#57606a',
red: '#cf222e', brightRed: '#a40e26',
green: '#116329', brightGreen: '#1a7f37',
yellow: '#633c01', brightYellow: '#7d4e00',
blue: '#0969da', brightBlue: '#218bff',
magenta: '#8250df', brightMagenta: '#a475f9',
cyan: '#1b7c83', brightCyan: '#3192aa',
white: '#6e7781', brightWhite: '#8c959f',
},
}
function getCurrentTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function TerminalComponent({ collapsed, onToggleCollapse }) {
const containerRef = useRef(null)
const termRef = useRef(null)
const fitRef = useRef(null)
const wsRef = useRef(null)
const fit = useCallback(() => {
const fitAddon = fitRef.current
const term = termRef.current
if (!fitAddon || !term) return
if (!containerRef.current || containerRef.current.offsetHeight === 0) return
try {
fitAddon.fit()
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
} catch {}
}, [])
const connect = useCallback(() => {
const term = termRef.current
if (!term) return
if (wsRef.current) { wsRef.current.onclose = null; wsRef.current.close() }
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${location.host}/shell-ws`)
wsRef.current = ws
ws.onopen = () => { term.clear(); fit() }
ws.onmessage = (e) => { term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data)) }
ws.onclose = () => { term.write('\r\n\x1b[33m[Disconnected — click Reconnect]\x1b[0m\r\n') }
ws.onerror = () => { term.write('\r\n\x1b[31m[WebSocket error]\x1b[0m\r\n') }
}, [fit])
// Re-fit one frame after expand so the CSS height transition has settled
useEffect(() => {
if (!collapsed) {
const id = requestAnimationFrame(() => fit())
return () => cancelAnimationFrame(id)
}
}, [collapsed, fit])
// Mount terminal
useEffect(() => {
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: '"Cascadia Code", "Fira Code", Menlo, Monaco, "Courier New", monospace',
theme: TERM_THEMES[getCurrentTheme()],
scrollback: 5000,
allowTransparency: false,
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.loadAddon(new WebLinksAddon())
term.open(containerRef.current)
requestAnimationFrame(() => fitAddon.fit())
termRef.current = term
fitRef.current = fitAddon
term.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data)
})
connect()
window.addEventListener('resize', fit)
const ro = new ResizeObserver(() => fit())
if (containerRef.current) ro.observe(containerRef.current)
// Watch <html data-theme> and update xterm theme live
const mo = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName === 'data-theme') {
term.options.theme = TERM_THEMES[getCurrentTheme()]
}
}
})
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
return () => {
window.removeEventListener('resize', fit)
ro.disconnect()
mo.disconnect()
wsRef.current?.close()
term.dispose()
}
}, [connect, fit])
return (
<div className={styles.wrap}>
<div className={styles.bar}>
<div className={styles.barLeft}>
<div className={styles.dots}>
<span className={styles.dot} style={{background:'#ff5f56'}} />
<span className={styles.dot} style={{background:'#ffbd2e'}} />
<span className={styles.dot} style={{background:'#27c93f'}} />
</div>
<span className={styles.barTitle}>
<span className={styles.barIcon}>$_</span>
bash kubekosh
</span>
</div>
<div className={styles.barRight}>
<button className={styles.barBtn} onClick={connect} title="Reconnect terminal">
Reconnect
</button>
<button className={styles.barBtn} onClick={onToggleCollapse} title={collapsed ? 'Expand terminal' : 'Collapse terminal'}>
{collapsed ? '▲' : '▼'}
</button>
</div>
</div>
{/*
xtermOuter is position:relative so the absolutely-positioned xterm
mount point (containerRef) fills it exactly — canonical xterm.js pattern.
The div stays in the DOM even when collapsed so the PTY session lives.
*/}
<div className={styles.xtermOuter} style={collapsed ? { height: 0 } : undefined}>
<div ref={containerRef} className={styles.terminal} />
</div>
</div>
)
}
@@ -0,0 +1,91 @@
.wrap {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: var(--term-bg);
overflow: hidden;
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
height: 36px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.barLeft {
display: flex;
align-items: center;
gap: 12px;
}
.dots {
display: flex;
align-items: center;
gap: 6px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: block;
opacity: 0.9;
}
.barTitle {
font-family: var(--mono);
font-size: 12px;
color: var(--text-3);
display: flex;
align-items: center;
gap: 7px;
}
.barIcon {
color: var(--green);
font-weight: 600;
}
.barRight { display: flex; gap: 8px; }
.barBtn {
background: none;
border: 1px solid var(--border);
border-radius: 5px;
padding: 3px 10px;
font-size: 11px;
color: var(--text-3);
cursor: pointer;
font-family: var(--sans);
transition: all 0.12s;
}
.barBtn:hover { color: var(--text-2); border-color: var(--border2); }
/*
Canonical xterm.js container pattern:
- xtermOuter: flex:1, position:relative → gives FitAddon a reliable size box
- terminal: position:absolute, inset:0 → fills xtermOuter exactly
This eliminates the black gap caused by xterm canvas not matching its container.
*/
.xtermOuter {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
/* height:0 override applied inline when collapsed, keeping DOM alive for PTY */
}
.terminal {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}