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 Admin from './pages/Admin'
|
||||||
import Leren from './pages/Leren'
|
import Leren from './pages/Leren'
|
||||||
|
import Testen from './pages/Testen'
|
||||||
|
|
||||||
// Placeholder components for routing structure
|
// Placeholder components for routing structure
|
||||||
const Testen = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Weektest</h1><p className="mt-4">Start your weekly test here.</p></div>
|
|
||||||
const Leaderboard = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leaderboard</h1><p className="mt-4">See who is on top!</p></div>
|
const Leaderboard = () => <div className="p-4 md:p-8"><h1 className="text-3xl md:text-4xl text-teal font-bold">Leaderboard</h1><p className="mt-4">See who is on top!</p></div>
|
||||||
|
|
||||||
// Protected Route Wrapper
|
// Protected Route Wrapper
|
||||||
const ProtectedRoute = ({ children, requireAdmin }) => {
|
const ProtectedRoute = ({ children, requireAdmin }) => {
|
||||||
const { state, logout } = useApp()
|
const { state, logout } = useApp()
|
||||||
|
|
||||||
if (state.isLoading) return <div className="p-8">Laden...</div>
|
if (state.isLoading) return <div className="p-8">Loading...</div>
|
||||||
|
|
||||||
if (!state.currentUser) {
|
if (!state.currentUser) {
|
||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
@@ -38,8 +38,8 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
|
|||||||
{/* Desktop Links */}
|
{/* Desktop Links */}
|
||||||
<div className="hidden md:flex items-center gap-6 text-sm font-medium">
|
<div className="hidden md:flex items-center gap-6 text-sm font-medium">
|
||||||
<Link to="/" className="hover:text-accent-soft transition-colors flex items-center gap-2"><LayoutDashboard size={16}/> Dashboard</Link>
|
<Link to="/" className="hover:text-accent-soft transition-colors flex items-center gap-2"><LayoutDashboard size={16}/> Dashboard</Link>
|
||||||
<Link to="/learn" className="hover:text-accent-soft transition-colors flex items-center gap-2"><BookOpen size={16}/> Leren</Link>
|
<Link to="/learn" className="hover:text-accent-soft transition-colors flex items-center gap-2"><BookOpen size={16}/> Learn</Link>
|
||||||
<Link to="/test" className="hover:text-accent-soft transition-colors flex items-center gap-2"><CheckSquare size={16}/> Testen</Link>
|
<Link to="/test" className="hover:text-accent-soft transition-colors flex items-center gap-2"><CheckSquare size={16}/> Test</Link>
|
||||||
<Link to="/leaderboard" className="hover:text-accent-soft transition-colors flex items-center gap-2"><Trophy size={16}/> Leaderboard</Link>
|
<Link to="/leaderboard" className="hover:text-accent-soft transition-colors flex items-center gap-2"><Trophy size={16}/> Leaderboard</Link>
|
||||||
{state.currentUser.role === 'admin' && (
|
{state.currentUser.role === 'admin' && (
|
||||||
<Link to="/admin" className="hover:text-accent-soft transition-colors flex items-center gap-2">
|
<Link to="/admin" className="hover:text-accent-soft transition-colors flex items-center gap-2">
|
||||||
@@ -50,7 +50,7 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
|
|||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="ml-4 border border-bg-warm/30 rounded-[var(--r-pill)] px-3 py-1 hover:bg-paper/10 transition-colors flex items-center gap-2"
|
className="ml-4 border border-bg-warm/30 rounded-[var(--r-pill)] px-3 py-1 hover:bg-paper/10 transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<LogOut size={14}/> Uitloggen
|
<LogOut size={14}/> Sign Out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,8 +65,8 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
|
|||||||
{/* Mobile Bottom Navigation */}
|
{/* Mobile Bottom Navigation */}
|
||||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-paper border-t border-bg-warm flex justify-around items-center p-2 z-50">
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-paper border-t border-bg-warm flex justify-around items-center p-2 z-50">
|
||||||
<Link to="/" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><LayoutDashboard size={20}/><span className="text-[10px] mt-1 font-medium">Home</span></Link>
|
<Link to="/" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><LayoutDashboard size={20}/><span className="text-[10px] mt-1 font-medium">Home</span></Link>
|
||||||
<Link to="/learn" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><BookOpen size={20}/><span className="text-[10px] mt-1 font-medium">Leren</span></Link>
|
<Link to="/learn" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><BookOpen size={20}/><span className="text-[10px] mt-1 font-medium">Learn</span></Link>
|
||||||
<Link to="/test" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><CheckSquare size={20}/><span className="text-[10px] mt-1 font-medium">Testen</span></Link>
|
<Link to="/test" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><CheckSquare size={20}/><span className="text-[10px] mt-1 font-medium">Test</span></Link>
|
||||||
<Link to="/leaderboard" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Trophy size={20}/><span className="text-[10px] mt-1 font-medium">Score</span></Link>
|
<Link to="/leaderboard" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Trophy size={20}/><span className="text-[10px] mt-1 font-medium">Score</span></Link>
|
||||||
{state.currentUser.role === 'admin' && (
|
{state.currentUser.role === 'admin' && (
|
||||||
<Link to="/admin" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Settings size={20}/><span className="text-[10px] mt-1 font-medium">Admin</span></Link>
|
<Link to="/admin" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Settings size={20}/><span className="text-[10px] mt-1 font-medium">Admin</span></Link>
|
||||||
@@ -83,7 +83,7 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
|
|||||||
function App() {
|
function App() {
|
||||||
const { state } = useApp()
|
const { state } = useApp()
|
||||||
|
|
||||||
if (state.isLoading) return <div className="p-8 flex items-center justify-center min-h-screen">De applicatie wordt geladen...</div>
|
if (state.isLoading) return <div className="p-8 flex items-center justify-center min-h-screen">Loading application...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
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">
|
<div ref={wrapperRef} className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing border-r border-bg-warm">
|
||||||
{topics.length === 0 ? (
|
{topics.length === 0 ? (
|
||||||
<div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
|
<div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
|
||||||
Nog geen kennisgraaf data. Upload eerst bronnen via het Bronmateriaal tabblad.
|
No knowledge graph data yet. Upload source material in the Sources tab first.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<svg ref={svgRef} className="w-full h-full" />
|
<svg ref={svgRef} className="w-full h-full" />
|
||||||
@@ -172,7 +172,7 @@ const KnowledgeGraph = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Beschrijving</p>
|
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Description</p>
|
||||||
<p className="text-sm leading-relaxed">{selectedNode.description}</p>
|
<p className="text-sm leading-relaxed">{selectedNode.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -181,7 +181,7 @@ const KnowledgeGraph = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-fg-muted">Klik op een node in de graaf om details te bekijken.</p>
|
<p className="text-sm text-fg-muted">Click a node in the graph to view its details.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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);
|
setIsDragging(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
const handleDragLeave = () => setIsDragging(false);
|
||||||
setIsDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const processFile = async (file) => {
|
const processFile = async (file) => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
@@ -28,10 +26,10 @@ const UploadZone = ({ onUploadComplete }) => {
|
|||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
await processSourceText(text, file.name);
|
await processSourceText(text, file.name);
|
||||||
setStatus({ type: 'success', msg: `Succesvol verwerkt: ${file.name}` });
|
setStatus({ type: 'success', msg: `Successfully processed: ${file.name}` });
|
||||||
if (onUploadComplete) onUploadComplete();
|
if (onUploadComplete) onUploadComplete();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus({ type: 'error', msg: `Fout bij verwerken: ${error.message}` });
|
setStatus({ type: 'error', msg: `Error processing file: ${error.message}` });
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
@@ -40,42 +38,29 @@ const UploadZone = ({ onUploadComplete }) => {
|
|||||||
const handleDrop = (e) => {
|
const handleDrop = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
if (e.dataTransfer.files?.length > 0) {
|
||||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
||||||
const file = e.dataTransfer.files[0];
|
const file = e.dataTransfer.files[0];
|
||||||
if (file.type === 'text/plain' || file.name.endsWith('.md')) {
|
if (file.type === 'text/plain' || file.name.endsWith('.md')) {
|
||||||
processFile(file);
|
processFile(file);
|
||||||
} else {
|
} else {
|
||||||
setStatus({ type: 'error', msg: 'Alleen .txt en .md bestanden worden momenteel ondersteund.' });
|
setStatus({ type: 'error', msg: 'Only .txt and .md files are currently supported.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileSelect = (e) => {
|
const handleFileSelect = (e) => {
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files?.length > 0) processFile(e.target.files[0]);
|
||||||
processFile(e.target.files[0]);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUrlSubmit = async (e) => {
|
const handleUrlSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!url) return;
|
setStatus({ type: 'error', msg: 'URL import is disabled in the browser due to CORS restrictions.' });
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus(null);
|
|
||||||
try {
|
|
||||||
// In a real scenario, this would call a backend proxy to bypass CORS.
|
|
||||||
// For this prototype, we'll simulate fetching or only support text URLs.
|
|
||||||
setStatus({ type: 'error', msg: 'URL import is experimenteel en momenteel uitgeschakeld ivm CORS restricties in browser.' });
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card
|
<Card
|
||||||
className={`border-2 border-dashed transition-colors flex flex-col items-center justify-center py-12 ${
|
className={`border-2 border-dashed transition-colors flex flex-col items-center justify-center py-12 cursor-pointer ${
|
||||||
isDragging ? 'border-teal bg-teal/5' : 'border-bg-warm bg-bg-warm/20'
|
isDragging ? 'border-teal bg-teal/5' : 'border-bg-warm bg-bg-warm/20'
|
||||||
} ${isProcessing ? 'opacity-50 pointer-events-none' : ''}`}
|
} ${isProcessing ? 'opacity-50 pointer-events-none' : ''}`}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
@@ -84,10 +69,10 @@ const UploadZone = ({ onUploadComplete }) => {
|
|||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<UploadCloud size={48} className="text-teal/40 mb-4" />
|
<UploadCloud size={48} className="text-teal/40 mb-4" />
|
||||||
<p className="font-medium text-lg">Sleep bestanden hierheen</p>
|
<p className="font-medium text-lg">Drag files here</p>
|
||||||
<p className="text-sm text-fg-muted mb-4">Ondersteunt .txt en .md (Max 5MB)</p>
|
<p className="text-sm text-fg-muted mb-4">Supports .txt and .md (max 5MB)</p>
|
||||||
<Button variant="outline" type="button" disabled={isProcessing}>
|
<Button variant="outline" type="button" disabled={isProcessing}>
|
||||||
{isProcessing ? 'Bezig met AI extractie...' : 'Bladeren op apparaat'}
|
{isProcessing ? 'AI extraction in progress...' : 'Browse files'}
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -100,15 +85,15 @@ const UploadZone = ({ onUploadComplete }) => {
|
|||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex-1 border-t border-bg-warm"></div>
|
<div className="flex-1 border-t border-bg-warm"></div>
|
||||||
<span className="text-sm text-fg-muted uppercase tracking-wider">OF</span>
|
<span className="text-sm text-fg-muted uppercase tracking-wider">OR</span>
|
||||||
<div className="flex-1 border-t border-bg-warm"></div>
|
<div className="flex-1 border-t border-bg-warm"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleUrlSubmit} className="flex gap-2 items-end">
|
<form onSubmit={handleUrlSubmit} className="flex gap-2 items-end">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Input
|
<Input
|
||||||
label="Importeer van URL"
|
label="Import from URL"
|
||||||
placeholder="https://wiki.respellion.nl/artikel"
|
placeholder="https://wiki.respellion.com/article"
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
@@ -125,7 +110,7 @@ const UploadZone = ({ onUploadComplete }) => {
|
|||||||
}`}>
|
}`}>
|
||||||
{status.type === 'error' ? <AlertCircle className="text-red-500 flex-shrink-0" /> : <CheckCircle className="text-teal-600 flex-shrink-0" />}
|
{status.type === 'error' ? <AlertCircle className="text-red-500 flex-shrink-0" /> : <CheckCircle className="text-teal-600 flex-shrink-0" />}
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{status.type === 'error' ? 'Fout' : 'Succes'}</p>
|
<p className="font-medium">{status.type === 'error' ? 'Error' : 'Success'}</p>
|
||||||
<p className="text-sm">{status.msg}</p>
|
<p className="text-sm">{status.msg}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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 { anthropicApi } from './api';
|
||||||
import { storage } from './storage';
|
import { storage } from './storage';
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `Je bent een AI-kennisextractor voor Respellion, een IT-bedrijf gericht op "radicale transparantie".
|
const SYSTEM_PROMPT = `You are an AI knowledge extractor for Respellion, an IT company built on radical transparency.
|
||||||
Je krijgt een brontekst. Jouw taak is om kernconcepten, rollen, processen en feiten te extraheren en deze als een gestructureerde JSON Kennisgraaf terug te geven.
|
You receive a source text. Your task is to extract core concepts, roles, processes, and facts, and return them as a structured JSON Knowledge Graph.
|
||||||
|
|
||||||
Geef ALTIJD een valide JSON object terug in het volgende formaat:
|
ALWAYS return a valid JSON object in the following format:
|
||||||
{
|
{
|
||||||
"topics": [
|
"topics": [
|
||||||
{
|
{
|
||||||
"id": "unieke-slug",
|
"id": "unique-slug",
|
||||||
"label": "Titel van onderwerp",
|
"label": "Topic title",
|
||||||
"type": "concept | role | process | fact",
|
"type": "concept | role | process | fact",
|
||||||
"description": "Een beknopte, heldere uitleg van max 3 zinnen."
|
"description": "A concise, clear explanation of max 3 sentences."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"relations": [
|
"relations": [
|
||||||
{
|
{
|
||||||
"source": "id-van-topic-1",
|
"source": "topic-id-1",
|
||||||
"target": "id-van-topic-2",
|
"target": "topic-id-2",
|
||||||
"type": "related_to | depends_on | part_of | executed_by"
|
"type": "related_to | depends_on | part_of | executed_by"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Zorg dat je alleen JSON teruggeeft, geen markdown blokken of andere tekst.`;
|
Return JSON only. No markdown blocks or other text.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Voert tekst door de Anthropic API en slaat de resulterende topics op.
|
* Voert tekst door de Anthropic API en slaat de resulterende topics op.
|
||||||
@@ -44,7 +44,7 @@ export async function processSourceText(textContent, sourceName) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. Roep Anthropic API aan
|
// 2. Roep Anthropic API aan
|
||||||
const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyseer de volgende tekst:\n\n${textContent}`);
|
const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyze the following text:\n\n${textContent}`);
|
||||||
|
|
||||||
// 3. Parse JSON (veilig)
|
// 3. Parse JSON (veilig)
|
||||||
let extractedData;
|
let extractedData;
|
||||||
@@ -54,7 +54,7 @@ export async function processSourceText(textContent, sourceName) {
|
|||||||
const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
|
const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
|
||||||
extractedData = JSON.parse(jsonStr);
|
extractedData = JSON.parse(jsonStr);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error('AI response was geen geldige JSON.');
|
throw new Error('AI response was not valid JSON.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Deduplicatie en opslag van Topics en Relaties
|
// 4. Deduplicatie en opslag van Topics en Relaties
|
||||||
|
|||||||
@@ -1,10 +1,37 @@
|
|||||||
import { anthropicApi } from './api';
|
import { anthropicApi } from './api';
|
||||||
import { storage } from './storage';
|
import { storage } from './storage';
|
||||||
|
|
||||||
const CONTENT_GENERATION_SYSTEM = `Je bent een expert leerinhoud-schrijver voor Respellion, een intern IT-bedrijf.
|
const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
|
||||||
Je schrijft leermateriaal voor medewerkers op basis van kennisonderwerpen.
|
You write training material for employees based on knowledge topics.
|
||||||
Schrijf altijd in het Nederlands, helder en professioneel.
|
Always write in clear, professional English.
|
||||||
Geef ALTIJD geldige JSON terug, zonder markdown code-blokken.`;
|
ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
|
||||||
|
|
||||||
|
const CONTENT_SCHEMA = `{
|
||||||
|
"article": {
|
||||||
|
"title": "Article title",
|
||||||
|
"intro": "Short intro of 1-2 sentences",
|
||||||
|
"sections": [
|
||||||
|
{ "heading": "Section title", "body": "Section text of at least 3 sentences." }
|
||||||
|
],
|
||||||
|
"keyTakeaways": ["Takeaway 1", "Takeaway 2", "Takeaway 3"]
|
||||||
|
},
|
||||||
|
"slides": [
|
||||||
|
{ "title": "Slide title", "bullets": ["Point 1", "Point 2", "Point 3"], "speakerNote": "Speaker note for this slide." }
|
||||||
|
],
|
||||||
|
"podcastScript": "A natural spoken script of approx. 300 words summarizing the topic as a podcast episode.",
|
||||||
|
"infographic": {
|
||||||
|
"headline": "A short, punchy headline summarizing the topic (max 8 words)",
|
||||||
|
"tagline": "A subtitle of max 15 words",
|
||||||
|
"stats": [
|
||||||
|
{ "value": "Number or %", "label": "Short description", "icon": "📊" }
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{ "number": 1, "title": "Step title", "description": "One-sentence description.", "icon": "🔑" }
|
||||||
|
],
|
||||||
|
"quote": "An inspiring or insightful quote about the topic.",
|
||||||
|
"colorTheme": "teal"
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the assigned topic for a user for a given week using round-robin.
|
* Get the assigned topic for a user for a given week using round-robin.
|
||||||
@@ -14,66 +41,69 @@ export function getAssignedTopic(userId, weekNumber) {
|
|||||||
const topics = storage.get('kb:topics', []);
|
const topics = storage.get('kb:topics', []);
|
||||||
if (!topics || topics.length === 0) return null;
|
if (!topics || topics.length === 0) return null;
|
||||||
|
|
||||||
// Simple deterministic hash
|
|
||||||
const str = `${userId}:${weekNumber}`;
|
const str = `${userId}:${weekNumber}`;
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||||
hash |= 0; // Convert to 32-bit integer
|
hash |= 0;
|
||||||
}
|
}
|
||||||
const index = Math.abs(hash) % topics.length;
|
const index = Math.abs(hash) % topics.length;
|
||||||
return topics[index];
|
return topics[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a complete learning module for a topic.
|
* Returns the cache key for a topic's content.
|
||||||
* Returns an object with { article, slides, podcastScript, infographic }.
|
|
||||||
* Caches results in storage.
|
|
||||||
*/
|
*/
|
||||||
export async function generateLearningContent(topic) {
|
export function getContentCacheKey(topicId) {
|
||||||
const cacheKey = `kb:content:${topic.id}`;
|
return `kb:content:${topicId}`;
|
||||||
const cached = storage.get(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
console.log(`[Learn] Cache hit voor topic: ${topic.id}`);
|
|
||||||
return cached;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = `Genereer een compleet leermodule voor het volgende onderwerp:
|
/**
|
||||||
|
* 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 = `Generate a complete learning module for the following topic:
|
||||||
|
|
||||||
Label: ${topic.label}
|
Label: ${topic.label}
|
||||||
Type: ${topic.type}
|
Type: ${topic.type}
|
||||||
Beschrijving: ${topic.description}
|
Description: ${topic.description}
|
||||||
|
|
||||||
Geef ALLEEN een JSON object terug met de volgende structuur:
|
Return ONLY a JSON object with the following structure:
|
||||||
{
|
${CONTENT_SCHEMA}
|
||||||
"article": {
|
|
||||||
"title": "Artikel titel",
|
|
||||||
"intro": "Korte intro van 1-2 zinnen",
|
|
||||||
"sections": [
|
|
||||||
{ "heading": "Sectietitel", "body": "Sectietekst van minimaal 3 zinnen." }
|
|
||||||
],
|
|
||||||
"keyTakeaways": ["Lespunt 1", "Lespunt 2", "Lespunt 3"]
|
|
||||||
},
|
|
||||||
"slides": [
|
|
||||||
{ "title": "Diatitel", "bullets": ["Punt 1", "Punt 2", "Punt 3"], "speakerNote": "Toelichting voor de spreker." }
|
|
||||||
],
|
|
||||||
"podcastScript": "Een vloeiend gesproken script van ca. 300 woorden dat de inhoud samenvat als een podcast.",
|
|
||||||
"infographic": {
|
|
||||||
"headline": "Een korte, krachtige zin die het onderwerp samenvat (max 8 woorden)",
|
|
||||||
"tagline": "Een subkop van max 15 woorden",
|
|
||||||
"stats": [
|
|
||||||
{ "value": "Getal of %", "label": "Korte omschrijving", "icon": "📊" }
|
|
||||||
],
|
|
||||||
"steps": [
|
|
||||||
{ "number": 1, "title": "Staptitel", "description": "Korte beschrijving van 1 zin.", "icon": "🔑" }
|
|
||||||
],
|
|
||||||
"quote": "Een inspirerende of kernachtige quote over het onderwerp.",
|
|
||||||
"colorTheme": "teal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Zorg voor minimaal 3 secties in het artikel, 4 slides, 3 statistieken en 3-5 stappen in de infographic.`;
|
|
||||||
|
|
||||||
|
Provide at least 3 article sections, 4 slides, 3 stats, and 3-5 steps in the infographic.`;
|
||||||
|
|
||||||
const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt);
|
const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt);
|
||||||
|
|
||||||
@@ -82,10 +112,47 @@ Zorg voor minimaal 3 secties in het artikel, 4 slides, 3 statistieken en 3-5 sta
|
|||||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||||
content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
|
content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error('AI kon geen geldige leerinhoud genereren.');
|
throw new Error('AI could not generate valid learning content. Please try again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the content
|
|
||||||
storage.set(cacheKey, content);
|
storage.set(cacheKey, content);
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refine existing content for a topic using a natural language instruction.
|
||||||
|
* Sends current content + refinement prompt to AI, saves new version.
|
||||||
|
*/
|
||||||
|
export async function refineLearningContent(topic, refinementInstruction) {
|
||||||
|
const cacheKey = getContentCacheKey(topic.id);
|
||||||
|
const existing = storage.get(cacheKey);
|
||||||
|
|
||||||
|
const prompt = `You have previously generated the following learning module for the topic "${topic.label}":
|
||||||
|
|
||||||
|
${JSON.stringify(existing, null, 2)}
|
||||||
|
|
||||||
|
The admin has requested the following refinement:
|
||||||
|
"${refinementInstruction}"
|
||||||
|
|
||||||
|
Apply the refinement and return the complete updated JSON object using the same structure. Return ONLY valid JSON.`;
|
||||||
|
|
||||||
|
const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt);
|
||||||
|
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||||
|
content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('AI could not process the refinement. Please try a different instruction.');
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.set(cacheKey, content);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete cached content for a topic, forcing a fresh generation next time.
|
||||||
|
*/
|
||||||
|
export function deleteCachedContent(topicId) {
|
||||||
|
storage.remove(getContentCacheKey(topicId));
|
||||||
|
}
|
||||||
|
|||||||
224
src/lib/testService.js
Normal file
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 React, { useState, useEffect } from 'react';
|
||||||
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info } from 'lucide-react';
|
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info, Layers, CheckSquare } from 'lucide-react';
|
||||||
import Card from '../../components/ui/Card';
|
import Card from '../../components/ui/Card';
|
||||||
import Tag from '../../components/ui/Tag';
|
import Tag from '../../components/ui/Tag';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
@@ -7,9 +7,11 @@ import Input from '../../components/ui/Input';
|
|||||||
import { storage } from '../../lib/storage';
|
import { storage } from '../../lib/storage';
|
||||||
import UploadZone from '../../components/admin/UploadZone';
|
import UploadZone from '../../components/admin/UploadZone';
|
||||||
import KnowledgeGraph from '../../components/admin/KnowledgeGraph';
|
import KnowledgeGraph from '../../components/admin/KnowledgeGraph';
|
||||||
|
import ContentManager from '../../components/admin/ContentManager';
|
||||||
|
import TestManager from '../../components/admin/TestManager';
|
||||||
|
|
||||||
const Admin = () => {
|
const Admin = () => {
|
||||||
const [activeTab, setActiveTab] = useState('bronnen');
|
const [activeTab, setActiveTab] = useState('sources');
|
||||||
const [sources, setSources] = useState([]);
|
const [sources, setSources] = useState([]);
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [apiKey, setApiKey] = useState('');
|
||||||
const [model, setModel] = useState('');
|
const [model, setModel] = useState('');
|
||||||
@@ -21,10 +23,10 @@ const Admin = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'bronnen') {
|
if (activeTab === 'sources') {
|
||||||
loadSources();
|
loadSources();
|
||||||
}
|
}
|
||||||
if (activeTab === 'instellingen') {
|
if (activeTab === 'settings') {
|
||||||
setApiKey(storage.get('admin:anthropic_key', ''));
|
setApiKey(storage.get('admin:anthropic_key', ''));
|
||||||
setModel(storage.get('admin:model', 'claude-sonnet-4-20250514'));
|
setModel(storage.get('admin:model', 'claude-sonnet-4-20250514'));
|
||||||
setUseSimulation(storage.get('admin:use_simulation', false));
|
setUseSimulation(storage.get('admin:use_simulation', false));
|
||||||
@@ -36,63 +38,53 @@ const Admin = () => {
|
|||||||
storage.set('admin:anthropic_key', apiKey.trim());
|
storage.set('admin:anthropic_key', apiKey.trim());
|
||||||
storage.set('admin:model', model.trim());
|
storage.set('admin:model', model.trim());
|
||||||
storage.set('admin:use_simulation', useSimulation);
|
storage.set('admin:use_simulation', useSimulation);
|
||||||
setSaveStatus('Opgeslagen!');
|
setSaveStatus('Saved!');
|
||||||
setTimeout(() => setSaveStatus(null), 3000);
|
setTimeout(() => setSaveStatus(null), 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ key: 'sources', icon: Database, label: 'Sources' },
|
||||||
|
{ key: 'content', icon: Layers, label: 'Content' },
|
||||||
|
{ key: 'tests', icon: CheckSquare, label: 'Quizzes' },
|
||||||
|
{ key: 'graph', icon: Network, label: 'Graph' },
|
||||||
|
{ key: 'team', icon: Users, label: 'Team' },
|
||||||
|
{ key: 'settings', icon: Settings, label: 'Settings', bottom: true },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col md:flex-row h-[calc(100vh-64px)] overflow-hidden">
|
<div className="flex flex-col md:flex-row h-[calc(100vh-64px)] overflow-hidden">
|
||||||
{/* Admin Sidebar */}
|
{/* Admin Sidebar */}
|
||||||
<div className="w-full md:w-64 bg-paper border-r border-bg-warm flex-shrink-0 flex flex-row md:flex-col p-4 gap-2 overflow-x-auto md:overflow-y-auto">
|
<div className="w-full md:w-64 bg-paper border-r border-bg-warm flex-shrink-0 flex flex-row md:flex-col p-4 gap-2 overflow-x-auto md:overflow-y-auto">
|
||||||
<h2 className="hidden md:block text-teal font-bold text-lg mb-4 px-2">Kennisbeheer</h2>
|
<h2 className="hidden md:block text-teal font-bold text-lg mb-4 px-2">Knowledge Mgmt</h2>
|
||||||
|
|
||||||
|
{navItems.map(({ key, icon: Icon, label, bottom }) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('bronnen')}
|
key={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 ${activeTab === 'bronnen' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
|
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'}`}
|
||||||
>
|
>
|
||||||
<Database size={20} />
|
<Icon size={20} />
|
||||||
<span className="text-sm mt-1 md:mt-0">Bronnen</span>
|
<span className="text-sm mt-1 md:mt-0">{label}</span>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('graaf')}
|
|
||||||
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 ${activeTab === 'graaf' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
|
|
||||||
>
|
|
||||||
<Network size={20} />
|
|
||||||
<span className="text-sm mt-1 md:mt-0">Graaf</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('gebruikers')}
|
|
||||||
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 ${activeTab === 'gebruikers' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
|
|
||||||
>
|
|
||||||
<Users size={20} />
|
|
||||||
<span className="text-sm mt-1 md:mt-0">Team</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('instellingen')}
|
|
||||||
className={`flex flex-col md:flex-row items-center gap-3 p-3 rounded-[var(--r-sm)] transition-colors min-w-[80px] md:min-w-0 mt-0 md:mt-auto ${activeTab === 'instellingen' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50 hover:text-fg'}`}
|
|
||||||
>
|
|
||||||
<Settings size={20} />
|
|
||||||
<span className="text-sm mt-1 md:mt-0">Settings</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin Main Content */}
|
{/* Admin Main Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-4 md:p-8 bg-bg pb-24 md:pb-8">
|
<div className="flex-1 overflow-y-auto p-4 md:p-8 bg-bg pb-24 md:pb-8">
|
||||||
{activeTab === 'bronnen' && (
|
|
||||||
|
{/* ── Sources ─────────────────────────────── */}
|
||||||
|
{activeTab === 'sources' && (
|
||||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||||
<h1 className="text-3xl text-teal mb-2">Bronmateriaal</h1>
|
<h1 className="text-3xl text-teal mb-2">Source Material</h1>
|
||||||
<p className="text-fg-muted mb-8">Upload bestanden of geef URL's op voor de AI-kennisextractie.</p>
|
<p className="text-fg-muted mb-8">Upload files to feed the AI knowledge extraction pipeline.</p>
|
||||||
|
|
||||||
<UploadZone onUploadComplete={loadSources} />
|
<UploadZone onUploadComplete={loadSources} />
|
||||||
|
|
||||||
<h3 className="text-xl mb-4 mt-12">Recente Bronnen</h3>
|
<h3 className="text-xl mb-4 mt-12">Recent Sources</h3>
|
||||||
<Card className="p-0 border border-bg-warm overflow-hidden">
|
<Card className="p-0 border border-bg-warm overflow-hidden">
|
||||||
<div className="divide-y divide-bg-warm">
|
<div className="divide-y divide-bg-warm">
|
||||||
{sources.length === 0 ? (
|
{sources.length === 0 ? (
|
||||||
<div className="p-8 text-center text-fg-muted">Geen bronnen geüpload.</div>
|
<div className="p-8 text-center text-fg-muted">No sources uploaded yet.</div>
|
||||||
) : (
|
) : (
|
||||||
sources.map((source) => (
|
sources.map((source) => (
|
||||||
<div key={source.id} className="p-4 flex items-center justify-between hover:bg-bg-warm/30 transition-colors">
|
<div key={source.id} className="p-4 flex items-center justify-between hover:bg-bg-warm/30 transition-colors">
|
||||||
@@ -112,9 +104,9 @@ const Admin = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{source.status === 'completed' && <Tag variant="success" className="flex items-center gap-1"><CheckCircle2 size={12}/> Voltooid</Tag>}
|
{source.status === 'completed' && <Tag variant="success" className="flex items-center gap-1"><CheckCircle2 size={12}/> Completed</Tag>}
|
||||||
{source.status === 'processing' && <Tag variant="accent" className="flex items-center gap-1"><Clock size={12}/> Bezig</Tag>}
|
{source.status === 'processing' && <Tag variant="accent" className="flex items-center gap-1"><Clock size={12}/> Processing</Tag>}
|
||||||
{source.status === 'failed' && <Tag variant="dark" className="bg-red-100 text-red-800 flex items-center gap-1"><AlertCircle size={12}/> Mislukt</Tag>}
|
{source.status === 'failed' && <Tag variant="dark" className="bg-red-100 text-red-800 flex items-center gap-1"><AlertCircle size={12}/> Failed</Tag>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -124,34 +116,55 @@ const Admin = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'graaf' && (
|
{/* ── Content Manager ──────────────────────── */}
|
||||||
|
{activeTab === 'content' && (
|
||||||
|
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-3xl text-teal mb-2">Learning Content</h1>
|
||||||
|
<p className="text-fg-muted mb-8">Manage, refine, or regenerate AI-generated learning modules for each knowledge topic.</p>
|
||||||
|
<ContentManager />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Test Manager ─────────────────────────── */}
|
||||||
|
{activeTab === 'tests' && (
|
||||||
|
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-3xl text-teal mb-2">Question Banks</h1>
|
||||||
|
<p className="text-fg-muted mb-8">Pre-generate and review quiz questions for each topic to eliminate loading times for users.</p>
|
||||||
|
<TestManager />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Knowledge Graph ──────────────────────── */}
|
||||||
|
{activeTab === 'graph' && (
|
||||||
<div className="animate-in fade-in duration-300 h-full flex flex-col">
|
<div className="animate-in fade-in duration-300 h-full flex flex-col">
|
||||||
<h1 className="text-3xl text-teal mb-2">Kennisgraaf</h1>
|
<h1 className="text-3xl text-teal mb-2">Knowledge Graph</h1>
|
||||||
<p className="text-fg-muted mb-4">Visualisatie van de opgebouwde Respellion kennis.</p>
|
<p className="text-fg-muted mb-4">Visual map of all extracted Respellion knowledge.</p>
|
||||||
<Card className="flex-1 p-0 border border-bg-warm overflow-hidden bg-paper min-h-[400px]">
|
<Card className="flex-1 p-0 border border-bg-warm overflow-hidden bg-paper min-h-[400px]">
|
||||||
<KnowledgeGraph />
|
<KnowledgeGraph />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'gebruikers' && (
|
{/* ── Team ────────────────────────────────── */}
|
||||||
|
{activeTab === 'team' && (
|
||||||
<div className="animate-in fade-in duration-300">
|
<div className="animate-in fade-in duration-300">
|
||||||
<h1 className="text-3xl text-teal mb-2">Gebruikersbeheer</h1>
|
<h1 className="text-3xl text-teal mb-2">Team Management</h1>
|
||||||
<Card className="border border-bg-warm">
|
<Card className="border border-bg-warm">
|
||||||
<p className="text-fg-muted">Hier kunnen medewerkers worden toegevoegd en beheerd.</p>
|
<p className="text-fg-muted">Team members can be added and managed here.</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'instellingen' && (
|
{/* ── Settings ────────────────────────────── */}
|
||||||
|
{activeTab === 'settings' && (
|
||||||
<div className="animate-in fade-in duration-300 max-w-2xl">
|
<div className="animate-in fade-in duration-300 max-w-2xl">
|
||||||
<h1 className="text-3xl text-teal mb-2">Instellingen</h1>
|
<h1 className="text-3xl text-teal mb-2">Settings</h1>
|
||||||
<p className="text-fg-muted mb-8">Beheer applicatie-instellingen en API-sleutels.</p>
|
<p className="text-fg-muted mb-8">Manage API keys and application configuration.</p>
|
||||||
|
|
||||||
<Card className="border border-bg-warm">
|
<Card className="border border-bg-warm">
|
||||||
<form onSubmit={saveSettings} className="space-y-6">
|
<form onSubmit={saveSettings} className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium mb-4">AI Configuratie</h3>
|
<h3 className="text-lg font-medium mb-4">AI Configuration</h3>
|
||||||
<Input
|
<Input
|
||||||
label="Anthropic API Key"
|
label="Anthropic API Key"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -166,18 +179,18 @@ const Admin = () => {
|
|||||||
value={model}
|
value={model}
|
||||||
onChange={(e) => setModel(e.target.value)}
|
onChange={(e) => setModel(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-fg-muted mt-1">Laat leeg voor de standaard. Controleer je Anthropic Console voor beschikbare modellen.</p>
|
<p className="text-xs text-fg-muted mt-1">Leave blank for the default. Check your Anthropic Console for available models.</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-fg-muted mt-2">
|
<p className="text-xs text-fg-muted mt-2">
|
||||||
Deze sleutel wordt lokaal in je browser opgeslagen (`localStorage`).
|
Your API key is stored locally in your browser's <code>localStorage</code>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 border-t border-bg-warm">
|
<div className="pt-4 border-t border-bg-warm">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Simulatie Mode</h3>
|
<h3 className="text-lg font-medium">Simulation Mode</h3>
|
||||||
<p className="text-sm text-fg-muted">Gebruik gesimuleerde AI-antwoorden als de API niet werkt.</p>
|
<p className="text-sm text-fg-muted">Use simulated AI responses when the API is unavailable.</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -192,16 +205,16 @@ const Admin = () => {
|
|||||||
{useSimulation && (
|
{useSimulation && (
|
||||||
<div className="p-3 bg-teal/5 border border-teal/20 rounded-[var(--r-sm)] flex gap-3 text-sm text-teal-800">
|
<div className="p-3 bg-teal/5 border border-teal/20 rounded-[var(--r-sm)] flex gap-3 text-sm text-teal-800">
|
||||||
<Info size={18} className="flex-shrink-0" />
|
<Info size={18} className="flex-shrink-0" />
|
||||||
<p>Wanneer ingeschakeld, zal het platform doen alsof de AI teksten verwerkt zonder een echte API-aanroep te doen. Ideaal voor UI testen.</p>
|
<p>When enabled, the platform will simulate AI processing without making real API calls. Ideal for UI testing.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 pt-4">
|
<div className="flex items-center gap-4 pt-4">
|
||||||
<Button type="submit">
|
<Button type="submit">
|
||||||
<Save size={18} className="mr-2" /> Instellingen Opslaan
|
<Save size={18} className="mr-2" /> Save Settings
|
||||||
</Button>
|
</Button>
|
||||||
{saveStatus && <span className="text-teal font-medium animate-in fade-out duration-1000 fill-mode-forwards">{saveStatus}</span>}
|
{saveStatus && <span className="text-teal font-medium">{saveStatus}</span>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useApp } from '../store/AppContext';
|
import { useApp } from '../store/AppContext';
|
||||||
import Card from '../components/ui/Card';
|
import Card from '../components/ui/Card';
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
@@ -11,33 +12,35 @@ const Dashboard = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<header>
|
<header>
|
||||||
<h1 className="text-3xl md:text-5xl mb-2">Welkom, {currentUser?.name}</h1>
|
<h1 className="text-3xl md:text-5xl mb-2">Welcome, {currentUser?.name}</h1>
|
||||||
<p className="text-fg-muted text-lg">Dit is je overzicht voor week {weekNumber}.</p>
|
<p className="text-fg-muted text-lg">Here is your overview for week {weekNumber}.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<Card className="flex flex-col border border-bg-warm" hoverable>
|
<Card className="flex flex-col border border-bg-warm" hoverable>
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl">Leren</h3>
|
<h3 className="text-xl">Learning</h3>
|
||||||
<p className="text-fg-muted text-sm mt-1">Jouw onderwerp deze week:</p>
|
<p className="text-fg-muted text-sm mt-1">Your topic this week:</p>
|
||||||
</div>
|
</div>
|
||||||
<Tag variant="accent">Te doen</Tag>
|
<Tag variant="accent">To Do</Tag>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl mt-2 mb-6">De Rol van de Product Owner</h2>
|
<h2 className="text-2xl mt-2 mb-6">The Role of the Product Owner</h2>
|
||||||
<Button className="mt-auto">Start Leersessie</Button>
|
<Link to="/learn">
|
||||||
|
<Button className="mt-auto w-full">Start Learning Session</Button>
|
||||||
|
</Link>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="flex flex-col border border-bg-warm" hoverable>
|
<Card className="flex flex-col border border-bg-warm" hoverable>
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl">Testen</h3>
|
<h3 className="text-xl">Testing</h3>
|
||||||
<p className="text-fg-muted text-sm mt-1">Weektest 10 vragen</p>
|
<p className="text-fg-muted text-sm mt-1">Weekly test — 10 questions</p>
|
||||||
</div>
|
</div>
|
||||||
<Tag variant="default">Te doen</Tag>
|
<Tag variant="default">To Do</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center justify-center py-6 text-fg-subtle">
|
<div className="flex-1 flex items-center justify-center py-6 text-fg-subtle">
|
||||||
Rond eerst je leersessie af
|
Complete your learning session first
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" className="mt-auto" disabled>Start Test</Button>
|
<Button variant="outline" className="mt-auto" disabled>Start Test</Button>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -45,29 +48,28 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<h3 className="text-2xl mb-4">Recente Activiteit</h3>
|
<h3 className="text-2xl mb-4">Recent Activity</h3>
|
||||||
<Card className="p-0 overflow-hidden border border-bg-warm">
|
<Card className="p-0 overflow-hidden border border-bg-warm">
|
||||||
<div className="divide-y divide-bg-warm">
|
<div className="divide-y divide-bg-warm">
|
||||||
{/* Placeholder activity items */}
|
|
||||||
<div className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
|
<div className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
|
||||||
<div className="w-10 h-10 rounded-[var(--r-org)] bg-accent-soft flex items-center justify-center text-purple-700 font-bold">
|
<div className="w-10 h-10 rounded-[var(--r-org)] bg-accent-soft flex items-center justify-center text-purple-700 font-bold">
|
||||||
T
|
T
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Test afgerond: Informatiebeveiliging</p>
|
<p className="font-medium">Test completed: Information Security</p>
|
||||||
<p className="text-sm text-fg-muted">Vorige week • Score: 90%</p>
|
<p className="text-sm text-fg-muted">Last week · Score: 90%</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto text-teal font-bold">+15 pt</div>
|
<div className="ml-auto text-teal font-bold">+15 pts</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
|
<div className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
|
||||||
<div className="w-10 h-10 rounded-[var(--r-org)] bg-sage flex items-center justify-center text-teal-900 font-bold">
|
<div className="w-10 h-10 rounded-[var(--r-org)] bg-sage flex items-center justify-center text-teal-900 font-bold">
|
||||||
L
|
L
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Leersessie: Informatiebeveiliging</p>
|
<p className="font-medium">Learning session: Information Security</p>
|
||||||
<p className="text-sm text-fg-muted">Vorige week</p>
|
<p className="text-sm text-fg-muted">Last week</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto text-teal font-bold">+15 pt</div>
|
<div className="ml-auto text-teal font-bold">+5 pts</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -77,23 +79,24 @@ const Dashboard = () => {
|
|||||||
<h3 className="text-2xl mb-4">Mini Leaderboard</h3>
|
<h3 className="text-2xl mb-4">Mini Leaderboard</h3>
|
||||||
<Card className="border border-bg-warm">
|
<Card className="border border-bg-warm">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Placeholder leaderboard items */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-mono font-bold text-accent">1.</span>
|
<span className="font-mono font-bold text-accent">1.</span>
|
||||||
<span className="font-medium">Admin</span>
|
<span className="font-medium">Admin</span>
|
||||||
</div>
|
</div>
|
||||||
<Tag variant="dark">120 pt</Tag>
|
<Tag variant="dark">120 pts</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-mono font-bold text-teal">2.</span>
|
<span className="font-mono font-bold text-teal">2.</span>
|
||||||
<span className="font-medium">{currentUser?.name}</span>
|
<span className="font-medium">{currentUser?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<Tag variant="default">85 pt</Tag>
|
<Tag variant="default">85 pts</Tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" className="w-full mt-4 text-sm">Bekijk volledig leaderboard</Button>
|
<Link to="/leaderboard">
|
||||||
|
<Button variant="ghost" className="w-full mt-4 text-sm">View full leaderboard</Button>
|
||||||
|
</Link>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, BookOpen, Presentation, Mic, CheckCircle, Loader, ArrowRight, Volume2, VolumeX, BarChart2 } from 'lucide-react';
|
import { BookOpen, CheckCircle, Loader, ArrowRight } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import Card from '../components/ui/Card';
|
import Card from '../components/ui/Card';
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
import Tag from '../components/ui/Tag';
|
import Tag from '../components/ui/Tag';
|
||||||
|
import LearningContentViewer from '../components/ui/LearningContentViewer';
|
||||||
import { useApp } from '../store/AppContext';
|
import { useApp } from '../store/AppContext';
|
||||||
import { getAssignedTopic, generateLearningContent } from '../lib/learningService';
|
import { getAssignedTopic, generateLearningContent, getCachedContent } from '../lib/learningService';
|
||||||
import { storage } from '../lib/storage';
|
import { storage } from '../lib/storage';
|
||||||
|
|
||||||
const Leren = () => {
|
const Leren = () => {
|
||||||
const { state } = useApp();
|
const { state } = useApp();
|
||||||
const [topic, setTopic] = useState(null);
|
const [topic, setTopic] = useState(null);
|
||||||
const [content, setContent] = useState(null);
|
const [content, setContent] = useState(null);
|
||||||
const [activeMode, setActiveMode] = useState('artikel'); // 'artikel' | 'slides' | 'podcast'
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [completed, setCompleted] = useState(false);
|
const [completed, setCompleted] = useState(false);
|
||||||
@@ -21,6 +22,13 @@ const Leren = () => {
|
|||||||
if (state.currentUser) {
|
if (state.currentUser) {
|
||||||
const assigned = getAssignedTopic(state.currentUser.id, state.weekNumber);
|
const assigned = getAssignedTopic(state.currentUser.id, state.weekNumber);
|
||||||
setTopic(assigned);
|
setTopic(assigned);
|
||||||
|
if (assigned) {
|
||||||
|
const cached = getCachedContent(assigned.id);
|
||||||
|
if (cached) setContent(cached);
|
||||||
|
}
|
||||||
|
// Check if already completed this week
|
||||||
|
const done = storage.get(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, false);
|
||||||
|
if (done) setCompleted(true);
|
||||||
}
|
}
|
||||||
}, [state.currentUser, state.weekNumber]);
|
}, [state.currentUser, state.weekNumber]);
|
||||||
|
|
||||||
@@ -43,27 +51,31 @@ const Leren = () => {
|
|||||||
storage.set(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, true);
|
storage.set(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── No topics available ───────────────────────────────────
|
||||||
if (!topic) {
|
if (!topic) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||||
<BookOpen size={64} className="mx-auto text-teal/30 mb-6" />
|
<BookOpen size={64} className="mx-auto text-teal/30 mb-6" />
|
||||||
<h1 className="text-3xl text-teal font-bold mb-4">Leerstation</h1>
|
<h1 className="text-3xl text-teal font-bold mb-4">Learning Station</h1>
|
||||||
<p className="text-fg-muted">Er zijn nog geen kennisonderwerpen beschikbaar. Vraag een admin om bronmateriaal te uploaden.</p>
|
<p className="text-fg-muted">No knowledge topics are available yet. Ask an admin to upload source material.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Session completed ─────────────────────────────────────
|
||||||
if (completed) {
|
if (completed) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
|
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
|
||||||
<CheckCircle size={80} className="mx-auto text-teal mb-6" />
|
<CheckCircle size={80} className="mx-auto text-teal mb-6" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<h1 className="text-3xl font-bold mb-4">Leersessie voltooid!</h1>
|
<h1 className="text-3xl font-bold mb-4">Learning session complete!</h1>
|
||||||
<p className="text-fg-muted mb-8">Je hebt "<strong>{topic.label}</strong>" succesvol doorgenomen. Ga nu de weektest maken!</p>
|
<p className="text-fg-muted mb-8">
|
||||||
<Button onClick={() => window.location.hash = '/testen'}>
|
You have successfully reviewed "<strong>{topic.label}</strong>". Now take the weekly test!
|
||||||
Weektest Starten <ArrowRight size={18} className="ml-2" />
|
</p>
|
||||||
</Button>
|
<Link to="/test">
|
||||||
|
<Button>Start Weekly Test <ArrowRight size={18} className="ml-2" /></Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,75 +92,43 @@ const Leren = () => {
|
|||||||
<p className="text-fg-muted mt-2">{topic.description}</p>
|
<p className="text-fg-muted mt-2">{topic.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mode Selector */}
|
{/* Generate prompt */}
|
||||||
{content && (
|
|
||||||
<div className="flex gap-2 mb-8 flex-wrap">
|
|
||||||
{[
|
|
||||||
{ key: 'artikel', icon: BookOpen, label: 'Artikel' },
|
|
||||||
{ key: 'slides', icon: Presentation, label: 'Slides' },
|
|
||||||
{ key: 'podcast', icon: Mic, label: 'Podcast' },
|
|
||||||
{ key: 'infographic', icon: BarChart2, label: 'Infographic' },
|
|
||||||
].map(({ key, icon: Icon, label }) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => setActiveMode(key)}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-[var(--r-sm)] border transition-all ${
|
|
||||||
activeMode === key
|
|
||||||
? 'bg-teal text-white border-teal shadow-sm'
|
|
||||||
: 'border-bg-warm text-fg-muted hover:text-fg hover:border-teal/50 bg-paper'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={16} />
|
|
||||||
<span className="font-medium text-sm">{label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
{!content && !isLoading && (
|
{!content && !isLoading && (
|
||||||
<Card className="border border-bg-warm text-center py-16">
|
<Card className="border border-bg-warm text-center py-16">
|
||||||
<BookOpen size={48} className="mx-auto text-teal/30 mb-4" />
|
<BookOpen size={48} className="mx-auto text-teal/30 mb-4" />
|
||||||
<p className="text-fg-muted mb-6">Klik op de knop om je gepersonaliseerde leerinhoud te genereren met AI.</p>
|
<p className="text-fg-muted mb-6">Click the button to generate your personalized AI learning content.</p>
|
||||||
<Button onClick={loadContent} disabled={isLoading}>
|
<Button onClick={loadContent}>Generate Learning Content</Button>
|
||||||
Leerinhoud Genereren
|
|
||||||
</Button>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<Card className="border border-bg-warm text-center py-16">
|
<Card className="border border-bg-warm text-center py-16">
|
||||||
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||||
<p className="font-medium">AI genereert je gepersonaliseerde leerinhoud...</p>
|
<p className="font-medium">AI is generating your personalized learning content...</p>
|
||||||
<p className="text-fg-muted text-sm mt-2">Dit kan 10-30 seconden duren.</p>
|
<p className="text-fg-muted text-sm mt-2">This may take 10–30 seconds.</p>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<Card className="border border-red-200 bg-red-50 text-red-900 p-6">
|
<Card className="border border-red-200 bg-red-50 text-red-900 p-6">
|
||||||
<p className="font-bold mb-1">Fout bij genereren</p>
|
<p className="font-bold mb-1">Generation failed</p>
|
||||||
<p className="text-sm">{error}</p>
|
<p className="text-sm">{error}</p>
|
||||||
<Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700">
|
<Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700">
|
||||||
Opnieuw proberen
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
{/* Content Viewer */}
|
||||||
{content && (
|
{content && <LearningContentViewer content={content} topic={topic} />}
|
||||||
<motion.div key={activeMode} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
|
|
||||||
{activeMode === 'artikel' && <ArticleView content={content.article} />}
|
|
||||||
{activeMode === 'slides' && <SlidesView slides={content.slides} />}
|
|
||||||
{activeMode === 'podcast' && <PodcastView script={content.podcastScript} topicLabel={topic.label} />}
|
|
||||||
{activeMode === 'infographic' && <InfographicView data={content.infographic} topicLabel={topic.label} />}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
|
{/* Complete button */}
|
||||||
{content && !completed && (
|
{content && !completed && (
|
||||||
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end">
|
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end">
|
||||||
<Button onClick={handleComplete}>
|
<Button onClick={handleComplete}>
|
||||||
<CheckCircle size={18} className="mr-2" /> Leersessie afronden
|
<CheckCircle size={18} className="mr-2" /> Complete Session
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -156,236 +136,4 @@ const Leren = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Article Renderer ─────────────────────────────────────── */
|
|
||||||
const ArticleView = ({ content }) => (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<Card className="border border-bg-warm">
|
|
||||||
<h2 className="text-2xl font-bold mb-3">{content.title}</h2>
|
|
||||||
<p className="text-fg-muted text-lg leading-relaxed">{content.intro}</p>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{content.sections?.map((section, i) => (
|
|
||||||
<Card key={i} className="border border-bg-warm">
|
|
||||||
<h3 className="text-xl font-semibold mb-3 text-teal">{section.heading}</h3>
|
|
||||||
<p className="leading-relaxed">{section.body}</p>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{content.keyTakeaways?.length > 0 && (
|
|
||||||
<Card className="border border-teal/30 bg-teal/5">
|
|
||||||
<h3 className="font-bold text-teal mb-4 flex items-center gap-2"><CheckCircle size={18} /> Kernpunten</h3>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{content.keyTakeaways.map((point, i) => (
|
|
||||||
<li key={i} className="flex items-start gap-3">
|
|
||||||
<span className="font-mono text-teal mt-0.5">→</span>
|
|
||||||
<span>{point}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ── Slides Renderer ──────────────────────────────────────── */
|
|
||||||
const SlidesView = ({ slides }) => {
|
|
||||||
const [current, setCurrent] = useState(0);
|
|
||||||
const total = slides?.length || 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div key={current} initial={{ opacity: 0, x: 40 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -40 }} transition={{ duration: 0.25 }}>
|
|
||||||
<Card className="border border-bg-warm min-h-[300px] flex flex-col justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<span className="font-mono text-xs text-fg-muted">{current + 1} / {total}</span>
|
|
||||||
<Tag variant="accent">Slide</Tag>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold mb-6">{slides[current]?.title}</h2>
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{slides[current]?.bullets?.map((bullet, i) => (
|
|
||||||
<li key={i} className="flex items-start gap-3">
|
|
||||||
<span className="font-mono text-purple-500 font-bold mt-0.5">▸</span>
|
|
||||||
<span className="text-lg">{bullet}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{slides[current]?.speakerNote && (
|
|
||||||
<p className="text-sm text-fg-muted border-t border-bg-warm pt-4 mt-6 italic">
|
|
||||||
💬 {slides[current].speakerNote}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-4">
|
|
||||||
<Button variant="outline" onClick={() => setCurrent(c => Math.max(0, c - 1))} disabled={current === 0}>
|
|
||||||
<ChevronLeft size={18} />
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
{slides?.map((_, i) => (
|
|
||||||
<button key={i} onClick={() => setCurrent(i)} className={`w-2 h-2 rounded-full transition-all ${i === current ? 'bg-teal w-4' : 'bg-bg-warm'}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={() => setCurrent(c => Math.min(total - 1, c + 1))} disabled={current === total - 1}>
|
|
||||||
<ChevronRight size={18} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ── Podcast Renderer ─────────────────────────────────────── */
|
|
||||||
const PodcastView = ({ script, topicLabel }) => {
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const utteranceRef = useRef(null);
|
|
||||||
|
|
||||||
const togglePlayback = () => {
|
|
||||||
if (!('speechSynthesis' in window)) {
|
|
||||||
alert('Text-to-speech wordt niet ondersteund door je browser.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
window.speechSynthesis.cancel();
|
|
||||||
setIsPlaying(false);
|
|
||||||
} else {
|
|
||||||
const utterance = new SpeechSynthesisUtterance(script);
|
|
||||||
utterance.lang = 'nl-NL';
|
|
||||||
utterance.rate = 0.95;
|
|
||||||
utterance.onend = () => setIsPlaying(false);
|
|
||||||
utteranceRef.current = utterance;
|
|
||||||
window.speechSynthesis.speak(utterance);
|
|
||||||
setIsPlaying(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => () => window.speechSynthesis?.cancel(), []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="border border-bg-warm">
|
|
||||||
<div className="flex items-center gap-4 mb-6 pb-6 border-b border-bg-warm">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-teal flex items-center justify-center flex-shrink-0">
|
|
||||||
<Mic size={28} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Podcast Aflevering</p>
|
|
||||||
<h3 className="text-xl font-bold">{topicLabel}</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={togglePlayback}
|
|
||||||
className={`ml-auto flex items-center gap-2 px-5 py-2.5 rounded-full font-medium transition-all ${
|
|
||||||
isPlaying ? 'bg-red-50 text-red-600 border border-red-200' : 'bg-teal text-white hover:bg-teal/90'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isPlaying ? <><VolumeX size={18} /> Stop</> : <><Volume2 size={18} /> Afspelen</>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isPlaying && (
|
|
||||||
<div className="flex items-center gap-2 mb-6 p-3 bg-teal/5 rounded-[var(--r-sm)] border border-teal/20">
|
|
||||||
<div className="flex gap-1 items-end h-6">
|
|
||||||
{[0.3, 0.7, 1, 0.6, 0.4, 0.8, 0.5].map((h, i) => (
|
|
||||||
<div key={i} className="w-1 bg-teal rounded-full animate-pulse" style={{ height: `${h * 24}px`, animationDelay: `${i * 0.1}s` }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-teal font-medium ml-2">Wordt afgespeeld...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="prose max-w-none">
|
|
||||||
<h4 className="text-sm font-semibold text-fg-muted uppercase tracking-wider mb-3">Script</h4>
|
|
||||||
<p className="leading-relaxed whitespace-pre-wrap text-fg">{script}</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ── Infographic Renderer ─────────────────────────────────── */
|
|
||||||
const InfographicView = ({ data, topicLabel }) => {
|
|
||||||
if (!data) {
|
|
||||||
return (
|
|
||||||
<Card className="border border-bg-warm p-8 text-center text-fg-muted">
|
|
||||||
Geen infographic data beschikbaar voor dit onderwerp.
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Hero Header */}
|
|
||||||
<div className="rounded-[var(--r-org)] overflow-hidden bg-gradient-to-br from-teal to-teal/70 text-white p-8 md:p-12 relative">
|
|
||||||
<div className="absolute inset-0 opacity-10" style={{backgroundImage: 'radial-gradient(circle at 20% 80%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px)', backgroundSize: '40px 40px'}} />
|
|
||||||
<div className="relative z-10">
|
|
||||||
<p className="text-white/60 text-sm uppercase tracking-widest font-mono mb-3">{topicLabel}</p>
|
|
||||||
<h2 className="text-3xl md:text-4xl font-bold leading-tight mb-4">{data.headline}</h2>
|
|
||||||
<p className="text-white/80 text-lg max-w-xl">{data.tagline}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Row */}
|
|
||||||
{data.stats?.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
{data.stats.map((stat, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.1 }}
|
|
||||||
>
|
|
||||||
<Card className="border border-bg-warm text-center py-6">
|
|
||||||
<div className="text-4xl mb-2">{stat.icon}</div>
|
|
||||||
<div className="text-3xl font-bold text-teal mb-1">{stat.value}</div>
|
|
||||||
<div className="text-sm text-fg-muted">{stat.label}</div>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Process Steps */}
|
|
||||||
{data.steps?.length > 0 && (
|
|
||||||
<Card className="border border-bg-warm">
|
|
||||||
<h3 className="text-lg font-bold mb-6 text-teal">Stappen & Processen</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{data.steps.map((step, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: i * 0.08 }}
|
|
||||||
className="flex items-start gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-teal/10 border border-teal/30 flex items-center justify-center">
|
|
||||||
<span className="text-lg">{step.icon}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 pb-4 border-b border-bg-warm last:border-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="font-mono text-xs text-teal font-bold">0{step.number}</span>
|
|
||||||
<h4 className="font-semibold">{step.title}</h4>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-fg-muted">{step.description}</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quote */}
|
|
||||||
{data.quote && (
|
|
||||||
<div className="border-l-4 border-teal pl-6 py-2">
|
|
||||||
<blockquote className="text-xl italic text-fg-muted leading-relaxed">
|
|
||||||
"{data.quote}"
|
|
||||||
</blockquote>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Leren;
|
export default Leren;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const Login = () => {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const userOptions = [
|
const userOptions = [
|
||||||
{ value: '', label: 'Selecteer een gebruiker...' },
|
{ value: '', label: 'Select a user...' },
|
||||||
...state.users.map(u => ({ value: u.id, label: u.name }))
|
...state.users.map(u => ({ value: u.id, label: u.name }))
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ const Login = () => {
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
if (!selectedUser || !pin) {
|
if (!selectedUser || !pin) {
|
||||||
setError('Vul alle velden in.');
|
setError('Please fill in all fields.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ const Login = () => {
|
|||||||
if (success) {
|
if (success) {
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} else {
|
} else {
|
||||||
setError('Onjuiste PIN.');
|
setError('Incorrect PIN.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,13 +42,13 @@ const Login = () => {
|
|||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<img src="/images/icon.png" alt="Respellion Icon" className="h-24 mx-auto mb-4" />
|
<img src="/images/icon.png" alt="Respellion Icon" className="h-24 mx-auto mb-4" />
|
||||||
<h2 className="text-2xl text-teal font-bold tracking-tight">Respellion</h2>
|
<h2 className="text-2xl text-teal font-bold tracking-tight">Respellion</h2>
|
||||||
<p className="text-fg-muted mt-2">Leerplatform Login</p>
|
<p className="text-fg-muted mt-2">Learning Platform</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border border-bg-warm">
|
<Card className="border border-bg-warm">
|
||||||
<form onSubmit={handleLogin} className="flex flex-col gap-5">
|
<form onSubmit={handleLogin} className="flex flex-col gap-5">
|
||||||
<Select
|
<Select
|
||||||
label="Gebruiker"
|
label="User"
|
||||||
options={userOptions}
|
options={userOptions}
|
||||||
value={selectedUser}
|
value={selectedUser}
|
||||||
onChange={(e) => setSelectedUser(e.target.value)}
|
onChange={(e) => setSelectedUser(e.target.value)}
|
||||||
@@ -68,7 +68,7 @@ const Login = () => {
|
|||||||
{error && <div className="text-red-500 text-sm font-medium">{error}</div>}
|
{error && <div className="text-red-500 text-sm font-medium">{error}</div>}
|
||||||
|
|
||||||
<Button type="submit" className="mt-2 w-full">
|
<Button type="submit" className="mt-2 w-full">
|
||||||
Inloggen
|
Sign In
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
406
src/pages/Testen.jsx
Normal file
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