- LoginScreen (credentialed, now the default entry point) with links to legacy pilot entry and forgot-password; SetPasswordScreen (shared activate/reset UI, driven by ?activate=/?reset= query params — no router library, matching this app's existing state-machine-per-shell convention); ForgotPasswordScreen (anti-enumeration, dev-only reset-token surface). - ClassroomTab: LIVE NOW banner, join, heartbeat only while joined and the tab is mounted, guidance-only current-activity link, own Lab status/launch (reuses the My Learning tab's exact API), announcements, Need Help form. No chat, no video, no shell access. - ProfileTab: identity, cohort, progress, achievements, active-session count, change-password (gated on has_credentials). - PlatformShell rewritten as an explicit view state machine; StudentDashboard gained the new tabs; api.js/Platform.module.css extended to match.
147 lines
5.9 KiB
React
147 lines
5.9 KiB
React
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
import { api } from './api'
|
|
import styles from './Platform.module.css'
|
|
|
|
const HEARTBEAT_MS = 25000 // §15 — "browser heartbeat every 20-30 seconds"
|
|
const POLL_MS = 5000
|
|
|
|
const HELP_CATEGORIES = [
|
|
{ value: 'lab_problem', label: 'Lab problem' },
|
|
{ value: 'dont_understand', label: "Don't understand the task" },
|
|
{ value: 'validation_failing', label: 'Validation failing' },
|
|
{ value: 'other', label: 'Other' },
|
|
]
|
|
|
|
// Phase 5A §22 — the live classroom experience. No live classroom -> a quiet "nothing live
|
|
// right now" state. Live -> title, current activity (guidance only — a link into the student's
|
|
// own Lab, never an auto-redirect), announcements, Need Help, and the student's own Lab status
|
|
// (reusing the exact same /api/environment/* the My Learning tab uses — no second Lab-status
|
|
// system). Heartbeats fire only while this tab is mounted and a classroom is actually joined.
|
|
export default function ClassroomTab() {
|
|
const [live, setLive] = useState(null)
|
|
const [detail, setDetail] = useState(null)
|
|
const [envStatus, setEnvStatus] = useState(null)
|
|
const [joined, setJoined] = useState(false)
|
|
const [helpCategory, setHelpCategory] = useState('lab_problem')
|
|
const [helpMessage, setHelpMessage] = useState('')
|
|
const [helpSent, setHelpSent] = useState(false)
|
|
const [error, setError] = useState(null)
|
|
const heartbeatRef = useRef(null)
|
|
const pollRef = useRef(null)
|
|
|
|
const loadLive = useCallback(() => {
|
|
api.liveClassroom().then((r) => setLive(r.classroom)).catch((e) => setError(e.message))
|
|
}, [])
|
|
useEffect(() => { loadLive() }, [loadLive])
|
|
|
|
const loadDetail = useCallback((id) => {
|
|
api.classroom(id).then(setDetail).catch((e) => setError(e.message))
|
|
api.environmentStatus().then(setEnvStatus).catch(() => {})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!live) return
|
|
loadDetail(live.classroom_session_id)
|
|
pollRef.current = setInterval(() => loadDetail(live.classroom_session_id), POLL_MS)
|
|
return () => clearInterval(pollRef.current)
|
|
}, [live, loadDetail])
|
|
|
|
useEffect(() => {
|
|
if (!joined || !live) return
|
|
heartbeatRef.current = setInterval(() => api.classroomHeartbeat(live.classroom_session_id).catch(() => {}), HEARTBEAT_MS)
|
|
api.classroomHeartbeat(live.classroom_session_id).catch(() => {}) // one immediately on join, not just after the first interval
|
|
return () => clearInterval(heartbeatRef.current)
|
|
}, [joined, live])
|
|
|
|
const join = async () => {
|
|
try { await api.joinClassroom(live.classroom_session_id); setJoined(true) }
|
|
catch (e) { setError(e.message) }
|
|
}
|
|
|
|
const sendHelp = async (e) => {
|
|
e.preventDefault()
|
|
try {
|
|
await api.requestHelp(live.classroom_session_id, helpCategory, helpMessage)
|
|
setHelpSent(true); setHelpMessage('')
|
|
} catch (e) { setError(e.message) }
|
|
}
|
|
|
|
const launchLab = async () => {
|
|
try { setEnvStatus(await api.launchEnvironment()) } catch (e) { setError(e.message) }
|
|
}
|
|
|
|
if (!live) {
|
|
return (
|
|
<div className={styles.card}>
|
|
<h2>Classroom</h2>
|
|
<p className={styles.muted}>No live class right now. Check back at your scheduled class time.</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className={styles.card}>
|
|
<p className={styles.livePill}>LIVE NOW</p>
|
|
<h2 style={{ margin: '0.4rem 0' }}>{live.title}</h2>
|
|
{live.description && <p className={styles.muted}>{live.description}</p>}
|
|
{live.meeting_url && (
|
|
<p><a className={styles.primaryBtn} href={live.meeting_url} target="_blank" rel="noreferrer">Join Video Class</a></p>
|
|
)}
|
|
{!joined ? (
|
|
<button className={styles.primaryBtn} onClick={join}>Join Classroom</button>
|
|
) : (
|
|
<p className={styles.muted}>You're in this classroom.</p>
|
|
)}
|
|
</div>
|
|
|
|
{joined && detail && (
|
|
<>
|
|
{detail.classroom.current_scenario_id && (
|
|
<div className={styles.card}>
|
|
<h3>Current class activity</h3>
|
|
<p>{detail.classroom.current_scenario_id}</p>
|
|
<p className={styles.muted}>Open it from the My Learning tab when you're ready — nothing here jumps you there automatically.</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className={styles.card}>
|
|
<h3>Your Lab</h3>
|
|
{envStatus?.status === 'running'
|
|
? <p><a className={styles.primaryBtn} href={envStatus.launch_path}>Open Lab</a></p>
|
|
: <button className={styles.primaryBtn} onClick={launchLab} disabled={envStatus?.status === 'provisioning'}>
|
|
{envStatus?.status === 'provisioning' ? 'Starting…' : 'Launch Lab'}
|
|
</button>}
|
|
</div>
|
|
|
|
<div className={styles.card}>
|
|
<h3>Announcements</h3>
|
|
{!detail.announcements.length ? <p className={styles.muted}>None yet.</p> : (
|
|
<ul>
|
|
{detail.announcements.map((a) => <li key={a.announcement_id}>{a.message}</li>)}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.card}>
|
|
<h3>Need help?</h3>
|
|
{helpSent ? <p className={styles.muted}>Help requested — your instructor has been notified.</p> : (
|
|
<form onSubmit={sendHelp}>
|
|
<label className={styles.label}>What's going on?</label>
|
|
<select className={styles.select} value={helpCategory} onChange={(e) => setHelpCategory(e.target.value)}>
|
|
{HELP_CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
|
|
</select>
|
|
<label className={styles.label}>Details (optional)</label>
|
|
<input className={styles.input} value={helpMessage} onChange={(e) => setHelpMessage(e.target.value)} />
|
|
<button className={styles.primaryBtn} type="submit">Need Help</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{error && <div className={styles.errorBox}>{error}</div>}
|
|
</div>
|
|
)
|
|
}
|