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

@@ -8,16 +8,16 @@ import Dashboard from './pages/Dashboard'
import Admin from './pages/Admin' import Admin from './pages/Admin'
import Leren from './pages/Leren' import Leren from './pages/Leren'
import Testen from './pages/Testen'
// Placeholder components for routing structure // Placeholder components for routing structure
const Testen = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Weektest</h1><p className="mt-4">Start your weekly test here.</p></div>
const Leaderboard = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leaderboard</h1><p className="mt-4">See who is on top!</p></div> const Leaderboard = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leaderboard</h1><p className="mt-4">See who is on top!</p></div>
// Protected Route Wrapper // Protected Route Wrapper
const ProtectedRoute = ({ children, requireAdmin }) => { const ProtectedRoute = ({ children, requireAdmin }) => {
const { state, logout } = useApp() const { state, logout } = useApp()
if (state.isLoading) return <div className="p-8">Laden...</div> if (state.isLoading) return <div className="p-8">Loading...</div>
if (!state.currentUser) { if (!state.currentUser) {
return <Navigate to="/login" replace /> return <Navigate to="/login" replace />
@@ -38,8 +38,8 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
{/* Desktop Links */} {/* Desktop Links */}
<div className="hidden md:flex items-center gap-6 text-sm font-medium"> <div className="hidden md:flex items-center gap-6 text-sm font-medium">
<Link to="/" className="hover:text-accent-soft transition-colors flex items-center gap-2"><LayoutDashboard size={16}/> Dashboard</Link> <Link to="/" className="hover:text-accent-soft transition-colors flex items-center gap-2"><LayoutDashboard size={16}/> Dashboard</Link>
<Link to="/learn" className="hover:text-accent-soft transition-colors flex items-center gap-2"><BookOpen size={16}/> Leren</Link> <Link to="/learn" className="hover:text-accent-soft transition-colors flex items-center gap-2"><BookOpen size={16}/> Learn</Link>
<Link to="/test" className="hover:text-accent-soft transition-colors flex items-center gap-2"><CheckSquare size={16}/> Testen</Link> <Link to="/test" className="hover:text-accent-soft transition-colors flex items-center gap-2"><CheckSquare size={16}/> Test</Link>
<Link to="/leaderboard" className="hover:text-accent-soft transition-colors flex items-center gap-2"><Trophy size={16}/> Leaderboard</Link> <Link to="/leaderboard" className="hover:text-accent-soft transition-colors flex items-center gap-2"><Trophy size={16}/> Leaderboard</Link>
{state.currentUser.role === 'admin' && ( {state.currentUser.role === 'admin' && (
<Link to="/admin" className="hover:text-accent-soft transition-colors flex items-center gap-2"> <Link to="/admin" className="hover:text-accent-soft transition-colors flex items-center gap-2">
@@ -50,7 +50,7 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
onClick={logout} onClick={logout}
className="ml-4 border border-bg-warm/30 rounded-[var(--r-pill)] px-3 py-1 hover:bg-paper/10 transition-colors flex items-center gap-2" className="ml-4 border border-bg-warm/30 rounded-[var(--r-pill)] px-3 py-1 hover:bg-paper/10 transition-colors flex items-center gap-2"
> >
<LogOut size={14}/> Uitloggen <LogOut size={14}/> Sign Out
</button> </button>
</div> </div>
@@ -65,8 +65,8 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
{/* Mobile Bottom Navigation */} {/* Mobile Bottom Navigation */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-paper border-t border-bg-warm flex justify-around items-center p-2 z-50"> <nav className="md:hidden fixed bottom-0 left-0 right-0 bg-paper border-t border-bg-warm flex justify-around items-center p-2 z-50">
<Link to="/" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><LayoutDashboard size={20}/><span className="text-[10px] mt-1 font-medium">Home</span></Link> <Link to="/" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><LayoutDashboard size={20}/><span className="text-[10px] mt-1 font-medium">Home</span></Link>
<Link to="/learn" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><BookOpen size={20}/><span className="text-[10px] mt-1 font-medium">Leren</span></Link> <Link to="/learn" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><BookOpen size={20}/><span className="text-[10px] mt-1 font-medium">Learn</span></Link>
<Link to="/test" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><CheckSquare size={20}/><span className="text-[10px] mt-1 font-medium">Testen</span></Link> <Link to="/test" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><CheckSquare size={20}/><span className="text-[10px] mt-1 font-medium">Test</span></Link>
<Link to="/leaderboard" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Trophy size={20}/><span className="text-[10px] mt-1 font-medium">Score</span></Link> <Link to="/leaderboard" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Trophy size={20}/><span className="text-[10px] mt-1 font-medium">Score</span></Link>
{state.currentUser.role === 'admin' && ( {state.currentUser.role === 'admin' && (
<Link to="/admin" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Settings size={20}/><span className="text-[10px] mt-1 font-medium">Admin</span></Link> <Link to="/admin" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Settings size={20}/><span className="text-[10px] mt-1 font-medium">Admin</span></Link>
@@ -83,7 +83,7 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
function App() { function App() {
const { state } = useApp() const { state } = useApp()
if (state.isLoading) return <div className="p-8 flex items-center justify-center min-h-screen">De applicatie wordt geladen...</div> if (state.isLoading) return <div className="p-8 flex items-center justify-center min-h-screen">Loading application...</div>
return ( return (
<Routes> <Routes>

View File

@@ -0,0 +1,226 @@
import React, { useState, useEffect } from 'react';
import {
RefreshCw, Wand2, Trash2, CheckCircle, Loader,
AlertCircle, BookOpen, ArrowLeft, Eye
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { getAllGeneratedContent, generateLearningContent, refineLearningContent, deleteCachedContent } from '../../lib/learningService';
import LearningContentViewer from '../ui/LearningContentViewer';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Tag from '../ui/Tag';
const ContentManager = () => {
const [items, setItems] = useState([]);
const [selected, setSelected] = useState(null); // { topic, content } — the open detail view
const [actionState, setActionState] = useState({}); // { [topicId]: { loading, error, success } }
const [refineText, setRefineText] = useState('');
const refresh = () => {
const fresh = getAllGeneratedContent();
setItems(fresh);
// Keep detail view in sync if its topic was updated
if (selected) {
const updated = fresh.find(i => i.topic.id === selected.topic.id);
if (updated) setSelected(updated);
}
};
useEffect(() => { refresh(); }, []);
const setTopicState = (id, patch) =>
setActionState(prev => ({ ...prev, [id]: { ...prev[id], ...patch } }));
const handleRegenerate = async (topic) => {
setTopicState(topic.id, { loading: 'regenerating', error: null, success: null });
try {
await generateLearningContent(topic, true);
setTopicState(topic.id, { loading: null, success: 'Content regenerated.' });
refresh();
} catch (e) {
setTopicState(topic.id, { loading: null, error: e.message });
}
};
const handleRefine = async (topic) => {
const instruction = refineText.trim();
if (!instruction) return;
setTopicState(topic.id, { loading: 'refining', error: null, success: null });
try {
await refineLearningContent(topic, instruction);
setTopicState(topic.id, { loading: null, success: 'Content refined.' });
setRefineText('');
refresh();
} catch (e) {
setTopicState(topic.id, { loading: null, error: e.message });
}
};
const handleDelete = (topicId) => {
deleteCachedContent(topicId);
if (selected?.topic.id === topicId) setSelected(null);
setActionState(prev => { const n = { ...prev }; delete n[topicId]; return n; });
refresh();
};
// ─── Empty state ──────────────────────────────────────────
if (items.length === 0) {
return (
<div className="text-center py-16 text-fg-muted">
<BookOpen size={48} className="mx-auto mb-4 text-teal/30" />
<p className="font-medium">No generated content yet.</p>
<p className="text-sm mt-1">Upload sources then visit the Learn page to generate content.</p>
</div>
);
}
// ─── Detail view ──────────────────────────────────────────
if (selected) {
const { topic, content } = selected;
const state = actionState[topic.id] || {};
const isLoading = !!state.loading;
return (
<div className="animate-in fade-in duration-200">
{/* Back + header */}
<div className="flex items-start justify-between mb-6 gap-4 flex-wrap">
<div>
<button
onClick={() => setSelected(null)}
className="flex items-center gap-2 text-sm text-fg-muted hover:text-teal transition-colors mb-3"
>
<ArrowLeft size={16} /> Back to all content
</button>
<div className="flex items-center gap-2 flex-wrap">
<h2 className="text-2xl font-bold">{topic.label}</h2>
<span className="font-mono text-xs bg-bg-warm px-2 py-0.5 rounded-full text-fg-muted">{topic.type}</span>
</div>
<p className="text-fg-muted text-sm mt-1">{topic.description}</p>
</div>
{/* Action buttons */}
<div className="flex gap-2 flex-shrink-0">
<Button
variant="outline"
onClick={() => handleRegenerate(topic)}
disabled={isLoading}
>
{state.loading === 'regenerating'
? <Loader size={16} className="mr-2 animate-spin" />
: <RefreshCw size={16} className="mr-2" />}
Regenerate
</Button>
<Button
variant="outline"
onClick={() => handleDelete(topic.id)}
disabled={isLoading}
className="border-red-200 text-red-600 hover:bg-red-50"
>
<Trash2 size={16} className="mr-2" /> Delete
</Button>
</div>
</div>
{/* Status bar */}
{(state.success || state.error) && (
<div className={`mb-6 p-3 rounded-[var(--r-sm)] text-sm flex items-center gap-2 ${state.error ? 'bg-red-50 text-red-800' : 'bg-teal-50 text-teal-800'}`}>
{state.error ? <AlertCircle size={16} /> : <CheckCircle size={16} />}
{state.error || state.success}
</div>
)}
{/* Full content preview */}
<LearningContentViewer content={content} topic={topic} />
{/* Refine panel */}
<Card className="border border-teal/30 mt-8">
<h3 className="font-bold text-lg mb-1 flex items-center gap-2">
<Wand2 size={18} className="text-teal" /> Refine with AI
</h3>
<p className="text-sm text-fg-muted mb-4">
Review the content above, then describe what should change. Be specific the AI will apply your instruction and update all content formats.
</p>
<textarea
value={refineText}
onChange={(e) => setRefineText(e.target.value)}
placeholder='e.g. "Make the article more beginner-friendly and add a real-world example to each slide."'
rows={4}
className="w-full p-3 rounded-[var(--r-sm)] border border-bg-warm bg-bg text-sm resize-none focus:outline-none focus:ring-2 focus:ring-teal/30"
disabled={isLoading}
/>
<div className="flex justify-end mt-3">
<Button
onClick={() => handleRefine(topic)}
disabled={isLoading || !refineText.trim()}
>
{state.loading === 'refining'
? <><Loader size={16} className="mr-2 animate-spin" /> Refining...</>
: <><Wand2 size={16} className="mr-2" /> Apply Refinement</>}
</Button>
</div>
</Card>
</div>
);
}
// ─── List view ────────────────────────────────────────────
return (
<div className="space-y-3">
{items.map(({ topic, content }) => {
const state = actionState[topic.id] || {};
const isLoading = !!state.loading;
return (
<Card key={topic.id} className="border border-bg-warm">
<div className="flex items-center gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold">{topic.label}</span>
<span className="font-mono text-xs bg-bg-warm px-2 py-0.5 rounded-full text-fg-muted">{topic.type}</span>
<Tag variant="success" className="text-xs">Ready</Tag>
</div>
<p className="text-sm text-fg-muted mt-0.5 truncate">{topic.description}</p>
{(state.success || state.error) && (
<p className={`text-xs mt-1 flex items-center gap-1 ${state.error ? 'text-red-600' : 'text-teal-700'}`}>
{state.error ? <AlertCircle size={12} /> : <CheckCircle size={12} />}
{state.error || state.success}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Quick actions */}
<button
onClick={() => handleRegenerate(topic)}
disabled={isLoading}
title="Regenerate from scratch"
className="p-2 rounded-[var(--r-sm)] hover:bg-bg-warm transition-colors text-fg-muted hover:text-teal disabled:opacity-40"
>
{state.loading === 'regenerating' ? <Loader size={17} className="animate-spin" /> : <RefreshCw size={17} />}
</button>
<button
onClick={() => handleDelete(topic.id)}
disabled={isLoading}
title="Delete cached content"
className="p-2 rounded-[var(--r-sm)] hover:bg-red-50 transition-colors text-fg-muted hover:text-red-500 disabled:opacity-40"
>
<Trash2 size={17} />
</button>
{/* Review button */}
<Button
onClick={() => setSelected({ topic, content })}
className="flex items-center gap-2"
>
<Eye size={16} /> Review
</Button>
</div>
</div>
</Card>
);
})}
</div>
);
};
export default ContentManager;

View File

@@ -149,7 +149,7 @@ const KnowledgeGraph = () => {
<div ref={wrapperRef} className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing border-r border-bg-warm"> <div ref={wrapperRef} className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing border-r border-bg-warm">
{topics.length === 0 ? ( {topics.length === 0 ? (
<div className="h-full flex items-center justify-center text-fg-muted p-8 text-center"> <div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
Nog geen kennisgraaf data. Upload eerst bronnen via het Bronmateriaal tabblad. No knowledge graph data yet. Upload source material in the Sources tab first.
</div> </div>
) : ( ) : (
<svg ref={svgRef} className="w-full h-full" /> <svg ref={svgRef} className="w-full h-full" />
@@ -172,7 +172,7 @@ const KnowledgeGraph = () => {
</span> </span>
</div> </div>
<div> <div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Beschrijving</p> <p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Description</p>
<p className="text-sm leading-relaxed">{selectedNode.description}</p> <p className="text-sm leading-relaxed">{selectedNode.description}</p>
</div> </div>
<div> <div>
@@ -181,7 +181,7 @@ const KnowledgeGraph = () => {
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-sm text-fg-muted">Klik op een node in de graaf om details te bekijken.</p> <p className="text-sm text-fg-muted">Click a node in the graph to view its details.</p>
)} )}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,200 @@
import React, { useState, useEffect } from 'react';
import { RefreshCw, Trash2, CheckCircle, Loader, AlertCircle, HelpCircle, ArrowLeft, Eye, ChevronDown, ChevronUp } from 'lucide-react';
import { storage } from '../../lib/storage';
import { forceGenerateTopicQuestions, getTopicQuestionBank, deleteQuestion } from '../../lib/testService';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Tag from '../ui/Tag';
import { motion, AnimatePresence } from 'framer-motion';
const TestManager = () => {
const [topics, setTopics] = useState([]);
const [selectedTopic, setSelectedTopic] = useState(null);
const [questions, setQuestions] = useState([]);
const [loadingTopicId, setLoadingTopicId] = useState(null);
const [error, setError] = useState(null);
const loadData = () => {
const allTopics = storage.get('kb:topics', []);
setTopics(allTopics);
if (selectedTopic) {
setQuestions(getTopicQuestionBank(selectedTopic.id));
}
};
useEffect(() => {
loadData();
}, [selectedTopic]);
const handleGenerate = async (topic, count = 10) => {
setLoadingTopicId(topic.id);
setError(null);
try {
await forceGenerateTopicQuestions(topic, count);
loadData(); // refresh list
} catch (e) {
setError(e.message);
} finally {
setLoadingTopicId(null);
}
};
const handleDelete = (topicId, questionId) => {
deleteQuestion(topicId, questionId);
loadData();
};
// ── Detail view ──
if (selectedTopic) {
return (
<div className="animate-in fade-in duration-200">
{/* Header */}
<div className="flex items-start justify-between mb-6 gap-4 flex-wrap">
<div>
<button
onClick={() => setSelectedTopic(null)}
className="flex items-center gap-2 text-sm text-fg-muted hover:text-teal transition-colors mb-3"
>
<ArrowLeft size={16} /> Back to Topics
</button>
<div className="flex items-center gap-2 flex-wrap">
<h2 className="text-2xl font-bold">{selectedTopic.label}</h2>
<Tag variant="accent" className="text-xs">{questions.length} questions</Tag>
</div>
<p className="text-fg-muted text-sm mt-1">{selectedTopic.description}</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => handleGenerate(selectedTopic, 5)}
disabled={loadingTopicId === selectedTopic.id}
>
{loadingTopicId === selectedTopic.id
? <Loader size={16} className="mr-2 animate-spin" />
: <RefreshCw size={16} className="mr-2" />}
Generate 5 More
</Button>
</div>
</div>
{error && (
<div className="mb-6 p-3 rounded-[var(--r-sm)] bg-red-50 text-red-800 text-sm flex items-center gap-2">
<AlertCircle size={16} /> {error}
</div>
)}
{/* Questions List */}
<div className="space-y-4">
{questions.length === 0 ? (
<Card className="text-center py-12 text-fg-muted border-dashed border-2">
<p>No questions generated for this topic yet.</p>
<Button className="mt-4" onClick={() => handleGenerate(selectedTopic, 10)} disabled={loadingTopicId === selectedTopic.id}>
Generate 10 Questions
</Button>
</Card>
) : (
questions.map((q, i) => (
<Card key={q.id} className="border border-bg-warm relative group">
<button
onClick={() => handleDelete(selectedTopic.id, q.id)}
className="absolute top-4 right-4 p-2 text-fg-muted hover:text-red-500 hover:bg-red-50 rounded-[var(--r-sm)] opacity-0 group-hover:opacity-100 transition-all"
title="Delete question"
>
<Trash2 size={16} />
</button>
<div className="flex items-start gap-3 mb-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-teal/10 text-teal flex items-center justify-center text-xs font-bold mt-0.5">
{i + 1}
</div>
<div>
<h4 className="font-semibold">{q.question}</h4>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 ml-9 mb-4">
{q.options.map((opt, oi) => (
<div
key={oi}
className={`px-3 py-2 rounded-[var(--r-sm)] text-sm border ${
oi === q.correctIndex
? 'border-teal bg-teal/5 text-teal font-medium'
: 'border-bg-warm text-fg-muted'
}`}
>
{oi === q.correctIndex && <CheckCircle size={14} className="inline mr-2 -mt-0.5" />}
{opt}
</div>
))}
</div>
<p className="text-sm text-fg-muted ml-9 italic border-l-2 border-teal/30 pl-3">
{q.explanation}
</p>
</Card>
))
)}
</div>
</div>
);
}
// ── List view ──
return (
<div className="space-y-3">
{topics.length === 0 && (
<div className="text-center py-16 text-fg-muted">
<HelpCircle size={48} className="mx-auto mb-4 text-teal/30" />
<p className="font-medium">No topics available.</p>
</div>
)}
{error && !selectedTopic && (
<div className="mb-4 p-3 rounded-[var(--r-sm)] bg-red-50 text-red-800 text-sm flex items-center gap-2">
<AlertCircle size={16} /> {error}
</div>
)}
{topics.map(topic => {
const bank = getTopicQuestionBank(topic.id);
const count = bank.length;
const isLoading = loadingTopicId === topic.id;
return (
<Card key={topic.id} className="border border-bg-warm flex items-center justify-between">
<div className="flex-1 min-w-0 pr-4">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold truncate">{topic.label}</h3>
<Tag variant={count > 0 ? 'success' : 'dark'} className="text-xs">
{count} {count === 1 ? 'question' : 'questions'}
</Tag>
</div>
<p className="text-sm text-fg-muted truncate">{topic.description}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{count === 0 ? (
<Button
variant="outline"
onClick={() => handleGenerate(topic)}
disabled={isLoading}
className="whitespace-nowrap"
>
{isLoading ? <Loader size={16} className="animate-spin mr-2" /> : <RefreshCw size={16} className="mr-2" />}
Generate
</Button>
) : (
<Button onClick={() => setSelectedTopic(topic)} className="flex items-center gap-2">
<Eye size={16} /> Review
</Button>
)}
</div>
</Card>
);
})}
</div>
);
};
export default TestManager;

View File

@@ -18,9 +18,7 @@ const UploadZone = ({ onUploadComplete }) => {
setIsDragging(true); setIsDragging(true);
}; };
const handleDragLeave = () => { const handleDragLeave = () => setIsDragging(false);
setIsDragging(false);
};
const processFile = async (file) => { const processFile = async (file) => {
setIsProcessing(true); setIsProcessing(true);
@@ -28,10 +26,10 @@ const UploadZone = ({ onUploadComplete }) => {
try { try {
const text = await file.text(); const text = await file.text();
await processSourceText(text, file.name); await processSourceText(text, file.name);
setStatus({ type: 'success', msg: `Succesvol verwerkt: ${file.name}` }); setStatus({ type: 'success', msg: `Successfully processed: ${file.name}` });
if (onUploadComplete) onUploadComplete(); if (onUploadComplete) onUploadComplete();
} catch (error) { } catch (error) {
setStatus({ type: 'error', msg: `Fout bij verwerken: ${error.message}` }); setStatus({ type: 'error', msg: `Error processing file: ${error.message}` });
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
@@ -40,42 +38,29 @@ const UploadZone = ({ onUploadComplete }) => {
const handleDrop = (e) => { const handleDrop = (e) => {
e.preventDefault(); e.preventDefault();
setIsDragging(false); setIsDragging(false);
if (e.dataTransfer.files?.length > 0) {
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0]; const file = e.dataTransfer.files[0];
if (file.type === 'text/plain' || file.name.endsWith('.md')) { if (file.type === 'text/plain' || file.name.endsWith('.md')) {
processFile(file); processFile(file);
} else { } else {
setStatus({ type: 'error', msg: 'Alleen .txt en .md bestanden worden momenteel ondersteund.' }); setStatus({ type: 'error', msg: 'Only .txt and .md files are currently supported.' });
} }
} }
}; };
const handleFileSelect = (e) => { const handleFileSelect = (e) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files?.length > 0) processFile(e.target.files[0]);
processFile(e.target.files[0]);
}
}; };
const handleUrlSubmit = async (e) => { const handleUrlSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
if (!url) return; setStatus({ type: 'error', msg: 'URL import is disabled in the browser due to CORS restrictions.' });
setIsProcessing(true);
setStatus(null);
try {
// In a real scenario, this would call a backend proxy to bypass CORS.
// For this prototype, we'll simulate fetching or only support text URLs.
setStatus({ type: 'error', msg: 'URL import is experimenteel en momenteel uitgeschakeld ivm CORS restricties in browser.' });
} finally {
setIsProcessing(false);
}
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card <Card
className={`border-2 border-dashed transition-colors flex flex-col items-center justify-center py-12 ${ className={`border-2 border-dashed transition-colors flex flex-col items-center justify-center py-12 cursor-pointer ${
isDragging ? 'border-teal bg-teal/5' : 'border-bg-warm bg-bg-warm/20' isDragging ? 'border-teal bg-teal/5' : 'border-bg-warm bg-bg-warm/20'
} ${isProcessing ? 'opacity-50 pointer-events-none' : ''}`} } ${isProcessing ? 'opacity-50 pointer-events-none' : ''}`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
@@ -84,10 +69,10 @@ const UploadZone = ({ onUploadComplete }) => {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> >
<UploadCloud size={48} className="text-teal/40 mb-4" /> <UploadCloud size={48} className="text-teal/40 mb-4" />
<p className="font-medium text-lg">Sleep bestanden hierheen</p> <p className="font-medium text-lg">Drag files here</p>
<p className="text-sm text-fg-muted mb-4">Ondersteunt .txt en .md (Max 5MB)</p> <p className="text-sm text-fg-muted mb-4">Supports .txt and .md (max 5MB)</p>
<Button variant="outline" type="button" disabled={isProcessing}> <Button variant="outline" type="button" disabled={isProcessing}>
{isProcessing ? 'Bezig met AI extractie...' : 'Bladeren op apparaat'} {isProcessing ? 'AI extraction in progress...' : 'Browse files'}
</Button> </Button>
<input <input
type="file" type="file"
@@ -100,15 +85,15 @@ const UploadZone = ({ onUploadComplete }) => {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex-1 border-t border-bg-warm"></div> <div className="flex-1 border-t border-bg-warm"></div>
<span className="text-sm text-fg-muted uppercase tracking-wider">OF</span> <span className="text-sm text-fg-muted uppercase tracking-wider">OR</span>
<div className="flex-1 border-t border-bg-warm"></div> <div className="flex-1 border-t border-bg-warm"></div>
</div> </div>
<form onSubmit={handleUrlSubmit} className="flex gap-2 items-end"> <form onSubmit={handleUrlSubmit} className="flex gap-2 items-end">
<div className="flex-1"> <div className="flex-1">
<Input <Input
label="Importeer van URL" label="Import from URL"
placeholder="https://wiki.respellion.nl/artikel" placeholder="https://wiki.respellion.com/article"
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
disabled={isProcessing} disabled={isProcessing}
@@ -125,7 +110,7 @@ const UploadZone = ({ onUploadComplete }) => {
}`}> }`}>
{status.type === 'error' ? <AlertCircle className="text-red-500 flex-shrink-0" /> : <CheckCircle className="text-teal-600 flex-shrink-0" />} {status.type === 'error' ? <AlertCircle className="text-red-500 flex-shrink-0" /> : <CheckCircle className="text-teal-600 flex-shrink-0" />}
<div> <div>
<p className="font-medium">{status.type === 'error' ? 'Fout' : 'Succes'}</p> <p className="font-medium">{status.type === 'error' ? 'Error' : 'Success'}</p>
<p className="text-sm">{status.msg}</p> <p className="text-sm">{status.msg}</p>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,282 @@
/**
* LearningContentViewer
* Shared component used by both the student Learn page and the Admin Content Manager.
* Renders all four learning modes: Article, Slides, Podcast, Infographic.
*/
import React, { useState, useEffect, useRef } from 'react';
import {
ChevronLeft, ChevronRight, BookOpen, Presentation,
Mic, CheckCircle, Volume2, VolumeX, BarChart2
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import Card from './Card';
import Tag from './Tag';
import Button from './Button';
const MODES = [
{ key: 'article', icon: BookOpen, label: 'Article' },
{ key: 'slides', icon: Presentation, label: 'Slides' },
{ key: 'podcast', icon: Mic, label: 'Podcast' },
{ key: 'infographic', icon: BarChart2, label: 'Infographic' },
];
const LearningContentViewer = ({ content, topic, initialMode = 'article' }) => {
const [activeMode, setActiveMode] = useState(initialMode);
if (!content || !topic) return null;
return (
<div>
{/* Mode Selector */}
<div className="flex gap-2 mb-6 flex-wrap">
{MODES.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 text-sm font-medium ${
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} />
{label}
</button>
))}
</div>
{/* Content */}
<AnimatePresence mode="wait">
<motion.div
key={activeMode}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{activeMode === 'article' && <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>
</div>
);
};
/* ── Article Renderer ─────────────────────────────────────── */
const ArticleView = ({ content }) => (
<div className="space-y-6">
<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} /> Key Takeaways
</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-[280px] 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={`h-2 rounded-full transition-all ${i === current ? 'bg-teal w-4' : 'bg-bg-warm w-2'}`}
/>
))}
</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 is not supported by your browser.');
return;
}
if (isPlaying) {
window.speechSynthesis.cancel();
setIsPlaying(false);
} else {
const utterance = new SpeechSynthesisUtterance(script);
utterance.lang = 'en-US';
utterance.rate = 0.95;
utterance.onend = () => setIsPlaying(false);
utteranceRef.current = utterance;
window.speechSynthesis.speak(utterance);
setIsPlaying(true);
}
};
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 Episode</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} /> Play</>}
</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">Now playing...</span>
</div>
)}
<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>
</Card>
);
};
/* ── Infographic Renderer ─────────────────────────────────── */
const InfographicView = ({ data, topicLabel }) => {
if (!data) {
return (
<Card className="border border-bg-warm p-8 text-center text-fg-muted">
No infographic data available for this topic.
</Card>
);
}
return (
<div className="space-y-6">
<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>
{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>
)}
{data.steps?.length > 0 && (
<Card className="border border-bg-warm">
<h3 className="text-lg font-bold mb-6 text-teal">Steps &amp; Processes</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>
)}
{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 LearningContentViewer;

View File

@@ -1,28 +1,28 @@
import { anthropicApi } from './api'; import { anthropicApi } from './api';
import { storage } from './storage'; import { storage } from './storage';
const SYSTEM_PROMPT = `Je bent een AI-kennisextractor voor Respellion, een IT-bedrijf gericht op "radicale transparantie". const SYSTEM_PROMPT = `You are an AI knowledge extractor for Respellion, an IT company built on radical transparency.
Je krijgt een brontekst. Jouw taak is om kernconcepten, rollen, processen en feiten te extraheren en deze als een gestructureerde JSON Kennisgraaf terug te geven. You receive a source text. Your task is to extract core concepts, roles, processes, and facts, and return them as a structured JSON Knowledge Graph.
Geef ALTIJD een valide JSON object terug in het volgende formaat: ALWAYS return a valid JSON object in the following format:
{ {
"topics": [ "topics": [
{ {
"id": "unieke-slug", "id": "unique-slug",
"label": "Titel van onderwerp", "label": "Topic title",
"type": "concept | role | process | fact", "type": "concept | role | process | fact",
"description": "Een beknopte, heldere uitleg van max 3 zinnen." "description": "A concise, clear explanation of max 3 sentences."
} }
], ],
"relations": [ "relations": [
{ {
"source": "id-van-topic-1", "source": "topic-id-1",
"target": "id-van-topic-2", "target": "topic-id-2",
"type": "related_to | depends_on | part_of | executed_by" "type": "related_to | depends_on | part_of | executed_by"
} }
] ]
} }
Zorg dat je alleen JSON teruggeeft, geen markdown blokken of andere tekst.`; Return JSON only. No markdown blocks or other text.`;
/** /**
* Voert tekst door de Anthropic API en slaat de resulterende topics op. * Voert tekst door de Anthropic API en slaat de resulterende topics op.
@@ -44,7 +44,7 @@ export async function processSourceText(textContent, sourceName) {
try { try {
// 2. Roep Anthropic API aan // 2. Roep Anthropic API aan
const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyseer de volgende tekst:\n\n${textContent}`); const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyze the following text:\n\n${textContent}`);
// 3. Parse JSON (veilig) // 3. Parse JSON (veilig)
let extractedData; let extractedData;
@@ -54,7 +54,7 @@ export async function processSourceText(textContent, sourceName) {
const jsonStr = jsonMatch ? jsonMatch[0] : responseText; const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
extractedData = JSON.parse(jsonStr); extractedData = JSON.parse(jsonStr);
} catch (e) { } catch (e) {
throw new Error('AI response was geen geldige JSON.'); throw new Error('AI response was not valid JSON.');
} }
// 4. Deduplicatie en opslag van Topics en Relaties // 4. Deduplicatie en opslag van Topics en Relaties

View File

@@ -1,10 +1,37 @@
import { anthropicApi } from './api'; import { anthropicApi } from './api';
import { storage } from './storage'; import { storage } from './storage';
const CONTENT_GENERATION_SYSTEM = `Je bent een expert leerinhoud-schrijver voor Respellion, een intern IT-bedrijf. const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
Je schrijft leermateriaal voor medewerkers op basis van kennisonderwerpen. You write training material for employees based on knowledge topics.
Schrijf altijd in het Nederlands, helder en professioneel. Always write in clear, professional English.
Geef ALTIJD geldige JSON terug, zonder markdown code-blokken.`; ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
const CONTENT_SCHEMA = `{
"article": {
"title": "Article title",
"intro": "Short intro of 1-2 sentences",
"sections": [
{ "heading": "Section title", "body": "Section text of at least 3 sentences." }
],
"keyTakeaways": ["Takeaway 1", "Takeaway 2", "Takeaway 3"]
},
"slides": [
{ "title": "Slide title", "bullets": ["Point 1", "Point 2", "Point 3"], "speakerNote": "Speaker note for this slide." }
],
"podcastScript": "A natural spoken script of approx. 300 words summarizing the topic as a podcast episode.",
"infographic": {
"headline": "A short, punchy headline summarizing the topic (max 8 words)",
"tagline": "A subtitle of max 15 words",
"stats": [
{ "value": "Number or %", "label": "Short description", "icon": "📊" }
],
"steps": [
{ "number": 1, "title": "Step title", "description": "One-sentence description.", "icon": "🔑" }
],
"quote": "An inspiring or insightful quote about the topic.",
"colorTheme": "teal"
}
}`;
/** /**
* Get the assigned topic for a user for a given week using round-robin. * Get the assigned topic for a user for a given week using round-robin.
@@ -14,66 +41,69 @@ export function getAssignedTopic(userId, weekNumber) {
const topics = storage.get('kb:topics', []); const topics = storage.get('kb:topics', []);
if (!topics || topics.length === 0) return null; if (!topics || topics.length === 0) return null;
// Simple deterministic hash
const str = `${userId}:${weekNumber}`; const str = `${userId}:${weekNumber}`;
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i); hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0; // Convert to 32-bit integer hash |= 0;
} }
const index = Math.abs(hash) % topics.length; const index = Math.abs(hash) % topics.length;
return topics[index]; return topics[index];
} }
/** /**
* Generate a complete learning module for a topic. * Returns the cache key for a topic's content.
* Returns an object with { article, slides, podcastScript, infographic }.
* Caches results in storage.
*/ */
export async function generateLearningContent(topic) { export function getContentCacheKey(topicId) {
const cacheKey = `kb:content:${topic.id}`; return `kb:content:${topicId}`;
const cached = storage.get(cacheKey); }
if (cached) {
console.log(`[Learn] Cache hit voor topic: ${topic.id}`); /**
return cached; * Returns cached content for a topic, or null if none exists.
*/
export function getCachedContent(topicId) {
return storage.get(getContentCacheKey(topicId), null);
}
/**
* List all topics that have generated content.
*/
export function getAllGeneratedContent() {
const topics = storage.get('kb:topics', []);
return topics
.map(topic => ({
topic,
content: getCachedContent(topic.id),
hasContent: !!getCachedContent(topic.id),
}))
.filter(item => item.hasContent);
}
/**
* Generate a complete learning module for a topic.
* Uses cached version if available (unless force = true).
*/
export async function generateLearningContent(topic, force = false) {
const cacheKey = getContentCacheKey(topic.id);
if (!force) {
const cached = storage.get(cacheKey);
if (cached) {
console.log(`[Learn] Cache hit for topic: ${topic.id}`);
return cached;
}
} }
const prompt = `Genereer een compleet leermodule voor het volgende onderwerp: const prompt = `Generate a complete learning module for the following topic:
Label: ${topic.label} Label: ${topic.label}
Type: ${topic.type} Type: ${topic.type}
Beschrijving: ${topic.description} Description: ${topic.description}
Geef ALLEEN een JSON object terug met de volgende structuur: Return ONLY a JSON object with the following structure:
{ ${CONTENT_SCHEMA}
"article": {
"title": "Artikel titel",
"intro": "Korte intro van 1-2 zinnen",
"sections": [
{ "heading": "Sectietitel", "body": "Sectietekst van minimaal 3 zinnen." }
],
"keyTakeaways": ["Lespunt 1", "Lespunt 2", "Lespunt 3"]
},
"slides": [
{ "title": "Diatitel", "bullets": ["Punt 1", "Punt 2", "Punt 3"], "speakerNote": "Toelichting voor de spreker." }
],
"podcastScript": "Een vloeiend gesproken script van ca. 300 woorden dat de inhoud samenvat als een podcast.",
"infographic": {
"headline": "Een korte, krachtige zin die het onderwerp samenvat (max 8 woorden)",
"tagline": "Een subkop van max 15 woorden",
"stats": [
{ "value": "Getal of %", "label": "Korte omschrijving", "icon": "📊" }
],
"steps": [
{ "number": 1, "title": "Staptitel", "description": "Korte beschrijving van 1 zin.", "icon": "🔑" }
],
"quote": "Een inspirerende of kernachtige quote over het onderwerp.",
"colorTheme": "teal"
}
}
Zorg voor minimaal 3 secties in het artikel, 4 slides, 3 statistieken en 3-5 stappen in de infographic.`;
Provide at least 3 article sections, 4 slides, 3 stats, and 3-5 steps in the infographic.`;
const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt); const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt);
@@ -82,10 +112,47 @@ Zorg voor minimaal 3 secties in het artikel, 4 slides, 3 statistieken en 3-5 sta
const jsonMatch = responseText.match(/\{[\s\S]*\}/); const jsonMatch = responseText.match(/\{[\s\S]*\}/);
content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText); content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
} catch (e) { } catch (e) {
throw new Error('AI kon geen geldige leerinhoud genereren.'); throw new Error('AI could not generate valid learning content. Please try again.');
} }
// Cache the content
storage.set(cacheKey, content); storage.set(cacheKey, content);
return content; return content;
} }
/**
* Refine existing content for a topic using a natural language instruction.
* Sends current content + refinement prompt to AI, saves new version.
*/
export async function refineLearningContent(topic, refinementInstruction) {
const cacheKey = getContentCacheKey(topic.id);
const existing = storage.get(cacheKey);
const prompt = `You have previously generated the following learning module for the topic "${topic.label}":
${JSON.stringify(existing, null, 2)}
The admin has requested the following refinement:
"${refinementInstruction}"
Apply the refinement and return the complete updated JSON object using the same structure. Return ONLY valid JSON.`;
const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt);
let content;
try {
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
} catch (e) {
throw new Error('AI could not process the refinement. Please try a different instruction.');
}
storage.set(cacheKey, content);
return content;
}
/**
* Delete cached content for a topic, forcing a fresh generation next time.
*/
export function deleteCachedContent(topicId) {
storage.remove(getContentCacheKey(topicId));
}

224
src/lib/testService.js Normal file
View File

@@ -0,0 +1,224 @@
import { anthropicApi } from './api';
import { storage } from './storage';
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
You generate multiple-choice questions to test employee knowledge on specific topics.
Always write in clear, professional English.
ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
/**
* Select topics for the weekly test:
* - 50% from the user's assigned learning topic this week
* - 50% from random other topics (review)
*/
function selectTestTopics(userId, weekNumber) {
const topics = storage.get('kb:topics', []);
if (!topics || topics.length === 0) return [];
// Deterministic hash for the user's current topic
const str = `${userId}:${weekNumber}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
const primaryIndex = Math.abs(hash) % topics.length;
const primaryTopic = topics[primaryIndex];
// Pick up to 5 "review" topics (random, different from primary)
const others = topics.filter((_, i) => i !== primaryIndex);
const shuffled = others.sort(() => 0.5 - Math.random());
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
return { primaryTopic, reviewTopics };
}
/**
* Retrieve cached quiz, or null.
*/
export function getCachedQuiz(userId, weekNumber) {
return storage.get(`quiz:${userId}:week:${weekNumber}`, null);
}
/**
* Exported helper for admin: manually trigger generation for a topic.
*/
export async function forceGenerateTopicQuestions(topic, count = 10) {
const bankKey = `quiz:bank:${topic.id}`;
let bank = storage.get(bankKey, []);
const prompt = `Generate exactly ${count} multiple-choice quiz questions based on this knowledge topic:
Topic: ${topic.label}
Type: ${topic.type}
Description: ${topic.description}
Return ONLY a JSON object with this structure:
{
"questions": [
{
"id": "unique-id-string",
"question": "The question text",
"topicLabel": "${topic.label}",
"options": ["A) First option", "B) Second option", "C) Third option", "D) Fourth option"],
"correctIndex": 0,
"explanation": "A clear 1-2 sentence explanation of why the correct answer is correct."
}
]
}
Rules:
- Each question must have exactly 4 options.
- correctIndex is 0-based (0=A, 1=B, 2=C, 3=D).
- Mix difficulty: 4 easy, 4 medium, 2 hard.
- Make questions specific and practical, not trivial.`;
const responseText = await anthropicApi.generateContent(QUIZ_SYSTEM, prompt);
let newQuestions = [];
try {
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
newQuestions = parsed.questions || [];
newQuestions.forEach(q => {
q.id = `${topic.id}-${Math.random().toString(36).substr(2, 9)}`;
});
} catch (e) {
console.error('Failed to generate questions for topic', topic.label, e);
throw new Error(`Could not generate questions for ${topic.label}`);
}
bank = [...bank, ...newQuestions];
storage.set(bankKey, bank);
return newQuestions;
}
/**
* Ensure a topic has enough questions in its bank, generating more if needed.
* Returns exactly `count` questions.
*/
async function getOrGenerateTopicQuestions(topic, count) {
const bankKey = `quiz:bank:${topic.id}`;
let bank = storage.get(bankKey, []);
// If we don't have enough questions, ask AI to generate a batch of 10
if (bank.length < count) {
await forceGenerateTopicQuestions(topic, 10);
bank = storage.get(bankKey, []); // reload
}
// Shuffle and pick `count` questions
const shuffled = [...bank].sort(() => 0.5 - Math.random());
return shuffled.slice(0, Math.min(count, shuffled.length));
}
/**
* Admin Helper: get question bank for a topic
*/
export function getTopicQuestionBank(topicId) {
return storage.get(`quiz:bank:${topicId}`, []);
}
/**
* Admin Helper: delete a single question
*/
export function deleteQuestion(topicId, questionId) {
const bankKey = `quiz:bank:${topicId}`;
const bank = storage.get(bankKey, []);
storage.set(bankKey, bank.filter(q => q.id !== questionId));
}
/**
* Generate 10 MCQ questions for a user's weekly test.
* Caches the result so the same quiz is served on retry.
* Pulls from topic-specific question banks, generating more if banks are low.
*/
export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
const cacheKey = `quiz:${userId}:week:${weekNumber}`;
if (!force) {
const cached = storage.get(cacheKey);
if (cached) return cached;
}
const { primaryTopic, reviewTopics } = selectTestTopics(userId, weekNumber);
if (!primaryTopic) throw new Error('No topics available to generate a quiz.');
const questions = [];
// Get 5 questions for the primary topic
const primaryQs = await getOrGenerateTopicQuestions(primaryTopic, 5);
questions.push(...primaryQs);
// Get 1 question for each of the up to 5 review topics
for (const rt of reviewTopics) {
const rtQs = await getOrGenerateTopicQuestions(rt, 1);
questions.push(...rtQs);
}
// If there are fewer than 10 questions (e.g. no review topics yet), pad with more from primary
if (questions.length < 10) {
const needed = 10 - questions.length;
// We already took 5, so let's try to get enough extra unique questions
const extraQs = await getOrGenerateTopicQuestions(primaryTopic, needed + 5);
const existingIds = new Set(questions.map(q => q.id));
for (const eq of extraQs) {
if (!existingIds.has(eq.id) && questions.length < 10) {
questions.push(eq);
}
}
}
// Shuffle the final 10 questions
questions.sort(() => 0.5 - Math.random());
const quiz = { questions };
quiz.meta = {
userId,
weekNumber,
generatedAt: new Date().toISOString(),
primaryTopic: primaryTopic.label,
};
storage.set(cacheKey, quiz);
return quiz;
}
/**
* Save a completed test result.
*/
export function saveTestResult(userId, weekNumber, result) {
const key = `quiz:result:${userId}:week:${weekNumber}`;
storage.set(key, result);
// Update leaderboard points
const leaderboard = storage.get('leaderboard:current', []);
const entry = leaderboard.find(e => e.userId === userId);
const pointsEarned = result.score * 2; // 2 pts per correct answer
if (entry) {
entry.points += pointsEarned;
entry.testsCompleted = (entry.testsCompleted || 0) + 1;
} else {
const users = storage.get('users:registry', []);
const user = users.find(u => u.id === userId);
leaderboard.push({
userId,
name: user?.name || 'Unknown',
points: pointsEarned,
testsCompleted: 1,
learningsCompleted: 0,
});
}
// Sort desc
leaderboard.sort((a, b) => b.points - a.points);
storage.set('leaderboard:current', leaderboard);
return { pointsEarned };
}
/**
* Get a previously completed test result, or null.
*/
export function getTestResult(userId, weekNumber) {
return storage.get(`quiz:result:${userId}:week:${weekNumber}`, null);
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; 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 Card from '../../components/ui/Card';
import Tag from '../../components/ui/Tag'; import Tag from '../../components/ui/Tag';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
@@ -7,9 +7,11 @@ import Input from '../../components/ui/Input';
import { storage } from '../../lib/storage'; import { storage } from '../../lib/storage';
import UploadZone from '../../components/admin/UploadZone'; import UploadZone from '../../components/admin/UploadZone';
import KnowledgeGraph from '../../components/admin/KnowledgeGraph'; import KnowledgeGraph from '../../components/admin/KnowledgeGraph';
import ContentManager from '../../components/admin/ContentManager';
import TestManager from '../../components/admin/TestManager';
const Admin = () => { const Admin = () => {
const [activeTab, setActiveTab] = useState('bronnen'); const [activeTab, setActiveTab] = useState('sources');
const [sources, setSources] = useState([]); const [sources, setSources] = useState([]);
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [model, setModel] = useState(''); const [model, setModel] = useState('');
@@ -21,10 +23,10 @@ const Admin = () => {
}; };
useEffect(() => { useEffect(() => {
if (activeTab === 'bronnen') { if (activeTab === 'sources') {
loadSources(); loadSources();
} }
if (activeTab === 'instellingen') { if (activeTab === 'settings') {
setApiKey(storage.get('admin:anthropic_key', '')); setApiKey(storage.get('admin:anthropic_key', ''));
setModel(storage.get('admin:model', 'claude-sonnet-4-20250514')); setModel(storage.get('admin:model', 'claude-sonnet-4-20250514'));
setUseSimulation(storage.get('admin:use_simulation', false)); setUseSimulation(storage.get('admin:use_simulation', false));
@@ -36,63 +38,53 @@ const Admin = () => {
storage.set('admin:anthropic_key', apiKey.trim()); storage.set('admin:anthropic_key', apiKey.trim());
storage.set('admin:model', model.trim()); storage.set('admin:model', model.trim());
storage.set('admin:use_simulation', useSimulation); storage.set('admin:use_simulation', useSimulation);
setSaveStatus('Opgeslagen!'); setSaveStatus('Saved!');
setTimeout(() => setSaveStatus(null), 3000); 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 ( return (
<div className="flex flex-col md:flex-row h-[calc(100vh-64px)] overflow-hidden"> <div className="flex flex-col md:flex-row h-[calc(100vh-64px)] overflow-hidden">
{/* Admin Sidebar */} {/* 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"> <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 {navItems.map(({ key, icon: Icon, label, bottom }) => (
onClick={() => setActiveTab('bronnen')} <button
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'}`} key={key}
> onClick={() => setActiveTab(key)}
<Database size={20} /> 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'}`}
<span className="text-sm mt-1 md:mt-0">Bronnen</span> >
</button> <Icon size={20} />
<span className="text-sm mt-1 md:mt-0">{label}</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>
</div> </div>
{/* Admin Main Content */} {/* Admin Main Content */}
<div className="flex-1 overflow-y-auto p-4 md:p-8 bg-bg pb-24 md:pb-8"> <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"> <div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
<h1 className="text-3xl text-teal mb-2">Bronmateriaal</h1> <h1 className="text-3xl text-teal mb-2">Source Material</h1>
<p className="text-fg-muted mb-8">Upload bestanden of geef URL's op voor de AI-kennisextractie.</p> <p className="text-fg-muted mb-8">Upload files to feed the AI knowledge extraction pipeline.</p>
<UploadZone onUploadComplete={loadSources} /> <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"> <Card className="p-0 border border-bg-warm overflow-hidden">
<div className="divide-y divide-bg-warm"> <div className="divide-y divide-bg-warm">
{sources.length === 0 ? ( {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) => ( sources.map((source) => (
<div key={source.id} className="p-4 flex items-center justify-between hover:bg-bg-warm/30 transition-colors"> <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> </div>
<div> <div>
{source.status === 'completed' && <Tag variant="success" className="flex items-center gap-1"><CheckCircle2 size={12}/> Voltooid</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}/> Bezig</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}/> Mislukt</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>
</div> </div>
)) ))
@@ -124,34 +116,55 @@ const Admin = () => {
</div> </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"> <div className="animate-in fade-in duration-300 h-full flex flex-col">
<h1 className="text-3xl text-teal mb-2">Kennisgraaf</h1> <h1 className="text-3xl text-teal mb-2">Knowledge Graph</h1>
<p className="text-fg-muted mb-4">Visualisatie van de opgebouwde Respellion kennis.</p> <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]"> <Card className="flex-1 p-0 border border-bg-warm overflow-hidden bg-paper min-h-[400px]">
<KnowledgeGraph /> <KnowledgeGraph />
</Card> </Card>
</div> </div>
)} )}
{activeTab === 'gebruikers' && ( {/* ── Team ────────────────────────────────── */}
{activeTab === 'team' && (
<div className="animate-in fade-in duration-300"> <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"> <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> </Card>
</div> </div>
)} )}
{activeTab === 'instellingen' && ( {/* ── Settings ────────────────────────────── */}
{activeTab === 'settings' && (
<div className="animate-in fade-in duration-300 max-w-2xl"> <div className="animate-in fade-in duration-300 max-w-2xl">
<h1 className="text-3xl text-teal mb-2">Instellingen</h1> <h1 className="text-3xl text-teal mb-2">Settings</h1>
<p className="text-fg-muted mb-8">Beheer applicatie-instellingen en API-sleutels.</p> <p className="text-fg-muted mb-8">Manage API keys and application configuration.</p>
<Card className="border border-bg-warm"> <Card className="border border-bg-warm">
<form onSubmit={saveSettings} className="space-y-6"> <form onSubmit={saveSettings} className="space-y-6">
<div> <div>
<h3 className="text-lg font-medium mb-4">AI Configuratie</h3> <h3 className="text-lg font-medium mb-4">AI Configuration</h3>
<Input <Input
label="Anthropic API Key" label="Anthropic API Key"
type="password" type="password"
@@ -166,18 +179,18 @@ const Admin = () => {
value={model} value={model}
onChange={(e) => setModel(e.target.value)} 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> </div>
<p className="text-xs text-fg-muted mt-2"> <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> </p>
</div> </div>
<div className="pt-4 border-t border-bg-warm"> <div className="pt-4 border-t border-bg-warm">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
<h3 className="text-lg font-medium">Simulatie Mode</h3> <h3 className="text-lg font-medium">Simulation Mode</h3>
<p className="text-sm text-fg-muted">Gebruik gesimuleerde AI-antwoorden als de API niet werkt.</p> <p className="text-sm text-fg-muted">Use simulated AI responses when the API is unavailable.</p>
</div> </div>
<label className="relative inline-flex items-center cursor-pointer"> <label className="relative inline-flex items-center cursor-pointer">
<input <input
@@ -192,16 +205,16 @@ const Admin = () => {
{useSimulation && ( {useSimulation && (
<div className="p-3 bg-teal/5 border border-teal/20 rounded-[var(--r-sm)] flex gap-3 text-sm text-teal-800"> <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" /> <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> </div>
<div className="flex items-center gap-4 pt-4"> <div className="flex items-center gap-4 pt-4">
<Button type="submit"> <Button type="submit">
<Save size={18} className="mr-2" /> Instellingen Opslaan <Save size={18} className="mr-2" /> Save Settings
</Button> </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> </div>
</form> </form>
</Card> </Card>

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom';
import { useApp } from '../store/AppContext'; import { useApp } from '../store/AppContext';
import Card from '../components/ui/Card'; import Card from '../components/ui/Card';
import Button from '../components/ui/Button'; import Button from '../components/ui/Button';
@@ -11,33 +12,35 @@ const Dashboard = () => {
return ( return (
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500"> <div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<header> <header>
<h1 className="text-3xl md:text-5xl mb-2">Welkom, {currentUser?.name}</h1> <h1 className="text-3xl md:text-5xl mb-2">Welcome, {currentUser?.name}</h1>
<p className="text-fg-muted text-lg">Dit is je overzicht voor week {weekNumber}.</p> <p className="text-fg-muted text-lg">Here is your overview for week {weekNumber}.</p>
</header> </header>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="flex flex-col border border-bg-warm" hoverable> <Card className="flex flex-col border border-bg-warm" hoverable>
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div> <div>
<h3 className="text-xl">Leren</h3> <h3 className="text-xl">Learning</h3>
<p className="text-fg-muted text-sm mt-1">Jouw onderwerp deze week:</p> <p className="text-fg-muted text-sm mt-1">Your topic this week:</p>
</div> </div>
<Tag variant="accent">Te doen</Tag> <Tag variant="accent">To Do</Tag>
</div> </div>
<h2 className="text-2xl mt-2 mb-6">De Rol van de Product Owner</h2> <h2 className="text-2xl mt-2 mb-6">The Role of the Product Owner</h2>
<Button className="mt-auto">Start Leersessie</Button> <Link to="/learn">
<Button className="mt-auto w-full">Start Learning Session</Button>
</Link>
</Card> </Card>
<Card className="flex flex-col border border-bg-warm" hoverable> <Card className="flex flex-col border border-bg-warm" hoverable>
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div> <div>
<h3 className="text-xl">Testen</h3> <h3 className="text-xl">Testing</h3>
<p className="text-fg-muted text-sm mt-1">Weektest 10 vragen</p> <p className="text-fg-muted text-sm mt-1">Weekly test 10 questions</p>
</div> </div>
<Tag variant="default">Te doen</Tag> <Tag variant="default">To Do</Tag>
</div> </div>
<div className="flex-1 flex items-center justify-center py-6 text-fg-subtle"> <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> </div>
<Button variant="outline" className="mt-auto" disabled>Start Test</Button> <Button variant="outline" className="mt-auto" disabled>Start Test</Button>
</Card> </Card>
@@ -45,29 +48,28 @@ const Dashboard = () => {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2"> <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"> <Card className="p-0 overflow-hidden border border-bg-warm">
<div className="divide-y divide-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="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"> <div className="w-10 h-10 rounded-[var(--r-org)] bg-accent-soft flex items-center justify-center text-purple-700 font-bold">
T T
</div> </div>
<div> <div>
<p className="font-medium">Test afgerond: Informatiebeveiliging</p> <p className="font-medium">Test completed: Information Security</p>
<p className="text-sm text-fg-muted">Vorige week Score: 90%</p> <p className="text-sm text-fg-muted">Last week · Score: 90%</p>
</div> </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>
<div className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors"> <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"> <div className="w-10 h-10 rounded-[var(--r-org)] bg-sage flex items-center justify-center text-teal-900 font-bold">
L L
</div> </div>
<div> <div>
<p className="font-medium">Leersessie: Informatiebeveiliging</p> <p className="font-medium">Learning session: Information Security</p>
<p className="text-sm text-fg-muted">Vorige week</p> <p className="text-sm text-fg-muted">Last week</p>
</div> </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>
</div> </div>
</Card> </Card>
@@ -77,23 +79,24 @@ const Dashboard = () => {
<h3 className="text-2xl mb-4">Mini Leaderboard</h3> <h3 className="text-2xl mb-4">Mini Leaderboard</h3>
<Card className="border border-bg-warm"> <Card className="border border-bg-warm">
<div className="space-y-4"> <div className="space-y-4">
{/* Placeholder leaderboard items */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="font-mono font-bold text-accent">1.</span> <span className="font-mono font-bold text-accent">1.</span>
<span className="font-medium">Admin</span> <span className="font-medium">Admin</span>
</div> </div>
<Tag variant="dark">120 pt</Tag> <Tag variant="dark">120 pts</Tag>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="font-mono font-bold text-teal">2.</span> <span className="font-mono font-bold text-teal">2.</span>
<span className="font-medium">{currentUser?.name}</span> <span className="font-medium">{currentUser?.name}</span>
</div> </div>
<Tag variant="default">85 pt</Tag> <Tag variant="default">85 pts</Tag>
</div> </div>
</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> </Card>
</div> </div>
</div> </div>

View File

@@ -1,18 +1,19 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight, BookOpen, Presentation, Mic, CheckCircle, Loader, ArrowRight, Volume2, VolumeX, BarChart2 } from 'lucide-react'; import { BookOpen, CheckCircle, Loader, ArrowRight } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion } from 'framer-motion';
import { Link } from 'react-router-dom';
import Card from '../components/ui/Card'; import Card from '../components/ui/Card';
import Button from '../components/ui/Button'; import Button from '../components/ui/Button';
import Tag from '../components/ui/Tag'; import Tag from '../components/ui/Tag';
import LearningContentViewer from '../components/ui/LearningContentViewer';
import { useApp } from '../store/AppContext'; import { useApp } from '../store/AppContext';
import { getAssignedTopic, generateLearningContent } from '../lib/learningService'; import { getAssignedTopic, generateLearningContent, getCachedContent } from '../lib/learningService';
import { storage } from '../lib/storage'; import { storage } from '../lib/storage';
const Leren = () => { const Leren = () => {
const { state } = useApp(); const { state } = useApp();
const [topic, setTopic] = useState(null); const [topic, setTopic] = useState(null);
const [content, setContent] = useState(null); const [content, setContent] = useState(null);
const [activeMode, setActiveMode] = useState('artikel'); // 'artikel' | 'slides' | 'podcast'
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [completed, setCompleted] = useState(false); const [completed, setCompleted] = useState(false);
@@ -21,6 +22,13 @@ const Leren = () => {
if (state.currentUser) { if (state.currentUser) {
const assigned = getAssignedTopic(state.currentUser.id, state.weekNumber); const assigned = getAssignedTopic(state.currentUser.id, state.weekNumber);
setTopic(assigned); 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]); }, [state.currentUser, state.weekNumber]);
@@ -43,27 +51,31 @@ const Leren = () => {
storage.set(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, true); storage.set(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, true);
}; };
// ── No topics available ───────────────────────────────────
if (!topic) { if (!topic) {
return ( return (
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20"> <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" /> <BookOpen size={64} className="mx-auto text-teal/30 mb-6" />
<h1 className="text-3xl text-teal font-bold mb-4">Leerstation</h1> <h1 className="text-3xl text-teal font-bold mb-4">Learning Station</h1>
<p className="text-fg-muted">Er zijn nog geen kennisonderwerpen beschikbaar. Vraag een admin om bronmateriaal te uploaden.</p> <p className="text-fg-muted">No knowledge topics are available yet. Ask an admin to upload source material.</p>
</div> </div>
); );
} }
// ── Session completed ─────────────────────────────────────
if (completed) { if (completed) {
return ( return (
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20"> <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 }}> <motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
<CheckCircle size={80} className="mx-auto text-teal mb-6" /> <CheckCircle size={80} className="mx-auto text-teal mb-6" />
</motion.div> </motion.div>
<h1 className="text-3xl font-bold mb-4">Leersessie voltooid!</h1> <h1 className="text-3xl font-bold mb-4">Learning session complete!</h1>
<p className="text-fg-muted mb-8">Je hebt "<strong>{topic.label}</strong>" succesvol doorgenomen. Ga nu de weektest maken!</p> <p className="text-fg-muted mb-8">
<Button onClick={() => window.location.hash = '/testen'}> You have successfully reviewed "<strong>{topic.label}</strong>". Now take the weekly test!
Weektest Starten <ArrowRight size={18} className="ml-2" /> </p>
</Button> <Link to="/test">
<Button>Start Weekly Test <ArrowRight size={18} className="ml-2" /></Button>
</Link>
</div> </div>
); );
} }
@@ -80,75 +92,43 @@ const Leren = () => {
<p className="text-fg-muted mt-2">{topic.description}</p> <p className="text-fg-muted mt-2">{topic.description}</p>
</div> </div>
{/* Mode Selector */} {/* Generate prompt */}
{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 */}
{!content && !isLoading && ( {!content && !isLoading && (
<Card className="border border-bg-warm text-center py-16"> <Card className="border border-bg-warm text-center py-16">
<BookOpen size={48} className="mx-auto text-teal/30 mb-4" /> <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> <p className="text-fg-muted mb-6">Click the button to generate your personalized AI learning content.</p>
<Button onClick={loadContent} disabled={isLoading}> <Button onClick={loadContent}>Generate Learning Content</Button>
Leerinhoud Genereren
</Button>
</Card> </Card>
)} )}
{/* Loading */}
{isLoading && ( {isLoading && (
<Card className="border border-bg-warm text-center py-16"> <Card className="border border-bg-warm text-center py-16">
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" /> <Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<p className="font-medium">AI genereert je gepersonaliseerde leerinhoud...</p> <p className="font-medium">AI is generating your personalized learning content...</p>
<p className="text-fg-muted text-sm mt-2">Dit kan 10-30 seconden duren.</p> <p className="text-fg-muted text-sm mt-2">This may take 1030 seconds.</p>
</Card> </Card>
)} )}
{/* Error */}
{error && ( {error && (
<Card className="border border-red-200 bg-red-50 text-red-900 p-6"> <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> <p className="text-sm">{error}</p>
<Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700"> <Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700">
Opnieuw proberen Try again
</Button> </Button>
</Card> </Card>
)} )}
<AnimatePresence mode="wait"> {/* Content Viewer */}
{content && ( {content && <LearningContentViewer content={content} topic={topic} />}
<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>
{/* Complete button */}
{content && !completed && ( {content && !completed && (
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end"> <div className="mt-10 pt-6 border-t border-bg-warm flex justify-end">
<Button onClick={handleComplete}> <Button onClick={handleComplete}>
<CheckCircle size={18} className="mr-2" /> Leersessie afronden <CheckCircle size={18} className="mr-2" /> Complete Session
</Button> </Button>
</div> </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; export default Leren;

View File

@@ -15,7 +15,7 @@ const Login = () => {
const [error, setError] = useState(''); const [error, setError] = useState('');
const userOptions = [ const userOptions = [
{ value: '', label: 'Selecteer een gebruiker...' }, { value: '', label: 'Select a user...' },
...state.users.map(u => ({ value: u.id, label: u.name })) ...state.users.map(u => ({ value: u.id, label: u.name }))
]; ];
@@ -24,7 +24,7 @@ const Login = () => {
setError(''); setError('');
if (!selectedUser || !pin) { if (!selectedUser || !pin) {
setError('Vul alle velden in.'); setError('Please fill in all fields.');
return; return;
} }
@@ -32,7 +32,7 @@ const Login = () => {
if (success) { if (success) {
navigate('/'); navigate('/');
} else { } else {
setError('Onjuiste PIN.'); setError('Incorrect PIN.');
} }
}; };
@@ -42,13 +42,13 @@ const Login = () => {
<div className="text-center mb-8"> <div className="text-center mb-8">
<img src="/images/icon.png" alt="Respellion Icon" className="h-24 mx-auto mb-4" /> <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> <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> </div>
<Card className="border border-bg-warm"> <Card className="border border-bg-warm">
<form onSubmit={handleLogin} className="flex flex-col gap-5"> <form onSubmit={handleLogin} className="flex flex-col gap-5">
<Select <Select
label="Gebruiker" label="User"
options={userOptions} options={userOptions}
value={selectedUser} value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)} onChange={(e) => setSelectedUser(e.target.value)}
@@ -68,7 +68,7 @@ const Login = () => {
{error && <div className="text-red-500 text-sm font-medium">{error}</div>} {error && <div className="text-red-500 text-sm font-medium">{error}</div>}
<Button type="submit" className="mt-2 w-full"> <Button type="submit" className="mt-2 w-full">
Inloggen Sign In
</Button> </Button>
</form> </form>
</Card> </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;