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) ──
|
||||
|
||||
export interface ComposeSet { id: number; name: string }
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useClerk, useOrganization, 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'
|
||||
|
||||
@@ -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() {
|
||||
useLang()
|
||||
const [help, setHelp] = useState(false)
|
||||
@@ -66,7 +79,6 @@ export function AppLayout() {
|
||||
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">
|
||||
@@ -101,7 +113,7 @@ export function AppLayout() {
|
||||
<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>
|
||||
<CreditBalance />
|
||||
<LanguageSwitch />
|
||||
</div>
|
||||
</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"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const en = {
|
||||
'layout.breadcrumb': 'Workspace',
|
||||
'layout.signOut': 'Sign out',
|
||||
'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.footer': 'A workspace for knowledge that keeps growing.',
|
||||
'layout.mainNav': 'Main navigation',
|
||||
@@ -397,6 +398,7 @@ const id: Record<Key, string> = {
|
||||
'layout.breadcrumb': 'Ruang kerja',
|
||||
'layout.signOut': 'Keluar',
|
||||
'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.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.',
|
||||
'layout.mainNav': 'Navigasi utama',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ClerkProvider } from '@clerk/react'
|
||||
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 { EngineError } from './api/client'
|
||||
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)')
|
||||
}
|
||||
|
||||
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: {
|
||||
// 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) },
|
||||
|
||||
@@ -79,7 +79,9 @@ input::placeholder,textarea::placeholder{color:#98a2b3}
|
||||
.breadcrumb span{color:#d0d5dd}
|
||||
.breadcrumb b{color:var(--ink);font-weight:600}
|
||||
.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}
|
||||
.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}
|
||||
@@ -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}
|
||||
.workspace-main{margin:0;width:100%}
|
||||
.topbar{position:static;height:auto;min-height:56px;padding:10px 16px;flex-wrap:wrap}
|
||||
.topbar-right{gap:10px}
|
||||
.topbar-right>span:not(.demo-indicator){display:none}
|
||||
.topbar-right{gap:8px 10px;flex-wrap:wrap}
|
||||
.demo-strip{margin:16px 16px 0;border-radius:16px;font-size:12px}
|
||||
.app main{padding:22px 16px}
|
||||
.app-footer{margin-left:16px;margin-right:16px}
|
||||
|
||||
Reference in New Issue
Block a user