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

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

View File

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

View File

@@ -149,7 +149,7 @@ const KnowledgeGraph = () => {
<div ref={wrapperRef} className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing border-r border-bg-warm">
{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>

View File

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

View File

@@ -18,9 +18,7 @@ const UploadZone = ({ onUploadComplete }) => {
setIsDragging(true);
};
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>