Refactor code structure for improved readability and maintainability
This commit is contained in:
30
app/frontend/middleware.ts
Normal file
30
app/frontend/middleware.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export function middleware(req: NextRequest) {
|
||||||
|
const { pathname } = req.nextUrl
|
||||||
|
const token = req.cookies.get('pb_token')?.value
|
||||||
|
const role = req.cookies.get('pb_role')?.value
|
||||||
|
|
||||||
|
const isAdminPath = pathname.startsWith('/admin')
|
||||||
|
const isAppPath = pathname.startsWith('/app')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
if (isAdminPath || isAppPath) {
|
||||||
|
return NextResponse.redirect(new URL('/auth', req.url))
|
||||||
|
}
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdminPath && role !== 'admin') {
|
||||||
|
return NextResponse.redirect(new URL(role === 'employee' ? '/app' : '/auth', req.url))
|
||||||
|
}
|
||||||
|
if (isAppPath && role !== 'employee') {
|
||||||
|
return NextResponse.redirect(new URL(role === 'admin' ? '/admin' : '/auth', req.url))
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/admin/:path*', '/app/:path*'],
|
||||||
|
}
|
||||||
5
app/frontend/next-env.d.ts
vendored
Normal file
5
app/frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
13
app/frontend/next.config.js
Normal file
13
app/frontend/next.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const withPWA = require('next-pwa')({
|
||||||
|
dest: 'public',
|
||||||
|
register: true,
|
||||||
|
skipWaiting: true,
|
||||||
|
disable: process.env.NODE_ENV === 'development',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withPWA(nextConfig)
|
||||||
6967
app/frontend/package-lock.json
generated
Normal file
6967
app/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
app/frontend/package.json
Normal file
27
app/frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "learning-platform-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "14.2.29",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"pocketbase": "^0.21",
|
||||||
|
"next-pwa": "^5",
|
||||||
|
"zod": "^3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
"tailwindcss": "^3",
|
||||||
|
"autoprefixer": "^10",
|
||||||
|
"postcss": "^8",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/node": "^20"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/frontend/postcss.config.js
Normal file
6
app/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
14
app/frontend/public/manifest.json
Normal file
14
app/frontend/public/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "Learning Platform",
|
||||||
|
"short_name": "Learn",
|
||||||
|
"description": "Employee knowledge and learning",
|
||||||
|
"start_url": "/app",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#1F5560",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
201
app/frontend/src/app/admin/curriculum/page.tsx
Normal file
201
app/frontend/src/app/admin/curriculum/page.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
'use client'
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { pb } from '@/lib/pocketbase'
|
||||||
|
import { CurriculumWeekRow } from '@/components/admin/CurriculumWeekRow'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { StatusBadge } from '@/components/ui/StatusBadge'
|
||||||
|
import { postCurriculumConfirm, patchCurriculumWeekOrder } from '@/lib/services'
|
||||||
|
import type { CurriculumVersion, CurriculumWeek } from '@/types'
|
||||||
|
|
||||||
|
export default function CurriculumPage() {
|
||||||
|
const [versions, setVersions] = useState<CurriculumVersion[]>([])
|
||||||
|
const [weeks, setWeeks] = useState<CurriculumWeek[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [vList, wList] = await Promise.all([
|
||||||
|
pb.collection('curriculum_versions').getFullList<CurriculumVersion>({
|
||||||
|
sort: '-version',
|
||||||
|
}),
|
||||||
|
pb.collection('curriculum_weeks').getFullList<CurriculumWeek>({
|
||||||
|
sort: 'week_number',
|
||||||
|
expand: 'theme',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
setVersions(vList)
|
||||||
|
setWeeks(wList)
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const activeVersion = versions.find((v) => v.status === 'active')
|
||||||
|
const draftVersion = versions.find((v) => v.status === 'draft')
|
||||||
|
|
||||||
|
function handleDragStart(e: React.DragEvent<HTMLDivElement>, idx: number) {
|
||||||
|
setDragIndex(idx)
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: React.DragEvent<HTMLDivElement>, idx: number) {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOverIndex(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: React.DragEvent<HTMLDivElement>, dropIdx: number) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (dragIndex === null || dragIndex === dropIdx) {
|
||||||
|
setDragIndex(null)
|
||||||
|
setDragOverIndex(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWeeks = [...weeks]
|
||||||
|
const [moved] = newWeeks.splice(dragIndex, 1)
|
||||||
|
newWeeks.splice(dropIdx, 0, moved)
|
||||||
|
setWeeks(newWeeks)
|
||||||
|
setDragIndex(null)
|
||||||
|
setDragOverIndex(null)
|
||||||
|
|
||||||
|
// Persist order
|
||||||
|
if (moved) {
|
||||||
|
void patchCurriculumWeekOrder(moved.id, dropIdx + 1).catch(() => {
|
||||||
|
// revert on error
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
setDragIndex(null)
|
||||||
|
setDragOverIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm(versionId: string) {
|
||||||
|
setConfirming(true)
|
||||||
|
try {
|
||||||
|
await postCurriculumConfirm(versionId)
|
||||||
|
void load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to confirm')
|
||||||
|
} finally {
|
||||||
|
setConfirming(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-6)' }}>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: '0 0 var(--s-2)',
|
||||||
|
fontSize: 'var(--t-h3)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Curriculum
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--t-body)', color: 'var(--fg-muted)' }}>
|
||||||
|
26-week learning schedule. Drag to reorder weeks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version info */}
|
||||||
|
{versions.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 'var(--s-3)',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 'var(--s-4)',
|
||||||
|
background: 'var(--bg-warm)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
{activeVersion && (
|
||||||
|
<span style={{ fontSize: 'var(--t-body)', color: 'var(--fg)' }}>
|
||||||
|
Active: <strong>v{activeVersion.version}</strong>{' '}
|
||||||
|
<StatusBadge status="active" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{draftVersion && (
|
||||||
|
<span style={{ fontSize: 'var(--t-body)', color: 'var(--fg)', marginLeft: 'var(--s-4)' }}>
|
||||||
|
Draft: <strong>v{draftVersion.version}</strong>{' '}
|
||||||
|
<StatusBadge status="draft" />
|
||||||
|
{' '}
|
||||||
|
<em style={{ fontSize: 'var(--t-small)', color: 'var(--fg-muted)' }}>
|
||||||
|
{draftVersion.generation_notes}
|
||||||
|
</em>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{draftVersion && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={confirming}
|
||||||
|
onClick={() => void handleConfirm(draftVersion.id)}
|
||||||
|
>
|
||||||
|
Activate draft v{draftVersion.version}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f8d7da',
|
||||||
|
color: '#c0392b',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
padding: 'var(--s-3) var(--s-4)',
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', fontSize: 'var(--t-body)' }}>Loading…</div>
|
||||||
|
) : weeks.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', fontSize: 'var(--t-body)', textAlign: 'center', padding: 'var(--s-6)' }}>
|
||||||
|
No curriculum weeks yet. Generate a curriculum from the knowledge base first.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
|
||||||
|
{weeks.map((week, idx) => (
|
||||||
|
<CurriculumWeekRow
|
||||||
|
key={week.id}
|
||||||
|
week={week}
|
||||||
|
isDragging={dragIndex === idx}
|
||||||
|
onDragStart={(e) => handleDragStart(e, idx)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, idx)}
|
||||||
|
onDrop={(e) => handleDrop(e, idx)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onUpdate={() => void load()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
app/frontend/src/app/admin/documents/page.tsx
Normal file
53
app/frontend/src/app/admin/documents/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client'
|
||||||
|
import React from 'react'
|
||||||
|
import { DocumentUpload } from '@/components/admin/DocumentUpload'
|
||||||
|
import { JobStatusList } from '@/components/admin/JobStatusList'
|
||||||
|
import { usePBCollection } from '@/lib/hooks/usePBCollection'
|
||||||
|
import type { SourceDocument } from '@/types'
|
||||||
|
|
||||||
|
export default function DocumentsPage() {
|
||||||
|
const { items: documents, loading, refresh } = usePBCollection<SourceDocument>(
|
||||||
|
'source_documents',
|
||||||
|
{ sort: '-created' },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-6)' }}>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: '0 0 var(--s-2)',
|
||||||
|
fontSize: 'var(--t-h3)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Documents
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--t-body)', color: 'var(--fg-muted)' }}>
|
||||||
|
Upload source documents to populate the knowledge base.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocumentUpload onSuccess={refresh} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: '0 0 var(--s-4)',
|
||||||
|
fontSize: 'var(--t-body)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Document history
|
||||||
|
</h2>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', fontSize: 'var(--t-body)' }}>Loading…</div>
|
||||||
|
) : (
|
||||||
|
<JobStatusList documents={documents} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
app/frontend/src/app/admin/knowledge/[themeId]/page.tsx
Normal file
163
app/frontend/src/app/admin/knowledge/[themeId]/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
'use client'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { pb } from '@/lib/pocketbase'
|
||||||
|
import { TopicEditCard } from '@/components/admin/TopicEditCard'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { StatusBadge } from '@/components/ui/StatusBadge'
|
||||||
|
import type { Theme, Topic } from '@/types'
|
||||||
|
|
||||||
|
export default function ThemeDetailPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const themeId = typeof params.themeId === 'string' ? params.themeId : ''
|
||||||
|
|
||||||
|
const [theme, setTheme] = useState<Theme | null>(null)
|
||||||
|
const [topics, setTopics] = useState<Topic[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [publishingAll, setPublishingAll] = useState(false)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [t, tList] = await Promise.all([
|
||||||
|
pb.collection('themes').getOne<Theme>(themeId),
|
||||||
|
pb.collection('topics').getFullList<Topic>({
|
||||||
|
filter: `theme="${themeId}"`,
|
||||||
|
sort: 'difficulty,title',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
setTheme(t)
|
||||||
|
setTopics(tList)
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [themeId])
|
||||||
|
|
||||||
|
async function handlePublishAll() {
|
||||||
|
setPublishingAll(true)
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
topics
|
||||||
|
.filter((t) => t.status === 'draft')
|
||||||
|
.map((t) => pb.collection('topics').update(t.id, { status: 'published' })),
|
||||||
|
)
|
||||||
|
void load()
|
||||||
|
} catch {
|
||||||
|
// refresh anyway
|
||||||
|
void load()
|
||||||
|
} finally {
|
||||||
|
setPublishingAll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', padding: 'var(--s-6)' }}>Loading…</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !theme) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f8d7da',
|
||||||
|
color: '#c0392b',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
padding: 'var(--s-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ?? 'Theme not found'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftCount = topics.filter((t) => t.status === 'draft').length
|
||||||
|
const publishedCount = topics.filter((t) => t.status === 'published').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)' }}>
|
||||||
|
{/* Back link */}
|
||||||
|
<Link
|
||||||
|
href="/admin/knowledge"
|
||||||
|
style={{
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--s-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Knowledge base
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-3)', marginBottom: 'var(--s-2)' }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'var(--t-h3)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{theme.title}
|
||||||
|
</h1>
|
||||||
|
<StatusBadge status={theme.status} />
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--t-body)', color: 'var(--fg-muted)', lineHeight: 1.6 }}>
|
||||||
|
{theme.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats + batch action */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--s-4)',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 'var(--t-body)', color: 'var(--fg-muted)' }}>
|
||||||
|
{publishedCount} published · {draftCount} draft
|
||||||
|
</span>
|
||||||
|
{draftCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handlePublishAll()}
|
||||||
|
loading={publishingAll}
|
||||||
|
>
|
||||||
|
Publish all drafts ({draftCount})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Topics list */}
|
||||||
|
{topics.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', fontSize: 'var(--t-body)' }}>
|
||||||
|
No topics in this theme yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
|
||||||
|
{topics.map((topic) => (
|
||||||
|
<TopicEditCard key={topic.id} topic={topic} onUpdate={() => void load()} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
app/frontend/src/app/admin/knowledge/page.tsx
Normal file
95
app/frontend/src/app/admin/knowledge/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
'use client'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { ThemeCard } from '@/components/admin/ThemeCard'
|
||||||
|
import { usePBCollection } from '@/lib/hooks/usePBCollection'
|
||||||
|
import type { Theme } from '@/types'
|
||||||
|
|
||||||
|
export default function KnowledgePage() {
|
||||||
|
const { items: themes, loading, error, refresh } = usePBCollection<Theme>('themes', {
|
||||||
|
sort: '-created_at',
|
||||||
|
})
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState<Theme['status'] | 'all'>('all')
|
||||||
|
|
||||||
|
const filtered = filter === 'all' ? themes : themes.filter((t) => t.status === filter)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-6)' }}>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: '0 0 var(--s-2)',
|
||||||
|
fontSize: 'var(--t-h3)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Knowledge base
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--t-body)', color: 'var(--fg-muted)' }}>
|
||||||
|
Themes extracted from ingested documents.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--s-2)', flexWrap: 'wrap' }}>
|
||||||
|
{(['all', 'draft', 'published', 'rejected'] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
style={{
|
||||||
|
background: filter === f ? 'var(--accent)' : 'var(--bg-warm)',
|
||||||
|
color: filter === f ? 'var(--paper)' : 'var(--fg)',
|
||||||
|
border: '1.5px solid var(--cream)',
|
||||||
|
borderRadius: 'var(--r-pill)',
|
||||||
|
padding: 'var(--s-1) var(--s-4)',
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minHeight: '36px',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
transition: 'background 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f8d7da',
|
||||||
|
color: '#c0392b',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
padding: 'var(--s-3) var(--s-4)',
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', fontSize: 'var(--t-body)' }}>Loading…</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
padding: 'var(--s-8, 64px) 0',
|
||||||
|
fontSize: 'var(--t-body)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filter === 'all' ? 'No themes yet. Upload documents to get started.' : `No ${filter} themes.`}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-3)' }}>
|
||||||
|
{filtered.map((theme) => (
|
||||||
|
<ThemeCard key={theme.id} theme={theme} onUpdate={refresh} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
202
app/frontend/src/app/admin/layout.tsx
Normal file
202
app/frontend/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
'use client'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { logout } from '@/lib/auth'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ href: '/admin/documents', label: 'Documents', icon: '📄' },
|
||||||
|
{ href: '/admin/knowledge', label: 'Knowledge', icon: '🧠' },
|
||||||
|
{ href: '/admin/curriculum', label: 'Curriculum', icon: '📅' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100dvh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top bar */}
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-dark)',
|
||||||
|
color: 'var(--paper)',
|
||||||
|
height: 'var(--nav-height)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 var(--s-5)',
|
||||||
|
gap: 'var(--s-4)',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Mobile menu toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileNavOpen((o) => !o)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--paper)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '22px',
|
||||||
|
padding: 'var(--s-1)',
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 'var(--t-body)', flex: 1 }}>
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Link href="/app" style={{ textDecoration: 'none' }}>
|
||||||
|
<span style={{ fontSize: 'var(--t-small)', color: 'rgba(255,255,255,0.75)', cursor: 'pointer' }}>
|
||||||
|
Employee app →
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={logout}
|
||||||
|
style={{
|
||||||
|
color: 'rgba(255,255,255,0.75)',
|
||||||
|
borderColor: 'rgba(255,255,255,0.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flex: 1, position: 'relative' }}>
|
||||||
|
{/* Sidebar (desktop) */}
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
width: '220px',
|
||||||
|
background: 'var(--bg-warm)',
|
||||||
|
borderRight: '1px solid var(--cream)',
|
||||||
|
padding: 'var(--s-5) 0',
|
||||||
|
display: 'none',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--s-1)',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 'var(--nav-height)',
|
||||||
|
height: 'calc(100dvh - var(--nav-height))',
|
||||||
|
overflowY: 'auto',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
className="admin-sidebar"
|
||||||
|
>
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = pathname.startsWith(item.href)
|
||||||
|
return (
|
||||||
|
<Link key={item.href} href={item.href} style={{ textDecoration: 'none' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--s-3)',
|
||||||
|
padding: 'var(--s-3) var(--s-5)',
|
||||||
|
background: active ? 'var(--bg)' : 'transparent',
|
||||||
|
borderRight: active ? '3px solid var(--accent)' : '3px solid transparent',
|
||||||
|
fontSize: 'var(--t-body)',
|
||||||
|
fontWeight: active ? 600 : 400,
|
||||||
|
color: active ? 'var(--accent)' : 'var(--fg)',
|
||||||
|
transition: 'background 150ms',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile nav dropdown */}
|
||||||
|
{mobileNavOpen && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: 'var(--bg-warm)',
|
||||||
|
borderBottom: '1px solid var(--cream)',
|
||||||
|
zIndex: 99,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = pathname.startsWith(item.href)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setMobileNavOpen(false)}
|
||||||
|
style={{ textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--s-3)',
|
||||||
|
padding: 'var(--s-4) var(--s-5)',
|
||||||
|
borderBottom: '1px solid var(--cream)',
|
||||||
|
background: active ? 'var(--bg)' : 'transparent',
|
||||||
|
fontSize: 'var(--t-body)',
|
||||||
|
fontWeight: active ? 600 : 400,
|
||||||
|
color: active ? 'var(--accent)' : 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: 'var(--s-5)',
|
||||||
|
maxWidth: '900px',
|
||||||
|
width: '100%',
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.admin-sidebar {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
app/frontend/src/app/admin/page.tsx
Normal file
5
app/frontend/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function AdminIndexPage() {
|
||||||
|
redirect('/admin/documents')
|
||||||
|
}
|
||||||
104
app/frontend/src/app/app/layout.tsx
Normal file
104
app/frontend/src/app/app/layout.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
'use client'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { R42Button } from '@/components/r42/R42Button'
|
||||||
|
import { R42Drawer } from '@/components/r42/R42Drawer'
|
||||||
|
import { ToastProvider } from '@/components/ui/Toast'
|
||||||
|
import { getAuthUser } from '@/lib/auth'
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ href: '/app', label: 'Session', icon: '▶' },
|
||||||
|
{ href: '/app/library', label: 'Library', icon: '📚' },
|
||||||
|
{ href: '/app/profile', label: 'Profile', icon: '👤' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const [r42Open, setR42Open] = useState(false)
|
||||||
|
const user = getAuthUser()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100dvh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
paddingBottom: 'var(--nav-height)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Main content */}
|
||||||
|
<main style={{ flex: 1, padding: 'var(--s-4)', maxWidth: '640px', margin: '0 auto', width: '100%' }}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* R42 floating button */}
|
||||||
|
<R42Button onClick={() => setR42Open(true)} />
|
||||||
|
|
||||||
|
{/* R42 drawer */}
|
||||||
|
<R42Drawer
|
||||||
|
open={r42Open}
|
||||||
|
onClose={() => setR42Open(false)}
|
||||||
|
userId={user?.id ?? ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom navigation */}
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 'var(--nav-height)',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
borderTop: '1px solid var(--cream)',
|
||||||
|
display: 'flex',
|
||||||
|
zIndex: 150,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active =
|
||||||
|
item.href === '/app'
|
||||||
|
? pathname === '/app' || pathname === '/app/'
|
||||||
|
: pathname.startsWith(item.href)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '2px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: active ? 'var(--accent)' : 'var(--fg-muted)',
|
||||||
|
fontSize: '20px',
|
||||||
|
minHeight: '44px',
|
||||||
|
transition: 'color 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--t-label)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 'var(--label-ls)',
|
||||||
|
fontWeight: active ? 700 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
299
app/frontend/src/app/app/library/[topicId]/page.tsx
Normal file
299
app/frontend/src/app/app/library/[topicId]/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
'use client'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { pb } from '@/lib/pocketbase'
|
||||||
|
import { getAuthUser } from '@/lib/auth'
|
||||||
|
import { MicroLearningRenderer } from '@/components/micro-learnings/MicroLearningRenderer'
|
||||||
|
import { StatusBadge } from '@/components/ui/StatusBadge'
|
||||||
|
import { postComplete } from '@/lib/services'
|
||||||
|
import type { Topic, MicroLearning } from '@/types'
|
||||||
|
|
||||||
|
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 default function TopicDetailPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const topicId = typeof params.topicId === 'string' ? params.topicId : ''
|
||||||
|
const user = getAuthUser()
|
||||||
|
|
||||||
|
const [topic, setTopic] = useState<Topic | null>(null)
|
||||||
|
const [microLearnings, setMicroLearnings] = useState<MicroLearning[]>([])
|
||||||
|
const [selectedMlId, setSelectedMlId] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [completed, setCompleted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [t, mls] = await Promise.all([
|
||||||
|
pb.collection('topics').getOne<Topic>(topicId, { expand: 'theme,related_topics' }),
|
||||||
|
pb.collection('micro_learnings').getFullList<MicroLearning>({
|
||||||
|
filter: `topic="${topicId}" && status="published"`,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
setTopic(t)
|
||||||
|
setMicroLearnings(mls)
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
}, [topicId])
|
||||||
|
|
||||||
|
async function handleComplete() {
|
||||||
|
if (!user || !selectedMlId) return
|
||||||
|
try {
|
||||||
|
await postComplete({
|
||||||
|
userId: user.id,
|
||||||
|
topicId,
|
||||||
|
microLearningId: selectedMlId,
|
||||||
|
weekNumber: 0, // library mode — no specific week
|
||||||
|
})
|
||||||
|
setCompleted(true)
|
||||||
|
} catch {
|
||||||
|
// non-critical in library mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedMl = microLearnings.find((ml) => ml.id === selectedMlId)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', padding: 'var(--s-6)' }}>Loading…</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !topic) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f8d7da',
|
||||||
|
color: '#c0392b',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
padding: 'var(--s-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ?? 'Topic not found'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)' }}>
|
||||||
|
{/* Back */}
|
||||||
|
<Link
|
||||||
|
href="/app/library"
|
||||||
|
style={{
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--s-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Library
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
{topic.expand?.theme && (
|
||||||
|
<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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{topic.expand.theme.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: '0 0 var(--s-3)',
|
||||||
|
fontSize: 'var(--t-h3)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{topic.title}
|
||||||
|
</h1>
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--s-2)', flexWrap: 'wrap' }}>
|
||||||
|
<StatusBadge status={topic.difficulty} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--t-body)', lineHeight: 1.7, color: 'var(--fg)' }}>
|
||||||
|
{topic.body}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Key terms */}
|
||||||
|
{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: '4px var(--s-3)',
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
border: '1px solid var(--cream)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{term}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Related topics */}
|
||||||
|
{topic.expand?.related_topics && topic.expand.related_topics.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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Related topics
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
|
||||||
|
{topic.expand.related_topics.map((rt) => (
|
||||||
|
<Link
|
||||||
|
key={rt.id}
|
||||||
|
href={`/app/library/${rt.id}`}
|
||||||
|
style={{ textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-warm)',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
padding: 'var(--s-3) var(--s-4)',
|
||||||
|
fontSize: 'var(--t-body)',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
fontWeight: 500,
|
||||||
|
minHeight: '44px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rt.title} →
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity picker */}
|
||||||
|
{microLearnings.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-3)' }}>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'var(--t-label)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 'var(--label-ls)',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Activities
|
||||||
|
</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-2) var(--s-4)',
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
minHeight: '44px',
|
||||||
|
transition: 'background 150ms, color 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TYPE_LABELS[ml.type] ?? ml.type}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedMl && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-warm)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: 'var(--s-4)',
|
||||||
|
marginTop: 'var(--s-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{completed ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--teal-700)',
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: 'var(--s-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Completed!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MicroLearningRenderer
|
||||||
|
content={selectedMl.content}
|
||||||
|
onComplete={() => void handleComplete()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
221
app/frontend/src/app/app/library/page.tsx
Normal file
221
app/frontend/src/app/app/library/page.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
'use client'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePBCollection } from '@/lib/hooks/usePBCollection'
|
||||||
|
import { StatusBadge } from '@/components/ui/StatusBadge'
|
||||||
|
import type { Topic, Theme } from '@/types'
|
||||||
|
|
||||||
|
type Difficulty = Topic['difficulty'] | 'all'
|
||||||
|
|
||||||
|
export default function LibraryPage() {
|
||||||
|
const { items: topics, loading } = usePBCollection<Topic>('topics', {
|
||||||
|
filter: 'status="published"',
|
||||||
|
expand: 'theme',
|
||||||
|
sort: 'theme,difficulty,title',
|
||||||
|
})
|
||||||
|
const { items: themes } = usePBCollection<Theme>('themes', {
|
||||||
|
filter: 'status="published"',
|
||||||
|
sort: 'title',
|
||||||
|
})
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [difficulty, setDifficulty] = useState<Difficulty>('all')
|
||||||
|
const [themeFilter, setThemeFilter] = useState<string>('all')
|
||||||
|
|
||||||
|
const filtered = topics.filter((t) => {
|
||||||
|
const matchSearch =
|
||||||
|
search === '' ||
|
||||||
|
t.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
t.key_terms.some((k) => k.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
const matchDiff = difficulty === 'all' || t.difficulty === difficulty
|
||||||
|
const matchTheme = themeFilter === 'all' || t.theme === themeFilter
|
||||||
|
return matchSearch && matchDiff && matchTheme
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group by theme
|
||||||
|
const grouped = new Map<string, Topic[]>()
|
||||||
|
for (const topic of filtered) {
|
||||||
|
const list = grouped.get(topic.theme) ?? []
|
||||||
|
list.push(topic)
|
||||||
|
grouped.set(topic.theme, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeMap = new Map(themes.map((t) => [t.id, t]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)' }}>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: '0 0 var(--s-2)',
|
||||||
|
fontSize: 'var(--t-h3)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Library
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--t-body)', color: 'var(--fg-muted)' }}>
|
||||||
|
Browse all published topics.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search topics or key terms…"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
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)',
|
||||||
|
outline: 'none',
|
||||||
|
minHeight: '44px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--s-2)', flexWrap: 'wrap' }}>
|
||||||
|
{(['all', 'introductory', 'intermediate', 'advanced'] as const).map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => setDifficulty(d)}
|
||||||
|
style={{
|
||||||
|
background: difficulty === d ? 'var(--accent)' : 'var(--bg-warm)',
|
||||||
|
color: difficulty === d ? 'var(--paper)' : 'var(--fg)',
|
||||||
|
border: '1.5px solid var(--cream)',
|
||||||
|
borderRadius: 'var(--r-pill)',
|
||||||
|
padding: 'var(--s-1) var(--s-3)',
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minHeight: '36px',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
transition: 'background 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme filter */}
|
||||||
|
{themes.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={themeFilter}
|
||||||
|
onChange={(e) => setThemeFilter(e.target.value)}
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
border: '1.5px solid var(--cream)',
|
||||||
|
padding: 'var(--s-2) var(--s-3)',
|
||||||
|
fontSize: 'var(--t-body)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
outline: 'none',
|
||||||
|
minHeight: '44px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="all">All themes</option>
|
||||||
|
{themes.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', fontSize: 'var(--t-body)' }}>Loading…</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', fontSize: 'var(--t-body)', textAlign: 'center', padding: 'var(--s-6)' }}>
|
||||||
|
No topics match your search.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
Array.from(grouped.entries()).map(([themeId, themeTopics]) => {
|
||||||
|
const theme = themeMap.get(themeId)
|
||||||
|
return (
|
||||||
|
<div key={themeId} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-3)' }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{theme?.title ?? themeId}
|
||||||
|
</h2>
|
||||||
|
{themeTopics.map((topic) => (
|
||||||
|
<Link
|
||||||
|
key={topic.id}
|
||||||
|
href={`/app/library/${topic.id}`}
|
||||||
|
style={{ textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-warm)',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
padding: 'var(--s-3) var(--s-4)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--s-3)',
|
||||||
|
minHeight: '56px',
|
||||||
|
transition: 'background 150ms',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--cream)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--bg-warm)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0 0 var(--s-1)',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 'var(--t-body)',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{topic.title}
|
||||||
|
</p>
|
||||||
|
{topic.key_terms.length > 0 && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{topic.key_terms.slice(0, 4).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={topic.difficulty} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
app/frontend/src/app/app/page.tsx
Normal file
5
app/frontend/src/app/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function AppRoot() {
|
||||||
|
redirect('/app/session')
|
||||||
|
}
|
||||||
290
app/frontend/src/app/app/profile/page.tsx
Normal file
290
app/frontend/src/app/app/profile/page.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
'use client'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { pb } from '@/lib/pocketbase'
|
||||||
|
import { getAuthUser, logout } from '@/lib/auth'
|
||||||
|
import { Heatmap } from '@/components/gamification/Heatmap'
|
||||||
|
import { BadgeGrid } from '@/components/gamification/BadgeGrid'
|
||||||
|
import { Leaderboard } from '@/components/gamification/Leaderboard'
|
||||||
|
import { ActivityFeed } from '@/components/gamification/ActivityFeed'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { getLeaderboard, getFeed } from '@/lib/services'
|
||||||
|
import type {
|
||||||
|
GamificationProfile,
|
||||||
|
EmployeeBadge,
|
||||||
|
Badge,
|
||||||
|
SessionCompletion,
|
||||||
|
LeaderboardEntry,
|
||||||
|
FeedEntry,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
type Tab = 'profile' | 'leaderboard' | 'feed'
|
||||||
|
|
||||||
|
const LEVEL_LABELS: Record<string, string> = {
|
||||||
|
intern: 'Intern',
|
||||||
|
junior: 'Junior',
|
||||||
|
medior: 'Medior',
|
||||||
|
senior: 'Senior',
|
||||||
|
staff: 'Staff',
|
||||||
|
principal: 'Principal',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const user = getAuthUser()
|
||||||
|
const [tab, setTab] = useState<Tab>('profile')
|
||||||
|
|
||||||
|
const [gamification, setGamification] = useState<GamificationProfile | null>(null)
|
||||||
|
const [earnedBadges, setEarnedBadges] = useState<EmployeeBadge[]>([])
|
||||||
|
const [allBadges, setAllBadges] = useState<Badge[]>([])
|
||||||
|
const [completions, setCompletions] = useState<SessionCompletion[]>([])
|
||||||
|
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
|
||||||
|
const [feed, setFeed] = useState<FeedEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [gamResult, badgeResult, allBadgeResult, compResult] = await Promise.allSettled([
|
||||||
|
pb
|
||||||
|
.collection('gamification_profiles')
|
||||||
|
.getFullList<GamificationProfile>({ filter: `user="${user!.id}"`, expand: 'user' })
|
||||||
|
.then((r) => r[0] ?? null),
|
||||||
|
pb
|
||||||
|
.collection('employee_badges')
|
||||||
|
.getFullList<EmployeeBadge>({ filter: `user="${user!.id}"`, expand: 'badge' }),
|
||||||
|
pb.collection('badges').getFullList<Badge>({ sort: 'tier,label' }),
|
||||||
|
pb
|
||||||
|
.collection('session_completions')
|
||||||
|
.getFullList<SessionCompletion>({ filter: `user="${user!.id}"`, sort: '-completed_at' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (gamResult.status === 'fulfilled') setGamification(gamResult.value)
|
||||||
|
if (badgeResult.status === 'fulfilled') setEarnedBadges(badgeResult.value)
|
||||||
|
if (allBadgeResult.status === 'fulfilled') setAllBadges(allBadgeResult.value)
|
||||||
|
if (compResult.status === 'fulfilled') setCompletions(compResult.value)
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
}, [user?.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === 'leaderboard') {
|
||||||
|
getLeaderboard().then(setLeaderboard).catch(() => {})
|
||||||
|
}
|
||||||
|
if (tab === 'feed') {
|
||||||
|
getFeed().then(setFeed).catch(() => {})
|
||||||
|
}
|
||||||
|
}, [tab])
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', textAlign: 'center', padding: 'var(--s-6)' }}>
|
||||||
|
Not logged in.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)' }}>
|
||||||
|
{/* User card */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-dark)',
|
||||||
|
color: 'var(--paper)',
|
||||||
|
borderRadius: 'var(--r-lg)',
|
||||||
|
padding: 'var(--s-5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--s-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '56px',
|
||||||
|
height: '56px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--accent)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 'var(--t-h3)',
|
||||||
|
fontWeight: 700,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.display_name?.[0]?.toUpperCase() ?? user.email[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: '0 0 var(--s-1)',
|
||||||
|
fontSize: 'var(--t-body)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.display_name || user.email}
|
||||||
|
</h2>
|
||||||
|
{gamification && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
opacity: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LEVEL_LABELS[gamification.current_level] ?? gamification.current_level} ·{' '}
|
||||||
|
{gamification.total_commits} commits · {gamification.current_streak_weeks}w streak
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={logout}
|
||||||
|
style={{ color: 'rgba(255,255,255,0.7)', borderColor: 'rgba(255,255,255,0.25)', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
borderBottom: '2px solid var(--cream)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(['profile', 'leaderboard', 'feed'] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: `2px solid ${tab === t ? 'var(--accent)' : 'transparent'}`,
|
||||||
|
marginBottom: '-2px',
|
||||||
|
padding: 'var(--s-3)',
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontWeight: tab === t ? 700 : 400,
|
||||||
|
color: tab === t ? 'var(--accent)' : 'var(--fg-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
minHeight: '44px',
|
||||||
|
transition: 'color 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{tab === 'profile' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-6)' }}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ color: 'var(--fg-muted)' }}>Loading…</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Stats */}
|
||||||
|
{gamification && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||||
|
gap: 'var(--s-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatCard label="Total commits" value={String(gamification.total_commits)} />
|
||||||
|
<StatCard label="Current streak" value={`${gamification.current_streak_weeks}w`} />
|
||||||
|
<StatCard label="Longest streak" value={`${gamification.longest_streak_weeks}w`} />
|
||||||
|
<StatCard label="Types explored" value={String(gamification.types_used.length)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Heatmap */}
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: '0 0 var(--s-3)',
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</h3>
|
||||||
|
<Heatmap completions={completions} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges */}
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: '0 0 var(--s-3)',
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Badges ({earnedBadges.length}/{allBadges.length})
|
||||||
|
</h3>
|
||||||
|
<BadgeGrid allBadges={allBadges} earned={earnedBadges} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'leaderboard' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
|
||||||
|
<Leaderboard entries={leaderboard} currentUserId={user.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'feed' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
|
||||||
|
<ActivityFeed entries={feed} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-warm)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: 'var(--s-4)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--s-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--t-label)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 'var(--label-ls)',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 'var(--t-h3)', fontWeight: 700, color: 'var(--fg)' }}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
197
app/frontend/src/app/app/session/page.tsx
Normal file
197
app/frontend/src/app/app/session/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
'use client'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { pb } from '@/lib/pocketbase'
|
||||||
|
import { getAuthUser } from '@/lib/auth'
|
||||||
|
import { WeekHeader } from '@/components/employee/WeekHeader'
|
||||||
|
import { TopicCard } from '@/components/employee/TopicCard'
|
||||||
|
import type {
|
||||||
|
EmployeeCurriculumState,
|
||||||
|
CurriculumWeek,
|
||||||
|
Topic,
|
||||||
|
SessionCompletion,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export default function SessionPage() {
|
||||||
|
const user = getAuthUser()
|
||||||
|
|
||||||
|
const [curriculumState, setCurriculumState] = useState<EmployeeCurriculumState | null>(null)
|
||||||
|
const [week, setWeek] = useState<CurriculumWeek | null>(null)
|
||||||
|
const [topics, setTopics] = useState<Topic[]>([])
|
||||||
|
const [completions, setCompletions] = useState<SessionCompletion[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!user) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
let state: EmployeeCurriculumState
|
||||||
|
try {
|
||||||
|
const results = await pb
|
||||||
|
.collection('employee_curriculum_state')
|
||||||
|
.getFullList<EmployeeCurriculumState>({
|
||||||
|
filter: `user="${user.id}"`,
|
||||||
|
})
|
||||||
|
if (results.length > 0 && results[0]) {
|
||||||
|
state = results[0]
|
||||||
|
} else {
|
||||||
|
state = await pb
|
||||||
|
.collection('employee_curriculum_state')
|
||||||
|
.create<EmployeeCurriculumState>({
|
||||||
|
user: user.id,
|
||||||
|
current_cycle: 1,
|
||||||
|
current_week: 1,
|
||||||
|
start_date: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurriculumState(state)
|
||||||
|
|
||||||
|
const weekResult = await pb
|
||||||
|
.collection('curriculum_weeks')
|
||||||
|
.getFullList<CurriculumWeek>({
|
||||||
|
filter: `week_number=${state.current_week}`,
|
||||||
|
expand: 'theme,topics',
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentWeek = weekResult[0]
|
||||||
|
if (!currentWeek) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setWeek(currentWeek)
|
||||||
|
|
||||||
|
if (currentWeek.expand?.topics && currentWeek.expand.topics.length > 0) {
|
||||||
|
setTopics(currentWeek.expand.topics)
|
||||||
|
} else if (currentWeek.topics.length > 0) {
|
||||||
|
const topicList = await pb.collection('topics').getFullList<Topic>({
|
||||||
|
filter: currentWeek.topics.map((id) => `id="${id}"`).join('||'),
|
||||||
|
})
|
||||||
|
setTopics(topicList)
|
||||||
|
}
|
||||||
|
|
||||||
|
const compList = await pb
|
||||||
|
.collection('session_completions')
|
||||||
|
.getFullList<SessionCompletion>({
|
||||||
|
filter: `user="${user.id}" && week_number=${state.current_week} && cycle=${state.current_cycle}`,
|
||||||
|
})
|
||||||
|
setCompletions(compList)
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load session')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [user?.id])
|
||||||
|
|
||||||
|
function handleTopicComplete(topicId: string) {
|
||||||
|
setCompletions((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `temp-${topicId}`,
|
||||||
|
user: user?.id ?? '',
|
||||||
|
topic: topicId,
|
||||||
|
micro_learning: '',
|
||||||
|
week_number: curriculumState?.current_week ?? 1,
|
||||||
|
cycle: curriculumState?.current_cycle ?? 1,
|
||||||
|
completed_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedTopicIds = new Set(completions.map((c) => c.topic))
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', padding: 'var(--s-6)', textAlign: 'center' }}>
|
||||||
|
Not logged in.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', padding: 'var(--s-6)' }}>
|
||||||
|
Loading your session…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f8d7da',
|
||||||
|
color: '#c0392b',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
padding: 'var(--s-4)',
|
||||||
|
margin: 'var(--s-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!curriculumState || !week) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: 'var(--s-8) var(--s-5)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--s-4)',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 'var(--t-h3)', color: 'var(--fg)' }}>
|
||||||
|
No curriculum yet
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--t-body)', color: 'var(--fg-muted)' }}>
|
||||||
|
Ask your admin to generate and activate a curriculum.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
|
||||||
|
<WeekHeader
|
||||||
|
curriculumState={curriculumState}
|
||||||
|
week={week}
|
||||||
|
completedCount={completedTopicIds.size}
|
||||||
|
totalCount={topics.length}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-3)' }}>
|
||||||
|
{topics.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--fg-muted)', fontSize: 'var(--t-body)', textAlign: 'center', padding: 'var(--s-6)' }}>
|
||||||
|
No topics this week.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
topics.map((topic) => (
|
||||||
|
<TopicCard
|
||||||
|
key={topic.id}
|
||||||
|
topic={topic}
|
||||||
|
weekNumber={curriculumState.current_week}
|
||||||
|
userId={user.id}
|
||||||
|
isCompleted={completedTopicIds.has(topic.id)}
|
||||||
|
onComplete={handleTopicComplete}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
191
app/frontend/src/app/auth/page.tsx
Normal file
191
app/frontend/src/app/auth/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use client'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { pb } from '@/lib/pocketbase'
|
||||||
|
import { syncAuthCookies } from '@/lib/auth'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import type { PBUser } from '@/types'
|
||||||
|
|
||||||
|
export default function AuthPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!email || !password) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.collection('users').authWithPassword(email, password)
|
||||||
|
syncAuthCookies()
|
||||||
|
|
||||||
|
const user = pb.authStore.model as PBUser | null
|
||||||
|
if (user?.role === 'admin') {
|
||||||
|
router.push('/admin')
|
||||||
|
} else {
|
||||||
|
router.push('/app')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Login failed. Check your credentials.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
minHeight: '100dvh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 'var(--s-5)',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '400px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--s-6)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo / brand */}
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--bg-dark)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--paper)',
|
||||||
|
fontSize: 'var(--t-h3)',
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: 'var(--s-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
L
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: '0 0 var(--s-2)',
|
||||||
|
fontSize: 'var(--t-h3)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Learning Platform
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--t-body)', color: 'var(--fg-muted)' }}>
|
||||||
|
Sign in to continue
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => void handleSubmit(e)}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="you@company.com"
|
||||||
|
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)',
|
||||||
|
outline: 'none',
|
||||||
|
minHeight: '44px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
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)',
|
||||||
|
outline: 'none',
|
||||||
|
minHeight: '44px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f8d7da',
|
||||||
|
color: '#c0392b',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
padding: 'var(--s-3) var(--s-4)',
|
||||||
|
fontSize: 'var(--t-small)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" variant="primary" size="lg" loading={loading} style={{ width: '100%' }}>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
app/frontend/src/app/globals.css
Normal file
45
app/frontend/src/app/globals.css
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--heatmap-0: #e8e6e6;
|
||||||
|
--heatmap-1: var(--sage);
|
||||||
|
--heatmap-2: var(--teal-700);
|
||||||
|
--heatmap-3: var(--teal);
|
||||||
|
--nav-height: 56px;
|
||||||
|
--r42-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
33
app/frontend/src/app/layout.tsx
Normal file
33
app/frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata, Viewport } from 'next'
|
||||||
|
import '../../../../stylesheet.css'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Learning Platform',
|
||||||
|
description: 'Employee knowledge and learning',
|
||||||
|
manifest: '/manifest.json',
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: 'default',
|
||||||
|
title: 'Learning Platform',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: '#1F5560',
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
app/frontend/src/app/page.tsx
Normal file
5
app/frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function RootPage() {
|
||||||
|
redirect('/auth')
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
34
app/frontend/src/lib/auth.ts
Normal file
34
app/frontend/src/lib/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client'
|
||||||
|
import { pb } from './pocketbase'
|
||||||
|
import type { PBUser } from '@/types'
|
||||||
|
|
||||||
|
const COOKIE_TOKEN = 'pb_token'
|
||||||
|
const COOKIE_ROLE = 'pb_role'
|
||||||
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 14 // 14 days
|
||||||
|
|
||||||
|
export function getAuthUser(): PBUser | null {
|
||||||
|
if (!pb.authStore.isValid) return null
|
||||||
|
return pb.authStore.model as PBUser | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncAuthCookies(): void {
|
||||||
|
if (pb.authStore.isValid && pb.authStore.token) {
|
||||||
|
const user = pb.authStore.model as PBUser | null
|
||||||
|
const role = user?.role ?? ''
|
||||||
|
document.cookie = `${COOKIE_TOKEN}=${pb.authStore.token}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`
|
||||||
|
document.cookie = `${COOKIE_ROLE}=${role}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`
|
||||||
|
} else {
|
||||||
|
clearAuthCookies()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthCookies(): void {
|
||||||
|
document.cookie = `${COOKIE_TOKEN}=; path=/; max-age=0`
|
||||||
|
document.cookie = `${COOKIE_ROLE}=; path=/; max-age=0`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(): void {
|
||||||
|
pb.authStore.clear()
|
||||||
|
clearAuthCookies()
|
||||||
|
window.location.href = '/auth'
|
||||||
|
}
|
||||||
69
app/frontend/src/lib/hooks/useIngestionStatus.ts
Normal file
69
app/frontend/src/lib/hooks/useIngestionStatus.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { getIngestionStatus } from '@/lib/services'
|
||||||
|
import type { IngestionJobStatus } from '@/types'
|
||||||
|
|
||||||
|
const TERMINAL_STATES = new Set<IngestionJobStatus['status']>(['done', 'failed'])
|
||||||
|
const POLL_INTERVAL_MS = 3000
|
||||||
|
|
||||||
|
interface UseIngestionStatusResult {
|
||||||
|
status: IngestionJobStatus | null
|
||||||
|
progress: number
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcProgress(s: IngestionJobStatus): number {
|
||||||
|
const stageMap: Record<IngestionJobStatus['status'], number> = {
|
||||||
|
queued: 5,
|
||||||
|
extracting: 15,
|
||||||
|
chunking: 30,
|
||||||
|
structuring: 50,
|
||||||
|
writing: 65,
|
||||||
|
embedding:
|
||||||
|
s.chunksTotal > 0 ? 65 + Math.round((s.chunksEmbedded / s.chunksTotal) * 30) : 70,
|
||||||
|
done: 100,
|
||||||
|
failed: 0,
|
||||||
|
}
|
||||||
|
return stageMap[s.status] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIngestionStatus(jobId: string | null): UseIngestionStatusResult {
|
||||||
|
const [status, setStatus] = useState<IngestionJobStatus | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jobId) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
if (cancelled) return
|
||||||
|
try {
|
||||||
|
const result = await getIngestionStatus(jobId!)
|
||||||
|
if (cancelled) return
|
||||||
|
setStatus(result)
|
||||||
|
setError(null)
|
||||||
|
if (!TERMINAL_STATES.has(result.status)) {
|
||||||
|
timerRef.current = setTimeout(poll, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void poll()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
}
|
||||||
|
}, [jobId])
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
progress: status ? calcProgress(status) : 0,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/frontend/src/lib/hooks/usePBCollection.ts
Normal file
57
app/frontend/src/lib/hooks/usePBCollection.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { pb } from '@/lib/pocketbase'
|
||||||
|
|
||||||
|
interface UsePBCollectionResult<T> {
|
||||||
|
items: T[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
refresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePBCollection<T>(
|
||||||
|
collection: string,
|
||||||
|
options?: Record<string, unknown>,
|
||||||
|
): UsePBCollectionResult<T> {
|
||||||
|
const [items, setItems] = useState<T[]>([])
|
||||||
|
const [loading, setLoading] = useState<boolean>(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [tick, setTick] = useState<number>(0)
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setTick((t) => t + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await pb.collection(collection).getFullList<T>({
|
||||||
|
...(options ?? {}),
|
||||||
|
})
|
||||||
|
if (!cancelled) {
|
||||||
|
setItems(result)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [collection, tick])
|
||||||
|
|
||||||
|
return { items, loading, error, refresh }
|
||||||
|
}
|
||||||
4
app/frontend/src/lib/pocketbase.ts
Normal file
4
app/frontend/src/lib/pocketbase.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
'use client'
|
||||||
|
import PocketBase from 'pocketbase'
|
||||||
|
|
||||||
|
export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL ?? 'http://localhost:8090')
|
||||||
89
app/frontend/src/lib/services.ts
Normal file
89
app/frontend/src/lib/services.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type {
|
||||||
|
IngestionJobStatus,
|
||||||
|
CompletePayload,
|
||||||
|
CompleteResponse,
|
||||||
|
LeaderboardEntry,
|
||||||
|
FeedEntry,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
const INGESTION_URL = process.env.NEXT_PUBLIC_INGESTION_URL ?? 'http://localhost:3001'
|
||||||
|
const GENERATION_URL = process.env.NEXT_PUBLIC_GENERATION_URL ?? 'http://localhost:3002'
|
||||||
|
const CURRICULUM_URL = process.env.NEXT_PUBLIC_CURRICULUM_URL ?? 'http://localhost:3003'
|
||||||
|
const PROGRESS_URL = process.env.NEXT_PUBLIC_PROGRESS_URL ?? 'http://localhost:3005'
|
||||||
|
|
||||||
|
async function handleResponse<T>(res: Response): Promise<T> {
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => res.statusText)
|
||||||
|
throw new Error(`HTTP ${res.status}: ${text}`)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postIngest(formData: FormData): Promise<{ jobId: string }> {
|
||||||
|
const res = await fetch(`${INGESTION_URL}/ingest`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
return handleResponse<{ jobId: string }>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIngestionStatus(jobId: string): Promise<IngestionJobStatus> {
|
||||||
|
const res = await fetch(`${INGESTION_URL}/ingest/${encodeURIComponent(jobId)}/status`)
|
||||||
|
return handleResponse<IngestionJobStatus>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postGenerateAll(themeId: string): Promise<{ queued: number }> {
|
||||||
|
const res = await fetch(`${GENERATION_URL}/generate/theme/${encodeURIComponent(themeId)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
return handleResponse<{ queued: number }>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postComplete(payload: CompletePayload): Promise<CompleteResponse> {
|
||||||
|
const res = await fetch(`${PROGRESS_URL}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
return handleResponse<CompleteResponse>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLeaderboard(): Promise<LeaderboardEntry[]> {
|
||||||
|
const res = await fetch(`${PROGRESS_URL}/leaderboard`)
|
||||||
|
return handleResponse<LeaderboardEntry[]>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFeed(): Promise<FeedEntry[]> {
|
||||||
|
const res = await fetch(`${PROGRESS_URL}/feed`)
|
||||||
|
return handleResponse<FeedEntry[]>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postCurriculumConfirm(versionId: string): Promise<void> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${CURRICULUM_URL}/curriculum/versions/${encodeURIComponent(versionId)}/confirm`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => res.statusText)
|
||||||
|
throw new Error(`HTTP ${res.status}: ${text}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchCurriculumWeekOrder(weekId: string, position: number): Promise<void> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${CURRICULUM_URL}/curriculum/weeks/${encodeURIComponent(weekId)}/order`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ position }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => res.statusText)
|
||||||
|
throw new Error(`HTTP ${res.status}: ${text}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
274
app/frontend/src/types/index.ts
Normal file
274
app/frontend/src/types/index.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
// PocketBase collection types
|
||||||
|
|
||||||
|
export interface SourceDocument {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
format: 'pdf' | 'md' | 'txt'
|
||||||
|
status: 'processing' | 'processed' | 'failed'
|
||||||
|
ingested_at: string
|
||||||
|
chunk_count: number
|
||||||
|
created_by: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
status: 'draft' | 'published' | 'rejected'
|
||||||
|
source_documents: string[]
|
||||||
|
approved_by: string
|
||||||
|
approved_at: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
expand?: { source_documents?: SourceDocument[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Topic {
|
||||||
|
id: string
|
||||||
|
theme: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
difficulty: 'introductory' | 'intermediate' | 'advanced'
|
||||||
|
complexity_weight: number
|
||||||
|
status: 'draft' | 'published'
|
||||||
|
related_topics: string[]
|
||||||
|
prerequisite_topics: string[]
|
||||||
|
contrast_topics: string[]
|
||||||
|
key_terms: string[]
|
||||||
|
qdrant_chunk_ids: string[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
expand?: {
|
||||||
|
theme?: Theme
|
||||||
|
related_topics?: Topic[]
|
||||||
|
prerequisite_topics?: Topic[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizOption {
|
||||||
|
label: string
|
||||||
|
text: string
|
||||||
|
correct: boolean
|
||||||
|
explanation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MisconceptionItem {
|
||||||
|
misconception: string
|
||||||
|
correction: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HowToStep {
|
||||||
|
number: number
|
||||||
|
instruction: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComparisonDimension {
|
||||||
|
label: string
|
||||||
|
a: string
|
||||||
|
b: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Flashcard {
|
||||||
|
question: string
|
||||||
|
answer: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MicroLearningType =
|
||||||
|
| 'concept_explainer'
|
||||||
|
| 'scenario_quiz'
|
||||||
|
| 'misconceptions'
|
||||||
|
| 'how_to'
|
||||||
|
| 'comparison_card'
|
||||||
|
| 'reflection_prompt'
|
||||||
|
| 'flashcard_set'
|
||||||
|
| 'case_study'
|
||||||
|
| 'glossary_anchor'
|
||||||
|
| 'myth_vs_evidence'
|
||||||
|
|
||||||
|
export type MicroLearningContent =
|
||||||
|
| { type: 'concept_explainer'; paragraphs: string[]; example: string }
|
||||||
|
| { type: 'scenario_quiz'; scenario: string; options: QuizOption[] }
|
||||||
|
| { type: 'misconceptions'; items: MisconceptionItem[] }
|
||||||
|
| { type: 'how_to'; steps: HowToStep[] }
|
||||||
|
| {
|
||||||
|
type: 'comparison_card'
|
||||||
|
subject_a: string
|
||||||
|
subject_b: string
|
||||||
|
dimensions: ComparisonDimension[]
|
||||||
|
}
|
||||||
|
| { type: 'reflection_prompt'; prompt: string; model_answer: string }
|
||||||
|
| { type: 'flashcard_set'; cards: Flashcard[] }
|
||||||
|
| { type: 'case_study'; scenario: string; questions: string[] }
|
||||||
|
| {
|
||||||
|
type: 'glossary_anchor'
|
||||||
|
term: string
|
||||||
|
definition: string
|
||||||
|
correct_use: string
|
||||||
|
misuse: string
|
||||||
|
}
|
||||||
|
| { type: 'myth_vs_evidence'; myth: string; evidence: string; sources: string[] }
|
||||||
|
|
||||||
|
export interface MicroLearning {
|
||||||
|
id: string
|
||||||
|
topic: string
|
||||||
|
type: MicroLearningType
|
||||||
|
content: MicroLearningContent
|
||||||
|
status: 'queued' | 'generated' | 'published' | 'rejected'
|
||||||
|
generation_model: string
|
||||||
|
generated_at: string
|
||||||
|
published_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurriculumVersion {
|
||||||
|
id: string
|
||||||
|
version: number
|
||||||
|
status: 'draft' | 'active' | 'superseded'
|
||||||
|
generated_at: string
|
||||||
|
approved_by: string
|
||||||
|
approved_at: string
|
||||||
|
generation_notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurriculumWeek {
|
||||||
|
id: string
|
||||||
|
curriculum_version: string
|
||||||
|
week_number: number
|
||||||
|
theme: string
|
||||||
|
topics: string[]
|
||||||
|
topic_order: number[]
|
||||||
|
estimated_duration_minutes: number
|
||||||
|
admin_notes: string
|
||||||
|
expand?: { theme?: Theme; topics?: Topic[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmployeeCurriculumState {
|
||||||
|
id: string
|
||||||
|
user: string
|
||||||
|
current_cycle: number
|
||||||
|
current_week: number
|
||||||
|
start_date: string
|
||||||
|
active_version: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionCompletion {
|
||||||
|
id: string
|
||||||
|
user: string
|
||||||
|
topic: string
|
||||||
|
micro_learning: string
|
||||||
|
week_number: number
|
||||||
|
cycle: number
|
||||||
|
completed_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamificationProfile {
|
||||||
|
id: string
|
||||||
|
user: string
|
||||||
|
total_commits: number
|
||||||
|
current_level: 'intern' | 'junior' | 'medior' | 'senior' | 'staff' | 'principal'
|
||||||
|
current_streak_weeks: number
|
||||||
|
longest_streak_weeks: number
|
||||||
|
types_used: string[]
|
||||||
|
last_active_week: number
|
||||||
|
updated_at: string
|
||||||
|
expand?: { user?: PBUser }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Badge {
|
||||||
|
id: string
|
||||||
|
key: string
|
||||||
|
tier: 'bronze' | 'silver' | 'gold' | 'legendary' | 'content'
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmployeeBadge {
|
||||||
|
id: string
|
||||||
|
user: string
|
||||||
|
badge: string
|
||||||
|
earned_at: string
|
||||||
|
cycle: number
|
||||||
|
expand?: { badge?: Badge }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MilestoneCard {
|
||||||
|
id: string
|
||||||
|
user: string
|
||||||
|
cycle: number
|
||||||
|
week: number
|
||||||
|
total_commits: number
|
||||||
|
streak_weeks: number
|
||||||
|
badge_keys: string[]
|
||||||
|
created_at: string
|
||||||
|
expand?: { user?: PBUser }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PBUser {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
role: 'admin' | 'employee'
|
||||||
|
display_name: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service response types
|
||||||
|
|
||||||
|
export interface IngestionJobStatus {
|
||||||
|
jobId: string
|
||||||
|
status:
|
||||||
|
| 'queued'
|
||||||
|
| 'extracting'
|
||||||
|
| 'chunking'
|
||||||
|
| 'structuring'
|
||||||
|
| 'writing'
|
||||||
|
| 'embedding'
|
||||||
|
| 'done'
|
||||||
|
| 'failed'
|
||||||
|
chunksTotal: number
|
||||||
|
chunksEmbedded: number
|
||||||
|
themesFound: number
|
||||||
|
topicsFound: number
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletePayload {
|
||||||
|
userId: string
|
||||||
|
topicId: string
|
||||||
|
microLearningId: string
|
||||||
|
weekNumber: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteResponse {
|
||||||
|
commitsEarned: number
|
||||||
|
newBadges: Badge[]
|
||||||
|
totalCommits: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaderboardEntry {
|
||||||
|
userId: string
|
||||||
|
displayName: string
|
||||||
|
totalCommits: number
|
||||||
|
currentStreak: number
|
||||||
|
typesUsed: number
|
||||||
|
badgeCount: number
|
||||||
|
level: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedEntry {
|
||||||
|
id: string
|
||||||
|
displayName: string
|
||||||
|
cycle: number
|
||||||
|
week: number
|
||||||
|
totalCommits: number
|
||||||
|
streakWeeks: number
|
||||||
|
badgeKeys: string[]
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type R42SSEEvent =
|
||||||
|
| { type: 'chunk'; text: string }
|
||||||
|
| { type: 'citations'; topics: Array<{ id: string; title: string }> }
|
||||||
|
| { type: 'out_of_scope'; text: string }
|
||||||
|
| { type: 'done' }
|
||||||
15
app/frontend/tailwind.config.ts
Normal file
15
app/frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
21
app/frontend/tsconfig.json
Normal file
21
app/frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
1
app/frontend/tsconfig.tsbuildinfo
Normal file
1
app/frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user