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