Initial commit: Learning Lab Express
Includes fixes found while reviewing the app: - Results: open each result by a type+agent+id key, so a video and a material draft with the same id no longer open each other's popup - AuthGate: clear the query cache during render, before pages build their queries, and remount pages on organization switch (pages no longer stay on skeletons for up to a minute after sign-in or refresh) - Instruction: keep only failed rules after a partial approve, so retrying does not create the successful schedules twice; drop the monthly note once count or frequency is edited - Parser/schedules: never schedule a count below one - Audio: ignore the end event of a cancelled utterance when changing speed - Downloads: revoke the object URL later so Safari/Firefox can read it - Results: refetch SCORM content when any part's revision changes; show a failed video as failed instead of "still rendering" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
423
src/pages/Results.tsx
Normal file
423
src/pages/Results.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Link } 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, materialKey, questionsOf, 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
|
||||
|
||||
type StatusFilter = 'all' | 'review' | 'approved' | 'working' | 'failed'
|
||||
const STATUS_GROUP: Record<MaterialStatus, StatusFilter> = {
|
||||
preparing: 'working', generating: 'working', ready: 'review', accepted: 'approved', failed: 'failed', rejected: 'failed',
|
||||
}
|
||||
|
||||
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,
|
||||
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 60_000),
|
||||
})
|
||||
const [tab, setTab] = useState<'content' | 'research'>('content')
|
||||
const [query, setQuery] = useState('')
|
||||
const [kind, setKind] = useState<ContentKind | 'all'>('all')
|
||||
const [status, setStatus] = useState<StatusFilter>('all')
|
||||
const [openId, setOpenId] = useState<string | null>(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) && (status === 'all' || STATUS_GROUP[m.status] === 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 (
|
||||
<>
|
||||
<PageHead title={t('res.title')} description={t('res.description')} />
|
||||
<div className="result-tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === 'content'} className={tab === 'content' ? 'active' : undefined} onClick={() => setTab('content')}>
|
||||
{t('res.tabContent')} <b>{materials.length}</b>
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'research'} className={tab === 'research' ? 'active' : undefined} onClick={() => setTab('research')}>
|
||||
{t('res.tabResearch')} <b>{pending ? t('res.pendingCount', { n: pending }) : sources.length}</b>
|
||||
</button>
|
||||
</div>
|
||||
<section className="card">
|
||||
<div className="toolbar">
|
||||
<h3>{tab === 'content' ? t('res.allContent') : t('res.foundSources')} <span className="badge neutral">{tab === 'content' ? shownMaterials.length : shownSources.length}</span></h3>
|
||||
<div className="toolbar-controls">
|
||||
<div className="search"><Icon name="search" />
|
||||
<input aria-label={t('res.search')} placeholder={t('res.search')} value={query} onChange={e => setQuery(e.target.value)} />
|
||||
</div>
|
||||
{tab === 'content' && (
|
||||
<>
|
||||
<select aria-label={t('res.filterKind')} value={kind} onChange={e => setKind(e.target.value as ContentKind | 'all')}>
|
||||
<option value="all">{t('res.allKinds')}</option>
|
||||
{kinds.map(k => <option key={k} value={k}>{kindLabel(k)}</option>)}
|
||||
</select>
|
||||
<select aria-label={t('res.filterStatus')} value={status} onChange={e => setStatus(e.target.value as StatusFilter)}>
|
||||
{(['all', 'review', 'approved', 'working', 'failed'] as const).map(s => <option key={s} value={s}>{t(`res.status.${s}` as Key)}</option>)}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{results.isPending && (tab === 'content' ? <SkeletonCards label={t('common.loading')} /> : <SkeletonRows label={t('common.loading')} />)}
|
||||
{results.isError && !results.data && <Empty icon="info" title={t('res.loadFailed')}>{results.error.message}</Empty>}
|
||||
{results.data && tab === 'content' && (
|
||||
shownMaterials.length
|
||||
? <div className="content-grid">{shownMaterials.map(m => <ContentCard key={materialKey(m)} material={m} onOpen={() => setOpenId(materialKey(m))} />)}</div>
|
||||
: <NoResults filtered={materials.length > 0} icon="layers" />
|
||||
)}
|
||||
{results.data && tab === 'research' && (
|
||||
shownSources.length
|
||||
? <div className="research-list">{shownSources.map(s => <SourceRow key={`${s.agentId}-${s.id}`} source={s} />)}</div>
|
||||
: <NoResults filtered={sources.length > 0} icon="search" />
|
||||
)}
|
||||
<div className="table-note">{tab === 'content' ? t('res.noteContent') : t('res.noteResearch')}</div>
|
||||
</section>
|
||||
{open && <Preview material={open} onClose={() => setOpenId(null)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NoResults({ filtered, icon }: { filtered: boolean; icon: IconName }) {
|
||||
if (filtered) return <Empty icon="search" title={t('res.noMatch')}>{t('res.noMatchCopy')}</Empty>
|
||||
return (
|
||||
<div className="empty">
|
||||
<span className="empty-icon"><Icon name={icon} /></span>
|
||||
<h3>{t('res.empty')}</h3>
|
||||
<p>{t('res.emptyCopy')}</p>
|
||||
<Link className="btn light" to="/instruction"><Icon name="plus" />{t('sch.newInstruction')}</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: MaterialStatus }) {
|
||||
const group = STATUS_GROUP[status]
|
||||
const tone = group === 'approved' ? 'ready' : group === 'failed' ? 'failed' : 'draft'
|
||||
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 }) {
|
||||
const working = isWorking(m)
|
||||
return (
|
||||
<article className="content-card">
|
||||
<div className={`content-cover kind-${m.kind}`}>
|
||||
<Icon name={KIND_ICON[m.kind]} /><span>{kindLabel(m.kind)}</span>
|
||||
<small>{m.rule.topic ? m.rule.topic.toUpperCase() : 'EXPRESS'}</small>
|
||||
</div>
|
||||
<div className="content-card-body">
|
||||
<div className="content-card-top"><span className="type-badge"><Icon name={KIND_ICON[m.kind]} />{kindLabel(m.kind)}</span><StatusBadge status={m.status} /></div>
|
||||
<button className="content-title" disabled={working} onClick={onOpen}>{titleOf(m)}</button>
|
||||
<p>{t(m.sources.length === 1 ? 'res.sourceOne' : 'res.sourceCount', { n: m.sources.length })} · {fmt(m.createdAt)}</p>
|
||||
<div className="content-card-foot">
|
||||
<span title={describeRule(m.rule)}>{describeRule(m.rule)}</span>
|
||||
{working
|
||||
? <span>{m.video ? t('res.videoProgress', { n: m.video.progress }) : t('res.working')}</span>
|
||||
: <button className="text-btn" onClick={onOpen}>{t('res.open')} <Icon name="arrow-right" /></button>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
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.
|
||||
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 (
|
||||
<dialog ref={ref} className="preview" aria-labelledby="preview-title" onClose={onClose}
|
||||
onClick={e => e.target === ref.current && ref.current.close()}>
|
||||
<div className="modal-head">
|
||||
<div><h2 id="preview-title">{titleOf(m)}</h2><p>{describeRule(m.rule)}</p></div>
|
||||
<button className="icon-btn" aria-label={t('res.close')} onClick={() => ref.current?.close()}><Icon name="close" /></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{m.status === 'ready' && <div className="preview-notice"><Icon name="info" /><span>{t('res.reviewNotice')}</span></div>}
|
||||
{m.kind === 'audio' && <div className="preview-notice concept"><Icon name="info" /><span>{t('res.audioNotice')}</span></div>}
|
||||
<div className="preview-layout">
|
||||
<div>
|
||||
{content.isPending && <SkeletonLines count={6} label={t('common.loading')} />}
|
||||
{content.isError && <Empty icon="info" title={t('res.loadFailed')}>{content.error.message}</Empty>}
|
||||
{content.isSuccess && (content.data.content ? <Body material={m} content={content.data.content} /> : <Empty icon="info" title={t('res.noContent')}>{t('res.noContentCopy')}</Empty>)}
|
||||
</div>
|
||||
<aside className="preview-side">
|
||||
<h3>{t('res.info')}</h3>
|
||||
<span className="type-badge"><Icon name={KIND_ICON[m.kind]} />{kindLabel(m.kind)}</span>
|
||||
<div className="detail-label">{t('res.statusLabel')}</div><StatusBadge status={m.status} />
|
||||
<div className="detail-label">{t('res.schedule')}</div><b>{describeRule(m.rule)}</b>
|
||||
<div className="detail-label">{t('res.created')}</div><b>{fmt(m.createdAt)}</b>
|
||||
{m.video?.credits != null && m.video.credits > 0 && <><div className="detail-label">{t('res.creditsUsed')}</div><b>{t('common.credits', { n: m.video.credits })}</b></>}
|
||||
<div className="detail-label">{t('res.sources')}</div>
|
||||
{m.sources.length ? m.sources.map(s => <div key={s} className="preview-source"><Icon name="file" />{s}</div>) : <b>–</b>}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
{decide.error ? <small className="error">{decide.error.message}</small> : <small>{t('res.footNote')}</small>}
|
||||
{m.composeSetId && <a className="btn" href={`${STUDIO_URL}/compose/${m.composeSetId}`} target="_blank" rel="noreferrer">{t('res.openStudio')}</a>}
|
||||
{content.data?.content && <DownloadMenu material={m} content={content.data.content} outputId={content.data.outputId} />}
|
||||
{m.status === 'ready' && (
|
||||
<>
|
||||
<button className="btn danger" disabled={decide.isPending} onClick={() => decide.mutate('reject')}>{t('res.reject')}</button>
|
||||
<button className="btn primary" disabled={decide.isPending} onClick={() => decide.mutate('accept')}><Icon name="check" />{t('res.accept')}</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// 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(<article className="prose"><Paragraphs text={summaryText(m, content)} /></article>)
|
||||
}
|
||||
return renderToStaticMarkup(<Body material={m} content={content} />)
|
||||
}
|
||||
|
||||
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<HTMLDetailsElement>(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
|
||||
? <a className="btn" href={m.video.url} download={fileName(m, 'mp4')}><Icon name="download" />{t('res.downloadVideo')}</a>
|
||||
: null
|
||||
}
|
||||
|
||||
const scormItem = (
|
||||
<button role="menuitem" onClick={() => { close(); scorm(m, content) }}>
|
||||
<b>SCORM 1.2 (.zip)</b><small>{t(m.kind === 'scorm' || m.kind === 'quiz' ? 'res.scormQuizHint' : 'res.scormHint')}</small>
|
||||
</button>
|
||||
)
|
||||
|
||||
const pdf = () => {
|
||||
const title = textOf(content.title) || titleOf(m)
|
||||
const meta = [describeRule(m.rule), fmt(m.createdAt)].join(' · ')
|
||||
const html = renderToStaticMarkup(
|
||||
<>
|
||||
<header><small>Learning Lab Express</small><h1>{title}</h1><p>{meta}</p></header>
|
||||
{/* The audio player is for the screen; on paper the script is the lesson. */}
|
||||
{m.kind === 'audio' ? <article className="prose"><Paragraphs text={summaryText(m, content)} /></article> : <Body material={m} content={content} />}
|
||||
{!!m.sources.length && <footer>{t('res.sources')}: {m.sources.join(', ')}</footer>}
|
||||
</>,
|
||||
)
|
||||
printPdf(title, html)
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="download-menu" ref={ref}>
|
||||
<summary className="btn"><Icon name="download" />{pptx.isPending ? t('res.preparingFile') : t('res.download')}</summary>
|
||||
<div role="menu">
|
||||
{m.kind === 'scorm' && scormItem}
|
||||
{m.kind === 'audio' && (
|
||||
<button role="menuitem" onClick={() => { close(); download(fileName(m, 'txt'), new Blob([summaryText(m, content)], { type: 'text/plain;charset=utf-8' })) }}>
|
||||
<b>{t('res.scriptTxt')}</b><small>{t('res.scriptHint')}</small>
|
||||
</button>
|
||||
)}
|
||||
<button role="menuitem" onClick={() => { close(); pdf() }}><b>PDF</b><small>{t('res.pdfHint')}</small></button>
|
||||
{m.kind === 'slide_deck' && outputId && (
|
||||
<button role="menuitem" disabled={pptx.isPending} onClick={() => { close(); pptx.mutate() }}><b>PowerPoint (.pptx)</b><small>{t('res.pptxHint')}</small></button>
|
||||
)}
|
||||
<button role="menuitem" onClick={() => { close(); download(fileName(m, 'md'), toMarkdown(m, content)) }}><b>Markdown (.md)</b><small>{t('res.mdHint')}</small></button>
|
||||
{m.kind !== 'scorm' && scormItem}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
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(<ul key={blocks.length}>{bullets.map((b, i) => <li key={i}>{b}</li>)}</ul>); 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 ? <h2 key={blocks.length}>{heading[1]}</h2> : <p key={blocks.length}>{line.replace(/\*\*/g, '')}</p>)
|
||||
}
|
||||
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 (
|
||||
<ol className="quiz-list">
|
||||
{questionsOf(content).map((q, i) => (
|
||||
<li key={i}><b>{q.question}</b>
|
||||
{!!q.options.length && <ul>{q.options.map((o, j) => <li key={j} className={j === q.correct ? 'correct' : undefined}>{o}</li>)}</ul>}
|
||||
{q.answer && q.correct < 0 && <p><b>{t('res.answer')}:</b> {q.answer}</p>}
|
||||
{q.explanation && <p className="muted">{q.explanation}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
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 <article className="prose"><h1>{title}</h1><video className="source-player" controls preload="metadata" src={m.video.url} /></article>
|
||||
if (m.status === 'failed') return <Empty icon="video" title={t('res.videoFailed')}>{t('res.videoFailedCopy')}</Empty>
|
||||
return <Empty icon="video" title={t('res.videoNotReady')}>{t('res.videoProgress', { n: m.video?.progress ?? 0 })}</Empty>
|
||||
}
|
||||
if (m.kind === 'summary') {
|
||||
return <article className="prose"><h1>{title}</h1><Paragraphs text={summaryText(m, content)} /></article>
|
||||
}
|
||||
if (m.kind === 'audio') {
|
||||
const text = summaryText(m, content)
|
||||
return (
|
||||
<>
|
||||
<AudioPlayer title={title} text={text} />
|
||||
<details className="script-details" style={{ marginTop: 20 }}><summary>{t('res.fullScript')}</summary>
|
||||
<article className="prose"><Paragraphs text={text} /></article>
|
||||
</details>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (m.kind === 'scorm') {
|
||||
const questions = questionsOf(content).length
|
||||
return (
|
||||
<article className="prose scorm-preview">
|
||||
<div className="scorm-preview-head"><Icon name="scorm" /><div><b>{t('res.scormModule')}</b><small>{t('res.scormMeta', { n: questions, mastery: MASTERY })}</small></div></div>
|
||||
<h1>{title}</h1>
|
||||
<Paragraphs text={summaryText(m, content)} />
|
||||
<h2>{t('res.quiz')}</h2>
|
||||
{questions ? <QuizList content={content} /> : <p className="muted">{t('res.quizPending')}</p>}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
if (m.kind === 'quiz') {
|
||||
return <article className="prose"><h1>{title}</h1><QuizList content={content} /></article>
|
||||
}
|
||||
if (m.kind === 'flashcards') {
|
||||
return (
|
||||
<article className="prose"><h1>{title}</h1>
|
||||
<div className="flashcards">
|
||||
{(listOf(content.cards) as Record<string, unknown>[]).map((c, i) => (
|
||||
<div key={i} className="flashcard"><b>{textOf(c.front)}</b><span>{textOf(c.back)}</span></div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
if (m.kind === 'slide_deck') {
|
||||
return (
|
||||
<article className="prose"><h1>{title}</h1>
|
||||
{!!content.description && <p>{textOf(content.description)}</p>}
|
||||
{slidesOf(content).map((s, i) => (
|
||||
<section key={i} className="slide-preview"><small>{t('res.slide', { n: i + 1 })}</small><h2>{s.title}</h2>
|
||||
{!!s.bullets.length && <ul>{s.bullets.map((b, j) => <li key={j}>{b}</li>)}</ul>}
|
||||
</section>
|
||||
))}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
// Roleplay and simulator: show their setup fields; the interactive versions live in Learning Studio.
|
||||
return (
|
||||
<article className="prose"><h1>{title}</h1>
|
||||
{FIELDS.map(k => [k, content[k]] as const).filter(([, v]) => v != null && v !== '').map(([k, v]) => (
|
||||
<div key={k}><h2>{t(`res.field.${k}`)}</h2>
|
||||
{Array.isArray(v) ? <ul>{v.map((x, i) => <li key={i}>{textOf(x)}</li>)}</ul> : <Paragraphs text={textOf(v)} />}
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<article className="research-row">
|
||||
<span><Icon name="search" /></span>
|
||||
<div>
|
||||
<span className={`status ${tone}`}><i className="dot"></i>{label}</span>
|
||||
<h3>{s.url ? <a href={s.url} target="_blank" rel="noreferrer">{s.title}</a> : s.title}</h3>
|
||||
<p>{describeRule(s.rule)} · {fmt(s.createdAt)}{s.status === 'accepted' && <> · <Link to="/brain">{t('res.inBrain')}</Link></>}</p>
|
||||
{s.preview && <p className="source-preview">{s.preview}</p>}
|
||||
{review.error && <span className="error">{review.error.message}</span>}
|
||||
</div>
|
||||
{s.status === 'pending' && (
|
||||
<div className="research-actions">
|
||||
<button className="btn small danger" disabled={review.isPending} onClick={() => review.mutate('reject')}>{t('res.reject')}</button>
|
||||
<button className="btn small primary" disabled={review.isPending} onClick={() => review.mutate('accept')}>{t('res.addToBrain')}</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user