feat: implement core knowledge graph UI components, extraction pipeline, and initial platform navigation pages
This commit is contained in:
226
src/components/admin/ContentManager.jsx
Normal file
226
src/components/admin/ContentManager.jsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
RefreshCw, Wand2, Trash2, CheckCircle, Loader,
|
||||
AlertCircle, BookOpen, ArrowLeft, Eye
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { getAllGeneratedContent, generateLearningContent, refineLearningContent, deleteCachedContent } from '../../lib/learningService';
|
||||
import LearningContentViewer from '../ui/LearningContentViewer';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import Tag from '../ui/Tag';
|
||||
|
||||
const ContentManager = () => {
|
||||
const [items, setItems] = useState([]);
|
||||
const [selected, setSelected] = useState(null); // { topic, content } — the open detail view
|
||||
const [actionState, setActionState] = useState({}); // { [topicId]: { loading, error, success } }
|
||||
const [refineText, setRefineText] = useState('');
|
||||
|
||||
const refresh = () => {
|
||||
const fresh = getAllGeneratedContent();
|
||||
setItems(fresh);
|
||||
// Keep detail view in sync if its topic was updated
|
||||
if (selected) {
|
||||
const updated = fresh.find(i => i.topic.id === selected.topic.id);
|
||||
if (updated) setSelected(updated);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { refresh(); }, []);
|
||||
|
||||
const setTopicState = (id, patch) =>
|
||||
setActionState(prev => ({ ...prev, [id]: { ...prev[id], ...patch } }));
|
||||
|
||||
const handleRegenerate = async (topic) => {
|
||||
setTopicState(topic.id, { loading: 'regenerating', error: null, success: null });
|
||||
try {
|
||||
await generateLearningContent(topic, true);
|
||||
setTopicState(topic.id, { loading: null, success: 'Content regenerated.' });
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setTopicState(topic.id, { loading: null, error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefine = async (topic) => {
|
||||
const instruction = refineText.trim();
|
||||
if (!instruction) return;
|
||||
setTopicState(topic.id, { loading: 'refining', error: null, success: null });
|
||||
try {
|
||||
await refineLearningContent(topic, instruction);
|
||||
setTopicState(topic.id, { loading: null, success: 'Content refined.' });
|
||||
setRefineText('');
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setTopicState(topic.id, { loading: null, error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (topicId) => {
|
||||
deleteCachedContent(topicId);
|
||||
if (selected?.topic.id === topicId) setSelected(null);
|
||||
setActionState(prev => { const n = { ...prev }; delete n[topicId]; return n; });
|
||||
refresh();
|
||||
};
|
||||
|
||||
// ─── Empty state ──────────────────────────────────────────
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16 text-fg-muted">
|
||||
<BookOpen size={48} className="mx-auto mb-4 text-teal/30" />
|
||||
<p className="font-medium">No generated content yet.</p>
|
||||
<p className="text-sm mt-1">Upload sources then visit the Learn page to generate content.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Detail view ──────────────────────────────────────────
|
||||
if (selected) {
|
||||
const { topic, content } = selected;
|
||||
const state = actionState[topic.id] || {};
|
||||
const isLoading = !!state.loading;
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in duration-200">
|
||||
{/* Back + header */}
|
||||
<div className="flex items-start justify-between mb-6 gap-4 flex-wrap">
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="flex items-center gap-2 text-sm text-fg-muted hover:text-teal transition-colors mb-3"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to all content
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-2xl font-bold">{topic.label}</h2>
|
||||
<span className="font-mono text-xs bg-bg-warm px-2 py-0.5 rounded-full text-fg-muted">{topic.type}</span>
|
||||
</div>
|
||||
<p className="text-fg-muted text-sm mt-1">{topic.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleRegenerate(topic)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{state.loading === 'regenerating'
|
||||
? <Loader size={16} className="mr-2 animate-spin" />
|
||||
: <RefreshCw size={16} className="mr-2" />}
|
||||
Regenerate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleDelete(topic.id)}
|
||||
disabled={isLoading}
|
||||
className="border-red-200 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={16} className="mr-2" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
{(state.success || state.error) && (
|
||||
<div className={`mb-6 p-3 rounded-[var(--r-sm)] text-sm flex items-center gap-2 ${state.error ? 'bg-red-50 text-red-800' : 'bg-teal-50 text-teal-800'}`}>
|
||||
{state.error ? <AlertCircle size={16} /> : <CheckCircle size={16} />}
|
||||
{state.error || state.success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full content preview */}
|
||||
<LearningContentViewer content={content} topic={topic} />
|
||||
|
||||
{/* Refine panel */}
|
||||
<Card className="border border-teal/30 mt-8">
|
||||
<h3 className="font-bold text-lg mb-1 flex items-center gap-2">
|
||||
<Wand2 size={18} className="text-teal" /> Refine with AI
|
||||
</h3>
|
||||
<p className="text-sm text-fg-muted mb-4">
|
||||
Review the content above, then describe what should change. Be specific — the AI will apply your instruction and update all content formats.
|
||||
</p>
|
||||
<textarea
|
||||
value={refineText}
|
||||
onChange={(e) => setRefineText(e.target.value)}
|
||||
placeholder='e.g. "Make the article more beginner-friendly and add a real-world example to each slide."'
|
||||
rows={4}
|
||||
className="w-full p-3 rounded-[var(--r-sm)] border border-bg-warm bg-bg text-sm resize-none focus:outline-none focus:ring-2 focus:ring-teal/30"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="flex justify-end mt-3">
|
||||
<Button
|
||||
onClick={() => handleRefine(topic)}
|
||||
disabled={isLoading || !refineText.trim()}
|
||||
>
|
||||
{state.loading === 'refining'
|
||||
? <><Loader size={16} className="mr-2 animate-spin" /> Refining...</>
|
||||
: <><Wand2 size={16} className="mr-2" /> Apply Refinement</>}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── List view ────────────────────────────────────────────
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map(({ topic, content }) => {
|
||||
const state = actionState[topic.id] || {};
|
||||
const isLoading = !!state.loading;
|
||||
|
||||
return (
|
||||
<Card key={topic.id} className="border border-bg-warm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold">{topic.label}</span>
|
||||
<span className="font-mono text-xs bg-bg-warm px-2 py-0.5 rounded-full text-fg-muted">{topic.type}</span>
|
||||
<Tag variant="success" className="text-xs">Ready</Tag>
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted mt-0.5 truncate">{topic.description}</p>
|
||||
{(state.success || state.error) && (
|
||||
<p className={`text-xs mt-1 flex items-center gap-1 ${state.error ? 'text-red-600' : 'text-teal-700'}`}>
|
||||
{state.error ? <AlertCircle size={12} /> : <CheckCircle size={12} />}
|
||||
{state.error || state.success}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Quick actions */}
|
||||
<button
|
||||
onClick={() => handleRegenerate(topic)}
|
||||
disabled={isLoading}
|
||||
title="Regenerate from scratch"
|
||||
className="p-2 rounded-[var(--r-sm)] hover:bg-bg-warm transition-colors text-fg-muted hover:text-teal disabled:opacity-40"
|
||||
>
|
||||
{state.loading === 'regenerating' ? <Loader size={17} className="animate-spin" /> : <RefreshCw size={17} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(topic.id)}
|
||||
disabled={isLoading}
|
||||
title="Delete cached content"
|
||||
className="p-2 rounded-[var(--r-sm)] hover:bg-red-50 transition-colors text-fg-muted hover:text-red-500 disabled:opacity-40"
|
||||
>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
|
||||
{/* Review button */}
|
||||
<Button
|
||||
onClick={() => setSelected({ topic, content })}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Eye size={16} /> Review
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentManager;
|
||||
@@ -149,7 +149,7 @@ const KnowledgeGraph = () => {
|
||||
<div ref={wrapperRef} className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing border-r border-bg-warm">
|
||||
{topics.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
|
||||
Nog geen kennisgraaf data. Upload eerst bronnen via het Bronmateriaal tabblad.
|
||||
No knowledge graph data yet. Upload source material in the Sources tab first.
|
||||
</div>
|
||||
) : (
|
||||
<svg ref={svgRef} className="w-full h-full" />
|
||||
@@ -172,7 +172,7 @@ const KnowledgeGraph = () => {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Beschrijving</p>
|
||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Description</p>
|
||||
<p className="text-sm leading-relaxed">{selectedNode.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -181,7 +181,7 @@ const KnowledgeGraph = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">Klik op een node in de graaf om details te bekijken.</p>
|
||||
<p className="text-sm text-fg-muted">Click a node in the graph to view its details.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
200
src/components/admin/TestManager.jsx
Normal file
200
src/components/admin/TestManager.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { RefreshCw, Trash2, CheckCircle, Loader, AlertCircle, HelpCircle, ArrowLeft, Eye, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { storage } from '../../lib/storage';
|
||||
import { forceGenerateTopicQuestions, getTopicQuestionBank, deleteQuestion } from '../../lib/testService';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import Tag from '../ui/Tag';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const TestManager = () => {
|
||||
const [topics, setTopics] = useState([]);
|
||||
const [selectedTopic, setSelectedTopic] = useState(null);
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [loadingTopicId, setLoadingTopicId] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadData = () => {
|
||||
const allTopics = storage.get('kb:topics', []);
|
||||
setTopics(allTopics);
|
||||
|
||||
if (selectedTopic) {
|
||||
setQuestions(getTopicQuestionBank(selectedTopic.id));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [selectedTopic]);
|
||||
|
||||
const handleGenerate = async (topic, count = 10) => {
|
||||
setLoadingTopicId(topic.id);
|
||||
setError(null);
|
||||
try {
|
||||
await forceGenerateTopicQuestions(topic, count);
|
||||
loadData(); // refresh list
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoadingTopicId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (topicId, questionId) => {
|
||||
deleteQuestion(topicId, questionId);
|
||||
loadData();
|
||||
};
|
||||
|
||||
// ── Detail view ──
|
||||
if (selectedTopic) {
|
||||
return (
|
||||
<div className="animate-in fade-in duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-6 gap-4 flex-wrap">
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setSelectedTopic(null)}
|
||||
className="flex items-center gap-2 text-sm text-fg-muted hover:text-teal transition-colors mb-3"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Topics
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-2xl font-bold">{selectedTopic.label}</h2>
|
||||
<Tag variant="accent" className="text-xs">{questions.length} questions</Tag>
|
||||
</div>
|
||||
<p className="text-fg-muted text-sm mt-1">{selectedTopic.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleGenerate(selectedTopic, 5)}
|
||||
disabled={loadingTopicId === selectedTopic.id}
|
||||
>
|
||||
{loadingTopicId === selectedTopic.id
|
||||
? <Loader size={16} className="mr-2 animate-spin" />
|
||||
: <RefreshCw size={16} className="mr-2" />}
|
||||
Generate 5 More
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-3 rounded-[var(--r-sm)] bg-red-50 text-red-800 text-sm flex items-center gap-2">
|
||||
<AlertCircle size={16} /> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Questions List */}
|
||||
<div className="space-y-4">
|
||||
{questions.length === 0 ? (
|
||||
<Card className="text-center py-12 text-fg-muted border-dashed border-2">
|
||||
<p>No questions generated for this topic yet.</p>
|
||||
<Button className="mt-4" onClick={() => handleGenerate(selectedTopic, 10)} disabled={loadingTopicId === selectedTopic.id}>
|
||||
Generate 10 Questions
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
questions.map((q, i) => (
|
||||
<Card key={q.id} className="border border-bg-warm relative group">
|
||||
<button
|
||||
onClick={() => handleDelete(selectedTopic.id, q.id)}
|
||||
className="absolute top-4 right-4 p-2 text-fg-muted hover:text-red-500 hover:bg-red-50 rounded-[var(--r-sm)] opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Delete question"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-teal/10 text-teal flex items-center justify-center text-xs font-bold mt-0.5">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold">{q.question}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 ml-9 mb-4">
|
||||
{q.options.map((opt, oi) => (
|
||||
<div
|
||||
key={oi}
|
||||
className={`px-3 py-2 rounded-[var(--r-sm)] text-sm border ${
|
||||
oi === q.correctIndex
|
||||
? 'border-teal bg-teal/5 text-teal font-medium'
|
||||
: 'border-bg-warm text-fg-muted'
|
||||
}`}
|
||||
>
|
||||
{oi === q.correctIndex && <CheckCircle size={14} className="inline mr-2 -mt-0.5" />}
|
||||
{opt}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted ml-9 italic border-l-2 border-teal/30 pl-3">
|
||||
{q.explanation}
|
||||
</p>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── List view ──
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{topics.length === 0 && (
|
||||
<div className="text-center py-16 text-fg-muted">
|
||||
<HelpCircle size={48} className="mx-auto mb-4 text-teal/30" />
|
||||
<p className="font-medium">No topics available.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !selectedTopic && (
|
||||
<div className="mb-4 p-3 rounded-[var(--r-sm)] bg-red-50 text-red-800 text-sm flex items-center gap-2">
|
||||
<AlertCircle size={16} /> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{topics.map(topic => {
|
||||
const bank = getTopicQuestionBank(topic.id);
|
||||
const count = bank.length;
|
||||
const isLoading = loadingTopicId === topic.id;
|
||||
|
||||
return (
|
||||
<Card key={topic.id} className="border border-bg-warm flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0 pr-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold truncate">{topic.label}</h3>
|
||||
<Tag variant={count > 0 ? 'success' : 'dark'} className="text-xs">
|
||||
{count} {count === 1 ? 'question' : 'questions'}
|
||||
</Tag>
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted truncate">{topic.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{count === 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleGenerate(topic)}
|
||||
disabled={isLoading}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{isLoading ? <Loader size={16} className="animate-spin mr-2" /> : <RefreshCw size={16} className="mr-2" />}
|
||||
Generate
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setSelectedTopic(topic)} className="flex items-center gap-2">
|
||||
<Eye size={16} /> Review
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestManager;
|
||||
@@ -18,9 +18,7 @@ const UploadZone = ({ onUploadComplete }) => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
const handleDragLeave = () => setIsDragging(false);
|
||||
|
||||
const processFile = async (file) => {
|
||||
setIsProcessing(true);
|
||||
@@ -28,10 +26,10 @@ const UploadZone = ({ onUploadComplete }) => {
|
||||
try {
|
||||
const text = await file.text();
|
||||
await processSourceText(text, file.name);
|
||||
setStatus({ type: 'success', msg: `Succesvol verwerkt: ${file.name}` });
|
||||
setStatus({ type: 'success', msg: `Successfully processed: ${file.name}` });
|
||||
if (onUploadComplete) onUploadComplete();
|
||||
} catch (error) {
|
||||
setStatus({ type: 'error', msg: `Fout bij verwerken: ${error.message}` });
|
||||
setStatus({ type: 'error', msg: `Error processing file: ${error.message}` });
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@@ -40,42 +38,29 @@ const UploadZone = ({ onUploadComplete }) => {
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
if (e.dataTransfer.files?.length > 0) {
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file.type === 'text/plain' || file.name.endsWith('.md')) {
|
||||
processFile(file);
|
||||
} else {
|
||||
setStatus({ type: 'error', msg: 'Alleen .txt en .md bestanden worden momenteel ondersteund.' });
|
||||
setStatus({ type: 'error', msg: 'Only .txt and .md files are currently supported.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
processFile(e.target.files[0]);
|
||||
}
|
||||
if (e.target.files?.length > 0) processFile(e.target.files[0]);
|
||||
};
|
||||
|
||||
const handleUrlSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!url) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
// In a real scenario, this would call a backend proxy to bypass CORS.
|
||||
// For this prototype, we'll simulate fetching or only support text URLs.
|
||||
setStatus({ type: 'error', msg: 'URL import is experimenteel en momenteel uitgeschakeld ivm CORS restricties in browser.' });
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
setStatus({ type: 'error', msg: 'URL import is disabled in the browser due to CORS restrictions.' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card
|
||||
className={`border-2 border-dashed transition-colors flex flex-col items-center justify-center py-12 ${
|
||||
className={`border-2 border-dashed transition-colors flex flex-col items-center justify-center py-12 cursor-pointer ${
|
||||
isDragging ? 'border-teal bg-teal/5' : 'border-bg-warm bg-bg-warm/20'
|
||||
} ${isProcessing ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -84,10 +69,10 @@ const UploadZone = ({ onUploadComplete }) => {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<UploadCloud size={48} className="text-teal/40 mb-4" />
|
||||
<p className="font-medium text-lg">Sleep bestanden hierheen</p>
|
||||
<p className="text-sm text-fg-muted mb-4">Ondersteunt .txt en .md (Max 5MB)</p>
|
||||
<p className="font-medium text-lg">Drag files here</p>
|
||||
<p className="text-sm text-fg-muted mb-4">Supports .txt and .md (max 5MB)</p>
|
||||
<Button variant="outline" type="button" disabled={isProcessing}>
|
||||
{isProcessing ? 'Bezig met AI extractie...' : 'Bladeren op apparaat'}
|
||||
{isProcessing ? 'AI extraction in progress...' : 'Browse files'}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
@@ -100,15 +85,15 @@ const UploadZone = ({ onUploadComplete }) => {
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 border-t border-bg-warm"></div>
|
||||
<span className="text-sm text-fg-muted uppercase tracking-wider">OF</span>
|
||||
<span className="text-sm text-fg-muted uppercase tracking-wider">OR</span>
|
||||
<div className="flex-1 border-t border-bg-warm"></div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUrlSubmit} className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Importeer van URL"
|
||||
placeholder="https://wiki.respellion.nl/artikel"
|
||||
label="Import from URL"
|
||||
placeholder="https://wiki.respellion.com/article"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
disabled={isProcessing}
|
||||
@@ -125,7 +110,7 @@ const UploadZone = ({ onUploadComplete }) => {
|
||||
}`}>
|
||||
{status.type === 'error' ? <AlertCircle className="text-red-500 flex-shrink-0" /> : <CheckCircle className="text-teal-600 flex-shrink-0" />}
|
||||
<div>
|
||||
<p className="font-medium">{status.type === 'error' ? 'Fout' : 'Succes'}</p>
|
||||
<p className="font-medium">{status.type === 'error' ? 'Error' : 'Success'}</p>
|
||||
<p className="text-sm">{status.msg}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
282
src/components/ui/LearningContentViewer.jsx
Normal file
282
src/components/ui/LearningContentViewer.jsx
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* LearningContentViewer
|
||||
* Shared component used by both the student Learn page and the Admin Content Manager.
|
||||
* Renders all four learning modes: Article, Slides, Podcast, Infographic.
|
||||
*/
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ChevronLeft, ChevronRight, BookOpen, Presentation,
|
||||
Mic, CheckCircle, Volume2, VolumeX, BarChart2
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Card from './Card';
|
||||
import Tag from './Tag';
|
||||
import Button from './Button';
|
||||
|
||||
const MODES = [
|
||||
{ key: 'article', icon: BookOpen, label: 'Article' },
|
||||
{ key: 'slides', icon: Presentation, label: 'Slides' },
|
||||
{ key: 'podcast', icon: Mic, label: 'Podcast' },
|
||||
{ key: 'infographic', icon: BarChart2, label: 'Infographic' },
|
||||
];
|
||||
|
||||
const LearningContentViewer = ({ content, topic, initialMode = 'article' }) => {
|
||||
const [activeMode, setActiveMode] = useState(initialMode);
|
||||
|
||||
if (!content || !topic) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Mode Selector */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
{MODES.map(({ key, icon: Icon, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveMode(key)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-[var(--r-sm)] border transition-all text-sm font-medium ${
|
||||
activeMode === key
|
||||
? 'bg-teal text-white border-teal shadow-sm'
|
||||
: 'border-bg-warm text-fg-muted hover:text-fg hover:border-teal/50 bg-paper'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeMode}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{activeMode === 'article' && <ArticleView content={content.article} />}
|
||||
{activeMode === 'slides' && <SlidesView slides={content.slides} />}
|
||||
{activeMode === 'podcast' && <PodcastView script={content.podcastScript} topicLabel={topic.label} />}
|
||||
{activeMode === 'infographic' && <InfographicView data={content.infographic} topicLabel={topic.label} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Article Renderer ─────────────────────────────────────── */
|
||||
const ArticleView = ({ content }) => (
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-bg-warm">
|
||||
<h2 className="text-2xl font-bold mb-3">{content.title}</h2>
|
||||
<p className="text-fg-muted text-lg leading-relaxed">{content.intro}</p>
|
||||
</Card>
|
||||
{content.sections?.map((section, i) => (
|
||||
<Card key={i} className="border border-bg-warm">
|
||||
<h3 className="text-xl font-semibold mb-3 text-teal">{section.heading}</h3>
|
||||
<p className="leading-relaxed">{section.body}</p>
|
||||
</Card>
|
||||
))}
|
||||
{content.keyTakeaways?.length > 0 && (
|
||||
<Card className="border border-teal/30 bg-teal/5">
|
||||
<h3 className="font-bold text-teal mb-4 flex items-center gap-2">
|
||||
<CheckCircle size={18} /> Key Takeaways
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{content.keyTakeaways.map((point, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<span className="font-mono text-teal mt-0.5">→</span>
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ── Slides Renderer ──────────────────────────────────────── */
|
||||
const SlidesView = ({ slides }) => {
|
||||
const [current, setCurrent] = useState(0);
|
||||
const total = slides?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={current}
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -40 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<Card className="border border-bg-warm min-h-[280px] flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="font-mono text-xs text-fg-muted">{current + 1} / {total}</span>
|
||||
<Tag variant="accent">Slide</Tag>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-6">{slides[current]?.title}</h2>
|
||||
<ul className="space-y-3">
|
||||
{slides[current]?.bullets?.map((bullet, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<span className="font-mono text-purple-500 font-bold mt-0.5">▸</span>
|
||||
<span className="text-lg">{bullet}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{slides[current]?.speakerNote && (
|
||||
<p className="text-sm text-fg-muted border-t border-bg-warm pt-4 mt-6 italic">
|
||||
💬 {slides[current].speakerNote}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button variant="outline" onClick={() => setCurrent(c => Math.max(0, c - 1))} disabled={current === 0}>
|
||||
<ChevronLeft size={18} />
|
||||
</Button>
|
||||
<div className="flex gap-1.5">
|
||||
{slides?.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrent(i)}
|
||||
className={`h-2 rounded-full transition-all ${i === current ? 'bg-teal w-4' : 'bg-bg-warm w-2'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setCurrent(c => Math.min(total - 1, c + 1))} disabled={current === total - 1}>
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Podcast Renderer ─────────────────────────────────────── */
|
||||
const PodcastView = ({ script, topicLabel }) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const utteranceRef = useRef(null);
|
||||
|
||||
const togglePlayback = () => {
|
||||
if (!('speechSynthesis' in window)) {
|
||||
alert('Text-to-speech is not supported by your browser.');
|
||||
return;
|
||||
}
|
||||
if (isPlaying) {
|
||||
window.speechSynthesis.cancel();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
const utterance = new SpeechSynthesisUtterance(script);
|
||||
utterance.lang = 'en-US';
|
||||
utterance.rate = 0.95;
|
||||
utterance.onend = () => setIsPlaying(false);
|
||||
utteranceRef.current = utterance;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => () => window.speechSynthesis?.cancel(), []);
|
||||
|
||||
return (
|
||||
<Card className="border border-bg-warm">
|
||||
<div className="flex items-center gap-4 mb-6 pb-6 border-b border-bg-warm">
|
||||
<div className="w-16 h-16 rounded-full bg-teal flex items-center justify-center flex-shrink-0">
|
||||
<Mic size={28} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Podcast Episode</p>
|
||||
<h3 className="text-xl font-bold">{topicLabel}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={togglePlayback}
|
||||
className={`ml-auto flex items-center gap-2 px-5 py-2.5 rounded-full font-medium transition-all ${
|
||||
isPlaying ? 'bg-red-50 text-red-600 border border-red-200' : 'bg-teal text-white hover:bg-teal/90'
|
||||
}`}
|
||||
>
|
||||
{isPlaying ? <><VolumeX size={18} /> Stop</> : <><Volume2 size={18} /> Play</>}
|
||||
</button>
|
||||
</div>
|
||||
{isPlaying && (
|
||||
<div className="flex items-center gap-2 mb-6 p-3 bg-teal/5 rounded-[var(--r-sm)] border border-teal/20">
|
||||
<div className="flex gap-1 items-end h-6">
|
||||
{[0.3, 0.7, 1, 0.6, 0.4, 0.8, 0.5].map((h, i) => (
|
||||
<div key={i} className="w-1 bg-teal rounded-full animate-pulse" style={{ height: `${h * 24}px`, animationDelay: `${i * 0.1}s` }} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-teal font-medium ml-2">Now playing...</span>
|
||||
</div>
|
||||
)}
|
||||
<h4 className="text-sm font-semibold text-fg-muted uppercase tracking-wider mb-3">Script</h4>
|
||||
<p className="leading-relaxed whitespace-pre-wrap text-fg">{script}</p>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Infographic Renderer ─────────────────────────────────── */
|
||||
const InfographicView = ({ data, topicLabel }) => {
|
||||
if (!data) {
|
||||
return (
|
||||
<Card className="border border-bg-warm p-8 text-center text-fg-muted">
|
||||
No infographic data available for this topic.
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-[var(--r-org)] overflow-hidden bg-gradient-to-br from-teal to-teal/70 text-white p-8 md:p-12 relative">
|
||||
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 80%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
|
||||
<div className="relative z-10">
|
||||
<p className="text-white/60 text-sm uppercase tracking-widest font-mono mb-3">{topicLabel}</p>
|
||||
<h2 className="text-3xl md:text-4xl font-bold leading-tight mb-4">{data.headline}</h2>
|
||||
<p className="text-white/80 text-lg max-w-xl">{data.tagline}</p>
|
||||
</div>
|
||||
</div>
|
||||
{data.stats?.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{data.stats.map((stat, i) => (
|
||||
<motion.div key={i} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.1 }}>
|
||||
<Card className="border border-bg-warm text-center py-6">
|
||||
<div className="text-4xl mb-2">{stat.icon}</div>
|
||||
<div className="text-3xl font-bold text-teal mb-1">{stat.value}</div>
|
||||
<div className="text-sm text-fg-muted">{stat.label}</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{data.steps?.length > 0 && (
|
||||
<Card className="border border-bg-warm">
|
||||
<h3 className="text-lg font-bold mb-6 text-teal">Steps & Processes</h3>
|
||||
<div className="space-y-4">
|
||||
{data.steps.map((step, i) => (
|
||||
<motion.div key={i} initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: i * 0.08 }} className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-teal/10 border border-teal/30 flex items-center justify-center">
|
||||
<span className="text-lg">{step.icon}</span>
|
||||
</div>
|
||||
<div className="flex-1 pb-4 border-b border-bg-warm last:border-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-xs text-teal font-bold">0{step.number}</span>
|
||||
<h4 className="font-semibold">{step.title}</h4>
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted">{step.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{data.quote && (
|
||||
<div className="border-l-4 border-teal pl-6 py-2">
|
||||
<blockquote className="text-xl italic text-fg-muted leading-relaxed">"{data.quote}"</blockquote>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LearningContentViewer;
|
||||
Reference in New Issue
Block a user