Initial commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>KubeKosh — Interactive Kubernetes Playground</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Epilogue:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "kubekosh-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<svg height="470" width="480" viewBox="100 95 480 470" role="img" xmlns="http://www.w3.org/2000/svg" style="">
|
||||
<title style="fill:rgb(0, 0, 0);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">KubeKosh icon</title>
|
||||
<desc style="fill:rgb(0, 0, 0);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">KubeKosh heptagon logo mark in blue with a white innermost layer and dark terminal prompt icon.</desc>
|
||||
|
||||
<!--
|
||||
Heptagon, cx=340, cy=340, angle offset=-90°, step=51.4286°
|
||||
r=220 (outer):
|
||||
k0: (340, 120)
|
||||
k1: (340+220cos(-38.57°), 340+220sin(-38.57°)) = (340+171.9, 340-137.1) = (511.9, 202.9)
|
||||
k2: (340+220cos(12.86°), 340+220sin(12.86°)) = (340+214.4, 340+48.9) = (554.4, 388.9)
|
||||
k3: (340+220cos(64.29°), 340+220sin(64.29°)) = (340+95.0, 340+198.3) = (435.0, 538.3)
|
||||
k4: (340-95.0, 538.3) = (245.0, 538.3)
|
||||
k5: (340-214.4, 388.9) = (125.6, 388.9)
|
||||
k6: (340-171.9, 202.9) = (168.1, 202.9)
|
||||
|
||||
r=178 (mid ring), factor=178/220=0.8091:
|
||||
delta from center scaled by 0.8091:
|
||||
k0: (340, 340+(120-340)*0.8091)=(340, 161.9)
|
||||
k1: (340+171.9*0.8091, 340-137.1*0.8091)=(479.1, 228.9)
|
||||
k2: (340+214.4*0.8091, 340+48.9*0.8091)=(513.5, 379.6)
|
||||
k3: (340+95.0*0.8091, 340+198.3*0.8091)=(416.9, 500.5)
|
||||
k4: (263.1, 500.5)
|
||||
k5: (166.5, 379.6)
|
||||
k6: (200.9, 228.9)
|
||||
|
||||
r=132 (inner screen), factor=132/220=0.6:
|
||||
k0: (340, 340+(120-340)*0.6)=(340, 208)
|
||||
k1: (340+171.9*0.6, 340-137.1*0.6)=(443.1, 257.7)
|
||||
k2: (340+214.4*0.6, 340+48.9*0.6)=(468.6, 369.3)
|
||||
k3: (340+95.0*0.6, 340+198.3*0.6)=(397.0, 458.9)
|
||||
k4: (283.0, 458.9)
|
||||
k5: (340-214.4*0.6, 369.3)=(211.4, 369.3)
|
||||
k6: (340-171.9*0.6, 257.7)=(236.9, 257.7)
|
||||
-->
|
||||
|
||||
<!-- Outer heptagon — mid blue -->
|
||||
<polygon points="340,120 511.9,202.9 554.4,388.9 435.0,538.3 245.0,538.3 125.6,388.9 168.1,202.9" fill="#378ADD" style="fill:rgb(55, 138, 221);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
|
||||
<!-- Mid ring — darker blue -->
|
||||
<polygon points="340,161.9 479.1,228.9 513.5,379.6 416.9,500.5 263.1,500.5 166.5,379.6 200.9,228.9" fill="#185FA5" style="fill:rgb(24, 95, 165);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
|
||||
<!-- Inner heptagon — white -->
|
||||
<polygon points="340,208 443.1,257.7 468.6,369.3 397.0,458.9 283.0,458.9 211.4,369.3 236.9,257.7" fill="#FFFFFF" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
|
||||
<!-- Terminal prompt, centered at (340, 333) -->
|
||||
<!-- Caret > -->
|
||||
<polyline points="296,308 340,333 296,358" fill="none" stroke="#0C447C" stroke-width="480" stroke-linecap="round" stroke-linejoin="round" style="fill:none;stroke:rgb(12, 68, 124);color:rgb(251, 251, 254);stroke-width:18px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<!-- Cursor underscore -->
|
||||
<line x1="352" y1="384" x2="404" y2="384" stroke="#185FA5" stroke-width="480" stroke-linecap="round" style="fill:rgb(0, 0, 0);stroke:rgb(24, 95, 165);color:rgb(251, 251, 254);stroke-width:13px;stroke-linecap:round;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
|
||||
<!-- Vertex dots -->
|
||||
<circle cx="340" cy="120" r="10" fill="#B5D4F4" style="fill:rgb(181, 212, 244);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<circle cx="511.9" cy="202.9" r="10" fill="#B5D4F4" style="fill:rgb(181, 212, 244);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<circle cx="554.4" cy="388.9" r="10" fill="#B5D4F4" style="fill:rgb(181, 212, 244);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<circle cx="435.0" cy="538.3" r="10" fill="#B5D4F4" style="fill:rgb(181, 212, 244);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<circle cx="245.0" cy="538.3" r="10" fill="#B5D4F4" style="fill:rgb(181, 212, 244);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<circle cx="125.6" cy="388.9" r="10" fill="#B5D4F4" style="fill:rgb(181, 212, 244);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
<circle cx="168.1" cy="202.9" r="10" fill="#B5D4F4" style="fill:rgb(181, 212, 244);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,346 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import ScenarioPanel from './components/ScenarioPanel'
|
||||
import Terminal from './components/Terminal'
|
||||
import Header from './components/Header'
|
||||
import BundleNav from './components/BundleNav'
|
||||
import ExamTimer from './components/ExamTimer'
|
||||
import ExamReport from './components/ExamReport'
|
||||
import ExamStartModal from './components/ExamStartModal'
|
||||
import styles from './App.module.css'
|
||||
|
||||
const MIN_SIDEBAR_W = 180
|
||||
const MAX_SIDEBAR_W = 560
|
||||
const DEFAULT_SIDEBAR_W = 280
|
||||
const SIDEBAR_COLLAPSE_PX = 100
|
||||
const SIDEBAR_COLLAPSED_W = 40
|
||||
|
||||
const MIN_TERM_H = 36
|
||||
const MAX_TERM_H = 600
|
||||
const DEFAULT_TERM_H = 280
|
||||
const TERM_COLLAPSE_PX = 60
|
||||
|
||||
export default function App() {
|
||||
const [bundles, setBundles] = useState([])
|
||||
const [activeBundleId, setActiveBundleId] = useState(null)
|
||||
|
||||
const [scenarios, setScenarios] = useState([])
|
||||
const [activeId, setActiveId] = useState(null)
|
||||
const [scenario, setScenario] = useState(null)
|
||||
const [progress, setProgress] = useState({})
|
||||
const [clusterReady, setClusterReady] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// ── Exam mode ─────────────────────────────────────────────────────────────
|
||||
const [examSession, setExamSession] = useState(null) // active session object
|
||||
const [examReport, setExamReport] = useState(null) // submitted report
|
||||
const [examModalBundle, setExamModalBundle] = useState(null) // bundle for start/retry modal
|
||||
|
||||
// Sidebar resize / collapse
|
||||
const [sidebarW, setSidebarW] = useState(DEFAULT_SIDEBAR_W)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const sbDragging = useRef(false)
|
||||
const sbDragX0 = useRef(0)
|
||||
const sbDragW0 = useRef(0)
|
||||
|
||||
// Terminal resize / collapse
|
||||
const [termH, setTermH] = useState(300)
|
||||
const [termCollapsed, setTermCollapsed] = useState(false)
|
||||
const [bundlesCollapsed, setBundlesCollapsed] = useState(false)
|
||||
const tmDragging = useRef(false)
|
||||
const tmDragY0 = useRef(0)
|
||||
const tmDragH0 = useRef(0)
|
||||
|
||||
// Track previous scenario id to teardown on switch
|
||||
const prevActiveIdRef = useRef(null)
|
||||
|
||||
// ── Cluster health ────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
async function check() {
|
||||
try {
|
||||
const d = await fetch('/api/health').then(r => r.json())
|
||||
setClusterReady(d.cluster === 'ready')
|
||||
} catch { setClusterReady(false) }
|
||||
}
|
||||
check()
|
||||
const t = setInterval(check, 8000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
// ── Load bundles (once) ───────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
fetch('/api/bundles')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setBundles(data)
|
||||
if (data.length > 0) setActiveBundleId(data[0].id)
|
||||
})
|
||||
.catch(console.error)
|
||||
}, [])
|
||||
|
||||
// ── Restore active exam session on load ───────────────────────────────────
|
||||
useEffect(() => {
|
||||
fetch('/api/sessions/active')
|
||||
.then(r => r.json())
|
||||
.then(s => { if (s) setExamSession(s) })
|
||||
.catch(() => { })
|
||||
}, [])
|
||||
|
||||
// ── Load scenarios for active bundle ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!activeBundleId) return
|
||||
setLoading(true)
|
||||
setActiveId(null)
|
||||
setScenario(null)
|
||||
const url = `/api/scenarios?bundle=${activeBundleId}`
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setScenarios(data)
|
||||
setProgress(Object.fromEntries(data.map(s => [s.id, s.progress])))
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [activeBundleId])
|
||||
|
||||
// ── Load full scenario when selected — teardown previous, load new ─────────
|
||||
useEffect(() => {
|
||||
if (!activeId) return
|
||||
|
||||
// Teardown the previously active scenario's cluster state on switch
|
||||
const prevId = prevActiveIdRef.current
|
||||
if (prevId && prevId !== activeId) {
|
||||
fetch(`/api/scenarios/${prevId}/teardown`, { method: 'POST' }).catch(() => { })
|
||||
}
|
||||
prevActiveIdRef.current = activeId
|
||||
|
||||
setScenario(null)
|
||||
fetch(`/api/scenarios/${activeId}`)
|
||||
.then(r => r.json())
|
||||
.then(s => {
|
||||
setScenario(s)
|
||||
// Feature 3: sync terminal context when scenario selected
|
||||
fetch(`/api/scenarios/${activeId}/context`, { method: 'POST' }).catch(() => { })
|
||||
})
|
||||
.catch(console.error)
|
||||
}, [activeId])
|
||||
|
||||
const refreshProgress = useCallback(async () => {
|
||||
const [bundleData, scenarioData] = await Promise.all([
|
||||
fetch('/api/bundles').then(r => r.json()),
|
||||
fetch(`/api/scenarios?bundle=${activeBundleId}`).then(r => r.json()),
|
||||
])
|
||||
setBundles(bundleData)
|
||||
setScenarios(scenarioData)
|
||||
setProgress(Object.fromEntries(scenarioData.map(s => [s.id, s.progress])))
|
||||
if (activeId) {
|
||||
const d2 = await fetch(`/api/scenarios/${activeId}`).then(r => r.json())
|
||||
setScenario(d2)
|
||||
}
|
||||
// Refresh exam session completion count
|
||||
if (examSession) {
|
||||
const updated = await fetch('/api/sessions/active').then(r => r.json()).catch(() => null)
|
||||
if (updated) setExamSession(updated)
|
||||
}
|
||||
}, [activeBundleId, activeId, examSession])
|
||||
|
||||
// ── Exam actions ──────────────────────────────────────────────────────────
|
||||
const startExam = useCallback(async (bundleId, customMinutes) => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bundleId, examMinutes: customMinutes }),
|
||||
}).then(r => r.json())
|
||||
// refresh to get scenarioCount
|
||||
const active = await fetch('/api/sessions/active').then(r => r.json())
|
||||
setExamSession(active)
|
||||
setActiveBundleId(bundleId)
|
||||
setActiveId(null)
|
||||
setScenario(null)
|
||||
}, [])
|
||||
|
||||
const submitExam = useCallback(async () => {
|
||||
if (!examSession) return
|
||||
const result = await fetch(`/api/sessions/${examSession.id}/submit`, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
const bundle = bundles.find(b => b.id === examSession.bundle_id)
|
||||
setExamReport({ ...result, bundle })
|
||||
setExamSession(null)
|
||||
}, [examSession, bundles])
|
||||
|
||||
const abandonExam = useCallback(async () => {
|
||||
if (!examSession) return
|
||||
await fetch(`/api/sessions/${examSession.id}/abandon`, { method: 'POST' }).catch(() => { })
|
||||
setExamSession(null)
|
||||
}, [examSession])
|
||||
|
||||
// ── Teardown when restarting a scenario (Feature 2) ───────────────────────
|
||||
const handleScenarioStart = useCallback(async (scenarioId) => {
|
||||
// Run teardown first to clean cluster state
|
||||
await fetch(`/api/scenarios/${scenarioId}/teardown`, { method: 'POST' }).catch(() => { })
|
||||
// Then setup will be called by ScenarioPanel as before
|
||||
}, [])
|
||||
|
||||
// ── Sidebar drag ─────────────────────────────────────────────────────────
|
||||
const onSidebarDragDown = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
sbDragging.current = true
|
||||
sbDragX0.current = e.clientX
|
||||
sbDragW0.current = sidebarCollapsed ? SIDEBAR_COLLAPSED_W : sidebarW
|
||||
|
||||
function onMove(ev) {
|
||||
if (!sbDragging.current) return
|
||||
const newW = sbDragW0.current + (ev.clientX - sbDragX0.current)
|
||||
if (newW < SIDEBAR_COLLAPSE_PX) {
|
||||
setSidebarCollapsed(true)
|
||||
} else {
|
||||
setSidebarCollapsed(false)
|
||||
setSidebarW(Math.min(MAX_SIDEBAR_W, Math.max(MIN_SIDEBAR_W, newW)))
|
||||
}
|
||||
}
|
||||
function onUp() {
|
||||
sbDragging.current = false
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}, [sidebarCollapsed, sidebarW])
|
||||
|
||||
// ── Terminal drag ────────────────────────────────────────────────────────
|
||||
const onTermDragDown = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
tmDragging.current = true
|
||||
tmDragY0.current = e.clientY
|
||||
tmDragH0.current = termCollapsed ? MIN_TERM_H : termH
|
||||
|
||||
function onMove(ev) {
|
||||
if (!tmDragging.current) return
|
||||
const newH = tmDragH0.current + (tmDragY0.current - ev.clientY)
|
||||
if (newH < TERM_COLLAPSE_PX) {
|
||||
setTermCollapsed(true)
|
||||
} else {
|
||||
setTermCollapsed(false)
|
||||
setTermH(Math.min(MAX_TERM_H, Math.max(MIN_TERM_H + 40, newH)))
|
||||
}
|
||||
}
|
||||
function onUp() {
|
||||
tmDragging.current = false
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}, [termCollapsed, termH])
|
||||
|
||||
const currentSidebarW = sidebarCollapsed ? SIDEBAR_COLLAPSED_W : sidebarW
|
||||
const currentTermH = termCollapsed ? MIN_TERM_H : termH
|
||||
const activeBundle = bundles.find(b => b.id === activeBundleId) || null
|
||||
const isMcq = scenario?.type === 'mcq'
|
||||
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
<Header clusterReady={clusterReady} />
|
||||
|
||||
{/* Bundle navigation bar */}
|
||||
<BundleNav
|
||||
bundles={bundles}
|
||||
activeBundleId={activeBundleId}
|
||||
examSession={examSession}
|
||||
onSelect={id => {
|
||||
// In exam mode, only allow switching within the exam bundle
|
||||
if (examSession && id !== examSession.bundle_id) return
|
||||
setActiveBundleId(id); setActiveId(null); setScenario(null)
|
||||
}}
|
||||
onProgressUpdate={refreshProgress}
|
||||
onStartExam={setExamModalBundle}
|
||||
collapsed={bundlesCollapsed}
|
||||
onToggleCollapse={() => setBundlesCollapsed(c => !c)}
|
||||
/>
|
||||
|
||||
{/* Exam timer bar */}
|
||||
{examSession && (
|
||||
<ExamTimer
|
||||
session={examSession}
|
||||
bundle={activeBundle}
|
||||
onSubmit={submitExam}
|
||||
onAbandon={abandonExam}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.body}>
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
scenarios={scenarios}
|
||||
activeId={activeId}
|
||||
onSelect={setActiveId}
|
||||
loading={loading}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={() => setSidebarCollapsed(c => !c)}
|
||||
width={currentSidebarW}
|
||||
activeBundleId={activeBundleId}
|
||||
onProgressUpdate={refreshProgress}
|
||||
/>
|
||||
|
||||
{/* Sidebar resize handle */}
|
||||
<div className={styles.sidebarHandle} onMouseDown={onSidebarDragDown} />
|
||||
|
||||
{/* Main area */}
|
||||
<div className={styles.main}>
|
||||
<div className={styles.scenarioWrap}>
|
||||
<ScenarioPanel
|
||||
scenario={scenario}
|
||||
onProgressUpdate={refreshProgress}
|
||||
onScenarioStart={handleScenarioStart}
|
||||
isExamMode={!!examSession}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isMcq && (
|
||||
<div className={styles.termHandle} onMouseDown={onTermDragDown} />
|
||||
)}
|
||||
|
||||
{!isMcq && (
|
||||
<div className={styles.terminalWrap} style={{ height: currentTermH }}>
|
||||
<Terminal
|
||||
collapsed={termCollapsed}
|
||||
onToggleCollapse={() => setTermCollapsed(c => !c)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exam report modal */}
|
||||
{examReport && (
|
||||
<ExamReport
|
||||
report={examReport}
|
||||
bundle={examReport.bundle}
|
||||
onClose={() => setExamReport(null)}
|
||||
onRetry={() => {
|
||||
setExamReport(null)
|
||||
setExamModalBundle(examReport.bundle)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Exam start modal */}
|
||||
{examModalBundle && (
|
||||
<ExamStartModal
|
||||
bundle={examModalBundle}
|
||||
onStart={mins => {
|
||||
setExamModalBundle(null)
|
||||
startExam(examModalBundle.id, mins)
|
||||
}}
|
||||
onCancel={() => setExamModalBundle(null)}
|
||||
/>
|
||||
)}
|
||||
<footer className={styles.footer}>
|
||||
<div>© {new Date().getFullYear()} The KubeKosh Project • All rights reserved</div>
|
||||
<div>
|
||||
Made with <span className={styles.heart}>❤️</span> by <a href="https://github.com/zeborg" target="_blank" rel="noopener noreferrer" className={styles.footerLink}>zeborg</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Scenario panel */
|
||||
.scenarioWrap {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Sidebar resize handle ────────────────────────────────────────────────── */
|
||||
.sidebarHandle {
|
||||
flex-shrink: 0;
|
||||
width: 5px;
|
||||
cursor: ew-resize;
|
||||
background: var(--border);
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
}
|
||||
.sidebarHandle:hover,
|
||||
.sidebarHandle:active {
|
||||
background: var(--green);
|
||||
}
|
||||
/* Grip indicator for sidebar */
|
||||
.sidebarHandle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 3px;
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
background: var(--border2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.sidebarHandle:hover::before,
|
||||
.sidebarHandle:active::before {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ── Terminal resize handle ───────────────────────────────────────────────── */
|
||||
.termHandle {
|
||||
flex-shrink: 0;
|
||||
height: 5px;
|
||||
cursor: ns-resize;
|
||||
background: var(--border);
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
}
|
||||
.termHandle:hover,
|
||||
.termHandle:active {
|
||||
background: var(--green);
|
||||
}
|
||||
/* Grip indicator */
|
||||
.termHandle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 32px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--border2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.termHandle:hover::before,
|
||||
.termHandle:active::before {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ── Terminal wrapper ─────────────────────────────────────────────────────── */
|
||||
.terminalWrap {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
|
||||
/* ── Footer ───────────────────────────────────────────────────────────────── */
|
||||
.footer {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
padding: 6px 12px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
font-family: var(--sans);
|
||||
letter-spacing: 0.3px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footerLink {
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.footerLink:hover {
|
||||
color: var(--green);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.heart {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
margin: 0 1px;
|
||||
transform: translateY(-0.5px);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} }
|
||||
@@ -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)} }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)} }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
/* ── Dark theme (default) ────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #0c0f14;
|
||||
--surface: #131720;
|
||||
--surface2: #1a2030;
|
||||
--surface3: #212840;
|
||||
--border: #252d42;
|
||||
--border2: #2e3a55;
|
||||
|
||||
--green: #39d98a;
|
||||
--green-dim: rgba(57,217,138,0.12);
|
||||
--blue: #4dabf7;
|
||||
--blue-dim: rgba(77,171,247,0.10);
|
||||
--amber: #fcc419;
|
||||
--amber-dim: rgba(252,196,25,0.10);
|
||||
--red: #ff6b6b;
|
||||
--red-dim: rgba(255,107,107,0.10);
|
||||
--purple: #9775fa;
|
||||
--purple-dim: rgba(151,117,250,0.10);
|
||||
|
||||
--text: #e2e8f4;
|
||||
--text-2: #8899b8;
|
||||
--text-3: #4d5f80;
|
||||
|
||||
--mono: 'IBM Plex Mono', monospace;
|
||||
--sans: 'Epilogue', sans-serif;
|
||||
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
--term-bg: #0d1117;
|
||||
}
|
||||
|
||||
/* ── Light theme ─────────────────────────────────────────────────────────── */
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f0f4f8;
|
||||
--surface: #ffffff;
|
||||
--surface2: #f5f7fa;
|
||||
--surface3: #eaecf1;
|
||||
--border: #d6dce8;
|
||||
--border2: #c0c9d8;
|
||||
|
||||
--green: #0d9955;
|
||||
--green-dim: rgba(13,153,85,0.10);
|
||||
--blue: #1976d2;
|
||||
--blue-dim: rgba(25,118,210,0.10);
|
||||
--amber: #b45309;
|
||||
--amber-dim: rgba(180,83,9,0.10);
|
||||
--red: #dc2626;
|
||||
--red-dim: rgba(220,38,38,0.10);
|
||||
--purple: #7c3aed;
|
||||
--purple-dim: rgba(124,58,237,0.10);
|
||||
|
||||
--text: #111827;
|
||||
--text-2: #374151;
|
||||
--text-3: #6b7280;
|
||||
|
||||
--term-bg: #f6f8fa;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background 0.25s ease, color 0.25s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-3); }
|
||||
|
||||
/* Markdown styles */
|
||||
.md h1,.md h2,.md h3 { font-family: var(--sans); font-weight: 700; margin-bottom: 12px; line-height: 1.3; }
|
||||
.md h2 { font-size: 18px; color: var(--text); }
|
||||
.md h3 { font-size: 15px; color: var(--text-2); }
|
||||
.md p { margin-bottom: 12px; color: var(--text-2); line-height: 1.7; }
|
||||
.md p:last-child { margin-bottom: 0; }
|
||||
.md code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
background: var(--surface3);
|
||||
color: var(--green);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Inline code rendered by InlineMd (outside .md wrapper) */
|
||||
.inline-code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
background: var(--surface3);
|
||||
color: var(--green);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.md pre {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
margin: 12px 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.md pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--green);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.md ul, .md ol { padding-left: 20px; margin-bottom: 12px; color: var(--text-2); }
|
||||
.md li { margin-bottom: 4px; line-height: 1.6; }
|
||||
.md strong { color: var(--text); font-weight: 600; }
|
||||
.md blockquote {
|
||||
border-left: 3px solid var(--blue);
|
||||
padding: 8px 14px;
|
||||
background: var(--blue-dim);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
margin: 12px 0;
|
||||
color: var(--text-2);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
|
||||
@keyframes spin { to{transform:rotate(360deg)} }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||
@keyframes slideIn { from{transform:translateX(-8px);opacity:0} to{transform:translateX(0);opacity:1} }
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
// Apply saved theme before first paint to avoid flash
|
||||
const savedTheme = localStorage.getItem('kubekosh-theme') || 'dark'
|
||||
document.documentElement.setAttribute('data-theme', savedTheme)
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:4000'
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user