Turn Production into a welcome dashboard with content activity

Greet the user by name, drop the development-phase strip, move three metric
cards (material in the Brain, content made, uploaded to the LMS) below the
knowledge graph, and add a content activity section with the latest and
in-progress results as Results cards that open their preview in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-27 23:22:57 +07:00
parent 3b56c27210
commit 6f3a5234b4
7 changed files with 125 additions and 56 deletions

View File

@@ -174,11 +174,6 @@ export function AppLayout() {
<LanguageSwitch />
</div>
</header>
<div className="demo-strip">
<Icon name="info" />
<span>{t('layout.devStrip')}</span>
<span className="demo-strip-tag">DEV</span>
</div>
{/* Keyed by route so each page plays the fade-in from theme.css. */}
<main tabIndex={-1} key={pathname}><Outlet /></main>
<footer className="app-footer">Learning Lab Express <span>by Bawana</span><span>{t('layout.footer')}</span></footer>

View File

@@ -1,10 +1,10 @@
import type { ReactNode } from 'react'
import { Icon, type IconName } from './Icon'
export function PageHead({ title, description, actions }: { title: string; description: string; actions?: ReactNode }) {
export function PageHead({ title, description, actions, eyebrow }: { title: ReactNode; description: string; actions?: ReactNode; eyebrow?: string }) {
return (
<div className="page-head">
<div><h1>{title}</h1><p>{description}</p></div>
<div>{eyebrow && <small className="page-eyebrow">{eyebrow}</small>}<h1>{title}</h1><p>{description}</p></div>
{actions && <div className="head-actions">{actions}</div>}
</div>
)

View File

@@ -21,7 +21,6 @@ const en = {
'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',
'layout.language': 'Language',
@@ -49,17 +48,27 @@ const en = {
// 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.description': 'Connected knowledge. Learning that keeps growing.',
'prod.unitMaterial': 'materials',
'prod.unitMaterialOne': 'material',
'prod.unitContent': 'content',
'prod.metricMaterial': 'Total material in the Brain',
'prod.metricMaterialSub': 'Your team’s knowledge sources',
'prod.metricMade': 'All content made',
'prod.metricMadeSub': 'Every piece of content created',
'prod.metricLms': 'Uploaded to the LMS',
'prod.metricLmsSub': 'Upload status in this demo',
'prod.activityTitle': 'Content activity',
'prod.activityDescription': 'From knowledge in the Brain to new learning.',
'prod.activityAll': 'See all content',
'prod.activityLatest': 'Latest content',
'prod.activityWorking': 'Content in progress',
'prod.activityNoneLatest': 'No content has been made yet.',
'prod.activityNoneWorking': 'Nothing is being made right now.',
'prod.eyebrow': 'Workspace overview',
'prod.welcome': 'Welcome',
'prod.welcomeName': 'Welcome, {name}',
'prod.newContent': 'Create new content',
'prod.graphTitle': 'Knowledge graph',
'prod.clickNode': 'Click a node',
'prod.loadingGraph': 'Drawing the knowledge graph…',
@@ -447,17 +456,27 @@ 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.description': 'Pengetahuan yang terhubung. Pembelajaran yang terus tumbuh.',
'prod.unitMaterial': 'materi',
'prod.unitMaterialOne': 'materi',
'prod.unitContent': 'konten',
'prod.metricMaterial': 'Total materi di Brain',
'prod.metricMaterialSub': 'Sumber pengetahuan tim Anda',
'prod.metricMade': 'Semua konten dibuat',
'prod.metricMadeSub': 'Seluruh hasil pembuatan konten',
'prod.metricLms': 'Sudah diupload ke LMS',
'prod.metricLmsSub': 'Status upload pada demo ini',
'prod.activityTitle': 'Aktivitas konten',
'prod.activityDescription': 'Dari pengetahuan di Brain menjadi pembelajaran baru.',
'prod.activityAll': 'Lihat semua hasil konten',
'prod.activityLatest': 'Konten terbaru',
'prod.activityWorking': 'Konten in progress',
'prod.activityNoneLatest': 'Belum ada konten yang dibuat.',
'prod.activityNoneWorking': 'Tidak ada konten yang sedang dibuat.',
'prod.eyebrow': 'Workspace overview',
'prod.welcome': 'Selamat datang',
'prod.welcomeName': 'Selamat datang, {name}',
'prod.newContent': 'Buat konten baru',
'prod.graphTitle': 'Knowledge graph',
'prod.clickNode': 'Klik sebuah node',
'prod.loadingGraph': 'Menggambar knowledge graph…',
@@ -491,7 +510,6 @@ const id: Record<Key, string> = {
'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',
'layout.language': 'Bahasa',

View File

@@ -125,7 +125,8 @@ export function InstructionPage() {
}
}
setSaving(false)
await queryClient.invalidateQueries({ queryKey: ['schedules'] })
// A new schedule may start producing right away; the dashboard shows it under content in progress.
await Promise.all([queryClient.invalidateQueries({ queryKey: ['schedules'] }), queryClient.invalidateQueries({ queryKey: ['results'] })])
const ok = done.filter(o => o.ok).length
const report = inLang(replyLang, () => (ok === done.length ? t('agent.done', { n: ok }) : t('agent.partial', { ok, n: done.length })))
setMessages(ms => [...ms, { role: 'agent', text: report, outcomes: done, typed: true }])

View File

@@ -1,14 +1,16 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useUser } from '@clerk/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 { PageHead, Skeleton, SkeletonCards } 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 { isWorking, listResults, materialKey, titleOf, type Material } from '../express/results'
import { listSchedules } from '../express/schedules'
import { locale, t, type Key } from '../i18n'
import { ContentCard, Preview } from './Results'
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',
@@ -19,21 +21,23 @@ const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'shor
// 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 { user } = useUser()
// A short greeting: Clerk's first name can hold more than one word.
const name = user?.firstName?.trim().split(/\s+/)[0]
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),
// Polled more often than on Results so a run that just started shows up under content in progress.
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 20_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 lms = materials.filter(m => m.status === 'accepted' && m.kind === 'scorm')
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]))
@@ -41,21 +45,18 @@ export function ProductionPage() {
[docs.data, results.data, schedules.data],
)
const num = (n: number, ready: boolean) => (ready ? <strong>{n.toLocaleString(locale())}</strong> : <Skeleton className="skel-num" />)
const num = (n: number, ready: boolean, unit: Key) => (ready
? <strong>{n.toLocaleString(locale())} <em>{t(unit, { n })}</em></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>
<PageHead eyebrow={t('prod.eyebrow')} description={t('prod.description')}
title={<>{name ? t('prod.welcomeName', { name }) : t('prod.welcome')} <span className="welcome-spark">✦</span></>}
actions={<>
<Link className="btn" to="/upload"><Icon name="upload" />{t('nav.brain')}</Link>
<Link className="btn primary" to="/brain"><Icon name="spark" />{t('prod.newContent')}</Link>
</>} />
<section className="graph-stage" aria-label={t('prod.graphTitle')}>
{graph
? <KnowledgeGraph graph={graph} />
@@ -91,10 +92,54 @@ export function ProductionPage() {
{working.length > 6 && <Link className="graph-more" to="/results">{t('prod.more', { n: working.length - 6 })}</Link>}
</aside>
</section>
<div className="metrics prod-metrics">
<div className="metric"><span>{t('prod.metricMaterial')}</span>{num(docs.data?.length ?? 0, !!docs.data, docs.data?.length === 1 ? 'prod.unitMaterialOne' : 'prod.unitMaterial')}<small>{t('prod.metricMaterialSub')}</small></div>
<div className="metric"><span>{t('prod.metricMade')}</span>{num(materials.filter(m => m.status !== 'failed').length, !!results.data, 'prod.unitContent')}<small>{t('prod.metricMadeSub')}</small></div>
<div className="metric"><span>{t('prod.metricLms')}</span>{num(lms.length, !!results.data, 'prod.unitContent')}<small>{t('prod.metricLmsSub')}</small></div>
</div>
<ContentActivity made={made} working={working} loading={results.isPending} />
</>
)
}
const ACTIVITY_LIMIT = 3
const newest = (list: Material[]) => [...list].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, ACTIVITY_LIMIT)
// The newest finished results and everything still being made (planned pieces included), as the same
// cards as the Results page; one opens in place.
function ContentActivity({ made, working, loading }: { made: Material[]; working: Material[]; loading: boolean }) {
const [tab, setTab] = useState<'latest' | 'working'>('latest')
const [openId, setOpenId] = useState<string | null>(null)
const shown = newest(tab === 'latest' ? made : working)
const open = [...made, ...working].find(m => materialKey(m) === openId) ?? null
return (
<section className="prod-activity">
<div className="section-heading">
<div><h3>{t('prod.activityTitle')}</h3><p>{t('prod.activityDescription')}</p></div>
<Link className="text-btn" to="/results">{t('prod.activityAll')}<Icon name="arrow-right" /></Link>
</div>
<div className="result-tabs" role="tablist">
<button role="tab" aria-selected={tab === 'latest'} className={tab === 'latest' ? 'active' : undefined} onClick={() => setTab('latest')}>
<Icon name="book" />{t('prod.activityLatest')} <b>{made.length}</b>
</button>
<button role="tab" aria-selected={tab === 'working'} className={tab === 'working' ? 'active' : undefined} onClick={() => setTab('working')}>
{working.length ? <span className="live-dot" /> : <Icon name="clock" />}{t('prod.activityWorking')} <b>{working.length}</b>
</button>
</div>
{loading
? <SkeletonCards count={3} label={t('common.loading')} />
: shown.length
? <div className="activity-grid">
{shown.map(m => <ContentCard key={materialKey(m)} material={m} onOpen={() => setOpenId(materialKey(m))} />)}
</div>
: <p className="graph-empty activity-empty">{t(tab === 'latest' ? 'prod.activityNoneLatest' : 'prod.activityNoneWorking')}</p>}
{open && <Preview material={open} onClose={() => setOpenId(null)} />}
</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.

View File

@@ -116,7 +116,7 @@ function StatusBadge({ status }: { status: MaterialStatus }) {
return <span className={`status ${tone}`}><i className="dot"></i>{t(`res.state.${status}` as Key)}</span>
}
function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () => void }) {
export function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () => void }) {
const working = isWorking(m)
return (
<article className="content-card">
@@ -157,7 +157,7 @@ function RetryButton({ material: m, className }: { material: Material; className
)
}
function Preview({ material: m, onClose }: { material: Material; onClose: () => void }) {
export function Preview({ material: m, onClose }: { material: Material; onClose: () => void }) {
const ref = useRef<HTMLDialogElement>(null)
const queryClient = useQueryClient()
// Every part's revision, so a SCORM module refetches when only its quiz changes.

View File

@@ -105,6 +105,8 @@ button.workspace:hover,button.workspace[aria-expanded=true]{border-color:var(--s
.page-head{margin-bottom:28px}
.page-head h1{font-size:38px;line-height:1.08;font-weight:800;letter-spacing:-.04em}
.page-head p{font-size:15px;margin-top:8px;max-width:720px}
.page-eyebrow{display:block;margin-bottom:10px;font-size:12px;font-weight:600;letter-spacing:.18em;text-transform:uppercase;color:#667085}
.welcome-spark{font-size:.7em;vertical-align:.12em}
.app-footer{margin:auto 36px 0;padding:20px 0;border-top:1px solid var(--line);font-size:12.5px;color:var(--muted)}
.app-footer>span:first-child{color:var(--blue-dark)}
.app-footer>span:last-child{font-size:12.5px}
@@ -290,9 +292,10 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.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))}
/* Production: the knowledge graph, then the metric row and the latest content */
.prod-metrics{grid-template-columns:repeat(3,minmax(0,1fr));margin-top:24px}
.prod-metrics .metric>span{display:flex;align-items:center;gap:8px}
.prod-metrics .metric strong em{font-style:normal;font-size:16px;font-weight:600;letter-spacing:0;opacity:.7}
.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)}}
@@ -369,7 +372,6 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(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}
@@ -378,5 +380,13 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.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(max-width:700px){.prod-metrics{grid-template-columns:1fr}}
@media (prefers-reduced-motion:reduce){.live-dot,.graph-stars circle,.graph-glow,.graph-orbit,.graph-links path,.graph-node *{animation:none!important}}
/* Production: content activity */
.prod-activity .section-heading{margin-top:8px}
.prod-activity .result-tabs button{display:inline-flex;align-items:center;gap:8px}
.prod-activity .result-tabs svg{width:16px;height:16px}
.activity-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}
.activity-empty{padding:28px 0;text-align:center}
@media(max-width:1000px){.activity-grid{grid-template-columns:1fr}}