From 6f3a5234b495bfa59c882b5a3afd3692359e3288 Mon Sep 17 00:00:00 2001 From: Iqbal Date: Tue, 27 Oct 2026 23:22:57 +0700 Subject: [PATCH] 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 --- src/components/AppLayout.tsx | 5 --- src/components/Page.tsx | 4 +- src/i18n.ts | 66 ++++++++++++++++++----------- src/pages/Instruction.tsx | 3 +- src/pages/Production.tsx | 81 ++++++++++++++++++++++++++++-------- src/pages/Results.tsx | 4 +- src/styles/theme.css | 18 ++++++-- 7 files changed, 125 insertions(+), 56 deletions(-) diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 3906c11..8d5f036 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -174,11 +174,6 @@ export function AppLayout() { -
- - {t('layout.devStrip')} - DEV -
{/* Keyed by route so each page plays the fade-in from theme.css. */}
Learning Lab Express by Bawana{t('layout.footer')}
diff --git a/src/components/Page.tsx b/src/components/Page.tsx index e50f04b..ac948b1 100644 --- a/src/components/Page.tsx +++ b/src/components/Page.tsx @@ -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 (
-

{title}

{description}

+
{eyebrow && {eyebrow}}

{title}

{description}

{actions &&
{actions}
}
) diff --git a/src/i18n.ts b/src/i18n.ts index d8c6511..2b9603d 100644 --- a/src/i18n.ts +++ b/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 = { '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 = { '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', diff --git a/src/pages/Instruction.tsx b/src/pages/Instruction.tsx index c808edf..733c198 100644 --- a/src/pages/Instruction.tsx +++ b/src/pages/Instruction.tsx @@ -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 }]) diff --git a/src/pages/Production.tsx b/src/pages/Production.tsx index 0c47b76..1960eb0 100644 --- a/src/pages/Production.tsx +++ b/src/pages/Production.tsx @@ -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 = { 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 ? {n.toLocaleString(locale())} : ) + const num = (n: number, ready: boolean, unit: Key) => (ready + ? {n.toLocaleString(locale())} {t(unit, { n })} + : ) return ( <> - -
-
- {t('prod.metricWorking')}{num(working.length, !!results.data)}{t('prod.metricWorkingSub')} -
-
{t('prod.metricMaterial')}{num(docs.data?.length ?? 0, !!docs.data)}{t('prod.metricMaterialSub', { n: inBrain })}
-
{t('prod.metricMade')}{num(made.length, !!results.data)}{t('prod.metricMadeSub')}
-
{t('prod.metricLive')}{num(approved.length, !!results.data)}{t('prod.metricLiveSub')}
-
{t('prod.metricLms')}{num(lms.length, !!results.data)}{t('prod.metricLmsSub')}
-
- + {name ? t('prod.welcomeName', { name }) : t('prod.welcome')} ✦} + actions={<> + {t('nav.brain')} + {t('prod.newContent')} + } />
{graph ? @@ -91,10 +92,54 @@ export function ProductionPage() { {working.length > 6 && {t('prod.more', { n: working.length - 6 })}}
+ +
+
{t('prod.metricMaterial')}{num(docs.data?.length ?? 0, !!docs.data, docs.data?.length === 1 ? 'prod.unitMaterialOne' : 'prod.unitMaterial')}{t('prod.metricMaterialSub')}
+
{t('prod.metricMade')}{num(materials.filter(m => m.status !== 'failed').length, !!results.data, 'prod.unitContent')}{t('prod.metricMadeSub')}
+
{t('prod.metricLms')}{num(lms.length, !!results.data, 'prod.unitContent')}{t('prod.metricLmsSub')}
+
+ + ) } +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(null) + const shown = newest(tab === 'latest' ? made : working) + const open = [...made, ...working].find(m => materialKey(m) === openId) ?? null + return ( +
+
+

{t('prod.activityTitle')}

{t('prod.activityDescription')}

+ {t('prod.activityAll')} +
+
+ + +
+ {loading + ? + : shown.length + ?
+ {shown.map(m => setOpenId(materialKey(m))} />)} +
+ :

{t(tab === 'latest' ? 'prod.activityNoneLatest' : 'prod.activityNoneWorking')}

} + {open && setOpenId(null)} />} +
+ ) +} + // 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. diff --git a/src/pages/Results.tsx b/src/pages/Results.tsx index c88e966..1c5fc9f 100644 --- a/src/pages/Results.tsx +++ b/src/pages/Results.tsx @@ -116,7 +116,7 @@ function StatusBadge({ status }: { status: MaterialStatus }) { return {t(`res.state.${status}` as Key)} } -function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () => void }) { +export function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () => void }) { const working = isWorking(m) return (
@@ -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(null) const queryClient = useQueryClient() // Every part's revision, so a SCORM module refetches when only its quiz changes. diff --git a/src/styles/theme.css b/src/styles/theme.css index 2908d4a..2a35a04 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -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}}