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>
)
}