StudentDashboard is now tabbed: My Learning (the original Phase 2A/2B flow, extracted unchanged into MyLearningTab and extended with an overall percent-complete bar), Practice, Assessments (available/active-with-live-countdown/history), Capstone (locked checklist -> unlocked -> start/resume -> completed), Achievements (a flat dated list, not a leaderboard). Inside the Lab UI: ProgressiveHints replaces the static hint array render when the Gateway has stripped hints (detected via scenario.hints being undefined but hint_count present - standalone dev mode never sees this, so it keeps the original behavior); a new top-level AssessmentBanner shows a live countdown and completion count whenever a Platform assessment is running, rendered alongside (not replacing) the pre-existing Lab-local exam-mode UI, which is untouched.
92 lines
4.5 KiB
React
92 lines
4.5 KiB
React
import { useState, useEffect, useCallback } from 'react'
|
|
import ReactMarkdown from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
import styles from './ScenarioPanel.module.css'
|
|
|
|
// Phase 3B progressive hints — used when the Gateway has stripped the full `hints` array from
|
|
// the scenario payload (the multi-student, Platform-mediated deployment; see
|
|
// platform/gateway/src/server.js's GET /lab/:token/api/scenarios/:scenarioId). Hints are
|
|
// fetched one at a time from the Platform API, server-enforced sequential (can't skip to hint
|
|
// 3), and every reveal is recorded against the student's attempt — none of that is possible
|
|
// with the old fully-preloaded array this replaces for this deployment mode.
|
|
//
|
|
// In standalone/single-container dev mode there's no Gateway rewrite, so `scenario.hints` is
|
|
// still the full array and ScenarioPanel never renders this component at all — see the
|
|
// `hintCount != null` check at the call site.
|
|
export default function ProgressiveHints({ scenarioId, totalHints }) {
|
|
const [revealed, setRevealed] = useState([]) // [{level, title, body, command}]
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState(null)
|
|
const [copiedIdx, setCopiedIdx] = useState(null)
|
|
const [assessmentHintPolicy, setAssessmentHintPolicy] = useState(null) // null = not in an assessment; 'free'|'disabled'|'flagged'
|
|
|
|
// A scenario/environment change resets local reveal state — the server is still the source
|
|
// of truth (GET /attempt would show the real highest_hint_level), this is just what's
|
|
// rendered locally this session. Also checks whether this scenario is part of an active
|
|
// Platform assessment, purely to show the policy indicator pre-emptively (the server enforces
|
|
// the policy regardless, on every reveal call — this is UX, not the security boundary).
|
|
useEffect(() => {
|
|
setRevealed([]); setError(null); setAssessmentHintPolicy(null)
|
|
fetch('/api/assessments/active').then((r) => r.json()).then((d) => {
|
|
if (d.active && d.scenarios?.some((s) => s.scenario_id === scenarioId)) setAssessmentHintPolicy(d.hint_policy)
|
|
}).catch(() => {})
|
|
}, [scenarioId])
|
|
|
|
const revealNext = useCallback(async () => {
|
|
setLoading(true); setError(null)
|
|
try {
|
|
const r = await fetch(`api/scenarios/${scenarioId}/hint`, { method: 'POST' })
|
|
const d = await r.json()
|
|
if (!r.ok) { setError(d.message || d.error || 'Could not reveal a hint.'); return }
|
|
setRevealed((h) => [...h, { level: d.level, ...d.hint }])
|
|
} catch {
|
|
setError('Could not reach the platform to reveal a hint.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [scenarioId])
|
|
|
|
function copyCmd(cmd, idx) {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
navigator.clipboard.writeText(cmd).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1800) })
|
|
}
|
|
}
|
|
|
|
if (totalHints === 0) return <div className={styles.noHints}>No hints available for this scenario.</div>
|
|
|
|
if (assessmentHintPolicy === 'disabled' && revealed.length === 0) {
|
|
return <div className={styles.noHints}>Hints Disabled — this assessment does not allow hints.</div>
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{assessmentHintPolicy === 'flagged' && <div className={styles.setupNote} style={{ marginBottom: '0.75rem' }}>Using a hint will be recorded.</div>}
|
|
{revealed.map((hint, i) => (
|
|
<div key={i} className={styles.hintCard}>
|
|
<div className={styles.hintHeader}>
|
|
<div className={styles.hintLeft}>
|
|
<span className={styles.hintNum}>Hint {hint.level}</span>
|
|
<span className={styles.hintTitle}>{hint.title}</span>
|
|
</div>
|
|
</div>
|
|
<div className={styles.hintBody}>
|
|
<p className={styles.hintText}><ReactMarkdown remarkPlugins={[remarkGfm]}>{hint.body}</ReactMarkdown></p>
|
|
{hint.command && (
|
|
<div className={styles.cmdBlock}>
|
|
<pre className={styles.cmdPre}>{hint.command}</pre>
|
|
<button className={styles.copyBtn} onClick={() => copyCmd(hint.command, i)}>{copiedIdx === i ? '✓ Copied' : 'Copy'}</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{error && <div className={styles.validateError}>⚠ {error}</div>}
|
|
{revealed.length < totalHints && (
|
|
<button className={styles.setupBtn} disabled={loading} onClick={revealNext}>
|
|
{loading ? 'Revealing…' : `Reveal Hint ${revealed.length + 1} of ${totalHints}`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|