feat: implement core knowledge graph UI components, extraction pipeline, and initial platform navigation pages

This commit is contained in:
RaymondVerhoef
2026-05-10 21:33:02 +02:00
parent a626042092
commit 31aacd68d5
14 changed files with 1634 additions and 480 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 1030 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 &amp; 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;

View File

@@ -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
View 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;