Compare commits

10 Commits

Author SHA1 Message Date
Iqbal
68050d8d4b Pin production Clerk key and Studio URL for local builds
A build without them used learning-lab's dev Clerk key, so production
users signed in to the wrong instance and saw no data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:56:56 +07:00
Iqbal
9f9fc93dfe Retry failed videos with a new run and explain documents that fail after their build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:48:00 +07:00
Iqbal
8a02f68589 Link to Learning Lab from the topbar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:48:00 +07:00
Iqbal
ddbdbc7d44 Add a Production dashboard with an animated knowledge graph
The sidebar now groups Production under Dashboard and the four existing
pages under Settings, and Production is the landing page. It shows content
in progress, material, content made, approved and LMS-ready counts, plus a
knowledge graph linking Brain documents, results, found sources and
schedules, with the schedule and what is being made right now beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:57:10 +07:00
Iqbal
6d4850f4ce Switch organizations from the Learning space card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:59:22 +07:00
Iqbal
51700bc0f3 Deploy to Netlify with the engine behind a same-origin /api proxy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:56:37 +07:00
Iqbal
42464d88b3 Show the workspace credit balance in the topbar
Read from GET /organizations/me, refreshed after every action and every
30 seconds, since agents and schedules spend credits in the background.
The date is dropped from the topbar, and its items wrap on phones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:57:01 +07:00
Iqbal
7c17191b8b Open Brain documents from their title
Clicking a document's title fetches the original file from the engine:
viewable formats open in a new tab, the rest (or a blocked tab) download
under the original name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:48:07 +07:00
Iqbal
c294e91253 Open a result by clicking anywhere on its card
Drop the Open button: the title's hit area now covers the whole card,
with Retry kept on top so it doesn't open the preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:38:52 +07:00
Iqbal
a4189f0d2a Fit the sign-in page on phones with a full blue background
Shorten the story header so the form shows without scrolling, keep the
Clerk card inside the screen, and run one blue background behind the
whole page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:33:51 +07:00
16 changed files with 817 additions and 46 deletions

5
.env.production Normal file
View 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
View 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"

View File

@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthGate } from './auth/AuthGate'
import { AppLayout } from './components/AppLayout'
import { BrainPage } from './pages/Brain'
import { ProductionPage } from './pages/Production'
import { InstructionPage } from './pages/Instruction'
import { SchedulePage } from './pages/Schedule'
import { ResultsPage } from './pages/Results'
@@ -11,6 +12,7 @@ export default function App() {
<AuthGate>
<Routes>
<Route element={<AppLayout />}>
<Route path="/production" element={<ProductionPage />} />
<Route path="/brain" element={<BrainPage />} />
<Route path="/instruction" element={<InstructionPage />} />
<Route path="/schedule" element={<SchedulePage />} />
@@ -21,7 +23,7 @@ export default function App() {
<Route path="/perintah" element={<Navigate replace to="/instruction" />} />
<Route path="/jadwal" element={<Navigate replace to="/schedule" />} />
<Route path="/hasil" element={<Navigate replace to="/results" />} />
<Route path="*" element={<Navigate replace to="/brain" />} />
<Route path="*" element={<Navigate replace to="/production" />} />
</Routes>
</AuthGate>
)

View File

@@ -49,6 +49,7 @@ function asSource(d: EngineDocument): Source {
export const listDocuments = async () => (await request<EngineDocument[]>('/upload/list')).map(asSource)
export const documentStatus = (id: number) => request<EngineDocument>(`/upload/${id}`)
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>> {
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) ──
export interface ComposeSet { id: number; name: string }

View File

@@ -1,17 +1,30 @@
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 { 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'
// Express Mode has four doors: feed the Brain, give an instruction, watch the schedule, collect results.
export const NAV: { to: string; label: Key; icon: IconName }[] = [
{ to: '/brain', label: 'nav.brain', icon: 'brain' },
{ to: '/instruction', label: 'nav.instruction', icon: 'spark' },
{ to: '/schedule', label: 'nav.schedule', icon: 'calendar' },
{ to: '/results', label: 'nav.results', icon: 'layers' },
// Learning Studio (learning-lab/frontend); both apps share one Clerk instance, so the user stays signed in.
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
// The Production dashboard shows what Express is making; the four settings doors run it:
// feed the Brain, give an instruction, watch the schedule, collect results.
export const NAV_GROUPS: { label: Key; items: { to: string; label: Key; icon: IconName }[] }[] = [
{ label: 'nav.dashboard', items: [{ to: '/production', label: 'nav.production', icon: 'chart' }] },
{
label: 'nav.settings',
items: [
{ to: '/brain', label: 'nav.brain', icon: 'brain' },
{ to: '/instruction', label: 'nav.instruction', icon: 'spark' },
{ to: '/schedule', label: 'nav.schedule', icon: 'calendar' },
{ to: '/results', label: 'nav.results', icon: 'layers' },
],
},
]
export const NAV = NAV_GROUPS.flatMap(g => g.items)
export function LanguageSwitch() {
const lang = useLang()
@@ -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() {
useLang()
const [help, setHelp] = useState(false)
const { user } = useUser()
const { organization } = useOrganization()
const { signOut } = useClerk()
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">
<aside className="sidebar">
<NavLink to="/brain"><Brand /></NavLink>
<div className="workspace">
<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>
<NavLink to="/production"><Brand /></NavLink>
<OrgSwitcher />
<nav aria-label={t('layout.mainNav')}>
{NAV.map(n => (
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
<Icon name={n.icon} />{t(n.label)}
</NavLink>
))}
{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)}>
<Icon name={n.icon} />{t(n.label)}
</NavLink>
)),
])}
</nav>
<div className="sidebar-help">
<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="topbar-right">
<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 />
</div>
</header>

View File

@@ -12,16 +12,20 @@ const paths: Record<string, string> = {
upload: '<path d="M12 16V4m-5 5 5-5 5 5M4 16v5h16v-5"/>',
file: '<path d="M5 3h9l5 5v13H5zM14 3v6h5M8 13h8M8 17h6"/>',
'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"/>',
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"/>',
check: '<path d="m5 12 4 4L19 6"/>',
logout: '<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>',
'chevron-updown': '<path d="m8 9 4-4 4 4M8 15l4 4 4-4"/>',
logout:'<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>',
plus: '<path d="M12 4v16M4 12h16"/>',
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"/>',
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"/>',
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"/>',
}

38
src/express/graph.test.ts Normal file
View 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
View 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
})
}
}

View File

@@ -5,7 +5,7 @@
import { request } from '../api/client'
import { t } from '../i18n'
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'
@@ -39,8 +39,8 @@ export interface Material {
sources: string[]
createdAt: string
rule: Rule
/** The plan item to produce again, when the engine allows another attempt. */
retry?: { planId: number; itemId: number }
/** How to produce it again, when the engine allows another attempt: the plan item, or (video) a new run. */
retry?: { planId: number; itemId: number } | { run: true }
video?: { url: string | null; progress: number; credits: number | null }
}
@@ -147,11 +147,15 @@ function videoStatus(s: string): MaterialStatus {
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`)
// 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 => {
const status = videoStatus(d.status)
return {
retry: status === 'failed' && active && d.id === newest ? { run: true } as const : undefined,
id: d.id, agentId: agent.id, kind: 'video', status, revision: 0,
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,
@@ -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.
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 = (m: Material) => retryItem(m.agentId, m.retry!.planId, m.retry!.itemId)
export const retryMaterial = ({ agentId, retry }: Material) =>
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') {
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 } })

View File

@@ -11,11 +11,16 @@ const en = {
'nav.instruction': 'Instruction',
'nav.schedule': 'Schedule',
'nav.results': 'Results',
'nav.workspace': 'WORKSPACE',
'nav.dashboard': 'Dashboard',
'nav.settings': 'Settings',
'nav.production': 'Production',
'layout.learningSpace': 'Learning space',
'layout.switchOrg': 'Switch organization',
'layout.breadcrumb': 'Workspace',
'layout.signOut': 'Sign out',
'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.footer': 'A workspace for knowledge that keeps growing.',
'layout.mainNav': 'Main navigation',
@@ -42,6 +47,39 @@ const en = {
'common.retry': 'Try again',
'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.title': 'Brain',
'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.failed': 'Could not add this to the Brain.',
'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.confirmCreditsFallback': 'credits',
'brain.addToBrain': 'Add to Brain',
'brain.delete': 'Delete {name}',
'brain.open': 'Open {name}',
'brain.deleteConfirm': 'Delete “{name}” from the Brain?',
// Instruction
@@ -228,7 +268,6 @@ const en = {
'res.sourceCount': '{n} sources',
'res.sourceOne': '1 source',
'res.working': 'Being created…',
'res.open': 'Open',
'res.close': 'Close',
'res.reviewNotice': 'AI-generated draft. Check it, then approve it or reject it.',
'res.noContent': 'No content to show',
@@ -388,15 +427,51 @@ const en = {
export type Key = keyof typeof en
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.instruction': 'Perintah',
'nav.schedule': 'Jadwal',
'nav.results': 'Hasil',
'nav.workspace': 'RUANG KERJA',
'nav.dashboard': 'Dasbor',
'nav.settings': 'Pengaturan',
'nav.production': 'Produksi',
'layout.learningSpace': 'Ruang belajar',
'layout.switchOrg': 'Ganti organisasi',
'layout.breadcrumb': 'Ruang kerja',
'layout.signOut': 'Keluar',
'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.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.',
'layout.mainNav': 'Navigasi utama',
@@ -457,10 +532,12 @@ const id: Record<Key, string> = {
'brain.needsCreditsNeed': ' (butuh {credits})',
'brain.failed': 'Gagal memasukkan ke Otak.',
'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.confirmCreditsFallback': 'kredit',
'brain.addToBrain': 'Masukkan ke Otak',
'brain.delete': 'Hapus {name}',
'brain.open': 'Buka {name}',
'brain.deleteConfirm': 'Hapus “{name}” dari Otak?',
'ins.title': 'Perintah',
@@ -602,7 +679,6 @@ const id: Record<Key, string> = {
'res.sourceCount': '{n} sumber',
'res.sourceOne': '1 sumber',
'res.working': 'Sedang dibuat…',
'res.open': 'Buka',
'res.close': 'Tutup',
'res.reviewNotice': 'Draft buatan AI. Periksa dulu, lalu setujui atau tolak.',
'res.noContent': 'Tidak ada konten untuk ditampilkan',

View File

@@ -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) },

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
answerVersion, buildKnowledge, deleteDocument, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
type Source,
} from '../api/engine'
import { Icon } from '../components/Icon'
@@ -12,6 +12,7 @@ import { locale, t } from '../i18n'
type Phase = 'ready' | 'working' | 'answer' | 'failed'
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.knowledge === 'ready') return 'ready'
if (s.knowledge === 'building' || s.status !== 'completed') return 'working'
@@ -154,7 +155,7 @@ function DocumentRow({ doc }: { doc: Source }) {
<div className="result-row">
<span className={`file-icon ${doc.format.toLowerCase()}`}>{doc.format.slice(0, 4)}</span>
<div style={{ minWidth: 0, flex: 1 }}>
<b>{doc.title}</b>
<OpenButton doc={doc} />
<div><small>{new Date(doc.createdAt).toLocaleDateString(locale())}</small></div>
{phase === 'working' && <Progress 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 }) {
const label = {
ready: t('brain.statusReady'),
@@ -216,6 +247,10 @@ function KnowledgeAction({ doc }: { doc: Source }) {
text = t('brain.needsCredits', { need: credits ? t('brain.needsCreditsNeed', { credits }) : '' })
actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('common.retry')}</button>
break
case 'ready':
// Processing failed after the build: building again is refused, only removing it unblocks schedules.
text = t('brain.failedAfterBuild')
break
case '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>

244
src/pages/Production.tsx Normal file
View 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>
</>
)
}

View File

@@ -132,10 +132,7 @@ function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () =
<span title={describeRule(m.rule)}>{describeRule(m.rule)}</span>
{working
? <span>{m.video ? t('res.videoProgress', { n: m.video.progress }) : t('res.working')}</span>
: <div className="content-card-actions">
{m.retry && <RetryButton material={m} className="text-btn" />}
<button className="text-btn" onClick={onOpen}>{t('res.open')} <Icon name="arrow-right" /></button>
</div>}
: m.retry && <div className="content-card-actions"><RetryButton material={m} className="text-btn" /></div>}
</div>
</div>
</article>

View File

@@ -10,6 +10,9 @@
.doc-action-buttons{display:flex;gap:7px;flex-wrap:wrap}
.doc-action .error{margin:0;font-size:11px}
.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>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}
@@ -62,6 +65,11 @@ textarea{resize:vertical;line-height:1.6}
.content-title:disabled{cursor:default;color:#8b97a9}
.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}
/* 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 h3 a{color:var(--ink)}
.research-row h3 a:hover{color:var(--blue)}

View File

@@ -54,6 +54,15 @@ input::placeholder,textarea::placeholder{color:#98a2b3}
.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 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}
.nav-label{color:var(--muted);font-size:11px;letter-spacing:.08em;text-transform:uppercase;margin:0 12px 10px}
.sidebar nav{gap:4px}
@@ -79,7 +88,12 @@ 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}
.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}
.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}
@@ -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 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.error{background:#991b1b}
.toast.error{background:#991b1b;color:#fff}
.progress-orb{background:var(--light);color:var(--blue);border-radius:20px}
/* 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}
.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}
.page-head h1{font-size:30px}
.page-head p{font-size:14px}
.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){
.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}
@keyframes page-in{from{opacity:0;transform:translateY(6px)}}
@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}}