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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
66
src/i18n.ts
66
src/i18n.ts
@@ -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',
|
||||
|
||||
@@ -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 }])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user