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:
121
src/components/AppLayout.tsx
Normal file
121
src/components/AppLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user