Refactor code structure for improved readability and maintainability
This commit is contained in:
171
app/frontend/src/components/admin/CurriculumWeekRow.tsx
Normal file
171
app/frontend/src/components/admin/CurriculumWeekRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
204
app/frontend/src/components/admin/DocumentUpload.tsx
Normal file
204
app/frontend/src/components/admin/DocumentUpload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
app/frontend/src/components/admin/JobStatusList.tsx
Normal file
83
app/frontend/src/components/admin/JobStatusList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
app/frontend/src/components/admin/ThemeCard.tsx
Normal file
129
app/frontend/src/components/admin/ThemeCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
204
app/frontend/src/components/admin/TopicEditCard.tsx
Normal file
204
app/frontend/src/components/admin/TopicEditCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
227
app/frontend/src/components/employee/TopicCard.tsx
Normal file
227
app/frontend/src/components/employee/TopicCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
app/frontend/src/components/employee/WeekHeader.tsx
Normal file
102
app/frontend/src/components/employee/WeekHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
app/frontend/src/components/gamification/ActivityFeed.tsx
Normal file
115
app/frontend/src/components/gamification/ActivityFeed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
app/frontend/src/components/gamification/BadgeGrid.tsx
Normal file
89
app/frontend/src/components/gamification/BadgeGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
app/frontend/src/components/gamification/Heatmap.tsx
Normal file
131
app/frontend/src/components/gamification/Heatmap.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
app/frontend/src/components/gamification/Leaderboard.tsx
Normal file
123
app/frontend/src/components/gamification/Leaderboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
app/frontend/src/components/micro-learnings/CaseStudy.tsx
Normal file
97
app/frontend/src/components/micro-learnings/CaseStudy.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
app/frontend/src/components/micro-learnings/ComparisonCard.tsx
Normal file
127
app/frontend/src/components/micro-learnings/ComparisonCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
121
app/frontend/src/components/micro-learnings/FlashcardSet.tsx
Normal file
121
app/frontend/src/components/micro-learnings/FlashcardSet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
app/frontend/src/components/micro-learnings/GlossaryAnchor.tsx
Normal file
116
app/frontend/src/components/micro-learnings/GlossaryAnchor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
app/frontend/src/components/micro-learnings/HowTo.tsx
Normal file
96
app/frontend/src/components/micro-learnings/HowTo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
125
app/frontend/src/components/micro-learnings/Misconceptions.tsx
Normal file
125
app/frontend/src/components/micro-learnings/Misconceptions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
app/frontend/src/components/micro-learnings/MythVsEvidence.tsx
Normal file
112
app/frontend/src/components/micro-learnings/MythVsEvidence.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
147
app/frontend/src/components/micro-learnings/ScenarioQuiz.tsx
Normal file
147
app/frontend/src/components/micro-learnings/ScenarioQuiz.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
app/frontend/src/components/r42/R42Button.tsx
Normal file
42
app/frontend/src/components/r42/R42Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
417
app/frontend/src/components/r42/R42Drawer.tsx
Normal file
417
app/frontend/src/components/r42/R42Drawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
app/frontend/src/components/ui/Badge.tsx
Normal file
40
app/frontend/src/components/ui/Badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
app/frontend/src/components/ui/Button.tsx
Normal file
112
app/frontend/src/components/ui/Button.tsx
Normal 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',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
54
app/frontend/src/components/ui/ProgressBar.tsx
Normal file
54
app/frontend/src/components/ui/ProgressBar.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
|
||||
interface ProgressBarProps {
|
||||
value: number // 0–100
|
||||
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>
|
||||
)
|
||||
}
|
||||
75
app/frontend/src/components/ui/StatusBadge.tsx
Normal file
75
app/frontend/src/components/ui/StatusBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
app/frontend/src/components/ui/Toast.tsx
Normal file
113
app/frontend/src/components/ui/Toast.tsx
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user