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>
|
||||
)
|
||||
}
|
||||
114
src/components/AudioPlayer.tsx
Normal file
114
src/components/AudioPlayer.tsx
Normal 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
51
src/components/Icon.tsx
Normal 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
71
src/components/Page.tsx
Normal 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
28
src/components/Toast.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user