import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { Link, useSearchParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { AudioPlayer } from '../components/AudioPlayer' import { Icon, KIND_ICON, type IconName } from '../components/Icon' import { Empty, PageHead, SkeletonCards, SkeletonLines, SkeletonRows } from '../components/Page' import { toast } from '../components/Toast' import { describeRule, kindLabel, type ContentKind } from '../express/parse' import { decideMaterial, download, exportPptx, fileName, isWorking, printPdf, listOf, listResults, materialContent, RESULTS_POLL, materialKey, questionsOf, retryMaterial, reviewSource, slidesOf, textOf, titleOf, toMarkdown, type FoundSource, type Material, type MaterialContent, type MaterialStatus, } from '../express/results' import { MASTERY, scormPackage } from '../express/scorm' import { getLang, locale, t, type Key } from '../i18n' const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL // Setup fields of roleplay and simulator content, in display order. const FIELDS = ['description', 'topic', 'objective', 'learning_objectives', 'user_role', 'ai_character', 'difficulty', 'context'] as const // 'done' is everything no longer being made except failures, as the Production page counts it; the // Production page links here with ?status=done or ?status=working. const STATUS_FILTERS = ['all', 'done', 'review', 'approved', 'working', 'failed'] as const type StatusFilter = typeof STATUS_FILTERS[number] const STATUS_GROUP: Record> = { preparing: 'working', generating: 'working', ready: 'review', accepted: 'approved', failed: 'failed', rejected: 'failed', } const matchesStatus = (m: Material, status: StatusFilter) => status === 'all' || (status === 'done' ? !isWorking(m) && m.status !== 'failed' : STATUS_GROUP[m.status] === status) const fmt = (iso: string) => new Intl.DateTimeFormat(locale(), { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(iso)) export function ResultsPage() { const results = useQuery({ queryKey: ['results'], queryFn: listResults, ...RESULTS_POLL, }) const [tab, setTab] = useState<'content' | 'research'>('content') const [query, setQuery] = useState('') const [kind, setKind] = useState('all') const [params] = useSearchParams() const [status, setStatus] = useState(() => STATUS_FILTERS.find(s => s === params.get('status')) ?? 'all') const [openId, setOpenId] = useState(null) const materials = results.data?.materials ?? [] const sources = results.data?.sources ?? [] const kinds = useMemo(() => [...new Set(materials.map(m => m.kind))], [materials]) const needle = query.trim().toLowerCase() const shownMaterials = materials.filter(m => (kind === 'all' || m.kind === kind) && matchesStatus(m, status) && (!needle || titleOf(m).toLowerCase().includes(needle))) const shownSources = sources.filter(s => !needle || s.title.toLowerCase().includes(needle) || s.url?.toLowerCase().includes(needle)) const open = materials.find(m => materialKey(m) === openId) ?? null const pending = sources.filter(s => s.status === 'pending').length return ( <>

{tab === 'content' ? t('res.allContent') : t('res.foundSources')} {tab === 'content' ? shownMaterials.length : shownSources.length}

setQuery(e.target.value)} />
{tab === 'content' && ( <> )}
{results.isPending && (tab === 'content' ? : )} {results.isError && !results.data && {results.error.message}} {results.data && tab === 'content' && ( shownMaterials.length ?
{shownMaterials.map(m => setOpenId(materialKey(m))} />)}
: 0} icon="layers" /> )} {results.data && tab === 'research' && ( shownSources.length ?
{shownSources.map(s => )}
: 0} icon="search" /> )}
{tab === 'content' ? t('res.noteContent') : t('res.noteResearch')}
{open && setOpenId(null)} />} ) } function NoResults({ filtered, icon }: { filtered: boolean; icon: IconName }) { if (filtered) return {t('res.noMatchCopy')} return (

{t('res.empty')}

{t('res.emptyCopy')}

{t('sch.newInstruction')}
) } function StatusBadge({ status }: { status: MaterialStatus }) { const group = STATUS_GROUP[status] const tone = group === 'approved' ? 'ready' : group === 'failed' ? 'failed' : 'draft' return {t(`res.state.${status}` as Key)} } export function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () => void }) { const working = isWorking(m) return (
{kindLabel(m.kind)} {m.rule.topic ? m.rule.topic.toUpperCase() : 'EXPRESS'}
{kindLabel(m.kind)}

{t(m.sources.length === 1 ? 'res.sourceOne' : 'res.sourceCount', { n: m.sources.length })} · {fmt(m.createdAt)}

{describeRule(m.rule)} {working ? {m.video ? t('res.videoProgress', { n: m.video.progress }) : t('res.working')} : m.retry &&
}
) } // A failed plan item is produced again with a fresh quote; the result then shows as preparing. function RetryButton({ material: m, className }: { material: Material; className: string }) { const queryClient = useQueryClient() const retry = useMutation({ mutationFn: () => retryMaterial(m), onSuccess: () => { toast(t('res.retried')) queryClient.invalidateQueries({ queryKey: ['results'] }) }, onError: e => toast(e.message, { error: true }), }) return ( ) } 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. const content = useQuery({ queryKey: ['material', materialKey(m), m.parts.map(p => p.revision).join('.')], queryFn: () => materialContent(m) }) const decide = useMutation({ mutationFn: (decision: 'accept' | 'reject') => decideMaterial(m, decision), onSuccess: (_, decision) => { toast(t(decision === 'accept' ? 'res.accepted' : 'res.rejected')) queryClient.invalidateQueries({ queryKey: ['results'] }) }, }) // No close() on cleanup: its async "close" event would call onClose, and StrictMode's // mount → cleanup → mount in dev would then shut the dialog right after it opened. // Unmounting removes the dialog element, which ends the modal anyway. useEffect(() => { if (ref.current && !ref.current.open) ref.current.showModal() }, []) return ( e.target === ref.current && ref.current.close()}>

{titleOf(m)}

{describeRule(m.rule)}

{m.status === 'ready' &&
{t('res.reviewNotice')}
} {m.kind === 'audio' &&
{t('res.audioNotice')}
}
{content.isPending && } {content.isError && {content.error.message}} {content.isSuccess && (content.data.content ? : {t('res.noContentCopy')})}
{decide.error ? {decide.error.message} : {t('res.footNote')}} {m.composeSetId && {t('res.openStudio')}} {m.retry && } {content.data?.content && } {m.status === 'ready' && ( <> )}
) } // What a learner sees in a SCORM package: the lesson without the answer key (the quiz is interactive there). function lessonHtml(m: Material, content: MaterialContent): string { if (m.kind === 'quiz') return '' if (m.kind === 'summary' || m.kind === 'audio' || m.kind === 'scorm') { return renderToStaticMarkup(
) } return renderToStaticMarkup() } function scorm(m: Material, content: MaterialContent) { const blob = scormPackage({ title: textOf(content.title) || titleOf(m), lessonHtml: lessonHtml(m, content), questions: m.kind === 'quiz' || m.kind === 'scorm' ? questionsOf(content) : [], lang: getLang(), labels: { quiz: t('res.quiz'), submit: t('scorm.submit'), retry: t('scorm.retry'), complete: t('scorm.complete'), completed: t('scorm.completed'), score: t('scorm.score'), passed: t('scorm.passed', { n: MASTERY }), failed: t('scorm.failed', { n: MASTERY }), answerAll: t('scorm.answerAll'), noLms: t('scorm.noLms'), }, }) download(fileName(m, 'zip'), blob) toast(t('res.scormReady')) } function DownloadMenu({ material: m, content, outputId }: { material: Material; content: MaterialContent; outputId: number | null }) { const ref = useRef(null) const pptx = useMutation({ mutationFn: () => exportPptx(outputId!), onSuccess: blob => download(fileName(m, 'pptx'), blob), onError: e => toast(e.message, { error: true }), }) const close = () => ref.current?.removeAttribute('open') if (m.kind === 'video') { return m.video?.url ? {t('res.downloadVideo')} : null } const scormItem = ( ) const pdf = () => { const title = textOf(content.title) || titleOf(m) const meta = [describeRule(m.rule), fmt(m.createdAt)].join(' · ') const html = renderToStaticMarkup( <>
Learning Lab Express

{title}

{meta}

{/* The audio player is for the screen; on paper the script is the lesson. */} {m.kind === 'audio' ?
: } {!!m.sources.length &&
{t('res.sources')}: {m.sources.join(', ')}
} , ) printPdf(title, html) } return (
{pptx.isPending ? t('res.preparingFile') : t('res.download')}
{m.kind === 'scorm' && scormItem} {m.kind === 'audio' && ( )} {m.kind === 'slide_deck' && outputId && ( )} {m.kind !== 'scorm' && scormItem}
) } function Paragraphs({ text }: { text: string }) { // Generated text is light Markdown: headings, bullets and paragraphs are enough to read it. const blocks: ReactNode[] = [] let bullets: string[] = [] const flush = () => { if (bullets.length) blocks.push(
    {bullets.map((b, i) =>
  • {b}
  • )}
); bullets = [] } for (const line of text.split('\n').map(l => l.trim())) { if (/^[-*•]\s+/.test(line)) { bullets.push(line.replace(/^[-*•]\s+/, '').replace(/\*\*/g, '')); continue } flush() if (!line) continue const heading = line.match(/^#{1,6}\s+(.*)/) blocks.push(heading ?

{heading[1]}

:

{line.replace(/\*\*/g, '')}

) } flush() return <>{blocks} } // Generated summaries usually open with their own title as a heading; it is already the h1. function summaryText(m: Material, content: MaterialContent) { const title = textOf(content.title) || titleOf(m) return textOf(content.text).replace(/^\s*#{1,6}\s+(.+)\n/, (line, heading: string) => heading.trim().toLowerCase() === title.trim().toLowerCase() ? '' : line) } function QuizList({ content }: { content: MaterialContent }) { return (
    {questionsOf(content).map((q, i) => (
  1. {q.question} {!!q.options.length &&
      {q.options.map((o, j) =>
    • {o}
    • )}
    } {q.answer && q.correct < 0 &&

    {t('res.answer')}: {q.answer}

    } {q.explanation &&

    {q.explanation}

    }
  2. ))}
) } function Body({ material: m, content }: { material: Material; content: MaterialContent }) { const title = textOf(content.title) || titleOf(m) if (m.kind === 'video') { if (m.video?.url) return

{title}

if (m.status === 'failed') return {t('res.videoFailedCopy')} return {t('res.videoProgress', { n: m.video?.progress ?? 0 })} } if (m.kind === 'summary') { return

{title}

} if (m.kind === 'audio') { const text = summaryText(m, content) return ( <>
{t('res.fullScript')}
) } if (m.kind === 'scorm') { const questions = questionsOf(content).length return (
{t('res.scormModule')}{t('res.scormMeta', { n: questions, mastery: MASTERY })}

{title}

{t('res.quiz')}

{questions ? :

{t('res.quizPending')}

}
) } if (m.kind === 'quiz') { return

{title}

} if (m.kind === 'flashcards') { return (

{title}

{(listOf(content.cards) as Record[]).map((c, i) => (
{textOf(c.front)}{textOf(c.back)}
))}
) } if (m.kind === 'slide_deck') { return (

{title}

{!!content.description &&

{textOf(content.description)}

} {slidesOf(content).map((s, i) => (
{t('res.slide', { n: i + 1 })}

{s.title}

{!!s.bullets.length &&
    {s.bullets.map((b, j) =>
  • {b}
  • )}
}
))}
) } // Roleplay and simulator: show their setup fields; the interactive versions live in Learning Studio. return (

{title}

{FIELDS.map(k => [k, content[k]] as const).filter(([, v]) => v != null && v !== '').map(([k, v]) => (

{t(`res.field.${k}`)}

{Array.isArray(v) ?
    {v.map((x, i) =>
  • {textOf(x)}
  • )}
: }
))}
) } function SourceRow({ source: s }: { source: FoundSource }) { const queryClient = useQueryClient() const review = useMutation({ mutationFn: (decision: 'accept' | 'reject') => reviewSource(s, decision), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['results'] }) queryClient.invalidateQueries({ queryKey: ['documents'] }) }, }) const tone = s.status === 'accepted' ? 'ready' : s.status === 'pending' ? 'draft' : 'failed' const label = ['pending', 'accepted', 'rejected'].includes(s.status) ? t(`res.source.${s.status}` as Key) : s.status return (
{label}

{s.url ? {s.title} : s.title}

{describeRule(s.rule)} · {fmt(s.createdAt)}{s.status === 'accepted' && <> · {t('res.inBrain')}}

{s.preview &&

{s.preview}

} {review.error && {review.error.message}}
{s.status === 'pending' && (
)}
) }