Compare commits
10 Commits
a1d49b37b5
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68050d8d4b | ||
|
|
9f9fc93dfe | ||
|
|
8a02f68589 | ||
|
|
ddbdbc7d44 | ||
|
|
6d4850f4ce | ||
|
|
51700bc0f3 | ||
|
|
42464d88b3 | ||
|
|
7c17191b8b | ||
|
|
c294e91253 | ||
|
|
a4189f0d2a |
5
.env.production
Normal file
5
.env.production
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Used by `vite build` (Netlify deploys are built locally). Both values are public.
|
||||||
|
# Without these, the build falls back to learning-lab's dev Clerk key and localhost Studio,
|
||||||
|
# so production users sign in to the wrong Clerk instance and see no data.
|
||||||
|
VITE_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsubGVhcm5pbmdzdHVkaW8uYmF3YW5hLmNvbSQ
|
||||||
|
VITE_STUDIO_URL=https://learningstudio.bawana.com
|
||||||
24
netlify.toml
Normal file
24
netlify.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[build]
|
||||||
|
command = "npm run build"
|
||||||
|
publish = "dist"
|
||||||
|
|
||||||
|
[build.environment]
|
||||||
|
NODE_VERSION = "22"
|
||||||
|
|
||||||
|
# Same-origin /api, as the Vite proxy does in dev: the engine needs no CORS setup.
|
||||||
|
[[redirects]]
|
||||||
|
from = "/api/*"
|
||||||
|
to = "https://learningstudio.bawana.com/api/:splat"
|
||||||
|
status = 200
|
||||||
|
force = true
|
||||||
|
|
||||||
|
# BrowserRouter: /brain, /results, ... all load index.html.
|
||||||
|
[[redirects]]
|
||||||
|
from = "/*"
|
||||||
|
to = "/index.html"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
[[headers]]
|
||||||
|
for = "/assets/*"
|
||||||
|
[headers.values]
|
||||||
|
Cache-Control = "public, max-age=31536000, immutable"
|
||||||
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
|||||||
import { AuthGate } from './auth/AuthGate'
|
import { AuthGate } from './auth/AuthGate'
|
||||||
import { AppLayout } from './components/AppLayout'
|
import { AppLayout } from './components/AppLayout'
|
||||||
import { BrainPage } from './pages/Brain'
|
import { BrainPage } from './pages/Brain'
|
||||||
|
import { ProductionPage } from './pages/Production'
|
||||||
import { InstructionPage } from './pages/Instruction'
|
import { InstructionPage } from './pages/Instruction'
|
||||||
import { SchedulePage } from './pages/Schedule'
|
import { SchedulePage } from './pages/Schedule'
|
||||||
import { ResultsPage } from './pages/Results'
|
import { ResultsPage } from './pages/Results'
|
||||||
@@ -11,6 +12,7 @@ export default function App() {
|
|||||||
<AuthGate>
|
<AuthGate>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
|
<Route path="/production" element={<ProductionPage />} />
|
||||||
<Route path="/brain" element={<BrainPage />} />
|
<Route path="/brain" element={<BrainPage />} />
|
||||||
<Route path="/instruction" element={<InstructionPage />} />
|
<Route path="/instruction" element={<InstructionPage />} />
|
||||||
<Route path="/schedule" element={<SchedulePage />} />
|
<Route path="/schedule" element={<SchedulePage />} />
|
||||||
@@ -21,7 +23,7 @@ export default function App() {
|
|||||||
<Route path="/perintah" element={<Navigate replace to="/instruction" />} />
|
<Route path="/perintah" element={<Navigate replace to="/instruction" />} />
|
||||||
<Route path="/jadwal" element={<Navigate replace to="/schedule" />} />
|
<Route path="/jadwal" element={<Navigate replace to="/schedule" />} />
|
||||||
<Route path="/hasil" element={<Navigate replace to="/results" />} />
|
<Route path="/hasil" element={<Navigate replace to="/results" />} />
|
||||||
<Route path="*" element={<Navigate replace to="/brain" />} />
|
<Route path="*" element={<Navigate replace to="/production" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthGate>
|
</AuthGate>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ function asSource(d: EngineDocument): Source {
|
|||||||
export const listDocuments = async () => (await request<EngineDocument[]>('/upload/list')).map(asSource)
|
export const listDocuments = async () => (await request<EngineDocument[]>('/upload/list')).map(asSource)
|
||||||
export const documentStatus = (id: number) => request<EngineDocument>(`/upload/${id}`)
|
export const documentStatus = (id: number) => request<EngineDocument>(`/upload/${id}`)
|
||||||
export const deleteDocument = (id: number) => request<null>(`/upload/${id}`, { method: 'DELETE' })
|
export const deleteDocument = (id: number) => request<null>(`/upload/${id}`, { method: 'DELETE' })
|
||||||
|
export const documentFile = (id: number) => request<Blob>(`/upload/${id}/file`, { as: 'blob' })
|
||||||
|
|
||||||
export async function supportedExtensions(): Promise<Set<string>> {
|
export async function supportedExtensions(): Promise<Set<string>> {
|
||||||
const f = await request<{ extensions: string[] }>('/upload/supported-formats')
|
const f = await request<{ extensions: string[] }>('/upload/supported-formats')
|
||||||
@@ -97,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,17 +1,30 @@
|
|||||||
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 { 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'
|
||||||
|
|
||||||
// Express Mode has four doors: feed the Brain, give an instruction, watch the schedule, collect results.
|
// Learning Studio (learning-lab/frontend); both apps share one Clerk instance, so the user stays signed in.
|
||||||
export const NAV: { to: string; label: Key; icon: IconName }[] = [
|
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: '/brain', label: 'nav.brain', icon: 'brain' },
|
||||||
{ to: '/instruction', label: 'nav.instruction', icon: 'spark' },
|
{ to: '/instruction', label: 'nav.instruction', icon: 'spark' },
|
||||||
{ to: '/schedule', label: 'nav.schedule', icon: 'calendar' },
|
{ to: '/schedule', label: 'nav.schedule', icon: 'calendar' },
|
||||||
{ to: '/results', label: 'nav.results', icon: 'layers' },
|
{ to: '/results', label: 'nav.results', icon: 'layers' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
export const NAV = NAV_GROUPS.flatMap(g => g.items)
|
||||||
|
|
||||||
export function LanguageSwitch() {
|
export function LanguageSwitch() {
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
@@ -57,32 +70,87 @@ 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
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">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<NavLink to="/brain"><Brand /></NavLink>
|
<NavLink to="/production"><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>
|
|
||||||
<nav aria-label={t('layout.mainNav')}>
|
<nav aria-label={t('layout.mainNav')}>
|
||||||
{NAV.map(n => (
|
{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)}>
|
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
|
||||||
<Icon name={n.icon} />{t(n.label)}
|
<Icon name={n.icon} />{t(n.label)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
)),
|
||||||
|
])}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="sidebar-help">
|
<div className="sidebar-help">
|
||||||
<Icon name="info" />
|
<Icon name="info" />
|
||||||
@@ -101,7 +169,8 @@ 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 />
|
||||||
|
<a className="studio-link" href={STUDIO_URL}><Icon name="external" />{t('layout.toStudio')}</a>
|
||||||
<LanguageSwitch />
|
<LanguageSwitch />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -12,16 +12,20 @@ const paths: Record<string, string> = {
|
|||||||
upload: '<path d="M12 16V4m-5 5 5-5 5 5M4 16v5h16v-5"/>',
|
upload: '<path d="M12 16V4m-5 5 5-5 5 5M4 16v5h16v-5"/>',
|
||||||
file: '<path d="M5 3h9l5 5v13H5zM14 3v6h5M8 13h8M8 17h6"/>',
|
file: '<path d="M5 3h9l5 5v13H5zM14 3v6h5M8 13h8M8 17h6"/>',
|
||||||
'arrow-right': '<path d="M4 12h16m-6-6 6 6-6 6"/>',
|
'arrow-right': '<path d="M4 12h16m-6-6 6 6-6 6"/>',
|
||||||
|
external: '<path d="M14 4h6v6M20 4l-9 9M18 14v6H4V6h6"/>',
|
||||||
close: '<path d="M6 6l12 12M18 6 6 18"/>',
|
close: '<path d="M6 6l12 12M18 6 6 18"/>',
|
||||||
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"/>',
|
||||||
|
'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"/>',
|
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"/>',
|
||||||
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"/>',
|
||||||
|
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
|
||||||
|
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"/>',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
src/express/graph.test.ts
Normal file
38
src/express/graph.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { Source } from '../api/engine'
|
||||||
|
import { buildGraph, HEIGHT, WIDTH } from './graph'
|
||||||
|
import type { FoundSource, Material } from './results'
|
||||||
|
import type { Schedule } from './schedules'
|
||||||
|
|
||||||
|
const doc = (docId: number, title: string) => ({ docId, title, originalName: `${title}.pdf` }) as Source
|
||||||
|
const material = (id: number, status: Material['status'], sources: string[]) =>
|
||||||
|
({ id, agentId: 7, kind: 'summary', status, planTitle: `Lesson ${id}`, sources }) as Material
|
||||||
|
const schedule = { agent: { id: 7, name: 'Express · 2 summary per week' } } as Schedule
|
||||||
|
|
||||||
|
describe('buildGraph', () => {
|
||||||
|
const data = {
|
||||||
|
docs: [doc(1, 'Onboarding'), doc(2, 'Safety')],
|
||||||
|
materials: [material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', [])],
|
||||||
|
sources: [{ id: 5, agentId: 7, title: 'Article', documentId: 2 } as FoundSource],
|
||||||
|
schedules: [schedule],
|
||||||
|
}
|
||||||
|
const graph = buildGraph(data, c => c)
|
||||||
|
|
||||||
|
it('puts results in progress in their own cluster', () => {
|
||||||
|
expect(graph.nodes.find(n => n.id === 'mat-7-10')?.cluster).toBe('made')
|
||||||
|
expect(graph.nodes.find(n => n.id === 'mat-7-11')?.cluster).toBe('working')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links results to their sources and schedule, and found sources to their document', () => {
|
||||||
|
const cross = graph.links.filter(l => !l.hub).map(l => `${l.source}>${l.target}`)
|
||||||
|
expect(cross).toEqual(expect.arrayContaining(['mat-7-10>doc-1', 'mat-7-10>doc-2', 'mat-7-10>agent-7', 'found-7-5>doc-2']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lays out the same data the same way, inside the canvas', () => {
|
||||||
|
expect(buildGraph(data, c => c).nodes).toEqual(graph.nodes)
|
||||||
|
for (const n of graph.nodes) {
|
||||||
|
expect(n.x).toBeGreaterThanOrEqual(0); expect(n.x).toBeLessThanOrEqual(WIDTH)
|
||||||
|
expect(n.y).toBeGreaterThanOrEqual(0); expect(n.y).toBeLessThanOrEqual(HEIGHT)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
133
src/express/graph.ts
Normal file
133
src/express/graph.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// The Production knowledge graph: every Brain document, result, found source and schedule as a node,
|
||||||
|
// grouped around one hub per cluster and linked where the engine says they are related (a result's
|
||||||
|
// sources, the schedule that made it, the document a found source became). Laid out once with a
|
||||||
|
// small seeded force simulation, so the same data always draws the same picture.
|
||||||
|
import type { Source } from '../api/engine'
|
||||||
|
import { isWorking, type FoundSource, type Material } from './results'
|
||||||
|
import type { Schedule } from './schedules'
|
||||||
|
|
||||||
|
export type Cluster = 'sources' | 'made' | 'working' | 'research' | 'schedules'
|
||||||
|
export const CLUSTERS: Cluster[] = ['sources', 'made', 'working', 'research', 'schedules']
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string
|
||||||
|
cluster: Cluster
|
||||||
|
label: string
|
||||||
|
hub: boolean
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphLink { source: string; target: string; hub: boolean }
|
||||||
|
|
||||||
|
export interface Graph { nodes: GraphNode[]; links: GraphLink[]; width: number; height: number }
|
||||||
|
|
||||||
|
export const WIDTH = 1000
|
||||||
|
export const HEIGHT = 640
|
||||||
|
|
||||||
|
// Where each cluster settles, around the middle of the canvas.
|
||||||
|
const ANCHOR: Record<Cluster, [number, number]> = {
|
||||||
|
sources: [560, 360], made: [450, 430], working: [330, 300], research: [440, 200], schedules: [700, 230],
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = (s: string) => s.trim().toLowerCase()
|
||||||
|
|
||||||
|
export function buildGraph(
|
||||||
|
{ docs, materials, sources, schedules }: { docs: Source[]; materials: Material[]; sources: FoundSource[]; schedules: Schedule[] },
|
||||||
|
hubLabel: (c: Cluster) => string,
|
||||||
|
): Graph {
|
||||||
|
const nodes: GraphNode[] = CLUSTERS.map(c => ({ id: `hub-${c}`, cluster: c, label: hubLabel(c), hub: true, x: 0, y: 0 }))
|
||||||
|
const links: GraphLink[] = []
|
||||||
|
const add = (id: string, cluster: Cluster, label: string) => {
|
||||||
|
nodes.push({ id, cluster, label, hub: false, x: 0, y: 0 })
|
||||||
|
links.push({ source: id, target: `hub-${cluster}`, hub: true })
|
||||||
|
}
|
||||||
|
const link = (source: string, target: string) => links.push({ source, target, hub: false })
|
||||||
|
|
||||||
|
// Results name their sources by title, so documents are found by title or file name.
|
||||||
|
const docByName = new Map<string, string>()
|
||||||
|
for (const d of docs) {
|
||||||
|
add(`doc-${d.docId}`, 'sources', d.title)
|
||||||
|
docByName.set(norm(d.title), `doc-${d.docId}`)
|
||||||
|
docByName.set(norm(d.originalName), `doc-${d.docId}`)
|
||||||
|
}
|
||||||
|
for (const s of schedules) add(`agent-${s.agent.id}`, 'schedules', s.agent.name.split(' · ').slice(1).join(' · ') || s.agent.name)
|
||||||
|
const agents = new Set(schedules.map(s => `agent-${s.agent.id}`))
|
||||||
|
|
||||||
|
for (const m of materials) {
|
||||||
|
const id = `${m.kind === 'video' ? 'video' : 'mat'}-${m.agentId}-${m.id}`
|
||||||
|
add(id, isWorking(m) ? 'working' : 'made', m.planTitle ?? m.kind)
|
||||||
|
if (agents.has(`agent-${m.agentId}`)) link(id, `agent-${m.agentId}`)
|
||||||
|
for (const title of new Set(m.sources)) {
|
||||||
|
const doc = docByName.get(norm(title))
|
||||||
|
if (doc) link(id, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const s of sources) {
|
||||||
|
const id = `found-${s.agentId}-${s.id}`
|
||||||
|
add(id, 'research', s.title)
|
||||||
|
if (agents.has(`agent-${s.agentId}`)) link(id, `agent-${s.agentId}`)
|
||||||
|
if (s.documentId != null && docs.some(d => d.docId === s.documentId)) link(id, `doc-${s.documentId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
layout(nodes, links)
|
||||||
|
return { nodes, links, width: WIDTH, height: HEIGHT }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic per node, so a refetch with the same data does not reshuffle the picture.
|
||||||
|
function seeded(id: string) {
|
||||||
|
let h = 2166136261
|
||||||
|
for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 16777619)
|
||||||
|
return () => {
|
||||||
|
h = Math.imul(h ^ (h >>> 15), 2246822507)
|
||||||
|
h = Math.imul(h ^ (h >>> 13), 3266489909)
|
||||||
|
return ((h ^= h >>> 16) >>> 0) / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function layout(nodes: GraphNode[], links: GraphLink[], iterations = 260) {
|
||||||
|
const index = new Map(nodes.map((n, i) => [n.id, i]))
|
||||||
|
const vx = new Float64Array(nodes.length)
|
||||||
|
const vy = new Float64Array(nodes.length)
|
||||||
|
for (const n of nodes) {
|
||||||
|
const rand = seeded(n.id)
|
||||||
|
const [ax, ay] = ANCHOR[n.cluster]
|
||||||
|
n.x = ax + (n.hub ? 0 : (rand() - 0.5) * 120)
|
||||||
|
n.y = ay + (n.hub ? 0 : (rand() - 0.5) * 120)
|
||||||
|
}
|
||||||
|
const pairs = links.map(l => [index.get(l.source)!, index.get(l.target)!, l.hub] as const).filter(([a, b]) => a != null && b != null)
|
||||||
|
|
||||||
|
for (let it = 0; it < iterations; it++) {
|
||||||
|
const alpha = 1 - it / iterations
|
||||||
|
// Repulsion keeps nodes from piling up; only near neighbours matter.
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
for (let j = i + 1; j < nodes.length; j++) {
|
||||||
|
const dx = nodes[j].x - nodes[i].x
|
||||||
|
const dy = nodes[j].y - nodes[i].y
|
||||||
|
const d2 = dx * dx + dy * dy + 0.01
|
||||||
|
if (d2 > 140 * 140) continue
|
||||||
|
const f = 260 / d2
|
||||||
|
vx[i] -= dx * f; vy[i] -= dy * f
|
||||||
|
vx[j] += dx * f; vy[j] += dy * f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Springs: short to the cluster hub, longer across clusters.
|
||||||
|
for (const [a, b, hub] of pairs) {
|
||||||
|
const dx = nodes[b].x - nodes[a].x
|
||||||
|
const dy = nodes[b].y - nodes[a].y
|
||||||
|
const d = Math.sqrt(dx * dx + dy * dy) || 1
|
||||||
|
const f = ((d - (hub ? 46 : 110)) / d) * (hub ? 0.06 : 0.015)
|
||||||
|
vx[a] += dx * f; vy[a] += dy * f
|
||||||
|
vx[b] -= dx * f; vy[b] -= dy * f
|
||||||
|
}
|
||||||
|
nodes.forEach((n, i) => {
|
||||||
|
const [ax, ay] = ANCHOR[n.cluster]
|
||||||
|
const pull = n.hub ? 0.12 : 0.012
|
||||||
|
vx[i] += (ax - n.x) * pull
|
||||||
|
vy[i] += (ay - n.y) * pull
|
||||||
|
n.x = Math.min(WIDTH - 20, Math.max(20, n.x + vx[i] * alpha))
|
||||||
|
n.y = Math.min(HEIGHT - 20, Math.max(20, n.y + vy[i] * alpha))
|
||||||
|
vx[i] *= 0.6; vy[i] *= 0.6
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
import { request } from '../api/client'
|
import { request } from '../api/client'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
import { kindLabel, type ContentKind, type Rule } from './parse'
|
import { kindLabel, type ContentKind, type Rule } from './parse'
|
||||||
import { expressAgents, MAX_ATTEMPTS, PLAN_ACTIVE, productionStatus, retryItem, type PlanItem, type Schedule } from './schedules'
|
import { expressAgents, MAX_ATTEMPTS, PLAN_ACTIVE, productionStatus, retryItem, runNow, type PlanItem, type Schedule } from './schedules'
|
||||||
|
|
||||||
const AGENTS = '/organizations/current/agents'
|
const AGENTS = '/organizations/current/agents'
|
||||||
|
|
||||||
@@ -39,8 +39,8 @@ export interface Material {
|
|||||||
sources: string[]
|
sources: string[]
|
||||||
createdAt: string
|
createdAt: string
|
||||||
rule: Rule
|
rule: Rule
|
||||||
/** The plan item to produce again, when the engine allows another attempt. */
|
/** How to produce it again, when the engine allows another attempt: the plan item, or (video) a new run. */
|
||||||
retry?: { planId: number; itemId: number }
|
retry?: { planId: number; itemId: number } | { run: true }
|
||||||
video?: { url: string | null; progress: number; credits: number | null }
|
video?: { url: string | null; progress: number; credits: number | null }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,11 +147,15 @@ function videoStatus(s: string): MaterialStatus {
|
|||||||
return 'generating'
|
return 'generating'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function videosOf({ agent, rule }: Schedule): Promise<Material[]> {
|
async function videosOf({ agent, rule, active }: Schedule): Promise<Material[]> {
|
||||||
const drafts = await request<RawVideo[]>(`${AGENTS}/${agent.id}/video-drafts`)
|
const drafts = await request<RawVideo[]>(`${AGENTS}/${agent.id}/video-drafts`)
|
||||||
|
// A video draft cannot be retried itself; a new run of its schedule makes it again. Offered on the
|
||||||
|
// newest draft only, so older failures don't each start another (paid) run.
|
||||||
|
const newest = Math.max(...drafts.map(d => d.id))
|
||||||
return drafts.map(d => {
|
return drafts.map(d => {
|
||||||
const status = videoStatus(d.status)
|
const status = videoStatus(d.status)
|
||||||
return {
|
return {
|
||||||
|
retry: status === 'failed' && active && d.id === newest ? { run: true } as const : undefined,
|
||||||
id: d.id, agentId: agent.id, kind: 'video', status, revision: 0,
|
id: d.id, agentId: agent.id, kind: 'video', status, revision: 0,
|
||||||
parts: [{ id: d.id, revision: 0, kind: 'video', status }],
|
parts: [{ id: d.id, revision: 0, kind: 'video', status }],
|
||||||
planTitle: d.title, composeSetId: null, sources: d.sources.map(s => s.title), createdAt: d.created_at, rule,
|
planTitle: d.title, composeSetId: null, sources: d.sources.map(s => s.title), createdAt: d.created_at, rule,
|
||||||
@@ -195,9 +199,10 @@ export async function materialContent(m: Material): Promise<{ content: MaterialC
|
|||||||
// The engine renders slide decks to PPTX itself; other kinds have no file export there.
|
// The engine renders slide decks to PPTX itself; other kinds have no file export there.
|
||||||
export const exportPptx = (deckId: number) => request<Blob>(`/slide-deck/${deckId}/export`, { method: 'POST', as: 'blob' })
|
export const exportPptx = (deckId: number) => request<Blob>(`/slide-deck/${deckId}/export`, { method: 'POST', as: 'blob' })
|
||||||
|
|
||||||
// Every part waiting for review gets the same decision, so a SCORM module is approved as one.
|
export const retryMaterial = ({ agentId, retry }: Material) =>
|
||||||
export const retryMaterial = (m: Material) => retryItem(m.agentId, m.retry!.planId, m.retry!.itemId)
|
retry && 'run' in retry ? runNow(agentId) : retryItem(agentId, retry!.planId, retry!.itemId)
|
||||||
|
|
||||||
|
// Every part waiting for review gets the same decision, so a SCORM module is approved as one.
|
||||||
export async function decideMaterial(m: Material, decision: 'accept' | 'reject') {
|
export async function decideMaterial(m: Material, decision: 'accept' | 'reject') {
|
||||||
for (const p of m.parts.filter(p => p.status === 'ready')) {
|
for (const p of m.parts.filter(p => p.status === 'ready')) {
|
||||||
if (m.kind === 'video') await request(`${AGENTS}/${m.agentId}/video-drafts/${p.id}/review`, { method: 'POST', json: { decision } })
|
if (m.kind === 'video') await request(`${AGENTS}/${m.agentId}/video-drafts/${p.id}/review`, { method: 'POST', json: { decision } })
|
||||||
|
|||||||
84
src/i18n.ts
84
src/i18n.ts
@@ -11,11 +11,16 @@ const en = {
|
|||||||
'nav.instruction': 'Instruction',
|
'nav.instruction': 'Instruction',
|
||||||
'nav.schedule': 'Schedule',
|
'nav.schedule': 'Schedule',
|
||||||
'nav.results': 'Results',
|
'nav.results': 'Results',
|
||||||
'nav.workspace': 'WORKSPACE',
|
'nav.dashboard': 'Dashboard',
|
||||||
|
'nav.settings': 'Settings',
|
||||||
|
'nav.production': 'Production',
|
||||||
'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',
|
||||||
|
'layout.creditsTitle': 'Workspace credit balance',
|
||||||
|
'layout.toStudio': 'Switch to Learning Lab',
|
||||||
'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',
|
||||||
@@ -42,6 +47,39 @@ const en = {
|
|||||||
'common.retry': 'Try again',
|
'common.retry': 'Try again',
|
||||||
'common.credits': '{n} credits',
|
'common.credits': '{n} credits',
|
||||||
|
|
||||||
|
// Production
|
||||||
|
'prod.title': 'Production',
|
||||||
|
'prod.description': 'Everything Express is producing from the Brain: material, content made, what is live, and what is being made right now.',
|
||||||
|
'prod.metricWorking': 'Content in progress',
|
||||||
|
'prod.metricWorkingSub': 'being researched or generated now',
|
||||||
|
'prod.metricMaterial': 'Material',
|
||||||
|
'prod.metricMaterialSub': '{n} in the Brain',
|
||||||
|
'prod.metricMade': 'Content made',
|
||||||
|
'prod.metricMadeSub': 'results the AI has produced',
|
||||||
|
'prod.metricLive': 'In production',
|
||||||
|
'prod.metricLiveSub': 'approved for use',
|
||||||
|
'prod.metricLms': 'On the LMS',
|
||||||
|
'prod.metricLmsSub': 'approved SCORM modules',
|
||||||
|
'prod.graphTitle': 'Knowledge graph',
|
||||||
|
'prod.clickNode': 'Click a node',
|
||||||
|
'prod.loadingGraph': 'Drawing the knowledge graph…',
|
||||||
|
'prod.graphLabel': 'Knowledge graph of {nodes} nodes',
|
||||||
|
'prod.hub.sources': 'Sources',
|
||||||
|
'prod.hub.made': 'Made',
|
||||||
|
'prod.hub.working': 'In progress',
|
||||||
|
'prod.hub.research': 'Research',
|
||||||
|
'prod.hub.schedules': 'Repeats',
|
||||||
|
'prod.nodes': 'Nodes',
|
||||||
|
'prod.nodesSub': 'in this Brain',
|
||||||
|
'prod.links': 'Links',
|
||||||
|
'prod.linksSub': 'connections between nodes',
|
||||||
|
'prod.scheduleTitle': 'Schedule',
|
||||||
|
'prod.noSchedules': 'No schedules yet.',
|
||||||
|
'prod.giveInstruction': 'Give an instruction',
|
||||||
|
'prod.nowTitle': 'Happening now',
|
||||||
|
'prod.nothingNow': 'Nothing is being made right now.',
|
||||||
|
'prod.more': '+{n} more in Results',
|
||||||
|
|
||||||
// Brain
|
// Brain
|
||||||
'brain.title': 'Brain',
|
'brain.title': 'Brain',
|
||||||
'brain.description': 'All team material is processed into one Brain that keeps growing. New content the AI creates goes in here too.',
|
'brain.description': 'All team material is processed into one Brain that keeps growing. New content the AI creates goes in here too.',
|
||||||
@@ -79,10 +117,12 @@ const en = {
|
|||||||
'brain.needsCreditsNeed': ' (needs {credits})',
|
'brain.needsCreditsNeed': ' (needs {credits})',
|
||||||
'brain.failed': 'Could not add this to the Brain.',
|
'brain.failed': 'Could not add this to the Brain.',
|
||||||
'brain.retryWith': 'Try again ({credits})',
|
'brain.retryWith': 'Try again ({credits})',
|
||||||
|
'brain.failedAfterBuild': 'Processing this file failed after its knowledge was built. Schedules that use the whole Brain stop until it is removed: delete it and upload it again.',
|
||||||
'brain.confirmCopy': 'Adding this document to the Brain uses {credits} from the workspace.',
|
'brain.confirmCopy': 'Adding this document to the Brain uses {credits} from the workspace.',
|
||||||
'brain.confirmCreditsFallback': 'credits',
|
'brain.confirmCreditsFallback': 'credits',
|
||||||
'brain.addToBrain': 'Add to Brain',
|
'brain.addToBrain': 'Add to Brain',
|
||||||
'brain.delete': 'Delete {name}',
|
'brain.delete': 'Delete {name}',
|
||||||
|
'brain.open': 'Open {name}',
|
||||||
'brain.deleteConfirm': 'Delete “{name}” from the Brain?',
|
'brain.deleteConfirm': 'Delete “{name}” from the Brain?',
|
||||||
|
|
||||||
// Instruction
|
// Instruction
|
||||||
@@ -228,7 +268,6 @@ const en = {
|
|||||||
'res.sourceCount': '{n} sources',
|
'res.sourceCount': '{n} sources',
|
||||||
'res.sourceOne': '1 source',
|
'res.sourceOne': '1 source',
|
||||||
'res.working': 'Being created…',
|
'res.working': 'Being created…',
|
||||||
'res.open': 'Open',
|
|
||||||
'res.close': 'Close',
|
'res.close': 'Close',
|
||||||
'res.reviewNotice': 'AI-generated draft. Check it, then approve it or reject it.',
|
'res.reviewNotice': 'AI-generated draft. Check it, then approve it or reject it.',
|
||||||
'res.noContent': 'No content to show',
|
'res.noContent': 'No content to show',
|
||||||
@@ -388,15 +427,51 @@ const en = {
|
|||||||
export type Key = keyof typeof en
|
export type Key = keyof typeof en
|
||||||
|
|
||||||
const id: Record<Key, string> = {
|
const id: Record<Key, string> = {
|
||||||
|
'prod.title': 'Produksi',
|
||||||
|
'prod.description': 'Semua yang diproduksi Express dari Otak: materi, konten yang dibuat, yang sudah tayang, dan yang sedang dibuat saat ini.',
|
||||||
|
'prod.metricWorking': 'Konten sedang dibuat',
|
||||||
|
'prod.metricWorkingSub': 'sedang diriset atau dibuat sekarang',
|
||||||
|
'prod.metricMaterial': 'Materi',
|
||||||
|
'prod.metricMaterialSub': '{n} sudah di Otak',
|
||||||
|
'prod.metricMade': 'Konten dibuat',
|
||||||
|
'prod.metricMadeSub': 'hasil yang sudah dibuat AI',
|
||||||
|
'prod.metricLive': 'Sudah produksi',
|
||||||
|
'prod.metricLiveSub': 'disetujui untuk dipakai',
|
||||||
|
'prod.metricLms': 'Sudah di LMS',
|
||||||
|
'prod.metricLmsSub': 'modul SCORM yang disetujui',
|
||||||
|
'prod.graphTitle': 'Knowledge graph',
|
||||||
|
'prod.clickNode': 'Klik sebuah node',
|
||||||
|
'prod.loadingGraph': 'Menggambar knowledge graph…',
|
||||||
|
'prod.graphLabel': 'Knowledge graph berisi {nodes} node',
|
||||||
|
'prod.hub.sources': 'Sumber',
|
||||||
|
'prod.hub.made': 'Dibuat',
|
||||||
|
'prod.hub.working': 'Sedang dibuat',
|
||||||
|
'prod.hub.research': 'Riset',
|
||||||
|
'prod.hub.schedules': 'Berulang',
|
||||||
|
'prod.nodes': 'Node',
|
||||||
|
'prod.nodesSub': 'di Otak ini',
|
||||||
|
'prod.links': 'Tautan',
|
||||||
|
'prod.linksSub': 'koneksi antar node',
|
||||||
|
'prod.scheduleTitle': 'Jadwal',
|
||||||
|
'prod.noSchedules': 'Belum ada jadwal.',
|
||||||
|
'prod.giveInstruction': 'Beri perintah',
|
||||||
|
'prod.nowTitle': 'Sedang berjalan',
|
||||||
|
'prod.nothingNow': 'Tidak ada yang sedang dibuat.',
|
||||||
|
'prod.more': '+{n} lagi di Hasil',
|
||||||
'nav.brain': 'Otak',
|
'nav.brain': 'Otak',
|
||||||
'nav.instruction': 'Perintah',
|
'nav.instruction': 'Perintah',
|
||||||
'nav.schedule': 'Jadwal',
|
'nav.schedule': 'Jadwal',
|
||||||
'nav.results': 'Hasil',
|
'nav.results': 'Hasil',
|
||||||
'nav.workspace': 'RUANG KERJA',
|
'nav.dashboard': 'Dasbor',
|
||||||
|
'nav.settings': 'Pengaturan',
|
||||||
|
'nav.production': 'Produksi',
|
||||||
'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',
|
||||||
|
'layout.creditsTitle': 'Saldo kredit workspace',
|
||||||
|
'layout.toStudio': 'Beralih ke Learning Lab',
|
||||||
'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',
|
||||||
@@ -457,10 +532,12 @@ const id: Record<Key, string> = {
|
|||||||
'brain.needsCreditsNeed': ' (butuh {credits})',
|
'brain.needsCreditsNeed': ' (butuh {credits})',
|
||||||
'brain.failed': 'Gagal memasukkan ke Otak.',
|
'brain.failed': 'Gagal memasukkan ke Otak.',
|
||||||
'brain.retryWith': 'Coba lagi ({credits})',
|
'brain.retryWith': 'Coba lagi ({credits})',
|
||||||
|
'brain.failedAfterBuild': 'Pemrosesan file ini gagal setelah pengetahuannya dibangun. Jadwal yang memakai seluruh Otak berhenti sampai file ini dihapus: hapus lalu unggah ulang.',
|
||||||
'brain.confirmCopy': 'Memasukkan dokumen ini ke Otak memakai {credits} dari ruang kerja.',
|
'brain.confirmCopy': 'Memasukkan dokumen ini ke Otak memakai {credits} dari ruang kerja.',
|
||||||
'brain.confirmCreditsFallback': 'kredit',
|
'brain.confirmCreditsFallback': 'kredit',
|
||||||
'brain.addToBrain': 'Masukkan ke Otak',
|
'brain.addToBrain': 'Masukkan ke Otak',
|
||||||
'brain.delete': 'Hapus {name}',
|
'brain.delete': 'Hapus {name}',
|
||||||
|
'brain.open': 'Buka {name}',
|
||||||
'brain.deleteConfirm': 'Hapus “{name}” dari Otak?',
|
'brain.deleteConfirm': 'Hapus “{name}” dari Otak?',
|
||||||
|
|
||||||
'ins.title': 'Perintah',
|
'ins.title': 'Perintah',
|
||||||
@@ -602,7 +679,6 @@ const id: Record<Key, string> = {
|
|||||||
'res.sourceCount': '{n} sumber',
|
'res.sourceCount': '{n} sumber',
|
||||||
'res.sourceOne': '1 sumber',
|
'res.sourceOne': '1 sumber',
|
||||||
'res.working': 'Sedang dibuat…',
|
'res.working': 'Sedang dibuat…',
|
||||||
'res.open': 'Buka',
|
|
||||||
'res.close': 'Tutup',
|
'res.close': 'Tutup',
|
||||||
'res.reviewNotice': 'Draft buatan AI. Periksa dulu, lalu setujui atau tolak.',
|
'res.reviewNotice': 'Draft buatan AI. Periksa dulu, lalu setujui atau tolak.',
|
||||||
'res.noContent': 'Tidak ada konten untuk ditampilkan',
|
'res.noContent': 'Tidak ada konten untuk ditampilkan',
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
answerVersion, buildKnowledge, deleteDocument, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
|
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
|
||||||
type Source,
|
type Source,
|
||||||
} from '../api/engine'
|
} from '../api/engine'
|
||||||
import { Icon } from '../components/Icon'
|
import { Icon } from '../components/Icon'
|
||||||
@@ -12,6 +12,7 @@ import { locale, t } from '../i18n'
|
|||||||
type Phase = 'ready' | 'working' | 'answer' | 'failed'
|
type Phase = 'ready' | 'working' | 'answer' | 'failed'
|
||||||
|
|
||||||
function phaseOf(s: Source): Phase {
|
function phaseOf(s: Source): Phase {
|
||||||
|
// A failed document blocks every whole-Brain schedule in the engine, even with a built knowledge base.
|
||||||
if (s.status === 'failed' || s.knowledge === 'failed') return 'failed'
|
if (s.status === 'failed' || s.knowledge === 'failed') return 'failed'
|
||||||
if (s.knowledge === 'ready') return 'ready'
|
if (s.knowledge === 'ready') return 'ready'
|
||||||
if (s.knowledge === 'building' || s.status !== 'completed') return 'working'
|
if (s.knowledge === 'building' || s.status !== 'completed') return 'working'
|
||||||
@@ -154,7 +155,7 @@ function DocumentRow({ doc }: { doc: Source }) {
|
|||||||
<div className="result-row">
|
<div className="result-row">
|
||||||
<span className={`file-icon ${doc.format.toLowerCase()}`}>{doc.format.slice(0, 4)}</span>
|
<span className={`file-icon ${doc.format.toLowerCase()}`}>{doc.format.slice(0, 4)}</span>
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<b>{doc.title}</b>
|
<OpenButton doc={doc} />
|
||||||
<div><small>{new Date(doc.createdAt).toLocaleDateString(locale())}</small></div>
|
<div><small>{new Date(doc.createdAt).toLocaleDateString(locale())}</small></div>
|
||||||
{phase === 'working' && <Progress doc={doc} />}
|
{phase === 'working' && <Progress doc={doc} />}
|
||||||
{(phase === 'answer' || phase === 'failed') && <KnowledgeAction doc={doc} />}
|
{(phase === 'answer' || phase === 'failed') && <KnowledgeAction doc={doc} />}
|
||||||
@@ -165,6 +166,36 @@ function DocumentRow({ doc }: { doc: Source }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Formats a browser can show in a tab; anything else is saved under its original name.
|
||||||
|
const VIEWABLE = new Set(['pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'txt', 'md', 'csv', 'mp3', 'wav', 'm4a', 'ogg', 'mp4', 'webm', 'mov'])
|
||||||
|
|
||||||
|
function OpenButton({ doc }: { doc: Source }) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
async function open() {
|
||||||
|
const inTab = VIEWABLE.has(doc.format.toLowerCase())
|
||||||
|
// Opened before the fetch: a tab opened after an await counts as a popup and is blocked.
|
||||||
|
const tab = inTab ? window.open('', '_blank') : null
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const url = URL.createObjectURL(await documentFile(doc.docId))
|
||||||
|
if (tab) tab.location.href = url
|
||||||
|
// Not viewable, or the tab was blocked: save it instead of doing nothing.
|
||||||
|
else Object.assign(document.createElement('a'), { href: url, download: doc.originalName }).click()
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||||
|
} catch (e) {
|
||||||
|
tab?.close()
|
||||||
|
toast((e as Error).message, { error: true })
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button className="doc-title" disabled={busy} title={t('brain.open', { name: doc.originalName })} onClick={open}>
|
||||||
|
{doc.title}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
|
function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
|
||||||
const label = {
|
const label = {
|
||||||
ready: t('brain.statusReady'),
|
ready: t('brain.statusReady'),
|
||||||
@@ -216,6 +247,10 @@ function KnowledgeAction({ doc }: { doc: Source }) {
|
|||||||
text = t('brain.needsCredits', { need: credits ? t('brain.needsCreditsNeed', { credits }) : '' })
|
text = t('brain.needsCredits', { need: credits ? t('brain.needsCreditsNeed', { credits }) : '' })
|
||||||
actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('common.retry')}</button>
|
actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('common.retry')}</button>
|
||||||
break
|
break
|
||||||
|
case 'ready':
|
||||||
|
// Processing failed after the build: building again is refused, only removing it unblocks schedules.
|
||||||
|
text = t('brain.failedAfterBuild')
|
||||||
|
break
|
||||||
case 'failed':
|
case 'failed':
|
||||||
text = d.error || t('brain.failed')
|
text = d.error || t('brain.failed')
|
||||||
if (d.quote) actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('brain.retryWith', { credits: credits ?? '' })}</button>
|
if (d.quote) actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('brain.retryWith', { credits: credits ?? '' })}</button>
|
||||||
|
|||||||
244
src/pages/Production.tsx
Normal file
244
src/pages/Production.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { listDocuments } from '../api/engine'
|
||||||
|
import { Icon } from '../components/Icon'
|
||||||
|
import { PageHead, Skeleton } from '../components/Page'
|
||||||
|
import { buildGraph, CLUSTERS, type Cluster, type Graph, type GraphNode } from '../express/graph'
|
||||||
|
import { describeRule } from '../express/parse'
|
||||||
|
import { isWorking, listResults, titleOf } from '../express/results'
|
||||||
|
import { listSchedules } from '../express/schedules'
|
||||||
|
import { locale, t, type Key } from '../i18n'
|
||||||
|
|
||||||
|
const HUB_LABEL: Record<Cluster, Key> = {
|
||||||
|
sources: 'prod.hub.sources', made: 'prod.hub.made', working: 'prod.hub.working', research: 'prod.hub.research', schedules: 'prod.hub.schedules',
|
||||||
|
}
|
||||||
|
|
||||||
|
const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
|
||||||
|
|
||||||
|
// The dashboard: what is in the Brain, what the AI made from it, what is still being made, and the
|
||||||
|
// knowledge graph that ties them together. Shares its queries with the Brain, Schedule and Results pages.
|
||||||
|
export function ProductionPage() {
|
||||||
|
const docs = useQuery({ queryKey: ['documents'], queryFn: listDocuments, refetchInterval: 20_000 })
|
||||||
|
const results = useQuery({
|
||||||
|
queryKey: ['results'],
|
||||||
|
queryFn: listResults,
|
||||||
|
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 60_000),
|
||||||
|
})
|
||||||
|
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
|
||||||
|
|
||||||
|
const materials = results.data?.materials ?? []
|
||||||
|
const working = materials.filter(isWorking)
|
||||||
|
const made = materials.filter(m => !isWorking(m) && m.status !== 'failed')
|
||||||
|
const approved = materials.filter(m => m.status === 'accepted')
|
||||||
|
// No LMS is connected yet: an approved SCORM module is the package that goes into one.
|
||||||
|
const lms = approved.filter(m => m.kind === 'scorm')
|
||||||
|
const inBrain = docs.data?.filter(d => d.knowledge === 'ready').length ?? 0
|
||||||
|
const graph = useMemo(
|
||||||
|
() => (docs.data && results.data && schedules.data
|
||||||
|
? buildGraph({ docs: docs.data, materials: results.data.materials, sources: results.data.sources, schedules: schedules.data }, c => t(HUB_LABEL[c]))
|
||||||
|
: null),
|
||||||
|
[docs.data, results.data, schedules.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const num = (n: number, ready: boolean) => (ready ? <strong>{n.toLocaleString(locale())}</strong> : <Skeleton className="skel-num" />)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead title={t('prod.title')} description={t('prod.description')} />
|
||||||
|
<div className="metrics prod-metrics">
|
||||||
|
<div className="metric">
|
||||||
|
<span><span className="live-dot" />{t('prod.metricWorking')}</span>{num(working.length, !!results.data)}<small>{t('prod.metricWorkingSub')}</small>
|
||||||
|
</div>
|
||||||
|
<div className="metric"><span>{t('prod.metricMaterial')}</span>{num(docs.data?.length ?? 0, !!docs.data)}<small>{t('prod.metricMaterialSub', { n: inBrain })}</small></div>
|
||||||
|
<div className="metric"><span>{t('prod.metricMade')}</span>{num(made.length, !!results.data)}<small>{t('prod.metricMadeSub')}</small></div>
|
||||||
|
<div className="metric"><span>{t('prod.metricLive')}</span>{num(approved.length, !!results.data)}<small>{t('prod.metricLiveSub')}</small></div>
|
||||||
|
<div className="metric"><span>{t('prod.metricLms')}</span>{num(lms.length, !!results.data)}<small>{t('prod.metricLmsSub')}</small></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="graph-stage" aria-label={t('prod.graphTitle')}>
|
||||||
|
{graph
|
||||||
|
? <KnowledgeGraph graph={graph} />
|
||||||
|
: <div className="graph-loading" role="status">{docs.isError || results.isError ? t('brain.engineDown') : t('prod.loadingGraph')}</div>}
|
||||||
|
|
||||||
|
<aside className="graph-panel">
|
||||||
|
<div className="graph-stats">
|
||||||
|
<div><small>{t('prod.nodes')}</small>{graph ? <b>{graph.nodes.filter(n => !n.hub).length}</b> : <b>-</b>}<span>{t('prod.nodesSub')}</span></div>
|
||||||
|
<div><small>{t('prod.links')}</small>{graph ? <b>{graph.links.filter(l => !l.hub).length}</b> : <b>-</b>}<span>{t('prod.linksSub')}</span></div>
|
||||||
|
</div>
|
||||||
|
<h3>{t('prod.scheduleTitle')}</h3>
|
||||||
|
{schedules.data?.length === 0 && <p className="graph-empty">{t('prod.noSchedules')} <Link to="/instruction">{t('prod.giveInstruction')}</Link></p>}
|
||||||
|
<ul className="graph-list">
|
||||||
|
{schedules.data?.slice(0, 4).map(s => (
|
||||||
|
<li key={s.agent.id}>
|
||||||
|
<time>{s.active && s.agent.next_run_at ? when(s.agent.next_run_at) : '-'}</time>
|
||||||
|
<span>{describeRule(s.rule)}</span>
|
||||||
|
<em className={s.active ? 'on' : undefined}>{t(s.active ? 'sch.active' : 'sch.paused')}</em>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<h3>{t('prod.nowTitle')}</h3>
|
||||||
|
{results.data && working.length === 0 && <p className="graph-empty">{t('prod.nothingNow')}</p>}
|
||||||
|
<ul className="graph-list now">
|
||||||
|
{working.slice(0, 6).map(m => (
|
||||||
|
<li key={`${m.kind}-${m.agentId}-${m.id}`}>
|
||||||
|
<i className="node-dot working" />
|
||||||
|
<span>{titleOf(m)}</span>
|
||||||
|
<em>{t(`res.state.${m.status as 'preparing' | 'generating'}`)}</em>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{working.length > 6 && <Link className="graph-more" to="/results">{t('prod.more', { n: working.length - 6 })}</Link>}
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Motion: nodes grow out of their hub on first draw, then drift gently around their place while
|
||||||
|
// light travels along the links between clusters. Positions are written straight to the DOM each
|
||||||
|
// frame, so hovering or selecting (React state) never fights the animation.
|
||||||
|
const DRIFT = 5
|
||||||
|
const MAX_PARTICLES = 80
|
||||||
|
const ease = (x: number) => 1 - Math.pow(1 - Math.min(1, Math.max(0, x)), 3)
|
||||||
|
|
||||||
|
function curve(ax: number, ay: number, bx: number, by: number, bend: number) {
|
||||||
|
const mx = (ax + bx) / 2, my = (ay + by) / 2
|
||||||
|
return { cx: mx - (by - ay) * bend, cy: my + (bx - ax) * bend }
|
||||||
|
}
|
||||||
|
|
||||||
|
function KnowledgeGraph({ graph }: { graph: Graph }) {
|
||||||
|
const [selected, setSelected] = useState<GraphNode | null>(null)
|
||||||
|
const [hover, setHover] = useState<string | null>(null)
|
||||||
|
const nodeEls = useRef<(SVGGElement | null)[]>([])
|
||||||
|
const linkEls = useRef<(SVGPathElement | null)[]>([])
|
||||||
|
const particleEls = useRef<(SVGCircleElement | null)[]>([])
|
||||||
|
const index = useMemo(() => new Map(graph.nodes.map((n, i) => [n.id, i])), [graph])
|
||||||
|
const pairs = useMemo(() => graph.links.map((l, i) => ({
|
||||||
|
a: index.get(l.source)!, b: index.get(l.target)!, hub: l.hub, bend: l.hub ? 0.08 * (i % 2 ? 1 : -1) : 0.18 * (i % 2 ? 1 : -1),
|
||||||
|
})), [graph, index])
|
||||||
|
const particles = useMemo(() => pairs.map((p, i) => ({ ...p, link: i })).filter(p => !p.hub).slice(0, MAX_PARTICLES), [pairs])
|
||||||
|
const degree = useMemo(() => {
|
||||||
|
const d = new Map<string, number>()
|
||||||
|
for (const l of graph.links) if (!l.hub) { d.set(l.source, (d.get(l.source) ?? 0) + 1); d.set(l.target, (d.get(l.target) ?? 0) + 1) }
|
||||||
|
return d
|
||||||
|
}, [graph])
|
||||||
|
const stars = useMemo(() => Array.from({ length: 70 }, (_, i) => {
|
||||||
|
const r = (k: number) => (Math.sin(i * 127.1 + k * 311.7) * 43758.5453) % 1
|
||||||
|
return { x: Math.abs(r(1)) * graph.width, y: Math.abs(r(2)) * graph.height, s: 0.4 + Math.abs(r(3)) * 1.1, d: Math.abs(r(4)) * 6 }
|
||||||
|
}), [graph])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
const hubOf = graph.nodes.map(n => index.get(`hub-${n.cluster}`)!)
|
||||||
|
const phase = graph.nodes.map((_, i) => i * 2.399)
|
||||||
|
const x = new Float64Array(graph.nodes.length)
|
||||||
|
const y = new Float64Array(graph.nodes.length)
|
||||||
|
const t0 = performance.now()
|
||||||
|
let frame = 0
|
||||||
|
const draw = (now: number) => {
|
||||||
|
const t = still ? 1e9 : now - t0
|
||||||
|
graph.nodes.forEach((n, i) => {
|
||||||
|
const hub = graph.nodes[hubOf[i]]
|
||||||
|
const grow = n.hub ? ease(t / 700) : ease((t - 250 - (i % 24) * 25) / 1100)
|
||||||
|
const drift = still ? 0 : (n.hub ? 0.4 : 1) * DRIFT
|
||||||
|
const k = t / 1000
|
||||||
|
x[i] = hub.x + (n.x - hub.x) * grow + Math.sin(k * 0.6 + phase[i]) * drift
|
||||||
|
y[i] = hub.y + (n.y - hub.y) * grow + Math.cos(k * 0.5 + phase[i] * 1.3) * drift
|
||||||
|
const el = nodeEls.current[i]
|
||||||
|
if (el) {
|
||||||
|
el.setAttribute('transform', `translate(${x[i].toFixed(1)} ${y[i].toFixed(1)})`)
|
||||||
|
el.style.opacity = n.hub ? '' : String(Math.min(1, grow * 1.4))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pairs.forEach((p, i) => {
|
||||||
|
const { cx, cy } = curve(x[p.a], y[p.a], x[p.b], y[p.b], p.bend)
|
||||||
|
linkEls.current[i]?.setAttribute('d', `M${x[p.a].toFixed(1)} ${y[p.a].toFixed(1)}Q${cx.toFixed(1)} ${cy.toFixed(1)} ${x[p.b].toFixed(1)} ${y[p.b].toFixed(1)}`)
|
||||||
|
})
|
||||||
|
particles.forEach((p, i) => {
|
||||||
|
const el = particleEls.current[i]
|
||||||
|
if (!el) return
|
||||||
|
if (still) { el.style.display = 'none'; return }
|
||||||
|
const u = ((t / 1000) * (0.18 + (i % 5) * 0.03) + i * 0.37) % 1
|
||||||
|
const { cx, cy } = curve(x[p.a], y[p.a], x[p.b], y[p.b], p.bend)
|
||||||
|
const v = 1 - u
|
||||||
|
el.setAttribute('cx', (v * v * x[p.a] + 2 * v * u * cx + u * u * x[p.b]).toFixed(1))
|
||||||
|
el.setAttribute('cy', (v * v * y[p.a] + 2 * v * u * cy + u * u * y[p.b]).toFixed(1))
|
||||||
|
el.style.opacity = String(Math.sin(u * Math.PI) * Math.min(1, t / 1600))
|
||||||
|
})
|
||||||
|
if (!still) frame = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
draw(t0)
|
||||||
|
if (!still) frame = requestAnimationFrame(draw)
|
||||||
|
return () => cancelAnimationFrame(frame)
|
||||||
|
}, [graph, index, pairs, particles])
|
||||||
|
|
||||||
|
const focus = hover ?? selected?.id ?? null
|
||||||
|
const neighbours = useMemo(() => {
|
||||||
|
if (!focus) return null
|
||||||
|
const set = new Set([focus])
|
||||||
|
for (const l of graph.links) {
|
||||||
|
if (l.source === focus) set.add(l.target)
|
||||||
|
if (l.target === focus) set.add(l.source)
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}, [focus, graph])
|
||||||
|
const lit = (p: { a: number; b: number }) => neighbours?.has(graph.nodes[p.a].id) && neighbours.has(graph.nodes[p.b].id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="graph-note">
|
||||||
|
<i className="live-dot" />
|
||||||
|
{selected
|
||||||
|
? <><b>{selected.label}</b>{!selected.hub && <span>{t(HUB_LABEL[selected.cluster])}</span>}<button type="button" aria-label={t('res.close')} onClick={() => setSelected(null)}><Icon name="close" /></button></>
|
||||||
|
: <><b>{t('prod.graphTitle')}</b><span>{t('prod.clickNode')}</span></>}
|
||||||
|
</div>
|
||||||
|
<svg className={`graph-svg${neighbours ? ' focused' : ''}`} viewBox={`0 0 ${graph.width} ${graph.height}`} role="img"
|
||||||
|
aria-label={t('prod.graphLabel', { nodes: graph.nodes.length - CLUSTERS.length })} onClick={() => setSelected(null)}>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="graph-glow"><stop offset="0" stopColor="#2e90fa" stopOpacity=".32" /><stop offset=".5" stopColor="#6941c6" stopOpacity=".08" /><stop offset="1" stopColor="#2e90fa" stopOpacity="0" /></radialGradient>
|
||||||
|
<radialGradient id="graph-core"><stop offset="0" stopColor="#fff" /><stop offset=".55" stopColor="#fff" stopOpacity=".9" /><stop offset="1" stopColor="#fff" stopOpacity=".2" /></radialGradient>
|
||||||
|
<filter id="graph-bloom" x="-200%" y="-200%" width="500%" height="500%"><feGaussianBlur stdDeviation="3" result="b" /><feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge></filter>
|
||||||
|
</defs>
|
||||||
|
<g className="graph-stars">
|
||||||
|
{stars.map((s, i) => <circle key={i} cx={s.x} cy={s.y} r={s.s} style={{ animationDelay: `${s.d}s` }} />)}
|
||||||
|
</g>
|
||||||
|
<circle className="graph-glow" cx={500} cy={320} r={320} fill="url(#graph-glow)" />
|
||||||
|
<ellipse className="graph-orbit slow" cx={500} cy={320} rx={430} ry={275} />
|
||||||
|
<ellipse className="graph-orbit" cx={500} cy={320} rx={300} ry={195} />
|
||||||
|
<g className="graph-links">
|
||||||
|
{pairs.map((p, i) => (
|
||||||
|
<path key={i} ref={el => { linkEls.current[i] = el }} style={{ ['--c' as string]: `var(--g-${graph.nodes[p.a].cluster})` }}
|
||||||
|
className={`${p.hub ? 'hub' : 'cross'}${lit(p) ? ' lit' : neighbours ? ' dim' : ''}`} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
<g className="graph-particles">
|
||||||
|
{particles.map((p, i) => (
|
||||||
|
<circle key={i} r={1.8} ref={el => { particleEls.current[i] = el }} className={`c-${graph.nodes[p.a].cluster}${neighbours && !lit(p) ? ' dim' : ''}`} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
{graph.nodes.map((n, i) => {
|
||||||
|
const r = n.hub ? 9 : n.cluster === 'schedules' ? 5.5 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7)
|
||||||
|
return (
|
||||||
|
<g key={n.id} ref={el => { nodeEls.current[i] = el }}
|
||||||
|
className={`graph-node c-${n.cluster}${n.hub ? ' hub' : ''}${neighbours && !neighbours.has(n.id) ? ' dim' : ''}${hover === n.id ? ' hover' : ''}${selected?.id === n.id ? ' sel' : ''}`}
|
||||||
|
onMouseEnter={() => setHover(n.id)} onMouseLeave={() => setHover(null)}
|
||||||
|
onClick={e => { e.stopPropagation(); setSelected(n) }}>
|
||||||
|
<circle className="hit" r={r + 8} />
|
||||||
|
{n.hub && <><circle className="ring" r={r + 4} /><circle className="ring late" r={r + 4} /><circle className="halo" r={26} /></>}
|
||||||
|
<circle className="core" r={r} filter="url(#graph-bloom)" />
|
||||||
|
{n.hub && <circle className="dot" r={r * 0.45} />}
|
||||||
|
<text y={-r - 8}>{n.label.length > 40 ? n.label.slice(0, 38) + '…' : n.label}</text>
|
||||||
|
<title>{n.label}</title>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<ul className="graph-legend">
|
||||||
|
{CLUSTERS.map(c => <li key={c}><i className={`node-dot ${c}`} />{t(HUB_LABEL[c])}</li>)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -132,10 +132,7 @@ function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () =
|
|||||||
<span title={describeRule(m.rule)}>{describeRule(m.rule)}</span>
|
<span title={describeRule(m.rule)}>{describeRule(m.rule)}</span>
|
||||||
{working
|
{working
|
||||||
? <span>{m.video ? t('res.videoProgress', { n: m.video.progress }) : t('res.working')}</span>
|
? <span>{m.video ? t('res.videoProgress', { n: m.video.progress }) : t('res.working')}</span>
|
||||||
: <div className="content-card-actions">
|
: m.retry && <div className="content-card-actions"><RetryButton material={m} className="text-btn" /></div>}
|
||||||
{m.retry && <RetryButton material={m} className="text-btn" />}
|
|
||||||
<button className="text-btn" onClick={onOpen}>{t('res.open')} <Icon name="arrow-right" /></button>
|
|
||||||
</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
.doc-action-buttons{display:flex;gap:7px;flex-wrap:wrap}
|
.doc-action-buttons{display:flex;gap:7px;flex-wrap:wrap}
|
||||||
.doc-action .error{margin:0;font-size:11px}
|
.doc-action .error{margin:0;font-size:11px}
|
||||||
.result-row b{overflow-wrap:anywhere}
|
.result-row b{overflow-wrap:anywhere}
|
||||||
|
.doc-title{display:block;max-width:100%;border:0;padding:0;background:none;text-align:left;font:inherit;font-weight:700;color:var(--ink);overflow-wrap:anywhere;cursor:pointer}
|
||||||
|
.doc-title:hover{color:var(--blue);text-decoration:underline}
|
||||||
|
.doc-title:disabled{cursor:progress;color:#8b97a9}
|
||||||
.drop-overlay{position:fixed;inset:0;z-index:50;display:grid;place-items:center;background:#142d5059;backdrop-filter:blur(2px);pointer-events:none}
|
.drop-overlay{position:fixed;inset:0;z-index:50;display:grid;place-items:center;background:#142d5059;backdrop-filter:blur(2px);pointer-events:none}
|
||||||
.drop-overlay>div{display:flex;flex-direction:column;align-items:center;gap:12px;padding:34px 46px;border:2px dashed var(--blue);border-radius:16px;background:white;color:var(--blue);font-size:14px}
|
.drop-overlay>div{display:flex;flex-direction:column;align-items:center;gap:12px;padding:34px 46px;border:2px dashed var(--blue);border-radius:16px;background:white;color:var(--blue);font-size:14px}
|
||||||
.drop-overlay svg{width:34px;height:34px}
|
.drop-overlay svg{width:34px;height:34px}
|
||||||
@@ -62,6 +65,11 @@ textarea{resize:vertical;line-height:1.6}
|
|||||||
.content-title:disabled{cursor:default;color:#8b97a9}
|
.content-title:disabled{cursor:default;color:#8b97a9}
|
||||||
.content-card-foot>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.content-card-foot>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.content-card-actions{display:flex;gap:12px;flex-shrink:0}
|
.content-card-actions{display:flex;gap:12px;flex-shrink:0}
|
||||||
|
/* The whole card opens the result: the title button's hit area stretches over it, Retry stays on top */
|
||||||
|
.content-card{position:relative}
|
||||||
|
.content-title:not(:disabled)::after{content:"";position:absolute;inset:0;border-radius:inherit}
|
||||||
|
.content-card:has(.content-title:not(:disabled)):hover .content-title{color:var(--blue)}
|
||||||
|
.content-card-actions{position:relative;z-index:1}
|
||||||
.research-row .status{margin-left:0}
|
.research-row .status{margin-left:0}
|
||||||
.research-row h3 a{color:var(--ink)}
|
.research-row h3 a{color:var(--ink)}
|
||||||
.research-row h3 a:hover{color:var(--blue)}
|
.research-row h3 a:hover{color:var(--blue)}
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -79,7 +88,12 @@ 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}
|
||||||
|
.studio-link{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:#fff;color:#344054;font-size:12.5px;font-weight:600;white-space:nowrap;text-decoration:none;transition:background .15s,border-color .15s}
|
||||||
|
.studio-link:hover{background:#f5f8ff;border-color:#d0d5dd;color:var(--blue-dark)}
|
||||||
|
.studio-link svg{width:14px;height:14px}
|
||||||
.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}
|
||||||
@@ -184,7 +198,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
|||||||
.help-step h3{font-size:14.5px}
|
.help-step h3{font-size:14.5px}
|
||||||
.help-step p{font-size:13px}
|
.help-step p{font-size:13px}
|
||||||
.toast{background:var(--ink);border-radius:14px;font-size:13.5px;box-shadow:0 18px 40px -16px rgba(16,24,40,.6)}
|
.toast{background:var(--ink);border-radius:14px;font-size:13.5px;box-shadow:0 18px 40px -16px rgba(16,24,40,.6)}
|
||||||
.toast.error{background:#991b1b}
|
.toast.error{background:#991b1b;color:#fff}
|
||||||
.progress-orb{background:var(--light);color:var(--blue);border-radius:20px}
|
.progress-orb{background:var(--light);color:var(--blue);border-radius:20px}
|
||||||
|
|
||||||
/* Sign in: bright blue story panel with dot grid and light glow */
|
/* Sign in: bright blue story panel with dot grid and light glow */
|
||||||
@@ -227,15 +241,27 @@ 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}
|
||||||
.page-head h1{font-size:30px}
|
.page-head h1{font-size:30px}
|
||||||
.page-head p{font-size:14px}
|
.page-head p{font-size:14px}
|
||||||
.metric strong{font-size:32px}
|
.metric strong{font-size:32px}
|
||||||
.login-story{padding:28px 20px}
|
/* Phone: a short story header so the sign-in form shows without scrolling past it */
|
||||||
|
/* Phone: one blue background behind both the story and the form, dot grid included */
|
||||||
|
.login-layout{background:radial-gradient(rgba(255,255,255,.16) 1px,transparent 1.2px) 0 0/22px 22px,radial-gradient(circle 360px at 100% 0,rgba(255,255,255,.28),rgba(255,255,255,0)),linear-gradient(170deg,#175cd3,#1570ef 45%,#2e90fa)}
|
||||||
|
.login-story,.login-panel{background:transparent}
|
||||||
|
.login-story::before,.login-story::after{display:none}
|
||||||
|
.login-story{padding:20px 16px 24px}
|
||||||
|
.login-copy{padding:20px 0 0}
|
||||||
|
.login-copy h1{font-size:28px;line-height:1.1;margin:14px 0 10px}
|
||||||
|
.login-copy>p{font-size:14px;line-height:1.5}
|
||||||
|
.eyebrow{font-size:10.5px;padding:5px 12px 5px 6px}
|
||||||
|
.login-benefits,.login-footer{display:none}
|
||||||
|
.login-panel{padding:24px 16px 32px}
|
||||||
|
.login-form-wrap{width:100%}
|
||||||
|
.login-form-wrap :is(.cl-rootBox,.cl-cardBox){width:100%;max-width:100%}
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion:reduce){
|
@media (prefers-reduced-motion:reduce){
|
||||||
.btn,.content-card,.sidebar nav :is(button,a){transition:none}
|
.btn,.content-card,.sidebar nav :is(button,a){transition:none}
|
||||||
@@ -258,3 +284,99 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
|||||||
.app main{animation:page-in .28s ease-out}
|
.app main{animation:page-in .28s ease-out}
|
||||||
@keyframes page-in{from{opacity:0;transform:translateY(6px)}}
|
@keyframes page-in{from{opacity:0;transform:translateY(6px)}}
|
||||||
@media (prefers-reduced-motion:reduce){.skel,.app main{animation:none}}
|
@media (prefers-reduced-motion:reduce){.skel,.app main{animation:none}}
|
||||||
|
|
||||||
|
/* Sidebar groups: Dashboard, then Settings */
|
||||||
|
.sidebar nav .nav-label{margin:0 12px 6px}
|
||||||
|
.sidebar nav .nav-label~.nav-label{margin-top:18px}
|
||||||
|
@media(max-width:700px){.sidebar nav .nav-label{display:none}}
|
||||||
|
|
||||||
|
/* Production: metric row with the in-progress count as the hero, then the knowledge graph */
|
||||||
|
.prod-metrics{grid-template-columns:repeat(5,minmax(0,1fr))}
|
||||||
|
.prod-metrics .metric>span{display:flex;align-items:center;gap:8px}
|
||||||
|
.live-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#4ade80;box-shadow:0 0 0 0 rgba(74,222,128,.6);animation:live-pulse 1.8s ease-out infinite;flex-shrink:0}
|
||||||
|
@keyframes live-pulse{70%{box-shadow:0 0 0 8px rgba(74,222,128,0)}100%{box-shadow:0 0 0 0 rgba(74,222,128,0)}}
|
||||||
|
|
||||||
|
.graph-stage{--g-sources:#6ce9a6;--g-made:#fde272;--g-working:#c3b5fd;--g-research:#84caff;--g-schedules:#fdb022;
|
||||||
|
position:relative;min-height:640px;border-radius:22px;overflow:hidden;color:#e4e7ec;
|
||||||
|
background:radial-gradient(ellipse at 45% 50%,#0f2a4d 0%,#0a1628 55%,#060d1a 100%);box-shadow:0 24px 60px -28px rgba(16,24,40,.6)}
|
||||||
|
.graph-svg{position:absolute;inset:0;width:calc(100% - 300px);height:100%;display:block}
|
||||||
|
.graph-loading{position:absolute;inset:0 300px 0 0;display:grid;place-items:center;color:#98a2b3;font-size:13px}
|
||||||
|
.graph-stars circle{fill:#fff;opacity:.25;animation:twinkle 4s ease-in-out infinite}
|
||||||
|
@keyframes twinkle{50%{opacity:.04}}
|
||||||
|
.graph-glow{transform-origin:500px 320px;animation:glow-breathe 7s ease-in-out infinite}
|
||||||
|
@keyframes glow-breathe{50%{transform:scale(1.08);opacity:.75}}
|
||||||
|
.graph-orbit{fill:none;stroke:rgba(132,202,255,.12);stroke-dasharray:2 7;transform-origin:500px 320px;animation:orbit-spin 90s linear infinite}
|
||||||
|
.graph-orbit.slow{stroke:rgba(132,202,255,.07);animation-duration:160s;animation-direction:reverse}
|
||||||
|
@keyframes orbit-spin{to{transform:rotate(360deg)}}
|
||||||
|
.graph-links path{fill:none;transition:opacity .3s,stroke .3s,stroke-width .3s}
|
||||||
|
.graph-links path.hub{stroke:var(--c);stroke-opacity:.16;stroke-width:.8}
|
||||||
|
.graph-links path.cross{stroke:rgba(132,202,255,.22);stroke-width:.9;stroke-dasharray:4 5;animation:dash-flow 3s linear infinite}
|
||||||
|
@keyframes dash-flow{to{stroke-dashoffset:-18}}
|
||||||
|
.graph-links path.lit{stroke:var(--c);stroke-opacity:.9;stroke-width:1.6}
|
||||||
|
.graph-links path.dim{opacity:.12}
|
||||||
|
.graph-particles circle{fill:var(--c);filter:drop-shadow(0 0 3px var(--c));opacity:0;transition:opacity .3s}
|
||||||
|
.graph-particles circle.dim{visibility:hidden}
|
||||||
|
.graph-node{cursor:pointer;transition:opacity .3s}
|
||||||
|
.graph-node.dim{opacity:.2!important}
|
||||||
|
.graph-node .hit{fill:transparent}
|
||||||
|
.graph-node .core{fill:var(--c);transition:transform .25s cubic-bezier(.3,1.6,.5,1)}
|
||||||
|
.graph-node:is(.hover,.sel) .core{transform:scale(1.7)}
|
||||||
|
.graph-node.hub .core{fill:var(--c);opacity:.9}
|
||||||
|
.graph-node.hub:is(.hover,.sel) .core{transform:scale(1.25)}
|
||||||
|
.graph-node .dot{fill:url(#graph-core);pointer-events:none}
|
||||||
|
.graph-node .halo{fill:var(--c);opacity:.1;animation:halo-breathe 4s ease-in-out infinite}
|
||||||
|
@keyframes halo-breathe{50%{opacity:.2;transform:scale(1.15)}}
|
||||||
|
.graph-node .ring{fill:none;stroke:var(--c);stroke-width:1.2;opacity:0;animation:ring-out 3.2s ease-out infinite}
|
||||||
|
.graph-node .ring.late{animation-delay:1.6s}
|
||||||
|
@keyframes ring-out{0%{opacity:.7;transform:scale(1)}100%{opacity:0;transform:scale(3.2)}}
|
||||||
|
.graph-node :is(.core,.halo,.ring){transform-box:fill-box;transform-origin:center}
|
||||||
|
.graph-node text{fill:#fff;font-size:13px;font-weight:700;letter-spacing:.01em;text-anchor:middle;paint-order:stroke;stroke:#08142a;stroke-width:4px;stroke-linejoin:round;pointer-events:none}
|
||||||
|
.graph-node:not(.hub) text{font-size:11.5px;font-weight:600;opacity:0;transform:translateY(4px);transition:opacity .2s,transform .2s}
|
||||||
|
.graph-node:not(.hub):is(.hover,.sel) text{opacity:1;transform:none}
|
||||||
|
.graph-node.c-working:not(.hub) .core{animation:node-pulse 1.6s ease-in-out infinite}
|
||||||
|
@keyframes node-pulse{50%{opacity:.4}}
|
||||||
|
.c-sources{--c:var(--g-sources)}.c-made{--c:var(--g-made)}.c-working{--c:var(--g-working)}.c-research{--c:var(--g-research)}.c-schedules{--c:var(--g-schedules)}
|
||||||
|
|
||||||
|
.graph-note{position:absolute;top:16px;left:16px;z-index:2;display:flex;align-items:center;gap:8px;max-width:min(460px,calc(100% - 340px));
|
||||||
|
padding:8px 14px;border-radius:999px;background:rgba(10,22,40,.8);border:1px solid rgba(255,255,255,.12);font-size:12.5px;backdrop-filter:blur(8px)}
|
||||||
|
.graph-note b{color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.graph-note span{color:#98a2b3;white-space:nowrap}
|
||||||
|
.graph-note button{display:grid;place-items:center;border:0;background:none;color:#98a2b3;padding:0;cursor:pointer}
|
||||||
|
.graph-note svg{width:14px;height:14px}
|
||||||
|
.graph-legend{position:absolute;left:16px;bottom:16px;z-index:2;display:flex;flex-wrap:wrap;gap:6px 14px;list-style:none;margin:0;padding:0;font-size:11.5px;color:#98a2b3}
|
||||||
|
.graph-legend li{display:flex;align-items:center;gap:6px}
|
||||||
|
.node-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--c);box-shadow:0 0 6px var(--c);flex-shrink:0}
|
||||||
|
.node-dot.sources{--c:var(--g-sources)}.node-dot.made{--c:var(--g-made)}.node-dot.working{--c:var(--g-working)}.node-dot.research{--c:var(--g-research)}.node-dot.schedules{--c:var(--g-schedules)}
|
||||||
|
|
||||||
|
.graph-panel{position:absolute;top:16px;right:16px;bottom:16px;width:284px;z-index:2;overflow:auto;padding:14px;border-radius:18px;
|
||||||
|
background:rgba(10,22,40,.72);border:1px solid rgba(255,255,255,.1);backdrop-filter:blur(10px)}
|
||||||
|
.graph-panel h3{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:#98a2b3;margin:18px 4px 8px;font-weight:650}
|
||||||
|
.graph-stats{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||||
|
.graph-stats>div{padding:12px;border-radius:12px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08)}
|
||||||
|
.graph-stats small{display:block;font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:#98a2b3}
|
||||||
|
.graph-stats b{display:block;font-size:28px;font-weight:650;color:#fff;margin:6px 0 2px;font-variant-numeric:tabular-nums}
|
||||||
|
.graph-stats span{font-size:11px;color:#98a2b3}
|
||||||
|
.graph-list{list-style:none;margin:0;padding:0;display:grid}
|
||||||
|
.graph-list li{display:grid;grid-template-columns:auto 1fr auto;gap:4px 10px;align-items:start;padding:9px 4px;border-bottom:1px solid rgba(255,255,255,.06);font-size:12.5px;line-height:1.45}
|
||||||
|
.graph-list li:last-child{border-bottom:0}
|
||||||
|
.graph-list time{font:600 11px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;color:#84caff;white-space:nowrap}
|
||||||
|
.graph-list span{color:#e4e7ec;min-width:0;overflow-wrap:anywhere}
|
||||||
|
.graph-list em{font-style:normal;font-size:10.5px;color:#98a2b3;border:1px solid rgba(255,255,255,.14);border-radius:999px;padding:1px 8px;white-space:nowrap}
|
||||||
|
.graph-list em.on{color:#6ce9a6;border-color:rgba(108,233,166,.4)}
|
||||||
|
.graph-list.now li{align-items:center}
|
||||||
|
.graph-list.now em{border:0;padding:0}
|
||||||
|
.graph-empty{font-size:12.5px;color:#98a2b3;margin:0 4px}
|
||||||
|
.graph-empty a,.graph-more{color:#84caff}
|
||||||
|
.graph-more{display:block;font-size:12px;margin:8px 4px 0}
|
||||||
|
|
||||||
|
@media(max-width:1120px){.prod-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}.prod-metrics .metric:first-child{grid-column:1/-1}}
|
||||||
|
@media(max-width:900px){
|
||||||
|
.graph-stage{min-height:0;display:flex;flex-direction:column}
|
||||||
|
.graph-svg{position:relative;width:100%;height:auto;aspect-ratio:1000/640}
|
||||||
|
.graph-loading{position:relative;inset:auto;min-height:280px}
|
||||||
|
.graph-note{position:relative;top:auto;left:auto;margin:16px 16px 0;max-width:none;align-self:flex-start}
|
||||||
|
.graph-legend{position:relative;left:auto;bottom:auto;margin:0 16px 12px}
|
||||||
|
.graph-panel{position:relative;inset:auto;width:auto;margin:0 16px 16px}
|
||||||
|
}
|
||||||
|
@media(max-width:700px){.prod-metrics{grid-template-columns:1fr 1fr}.prod-metrics .metric:first-child{grid-column:1/-1}}
|
||||||
|
@media (prefers-reduced-motion:reduce){.live-dot,.graph-stars circle,.graph-glow,.graph-orbit,.graph-links path,.graph-node *{animation:none!important}}
|
||||||
|
|||||||
Reference in New Issue
Block a user