Each group label (Dashboard, Settings) toggles its items; a group starts closed unless it holds the current route, and a manual toggle resets on the next navigation. Mobile keeps every item visible. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
9.1 KiB
TypeScript
197 lines
9.1 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: '/upload', label: 'nav.brain', icon: 'brain' },
|
|
{ to: '/brain', 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))
|
|
// Nav groups stay closed unless they hold the current page; a click on the label overrides that until the next route.
|
|
const [toggled, setToggled] = useState<Partial<Record<Key, boolean>>>({})
|
|
useEffect(() => setToggled({}), [pathname])
|
|
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 => {
|
|
const open = toggled[g.label] ?? g.items.some(n => pathname.startsWith(n.to))
|
|
return [
|
|
<button key={g.label} type="button" className="nav-label" aria-expanded={open} aria-controls={`nav-${g.label}`}
|
|
onClick={() => setToggled(s => ({ ...s, [g.label]: !open }))}>
|
|
{t(g.label)}<Icon name="chevron-down" />
|
|
</button>,
|
|
<div key={`${g.label}-items`} id={`nav-${g.label}`} className={open ? 'nav-items' : 'nav-items closed'}>
|
|
{g.items.map(n => (
|
|
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
|
|
<Icon name={n.icon} />{t(n.label)}
|
|
</NavLink>
|
|
))}
|
|
</div>,
|
|
]
|
|
})}
|
|
</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>
|
|
{/* 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>
|
|
)
|
|
}
|