Files
learning-lab-express/src/components/AppLayout.tsx
Iqbal 8a02f68589 Link to Learning Lab from the topbar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:48:00 +07:00

191 lines
8.5 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { useClerk, useOrganization, useOrganizationList, useUser } from '@clerk/react'
import { useQuery } from '@tanstack/react-query'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n'
import { workspaceCredits } from '../api/engine'
import { Brand, Icon, type IconName } from './Icon'
import { Toaster } from './Toast'
// Learning Studio (learning-lab/frontend); both apps share one Clerk instance, so the user stays signed in.
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
// The Production dashboard shows what Express is making; the four settings doors run it:
// feed the Brain, give an instruction, watch the schedule, collect results.
export const NAV_GROUPS: { label: Key; items: { to: string; label: Key; icon: IconName }[] }[] = [
{ label: 'nav.dashboard', items: [{ to: '/production', label: 'nav.production', icon: 'chart' }] },
{
label: 'nav.settings',
items: [
{ 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 const NAV = NAV_GROUPS.flatMap(g => g.items)
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>
)
}
// Polled because agents and schedules also spend credits outside this tab; main.tsx refreshes it after every mutation.
function CreditBalance() {
const credits = useQuery({ queryKey: ['credits'], queryFn: workspaceCredits, refetchInterval: 30_000 })
if (credits.data == null) return null
return (
<span className="credit-pill" title={t('layout.creditsTitle')}>
<Icon name="coin" />{t('common.credits', { n: credits.data.toLocaleString(locale()) })}
</span>
)
}
// The Learning space card switches the active organization. AuthGate keys the pages by orgId and
// clears the query cache, so Brain, schedules, results and credits all reload for the new one.
function OrgSwitcher() {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const { organization } = useOrganization()
const { setActive, userMemberships } = useOrganizationList({ userMemberships: { infinite: true } })
useEffect(() => {
if (!open) return
const onClick = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false) }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onClick)
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('mousedown', onClick); document.removeEventListener('keydown', onKey) }
}, [open])
const choose = (id: string) => {
setOpen(false)
if (id !== organization?.id) void setActive?.({ organization: id })
}
return (
<div className="workspace-switch" ref={ref}>
<button type="button" className="workspace" aria-haspopup="listbox" aria-expanded={open}
title={t('layout.switchOrg')} onClick={() => setOpen(o => !o)}>
<span className="workspace-icon">{(organization?.name || 'L').charAt(0).toUpperCase()}</span>
<div>{t('layout.learningSpace')}<small>{organization?.name ?? '-'}</small></div>
<Icon name="chevron-updown" />
</button>
{open && (
<ul className="workspace-menu" role="listbox" aria-label={t('layout.switchOrg')}>
{userMemberships.data?.map(({ organization: org }) => (
<li key={org.id} role="option" aria-selected={org.id === organization?.id}>
<button type="button" onClick={() => choose(org.id)}>
<span className="workspace-icon">{org.name.charAt(0).toUpperCase()}</span>
<span>{org.name}</span>
{org.id === organization?.id && <Icon name="check" />}
</button>
</li>
))}
</ul>
)}
</div>
)
}
export function AppLayout() {
useLang()
const [help, setHelp] = useState(false)
const { user } = useUser()
const { signOut } = useClerk()
const { pathname } = useLocation()
const current = NAV.find(n => pathname.startsWith(n.to))
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
return (
<div className="app">
<aside className="sidebar">
<NavLink to="/production"><Brand /></NavLink>
<OrgSwitcher />
<nav aria-label={t('layout.mainNav')}>
{NAV_GROUPS.map(g => [
<div key={g.label} className="nav-label">{t(g.label)}</div>,
...g.items.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>
<CreditBalance />
<a className="studio-link" href={STUDIO_URL}><Icon name="external" />{t('layout.toStudio')}</a>
<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>
)
}