Initial commit: Learning Lab Express

Includes fixes found while reviewing the app:
- Results: open each result by a type+agent+id key, so a video and a
  material draft with the same id no longer open each other's popup
- AuthGate: clear the query cache during render, before pages build their
  queries, and remount pages on organization switch (pages no longer stay
  on skeletons for up to a minute after sign-in or refresh)
- Instruction: keep only failed rules after a partial approve, so retrying
  does not create the successful schedules twice; drop the monthly note
  once count or frequency is edited
- Parser/schedules: never schedule a count below one
- Audio: ignore the end event of a cancelled utterance when changing speed
- Downloads: revoke the object URL later so Safari/Firefox can read it
- Results: refetch SCORM content when any part's revision changes; show a
  failed video as failed instead of "still rendering"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-23 10:42:12 +07:00
commit f219b26f71
41 changed files with 8172 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react'
import { useClerk, useOrganization, useUser } from '@clerk/react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n'
import { Brand, Icon, type IconName } from './Icon'
import { Toaster } from './Toast'
// Express Mode has four doors: feed the Brain, give an instruction, watch the schedule, collect results.
export const NAV: { to: string; label: Key; icon: IconName }[] = [
{ to: '/brain', label: 'nav.brain', icon: 'brain' },
{ to: '/instruction', label: 'nav.instruction', icon: 'spark' },
{ to: '/schedule', label: 'nav.schedule', icon: 'calendar' },
{ to: '/results', label: 'nav.results', icon: 'layers' },
]
export function LanguageSwitch() {
const lang = useLang()
return (
<div className="lang-switch" role="group" aria-label={t('layout.language')}>
{LANGS.map(l => (
<button key={l.code} type="button" aria-pressed={lang === l.code} className={lang === l.code ? 'active' : undefined}
onClick={() => setLang(l.code)}>{l.label}</button>
))}
</div>
)
}
// The four steps of Express, from the mockup's "how it works" guide.
const HELP_STEPS: { icon: IconName; title: Key; body: Key }[] = [
{ icon: 'brain', title: 'help.brainTitle', body: 'help.brainBody' },
{ icon: 'spark', title: 'help.instructionTitle', body: 'help.instructionBody' },
{ icon: 'calendar', title: 'help.scheduleTitle', body: 'help.scheduleBody' },
{ icon: 'layers', title: 'help.resultsTitle', body: 'help.resultsBody' },
]
function HelpDialog({ onClose }: { onClose: () => void }) {
const ref = useRef<HTMLDialogElement>(null)
// Same pattern as the Results preview: open once, let the dialog's own close event unmount it.
useEffect(() => { if (ref.current && !ref.current.open) ref.current.showModal() }, [])
return (
<dialog ref={ref} aria-labelledby="help-title" onClose={onClose} onClick={e => e.target === ref.current && ref.current.close()}>
<div className="modal-head">
<div><h2 id="help-title">{t('help.title')}</h2><p>{t('help.subtitle')}</p></div>
<button className="icon-btn" aria-label={t('res.close')} onClick={() => ref.current?.close()}><Icon name="close" /></button>
</div>
<div className="modal-body">
<div className="help-steps">
{HELP_STEPS.map((s, i) => (
<div key={s.title} className="help-step"><span>{i + 1}</span><div><h3>{t(s.title)}</h3><p>{t(s.body)}</p></div></div>
))}
</div>
<div className="spacer" />
<div className="inline-note"><Icon name="info" />{t('help.note')}</div>
</div>
<div className="modal-foot"><button className="btn primary" onClick={() => ref.current?.close()}>{t('help.done')}<Icon name="arrow-right" /></button></div>
</dialog>
)
}
export function AppLayout() {
useLang()
const [help, setHelp] = useState(false)
const { user } = useUser()
const { organization } = useOrganization()
const { signOut } = useClerk()
const { pathname } = useLocation()
const current = NAV.find(n => pathname.startsWith(n.to))
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
const today = new Intl.DateTimeFormat(locale(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
return (
<div className="app">
<aside className="sidebar">
<NavLink to="/brain"><Brand /></NavLink>
<div className="workspace">
<span className="workspace-icon">{(organization?.name || 'L').charAt(0).toUpperCase()}</span>
<div>{t('layout.learningSpace')}<small>{organization?.name ?? '-'}</small></div>
</div>
<div className="nav-label">{t('nav.workspace')}</div>
<nav aria-label={t('layout.mainNav')}>
{NAV.map(n => (
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
<Icon name={n.icon} />{t(n.label)}
</NavLink>
))}
</nav>
<div className="sidebar-help">
<Icon name="info" />
<b>{t('help.boxTitle')}</b>
<p>{t('help.boxBody')}</p>
<button type="button" onClick={() => setHelp(true)}>{t('help.open')} <Icon name="arrow-right" /></button>
</div>
<div className="profile">
<span className="avatar">{name.charAt(0).toUpperCase()}</span>
<div><b>{name}</b><small>{user?.primaryEmailAddress?.emailAddress}</small></div>
<button className="icon-btn" aria-label={t('layout.signOut')} onClick={() => signOut({ redirectUrl: '/' })}><Icon name="logout" /></button>
</div>
</aside>
<div className="workspace-main">
<header className="topbar">
<div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div>
<div className="topbar-right">
<span className="demo-indicator"><i></i> {t('layout.engine')}</span>
<span>{today.format(new Date())}</span>
<LanguageSwitch />
</div>
</header>
<div className="demo-strip">
<Icon name="info" />
<span>{t('layout.devStrip')}</span>
<span className="demo-strip-tag">DEV</span>
</div>
{/* Keyed by route so each page plays the fade-in from theme.css. */}
<main tabIndex={-1} key={pathname}><Outlet /></main>
<footer className="app-footer">Learning Lab Express <span>by Bawana</span><span>{t('layout.footer')}</span></footer>
</div>
{help && <HelpDialog onClose={() => setHelp(false)} />}
<Toaster />
</div>
)
}

View File

@@ -0,0 +1,114 @@
// Plays an audio lesson's narration script with the device's own voice (Web Speech API).
// The engine has no audio output for agents yet, so there is no MP3: the script is the deliverable,
// read aloud here and downloadable as text for recording or a TTS tool.
import { useEffect, useMemo, useRef, useState } from 'react'
import { t } from '../i18n'
import { Icon } from './Icon'
// Long utterances stop silently in some browsers (Chrome after ~15 s), so the script is read
// one sentence at a time.
export function sentencesOf(text: string): string[] {
return text
.replace(/^\s*(?:#{1,6}|[-*•>]|\d+[.)])\s+/gm, '') // headings, bullets, numbered items, quotes
.replace(/[*_`]+/g, '')
.split(/(?<=[.!?…])\s+|\n+/)
.map(s => s.replace(/\s+/g, ' ').trim())
.filter(s => /\p{L}/u.test(s))
}
// The script's language decides the voice; the UI language may differ from the content's.
const ID_WORDS = /\b(yang|dan|dengan|untuk|adalah|ini|itu|kita|anda|tidak|dalam|akan|bisa|dari)\b/gi
const EN_WORDS = /\b(the|and|with|for|is|this|that|we|you|not|in|will|can|from)\b/gi
export const scriptLang = (text: string) => ((text.match(ID_WORDS)?.length ?? 0) > (text.match(EN_WORDS)?.length ?? 0) ? 'id' : 'en')
const supported = typeof window !== 'undefined' && 'speechSynthesis' in window
const BARS = [16, 29, 45, 22, 37, 56, 31, 19, 43]
export function AudioPlayer({ title, text }: { title: string; text: string }) {
const sentences = useMemo(() => sentencesOf(text), [text])
const lang = useMemo(() => scriptLang(text), [text])
const [state, setState] = useState<'idle' | 'playing' | 'paused'>('idle')
const [index, setIndex] = useState(0)
const [rate, setRate] = useState(1)
// The sentence to speak next; a ref so the utterance callbacks see the latest value.
const at = useRef(0)
const playing = useRef(false)
// The utterance being spoken: cancel() also fires "end" on the one it stops, which must not advance.
const current = useRef<SpeechSynthesisUtterance | null>(null)
const voice = () => speechSynthesis.getVoices().find(v => v.lang.toLowerCase().startsWith(lang)) ?? null
function speak(i: number) {
if (i >= sentences.length) { stop(); return }
at.current = i
setIndex(i)
const u = new SpeechSynthesisUtterance(sentences[i])
u.lang = lang === 'id' ? 'id-ID' : 'en-US'
u.voice = voice()
u.rate = rate
u.onend = () => { if (playing.current && current.current === u) speak(at.current + 1) }
current.current = u
speechSynthesis.speak(u)
}
function play() {
if (state === 'paused') { speechSynthesis.resume(); setState('playing'); playing.current = true; return }
current.current = null
speechSynthesis.cancel()
playing.current = true
setState('playing')
speak(at.current)
}
function pause() { speechSynthesis.pause(); playing.current = false; setState('paused') }
function stop() {
playing.current = false
current.current = null
speechSynthesis.cancel()
at.current = 0
setIndex(0)
setState('idle')
}
// Leaving the preview must not leave the device talking.
useEffect(() => () => { playing.current = false; if (supported) speechSynthesis.cancel() }, [])
// A new speed applies from the current sentence.
useEffect(() => {
if (!playing.current) return
current.current = null
speechSynthesis.cancel()
speak(at.current)
}, [rate])
const progress = sentences.length ? Math.round(((index + (state === 'idle' ? 0 : 1)) / sentences.length) * 100) : 0
return (
<div className="audio-preview">
<span className="audio-orb"><Icon name="audio" /></span>
<div className={`waveform${state === 'playing' ? ' playing' : ''}`} aria-hidden="true">
{Array.from({ length: 36 }, (_, i) => <i key={i} style={{ height: BARS[i % BARS.length], animationDelay: `${(i % 9) * 0.08}s` }} />)}
</div>
<h2>{title}</h2>
<p>{supported ? t('audio.copy') : t('audio.unsupported')}</p>
{supported && sentences.length > 0 && (
<>
<div className="head-actions">
{state === 'playing'
? <button className="btn primary" onClick={pause}>{t('audio.pause')}</button>
: <button className="btn primary" onClick={play}><Icon name="audio" />{state === 'paused' ? t('audio.resume') : t('audio.play')}</button>}
<button className="btn" disabled={state === 'idle'} onClick={stop}><Icon name="close" />{t('audio.stop')}</button>
<select aria-label={t('audio.speed')} value={rate} onChange={e => setRate(Number(e.target.value))} className="audio-rate">
{[0.85, 1, 1.15, 1.3].map(r => <option key={r} value={r}>{r}×</option>)}
</select>
</div>
<div className="audio-progress"><i style={{ width: `${progress}%` }} /></div>
<small role="status">
{state === 'idle' ? t('audio.ready', { n: sentences.length }) : t('audio.sentence', { n: index + 1, total: sentences.length })}
</small>
{state !== 'idle' && <blockquote className="audio-now">{sentences[index]}</blockquote>}
</>
)}
</div>
)
}

51
src/components/Icon.tsx Normal file
View File

@@ -0,0 +1,51 @@
import type { ContentKind, Rule } from '../express/parse'
// Line icons from the mockup (24×24, stroke = currentColor).
const paths: Record<string, string> = {
book: '<path d="M3 5c3-1 6 0 9 2 3-2 6-3 9-2v14c-3-1-6 0-9 2-3-2-6-3-9-2z"/><path d="M12 7v14M6 9l3 1M15 10l3-1"/>',
home: '<path d="m3 10 9-7 9 7v10H3z"/><path d="M9 20v-7h6v7"/>',
spark: '<path d="m12 3 2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5z"/><path d="m20 2 .5 1.5L22 4l-1.5.5L20 6l-.5-1.5L18 4l1.5-.5z"/>',
calendar: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M7 3v4M17 3v4M3 11h18M7 15h3M14 15h3"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v6l4 2"/>',
layers: '<path d="m12 3 10 5-10 5L2 8zM2 12l10 5 10-5M2 16l10 5 10-5"/>',
download: '<path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/>',
upload: '<path d="M12 16V4m-5 5 5-5 5 5M4 16v5h16v-5"/>',
file: '<path d="M5 3h9l5 5v13H5zM14 3v6h5M8 13h8M8 17h6"/>',
'arrow-right': '<path d="M4 12h16m-6-6 6 6-6 6"/>',
close: '<path d="M6 6l12 12M18 6 6 18"/>',
search: '<circle cx="10" cy="10" r="6"/><path d="m15 15 5 5"/>',
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7v.2"/>',
check: '<path d="m5 12 4 4L19 6"/>',
logout: '<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>',
plus: '<path d="M12 4v16M4 12h16"/>',
trash: '<path d="M3 6h18M9 3h6l1 3M6 6l1 15h10l1-15M10 10v7M14 10v7"/>',
video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>',
audio: '<path d="M4 13v-2a8 8 0 0 1 16 0v2"/><rect x="3" y="11" width="4" height="8" rx="2"/><rect x="17" y="11" width="4" height="8" rx="2"/>',
scorm: '<path d="m12 3 9 5-9 5-9-5zM3 8v9l9 5 9-5V8M12 13v9"/>',
brain: '<path d="M12 5c-3-4-8-1-7 3-4 2-3 7 0 8-1 4 5 6 7 2M12 5c3-4 8-1 7 3 4 2 3 7 0 8 1 4-5 6-7 2M12 5v15M7 9l3 2M17 9l-3 2M6 15l3-1M18 15l-3-1"/>',
}
export type IconName = keyof typeof paths
export const KIND_ICON: Record<ContentKind, IconName> = {
summary: 'file', quiz: 'check', flashcards: 'layers', roleplay: 'spark', simulator: 'spark', slide_deck: 'layers',
video: 'video', audio: 'audio', scorm: 'scorm',
}
export const ruleIcon = (rule: Rule): IconName => (rule.type === 'research' ? 'search' : KIND_ICON[rule.kind])
export function Icon({ name }: { name: IconName }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
dangerouslySetInnerHTML={{ __html: paths[name] ?? paths.file }} />
)
}
export function Brand() {
return (
<span className="brand">
<span className="brand-mark"><Icon name="book" /></span>
<span>Learning Lab <b>Express</b><small>by Bawana</small></span>
</span>
)
}

71
src/components/Page.tsx Normal file
View File

@@ -0,0 +1,71 @@
import type { ReactNode } from 'react'
import { Icon, type IconName } from './Icon'
export function PageHead({ title, description, actions }: { title: string; description: string; actions?: ReactNode }) {
return (
<div className="page-head">
<div><h1>{title}</h1><p>{description}</p></div>
{actions && <div className="head-actions">{actions}</div>}
</div>
)
}
// Shimmer placeholders shaped like the content they stand in for, so a page keeps its layout while loading.
export function Skeleton({ w, h, className }: { w?: number | string; h?: number | string; className?: string }) {
return <span className={className ? `skel ${className}` : 'skel'} style={{ width: w, height: h }} aria-hidden="true" />
}
const LINE_WIDTHS = ['62%', '48%', '70%', '55%']
export function SkeletonRows({ count = 3, label }: { count?: number; label: string }) {
return (
<div className="result-list" role="status" aria-label={label}>
{Array.from({ length: count }, (_, i) => (
<div key={i} className="result-row skel-row">
<Skeleton className="skel-cover" />
<div className="result-main">
<Skeleton w={LINE_WIDTHS[i % LINE_WIDTHS.length]} h={14} />
<Skeleton w="34%" h={11} />
</div>
<Skeleton className="skel-pill" />
</div>
))}
</div>
)
}
export function SkeletonCards({ count = 4, label }: { count?: number; label: string }) {
return (
<div className="content-grid" role="status" aria-label={label}>
{Array.from({ length: count }, (_, i) => (
<div key={i} className="content-card">
<Skeleton className="skel-block" />
<div className="content-card-body skel-stack">
<Skeleton w="40%" h={20} className="skel-pill" />
<Skeleton w="90%" h={14} />
<Skeleton w={LINE_WIDTHS[i % LINE_WIDTHS.length]} h={14} />
<Skeleton w="50%" h={11} />
</div>
</div>
))}
</div>
)
}
export function SkeletonLines({ count = 2, label }: { count?: number; label: string }) {
return (
<div className="skel-stack" role="status" aria-label={label}>
{Array.from({ length: count }, (_, i) => <Skeleton key={i} w={LINE_WIDTHS[i % LINE_WIDTHS.length]} h={12} />)}
</div>
)
}
export function Empty({ icon = 'book', title, children }: { icon?: IconName; title: string; children?: ReactNode }) {
return (
<div className="empty">
<span className="empty-icon"><Icon name={icon} /></span>
<h3>{title}</h3>
{children && <p>{children}</p>}
</div>
)
}

28
src/components/Toast.tsx Normal file
View File

@@ -0,0 +1,28 @@
// Short notices at the bottom of the screen (styles from the mockup's .toast), instead of alert().
// A module-level store, so mutations can report from anywhere without a context provider.
import { useSyncExternalStore } from 'react'
interface Notice { id: number; text: string; error: boolean }
let current: Notice | null = null
let nextId = 1
let timer: ReturnType<typeof setTimeout> | undefined
const listeners = new Set<() => void>()
const emit = () => listeners.forEach(l => l())
export function toast(text: string, { error = false } = {}) {
current = { id: nextId++, text, error }
clearTimeout(timer)
// Errors stay a little longer: they usually need reading.
timer = setTimeout(() => { current = null; emit() }, error ? 7000 : 3500)
emit()
}
export function Toaster() {
const notice = useSyncExternalStore(cb => { listeners.add(cb); return () => listeners.delete(cb) }, () => current)
return (
<div className={`toast${notice ? ' show' : ''}${notice?.error ? ' error' : ''}`} role={notice?.error ? 'alert' : 'status'} aria-live="polite">
{notice?.text}
</div>
)
}