diff --git a/apps/web/README.md b/apps/web/README.md index c7b7344..831482b 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -46,6 +46,46 @@ numeric fields; invalid bundles fail loudly instead of rendering. Set Filters write `?difficulty=&type=&track=` query params, so filtered views are shareable URLs while the grid itself renders server-side. +### Learning, challenges, notifications + +`/learn`, `/learn/[pathSlug]`, `/learn/[pathSlug]/[moduleSlug]`, +`/challenges`, and `/notifications` read and mutate through the same-origin +proxy from client components (`lib/api-browser.ts`). These surfaces are +inherently per-user and interactive, so the browser owns the fetch lifecycle: +loading skeletons first, an inline sign-in prompt on 401/403, and a problem- +details error banner otherwise. Response types live once in +`@openrange/contracts` (`ApiClient.learning`, `.challenges`, `.notifications`); +the web app casts nothing. + +Two behaviors worth knowing: + +- Quiz feedback shows only per-question correctness returned by + `POST /api/or/quiz/{section_id}/attempt`. Answer keys never reach the + browser, so the UI cannot leak them. +- The module reading pane offers a language switcher only when a probe of + `GET /api/or/learning/modules/{slug}?lang=` answers 200 for a + candidate language ("when 200 else hide"). Today no candidate answers 200, + so the switcher stays hidden until localization ships backend-side. + +### Browser terminal + +`/terminal` attaches xterm.js to the gateway WebSocket at +`/terminal/{instance_id}?token=...` (binary frames carry keystrokes and PTY +output; text frames are control JSON such as resize, plus the initial +`{"type":"session"}` handshake used for `?resume=` reconnects). + +Token reality check (grep before you trust this): the gateway verifies +short-lived `lab-v1` tokens (`apps/lab-gateway/src/openrange/gateway/tokens.py`), +but the FastAPI side has **no minting endpoint yet** — there is no +`POST /api/labs/{id}/terminal-token` route in `apps/api`, and the tokens +module docstring says minting lands in a later branch. Until that endpoint +ships, the page therefore stays gated behind selecting a READY lab from your +own instance list, and Connect reports precisely that the token endpoint is +missing instead of pretending to attach. When the endpoint exists, the same +client connects for real: it expects `{token}` from +`POST /api/or/labs/{id}/terminal-token` and opens +`${NEXT_PUBLIC_GATEWAY_WS_URL:-ws://localhost:8772}/terminal/{id}?token=...`. + ### Styling and accessibility - `packages/ui` owns tokens (`tokens.css`: near-black surfaces, single lime @@ -56,8 +96,11 @@ shareable URLs while the grid itself renders server-side. - Keyboard support: skip link as first tab stop, visible focus rings everywhere, native `` for modals (focus trap + Escape for free), WAI-ARIA tabs pattern in the Tabs primitive, reduced-motion-safe animations. -- Playwright runs axe on `/`, `/catalog`, `/login` at 320 px and 1280 px, plus - a keyboard-only journey test. +- The top navigation marks the active route with `aria-current="page"`; + below the `md` breakpoint it collapses into a disclosure menu button. +- Playwright runs axe on `/`, `/catalog`, `/login`, `/learn`, and + `/challenges` at 320 px and 1280 px, plus a keyboard-only journey test and + mocked-API journeys for enroll/module/quiz and challenge flag submission. ## Local development @@ -70,6 +113,9 @@ Environment variables (see `.env.example` at the repo root): - `ORIGIN_API_URL`: where FastAPI listens (default `http://localhost:8700`). - `OPENRANGE_CONTENT_DIR`: override for the content bundle directory. +- `NEXT_PUBLIC_GATEWAY_WS_URL`: gateway WebSocket base for the browser + terminal (default `ws://localhost:8772`); production Caddy exposes it under + `/gateway/*`, so set `wss:///gateway`. ## Commands diff --git a/apps/web/app/challenges/page.tsx b/apps/web/app/challenges/page.tsx new file mode 100644 index 0000000..cb2bf8f --- /dev/null +++ b/apps/web/app/challenges/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; +import { ChallengeBoard } from '@/components/ChallengeBoard'; + +export const metadata: Metadata = { title: 'Challenges' }; + +export default function ChallengesPage() { + return ( +
+
+

Challenges

+

+ A jeopardy board of standalone puzzles. Points may decay as solves pile up on dynamic + challenges; submit flags from each challenge's detail view. +

+
+ +
+ ); +} diff --git a/apps/web/app/learn/[pathSlug]/[moduleSlug]/page.tsx b/apps/web/app/learn/[pathSlug]/[moduleSlug]/page.tsx new file mode 100644 index 0000000..bb755dc --- /dev/null +++ b/apps/web/app/learn/[pathSlug]/[moduleSlug]/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next'; +import { ModuleReader } from '@/components/ModuleReader'; + +export const metadata: Metadata = { title: 'Module' }; + +interface Props { + params: Promise<{ pathSlug: string; moduleSlug: string }>; +} + +export default async function ModulePage({ params }: Props) { + const { pathSlug, moduleSlug } = await params; + return ( +
+ +
+ ); +} diff --git a/apps/web/app/learn/[pathSlug]/page.tsx b/apps/web/app/learn/[pathSlug]/page.tsx new file mode 100644 index 0000000..c5d742b --- /dev/null +++ b/apps/web/app/learn/[pathSlug]/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next'; +import { PathOutline } from '@/components/PathOutline'; + +export const metadata: Metadata = { title: 'Learning path' }; + +interface Props { + params: Promise<{ pathSlug: string }>; +} + +export default async function LearningPathPage({ params }: Props) { + const { pathSlug } = await params; + return ( +
+ +
+ ); +} diff --git a/apps/web/app/learn/page.tsx b/apps/web/app/learn/page.tsx new file mode 100644 index 0000000..3412a78 --- /dev/null +++ b/apps/web/app/learn/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; +import { LearnCatalog } from '@/components/LearnCatalog'; + +export const metadata: Metadata = { title: 'Learn' }; + +export default function LearnPage() { + return ( +
+
+

Learning paths

+

+ Guided curricula that pair reading, quizzes, and live lab rooms. Enroll to track section + completion and earn a certificate when the required modules are done. +

+
+ +
+ ); +} diff --git a/apps/web/app/notifications/page.tsx b/apps/web/app/notifications/page.tsx new file mode 100644 index 0000000..01191b8 --- /dev/null +++ b/apps/web/app/notifications/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next'; +import { NotificationsView } from '@/components/NotificationsView'; + +export const metadata: Metadata = { title: 'Notifications' }; + +export default function NotificationsPage() { + return ( +
+
+

Notifications

+

+ Unread items first. Control channels and muted categories below the feed. +

+
+
+ +
+
+ ); +} diff --git a/apps/web/app/terminal/page.tsx b/apps/web/app/terminal/page.tsx new file mode 100644 index 0000000..e393651 --- /dev/null +++ b/apps/web/app/terminal/page.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { getCurrentUser, getServerApiClient } from '@/lib/api-server'; +import type { LabInstance } from '@openrange/contracts'; +import { TerminalClient } from '@/components/TerminalClient'; + +export const metadata: Metadata = { title: 'Terminal' }; +export const dynamic = 'force-dynamic'; + +export default async function TerminalPage() { + const user = await getCurrentUser(); + if (user === null) { + redirect('/login'); + } + + let labs: LabInstance[] = []; + let apiError: string | null = null; + try { + labs = [...(await (await getServerApiClient()).labs.list())]; + } catch { + apiError = 'Could not load your labs. Is the API server running?'; + } + + return ( +
+
+

Terminal

+

+ An SSH shell into one of your lab instances, bridged through the gateway. Pick a READY + lab; every keystroke stays inside that isolated session. +

+
+ + {apiError !== null ? ( +

+ {apiError} +

+ ) : ( + + )} + +

+ Need a target first?{' '} + + Start a lab + {' '} + from any room page. +

+
+ ); +} diff --git a/apps/web/components/ChallengeBoard.tsx b/apps/web/components/ChallengeBoard.tsx new file mode 100644 index 0000000..1a249cb --- /dev/null +++ b/apps/web/components/ChallengeBoard.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { Skeleton } from '@openrange/ui'; +import type { ChallengeCategory, ChallengeSummary } from '@openrange/contracts'; +import { apiProxy, isAuthError, ProxyError } from '@/lib/api-browser'; +import { ChallengeModal } from './ChallengeModal'; +import { SignInPrompt } from './SignInPrompt'; + +type Load = + | { kind: 'loading' } + | { kind: 'signedOut' } + | { kind: 'error'; message: string } + | { kind: 'ready'; challenges: ChallengeSummary[] }; + +/** Display order for categories; unknown categories append in API order. */ +const CATEGORY_ORDER: readonly ChallengeCategory[] = [ + 'web', + 'crypto', + 'pwn', + 'rev', + 'forensics', + 'misc', + 'osint', +]; + +export function ChallengeBoard() { + const [load, setLoad] = useState({ kind: 'loading' }); + const [openSlug, setOpenSlug] = useState(null); + const [solvedSlugs, setSolvedSlugs] = useState([]); + + useEffect(() => { + let cancelled = false; + apiProxy('challenges').then( + (challenges) => { + if (!cancelled) { + setLoad({ kind: 'ready', challenges }); + } + }, + (error: unknown) => { + if (cancelled) { + return; + } + if (isAuthError(error)) { + setLoad({ kind: 'signedOut' }); + } else { + setLoad({ + kind: 'error', + message: error instanceof ProxyError ? error.message : 'Could not reach the server.', + }); + } + }, + ); + return () => { + cancelled = true; + }; + }, []); + + // Modal callbacks must be stable: the detail loader depends on them. + const markSolved = useCallback((slug: string) => { + setSolvedSlugs((current) => (current.includes(slug) ? current : [...current, slug])); + }, []); + + if (load.kind === 'loading') { + return ( +
+ {[0, 1, 2].map((slot) => ( + + ))} +
+ ); + } + + if (load.kind === 'signedOut') { + return ; + } + + if (load.kind === 'error') { + return ( +

+ {load.message} +

+ ); + } + + const grouped = new Map(); + for (const challenge of load.challenges) { + const bucket = grouped.get(challenge.category) ?? []; + bucket.push(challenge); + grouped.set(challenge.category, bucket); + } + const categories = [...grouped.keys()].sort((a, b) => { + const ia = CATEGORY_ORDER.indexOf(a as ChallengeCategory); + const ib = CATEGORY_ORDER.indexOf(b as ChallengeCategory); + return (ia === -1 ? CATEGORY_ORDER.length : ia) - (ib === -1 ? CATEGORY_ORDER.length : ib); + }); + + return ( + <> +

+ {load.challenges.length} {load.challenges.length === 1 ? 'challenge' : 'challenges'} on the + board · {solvedSlugs.length} solved by you +

+ + {categories.map((category) => { + const items = grouped.get(category) ?? []; + return ( +
+

+ {category} + + {items.length} {items.length === 1 ? 'challenge' : 'challenges'} + +

+
    + {items.map((challenge) => { + const solved = solvedSlugs.includes(challenge.slug); + return ( +
  • + +
  • + ); + })} +
+
+ ); + })} + + {openSlug !== null ? ( + setOpenSlug(null)} + onSolved={markSolved} + /> + ) : null} + + ); +} diff --git a/apps/web/components/ChallengeModal.tsx b/apps/web/components/ChallengeModal.tsx new file mode 100644 index 0000000..4f237ea --- /dev/null +++ b/apps/web/components/ChallengeModal.tsx @@ -0,0 +1,215 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Badge, Button, Dialog, useToast } from '@openrange/ui'; +import type { ChallengeDetail, FlagSubmitResult } from '@openrange/contracts'; +import { apiProxy, ProxyError } from '@/lib/api-browser'; +import { Markdown } from './Markdown'; + +function formatBytes(size: number): string { + if (size < 1024) { + return `${size} B`; + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB`; + } + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +type Outcome = + | { kind: 'correct'; points: number | null; firstBlood: boolean; already: boolean } + | { kind: 'wrong'; message: string } + | { kind: 'error'; message: string }; + +/** + * Detail modal for one challenge: description markdown, attachment downloads, + * and the flag submit form. Success is celebrated with a toast plus a subtle + * Solved badge; no confetti. + */ +export function ChallengeModal({ + slug, + onClose, + onSolved, +}: { + slug: string; + onClose: () => void; + onSolved: (slug: string) => void; +}) { + const [detail, setDetail] = useState(null); + const [loadError, setLoadError] = useState(null); + const [flag, setFlag] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [outcome, setOutcome] = useState(null); + const flagInputRef = useRef(null); + const toast = useToast(); + + useEffect(() => { + let cancelled = false; + apiProxy(`challenges/${encodeURIComponent(slug)}`).then( + (loaded) => { + if (!cancelled) { + setDetail(loaded); + if (loaded.solved_by_me) { + onSolved(slug); + } + } + }, + (error: unknown) => { + if (!cancelled) { + setLoadError(error instanceof ProxyError ? error.message : 'Could not reach the server.'); + } + }, + ); + return () => { + cancelled = true; + }; + }, [slug, onSolved]); + + async function submitFlag(event: React.FormEvent) { + event.preventDefault(); + if (flag.trim().length === 0) { + return; + } + setSubmitting(true); + try { + const result = await apiProxy( + `challenges/${encodeURIComponent(slug)}/submit`, + { method: 'POST', json: { flag } }, + ); + if (result.correct) { + setOutcome({ + kind: 'correct', + points: result.points, + firstBlood: result.first_blood, + already: result.already_solved, + }); + onSolved(slug); + toast.push({ title: 'Flag accepted', variant: 'success' }); + } else { + setOutcome({ kind: 'wrong', message: 'That flag is not correct. Try again.' }); + flagInputRef.current?.focus(); + flagInputRef.current?.select(); + } + } catch (error) { + const message = error instanceof ProxyError ? error.message : 'Could not reach the server.'; + setOutcome({ kind: 'error', message }); + flagInputRef.current?.focus(); + } finally { + setSubmitting(false); + } + } + + const solved = outcome !== null && outcome.kind === 'correct'; + const outcomeId = 'challenge-outcome'; + + return ( + + {detail === null && loadError === null ? ( +

Loading...

+ ) : null} + {loadError !== null ? ( +

+ {loadError} +

+ ) : null} + + {detail !== null ? ( + <> +
+ + {solved || detail.solved_by_me ? 'Solved' : `${detail.points} pts`} + + + {detail.category} · difficulty {detail.difficulty}/10 · {detail.solve_count} solves + +
+ +
+ {detail.description} +
+ + {detail.files.length > 0 ? ( +
+

+ Files +

+ +
+ ) : null} + + {!detail.solved_by_me && !solved ? ( +
+ + { + setFlag(event.target.value); + if (outcome !== null && outcome.kind !== 'correct') { + setOutcome(null); + } + }} + /> +
+ +
+
+ ) : null} + +

+ {outcome === null ? null : outcome.kind === 'correct' ? ( + + + {outcome.already + ? 'Already solved earlier.' + : outcome.points !== null + ? `+${outcome.points} points awarded.` + : 'Correct.'} + + {outcome.firstBlood ? ( + + First blood on this challenge. + + ) : null} + + ) : ( + + {outcome.message} + + )} +

+ + ) : null} +
+ ); +} diff --git a/apps/web/components/EnrollmentControls.tsx b/apps/web/components/EnrollmentControls.tsx new file mode 100644 index 0000000..1f8cced --- /dev/null +++ b/apps/web/components/EnrollmentControls.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useState } from 'react'; +import { Button, useToast } from '@openrange/ui'; +import type { EnrollmentState } from '@openrange/contracts'; +import { apiProxy, ProxyError } from '@/lib/api-browser'; + +type Mutation = 'enroll' | 'pause' | 'resume'; + +interface EnrollmentResponse { + readonly path_slug?: string; + readonly state?: EnrollmentState; +} + +const LABELS: Record = { + enroll: 'Enroll', + pause: 'Pause', + resume: 'Resume', +}; + +const PAST_TENSE: Record = { + enroll: 'Enrolled', + pause: 'Paused', + resume: 'Resumed', +}; + +/** + * Enroll/pause/resume controls for one learning path. The parent owns the + * displayed state; every successful call reports the new state through + * `onChange` so both catalog cards and path outlines stay in sync. + */ +export function EnrollmentControls({ + pathSlug, + state, + onChange, +}: { + pathSlug: string; + state: EnrollmentState | null | undefined; + onChange: (next: EnrollmentState) => void; +}) { + const [busy, setBusy] = useState(null); + const toast = useToast(); + + const actions: Mutation[] = + state === 'active' + ? ['pause'] + : state === 'paused' + ? ['resume'] + : state === 'completed' + ? [] + : ['enroll']; + + async function run(mutation: Mutation) { + setBusy(mutation); + try { + const result = await apiProxy( + `learning/paths/${encodeURIComponent(pathSlug)}/${mutation}`, + { method: 'POST', json: {} }, + ); + if (result.state !== undefined) { + onChange(result.state); + toast.push({ title: `${PAST_TENSE[mutation]} in ${pathSlug}`, variant: 'success' }); + } + } catch (error) { + toast.push({ + title: `${LABELS[mutation]} failed`, + description: error instanceof ProxyError ? error.message : 'Could not reach the server.', + variant: 'error', + }); + } finally { + setBusy(null); + } + } + + if (actions.length === 0) { + return null; + } + + return ( + + {actions.map((mutation) => ( + + ))} + + ); +} diff --git a/apps/web/components/LearnCatalog.tsx b/apps/web/components/LearnCatalog.tsx new file mode 100644 index 0000000..420e4bc --- /dev/null +++ b/apps/web/components/LearnCatalog.tsx @@ -0,0 +1,166 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { Badge, ButtonLink, Card, CardBody, CardFooter, CardHeader, Skeleton } from '@openrange/ui'; +import type { EnrollmentState, LearningPath } from '@openrange/contracts'; +import { apiProxy, isAuthError, ProxyError } from '@/lib/api-browser'; +import { EnrollmentControls } from './EnrollmentControls'; +import { SignInPrompt } from './SignInPrompt'; + +type Load = + | { kind: 'loading' } + | { kind: 'signedOut' } + | { kind: 'error'; message: string } + | { kind: 'ready'; paths: LearningPath[] }; + +const STATE_LABEL: Record = { + active: 'In progress', + paused: 'Paused', + completed: 'Completed', +}; + +const STATE_VARIANT = { + active: 'success', + paused: 'warning', + completed: 'default', +} as const; + +export function LearnCatalog() { + const [load, setLoad] = useState({ kind: 'loading' }); + + useEffect(() => { + let cancelled = false; + apiProxy('learning/paths').then( + (paths) => { + if (!cancelled) { + setLoad({ kind: 'ready', paths }); + } + }, + (error: unknown) => { + if (cancelled) { + return; + } + if (isAuthError(error)) { + setLoad({ kind: 'signedOut' }); + } else { + setLoad({ + kind: 'error', + message: error instanceof ProxyError ? error.message : 'Could not reach the server.', + }); + } + }, + ); + return () => { + cancelled = true; + }; + }, []); + + function setState(slug: string, next: EnrollmentState) { + setLoad((current) => + current.kind === 'ready' + ? { + kind: 'ready', + paths: current.paths.map((path) => + path.slug === slug ? { ...path, enrollment_state: next } : path, + ), + } + : current, + ); + } + + if (load.kind === 'loading') { + return ( +
+ {[0, 1, 2].map((slot) => ( + + ))} +
+ ); + } + + if (load.kind === 'signedOut') { + return ; + } + + if (load.kind === 'error') { + return ( +

+ {load.message} +

+ ); + } + + return ( + <> +

+ {load.paths.length} learning {load.paths.length === 1 ? 'path' : 'paths'} +

+ + {load.paths.length === 0 ? ( +

+ No learning paths are published yet. +

+ ) : ( +
    + {load.paths.map((path) => { + const enrolled = path.enrollment_state !== undefined && path.enrollment_state !== null; + return ( +
  • + + + {path.title} + + } + actions={ + enrolled ? ( + + {STATE_LABEL[path.enrollment_state as EnrollmentState]} + + ) : null + } + /> + +

    {path.summary}

    +
      +
    • {path.difficulty}
    • + +
    • {path.estimated_minutes} min
    • + {path.capstones.length > 0 ? ( + <> + +
    • capstone room
    • + + ) : null} +
    +
    + + + {enrolled ? 'Continue' : 'Outline'} + + setState(path.slug, next)} + /> + +
    +
  • + ); + })} +
+ )} + + ); +} diff --git a/apps/web/components/Markdown.tsx b/apps/web/components/Markdown.tsx new file mode 100644 index 0000000..2acc311 --- /dev/null +++ b/apps/web/components/Markdown.tsx @@ -0,0 +1,87 @@ +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +/** + * Markdown renderer for learner-facing content (module blocks, challenge + * descriptions). Raw HTML is disabled by react-markdown default, so content + * from the API can never inject scripts. + */ +export function Markdown({ children }: { children: string }) { + return ( +
+ + {children} + + ); + }, + ul({ children }) { + return
    {children}
; + }, + ol({ children }) { + return
    {children}
; + }, + li({ children }) { + return
  • {children}
  • ; + }, + blockquote({ children }) { + return ( +
    + {children} +
    + ); + }, + code({ className, children }) { + const isBlock = typeof className === 'string' && className.includes('language-'); + if (!isBlock) { + return ( + + {children} + + ); + } + return {children}; + }, + pre({ children }) { + return ( +
    +                {children}
    +              
    + ); + }, + table({ children }) { + return ( +
    + {children}
    +
    + ); + }, + th({ children }) { + return ( + + {children} + + ); + }, + td({ children }) { + return {children}; + }, + hr() { + return
    ; + }, + }} + > + {children} +
    +
    + ); +} diff --git a/apps/web/components/MobileNav.tsx b/apps/web/components/MobileNav.tsx new file mode 100644 index 0000000..a9d3dc6 --- /dev/null +++ b/apps/web/components/MobileNav.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { NavLink } from './NavLink'; + +export interface MobileNavProps { + links: readonly { href: string; label: string }[]; +} + +/** Disclosure navigation for narrow viewports; the desktop list stays inline. */ +export function MobileNav({ links }: MobileNavProps) { + const [open, setOpen] = useState(false); + const buttonRef = useRef(null); + + useEffect(() => { + if (!open) { + return; + } + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + buttonRef.current?.focus(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open]); + + const close = () => setOpen(false); + + return ( +
    + + {open ? ( + + ) : null} +
    + ); +} diff --git a/apps/web/components/ModuleReader.tsx b/apps/web/components/ModuleReader.tsx new file mode 100644 index 0000000..89bec5c --- /dev/null +++ b/apps/web/components/ModuleReader.tsx @@ -0,0 +1,374 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; +import { Badge, Button, Skeleton, useToast } from '@openrange/ui'; +import type { + CalloutTone, + LearningModule, + LearningPathDetail, + ModuleSectionView, + QuizBlockView, + SectionBlockView, +} from '@openrange/contracts'; +import { apiProxy, isAuthError, ProxyError } from '@/lib/api-browser'; +import { Markdown } from './Markdown'; +import { QuizBlockForm } from './QuizBlockForm'; +import { SignInPrompt } from './SignInPrompt'; + +type Load = + | { kind: 'loading' } + | { kind: 'signedOut' } + | { kind: 'error'; message: string } + | { kind: 'ready'; module: LearningModule; detail: LearningPathDetail }; + +/** + * Languages offered by the switcher. `en` is the default the API serves; + * every other candidate must answer 200 on a probe request before the + * switcher appears at all ("when 200 else hide"). + */ +const LANG_CANDIDATES = [{ code: 'hu', label: 'Magyar' }] as const; + +function Callout({ tone, markdown }: { tone: CalloutTone; markdown: string }) { + const border = + tone === 'danger' + ? 'border-danger' + : tone === 'warning' + ? 'border-warning' + : tone === 'tip' + ? 'border-accent' + : 'border-edge-strong'; + return ( + + ); +} + +function BlockView({ block }: { block: SectionBlockView }) { + if (block.type === 'prose') { + return {block.markdown}; + } + if (block.type === 'callout') { + return ; + } + if (block.type === 'code') { + return ( +
    +
    + {block.language} +
    +
    +          {block.code}
    +        
    +
    + ); + } + return null; +} + +function MarkCompleteButton({ + moduleSlug, + section, + completed, + onCompleted, +}: { + moduleSlug: string; + section: ModuleSectionView; + completed: boolean; + onCompleted: (sectionId: string) => void; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const toast = useToast(); + + if (completed) { + return Completed; + } + + async function markComplete() { + setBusy(true); + setError(null); + try { + await apiProxy( + `learning/modules/${encodeURIComponent(moduleSlug)}/sections/${encodeURIComponent(section.id)}/complete`, + { method: 'POST', json: {} }, + ); + onCompleted(section.id); + toast.push({ title: `Section marked complete`, variant: 'success' }); + } catch (caught) { + setError(caught instanceof ProxyError ? caught.message : 'Could not reach the server.'); + } finally { + setBusy(false); + } + } + + return ( + + + {error !== null ? ( + + {error} + + ) : null} + + ); +} + +export function ModuleReader({ pathSlug, moduleSlug }: { pathSlug: string; moduleSlug: string }) { + const [load, setLoad] = useState({ kind: 'loading' }); + const [lang, setLang] = useState('en'); + const [availableLangs, setAvailableLangs] = useState([]); + + useEffect(() => { + let cancelled = false; + // The switcher only renders when a non-default language actually answers + // 200 for this module; otherwise it stays hidden entirely. + async function probe() { + for (const candidate of LANG_CANDIDATES) { + try { + const response = await fetch( + `/api/or/learning/modules/${encodeURIComponent(moduleSlug)}?lang=${candidate.code}`, + { credentials: 'same-origin' }, + ); + if (!cancelled && response.ok) { + setAvailableLangs((current) => + current.includes(candidate.code) ? current : [...current, candidate.code], + ); + } + } catch { + // Probe failures keep the switcher hidden. + } + } + } + void probe(); + return () => { + cancelled = true; + }; + }, [moduleSlug]); + + useEffect(() => { + let cancelled = false; + const query = lang === 'en' ? '' : `?lang=${encodeURIComponent(lang)}`; + Promise.all([ + apiProxy(`learning/modules/${encodeURIComponent(moduleSlug)}${query}`), + apiProxy(`learning/paths/${encodeURIComponent(pathSlug)}`), + ]).then( + ([moduleBundle, detail]) => { + if (!cancelled) { + setLoad({ kind: 'ready', module: moduleBundle, detail }); + } + }, + (error: unknown) => { + if (cancelled) { + return; + } + if (isAuthError(error)) { + setLoad({ kind: 'signedOut' }); + } else if (error instanceof ProxyError && error.status === 404) { + setLoad({ kind: 'error', message: 'This module does not exist.' }); + } else { + setLoad({ + kind: 'error', + message: error instanceof ProxyError ? error.message : 'Could not reach the server.', + }); + } + }, + ); + return () => { + cancelled = true; + }; + }, [moduleSlug, pathSlug, lang]); + + const completedSections = useMemo(() => { + if (load.kind !== 'ready') { + return new Set(); + } + return new Set( + load.detail.modules + .find((module) => module.slug === moduleSlug) + ?.sections.filter((section) => section.completed) + .map((section) => section.id) ?? [], + ); + }, [load, moduleSlug]); + + function markSectionComplete(sectionId: string) { + setLoad((current) => { + if (current.kind !== 'ready') { + return current; + } + return { + ...current, + detail: { + ...current.detail, + modules: current.detail.modules.map((module) => + module.slug !== moduleSlug + ? module + : { + ...module, + sections: module.sections.map((section) => + section.id === sectionId ? { ...section, completed: true } : section, + ), + }, + ), + }, + }; + }); + } + + if (load.kind === 'loading') { + return ( +
    + + +
    + ); + } + + if (load.kind === 'signedOut') { + return ; + } + + if (load.kind === 'error') { + return ( + <> + +

    + {load.message} +

    + + ); + } + + const { module: bundle } = load; + const canTrackProgress = load.detail.enrollment_state === 'active'; + + return ( + <> + + +
    +
    +

    {bundle.title}

    +

    {bundle.summary}

    +
    + {availableLangs.length > 0 || lang !== 'en' ? ( +
    + + +
    + ) : null} +
    + + {bundle.objectives.length > 0 ? ( +
    +

    + Objectives +

    +
      + {bundle.objectives.map((objective) => ( +
    • {objective}
    • + ))} +
    +
    + ) : null} + + {bundle.sections.map((section) => ( +
    +
    +

    + {section.title} +

    + {canTrackProgress ? ( + + ) : null} +
    + +
    + {section.blocks.map((block, index) => { + if (block.type === 'quiz') { + const quizzes = section.blocks.filter((b): b is QuizBlockView => b.type === 'quiz'); + if (quizzes[0] !== block) { + return null; + } + return ( + + ); + } + return ; + })} +
    +
    + ))} + + {bundle.cheat_sheet !== null && bundle.cheat_sheet.length > 0 ? ( +
    + + Cheat sheet + +
    + {bundle.cheat_sheet} +
    +
    + ) : null} + + ); +} diff --git a/apps/web/components/NavLink.tsx b/apps/web/components/NavLink.tsx new file mode 100644 index 0000000..5a16297 --- /dev/null +++ b/apps/web/components/NavLink.tsx @@ -0,0 +1,33 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import type { ReactNode } from 'react'; + +export interface NavLinkProps { + href: string; + children: ReactNode; + /** Called after activation; lets the mobile menu close itself. */ + onNavigate?: () => void; +} + +/** Navigation link with `aria-current` and accent styling on the active route. */ +export function NavLink({ href, children, onNavigate }: NavLinkProps) { + const pathname = usePathname(); + const active = pathname === href || pathname.startsWith(`${href}/`); + return ( + + {children} + + ); +} diff --git a/apps/web/components/NotificationsView.tsx b/apps/web/components/NotificationsView.tsx new file mode 100644 index 0000000..c6b1042 --- /dev/null +++ b/apps/web/components/NotificationsView.tsx @@ -0,0 +1,318 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Badge, Button, Skeleton, useToast } from '@openrange/ui'; +import type { NotificationFeed, NotificationItem, NotificationPrefs } from '@openrange/contracts'; +import { apiProxy, isAuthError, ProxyError } from '@/lib/api-browser'; +import { SignInPrompt } from './SignInPrompt'; + +const CATEGORIES = ['lab_expiry', 'announcement', 'achievement', 'assignment', 'system'] as const; + +const CATEGORY_LABEL: Record = { + lab_expiry: 'Lab expiry', + announcement: 'Announcement', + achievement: 'Achievement', + assignment: 'Assignment', + system: 'System', +}; + +type Load = + | { kind: 'loading' } + | { kind: 'signedOut' } + | { kind: 'error'; message: string } + | { kind: 'ready'; feed: NotificationFeed; prefs: NotificationPrefs | null }; + +function payloadText(item: NotificationItem): { title: string; body: string | null } { + const payload = item.payload; + const title = typeof payload['title'] === 'string' ? payload['title'] : null; + const body = typeof payload['body'] === 'string' ? payload['body'] : null; + return { + title: title ?? CATEGORY_LABEL[item.category] ?? item.category, + body, + }; +} + +export function NotificationsView() { + const [load, setLoad] = useState({ kind: 'loading' }); + const [savingPrefs, setSavingPrefs] = useState(false); + const toast = useToast(); + + useEffect(() => { + let cancelled = false; + Promise.all([ + apiProxy('notifications/me'), + apiProxy('notifications/me/prefs').catch(() => null), + ]).then( + ([feed, prefs]) => { + if (!cancelled) { + setLoad({ kind: 'ready', feed, prefs }); + } + }, + (error: unknown) => { + if (cancelled) { + return; + } + if (isAuthError(error)) { + setLoad({ kind: 'signedOut' }); + } else { + setLoad({ + kind: 'error', + message: error instanceof ProxyError ? error.message : 'Could not reach the server.', + }); + } + }, + ); + return () => { + cancelled = true; + }; + }, []); + + async function markRead(id: string) { + try { + await apiProxy(`notifications/${encodeURIComponent(id)}/read`, { method: 'POST', json: {} }); + setLoad((current) => + current.kind === 'ready' + ? { + ...current, + feed: { + ...current.feed, + items: current.feed.items.map((item) => + item.id === id && item.read_at === null + ? { ...item, read_at: new Date().toISOString() } + : item, + ), + }, + } + : current, + ); + } catch (error) { + toast.push({ + title: 'Could not mark as read', + description: error instanceof ProxyError ? error.message : undefined, + variant: 'error', + }); + } + } + + async function readAll() { + try { + await apiProxy('notifications/read-all', { method: 'POST', json: {} }); + setLoad((current) => + current.kind === 'ready' + ? { + ...current, + feed: { + ...current.feed, + items: current.feed.items.map((item) => ({ + ...item, + read_at: item.read_at ?? new Date().toISOString(), + })), + }, + } + : current, + ); + toast.push({ title: 'All notifications marked read', variant: 'success' }); + } catch (error) { + toast.push({ + title: 'Could not mark all as read', + description: error instanceof ProxyError ? error.message : undefined, + variant: 'error', + }); + } + } + + async function savePrefs(event: React.FormEvent) { + event.preventDefault(); + if (load.kind !== 'ready' || load.prefs === null) { + return; + } + const form = new FormData(event.currentTarget); + setSavingPrefs(true); + try { + const updated = await apiProxy('notifications/me/prefs', { + method: 'PUT', + json: { + channel_in_app: form.get('channel_in_app') === 'on', + channel_email: form.get('channel_email') === 'on', + digest_weekly: form.get('digest_weekly') === 'on', + muted_categories: CATEGORIES.filter((category) => form.get(`mute-${category}`) === 'on'), + }, + }); + setLoad((current) => (current.kind === 'ready' ? { ...current, prefs: updated } : current)); + toast.push({ title: 'Preferences saved', variant: 'success' }); + } catch (error) { + toast.push({ + title: 'Could not save preferences', + description: error instanceof ProxyError ? error.message : undefined, + variant: 'error', + }); + } finally { + setSavingPrefs(false); + } + } + + if (load.kind === 'loading') { + return ( +
    + {[0, 1, 2, 3].map((slot) => ( + + ))} +
    + ); + } + + if (load.kind === 'signedOut') { + return ; + } + + if (load.kind === 'error') { + return ( +

    + {load.message} +

    + ); + } + + const unread = load.feed.items.filter((item) => item.read_at === null).length; + const prefs = load.prefs; + + return ( + <> +
    +

    + {unread} unread of {load.feed.total} +

    + +
    + +
      + {load.feed.items.length === 0 ? ( +
    • + Nothing here yet. Lab expiries, announcements and achievements land in this feed. +
    • + ) : ( + load.feed.items.map((item) => { + const text = payloadText(item); + const isUnread = item.read_at === null; + return ( +
    • +
      +
      + {isUnread ? ( +
      + {text.body !== null ? ( +

      {text.body}

      + ) : null} +

      + {new Date(item.created_at).toLocaleString()} +

      +
      + {isUnread ? ( + + ) : ( + read + )} +
    • + ); + }) + )} +
    + + {prefs !== null ? ( +
    +

    + Notification preferences +

    +
    +
    + + Channels + + + + +
    + +
    + + Muted categories + +
    + {CATEGORIES.map((category) => ( + + ))} +
    +
    + +
    + +
    +
    +
    + ) : null} + + ); +} diff --git a/apps/web/components/PathOutline.tsx b/apps/web/components/PathOutline.tsx new file mode 100644 index 0000000..7065095 --- /dev/null +++ b/apps/web/components/PathOutline.tsx @@ -0,0 +1,225 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { Badge, ButtonLink, Card, CardBody, Skeleton } from '@openrange/ui'; +import type { EnrollmentState, LearningPathDetail, PathSectionRef } from '@openrange/contracts'; +import { apiProxy, isAuthError, ProxyError } from '@/lib/api-browser'; +import { EnrollmentControls } from './EnrollmentControls'; +import { SignInPrompt } from './SignInPrompt'; + +type Load = + | { kind: 'loading' } + | { kind: 'signedOut' } + | { kind: 'error'; message: string } + | { kind: 'ready'; detail: LearningPathDetail }; + +function SectionRow({ + pathSlug, + moduleSlug, + section, + enrolled, +}: { + pathSlug: string; + moduleSlug: string; + section: PathSectionRef; + enrolled: boolean; +}) { + const href = `/learn/${pathSlug}/${moduleSlug}#${section.id}`; + return ( +
  • + + {section.title} + + + {section.completed ? 'Completed' : 'Not started'} + + {section.optional_branch ? ( + optional + ) : null} + {!enrolled || section.completed ? null : ( + + {section.prerequisites.length > 0 + ? `Requires ${section.prerequisites.map((p) => `${p.module}/${p.section}`).join(', ')}` + : 'No prerequisites'} + + )} +
  • + ); +} + +export function PathOutline({ pathSlug }: { pathSlug: string }) { + const [load, setLoad] = useState({ kind: 'loading' }); + + useEffect(() => { + let cancelled = false; + apiProxy(`learning/paths/${encodeURIComponent(pathSlug)}`).then( + (detail) => { + if (!cancelled) { + setLoad({ kind: 'ready', detail }); + } + }, + (error: unknown) => { + if (cancelled) { + return; + } + if (isAuthError(error)) { + setLoad({ kind: 'signedOut' }); + } else if (error instanceof ProxyError && error.status === 404) { + setLoad({ kind: 'error', message: 'This learning path does not exist.' }); + } else { + setLoad({ + kind: 'error', + message: error instanceof ProxyError ? error.message : 'Could not reach the server.', + }); + } + }, + ); + return () => { + cancelled = true; + }; + }, [pathSlug]); + + const setState = (next: EnrollmentState) => { + setLoad((current) => + current.kind === 'ready' + ? { kind: 'ready', detail: { ...current.detail, enrollment_state: next } } + : current, + ); + }; + + if (load.kind === 'loading') { + return ( +
    + + +
    + ); + } + + if (load.kind === 'signedOut') { + return ; + } + + if (load.kind === 'error') { + return ( + <> + +

    + {load.message} +

    + + ); + } + + const { detail } = load; + const enrolled = detail.enrollment_state === 'active' || detail.enrollment_state === 'paused'; + const totalSections = detail.modules.reduce( + (sum, module) => sum + module.sections.filter((s) => !s.optional_branch).length, + 0, + ); + const doneSections = detail.modules.reduce( + (sum, module) => sum + module.sections.filter((s) => s.completed && !s.optional_branch).length, + 0, + ); + + return ( + <> + + +
    +
    +

    {detail.title}

    +

    {detail.summary}

    +
      +
    • {detail.difficulty}
    • + +
    • {detail.estimated_minutes} min
    • + +
    • + {doneSections}/{totalSections} required sections complete +
    • +
    +
    +
    + {detail.enrollment_state !== undefined && detail.enrollment_state !== null ? ( + + {detail.enrollment_state} + + ) : null} + +
    +
    + +
      + {detail.modules.map((module, index) => ( +
    1. + + +
      +

      + + {String(index + 1).padStart(2, '0')} + + + {module.title} + +

      + {module.optional_branch ? ( + optional branch + ) : ( + + Open module + + )} +
      +
        + {module.sections.map((section) => ( + + ))} +
      +
      +
      +
    2. + ))} +
    + + ); +} diff --git a/apps/web/components/QuizBlockForm.tsx b/apps/web/components/QuizBlockForm.tsx new file mode 100644 index 0000000..2e73773 --- /dev/null +++ b/apps/web/components/QuizBlockForm.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@openrange/ui'; +import type { QuizBlockView, QuizAttemptResult } from '@openrange/contracts'; +import { apiProxy, ProxyError } from '@/lib/api-browser'; + +/** + * Quiz form for one section. Multiple answers per question are allowed (the + * API grades a sorted id list). Feedback never reveals the answer key; the + * server only reports per-question correctness. + */ +export function QuizBlockForm({ + moduleSlug, + sectionId, + quizzes, +}: { + moduleSlug: string; + sectionId: string; + quizzes: readonly QuizBlockView[]; +}) { + const [selected, setSelected] = useState>(() => + Object.fromEntries(quizzes.map((quiz) => [quiz.question_id, [] as string[]])), + ); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + function toggle(questionId: string, optionId: string) { + setResult(null); + setError(null); + setSelected((current) => { + const chosen = current[questionId] ?? []; + const next = chosen.includes(optionId) + ? chosen.filter((id) => id !== optionId) + : [...chosen, optionId]; + return { ...current, [questionId]: next }; + }); + } + + async function submit() { + setSubmitting(true); + setError(null); + try { + const attempt = await apiProxy( + `learning/quiz/${encodeURIComponent(sectionId)}/attempt`, + { + method: 'POST', + json: { answers: selected, module: moduleSlug }, + }, + ); + setResult(attempt); + } catch (caught) { + setError(caught instanceof ProxyError ? caught.message : 'Could not reach the server.'); + } finally { + setSubmitting(false); + } + } + + const correctness = new Map( + (result?.results ?? []).map((entry) => [entry.question_id, entry.correct]), + ); + + return ( +
    { + event.preventDefault(); + void submit(); + }} + > + {quizzes.map((quiz) => { + const verdict = correctness.get(quiz.question_id); + return ( +
    + {quiz.question} +
      + {quiz.options.map((option) => { + const checked = (selected[quiz.question_id] ?? []).includes(option.id); + return ( +
    • + +
    • + ); + })} +
    + {verdict === undefined ? null : ( +

    + {verdict ? 'Correct.' : 'Not quite. Review the material and try again.'} +

    + )} +
    + ); + })} + +
    + +

    + {result === null ? null : result.passed ? ( + All questions correct. + ) : ( + Some answers are wrong. You can retry. + )} + {error !== null ? {error} : null} +

    +
    +
    + ); +} diff --git a/apps/web/components/SignInPrompt.tsx b/apps/web/components/SignInPrompt.tsx new file mode 100644 index 0000000..b74695b --- /dev/null +++ b/apps/web/components/SignInPrompt.tsx @@ -0,0 +1,19 @@ +import { ButtonLink } from '@openrange/ui'; + +/** Inline panel shown where data needs a session and the visitor has none. */ +export function SignInPrompt({ what }: { what: string }) { + return ( +
    +

    Sign in to {what}

    +

    + This area tracks your personal progress, so it needs an account. Everything else on the + range stays browsable while signed out. +

    +
    + + Sign in + +
    +
    + ); +} diff --git a/apps/web/components/TerminalClient.tsx b/apps/web/components/TerminalClient.tsx new file mode 100644 index 0000000..e095c84 --- /dev/null +++ b/apps/web/components/TerminalClient.tsx @@ -0,0 +1,279 @@ +'use client'; + +import Link from 'next/link'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Button } from '@openrange/ui'; +import type { LabInstance } from '@openrange/contracts'; +import { apiProxy, ProxyError } from '@/lib/api-browser'; + +type Status = + | { kind: 'idle' } + | { kind: 'busy'; label: string } + | { kind: 'open' } + | { kind: 'closed'; detail: string } + | { kind: 'error'; detail: string }; + +interface TerminalTokenResponse { + readonly token?: string; +} + +const CLOSE_CODE_DETAIL: Record = { + 1000: 'Session ended.', + 1008: 'Connection refused by policy (bad/expired token or wrong lab).', + 1011: 'Upstream SSH failure.', + 1013: 'Per-lab session limit reached. Close another tab and retry.', +}; + +/** + * Browser terminal for one lab instance. + * + * Token flow: the gateway verifies short-lived `lab-v1` tokens on + * `/terminal/{instance_id}?token=...`. The FastAPI endpoint that mints them + * (`POST /api/labs/{id}/terminal-token`) does not exist yet, so until it + * ships this page stays gated behind picking a READY lab and surfaces the + * exact failure instead of pretending to connect. See apps/web/README.md. + */ +export function TerminalClient({ labs }: { labs: readonly LabInstance[] }) { + const readyLabs = labs.filter((lab) => lab.state === 'ready'); + const [labId, setLabId] = useState(''); + const [status, setStatus] = useState({ kind: 'idle' }); + const containerRef = useRef(null); + const termRef = useRef<{ write: (data: string | Uint8Array) => void } | null>(null); + const socketRef = useRef(null); + const sessionIdRef = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); + + useEffect(() => { + let disposed = false; + let observer: ResizeObserver | null = null; + + async function init() { + const [{ Terminal }, { FitAddon }] = await Promise.all([ + import('@xterm/xterm'), + import('@xterm/addon-fit'), + ]); + if (disposed || containerRef.current === null) { + return; + } + const terminal = new Terminal({ + cursorBlink: true, + fontFamily: 'var(--or-font-mono), monospace', + convertEol: true, + }); + const fit = new FitAddon(); + terminal.loadAddon(fit); + terminal.open(containerRef.current); + fit.fit(); + terminal.write('Select a lab and press Connect.\r\n'); + termRef.current = terminal; + + terminal.onData((data) => { + const socket = socketRef.current; + if (socket !== null && socket.readyState === WebSocket.OPEN) { + socket.send(new TextEncoder().encode(data)); + } + }); + terminal.onResize(({ cols, rows }) => { + const socket = socketRef.current; + if (socket !== null && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'resize', cols, rows })); + } + }); + + observer = new ResizeObserver(() => fit.fit()); + observer.observe(containerRef.current); + cleanupRef.current = () => { + observer?.disconnect(); + terminal.dispose(); + }; + } + + void init(); + return () => { + disposed = true; + socketRef.current?.close(); + socketRef.current = null; + cleanupRef.current?.(); + }; + }, []); + + const connect = useCallback( + async (resume: boolean) => { + if (labId === '') { + return; + } + socketRef.current?.close(); + socketRef.current = null; + + setStatus({ kind: 'busy', label: resume ? 'Resuming session...' : 'Requesting token...' }); + let token: string; + try { + const minted = await apiProxy( + `labs/${encodeURIComponent(labId)}/terminal-token`, + { method: 'POST', json: {} }, + ); + if (typeof minted.token !== 'string' || minted.token.length === 0) { + throw new ProxyError('Token endpoint returned no token.', 500); + } + token = minted.token; + } catch (error) { + if (error instanceof ProxyError && error.status === 404) { + setStatus({ + kind: 'error', + detail: + 'The API does not expose POST /labs/{id}/terminal-token yet, so no gateway token can be minted. This panel connects automatically once the endpoint ships.', + }); + } else { + setStatus({ + kind: 'error', + detail: error instanceof ProxyError ? error.message : 'Could not reach the server.', + }); + } + return; + } + + const base = process.env.NEXT_PUBLIC_GATEWAY_WS_URL ?? 'ws://localhost:8772'; + const params = new URLSearchParams({ token }); + const resumeId = resume ? sessionIdRef.current : null; + if (resumeId !== null && resume) { + params.set('resume', resumeId); + } + setStatus({ kind: 'busy', label: 'Opening shell...' }); + + let socket: WebSocket; + try { + socket = new WebSocket(`${base}/terminal/${encodeURIComponent(labId)}?${params}`); + } catch { + setStatus({ kind: 'error', detail: `Invalid gateway URL: ${base}` }); + return; + } + socket.binaryType = 'arraybuffer'; + + socket.onmessage = (event) => { + if (event.data instanceof ArrayBuffer) { + termRef.current?.write(new Uint8Array(event.data)); + } else if (typeof event.data === 'string') { + try { + const control: unknown = JSON.parse(event.data); + if ( + control !== null && + typeof control === 'object' && + control instanceof Object && + 'session_id' in control && + typeof (control as { session_id: unknown }).session_id === 'string' + ) { + sessionIdRef.current = (control as { session_id: string }).session_id; + setStatus({ kind: 'open' }); + } + } catch { + // Non-JSON text frames carry no meaning for this client. + } + } + }; + socket.onopen = () => { + termRef.current?.write(''); + setStatus({ kind: 'open' }); + }; + socket.onclose = (event) => { + socketRef.current = null; + setStatus({ + kind: 'closed', + detail: CLOSE_CODE_DETAIL[event.code] ?? `Connection closed (code ${event.code}).`, + }); + }; + socket.onerror = () => { + // onclose always follows; leave messaging to it. + }; + socketRef.current = socket; + }, + [labId], + ); + + if (readyLabs.length === 0) { + return ( +
    +

    No running lab

    +

    + The terminal attaches to a READY lab instance. Start one from any room page, then come + back here. +

    +
    + + Browse the catalog + +
    +
    + ); + } + + const busy = status.kind === 'busy'; + const connected = status.kind === 'open'; + + return ( +
    +
    +
    + + +
    + + + {connected ? ( + + ) : null} +
    + +

    + {status.kind === 'idle' ? ( + Waiting for a connection. + ) : null} + {status.kind === 'busy' ? {status.label} : null} + {status.kind === 'open' ? Connected. : null} + {status.kind === 'closed' ? ( + Disconnected: {status.detail} + ) : null} + {status.kind === 'error' ? ( + + {status.detail} + + ) : null} +

    + +
    +
    +
    +
    + ); +} diff --git a/apps/web/components/TopNav.tsx b/apps/web/components/TopNav.tsx index 47ca67a..0b0d9c6 100644 --- a/apps/web/components/TopNav.tsx +++ b/apps/web/components/TopNav.tsx @@ -2,12 +2,24 @@ import Link from 'next/link'; import { getCurrentUser } from '@/lib/api-server'; import { CommandPalette } from './CommandPalette'; import { LogoutButton } from './LogoutButton'; +import { MobileNav } from './MobileNav'; +import { NavLink } from './NavLink'; export async function TopNav() { const user = await getCurrentUser(); + const links = [ + { href: '/catalog', label: 'Catalog' }, + { href: '/dashboard', label: 'Dashboard' }, + { href: '/learn', label: 'Learn' }, + { href: '/challenges', label: 'Challenges' }, + { href: '/terminal', label: 'Terminal' }, + { href: '/notifications', label: 'Notifications' }, + ...(user === null ? [] : [{ href: '/profile', label: 'Profile' }]), + ]; + return ( -
    +