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