production-dashboard #1
@@ -98,6 +98,12 @@ export async function waitReady(id: number, onProgress?: (d: EngineDocument) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Workspace wallet ──
|
||||||
|
|
||||||
|
// The organization's credit balance: Express spends workspace credits, never personal ones.
|
||||||
|
export const workspaceCredits = async () =>
|
||||||
|
(await request<{ organization: { credit_balance: number } | null }>('/organizations/me')).organization?.credit_balance ?? null
|
||||||
|
|
||||||
// ── Compose (text content) ──
|
// ── Compose (text content) ──
|
||||||
|
|
||||||
export interface ComposeSet { id: number; name: string }
|
export interface ComposeSet { id: number; name: string }
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useClerk, useOrganization, useUser } from '@clerk/react'
|
import { useClerk, useOrganization, useUser } from '@clerk/react'
|
||||||
|
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'
|
||||||
|
import { workspaceCredits } from '../api/engine'
|
||||||
import { Brand, Icon, type IconName } from './Icon'
|
import { Brand, Icon, type IconName } from './Icon'
|
||||||
import { Toaster } from './Toast'
|
import { Toaster } from './Toast'
|
||||||
|
|
||||||
@@ -57,6 +59,17 @@ function HelpDialog({ onClose }: { onClose: () => void }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
useLang()
|
useLang()
|
||||||
const [help, setHelp] = useState(false)
|
const [help, setHelp] = useState(false)
|
||||||
@@ -66,7 +79,6 @@ export function AppLayout() {
|
|||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const current = NAV.find(n => pathname.startsWith(n.to))
|
const current = NAV.find(n => pathname.startsWith(n.to))
|
||||||
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
|
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
|
||||||
const today = new Intl.DateTimeFormat(locale(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@@ -101,7 +113,7 @@ export function AppLayout() {
|
|||||||
<div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div>
|
<div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div>
|
||||||
<div className="topbar-right">
|
<div className="topbar-right">
|
||||||
<span className="demo-indicator"><i></i> {t('layout.engine')}</span>
|
<span className="demo-indicator"><i></i> {t('layout.engine')}</span>
|
||||||
<span>{today.format(new Date())}</span>
|
<CreditBalance />
|
||||||
<LanguageSwitch />
|
<LanguageSwitch />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const paths: Record<string, string> = {
|
|||||||
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"/>',
|
||||||
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"/>',
|
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"/>',
|
scorm: '<path d="m12 3 9 5-9 5-9-5zM3 8v9l9 5 9-5V8M12 13v9"/>',
|
||||||
|
coin: '<circle cx="12" cy="12" r="9"/><path d="M15 9.5c-.5-1-1.6-1.5-3-1.5-1.7 0-3 .8-3 2s1.3 1.7 3 2 3 .8 3 2-1.3 2-3 2c-1.4 0-2.5-.5-3-1.5M12 6v2M12 16v2"/>',
|
||||||
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"/>',
|
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"/>',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const en = {
|
|||||||
'layout.breadcrumb': 'Workspace',
|
'layout.breadcrumb': 'Workspace',
|
||||||
'layout.signOut': 'Sign out',
|
'layout.signOut': 'Sign out',
|
||||||
'layout.engine': 'learning-lab engine',
|
'layout.engine': 'learning-lab engine',
|
||||||
|
'layout.creditsTitle': 'Workspace credit balance',
|
||||||
'layout.devStrip': 'Development phase: connected to the learning-lab engine. Review AI output before using it.',
|
'layout.devStrip': 'Development phase: connected to the learning-lab engine. Review AI output before using it.',
|
||||||
'layout.footer': 'A workspace for knowledge that keeps growing.',
|
'layout.footer': 'A workspace for knowledge that keeps growing.',
|
||||||
'layout.mainNav': 'Main navigation',
|
'layout.mainNav': 'Main navigation',
|
||||||
@@ -397,6 +398,7 @@ const id: Record<Key, string> = {
|
|||||||
'layout.breadcrumb': 'Ruang kerja',
|
'layout.breadcrumb': 'Ruang kerja',
|
||||||
'layout.signOut': 'Keluar',
|
'layout.signOut': 'Keluar',
|
||||||
'layout.engine': 'Engine learning-lab',
|
'layout.engine': 'Engine learning-lab',
|
||||||
|
'layout.creditsTitle': 'Saldo kredit workspace',
|
||||||
'layout.devStrip': 'Fase development: terhubung ke engine learning-lab. Hasil AI perlu ditinjau sebelum dipakai.',
|
'layout.devStrip': 'Fase development: terhubung ke engine learning-lab. Hasil AI perlu ditinjau sebelum dipakai.',
|
||||||
'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.',
|
'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.',
|
||||||
'layout.mainNav': 'Navigasi utama',
|
'layout.mainNav': 'Navigasi utama',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { ClerkProvider } from '@clerk/react'
|
import { ClerkProvider } from '@clerk/react'
|
||||||
import { enUS, idID } from '@clerk/localizations'
|
import { enUS, idID } from '@clerk/localizations'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { EngineError } from './api/client'
|
import { EngineError } from './api/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
@@ -16,7 +16,9 @@ if (!clerkPubKey) {
|
|||||||
throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY (set it, or keep ../learning-lab/backend/.env available)')
|
throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY (set it, or keep ../learning-lab/backend/.env available)')
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient: QueryClient = new QueryClient({
|
||||||
|
// Any action may have spent workspace credits, so the topbar balance is read again after each one.
|
||||||
|
mutationCache: new MutationCache({ onSettled: () => queryClient.invalidateQueries({ queryKey: ['credits'] }) }),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
// Retrying a 4xx (auth, credits, validation) only repeats the same answer.
|
// Retrying a 4xx (auth, credits, validation) only repeats the same answer.
|
||||||
queries: { retry: (n, e) => n < 2 && !(e instanceof EngineError && e.status >= 400 && e.status < 500) },
|
queries: { retry: (n, e) => n < 2 && !(e instanceof EngineError && e.status >= 400 && e.status < 500) },
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ input::placeholder,textarea::placeholder{color:#98a2b3}
|
|||||||
.breadcrumb span{color:#d0d5dd}
|
.breadcrumb span{color:#d0d5dd}
|
||||||
.breadcrumb b{color:var(--ink);font-weight:600}
|
.breadcrumb b{color:var(--ink);font-weight:600}
|
||||||
.topbar-right{font-size:13px;color:var(--muted);gap:16px}
|
.topbar-right{font-size:13px;color:var(--muted);gap:16px}
|
||||||
.demo-indicator{height:30px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:#fff;font-size:12.5px;font-weight:550;color:#344054}
|
.demo-indicator{white-space:nowrap;height:30px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:#fff;font-size:12.5px;font-weight:550;color:#344054}
|
||||||
|
.credit-pill{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 12px;border-radius:999px;background:var(--light);color:var(--blue-dark);font-size:12.5px;font-weight:700;white-space:nowrap}
|
||||||
|
.credit-pill svg{width:15px;height:15px}
|
||||||
.demo-indicator i{width:7px;height:7px;background:#059669;box-shadow:0 0 0 3px #d1fae5}
|
.demo-indicator i{width:7px;height:7px;background:#059669;box-shadow:0 0 0 3px #d1fae5}
|
||||||
.lang-switch{border:0;border-radius:999px;background:#f2f4f7;padding:3px}
|
.lang-switch{border:0;border-radius:999px;background:#f2f4f7;padding:3px}
|
||||||
.lang-switch button{border-radius:999px;padding:5px 11px;font-size:12px;color:#475467}
|
.lang-switch button{border-radius:999px;padding:5px 11px;font-size:12px;color:#475467}
|
||||||
@@ -227,8 +229,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
|||||||
.sidebar nav :is(button,a).active::before{display:none}
|
.sidebar nav :is(button,a).active::before{display:none}
|
||||||
.workspace-main{margin:0;width:100%}
|
.workspace-main{margin:0;width:100%}
|
||||||
.topbar{position:static;height:auto;min-height:56px;padding:10px 16px;flex-wrap:wrap}
|
.topbar{position:static;height:auto;min-height:56px;padding:10px 16px;flex-wrap:wrap}
|
||||||
.topbar-right{gap:10px}
|
.topbar-right{gap:8px 10px;flex-wrap:wrap}
|
||||||
.topbar-right>span:not(.demo-indicator){display:none}
|
|
||||||
.demo-strip{margin:16px 16px 0;border-radius:16px;font-size:12px}
|
.demo-strip{margin:16px 16px 0;border-radius:16px;font-size:12px}
|
||||||
.app main{padding:22px 16px}
|
.app main{padding:22px 16px}
|
||||||
.app-footer{margin-left:16px;margin-right:16px}
|
.app-footer{margin-left:16px;margin-right:16px}
|
||||||
|
|||||||
Reference in New Issue
Block a user