production-dashboard #1
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useClerk, useOrganization, useUser } from '@clerk/react'
|
import { useClerk, useOrganization, useOrganizationList, useUser } from '@clerk/react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||||
import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n'
|
import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n'
|
||||||
@@ -70,11 +70,57 @@ function CreditBalance() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
export function AppLayout() {
|
||||||
useLang()
|
useLang()
|
||||||
const [help, setHelp] = useState(false)
|
const [help, setHelp] = useState(false)
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
const { organization } = useOrganization()
|
|
||||||
const { signOut } = useClerk()
|
const { signOut } = useClerk()
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const current = NAV.find(n => pathname.startsWith(n.to))
|
const current = NAV.find(n => pathname.startsWith(n.to))
|
||||||
@@ -84,10 +130,7 @@ export function AppLayout() {
|
|||||||
<div className="app">
|
<div className="app">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<NavLink to="/brain"><Brand /></NavLink>
|
<NavLink to="/brain"><Brand /></NavLink>
|
||||||
<div className="workspace">
|
<OrgSwitcher />
|
||||||
<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>
|
<div className="nav-label">{t('nav.workspace')}</div>
|
||||||
<nav aria-label={t('layout.mainNav')}>
|
<nav aria-label={t('layout.mainNav')}>
|
||||||
{NAV.map(n => (
|
{NAV.map(n => (
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const paths: Record<string, string> = {
|
|||||||
search: '<circle cx="10" cy="10" r="6"/><path d="m15 15 5 5"/>',
|
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"/>',
|
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7v.2"/>',
|
||||||
check: '<path d="m5 12 4 4L19 6"/>',
|
check: '<path d="m5 12 4 4L19 6"/>',
|
||||||
logout: '<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>',
|
'chevron-updown': '<path d="m8 9 4-4 4 4M8 15l4 4 4-4"/>',
|
||||||
|
logout:'<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>',
|
||||||
plus: '<path d="M12 4v16M4 12h16"/>',
|
plus: '<path d="M12 4v16M4 12h16"/>',
|
||||||
trash: '<path d="M3 6h18M9 3h6l1 3M6 6l1 15h10l1-15M10 10v7M14 10v7"/>',
|
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"/>',
|
video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const en = {
|
|||||||
'nav.results': 'Results',
|
'nav.results': 'Results',
|
||||||
'nav.workspace': 'WORKSPACE',
|
'nav.workspace': 'WORKSPACE',
|
||||||
'layout.learningSpace': 'Learning space',
|
'layout.learningSpace': 'Learning space',
|
||||||
|
'layout.switchOrg': 'Switch organization',
|
||||||
'layout.breadcrumb': 'Workspace',
|
'layout.breadcrumb': 'Workspace',
|
||||||
'layout.signOut': 'Sign out',
|
'layout.signOut': 'Sign out',
|
||||||
'layout.engine': 'learning-lab engine',
|
'layout.engine': 'learning-lab engine',
|
||||||
@@ -395,6 +396,7 @@ const id: Record<Key, string> = {
|
|||||||
'nav.results': 'Hasil',
|
'nav.results': 'Hasil',
|
||||||
'nav.workspace': 'RUANG KERJA',
|
'nav.workspace': 'RUANG KERJA',
|
||||||
'layout.learningSpace': 'Ruang belajar',
|
'layout.learningSpace': 'Ruang belajar',
|
||||||
|
'layout.switchOrg': 'Ganti organisasi',
|
||||||
'layout.breadcrumb': 'Ruang kerja',
|
'layout.breadcrumb': 'Ruang kerja',
|
||||||
'layout.signOut': 'Keluar',
|
'layout.signOut': 'Keluar',
|
||||||
'layout.engine': 'Engine learning-lab',
|
'layout.engine': 'Engine learning-lab',
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ input::placeholder,textarea::placeholder{color:#98a2b3}
|
|||||||
.sidebar .brand-mark{width:38px;height:38px;border-radius:12px}
|
.sidebar .brand-mark{width:38px;height:38px;border-radius:12px}
|
||||||
.workspace{border:1px solid var(--line);background:#f8faff;border-radius:14px;padding:10px;margin-bottom:26px;font-size:13.5px}
|
.workspace{border:1px solid var(--line);background:#f8faff;border-radius:14px;padding:10px;margin-bottom:26px;font-size:13.5px}
|
||||||
.workspace small{font-size:12px}
|
.workspace small{font-size:12px}
|
||||||
|
.workspace-switch{position:relative;margin-bottom:26px}
|
||||||
|
button.workspace{width:100%;margin:0;text-align:left;font:inherit;font-size:13.5px;color:inherit;cursor:pointer;transition:border-color .15s,background .15s}
|
||||||
|
button.workspace:hover,button.workspace[aria-expanded=true]{border-color:var(--soft);background:#f2f7ff}
|
||||||
|
.workspace-menu{position:absolute;z-index:6;top:calc(100% + 6px);left:0;right:0;max-height:280px;overflow:auto;margin:0;padding:6px;list-style:none;background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:0 16px 32px -12px rgba(16,24,40,.22)}
|
||||||
|
.workspace-menu button{display:flex;align-items:center;gap:10px;width:100%;padding:8px;border:0;border-radius:10px;background:none;font:inherit;font-size:13.5px;font-weight:550;color:var(--ink);text-align:left;cursor:pointer}
|
||||||
|
.workspace-menu button:hover{background:#f2f7ff}
|
||||||
|
.workspace-menu button>span:nth-child(2){flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.workspace-menu .workspace-icon{display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||||
|
.workspace-menu svg{width:16px;height:16px;color:var(--blue)}
|
||||||
.workspace-icon{background:var(--light);color:var(--blue-dark);border-radius:9px;width:30px;height:30px;font-weight:800}
|
.workspace-icon{background:var(--light);color:var(--blue-dark);border-radius:9px;width:30px;height:30px;font-weight:800}
|
||||||
.nav-label{color:var(--muted);font-size:11px;letter-spacing:.08em;text-transform:uppercase;margin:0 12px 10px}
|
.nav-label{color:var(--muted);font-size:11px;letter-spacing:.08em;text-transform:uppercase;margin:0 12px 10px}
|
||||||
.sidebar nav{gap:4px}
|
.sidebar nav{gap:4px}
|
||||||
|
|||||||
Reference in New Issue
Block a user