feat: implement core knowledge graph UI components, extraction pipeline, and initial platform navigation pages
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info } from 'lucide-react';
|
||||
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info, Layers, CheckSquare } from 'lucide-react';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Tag from '../../components/ui/Tag';
|
||||
import Button from '../../components/ui/Button';
|
||||
@@ -7,9 +7,11 @@ import Input from '../../components/ui/Input';
|
||||
import { storage } from '../../lib/storage';
|
||||
import UploadZone from '../../components/admin/UploadZone';
|
||||
import KnowledgeGraph from '../../components/admin/KnowledgeGraph';
|
||||
import ContentManager from '../../components/admin/ContentManager';
|
||||
import TestManager from '../../components/admin/TestManager';
|
||||
|
||||
const Admin = () => {
|
||||
const [activeTab, setActiveTab] = useState('bronnen');
|
||||
const [activeTab, setActiveTab] = useState('sources');
|
||||
const [sources, setSources] = useState([]);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
@@ -21,10 +23,10 @@ const Admin = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'bronnen') {
|
||||
if (activeTab === 'sources') {
|
||||
loadSources();
|
||||
}
|
||||
if (activeTab === 'instellingen') {
|
||||
if (activeTab === 'settings') {
|
||||
setApiKey(storage.get('admin:anthropic_key', ''));
|
||||
setModel(storage.get('admin:model', 'claude-sonnet-4-20250514'));
|
||||
setUseSimulation(storage.get('admin:use_simulation', false));
|
||||
@@ -36,63 +38,53 @@ const Admin = () => {
|
||||
storage.set('admin:anthropic_key', apiKey.trim());
|
||||
storage.set('admin:model', model.trim());
|
||||
storage.set('admin:use_simulation', useSimulation);
|
||||
setSaveStatus('Opgeslagen!');
|
||||
setSaveStatus('Saved!');
|
||||
setTimeout(() => setSaveStatus(null), 3000);
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ key: 'sources', icon: Database, label: 'Sources' },
|
||||
{ key: 'content', icon: Layers, label: 'Content' },
|
||||
{ key: 'tests', icon: CheckSquare, label: 'Quizzes' },
|
||||
{ key: 'graph', icon: Network, label: 'Graph' },
|
||||
{ key: 'team', icon: Users, label: 'Team' },
|
||||
{ key: 'settings', icon: Settings, label: 'Settings', bottom: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row h-[calc(100vh-64px)] overflow-hidden">
|
||||
{/* Admin Sidebar */}
|
||||
<div className="w-full md:w-64 bg-paper border-r border-bg-warm flex-shrink-0 flex flex-row md:flex-col p-4 gap-2 overflow-x-auto md:overflow-y-auto">
|
||||
<h2 className="hidden md:block text-teal font-bold text-lg mb-4 px-2">Kennisbeheer</h2>
|
||||
<h2 className="hidden md:block text-teal font-bold text-lg mb-4 px-2">Knowledge Mgmt</h2>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('bronnen')}
|
||||
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 ${activeTab === 'bronnen' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
|
||||
>
|
||||
<Database size={20} />
|
||||
<span className="text-sm mt-1 md:mt-0">Bronnen</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('graaf')}
|
||||
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 ${activeTab === 'graaf' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
|
||||
>
|
||||
<Network size={20} />
|
||||
<span className="text-sm mt-1 md:mt-0">Graaf</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('gebruikers')}
|
||||
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 ${activeTab === 'gebruikers' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
|
||||
>
|
||||
<Users size={20} />
|
||||
<span className="text-sm mt-1 md:mt-0">Team</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('instellingen')}
|
||||
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 mt-0 md:mt-auto ${activeTab === 'instellingen' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
|
||||
>
|
||||
<Settings size={20} />
|
||||
<span className="text-sm mt-1 md:mt-0">Settings</span>
|
||||
</button>
|
||||
{navItems.map(({ key, icon: Icon, label, bottom }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveTab(key)}
|
||||
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 ${bottom ? 'mt-0 md:mt-auto' : ''} ${activeTab === key ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="text-sm mt-1 md:mt-0">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Admin Main Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-8 bg-bg pb-24 md:pb-8">
|
||||
{activeTab === 'bronnen' && (
|
||||
|
||||
{/* ── Sources ─────────────────────────────── */}
|
||||
{activeTab === 'sources' && (
|
||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl text-teal mb-2">Bronmateriaal</h1>
|
||||
<p className="text-fg-muted mb-8">Upload bestanden of geef URL's op voor de AI-kennisextractie.</p>
|
||||
<h1 className="text-3xl text-teal mb-2">Source Material</h1>
|
||||
<p className="text-fg-muted mb-8">Upload files to feed the AI knowledge extraction pipeline.</p>
|
||||
|
||||
<UploadZone onUploadComplete={loadSources} />
|
||||
|
||||
<h3 className="text-xl mb-4 mt-12">Recente Bronnen</h3>
|
||||
<h3 className="text-xl mb-4 mt-12">Recent Sources</h3>
|
||||
<Card className="p-0 border border-bg-warm overflow-hidden">
|
||||
<div className="divide-y divide-bg-warm">
|
||||
{sources.length === 0 ? (
|
||||
<div className="p-8 text-center text-fg-muted">Geen bronnen geüpload.</div>
|
||||
<div className="p-8 text-center text-fg-muted">No sources uploaded yet.</div>
|
||||
) : (
|
||||
sources.map((source) => (
|
||||
<div key={source.id} className="p-4 flex items-center justify-between hover:bg-bg-warm/30 transition-colors">
|
||||
@@ -112,9 +104,9 @@ const Admin = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{source.status === 'completed' && <Tag variant="success" className="flex items-center gap-1"><CheckCircle2 size={12}/> Voltooid</Tag>}
|
||||
{source.status === 'processing' && <Tag variant="accent" className="flex items-center gap-1"><Clock size={12}/> Bezig</Tag>}
|
||||
{source.status === 'failed' && <Tag variant="dark" className="bg-red-100 text-red-800 flex items-center gap-1"><AlertCircle size={12}/> Mislukt</Tag>}
|
||||
{source.status === 'completed' && <Tag variant="success" className="flex items-center gap-1"><CheckCircle2 size={12}/> Completed</Tag>}
|
||||
{source.status === 'processing' && <Tag variant="accent" className="flex items-center gap-1"><Clock size={12}/> Processing</Tag>}
|
||||
{source.status === 'failed' && <Tag variant="dark" className="bg-red-100 text-red-800 flex items-center gap-1"><AlertCircle size={12}/> Failed</Tag>}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -124,34 +116,55 @@ const Admin = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'graaf' && (
|
||||
{/* ── Content Manager ──────────────────────── */}
|
||||
{activeTab === 'content' && (
|
||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl text-teal mb-2">Learning Content</h1>
|
||||
<p className="text-fg-muted mb-8">Manage, refine, or regenerate AI-generated learning modules for each knowledge topic.</p>
|
||||
<ContentManager />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Test Manager ─────────────────────────── */}
|
||||
{activeTab === 'tests' && (
|
||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl text-teal mb-2">Question Banks</h1>
|
||||
<p className="text-fg-muted mb-8">Pre-generate and review quiz questions for each topic to eliminate loading times for users.</p>
|
||||
<TestManager />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Knowledge Graph ──────────────────────── */}
|
||||
{activeTab === 'graph' && (
|
||||
<div className="animate-in fade-in duration-300 h-full flex flex-col">
|
||||
<h1 className="text-3xl text-teal mb-2">Kennisgraaf</h1>
|
||||
<p className="text-fg-muted mb-4">Visualisatie van de opgebouwde Respellion kennis.</p>
|
||||
<h1 className="text-3xl text-teal mb-2">Knowledge Graph</h1>
|
||||
<p className="text-fg-muted mb-4">Visual map of all extracted Respellion knowledge.</p>
|
||||
<Card className="flex-1 p-0 border border-bg-warm overflow-hidden bg-paper min-h-[400px]">
|
||||
<KnowledgeGraph />
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'gebruikers' && (
|
||||
{/* ── Team ────────────────────────────────── */}
|
||||
{activeTab === 'team' && (
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<h1 className="text-3xl text-teal mb-2">Gebruikersbeheer</h1>
|
||||
<h1 className="text-3xl text-teal mb-2">Team Management</h1>
|
||||
<Card className="border border-bg-warm">
|
||||
<p className="text-fg-muted">Hier kunnen medewerkers worden toegevoegd en beheerd.</p>
|
||||
<p className="text-fg-muted">Team members can be added and managed here.</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'instellingen' && (
|
||||
{/* ── Settings ────────────────────────────── */}
|
||||
{activeTab === 'settings' && (
|
||||
<div className="animate-in fade-in duration-300 max-w-2xl">
|
||||
<h1 className="text-3xl text-teal mb-2">Instellingen</h1>
|
||||
<p className="text-fg-muted mb-8">Beheer applicatie-instellingen en API-sleutels.</p>
|
||||
<h1 className="text-3xl text-teal mb-2">Settings</h1>
|
||||
<p className="text-fg-muted mb-8">Manage API keys and application configuration.</p>
|
||||
|
||||
<Card className="border border-bg-warm">
|
||||
<form onSubmit={saveSettings} className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-4">AI Configuratie</h3>
|
||||
<h3 className="text-lg font-medium mb-4">AI Configuration</h3>
|
||||
<Input
|
||||
label="Anthropic API Key"
|
||||
type="password"
|
||||
@@ -166,18 +179,18 @@ const Admin = () => {
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-fg-muted mt-1">Laat leeg voor de standaard. Controleer je Anthropic Console voor beschikbare modellen.</p>
|
||||
<p className="text-xs text-fg-muted mt-1">Leave blank for the default. Check your Anthropic Console for available models.</p>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted mt-2">
|
||||
Deze sleutel wordt lokaal in je browser opgeslagen (`localStorage`).
|
||||
Your API key is stored locally in your browser's <code>localStorage</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-bg-warm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Simulatie Mode</h3>
|
||||
<p className="text-sm text-fg-muted">Gebruik gesimuleerde AI-antwoorden als de API niet werkt.</p>
|
||||
<h3 className="text-lg font-medium">Simulation Mode</h3>
|
||||
<p className="text-sm text-fg-muted">Use simulated AI responses when the API is unavailable.</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
@@ -192,16 +205,16 @@ const Admin = () => {
|
||||
{useSimulation && (
|
||||
<div className="p-3 bg-teal/5 border border-teal/20 rounded-[var(--r-sm)] flex gap-3 text-sm text-teal-800">
|
||||
<Info size={18} className="flex-shrink-0" />
|
||||
<p>Wanneer ingeschakeld, zal het platform doen alsof de AI teksten verwerkt zonder een echte API-aanroep te doen. Ideaal voor UI testen.</p>
|
||||
<p>When enabled, the platform will simulate AI processing without making real API calls. Ideal for UI testing.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<Button type="submit">
|
||||
<Save size={18} className="mr-2" /> Instellingen Opslaan
|
||||
<Save size={18} className="mr-2" /> Save Settings
|
||||
</Button>
|
||||
{saveStatus && <span className="text-teal font-medium animate-in fade-out duration-1000 fill-mode-forwards">{saveStatus}</span>}
|
||||
{saveStatus && <span className="text-teal font-medium">{saveStatus}</span>}
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import Card from '../components/ui/Card';
|
||||
import Button from '../components/ui/Button';
|
||||
@@ -11,33 +12,35 @@ const Dashboard = () => {
|
||||
return (
|
||||
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<header>
|
||||
<h1 className="text-3xl md:text-5xl mb-2">Welkom, {currentUser?.name}</h1>
|
||||
<p className="text-fg-muted text-lg">Dit is je overzicht voor week {weekNumber}.</p>
|
||||
<h1 className="text-3xl md:text-5xl mb-2">Welcome, {currentUser?.name}</h1>
|
||||
<p className="text-fg-muted text-lg">Here is your overview for week {weekNumber}.</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card className="flex flex-col border border-bg-warm" hoverable>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl">Leren</h3>
|
||||
<p className="text-fg-muted text-sm mt-1">Jouw onderwerp deze week:</p>
|
||||
<h3 className="text-xl">Learning</h3>
|
||||
<p className="text-fg-muted text-sm mt-1">Your topic this week:</p>
|
||||
</div>
|
||||
<Tag variant="accent">Te doen</Tag>
|
||||
<Tag variant="accent">To Do</Tag>
|
||||
</div>
|
||||
<h2 className="text-2xl mt-2 mb-6">De Rol van de Product Owner</h2>
|
||||
<Button className="mt-auto">Start Leersessie</Button>
|
||||
<h2 className="text-2xl mt-2 mb-6">The Role of the Product Owner</h2>
|
||||
<Link to="/learn">
|
||||
<Button className="mt-auto w-full">Start Learning Session</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card className="flex flex-col border border-bg-warm" hoverable>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl">Testen</h3>
|
||||
<p className="text-fg-muted text-sm mt-1">Weektest 10 vragen</p>
|
||||
<h3 className="text-xl">Testing</h3>
|
||||
<p className="text-fg-muted text-sm mt-1">Weekly test — 10 questions</p>
|
||||
</div>
|
||||
<Tag variant="default">Te doen</Tag>
|
||||
<Tag variant="default">To Do</Tag>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center py-6 text-fg-subtle">
|
||||
Rond eerst je leersessie af
|
||||
Complete your learning session first
|
||||
</div>
|
||||
<Button variant="outline" className="mt-auto" disabled>Start Test</Button>
|
||||
</Card>
|
||||
@@ -45,29 +48,28 @@ const Dashboard = () => {
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-2xl mb-4">Recente Activiteit</h3>
|
||||
<h3 className="text-2xl mb-4">Recent Activity</h3>
|
||||
<Card className="p-0 overflow-hidden border border-bg-warm">
|
||||
<div className="divide-y divide-bg-warm">
|
||||
{/* Placeholder activity items */}
|
||||
<div className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
|
||||
<div className="w-10 h-10 rounded-[var(--r-org)] bg-accent-soft flex items-center justify-center text-purple-700 font-bold">
|
||||
T
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Test afgerond: Informatiebeveiliging</p>
|
||||
<p className="text-sm text-fg-muted">Vorige week • Score: 90%</p>
|
||||
<p className="font-medium">Test completed: Information Security</p>
|
||||
<p className="text-sm text-fg-muted">Last week · Score: 90%</p>
|
||||
</div>
|
||||
<div className="ml-auto text-teal font-bold">+15 pt</div>
|
||||
<div className="ml-auto text-teal font-bold">+15 pts</div>
|
||||
</div>
|
||||
<div className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
|
||||
<div className="w-10 h-10 rounded-[var(--r-org)] bg-sage flex items-center justify-center text-teal-900 font-bold">
|
||||
L
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Leersessie: Informatiebeveiliging</p>
|
||||
<p className="text-sm text-fg-muted">Vorige week</p>
|
||||
<p className="font-medium">Learning session: Information Security</p>
|
||||
<p className="text-sm text-fg-muted">Last week</p>
|
||||
</div>
|
||||
<div className="ml-auto text-teal font-bold">+15 pt</div>
|
||||
<div className="ml-auto text-teal font-bold">+5 pts</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -77,23 +79,24 @@ const Dashboard = () => {
|
||||
<h3 className="text-2xl mb-4">Mini Leaderboard</h3>
|
||||
<Card className="border border-bg-warm">
|
||||
<div className="space-y-4">
|
||||
{/* Placeholder leaderboard items */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-bold text-accent">1.</span>
|
||||
<span className="font-medium">Admin</span>
|
||||
</div>
|
||||
<Tag variant="dark">120 pt</Tag>
|
||||
<Tag variant="dark">120 pts</Tag>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-bold text-teal">2.</span>
|
||||
<span className="font-medium">{currentUser?.name}</span>
|
||||
</div>
|
||||
<Tag variant="default">85 pt</Tag>
|
||||
<Tag variant="default">85 pts</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" className="w-full mt-4 text-sm">Bekijk volledig leaderboard</Button>
|
||||
<Link to="/leaderboard">
|
||||
<Button variant="ghost" className="w-full mt-4 text-sm">View full leaderboard</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { ChevronLeft, ChevronRight, BookOpen, Presentation, Mic, CheckCircle, Loader, ArrowRight, Volume2, VolumeX, BarChart2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BookOpen, CheckCircle, Loader, ArrowRight } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Card from '../components/ui/Card';
|
||||
import Button from '../components/ui/Button';
|
||||
import Tag from '../components/ui/Tag';
|
||||
import LearningContentViewer from '../components/ui/LearningContentViewer';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import { getAssignedTopic, generateLearningContent } from '../lib/learningService';
|
||||
import { getAssignedTopic, generateLearningContent, getCachedContent } from '../lib/learningService';
|
||||
import { storage } from '../lib/storage';
|
||||
|
||||
const Leren = () => {
|
||||
const { state } = useApp();
|
||||
const [topic, setTopic] = useState(null);
|
||||
const [content, setContent] = useState(null);
|
||||
const [activeMode, setActiveMode] = useState('artikel'); // 'artikel' | 'slides' | 'podcast'
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
@@ -21,6 +22,13 @@ const Leren = () => {
|
||||
if (state.currentUser) {
|
||||
const assigned = getAssignedTopic(state.currentUser.id, state.weekNumber);
|
||||
setTopic(assigned);
|
||||
if (assigned) {
|
||||
const cached = getCachedContent(assigned.id);
|
||||
if (cached) setContent(cached);
|
||||
}
|
||||
// Check if already completed this week
|
||||
const done = storage.get(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, false);
|
||||
if (done) setCompleted(true);
|
||||
}
|
||||
}, [state.currentUser, state.weekNumber]);
|
||||
|
||||
@@ -43,27 +51,31 @@ const Leren = () => {
|
||||
storage.set(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, true);
|
||||
};
|
||||
|
||||
// ── No topics available ───────────────────────────────────
|
||||
if (!topic) {
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||
<BookOpen size={64} className="mx-auto text-teal/30 mb-6" />
|
||||
<h1 className="text-3xl text-teal font-bold mb-4">Leerstation</h1>
|
||||
<p className="text-fg-muted">Er zijn nog geen kennisonderwerpen beschikbaar. Vraag een admin om bronmateriaal te uploaden.</p>
|
||||
<h1 className="text-3xl text-teal font-bold mb-4">Learning Station</h1>
|
||||
<p className="text-fg-muted">No knowledge topics are available yet. Ask an admin to upload source material.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Session completed ─────────────────────────────────────
|
||||
if (completed) {
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
|
||||
<CheckCircle size={80} className="mx-auto text-teal mb-6" />
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold mb-4">Leersessie voltooid!</h1>
|
||||
<p className="text-fg-muted mb-8">Je hebt "<strong>{topic.label}</strong>" succesvol doorgenomen. Ga nu de weektest maken!</p>
|
||||
<Button onClick={() => window.location.hash = '/testen'}>
|
||||
Weektest Starten <ArrowRight size={18} className="ml-2" />
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold mb-4">Learning session complete!</h1>
|
||||
<p className="text-fg-muted mb-8">
|
||||
You have successfully reviewed "<strong>{topic.label}</strong>". Now take the weekly test!
|
||||
</p>
|
||||
<Link to="/test">
|
||||
<Button>Start Weekly Test <ArrowRight size={18} className="ml-2" /></Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -80,75 +92,43 @@ const Leren = () => {
|
||||
<p className="text-fg-muted mt-2">{topic.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Mode Selector */}
|
||||
{content && (
|
||||
<div className="flex gap-2 mb-8 flex-wrap">
|
||||
{[
|
||||
{ key: 'artikel', icon: BookOpen, label: 'Artikel' },
|
||||
{ key: 'slides', icon: Presentation, label: 'Slides' },
|
||||
{ key: 'podcast', icon: Mic, label: 'Podcast' },
|
||||
{ key: 'infographic', icon: BarChart2, label: 'Infographic' },
|
||||
].map(({ key, icon: Icon, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveMode(key)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-[var(--r-sm)] border transition-all ${
|
||||
activeMode === key
|
||||
? 'bg-teal text-white border-teal shadow-sm'
|
||||
: 'border-bg-warm text-fg-muted hover:text-fg hover:border-teal/50 bg-paper'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span className="font-medium text-sm">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Area */}
|
||||
{/* Generate prompt */}
|
||||
{!content && !isLoading && (
|
||||
<Card className="border border-bg-warm text-center py-16">
|
||||
<BookOpen size={48} className="mx-auto text-teal/30 mb-4" />
|
||||
<p className="text-fg-muted mb-6">Klik op de knop om je gepersonaliseerde leerinhoud te genereren met AI.</p>
|
||||
<Button onClick={loadContent} disabled={isLoading}>
|
||||
Leerinhoud Genereren
|
||||
</Button>
|
||||
<p className="text-fg-muted mb-6">Click the button to generate your personalized AI learning content.</p>
|
||||
<Button onClick={loadContent}>Generate Learning Content</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<Card className="border border-bg-warm text-center py-16">
|
||||
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||
<p className="font-medium">AI genereert je gepersonaliseerde leerinhoud...</p>
|
||||
<p className="text-fg-muted text-sm mt-2">Dit kan 10-30 seconden duren.</p>
|
||||
<p className="font-medium">AI is generating your personalized learning content...</p>
|
||||
<p className="text-fg-muted text-sm mt-2">This may take 10–30 seconds.</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Card className="border border-red-200 bg-red-50 text-red-900 p-6">
|
||||
<p className="font-bold mb-1">Fout bij genereren</p>
|
||||
<p className="font-bold mb-1">Generation failed</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
<Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700">
|
||||
Opnieuw proberen
|
||||
Try again
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{content && (
|
||||
<motion.div key={activeMode} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
|
||||
{activeMode === 'artikel' && <ArticleView content={content.article} />}
|
||||
{activeMode === 'slides' && <SlidesView slides={content.slides} />}
|
||||
{activeMode === 'podcast' && <PodcastView script={content.podcastScript} topicLabel={topic.label} />}
|
||||
{activeMode === 'infographic' && <InfographicView data={content.infographic} topicLabel={topic.label} />}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* Content Viewer */}
|
||||
{content && <LearningContentViewer content={content} topic={topic} />}
|
||||
|
||||
{/* Complete button */}
|
||||
{content && !completed && (
|
||||
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end">
|
||||
<Button onClick={handleComplete}>
|
||||
<CheckCircle size={18} className="mr-2" /> Leersessie afronden
|
||||
<CheckCircle size={18} className="mr-2" /> Complete Session
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -156,236 +136,4 @@ const Leren = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Article Renderer ─────────────────────────────────────── */
|
||||
const ArticleView = ({ content }) => (
|
||||
<div className="space-y-8">
|
||||
<Card className="border border-bg-warm">
|
||||
<h2 className="text-2xl font-bold mb-3">{content.title}</h2>
|
||||
<p className="text-fg-muted text-lg leading-relaxed">{content.intro}</p>
|
||||
</Card>
|
||||
|
||||
{content.sections?.map((section, i) => (
|
||||
<Card key={i} className="border border-bg-warm">
|
||||
<h3 className="text-xl font-semibold mb-3 text-teal">{section.heading}</h3>
|
||||
<p className="leading-relaxed">{section.body}</p>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{content.keyTakeaways?.length > 0 && (
|
||||
<Card className="border border-teal/30 bg-teal/5">
|
||||
<h3 className="font-bold text-teal mb-4 flex items-center gap-2"><CheckCircle size={18} /> Kernpunten</h3>
|
||||
<ul className="space-y-2">
|
||||
{content.keyTakeaways.map((point, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<span className="font-mono text-teal mt-0.5">→</span>
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ── Slides Renderer ──────────────────────────────────────── */
|
||||
const SlidesView = ({ slides }) => {
|
||||
const [current, setCurrent] = useState(0);
|
||||
const total = slides?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div key={current} initial={{ opacity: 0, x: 40 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -40 }} transition={{ duration: 0.25 }}>
|
||||
<Card className="border border-bg-warm min-h-[300px] flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="font-mono text-xs text-fg-muted">{current + 1} / {total}</span>
|
||||
<Tag variant="accent">Slide</Tag>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-6">{slides[current]?.title}</h2>
|
||||
<ul className="space-y-3">
|
||||
{slides[current]?.bullets?.map((bullet, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<span className="font-mono text-purple-500 font-bold mt-0.5">▸</span>
|
||||
<span className="text-lg">{bullet}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{slides[current]?.speakerNote && (
|
||||
<p className="text-sm text-fg-muted border-t border-bg-warm pt-4 mt-6 italic">
|
||||
💬 {slides[current].speakerNote}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button variant="outline" onClick={() => setCurrent(c => Math.max(0, c - 1))} disabled={current === 0}>
|
||||
<ChevronLeft size={18} />
|
||||
</Button>
|
||||
<div className="flex gap-1.5">
|
||||
{slides?.map((_, i) => (
|
||||
<button key={i} onClick={() => setCurrent(i)} className={`w-2 h-2 rounded-full transition-all ${i === current ? 'bg-teal w-4' : 'bg-bg-warm'}`} />
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setCurrent(c => Math.min(total - 1, c + 1))} disabled={current === total - 1}>
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Podcast Renderer ─────────────────────────────────────── */
|
||||
const PodcastView = ({ script, topicLabel }) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const utteranceRef = useRef(null);
|
||||
|
||||
const togglePlayback = () => {
|
||||
if (!('speechSynthesis' in window)) {
|
||||
alert('Text-to-speech wordt niet ondersteund door je browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
window.speechSynthesis.cancel();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
const utterance = new SpeechSynthesisUtterance(script);
|
||||
utterance.lang = 'nl-NL';
|
||||
utterance.rate = 0.95;
|
||||
utterance.onend = () => setIsPlaying(false);
|
||||
utteranceRef.current = utterance;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => () => window.speechSynthesis?.cancel(), []);
|
||||
|
||||
return (
|
||||
<Card className="border border-bg-warm">
|
||||
<div className="flex items-center gap-4 mb-6 pb-6 border-b border-bg-warm">
|
||||
<div className="w-16 h-16 rounded-full bg-teal flex items-center justify-center flex-shrink-0">
|
||||
<Mic size={28} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Podcast Aflevering</p>
|
||||
<h3 className="text-xl font-bold">{topicLabel}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={togglePlayback}
|
||||
className={`ml-auto flex items-center gap-2 px-5 py-2.5 rounded-full font-medium transition-all ${
|
||||
isPlaying ? 'bg-red-50 text-red-600 border border-red-200' : 'bg-teal text-white hover:bg-teal/90'
|
||||
}`}
|
||||
>
|
||||
{isPlaying ? <><VolumeX size={18} /> Stop</> : <><Volume2 size={18} /> Afspelen</>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isPlaying && (
|
||||
<div className="flex items-center gap-2 mb-6 p-3 bg-teal/5 rounded-[var(--r-sm)] border border-teal/20">
|
||||
<div className="flex gap-1 items-end h-6">
|
||||
{[0.3, 0.7, 1, 0.6, 0.4, 0.8, 0.5].map((h, i) => (
|
||||
<div key={i} className="w-1 bg-teal rounded-full animate-pulse" style={{ height: `${h * 24}px`, animationDelay: `${i * 0.1}s` }} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-teal font-medium ml-2">Wordt afgespeeld...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prose max-w-none">
|
||||
<h4 className="text-sm font-semibold text-fg-muted uppercase tracking-wider mb-3">Script</h4>
|
||||
<p className="leading-relaxed whitespace-pre-wrap text-fg">{script}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Infographic Renderer ─────────────────────────────────── */
|
||||
const InfographicView = ({ data, topicLabel }) => {
|
||||
if (!data) {
|
||||
return (
|
||||
<Card className="border border-bg-warm p-8 text-center text-fg-muted">
|
||||
Geen infographic data beschikbaar voor dit onderwerp.
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hero Header */}
|
||||
<div className="rounded-[var(--r-org)] overflow-hidden bg-gradient-to-br from-teal to-teal/70 text-white p-8 md:p-12 relative">
|
||||
<div className="absolute inset-0 opacity-10" style={{backgroundImage: 'radial-gradient(circle at 20% 80%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px)', backgroundSize: '40px 40px'}} />
|
||||
<div className="relative z-10">
|
||||
<p className="text-white/60 text-sm uppercase tracking-widest font-mono mb-3">{topicLabel}</p>
|
||||
<h2 className="text-3xl md:text-4xl font-bold leading-tight mb-4">{data.headline}</h2>
|
||||
<p className="text-white/80 text-lg max-w-xl">{data.tagline}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
{data.stats?.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{data.stats.map((stat, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
>
|
||||
<Card className="border border-bg-warm text-center py-6">
|
||||
<div className="text-4xl mb-2">{stat.icon}</div>
|
||||
<div className="text-3xl font-bold text-teal mb-1">{stat.value}</div>
|
||||
<div className="text-sm text-fg-muted">{stat.label}</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process Steps */}
|
||||
{data.steps?.length > 0 && (
|
||||
<Card className="border border-bg-warm">
|
||||
<h3 className="text-lg font-bold mb-6 text-teal">Stappen & Processen</h3>
|
||||
<div className="space-y-4">
|
||||
{data.steps.map((step, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
className="flex items-start gap-4"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-teal/10 border border-teal/30 flex items-center justify-center">
|
||||
<span className="text-lg">{step.icon}</span>
|
||||
</div>
|
||||
<div className="flex-1 pb-4 border-b border-bg-warm last:border-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-xs text-teal font-bold">0{step.number}</span>
|
||||
<h4 className="font-semibold">{step.title}</h4>
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted">{step.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quote */}
|
||||
{data.quote && (
|
||||
<div className="border-l-4 border-teal pl-6 py-2">
|
||||
<blockquote className="text-xl italic text-fg-muted leading-relaxed">
|
||||
"{data.quote}"
|
||||
</blockquote>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Leren;
|
||||
|
||||
@@ -15,7 +15,7 @@ const Login = () => {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const userOptions = [
|
||||
{ value: '', label: 'Selecteer een gebruiker...' },
|
||||
{ value: '', label: 'Select a user...' },
|
||||
...state.users.map(u => ({ value: u.id, label: u.name }))
|
||||
];
|
||||
|
||||
@@ -24,7 +24,7 @@ const Login = () => {
|
||||
setError('');
|
||||
|
||||
if (!selectedUser || !pin) {
|
||||
setError('Vul alle velden in.');
|
||||
setError('Please fill in all fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const Login = () => {
|
||||
if (success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
setError('Onjuiste PIN.');
|
||||
setError('Incorrect PIN.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,13 +42,13 @@ const Login = () => {
|
||||
<div className="text-center mb-8">
|
||||
<img src="/images/icon.png" alt="Respellion Icon" className="h-24 mx-auto mb-4" />
|
||||
<h2 className="text-2xl text-teal font-bold tracking-tight">Respellion</h2>
|
||||
<p className="text-fg-muted mt-2">Leerplatform Login</p>
|
||||
<p className="text-fg-muted mt-2">Learning Platform</p>
|
||||
</div>
|
||||
|
||||
<Card className="border border-bg-warm">
|
||||
<form onSubmit={handleLogin} className="flex flex-col gap-5">
|
||||
<Select
|
||||
label="Gebruiker"
|
||||
label="User"
|
||||
options={userOptions}
|
||||
value={selectedUser}
|
||||
onChange={(e) => setSelectedUser(e.target.value)}
|
||||
@@ -68,7 +68,7 @@ const Login = () => {
|
||||
{error && <div className="text-red-500 text-sm font-medium">{error}</div>}
|
||||
|
||||
<Button type="submit" className="mt-2 w-full">
|
||||
Inloggen
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
406
src/pages/Testen.jsx
Normal file
406
src/pages/Testen.jsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
CheckSquare, Loader, AlertCircle, Trophy, ArrowRight,
|
||||
Clock, CheckCircle, XCircle, BarChart2
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Card from '../components/ui/Card';
|
||||
import Button from '../components/ui/Button';
|
||||
import Tag from '../components/ui/Tag';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import { generateWeeklyQuiz, getCachedQuiz, saveTestResult, getTestResult } from '../lib/testService';
|
||||
|
||||
const TIMER_SECONDS = 300; // 5 minutes
|
||||
|
||||
// ─── Helper: format mm:ss ──────────────────────────────────
|
||||
function formatTime(sec) {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const Testen = () => {
|
||||
const { state } = useApp();
|
||||
const { currentUser, weekNumber } = state;
|
||||
|
||||
const [phase, setPhase] = useState('intro'); // intro | loading | quiz | review | results
|
||||
const [quiz, setQuiz] = useState(null);
|
||||
const [answers, setAnswers] = useState({}); // { questionId: selectedIndex }
|
||||
const [currentQ, setCurrentQ] = useState(0);
|
||||
const [showFeedback, setShowFeedback] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState(TIMER_SECONDS);
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
// ── Check for existing result ──
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
const existing = getTestResult(currentUser.id, weekNumber);
|
||||
if (existing) {
|
||||
setResult(existing);
|
||||
setPhase('results');
|
||||
}
|
||||
}
|
||||
}, [currentUser, weekNumber]);
|
||||
|
||||
// ── Timer ──
|
||||
useEffect(() => {
|
||||
if (phase === 'quiz') {
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timerRef.current);
|
||||
finishQuiz();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(timerRef.current);
|
||||
}, [phase]);
|
||||
|
||||
// ── Start quiz ──
|
||||
const startQuiz = async () => {
|
||||
setPhase('loading');
|
||||
setError(null);
|
||||
try {
|
||||
const q = await generateWeeklyQuiz(currentUser.id, weekNumber);
|
||||
setQuiz(q);
|
||||
setCurrentQ(0);
|
||||
setAnswers({});
|
||||
setShowFeedback(false);
|
||||
setTimeLeft(TIMER_SECONDS);
|
||||
setPhase('quiz');
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
setPhase('intro');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Select answer ──
|
||||
const selectAnswer = (questionId, optionIndex) => {
|
||||
if (showFeedback) return; // locked
|
||||
setAnswers(prev => ({ ...prev, [questionId]: optionIndex }));
|
||||
setShowFeedback(true);
|
||||
};
|
||||
|
||||
// ── Next question / finish ──
|
||||
const nextQuestion = () => {
|
||||
setShowFeedback(false);
|
||||
if (currentQ < quiz.questions.length - 1) {
|
||||
setCurrentQ(prev => prev + 1);
|
||||
} else {
|
||||
finishQuiz();
|
||||
}
|
||||
};
|
||||
|
||||
// ── Finish quiz & score ──
|
||||
const finishQuiz = useCallback(() => {
|
||||
clearInterval(timerRef.current);
|
||||
if (!quiz) return;
|
||||
|
||||
const questions = quiz.questions;
|
||||
let score = 0;
|
||||
const breakdown = questions.map(q => {
|
||||
const selected = answers[q.id];
|
||||
const correct = selected === q.correctIndex;
|
||||
if (correct) score++;
|
||||
return {
|
||||
questionId: q.id,
|
||||
question: q.question,
|
||||
topicLabel: q.topicLabel,
|
||||
selected,
|
||||
correctIndex: q.correctIndex,
|
||||
correct,
|
||||
explanation: q.explanation,
|
||||
options: q.options,
|
||||
};
|
||||
});
|
||||
|
||||
const testResult = {
|
||||
score,
|
||||
total: questions.length,
|
||||
percentage: Math.round((score / questions.length) * 100),
|
||||
timeUsed: TIMER_SECONDS - timeLeft,
|
||||
completedAt: new Date().toISOString(),
|
||||
breakdown,
|
||||
};
|
||||
|
||||
const { pointsEarned } = saveTestResult(currentUser.id, weekNumber, testResult);
|
||||
testResult.pointsEarned = pointsEarned;
|
||||
setResult(testResult);
|
||||
setPhase('results');
|
||||
}, [quiz, answers, timeLeft, currentUser, weekNumber]);
|
||||
|
||||
// ─── Intro / Start screen ────────────────────────────────
|
||||
if (phase === 'intro') {
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto">
|
||||
<div className="text-center py-12">
|
||||
<CheckSquare size={64} className="mx-auto text-teal/30 mb-6" />
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-4">Weekly Test</h1>
|
||||
<p className="text-fg-muted text-lg mb-2">Week {weekNumber}</p>
|
||||
<p className="text-fg-muted mb-8 max-w-md mx-auto">
|
||||
Test your knowledge with 10 AI-generated questions. You have 5 minutes to complete the test. Good luck!
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<Card className="border border-red-200 bg-red-50 text-red-900 p-4 mb-6 text-sm text-left">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-center gap-6 text-sm text-fg-muted">
|
||||
<span className="flex items-center gap-1.5"><CheckSquare size={14} /> 10 questions</span>
|
||||
<span className="flex items-center gap-1.5"><Clock size={14} /> 5 minutes</span>
|
||||
<span className="flex items-center gap-1.5"><Trophy size={14} /> 20 pts max</span>
|
||||
</div>
|
||||
<Button onClick={startQuiz} className="text-lg px-8 py-3">
|
||||
Start Test <ArrowRight size={20} className="ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Loading ──────────────────────────────────────────────
|
||||
if (phase === 'loading') {
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||
<p className="font-medium text-lg">AI is generating your test...</p>
|
||||
<p className="text-sm text-fg-muted mt-2">Preparing 10 questions based on your learning topics.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Results ──────────────────────────────────────────────
|
||||
if (phase === 'results' && result) {
|
||||
const isPerfect = result.percentage === 100;
|
||||
const isGood = result.percentage >= 70;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-3xl mx-auto pb-24 md:pb-8">
|
||||
{/* Score Header */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="text-center mb-10"
|
||||
>
|
||||
<div className={`inline-flex items-center justify-center w-28 h-28 rounded-full mb-6 ${
|
||||
isPerfect ? 'bg-teal text-white' : isGood ? 'bg-teal/10 text-teal' : 'bg-orange-100 text-orange-600'
|
||||
}`}>
|
||||
<span className="text-4xl font-bold">{result.percentage}%</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">
|
||||
{isPerfect ? 'Perfect score!' : isGood ? 'Well done!' : 'Keep learning!'}
|
||||
</h1>
|
||||
<p className="text-fg-muted">
|
||||
You scored {result.score}/{result.total} in {formatTime(result.timeUsed)}.
|
||||
{result.pointsEarned && <span className="text-teal font-semibold"> +{result.pointsEarned} pts</span>}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<Card className="border border-bg-warm text-center py-4">
|
||||
<CheckCircle size={24} className="mx-auto text-teal mb-1" />
|
||||
<div className="text-2xl font-bold text-teal">{result.score}</div>
|
||||
<div className="text-xs text-fg-muted">Correct</div>
|
||||
</Card>
|
||||
<Card className="border border-bg-warm text-center py-4">
|
||||
<XCircle size={24} className="mx-auto text-red-400 mb-1" />
|
||||
<div className="text-2xl font-bold text-red-500">{result.total - result.score}</div>
|
||||
<div className="text-xs text-fg-muted">Incorrect</div>
|
||||
</Card>
|
||||
<Card className="border border-bg-warm text-center py-4">
|
||||
<Clock size={24} className="mx-auto text-fg-muted mb-1" />
|
||||
<div className="text-2xl font-bold">{formatTime(result.timeUsed)}</div>
|
||||
<div className="text-xs text-fg-muted">Time Used</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Review toggle */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold">Question Review</h2>
|
||||
<Link to="/leaderboard">
|
||||
<Button variant="outline">
|
||||
<Trophy size={16} className="mr-2" /> Leaderboard
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Question breakdown */}
|
||||
<div className="space-y-4">
|
||||
{result.breakdown.map((item, i) => (
|
||||
<Card key={i} className={`border ${item.correct ? 'border-teal/30' : 'border-red-200'}`}>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className={`flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||
item.correct ? 'bg-teal/10 text-teal' : 'bg-red-50 text-red-500'
|
||||
}`}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{item.question}</p>
|
||||
<p className="text-xs text-fg-muted mt-0.5">{item.topicLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 ml-10 mb-3">
|
||||
{item.options.map((opt, oi) => {
|
||||
const isCorrect = oi === item.correctIndex;
|
||||
const wasSelected = oi === item.selected;
|
||||
return (
|
||||
<div
|
||||
key={oi}
|
||||
className={`px-3 py-2 rounded-[var(--r-sm)] text-sm border ${
|
||||
isCorrect
|
||||
? 'border-teal bg-teal/5 text-teal font-medium'
|
||||
: wasSelected
|
||||
? 'border-red-300 bg-red-50 text-red-700'
|
||||
: 'border-bg-warm text-fg-muted'
|
||||
}`}
|
||||
>
|
||||
{isCorrect && <CheckCircle size={14} className="inline mr-1 -mt-0.5" />}
|
||||
{wasSelected && !isCorrect && <XCircle size={14} className="inline mr-1 -mt-0.5" />}
|
||||
{opt}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-fg-muted ml-10 italic">{item.explanation}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Active Quiz ──────────────────────────────────────────
|
||||
if (phase === 'quiz' && quiz) {
|
||||
const q = quiz.questions[currentQ];
|
||||
const selectedAnswer = answers[q.id];
|
||||
const isCorrect = selectedAnswer === q.correctIndex;
|
||||
const progress = ((currentQ + (showFeedback ? 1 : 0)) / quiz.questions.length) * 100;
|
||||
const timerDanger = timeLeft < 60;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto pb-24 md:pb-8">
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-mono text-sm text-fg-muted">
|
||||
Question {currentQ + 1} of {quiz.questions.length}
|
||||
</span>
|
||||
<span className={`font-mono text-sm font-medium flex items-center gap-1.5 ${timerDanger ? 'text-red-500 animate-pulse' : 'text-fg-muted'}`}>
|
||||
<Clock size={14} /> {formatTime(timeLeft)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1.5 bg-bg-warm rounded-full mb-8 overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-teal rounded-full"
|
||||
initial={false}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Question card */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={q.id}
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -30 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<Card className="border border-bg-warm mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Tag variant="accent" className="text-xs">{q.topicLabel}</Tag>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold leading-snug">{q.question}</h2>
|
||||
</Card>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-3">
|
||||
{q.options.map((option, oi) => {
|
||||
let optionClass = 'border-bg-warm bg-paper hover:border-teal/50 hover:bg-teal/5 cursor-pointer';
|
||||
|
||||
if (showFeedback) {
|
||||
if (oi === q.correctIndex) {
|
||||
optionClass = 'border-teal bg-teal/10 text-teal';
|
||||
} else if (oi === selectedAnswer && !isCorrect) {
|
||||
optionClass = 'border-red-300 bg-red-50 text-red-700';
|
||||
} else {
|
||||
optionClass = 'border-bg-warm text-fg-muted opacity-50';
|
||||
}
|
||||
} else if (selectedAnswer === oi) {
|
||||
optionClass = 'border-teal bg-teal/5';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={oi}
|
||||
onClick={() => selectAnswer(q.id, oi)}
|
||||
disabled={showFeedback}
|
||||
className={`w-full text-left p-4 rounded-[var(--r-sm)] border transition-all flex items-center gap-3 ${optionClass}`}
|
||||
>
|
||||
<span className="flex-shrink-0 w-8 h-8 rounded-full border border-current flex items-center justify-center font-mono font-bold text-sm">
|
||||
{String.fromCharCode(65 + oi)}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{option.replace(/^[A-D]\)\s*/, '')}</span>
|
||||
{showFeedback && oi === q.correctIndex && <CheckCircle size={18} className="ml-auto text-teal" />}
|
||||
{showFeedback && oi === selectedAnswer && !isCorrect && oi !== q.correctIndex && <XCircle size={18} className="ml-auto text-red-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{showFeedback && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-6"
|
||||
>
|
||||
<Card className={`border ${isCorrect ? 'border-teal/30 bg-teal/5' : 'border-red-200 bg-red-50'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{isCorrect
|
||||
? <CheckCircle size={20} className="text-teal flex-shrink-0 mt-0.5" />
|
||||
: <XCircle size={20} className="text-red-500 flex-shrink-0 mt-0.5" />}
|
||||
<div>
|
||||
<p className={`font-bold text-sm ${isCorrect ? 'text-teal' : 'text-red-700'}`}>
|
||||
{isCorrect ? 'Correct!' : 'Incorrect'}
|
||||
</p>
|
||||
<p className="text-sm mt-1">{q.explanation}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button onClick={nextQuestion}>
|
||||
{currentQ < quiz.questions.length - 1 ? <>Next Question <ArrowRight size={18} className="ml-2" /></> : <>Finish Test <Trophy size={18} className="ml-2" /></>}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Testen;
|
||||
Reference in New Issue
Block a user