Refactor code structure for improved readability and maintainability

This commit is contained in:
RaymondVerhoef
2026-05-23 19:37:17 +02:00
parent 472685f0d7
commit f4d0c85c55
61 changed files with 13321 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
'use client'
import React, { useState } from 'react'
import type { CurriculumWeek } from '@/types'
import { pb } from '@/lib/pocketbase'
import { Button } from '@/components/ui/Button'
interface CurriculumWeekRowProps {
week: CurriculumWeek
isDragging: boolean
onDragStart: (e: React.DragEvent<HTMLDivElement>) => void
onDragOver: (e: React.DragEvent<HTMLDivElement>) => void
onDrop: (e: React.DragEvent<HTMLDivElement>) => void
onDragEnd: () => void
onUpdate: () => void
}
export function CurriculumWeekRow({
week,
isDragging,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
onUpdate,
}: CurriculumWeekRowProps) {
const [editingNotes, setEditingNotes] = useState(false)
const [notes, setNotes] = useState(week.admin_notes)
const [saving, setSaving] = useState(false)
async function saveNotes() {
setSaving(true)
try {
await pb.collection('curriculum_weeks').update(week.id, { admin_notes: notes })
setEditingNotes(false)
onUpdate()
} catch {
// ignore save error
} finally {
setSaving(false)
}
}
const themeName =
(week.expand?.theme?.title) ?? '—'
const topicCount = week.topics.length
return (
<div
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
style={{
background: 'var(--bg)',
border: `1.5px solid ${isDragging ? 'var(--accent)' : 'var(--cream)'}`,
borderRadius: 'var(--r-md)',
padding: 'var(--s-4)',
cursor: 'grab',
opacity: isDragging ? 0.5 : 1,
transition: 'opacity 150ms, border-color 150ms',
display: 'flex',
flexDirection: 'column',
gap: 'var(--s-3)',
userSelect: 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-3)' }}>
{/* Drag handle */}
<div
style={{
color: 'var(--fg-subtle)',
fontSize: '18px',
flexShrink: 0,
cursor: 'grab',
}}
aria-hidden="true"
>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<span
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: 'var(--fg-muted)',
}}
>
Week {week.week_number}
</span>
<span
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
color: 'var(--fg-subtle)',
}}
>
{week.estimated_duration_minutes}min · {topicCount} topic{topicCount !== 1 ? 's' : ''}
</span>
</div>
<p
style={{
margin: 0,
fontWeight: 600,
fontSize: 'var(--t-body)',
color: 'var(--fg)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{themeName}
</p>
</div>
</div>
{editingNotes ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
style={{
width: '100%',
borderRadius: 'var(--r-sm)',
border: '1.5px solid var(--accent)',
padding: 'var(--s-2) var(--s-3)',
fontSize: 'var(--t-small)',
fontFamily: 'var(--font-sans)',
color: 'var(--fg)',
background: 'var(--bg)',
resize: 'vertical',
outline: 'none',
}}
autoFocus
/>
<div style={{ display: 'flex', gap: 'var(--s-2)', justifyContent: 'flex-end' }}>
<Button size="sm" variant="secondary" onClick={() => setEditingNotes(false)}>
Cancel
</Button>
<Button size="sm" variant="primary" loading={saving} onClick={() => void saveNotes()}>
Save
</Button>
</div>
</div>
) : (
<button
onClick={() => setEditingNotes(true)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'text',
textAlign: 'left',
fontFamily: 'var(--font-sans)',
fontSize: 'var(--t-small)',
color: week.admin_notes ? 'var(--fg-muted)' : 'var(--fg-subtle)',
fontStyle: week.admin_notes ? 'normal' : 'italic',
minHeight: '24px',
}}
>
{week.admin_notes || 'Add admin notes…'}
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,204 @@
'use client'
import React, { useRef, useState, useCallback } from 'react'
import { Button } from '@/components/ui/Button'
import { ProgressBar } from '@/components/ui/ProgressBar'
import { useIngestionStatus } from '@/lib/hooks/useIngestionStatus'
import { pb } from '@/lib/pocketbase'
import { postIngest } from '@/lib/services'
interface DocumentUploadProps {
onSuccess: () => void
}
export function DocumentUpload({ onSuccess }: DocumentUploadProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [jobId, setJobId] = useState<string | null>(null)
const [uploadError, setUploadError] = useState<string | null>(null)
const [dragOver, setDragOver] = useState(false)
const { status, progress } = useIngestionStatus(jobId)
const handleFiles = useCallback(
async (files: FileList | null) => {
if (!files || files.length === 0) return
const file = files[0]
if (!file) return
const allowed = ['.pdf', '.md', '.txt']
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
if (!allowed.includes(ext)) {
setUploadError(`Unsupported file type: ${ext}. Use PDF, MD, or TXT.`)
return
}
setUploadError(null)
setUploading(true)
setJobId(null)
try {
// Create record in PocketBase
const fd = new FormData()
fd.append('file', file)
fd.append('filename', file.name)
fd.append('format', ext.slice(1) as 'pdf' | 'md' | 'txt')
fd.append('status', 'processing')
const doc = await pb.collection('source_documents').create<{ id: string }>(fd)
// Kick off ingestion
const ingestFd = new FormData()
ingestFd.append('documentId', doc.id)
const { jobId: jId } = await postIngest(ingestFd)
setJobId(jId)
} catch (err) {
setUploadError(err instanceof Error ? err.message : 'Upload failed')
} finally {
setUploading(false)
}
},
[],
)
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
void handleFiles(e.target.files)
e.target.value = ''
}
function handleDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault()
setDragOver(false)
void handleFiles(e.dataTransfer.files)
}
function handleDragOver(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault()
setDragOver(true)
}
function handleDragLeave() {
setDragOver(false)
}
const isDone = status?.status === 'done'
const isFailed = status?.status === 'failed'
if (isDone && jobId) {
return (
<div
style={{
background: 'var(--success)',
borderRadius: 'var(--r-md)',
padding: 'var(--s-5)',
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
gap: 'var(--s-3)',
}}
>
<p style={{ margin: 0, fontWeight: 600, color: 'var(--teal-900)' }}>
Ingestion complete! {status.themesFound} themes, {status.topicsFound} topics found.
</p>
<Button
variant="secondary"
size="sm"
onClick={() => {
setJobId(null)
onSuccess()
}}
>
Upload another
</Button>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => fileInputRef.current?.click()}
style={{
border: `2px dashed ${dragOver ? 'var(--accent)' : 'var(--cream)'}`,
borderRadius: 'var(--r-lg)',
padding: 'var(--s-8, 64px) var(--s-5)',
textAlign: 'center',
cursor: 'pointer',
background: dragOver ? 'var(--accent-soft)' : 'var(--bg-warm)',
transition: 'background 150ms, border-color 150ms',
}}
>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.md,.txt"
style={{ display: 'none' }}
onChange={handleChange}
/>
<p
style={{
margin: '0 0 var(--s-2)',
fontSize: 'var(--t-lead)',
fontWeight: 600,
color: 'var(--fg)',
}}
>
Drop a document here
</p>
<p style={{ margin: 0, fontSize: 'var(--t-small)', color: 'var(--fg-muted)' }}>
or tap to browse PDF, Markdown, TXT
</p>
</div>
{uploadError && (
<div
style={{
background: '#f8d7da',
color: '#c0392b',
borderRadius: 'var(--r-sm)',
padding: 'var(--s-3) var(--s-4)',
fontSize: 'var(--t-small)',
}}
>
{uploadError}
</div>
)}
{(uploading || jobId) && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
<ProgressBar
value={uploading ? 5 : progress}
label={
uploading
? 'Uploading…'
: isFailed
? `Failed: ${status?.reason ?? 'unknown error'}`
: status?.status
? `${status.status.charAt(0).toUpperCase()}${status.status.slice(1)}`
: 'Processing…'
}
color={isFailed ? '#c0392b' : 'var(--accent)'}
/>
{status && (
<div
style={{
fontSize: 'var(--t-small)',
color: 'var(--fg-muted)',
display: 'flex',
gap: 'var(--s-4)',
flexWrap: 'wrap',
}}
>
<span>Chunks: {status.chunksEmbedded}/{status.chunksTotal}</span>
<span>Themes: {status.themesFound}</span>
<span>Topics: {status.topicsFound}</span>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,83 @@
'use client'
import React from 'react'
import type { SourceDocument } from '@/types'
import { StatusBadge } from '@/components/ui/StatusBadge'
interface JobStatusListProps {
documents: SourceDocument[]
}
function formatDate(iso: string): string {
if (!iso) return '—'
try {
return new Intl.DateTimeFormat('en', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(iso))
} catch {
return iso
}
}
export function JobStatusList({ documents }: JobStatusListProps) {
if (documents.length === 0) {
return (
<div style={{ color: 'var(--fg-muted)', fontSize: 'var(--t-body)', padding: 'var(--s-4)' }}>
No documents yet.
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
{documents.map((doc) => (
<div
key={doc.id}
style={{
background: 'var(--bg-warm)',
borderRadius: 'var(--r-sm)',
padding: 'var(--s-3) var(--s-4)',
display: 'flex',
alignItems: 'center',
gap: 'var(--s-3)',
flexWrap: 'wrap',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<p
style={{
margin: 0,
fontWeight: 600,
fontSize: 'var(--t-body)',
color: 'var(--fg)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{doc.filename}
</p>
<p style={{ margin: 0, fontSize: 'var(--t-small)', color: 'var(--fg-muted)' }}>
{doc.chunk_count} chunks · {formatDate(doc.ingested_at)}
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<span
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
color: 'var(--fg-subtle)',
}}
>
{doc.format}
</span>
<StatusBadge status={doc.status} />
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,129 @@
'use client'
import React, { useState } from 'react'
import Link from 'next/link'
import type { Theme } from '@/types'
import { StatusBadge } from '@/components/ui/StatusBadge'
import { Button } from '@/components/ui/Button'
import { postGenerateAll } from '@/lib/services'
interface ThemeCardProps {
theme: Theme
onUpdate: () => void
}
export function ThemeCard({ theme, onUpdate }: ThemeCardProps) {
const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [queued, setQueued] = useState<number | null>(null)
async function handleGenerate() {
setGenerating(true)
setError(null)
try {
const { queued: q } = await postGenerateAll(theme.id)
setQueued(q)
onUpdate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Generation failed')
} finally {
setGenerating(false)
}
}
return (
<div
style={{
background: 'var(--bg)',
border: '1.5px solid var(--cream)',
borderRadius: 'var(--r-md)',
padding: 'var(--s-4)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--s-3)',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 'var(--s-3)' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<h3
style={{
margin: '0 0 var(--s-1)',
fontSize: 'var(--t-body)',
fontWeight: 700,
color: 'var(--fg)',
}}
>
{theme.title}
</h3>
<p
style={{
margin: 0,
fontSize: 'var(--t-small)',
color: 'var(--fg-muted)',
lineHeight: 1.5,
}}
>
{theme.description}
</p>
</div>
<StatusBadge status={theme.status} />
</div>
<div
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
color: 'var(--fg-subtle)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
}}
>
{theme.source_documents.length} source doc{theme.source_documents.length !== 1 ? 's' : ''}
</div>
{queued !== null && (
<div
style={{
background: 'var(--success)',
color: 'var(--teal-900)',
borderRadius: 'var(--r-sm)',
padding: 'var(--s-2) var(--s-3)',
fontSize: 'var(--t-small)',
fontWeight: 500,
}}
>
{queued} micro-learnings queued for generation
</div>
)}
{error && (
<div
style={{
background: '#f8d7da',
color: '#c0392b',
borderRadius: 'var(--r-sm)',
padding: 'var(--s-2) var(--s-3)',
fontSize: 'var(--t-small)',
}}
>
{error}
</div>
)}
<div style={{ display: 'flex', gap: 'var(--s-2)', flexWrap: 'wrap' }}>
<Link href={`/admin/knowledge/${theme.id}`} style={{ textDecoration: 'none' }}>
<Button variant="secondary" size="sm">
View topics
</Button>
</Link>
<Button
variant="primary"
size="sm"
onClick={() => void handleGenerate()}
loading={generating}
>
Generate micro-learnings
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,204 @@
'use client'
import React, { useState } from 'react'
import type { Topic } from '@/types'
import { StatusBadge } from '@/components/ui/StatusBadge'
import { Button } from '@/components/ui/Button'
import { pb } from '@/lib/pocketbase'
interface TopicEditCardProps {
topic: Topic
onUpdate: () => void
}
const DIFFICULTY_COLORS: Record<Topic['difficulty'], string> = {
introductory: 'var(--success)',
intermediate: '#fff3cd',
advanced: 'var(--accent-soft)',
}
export function TopicEditCard({ topic, onUpdate }: TopicEditCardProps) {
const [expanded, setExpanded] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleApprove() {
setSaving(true)
setError(null)
try {
await pb.collection('topics').update(topic.id, { status: 'published' })
onUpdate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to approve')
} finally {
setSaving(false)
}
}
async function handleReject() {
setSaving(true)
setError(null)
try {
await pb.collection('topics').update(topic.id, { status: 'draft' })
onUpdate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reject')
} finally {
setSaving(false)
}
}
return (
<div
style={{
background: 'var(--bg)',
border: '1.5px solid var(--cream)',
borderRadius: 'var(--r-md)',
overflow: 'hidden',
}}
>
<button
onClick={() => setExpanded((e) => !e)}
style={{
width: '100%',
background: 'none',
border: 'none',
padding: 'var(--s-4)',
textAlign: 'left',
cursor: 'pointer',
display: 'flex',
gap: 'var(--s-3)',
alignItems: 'flex-start',
minHeight: '44px',
fontFamily: 'var(--font-sans)',
}}
aria-expanded={expanded}
>
<div style={{ flex: 1, minWidth: 0 }}>
<h4
style={{
margin: '0 0 var(--s-1)',
fontSize: 'var(--t-body)',
fontWeight: 600,
color: 'var(--fg)',
}}
>
{topic.title}
</h4>
<div style={{ display: 'flex', gap: 'var(--s-2)', flexWrap: 'wrap', alignItems: 'center' }}>
<span
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
padding: '2px var(--s-2)',
borderRadius: 'var(--r-pill)',
background: DIFFICULTY_COLORS[topic.difficulty],
color: 'var(--fg-muted)',
}}
>
{topic.difficulty}
</span>
<StatusBadge status={topic.status} />
</div>
</div>
<span style={{ color: 'var(--fg-muted)', fontSize: '18px', flexShrink: 0 }}>
{expanded ? '' : '+'}
</span>
</button>
{expanded && (
<div
style={{
padding: 'var(--s-4)',
borderTop: '1px solid var(--cream)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--s-4)',
}}
>
<p
style={{
margin: 0,
fontSize: 'var(--t-body)',
lineHeight: 1.65,
color: 'var(--fg)',
}}
>
{topic.body}
</p>
{topic.key_terms.length > 0 && (
<div>
<p
style={{
margin: '0 0 var(--s-2)',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: 'var(--fg-muted)',
}}
>
Key terms
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--s-2)' }}>
{topic.key_terms.map((term) => (
<span
key={term}
style={{
background: 'var(--bg-warm)',
borderRadius: 'var(--r-pill)',
padding: '2px var(--s-3)',
fontSize: 'var(--t-small)',
color: 'var(--fg-muted)',
}}
>
{term}
</span>
))}
</div>
</div>
)}
{error && (
<div
style={{
background: '#f8d7da',
color: '#c0392b',
borderRadius: 'var(--r-sm)',
padding: 'var(--s-2) var(--s-3)',
fontSize: 'var(--t-small)',
}}
>
{error}
</div>
)}
<div style={{ display: 'flex', gap: 'var(--s-2)', justifyContent: 'flex-end' }}>
{topic.status !== 'draft' && (
<Button
variant="secondary"
size="sm"
onClick={() => void handleReject()}
loading={saving}
>
Revert to draft
</Button>
)}
{topic.status !== 'published' && (
<Button
variant="primary"
size="sm"
onClick={() => void handleApprove()}
loading={saving}
>
Publish
</Button>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,227 @@
'use client'
import React, { useState } from 'react'
import type { Topic, MicroLearning, CompletePayload } from '@/types'
import { MicroLearningRenderer } from '@/components/micro-learnings/MicroLearningRenderer'
import { StatusBadge } from '@/components/ui/StatusBadge'
import { Button } from '@/components/ui/Button'
import { postComplete } from '@/lib/services'
import { usePBCollection } from '@/lib/hooks/usePBCollection'
interface TopicCardProps {
topic: Topic
weekNumber: number
userId: string
isCompleted?: boolean
onComplete: (topicId: string) => void
}
const TYPE_LABELS: Record<string, string> = {
concept_explainer: 'Explainer',
scenario_quiz: 'Quiz',
misconceptions: 'Myths',
how_to: 'How-to',
comparison_card: 'Compare',
reflection_prompt: 'Reflect',
flashcard_set: 'Flashcards',
case_study: 'Case study',
glossary_anchor: 'Glossary',
myth_vs_evidence: 'Evidence',
}
export function TopicCard({ topic, weekNumber, userId, isCompleted, onComplete }: TopicCardProps) {
const [expanded, setExpanded] = useState(false)
const [selectedMlId, setSelectedMlId] = useState<string | null>(null)
const [completing, setCompleting] = useState(false)
const { items: microLearnings } = usePBCollection<MicroLearning>('micro_learnings', {
filter: `topic="${topic.id}" && status="published"`,
})
const selectedMl = microLearnings.find((ml) => ml.id === selectedMlId)
async function handleComplete() {
if (!selectedMl) return
setCompleting(true)
try {
const payload: CompletePayload = {
userId,
topicId: topic.id,
microLearningId: selectedMl.id,
weekNumber,
}
await postComplete(payload)
onComplete(topic.id)
} catch {
// user can retry
} finally {
setCompleting(false)
}
}
return (
<div
style={{
background: isCompleted ? 'var(--bg-warm)' : 'var(--bg)',
border: `1.5px solid ${isCompleted ? 'var(--success)' : 'var(--cream)'}`,
borderRadius: 'var(--r-md)',
overflow: 'hidden',
transition: 'border-color 200ms',
}}
>
<button
onClick={() => setExpanded((e) => !e)}
style={{
width: '100%',
background: 'none',
border: 'none',
padding: 'var(--s-4)',
textAlign: 'left',
cursor: 'pointer',
display: 'flex',
gap: 'var(--s-3)',
alignItems: 'flex-start',
minHeight: '44px',
fontFamily: 'var(--font-sans)',
}}
aria-expanded={expanded}
>
<span
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: isCompleted ? 'var(--success)' : 'var(--bg-warm)',
color: isCompleted ? 'var(--teal-900)' : 'var(--fg-muted)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontWeight: 700,
flexShrink: 0,
marginTop: '2px',
}}
>
{isCompleted ? '✓' : '○'}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<h4
style={{
margin: '0 0 var(--s-1)',
fontSize: 'var(--t-body)',
fontWeight: 600,
color: isCompleted ? 'var(--fg-muted)' : 'var(--fg)',
textDecoration: isCompleted ? 'line-through' : 'none',
}}
>
{topic.title}
</h4>
<div style={{ display: 'flex', gap: 'var(--s-2)', flexWrap: 'wrap', alignItems: 'center' }}>
<StatusBadge status={topic.difficulty} />
<span
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
color: 'var(--fg-subtle)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
}}
>
{microLearnings.length} activities
</span>
</div>
</div>
<span style={{ color: 'var(--fg-muted)', fontSize: '18px', flexShrink: 0 }}>
{expanded ? '' : '+'}
</span>
</button>
{expanded && (
<div
style={{
padding: 'var(--s-4)',
borderTop: '1px solid var(--cream)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--s-4)',
}}
>
{/* Topic body */}
<p style={{ margin: 0, fontSize: 'var(--t-body)', lineHeight: 1.65, color: 'var(--fg)' }}>
{topic.body}
</p>
{/* Type picker */}
{microLearnings.length > 0 && (
<div>
<p
style={{
margin: '0 0 var(--s-2)',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: 'var(--fg-muted)',
}}
>
Choose an activity
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--s-2)' }}>
{microLearnings.map((ml) => (
<button
key={ml.id}
onClick={() => setSelectedMlId(ml.id === selectedMlId ? null : ml.id)}
style={{
background:
selectedMlId === ml.id ? 'var(--accent)' : 'var(--bg-warm)',
color: selectedMlId === ml.id ? 'var(--paper)' : 'var(--fg)',
border: `1.5px solid ${selectedMlId === ml.id ? 'var(--accent)' : 'var(--cream)'}`,
borderRadius: 'var(--r-pill)',
padding: 'var(--s-1) var(--s-3)',
fontSize: 'var(--t-small)',
fontFamily: 'var(--font-sans)',
fontWeight: 500,
cursor: 'pointer',
minHeight: '36px',
transition: 'background 150ms, color 150ms',
}}
>
{TYPE_LABELS[ml.type] ?? ml.type}
</button>
))}
</div>
</div>
)}
{/* Selected micro-learning */}
{selectedMl && (
<div
style={{
background: 'var(--bg-warm)',
borderRadius: 'var(--r-md)',
padding: 'var(--s-4)',
}}
>
<MicroLearningRenderer
content={selectedMl.content}
onComplete={() => void handleComplete()}
/>
</div>
)}
{isCompleted && !selectedMl && (
<div
style={{
textAlign: 'center',
color: 'var(--teal-700)',
fontSize: 'var(--t-small)',
fontWeight: 500,
}}
>
Completed well done!
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,102 @@
'use client'
import React from 'react'
import type { EmployeeCurriculumState, CurriculumWeek, Theme } from '@/types'
interface WeekHeaderProps {
curriculumState: EmployeeCurriculumState
week: CurriculumWeek & { expand?: { theme?: Theme } }
completedCount: number
totalCount: number
}
export function WeekHeader({ curriculumState, week, completedCount, totalCount }: WeekHeaderProps) {
const themeName = week.expand?.theme?.title ?? '—'
const pct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
return (
<div
style={{
background: 'var(--bg-dark)',
color: 'var(--paper)',
borderRadius: 'var(--r-lg)',
padding: 'var(--s-5)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--s-3)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<p
style={{
margin: '0 0 var(--s-1)',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
opacity: 0.65,
}}
>
Cycle {curriculumState.current_cycle} · Week {curriculumState.current_week}
</p>
<h2
style={{
margin: 0,
fontSize: 'var(--t-h3)',
fontWeight: 700,
lineHeight: 1.25,
}}
>
{themeName}
</h2>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<p style={{ margin: 0, fontSize: 'var(--t-h3)', fontWeight: 700 }}>
{completedCount}/{totalCount}
</p>
<p
style={{
margin: 0,
fontSize: 'var(--t-small)',
opacity: 0.7,
}}
>
topics
</p>
</div>
</div>
{/* Progress bar */}
<div
style={{
width: '100%',
height: '6px',
background: 'rgba(255,255,255,0.2)',
borderRadius: 'var(--r-pill)',
overflow: 'hidden',
}}
>
<div
style={{
width: `${pct}%`,
height: '100%',
background: 'var(--paper)',
borderRadius: 'var(--r-pill)',
transition: 'width 400ms ease',
}}
/>
</div>
<div style={{ display: 'flex', gap: 'var(--s-4)', flexWrap: 'wrap' }}>
<span style={{ fontSize: 'var(--t-small)', opacity: 0.75 }}>
{week.estimated_duration_minutes} min estimated
</span>
{pct === 100 && (
<span style={{ fontSize: 'var(--t-small)', fontWeight: 600 }}>
Week complete!
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,115 @@
'use client'
import React from 'react'
import type { FeedEntry } from '@/types'
interface ActivityFeedProps {
entries: FeedEntry[]
}
function formatDate(iso: string): string {
try {
return new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric' }).format(new Date(iso))
} catch {
return iso
}
}
export function ActivityFeed({ entries }: ActivityFeedProps) {
if (entries.length === 0) {
return (
<div
style={{
textAlign: 'center',
color: 'var(--fg-muted)',
padding: 'var(--s-6)',
fontSize: 'var(--t-body)',
}}
>
No activity yet.
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-3)' }}>
{entries.map((entry) => (
<div
key={entry.id}
style={{
background: 'var(--bg-warm)',
borderRadius: 'var(--r-md)',
padding: 'var(--s-4)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--s-2)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--t-body)', color: 'var(--fg)' }}>
{entry.displayName}
</span>
<span
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
color: 'var(--fg-subtle)',
}}
>
{formatDate(entry.createdAt)}
</span>
</div>
<div style={{ display: 'flex', gap: 'var(--s-4)', flexWrap: 'wrap' }}>
<Stat label="Cycle" value={`${entry.cycle}`} />
<Stat label="Week" value={`${entry.week}`} />
<Stat label="Commits" value={`${entry.totalCommits}`} />
<Stat label="Streak" value={`${entry.streakWeeks}w`} />
</div>
{entry.badgeKeys.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--s-1)' }}>
{entry.badgeKeys.map((key) => (
<span
key={key}
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
padding: '2px var(--s-2)',
borderRadius: 'var(--r-pill)',
background: 'var(--accent-soft)',
color: 'var(--teal-900)',
}}
>
{key}
</span>
))}
</div>
)}
</div>
))}
</div>
)
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<span
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: 'var(--fg-subtle)',
}}
>
{label}
</span>
<span style={{ fontSize: 'var(--t-small)', fontWeight: 600, color: 'var(--fg)' }}>
{value}
</span>
</div>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import React from 'react'
import type { Badge, EmployeeBadge } from '@/types'
interface BadgeGridProps {
allBadges: Badge[]
earned: EmployeeBadge[]
}
const TIER_COLORS: Record<Badge['tier'], { bg: string; border: string; label: string }> = {
bronze: { bg: '#f5e0c8', border: '#c8914f', label: '#7a4f2a' },
silver: { bg: '#ececec', border: '#a8a8a8', label: '#555' },
gold: { bg: '#fff3cc', border: '#e6b800', label: '#6b5000' },
legendary: { bg: '#ede0ff', border: 'var(--purple)', label: 'var(--purple-700)' },
content: { bg: 'var(--bg-warm)', border: 'var(--cream)', label: 'var(--fg-muted)' },
}
export function BadgeGrid({ allBadges, earned }: BadgeGridProps) {
const earnedKeys = new Set(earned.map((e) => e.badge))
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: 'var(--s-3)',
}}
>
{allBadges.map((badge) => {
const isEarned = earnedKeys.has(badge.id)
const tier = TIER_COLORS[badge.tier]
return (
<div
key={badge.id}
style={{
background: isEarned ? tier.bg : 'var(--bg-warm)',
border: `2px solid ${isEarned ? tier.border : 'var(--cream)'}`,
borderRadius: 'var(--r-md)',
padding: 'var(--s-4)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 'var(--s-2)',
textAlign: 'center',
opacity: isEarned ? 1 : 0.45,
transition: 'opacity 200ms',
}}
>
<span style={{ fontSize: '28px', lineHeight: 1 }}>{badge.icon}</span>
<span
style={{
fontSize: 'var(--t-small)',
fontWeight: 600,
color: isEarned ? tier.label : 'var(--fg-muted)',
lineHeight: 1.3,
}}
>
{badge.label}
</span>
<span
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: isEarned ? tier.label : 'var(--fg-subtle)',
opacity: 0.75,
}}
>
{badge.tier}
</span>
{isEarned && (
<span
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
color: isEarned ? tier.label : 'var(--fg-subtle)',
opacity: 0.7,
}}
>
{badge.description}
</span>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,131 @@
'use client'
import React, { useState } from 'react'
import type { SessionCompletion } from '@/types'
interface HeatmapProps {
completions: SessionCompletion[]
totalWeeks?: number
}
function getHeatLevel(count: number): 0 | 1 | 2 | 3 {
if (count === 0) return 0
if (count === 1) return 1
if (count <= 3) return 2
return 3
}
const HEAT_COLORS = [
'var(--heatmap-0)',
'var(--heatmap-1)',
'var(--heatmap-2)',
'var(--heatmap-3)',
]
export function Heatmap({ completions, totalWeeks = 26 }: HeatmapProps) {
const [tooltip, setTooltip] = useState<{ week: number; count: number } | null>(null)
// Group completions by week_number
const weekMap = new Map<number, number>()
for (const c of completions) {
weekMap.set(c.week_number, (weekMap.get(c.week_number) ?? 0) + 1)
}
const weeks = Array.from({ length: totalWeeks }, (_, i) => i + 1)
return (
<div>
<div
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: 'var(--fg-muted)',
marginBottom: 'var(--s-3)',
}}
>
26-week activity
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(13, 1fr)',
gap: '4px',
position: 'relative',
}}
>
{weeks.map((week) => {
const count = weekMap.get(week) ?? 0
const level = getHeatLevel(count)
return (
<div
key={week}
onMouseEnter={() => setTooltip({ week, count })}
onMouseLeave={() => setTooltip(null)}
onFocus={() => setTooltip({ week, count })}
onBlur={() => setTooltip(null)}
tabIndex={0}
role="gridcell"
aria-label={`Week ${week}: ${count} completion${count !== 1 ? 's' : ''}`}
style={{
aspectRatio: '1',
borderRadius: 'var(--r-sm)',
background: HEAT_COLORS[level],
cursor: 'pointer',
transition: 'transform 100ms',
outline: 'none',
}}
onMouseDown={(e) => {
e.currentTarget.style.transform = 'scale(0.9)'
}}
onMouseUp={(e) => {
e.currentTarget.style.transform = 'scale(1)'
}}
/>
)
})}
</div>
{tooltip && (
<div
style={{
marginTop: 'var(--s-2)',
fontSize: 'var(--t-small)',
color: 'var(--fg-muted)',
textAlign: 'center',
}}
>
Week {tooltip.week}: {tooltip.count} completion{tooltip.count !== 1 ? 's' : ''}
</div>
)}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--s-2)',
marginTop: 'var(--s-3)',
justifyContent: 'flex-end',
}}
>
<span style={{ fontSize: 'var(--t-label)', fontFamily: 'var(--font-mono)', color: 'var(--fg-subtle)' }}>
Less
</span>
{HEAT_COLORS.map((color, i) => (
<div
key={i}
style={{
width: '12px',
height: '12px',
borderRadius: '3px',
background: color,
}}
/>
))}
<span style={{ fontSize: 'var(--t-label)', fontFamily: 'var(--font-mono)', color: 'var(--fg-subtle)' }}>
More
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,123 @@
'use client'
import React from 'react'
import type { LeaderboardEntry } from '@/types'
const LEVEL_EMOJI: Record<string, string> = {
intern: '🌱',
junior: '📗',
medior: '📘',
senior: '📙',
staff: '🔷',
principal: '🏆',
}
interface LeaderboardProps {
entries: LeaderboardEntry[]
currentUserId?: string
}
export function Leaderboard({ entries, currentUserId }: LeaderboardProps) {
return (
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: 'var(--t-small)',
minWidth: '480px',
}}
>
<thead>
<tr>
{['#', 'Name', 'Commits', 'Streak', 'Types', 'Badges', 'Level'].map((col) => (
<th
key={col}
style={{
padding: 'var(--s-2) var(--s-3)',
textAlign: col === '#' || col === 'Name' ? 'left' : 'center',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: 'var(--fg-muted)',
borderBottom: '2px solid var(--cream)',
background: 'var(--bg)',
position: col === 'Name' ? 'sticky' : undefined,
left: col === 'Name' ? '28px' : undefined,
whiteSpace: 'nowrap',
}}
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{entries.map((entry, idx) => {
const isMe = entry.userId === currentUserId
return (
<tr
key={entry.userId}
style={{
background: isMe ? 'var(--accent-soft)' : idx % 2 === 0 ? 'var(--bg)' : 'var(--bg-warm)',
}}
>
<td
style={{
padding: 'var(--s-2) var(--s-3)',
color: 'var(--fg-muted)',
fontWeight: idx < 3 ? 700 : 400,
whiteSpace: 'nowrap',
}}
>
{idx === 0 ? '🥇' : idx === 1 ? '🥈' : idx === 2 ? '🥉' : idx + 1}
</td>
<td
style={{
padding: 'var(--s-2) var(--s-3)',
fontWeight: isMe ? 700 : 500,
color: isMe ? 'var(--teal-900)' : 'var(--fg)',
position: 'sticky',
left: '28px',
background: isMe ? 'var(--accent-soft)' : idx % 2 === 0 ? 'var(--bg)' : 'var(--bg-warm)',
whiteSpace: 'nowrap',
}}
>
{entry.displayName}
{isMe && (
<span
style={{
marginLeft: 'var(--s-2)',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
color: 'var(--teal-700)',
}}
>
you
</span>
)}
</td>
<td style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'center', fontWeight: 600 }}>
{entry.totalCommits}
</td>
<td style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'center' }}>
{entry.currentStreak}w
</td>
<td style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'center' }}>
{entry.typesUsed}
</td>
<td style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'center' }}>
{entry.badgeCount}
</td>
<td style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'center', whiteSpace: 'nowrap' }}>
{LEVEL_EMOJI[entry.level] ?? ''} {entry.level}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,97 @@
'use client'
import React, { useState } from 'react'
import { Button } from '@/components/ui/Button'
interface CaseStudyContent {
type: 'case_study'
scenario: string
questions: string[]
}
interface Props {
content: CaseStudyContent
onComplete: () => void
}
export function CaseStudy({ content, onComplete }: Props) {
const [answers, setAnswers] = useState<Record<number, string>>({})
const allAnswered = content.questions.every((_, i) => (answers[i] ?? '').trim().length > 10)
function setAnswer(idx: number, val: string) {
setAnswers((prev) => ({ ...prev, [idx]: val }))
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)' }}>
<div
style={{
background: 'var(--bg-warm)',
borderRadius: 'var(--r-md)',
padding: 'var(--s-5)',
fontSize: 'var(--t-body)',
lineHeight: 1.7,
color: 'var(--fg)',
}}
>
<p
style={{
margin: '0 0 var(--s-3)',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: 'var(--fg-muted)',
}}
>
Scenario
</p>
{content.scenario}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
{content.questions.map((q, idx) => (
<div key={idx} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
<label
style={{
fontSize: 'var(--t-body)',
fontWeight: 600,
color: 'var(--fg)',
lineHeight: 1.5,
}}
>
{idx + 1}. {q}
</label>
<textarea
value={answers[idx] ?? ''}
onChange={(e) => setAnswer(idx, e.target.value)}
placeholder="Your answer…"
rows={3}
style={{
width: '100%',
borderRadius: 'var(--r-sm)',
border: '1.5px solid var(--cream)',
padding: 'var(--s-3)',
fontSize: 'var(--t-body)',
fontFamily: 'var(--font-sans)',
color: 'var(--fg)',
background: 'var(--bg)',
resize: 'vertical',
outline: 'none',
lineHeight: 1.5,
}}
/>
</div>
))}
</div>
<Button
variant="primary"
onClick={onComplete}
disabled={!allAnswered}
style={{ alignSelf: 'flex-end' }}
>
Submit reflections
</Button>
</div>
)
}

View File

@@ -0,0 +1,127 @@
'use client'
import React from 'react'
import { Button } from '@/components/ui/Button'
interface ComparisonDimension {
label: string
a: string
b: string
}
interface ComparisonCardContent {
type: 'comparison_card'
subject_a: string
subject_b: string
dimensions: ComparisonDimension[]
}
interface Props {
content: ComparisonCardContent
onComplete: () => void
}
export function ComparisonCard({ content, onComplete }: Props) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: 'var(--t-body)',
minWidth: '320px',
}}
>
<thead>
<tr>
<th
style={{
width: '28%',
padding: 'var(--s-2) var(--s-3)',
textAlign: 'left',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: 'var(--fg-muted)',
border: '1px solid var(--cream)',
background: 'var(--bg-warm)',
}}
>
Dimension
</th>
<th
style={{
width: '36%',
padding: 'var(--s-2) var(--s-3)',
textAlign: 'left',
fontWeight: 700,
color: 'var(--teal)',
border: '1px solid var(--cream)',
background: 'var(--bg-warm)',
}}
>
{content.subject_a}
</th>
<th
style={{
width: '36%',
padding: 'var(--s-2) var(--s-3)',
textAlign: 'left',
fontWeight: 700,
color: 'var(--accent)',
border: '1px solid var(--cream)',
background: 'var(--bg-warm)',
}}
>
{content.subject_b}
</th>
</tr>
</thead>
<tbody>
{content.dimensions.map((dim, idx) => (
<tr key={idx} style={{ background: idx % 2 === 0 ? 'var(--bg)' : 'var(--bg-warm)' }}>
<td
style={{
padding: 'var(--s-2) var(--s-3)',
fontWeight: 600,
fontSize: 'var(--t-small)',
color: 'var(--fg-muted)',
border: '1px solid var(--cream)',
verticalAlign: 'top',
}}
>
{dim.label}
</td>
<td
style={{
padding: 'var(--s-2) var(--s-3)',
border: '1px solid var(--cream)',
verticalAlign: 'top',
lineHeight: 1.5,
}}
>
{dim.a}
</td>
<td
style={{
padding: 'var(--s-2) var(--s-3)',
border: '1px solid var(--cream)',
verticalAlign: 'top',
lineHeight: 1.5,
}}
>
{dim.b}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Button variant="primary" onClick={onComplete} style={{ alignSelf: 'flex-end' }}>
Mark complete
</Button>
</div>
)
}

View File

@@ -0,0 +1,67 @@
'use client'
import React from 'react'
import { Button } from '@/components/ui/Button'
interface ConceptExplainerContent {
type: 'concept_explainer'
paragraphs: string[]
example: string
}
interface Props {
content: ConceptExplainerContent
onComplete: () => void
}
export function ConceptExplainer({ content, onComplete }: Props) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-3)' }}>
{content.paragraphs.map((para, i) => (
<p
key={i}
style={{
margin: 0,
fontSize: 'var(--t-body)',
lineHeight: 1.65,
color: 'var(--fg)',
}}
>
{para}
</p>
))}
</div>
{content.example && (
<div
style={{
background: 'var(--bg-warm)',
borderLeft: '4px solid var(--accent)',
borderRadius: 'var(--r-sm)',
padding: 'var(--s-4)',
}}
>
<p
style={{
margin: '0 0 var(--s-2)',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: 'var(--fg-muted)',
}}
>
Example
</p>
<p style={{ margin: 0, fontSize: 'var(--t-body)', lineHeight: 1.6 }}>
{content.example}
</p>
</div>
)}
<Button variant="primary" onClick={onComplete} style={{ alignSelf: 'flex-end' }}>
Mark complete
</Button>
</div>
)
}

View File

@@ -0,0 +1,121 @@
'use client'
import React, { useState } from 'react'
import { Button } from '@/components/ui/Button'
interface Flashcard {
question: string
answer: string
}
interface FlashcardSetContent {
type: 'flashcard_set'
cards: Flashcard[]
}
interface Props {
content: FlashcardSetContent
onComplete: () => void
}
export function FlashcardSet({ content, onComplete }: Props) {
const [currentIdx, setCurrentIdx] = useState(0)
const [flipped, setFlipped] = useState(false)
const [done, setDone] = useState(false)
const card = content.cards[currentIdx]
const isLast = currentIdx === content.cards.length - 1
function handleNext() {
if (isLast) {
setDone(true)
} else {
setCurrentIdx((i) => i + 1)
setFlipped(false)
}
}
function handlePrev() {
if (currentIdx > 0) {
setCurrentIdx((i) => i - 1)
setFlipped(false)
}
}
if (!card) return null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)', alignItems: 'center' }}>
<div style={{ fontSize: 'var(--t-small)', color: 'var(--fg-muted)', alignSelf: 'flex-end' }}>
{currentIdx + 1} / {content.cards.length}
</div>
<button
onClick={() => setFlipped((f) => !f)}
style={{
width: '100%',
minHeight: '200px',
borderRadius: 'var(--r-lg)',
border: '1.5px solid var(--cream)',
background: flipped ? 'var(--bg-dark)' : 'var(--bg)',
color: flipped ? 'var(--paper)' : 'var(--fg)',
padding: 'var(--s-6)',
fontSize: 'var(--t-lead)',
lineHeight: 1.6,
cursor: 'pointer',
textAlign: 'center',
fontFamily: 'var(--font-sans)',
transition: 'background 250ms, color 250ms',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--s-3)',
boxShadow: 'var(--shadow-soft)',
}}
aria-label={flipped ? 'Answer — tap to see question' : 'Question — tap to reveal answer'}
>
<span
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
opacity: 0.65,
}}
>
{flipped ? 'Answer' : 'Question'}
</span>
<span>{flipped ? card.answer : card.question}</span>
<span style={{ fontSize: 'var(--t-small)', opacity: 0.5, marginTop: 'var(--s-2)' }}>
Tap to flip
</span>
</button>
<div style={{ display: 'flex', gap: 'var(--s-3)', width: '100%' }}>
<Button
variant="secondary"
onClick={handlePrev}
disabled={currentIdx === 0}
style={{ flex: 1 }}
>
Previous
</Button>
{flipped ? (
<Button variant="primary" onClick={handleNext} style={{ flex: 1 }}>
{isLast ? 'Finish' : 'Next'}
</Button>
) : (
<Button variant="ghost" onClick={() => setFlipped(true)} style={{ flex: 1 }}>
Reveal
</Button>
)}
</div>
{done && (
<Button variant="primary" onClick={onComplete} style={{ width: '100%' }}>
All cards done mark complete
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,116 @@
'use client'
import React from 'react'
import { Button } from '@/components/ui/Button'
interface GlossaryAnchorContent {
type: 'glossary_anchor'
term: string
definition: string
correct_use: string
misuse: string
}
interface Props {
content: GlossaryAnchorContent
onComplete: () => void
}
export function GlossaryAnchor({ content, onComplete }: Props) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div
style={{
background: 'var(--bg-dark)',
color: 'var(--paper)',
borderRadius: 'var(--r-lg)',
padding: 'var(--s-5)',
textAlign: 'center',
}}
>
<p
style={{
margin: '0 0 var(--s-2)',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
opacity: 0.65,
}}
>
Term
</p>
<h2
style={{
margin: '0 0 var(--s-4)',
fontSize: 'var(--t-h3)',
fontWeight: 700,
fontFamily: 'var(--font-sans)',
}}
>
{content.term}
</h2>
<p style={{ margin: 0, fontSize: 'var(--t-body)', lineHeight: 1.65, opacity: 0.9 }}>
{content.definition}
</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-3)' }}>
<Section
label="Correct use"
text={content.correct_use}
borderColor="var(--success)"
labelColor="var(--teal-700)"
/>
<Section
label="Common misuse"
text={content.misuse}
borderColor="#f5c6cb"
labelColor="#c0392b"
/>
</div>
<Button variant="primary" onClick={onComplete} style={{ alignSelf: 'flex-end' }}>
Got it
</Button>
</div>
)
}
function Section({
label,
text,
borderColor,
labelColor,
}: {
label: string
text: string
borderColor: string
labelColor: string
}) {
return (
<div
style={{
borderLeft: `4px solid ${borderColor}`,
background: 'var(--bg-warm)',
borderRadius: 'var(--r-sm)',
padding: 'var(--s-4)',
}}
>
<p
style={{
margin: '0 0 var(--s-2)',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: labelColor,
}}
>
{label}
</p>
<p style={{ margin: 0, fontSize: 'var(--t-body)', lineHeight: 1.6, color: 'var(--fg)' }}>
{text}
</p>
</div>
)
}

View File

@@ -0,0 +1,96 @@
'use client'
import React, { useState } from 'react'
import { Button } from '@/components/ui/Button'
interface HowToStep {
number: number
instruction: string
}
interface HowToContent {
type: 'how_to'
steps: HowToStep[]
}
interface Props {
content: HowToContent
onComplete: () => void
}
export function HowTo({ content, onComplete }: Props) {
const [checked, setChecked] = useState<Set<number>>(new Set())
const allDone = checked.size === content.steps.length
function toggle(num: number) {
setChecked((prev) => {
const next = new Set(prev)
if (next.has(num)) {
next.delete(num)
} else {
next.add(num)
}
return next
})
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<ol style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
{content.steps.map((step) => {
const done = checked.has(step.number)
return (
<li key={step.number}>
<button
onClick={() => toggle(step.number)}
style={{
width: '100%',
background: done ? 'var(--bg-warm)' : 'var(--bg)',
border: '1.5px solid var(--cream)',
borderRadius: 'var(--r-sm)',
padding: 'var(--s-3) var(--s-4)',
textAlign: 'left',
fontSize: 'var(--t-body)',
color: done ? 'var(--fg-muted)' : 'var(--fg)',
fontFamily: 'var(--font-sans)',
cursor: 'pointer',
minHeight: '44px',
display: 'flex',
gap: 'var(--s-3)',
alignItems: 'flex-start',
transition: 'background 150ms',
}}
>
<span
style={{
width: '28px',
height: '28px',
borderRadius: '50%',
background: done ? 'var(--success)' : 'var(--cream)',
color: done ? 'var(--teal-900)' : 'var(--fg-muted)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 'var(--t-small)',
fontWeight: 700,
flexShrink: 0,
}}
>
{done ? '✓' : step.number}
</span>
<span style={{ textDecoration: done ? 'line-through' : 'none', lineHeight: 1.6 }}>
{step.instruction}
</span>
</button>
</li>
)
})}
</ol>
{allDone && (
<Button variant="primary" onClick={onComplete} style={{ alignSelf: 'flex-end' }}>
All done continue
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,51 @@
'use client'
import React from 'react'
import type { MicroLearningContent } from '@/types'
import { ConceptExplainer } from './ConceptExplainer'
import { ScenarioQuiz } from './ScenarioQuiz'
import { Misconceptions } from './Misconceptions'
import { HowTo } from './HowTo'
import { ComparisonCard } from './ComparisonCard'
import { ReflectionPrompt } from './ReflectionPrompt'
import { FlashcardSet } from './FlashcardSet'
import { CaseStudy } from './CaseStudy'
import { GlossaryAnchor } from './GlossaryAnchor'
import { MythVsEvidence } from './MythVsEvidence'
interface Props {
content: MicroLearningContent
onComplete: () => void
}
export function MicroLearningRenderer({ content, onComplete }: Props) {
switch (content.type) {
case 'concept_explainer':
return <ConceptExplainer content={content} onComplete={onComplete} />
case 'scenario_quiz':
return <ScenarioQuiz content={content} onComplete={onComplete} />
case 'misconceptions':
return <Misconceptions content={content} onComplete={onComplete} />
case 'how_to':
return <HowTo content={content} onComplete={onComplete} />
case 'comparison_card':
return <ComparisonCard content={content} onComplete={onComplete} />
case 'reflection_prompt':
return <ReflectionPrompt content={content} onComplete={onComplete} />
case 'flashcard_set':
return <FlashcardSet content={content} onComplete={onComplete} />
case 'case_study':
return <CaseStudy content={content} onComplete={onComplete} />
case 'glossary_anchor':
return <GlossaryAnchor content={content} onComplete={onComplete} />
case 'myth_vs_evidence':
return <MythVsEvidence content={content} onComplete={onComplete} />
default: {
const _exhaustive: never = content
return (
<div style={{ color: 'var(--fg-muted)', fontSize: 'var(--t-body)' }}>
Unknown content type.
</div>
)
}
}
}

View File

@@ -0,0 +1,125 @@
'use client'
import React, { useState } from 'react'
import { Button } from '@/components/ui/Button'
interface MisconceptionItem {
misconception: string
correction: string
}
interface MisconceptionsContent {
type: 'misconceptions'
items: MisconceptionItem[]
}
interface Props {
content: MisconceptionsContent
onComplete: () => void
}
export function Misconceptions({ content, onComplete }: Props) {
const [expanded, setExpanded] = useState<Set<number>>(new Set())
function toggle(idx: number) {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(idx)) {
next.delete(idx)
} else {
next.add(idx)
}
return next
})
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
{content.items.map((item, idx) => {
const open = expanded.has(idx)
return (
<div
key={idx}
style={{
border: '1.5px solid var(--cream)',
borderRadius: 'var(--r-sm)',
overflow: 'hidden',
}}
>
<button
onClick={() => toggle(idx)}
style={{
width: '100%',
background: open ? 'var(--bg-warm)' : 'var(--bg)',
border: 'none',
padding: 'var(--s-3) var(--s-4)',
textAlign: 'left',
fontSize: 'var(--t-body)',
color: 'var(--fg)',
fontFamily: 'var(--font-sans)',
cursor: 'pointer',
minHeight: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 'var(--s-3)',
fontWeight: 500,
}}
aria-expanded={open}
>
<span>
<span
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: '#c0392b',
marginRight: 'var(--s-2)',
}}
>
Myth
</span>
{item.misconception}
</span>
<span style={{ flexShrink: 0, fontSize: '18px', color: 'var(--fg-muted)' }}>
{open ? '' : '+'}
</span>
</button>
{open && (
<div
style={{
padding: 'var(--s-3) var(--s-4)',
background: 'var(--bg)',
borderTop: '1px solid var(--cream)',
fontSize: 'var(--t-body)',
lineHeight: 1.6,
color: 'var(--fg)',
}}
>
<span
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: 'var(--teal-700)',
marginRight: 'var(--s-2)',
}}
>
Correction
</span>
{item.correction}
</div>
)}
</div>
)
})}
</div>
<Button variant="primary" onClick={onComplete} style={{ alignSelf: 'flex-end' }}>
Mark complete
</Button>
</div>
)
}

View File

@@ -0,0 +1,112 @@
'use client'
import React, { useState } from 'react'
import { Button } from '@/components/ui/Button'
interface MythVsEvidenceContent {
type: 'myth_vs_evidence'
myth: string
evidence: string
sources: string[]
}
interface Props {
content: MythVsEvidenceContent
onComplete: () => void
}
export function MythVsEvidence({ content, onComplete }: Props) {
const [revealed, setRevealed] = useState(false)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div
style={{
background: '#f8d7da',
borderRadius: 'var(--r-md)',
padding: 'var(--s-5)',
}}
>
<p
style={{
margin: '0 0 var(--s-2)',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: '#c0392b',
}}
>
Myth
</p>
<p style={{ margin: 0, fontSize: 'var(--t-body)', lineHeight: 1.65, color: 'var(--fg)' }}>
{content.myth}
</p>
</div>
{!revealed ? (
<Button variant="secondary" onClick={() => setRevealed(true)}>
Reveal the evidence
</Button>
) : (
<>
<div
style={{
background: 'var(--bg-warm)',
borderLeft: '4px solid var(--success)',
borderRadius: 'var(--r-sm)',
padding: 'var(--s-5)',
animation: 'fadeIn 300ms ease',
}}
>
<p
style={{
margin: '0 0 var(--s-2)',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: 'var(--teal-700)',
}}
>
Evidence
</p>
<p style={{ margin: 0, fontSize: 'var(--t-body)', lineHeight: 1.65, color: 'var(--fg)' }}>
{content.evidence}
</p>
</div>
{content.sources.length > 0 && (
<div>
<p
style={{
margin: '0 0 var(--s-2)',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: 'var(--fg-subtle)',
}}
>
Sources
</p>
<ul style={{ margin: 0, padding: '0 0 0 var(--s-4)' }}>
{content.sources.map((src, i) => (
<li
key={i}
style={{ fontSize: 'var(--t-small)', color: 'var(--fg-muted)', lineHeight: 1.6 }}
>
{src}
</li>
))}
</ul>
</div>
)}
<Button variant="primary" onClick={onComplete} style={{ alignSelf: 'flex-end' }}>
Mark complete
</Button>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,96 @@
'use client'
import React, { useState } from 'react'
import { Button } from '@/components/ui/Button'
interface ReflectionPromptContent {
type: 'reflection_prompt'
prompt: string
model_answer: string
}
interface Props {
content: ReflectionPromptContent
onComplete: () => void
}
export function ReflectionPrompt({ content, onComplete }: Props) {
const [answer, setAnswer] = useState('')
const [showModel, setShowModel] = useState(false)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div
style={{
background: 'var(--bg-dark)',
color: 'var(--paper)',
borderRadius: 'var(--r-md)',
padding: 'var(--s-5)',
fontSize: 'var(--t-lead)',
lineHeight: 1.6,
fontWeight: 500,
}}
>
{content.prompt}
</div>
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Write your reflection here…"
rows={5}
style={{
width: '100%',
borderRadius: 'var(--r-sm)',
border: '1.5px solid var(--cream)',
padding: 'var(--s-3) var(--s-4)',
fontSize: 'var(--t-body)',
fontFamily: 'var(--font-sans)',
color: 'var(--fg)',
background: 'var(--bg)',
resize: 'vertical',
outline: 'none',
lineHeight: 1.6,
}}
/>
{answer.trim().length > 20 && !showModel && (
<Button variant="secondary" onClick={() => setShowModel(true)}>
Show model answer
</Button>
)}
{showModel && (
<div
style={{
background: 'var(--bg-warm)',
borderRadius: 'var(--r-sm)',
padding: 'var(--s-4)',
borderLeft: '4px solid var(--success)',
}}
>
<p
style={{
margin: '0 0 var(--s-2)',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
color: 'var(--teal-700)',
}}
>
Model answer
</p>
<p style={{ margin: 0, fontSize: 'var(--t-body)', lineHeight: 1.65 }}>
{content.model_answer}
</p>
</div>
)}
{showModel && (
<Button variant="primary" onClick={onComplete} style={{ alignSelf: 'flex-end' }}>
Continue
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,147 @@
'use client'
import React, { useState } from 'react'
import { Button } from '@/components/ui/Button'
interface QuizOption {
label: string
text: string
correct: boolean
explanation: string
}
interface ScenarioQuizContent {
type: 'scenario_quiz'
scenario: string
options: QuizOption[]
}
interface Props {
content: ScenarioQuizContent
onComplete: () => void
}
export function ScenarioQuiz({ content, onComplete }: Props) {
const [selected, setSelected] = useState<number | null>(null)
const [revealed, setRevealed] = useState(false)
function handleSelect(idx: number) {
if (revealed) return
setSelected(idx)
setRevealed(true)
}
const isCorrect = selected !== null && content.options[selected]?.correct
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div
style={{
background: 'var(--bg-warm)',
borderRadius: 'var(--r-md)',
padding: 'var(--s-4)',
fontSize: 'var(--t-body)',
lineHeight: 1.65,
color: 'var(--fg)',
}}
>
{content.scenario}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
{content.options.map((opt, idx) => {
let bg = 'var(--bg-warm)'
let border = '1.5px solid var(--cream)'
let color = 'var(--fg)'
if (revealed && selected === idx) {
if (opt.correct) {
bg = 'var(--success)'
border = '1.5px solid var(--teal-700)'
color = 'var(--teal-900)'
} else {
bg = '#f8d7da'
border = '1.5px solid #f5c6cb'
color = '#c0392b'
}
} else if (revealed && opt.correct) {
bg = 'var(--success)'
border = '1.5px solid var(--teal-700)'
color = 'var(--teal-900)'
}
return (
<div key={idx} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
<button
onClick={() => handleSelect(idx)}
disabled={revealed}
style={{
background: bg,
border,
borderRadius: 'var(--r-sm)',
padding: 'var(--s-3) var(--s-4)',
fontSize: 'var(--t-body)',
color,
textAlign: 'left',
cursor: revealed ? 'default' : 'pointer',
minHeight: '44px',
transition: 'background 200ms, border-color 200ms',
display: 'flex',
gap: 'var(--s-3)',
alignItems: 'flex-start',
fontFamily: 'var(--font-sans)',
}}
>
<span
style={{
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
fontWeight: 700,
textTransform: 'uppercase',
flexShrink: 0,
paddingTop: '2px',
}}
>
{opt.label}
</span>
<span>{opt.text}</span>
</button>
{revealed && selected === idx && (
<div
style={{
fontSize: 'var(--t-small)',
color: 'var(--fg-muted)',
padding: '0 var(--s-4)',
lineHeight: 1.5,
}}
>
{opt.explanation}
</div>
)}
</div>
)
})}
</div>
{revealed && (
<div
style={{
padding: 'var(--s-3) var(--s-4)',
borderRadius: 'var(--r-sm)',
background: isCorrect ? 'var(--success)' : '#f8d7da',
color: isCorrect ? 'var(--teal-900)' : '#c0392b',
fontSize: 'var(--t-body)',
fontWeight: 500,
}}
>
{isCorrect ? 'Correct!' : 'Not quite — see the highlighted answer above.'}
</div>
)}
{revealed && (
<Button variant="primary" onClick={onComplete} style={{ alignSelf: 'flex-end' }}>
Continue
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
import React from 'react'
interface R42ButtonProps {
onClick: () => void
}
export function R42Button({ onClick }: R42ButtonProps) {
return (
<button
onClick={onClick}
aria-label="Open R42 assistant"
style={{
position: 'fixed',
bottom: 'calc(var(--nav-height) + 16px)',
right: '16px',
width: 'var(--r42-size)',
height: 'var(--r42-size)',
borderRadius: '50%',
background: 'var(--accent)',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'var(--shadow-pill)',
zIndex: 200,
transition: 'transform 150ms, box-shadow 150ms',
color: 'var(--paper)',
fontSize: '22px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.08)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'
}}
>
R
</button>
)
}

View File

@@ -0,0 +1,417 @@
'use client'
import React, { useState, useRef, useEffect, useCallback } from 'react'
import type { R42SSEEvent } from '@/types'
import { Button } from '@/components/ui/Button'
interface CitationTopic {
id: string
title: string
}
interface Message {
role: 'user' | 'assistant'
text: string
citations?: CitationTopic[]
outOfScope?: boolean
}
interface R42DrawerProps {
open: boolean
onClose: () => void
userId: string
}
const CHAT_URL = process.env.NEXT_PUBLIC_CHAT_URL ?? 'http://localhost:3004'
export function R42Drawer({ open, onClose, userId }: R42DrawerProps) {
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const [error, setError] = useState<string | null>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
if (open) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}
}, [messages, open])
const handleClose = useCallback(() => {
abortRef.current?.abort()
onClose()
}, [onClose])
async function sendMessage() {
const text = input.trim()
if (!text || streaming) return
setInput('')
setError(null)
const userMsg: Message = { role: 'user', text }
setMessages((prev) => [...prev, userMsg])
const ctrl = new AbortController()
abortRef.current = ctrl
setStreaming(true)
let assistantText = ''
let citations: CitationTopic[] = []
let outOfScope = false
setMessages((prev) => [...prev, { role: 'assistant', text: '' }])
try {
const res = await fetch(`${CHAT_URL}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, message: text }),
signal: ctrl.signal,
})
if (!res.ok) {
throw new Error(`Chat service error: ${res.status}`)
}
const reader = res.body?.getReader()
if (!reader) throw new Error('No response body')
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const raw = line.slice(6).trim()
if (raw === '[DONE]') continue
try {
const event = JSON.parse(raw) as R42SSEEvent
if (event.type === 'chunk') {
assistantText += event.text
setMessages((prev) => {
const copy = [...prev]
copy[copy.length - 1] = { role: 'assistant', text: assistantText }
return copy
})
} else if (event.type === 'citations') {
citations = event.topics
} else if (event.type === 'out_of_scope') {
outOfScope = true
assistantText = event.text
setMessages((prev) => {
const copy = [...prev]
copy[copy.length - 1] = { role: 'assistant', text: assistantText, outOfScope: true }
return copy
})
} else if (event.type === 'done') {
setMessages((prev) => {
const copy = [...prev]
copy[copy.length - 1] = {
role: 'assistant',
text: assistantText,
citations: citations.length > 0 ? citations : undefined,
outOfScope,
}
return copy
})
}
} catch {
// skip malformed SSE lines
}
}
}
} catch (err) {
if ((err as Error).name === 'AbortError') return
setError(err instanceof Error ? err.message : 'Failed to send message')
setMessages((prev) => prev.slice(0, -1))
} finally {
setStreaming(false)
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
void sendMessage()
}
}
if (!open) return null
return (
<>
{/* Backdrop */}
<div
onClick={handleClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.35)',
zIndex: 300,
}}
aria-hidden="true"
/>
{/* Panel */}
<div
role="dialog"
aria-label="R42 Assistant"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
top: 'auto',
maxHeight: '85dvh',
background: 'var(--bg)',
borderTopLeftRadius: 'var(--r-xl)',
borderTopRightRadius: 'var(--r-xl)',
boxShadow: '0 -8px 40px rgba(0,0,0,0.18)',
zIndex: 301,
display: 'flex',
flexDirection: 'column',
// Desktop: side panel
}}
>
{/* Header */}
<div
style={{
padding: 'var(--s-4) var(--s-5)',
borderBottom: '1px solid var(--cream)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-3)' }}>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: 'var(--accent)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--paper)',
fontWeight: 700,
fontSize: 'var(--t-small)',
}}
>
R
</div>
<span style={{ fontWeight: 600, fontSize: 'var(--t-body)', color: 'var(--fg)' }}>
R42
</span>
</div>
<button
onClick={handleClose}
aria-label="Close R42"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--fg-muted)',
fontSize: '24px',
lineHeight: 1,
padding: 'var(--s-1)',
minWidth: '44px',
minHeight: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
×
</button>
</div>
{/* Messages */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: 'var(--s-4) var(--s-5)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--s-4)',
}}
>
{messages.length === 0 && (
<div
style={{
textAlign: 'center',
color: 'var(--fg-muted)',
fontSize: 'var(--t-body)',
marginTop: 'var(--s-6)',
}}
>
Ask R42 anything about the knowledge base.
</div>
)}
{messages.map((msg, idx) => (
<div
key={idx}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start',
gap: 'var(--s-2)',
}}
>
<div
style={{
maxWidth: '85%',
background: msg.role === 'user' ? 'var(--accent)' : 'var(--bg-warm)',
color: msg.role === 'user' ? 'var(--paper)' : 'var(--fg)',
borderRadius:
msg.role === 'user'
? 'var(--r-md) var(--r-md) var(--r-sm) var(--r-md)'
: 'var(--r-md) var(--r-md) var(--r-md) var(--r-sm)',
padding: 'var(--s-3) var(--s-4)',
fontSize: 'var(--t-body)',
lineHeight: 1.6,
opacity: msg.outOfScope ? 0.75 : 1,
}}
>
{msg.text || (msg.role === 'assistant' && streaming && idx === messages.length - 1 ? (
<StreamingDots />
) : null)}
</div>
{msg.citations && msg.citations.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--s-2)', maxWidth: '85%' }}>
{msg.citations.map((c) => (
<span
key={c.id}
style={{
display: 'inline-block',
background: 'var(--accent-soft)',
color: 'var(--teal-900)',
borderRadius: 'var(--r-pill)',
fontSize: 'var(--t-small)',
padding: '2px var(--s-3)',
}}
>
{c.title}
</span>
))}
</div>
)}
</div>
))}
{error && (
<div
style={{
color: '#c0392b',
fontSize: 'var(--t-small)',
padding: 'var(--s-2) var(--s-4)',
background: '#f8d7da',
borderRadius: 'var(--r-sm)',
}}
>
{error}
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div
style={{
padding: 'var(--s-3) var(--s-5) var(--s-5)',
borderTop: '1px solid var(--cream)',
display: 'flex',
gap: 'var(--s-2)',
alignItems: 'flex-end',
flexShrink: 0,
}}
>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask anything…"
rows={1}
disabled={streaming}
style={{
flex: 1,
borderRadius: 'var(--r-md)',
border: '1.5px solid var(--cream)',
padding: 'var(--s-3) var(--s-4)',
fontSize: 'var(--t-body)',
fontFamily: 'var(--font-sans)',
color: 'var(--fg)',
background: 'var(--bg)',
resize: 'none',
outline: 'none',
lineHeight: 1.5,
minHeight: '44px',
maxHeight: '120px',
overflowY: 'auto',
}}
/>
<Button
variant="primary"
size="md"
onClick={() => void sendMessage()}
loading={streaming}
disabled={!input.trim() || streaming}
style={{ flexShrink: 0, minWidth: '44px', minHeight: '44px' }}
>
{streaming ? '' : '↑'}
</Button>
</div>
</div>
<style>{`
@media (min-width: 768px) {
[role="dialog"][aria-label="R42 Assistant"] {
right: 0;
left: auto;
top: 0;
bottom: 0;
max-height: 100dvh;
width: 400px;
border-radius: var(--r-lg) 0 0 var(--r-lg);
}
}
`}</style>
</>
)
}
function StreamingDots() {
return (
<span style={{ display: 'inline-flex', gap: '4px', alignItems: 'center' }}>
{[0, 1, 2].map((i) => (
<span
key={i}
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: 'var(--fg-muted)',
animation: `bounce 1s ${i * 0.15}s infinite`,
}}
/>
))}
<style>{`
@keyframes bounce {
0%, 80%, 100% { transform: scale(0.7); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
`}</style>
</span>
)
}

View File

@@ -0,0 +1,40 @@
'use client'
import React from 'react'
type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'accent'
interface BadgeProps {
label: string
variant?: BadgeVariant
style?: React.CSSProperties
}
const variantStyles: Record<BadgeVariant, React.CSSProperties> = {
default: { background: 'var(--bg-warm)', color: 'var(--fg-muted)' },
success: { background: 'var(--success)', color: 'var(--teal-900)' },
warning: { background: '#f0c040', color: '#5a3e00' },
error: { background: '#f8d7da', color: '#c0392b' },
info: { background: 'var(--accent-soft)', color: 'var(--teal-900)' },
accent: { background: 'var(--accent)', color: 'var(--paper)' },
}
export function Badge({ label, variant = 'default', style }: BadgeProps) {
return (
<span
style={{
display: 'inline-block',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
padding: '2px var(--s-2)',
borderRadius: 'var(--r-pill)',
...variantStyles[variant],
...style,
}}
>
{label}
</span>
)
}

View File

@@ -0,0 +1,112 @@
'use client'
import React from 'react'
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
type ButtonSize = 'sm' | 'md' | 'lg'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
children: React.ReactNode
}
const variantStyles: Record<ButtonVariant, React.CSSProperties> = {
primary: {
background: 'var(--accent)',
color: 'var(--paper)',
border: 'none',
},
secondary: {
background: 'var(--bg-warm)',
color: 'var(--fg)',
border: '1.5px solid var(--cream)',
},
danger: {
background: '#c0392b',
color: 'var(--paper)',
border: 'none',
},
ghost: {
background: 'transparent',
color: 'var(--fg)',
border: '1.5px solid var(--cream)',
},
}
const sizeStyles: Record<ButtonSize, React.CSSProperties> = {
sm: {
fontSize: 'var(--t-small)',
padding: 'var(--s-2) var(--s-3)',
minHeight: '36px',
borderRadius: 'var(--r-sm)',
},
md: {
fontSize: 'var(--t-body)',
padding: 'var(--s-2) var(--s-5)',
minHeight: '44px',
borderRadius: 'var(--r-md)',
},
lg: {
fontSize: 'var(--t-lead)',
padding: 'var(--s-3) var(--s-6)',
minHeight: '52px',
borderRadius: 'var(--r-lg)',
},
}
export function Button({
variant = 'primary',
size = 'md',
loading = false,
disabled,
children,
style,
...rest
}: ButtonProps) {
return (
<button
disabled={disabled ?? loading}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--s-2)',
fontFamily: 'var(--font-sans)',
fontWeight: 500,
cursor: disabled ?? loading ? 'not-allowed' : 'pointer',
opacity: disabled ?? loading ? 0.6 : 1,
transition: 'opacity 150ms, transform 100ms',
...variantStyles[variant],
...sizeStyles[size],
...style,
}}
{...rest}
>
{loading ? (
<>
<Spinner />
{children}
</>
) : (
children
)}
</button>
)
}
function Spinner() {
return (
<span
style={{
width: '16px',
height: '16px',
border: '2px solid currentColor',
borderTopColor: 'transparent',
borderRadius: '50%',
display: 'inline-block',
animation: 'spin 0.7s linear infinite',
}}
/>
)
}

View File

@@ -0,0 +1,54 @@
'use client'
import React from 'react'
interface ProgressBarProps {
value: number // 0100
label?: string
color?: string
}
export function ProgressBar({ value, label, color = 'var(--accent)' }: ProgressBarProps) {
const clamped = Math.min(100, Math.max(0, value))
return (
<div style={{ width: '100%' }}>
{label && (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 'var(--s-1)',
fontSize: 'var(--t-small)',
color: 'var(--fg-muted)',
}}
>
<span>{label}</span>
<span>{clamped}%</span>
</div>
)}
<div
style={{
width: '100%',
height: '8px',
background: 'var(--bg-warm)',
borderRadius: 'var(--r-pill)',
overflow: 'hidden',
}}
role="progressbar"
aria-valuenow={clamped}
aria-valuemin={0}
aria-valuemax={100}
>
<div
style={{
width: `${clamped}%`,
height: '100%',
background: color,
borderRadius: 'var(--r-pill)',
transition: 'width 400ms ease',
}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,75 @@
'use client'
import React from 'react'
import type { IngestionJobStatus } from '@/types'
type AnyStatus =
| IngestionJobStatus['status']
| 'processing'
| 'processed'
| 'failed'
| 'draft'
| 'published'
| 'rejected'
| 'queued'
| 'generated'
| 'active'
| 'superseded'
| 'introductory'
| 'intermediate'
| 'advanced'
const STATUS_CONFIG: Record<
AnyStatus,
{ label: string; background: string; color: string }
> = {
queued: { label: 'Queued', background: 'var(--cream)', color: 'var(--fg-muted)' },
extracting: { label: 'Extracting', background: '#fff3cd', color: '#856404' },
chunking: { label: 'Chunking', background: '#fff3cd', color: '#856404' },
structuring: { label: 'Structuring', background: '#cce5ff', color: '#004085' },
writing: { label: 'Writing', background: '#cce5ff', color: '#004085' },
embedding: { label: 'Embedding', background: '#d4edda', color: '#155724' },
done: { label: 'Done', background: 'var(--success)', color: 'var(--teal-900)' },
failed: { label: 'Failed', background: '#f8d7da', color: '#c0392b' },
processing: { label: 'Processing', background: '#fff3cd', color: '#856404' },
processed: { label: 'Processed', background: 'var(--success)', color: 'var(--teal-900)' },
draft: { label: 'Draft', background: 'var(--cream)', color: 'var(--fg-muted)' },
published: { label: 'Published', background: 'var(--success)', color: 'var(--teal-900)' },
rejected: { label: 'Rejected', background: '#f8d7da', color: '#c0392b' },
generated: { label: 'Generated', background: '#cce5ff', color: '#004085' },
active: { label: 'Active', background: 'var(--success)', color: 'var(--teal-900)' },
superseded: { label: 'Superseded', background: 'var(--cream)', color: 'var(--fg-muted)' },
introductory: { label: 'Introductory', background: 'var(--accent-soft)', color: 'var(--teal-900)' },
intermediate: { label: 'Intermediate', background: '#fff3cd', color: '#856404' },
advanced: { label: 'Advanced', background: 'var(--peri)', color: 'var(--teal-900)' },
}
interface StatusBadgeProps {
status: AnyStatus
}
export function StatusBadge({ status }: StatusBadgeProps) {
const config = STATUS_CONFIG[status] ?? {
label: status,
background: 'var(--cream)',
color: 'var(--fg-muted)',
}
return (
<span
style={{
display: 'inline-block',
padding: '2px var(--s-2)',
borderRadius: 'var(--r-pill)',
fontSize: 'var(--t-label)',
fontFamily: 'var(--font-mono)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: 'var(--label-ls)',
background: config.background,
color: config.color,
}}
>
{config.label}
</span>
)
}

View File

@@ -0,0 +1,113 @@
'use client'
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
type ToastType = 'success' | 'error' | 'info'
interface ToastItem {
id: string
message: string
type: ToastType
}
interface ToastContextValue {
showToast: (message: string, type?: ToastType) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const showToast = useCallback((message: string, type: ToastType = 'info') => {
const id = `toast-${Date.now()}-${Math.random()}`
setToasts((prev) => [...prev, { id, message, type }])
}, [])
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div
style={{
position: 'fixed',
bottom: 'calc(var(--nav-height) + var(--s-5))',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--s-2)',
zIndex: 1000,
width: 'min(360px, 90vw)',
}}
>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
))}
</div>
</ToastContext.Provider>
)
}
function ToastItem({ toast, onDismiss }: { toast: ToastItem; onDismiss: (id: string) => void }) {
useEffect(() => {
const timer = setTimeout(() => onDismiss(toast.id), 4000)
return () => clearTimeout(timer)
}, [toast.id, onDismiss])
const bgMap: Record<ToastType, string> = {
success: 'var(--success)',
error: '#f8d7da',
info: 'var(--bg-dark)',
}
const colorMap: Record<ToastType, string> = {
success: 'var(--teal-900)',
error: '#c0392b',
info: 'var(--paper)',
}
return (
<div
style={{
background: bgMap[toast.type],
color: colorMap[toast.type],
borderRadius: 'var(--r-md)',
padding: 'var(--s-3) var(--s-4)',
fontSize: 'var(--t-small)',
fontFamily: 'var(--font-sans)',
boxShadow: 'var(--shadow-soft)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 'var(--s-3)',
}}
>
<span>{toast.message}</span>
<button
onClick={() => onDismiss(toast.id)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'inherit',
fontSize: '18px',
lineHeight: 1,
padding: 0,
minWidth: '24px',
minHeight: '24px',
}}
aria-label="Dismiss"
>
×
</button>
</div>
)
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used within ToastProvider')
return ctx
}