diff --git a/src/App.jsx b/src/App.jsx
index 5b00847..ace264f 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -6,8 +6,9 @@ import { useApp } from './store/AppContext'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
+import Admin from './pages/Admin'
+
// Placeholder components for routing structure
-const Admin = () =>
Admin
Kennisbeheer and Source Upload.
const Testen = () => Weektest
Start your weekly test here.
const Leren = () => Leren
Start your weekly learning session.
const Leaderboard = () => Leaderboard
See who is on top!
diff --git a/src/components/admin/KnowledgeGraph.jsx b/src/components/admin/KnowledgeGraph.jsx
new file mode 100644
index 0000000..b55de94
--- /dev/null
+++ b/src/components/admin/KnowledgeGraph.jsx
@@ -0,0 +1,191 @@
+import React, { useEffect, useRef, useState } from 'react';
+import * as d3 from 'd3';
+import { storage } from '../../lib/storage';
+
+const KnowledgeGraph = () => {
+ const svgRef = useRef(null);
+ const wrapperRef = useRef(null);
+ const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
+ const [selectedNode, setSelectedNode] = useState(null);
+
+ // Load data
+ const topics = storage.get('kb:topics', []);
+ const relations = storage.get('kb:relations', []);
+
+ useEffect(() => {
+ if (!wrapperRef.current) return;
+ const { width, height } = wrapperRef.current.getBoundingClientRect();
+ setDimensions({ width, height });
+
+ const handleResize = () => {
+ if (wrapperRef.current) {
+ const { width, height } = wrapperRef.current.getBoundingClientRect();
+ setDimensions({ width, height });
+ }
+ };
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
+ useEffect(() => {
+ if (!svgRef.current || topics.length === 0) return;
+
+ const { width, height } = dimensions;
+
+ // Clear previous render
+ d3.select(svgRef.current).selectAll('*').remove();
+
+ // Setup nodes and links
+ // Copy objects because d3 modifies them
+ const nodes = topics.map(d => ({ ...d }));
+ const links = relations.map(d => ({ ...d }));
+
+ // Create SVG container with zoom support
+ const svg = d3.select(svgRef.current)
+ .attr('viewBox', [0, 0, width, height])
+ .attr('width', width)
+ .attr('height', height);
+
+ const g = svg.append('g');
+
+ const zoom = d3.zoom()
+ .scaleExtent([0.1, 4])
+ .on('zoom', (event) => {
+ g.attr('transform', event.transform);
+ });
+
+ svg.call(zoom);
+
+ // Color scale for node types
+ const color = d3.scaleOrdinal()
+ .domain(['concept', 'role', 'process', 'fact'])
+ .range(['#1F5560', '#5C1DD8', '#AFC0FF', '#E2E8F0']);
+
+ // Setup force simulation
+ const simulation = d3.forceSimulation(nodes)
+ .force('link', d3.forceLink(links).id(d => d.id).distance(100))
+ .force('charge', d3.forceManyBody().strength(-300))
+ .force('center', d3.forceCenter(width / 2, height / 2))
+ .force('collide', d3.forceCollide().radius(40));
+
+ // Draw links
+ const link = g.append('g')
+ .attr('stroke', 'var(--color-bg-warm)')
+ .attr('stroke-opacity', 0.6)
+ .selectAll('line')
+ .data(links)
+ .join('line')
+ .attr('stroke-width', 2);
+
+ // Draw nodes
+ const node = g.append('g')
+ .selectAll('g')
+ .data(nodes)
+ .join('g')
+ .call(drag(simulation));
+
+ // Add circles to nodes
+ node.append('circle')
+ .attr('r', 20)
+ .attr('fill', d => color(d.type) || '#1F5560')
+ .attr('stroke', '#fff')
+ .attr('stroke-width', 2)
+ .on('click', (event, d) => {
+ setSelectedNode(d);
+ });
+
+ // Add labels to nodes
+ node.append('text')
+ .text(d => d.label)
+ .attr('x', 25)
+ .attr('y', 5)
+ .attr('font-size', '12px')
+ .attr('fill', 'var(--color-fg)')
+ .attr('class', 'font-mono select-none pointer-events-none');
+
+ simulation.on('tick', () => {
+ link
+ .attr('x1', d => d.source.x)
+ .attr('y1', d => d.source.y)
+ .attr('x2', d => d.target.x)
+ .attr('y2', d => d.target.y);
+
+ node
+ .attr('transform', d => `translate(${d.x},${d.y})`);
+ });
+
+ // Drag behavior
+ function drag(simulation) {
+ function dragstarted(event) {
+ if (!event.active) simulation.alphaTarget(0.3).restart();
+ event.subject.fx = event.subject.x;
+ event.subject.fy = event.subject.y;
+ }
+
+ function dragged(event) {
+ event.subject.fx = event.x;
+ event.subject.fy = event.y;
+ }
+
+ function dragended(event) {
+ if (!event.active) simulation.alphaTarget(0);
+ event.subject.fx = null;
+ event.subject.fy = null;
+ }
+
+ return d3.drag()
+ .on('start', dragstarted)
+ .on('drag', dragged)
+ .on('end', dragended);
+ }
+
+ return () => {
+ simulation.stop();
+ };
+ }, [dimensions, topics, relations]);
+
+ return (
+
+
+ {topics.length === 0 ? (
+
+ Nog geen kennisgraaf data. Upload eerst bronnen via het Bronmateriaal tabblad.
+
+ ) : (
+
+ )}
+
+
+ {/* Node Info Panel */}
+
+
Node Details
+ {selectedNode ? (
+
+
+
Label
+
{selectedNode.label}
+
+
+
Type
+
+ {selectedNode.type}
+
+
+
+
Beschrijving
+
{selectedNode.description}
+
+
+
ID
+
{selectedNode.id}
+
+
+ ) : (
+
Klik op een node in de graaf om details te bekijken.
+ )}
+
+
+ );
+};
+
+export default KnowledgeGraph;
diff --git a/src/components/admin/UploadZone.jsx b/src/components/admin/UploadZone.jsx
new file mode 100644
index 0000000..03b57df
--- /dev/null
+++ b/src/components/admin/UploadZone.jsx
@@ -0,0 +1,137 @@
+import React, { useState, useRef } from 'react';
+import { UploadCloud, Link as LinkIcon, AlertCircle, CheckCircle } from 'lucide-react';
+import { processSourceText } from '../../lib/extractionPipeline';
+import Card from '../ui/Card';
+import Button from '../ui/Button';
+import Input from '../ui/Input';
+
+const UploadZone = ({ onUploadComplete }) => {
+ const [isDragging, setIsDragging] = useState(false);
+ const [url, setUrl] = useState('');
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [status, setStatus] = useState(null); // { type: 'error' | 'success', msg: string }
+
+ const fileInputRef = useRef(null);
+
+ const handleDragOver = (e) => {
+ e.preventDefault();
+ setIsDragging(true);
+ };
+
+ const handleDragLeave = () => {
+ setIsDragging(false);
+ };
+
+ const processFile = async (file) => {
+ setIsProcessing(true);
+ setStatus(null);
+ try {
+ const text = await file.text();
+ await processSourceText(text, file.name);
+ setStatus({ type: 'success', msg: `Succesvol verwerkt: ${file.name}` });
+ if (onUploadComplete) onUploadComplete();
+ } catch (error) {
+ setStatus({ type: 'error', msg: `Fout bij verwerken: ${error.message}` });
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ setIsDragging(false);
+
+ if (e.dataTransfer.files && 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.' });
+ }
+ }
+ };
+
+ const handleFileSelect = (e) => {
+ if (e.target.files && 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);
+ }
+ };
+
+ return (
+
+
fileInputRef.current?.click()}
+ >
+
+ Sleep bestanden hierheen
+ Ondersteunt .txt en .md (Max 5MB)
+
+
+
+
+
+
+
+
+ {status && (
+
+ {status.type === 'error' ?
:
}
+
+
{status.type === 'error' ? 'Fout' : 'Succes'}
+
{status.msg}
+
+
+ )}
+
+ );
+};
+
+export default UploadZone;
diff --git a/src/lib/api.js b/src/lib/api.js
index 0658ef6..cfea2fa 100644
--- a/src/lib/api.js
+++ b/src/lib/api.js
@@ -1,3 +1,5 @@
+import { storage } from './storage';
+
/**
* Anthropic API Service
* Handles communication with the /v1/messages endpoint.
@@ -16,7 +18,8 @@ export const anthropicApi = {
// In a real application, the API key should not be exposed to the client.
// For this prototype, we'll assume it's passed or available in the environment,
// or proxied via a secure endpoint if deployed.
- const apiKey = import.meta.env.VITE_ANTHROPIC_API_KEY || 'no-key-provided';
+ // Prioritize key from local storage (set via Admin Settings)
+ const apiKey = storage.get('admin:anthropic_key') || import.meta.env.VITE_ANTHROPIC_API_KEY || 'no-key-provided';
while (retries <= maxRetries) {
try {
diff --git a/src/lib/extractionPipeline.js b/src/lib/extractionPipeline.js
new file mode 100644
index 0000000..843fa1a
--- /dev/null
+++ b/src/lib/extractionPipeline.js
@@ -0,0 +1,112 @@
+import { anthropicApi } from './api';
+import { storage } from './storage';
+
+const SYSTEM_PROMPT = `Je bent een AI-kennisextractor voor Respellion, een IT-bedrijf gericht op "radicale transparantie".
+Je krijgt een brontekst. Jouw taak is om kernconcepten, rollen, processen en feiten te extraheren en deze als een gestructureerde JSON Kennisgraaf terug te geven.
+
+Geef ALTIJD een valide JSON object terug in het volgende formaat:
+{
+ "topics": [
+ {
+ "id": "unieke-slug",
+ "label": "Titel van onderwerp",
+ "type": "concept | role | process | fact",
+ "description": "Een beknopte, heldere uitleg van max 3 zinnen."
+ }
+ ],
+ "relations": [
+ {
+ "source": "id-van-topic-1",
+ "target": "id-van-topic-2",
+ "type": "related_to | depends_on | part_of | executed_by"
+ }
+ ]
+}
+Zorg dat je alleen JSON teruggeeft, geen markdown blokken of andere tekst.`;
+
+/**
+ * Voert tekst door de Anthropic API en slaat de resulterende topics op.
+ * @param {string} textContent De te analyseren tekst.
+ * @param {string} sourceName Naam van de bron (voor tracking).
+ */
+export async function processSourceText(textContent, sourceName) {
+ // 1. Sla de bron eerst op als "processing"
+ const sourceId = `src_${Date.now()}`;
+ const newSource = {
+ id: sourceId,
+ name: sourceName,
+ status: 'processing',
+ date: new Date().toISOString()
+ };
+
+ const sources = storage.get('admin:sources', []);
+ storage.set('admin:sources', [newSource, ...sources]);
+
+ try {
+ // 2. Roep Anthropic API aan
+ const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyseer de volgende tekst:\n\n${textContent}`);
+
+ // 3. Parse JSON (veilig)
+ let extractedData;
+ try {
+ // Zoek naar JSON in de response (indien het toch in markdown was verpakt)
+ const jsonMatch = responseText.match(/\{[\s\S]*\}/);
+ const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
+ extractedData = JSON.parse(jsonStr);
+ } catch (e) {
+ throw new Error('AI response was geen geldige JSON.');
+ }
+
+ // 4. Deduplicatie en opslag van Topics en Relaties
+ mergeKnowledgeGraph(extractedData);
+
+ // 5. Markeer bron als voltooid
+ updateSourceStatus(sourceId, 'completed');
+
+ return { success: true, data: extractedData };
+
+ } catch (error) {
+ // Markeer bron als mislukt
+ updateSourceStatus(sourceId, 'failed', error.message);
+ throw error;
+ }
+}
+
+function updateSourceStatus(sourceId, status, errorMsg = '') {
+ const sources = storage.get('admin:sources', []);
+ const updated = sources.map(s =>
+ s.id === sourceId ? { ...s, status, error: errorMsg } : s
+ );
+ storage.set('admin:sources', updated);
+}
+
+function mergeKnowledgeGraph(newData) {
+ const existingTopics = storage.get('kb:topics', []);
+ const existingRelations = storage.get('kb:relations', []);
+
+ // Simpele deduplicatie op ID
+ const topicsMap = new Map(existingTopics.map(t => [t.id, t]));
+
+ if (newData.topics && Array.isArray(newData.topics)) {
+ for (const t of newData.topics) {
+ if (!topicsMap.has(t.id)) {
+ topicsMap.set(t.id, t);
+ }
+ }
+ }
+
+ // Voeg relaties toe (we gaan er nu vanuit dat ze uniek genoeg zijn of dubbele negeren)
+ const newRelations = [...existingRelations];
+ if (newData.relations && Array.isArray(newData.relations)) {
+ for (const r of newData.relations) {
+ // Simpele check om exacte duplicaten te voorkomen
+ const isDup = newRelations.some(ex => ex.source === r.source && ex.target === r.target && ex.type === r.type);
+ if (!isDup) {
+ newRelations.push(r);
+ }
+ }
+ }
+
+ storage.set('kb:topics', Array.from(topicsMap.values()));
+ storage.set('kb:relations', newRelations);
+}
diff --git a/src/lib/storage.js b/src/lib/storage.js
index 910a665..066ba95 100644
--- a/src/lib/storage.js
+++ b/src/lib/storage.js
@@ -11,13 +11,13 @@ export const storage = {
* @param {string} key - e.g. 'kb:topics'
* @returns {any}
*/
- get(key) {
+ get(key, defaultValue = null) {
try {
const item = window.localStorage.getItem(STORAGE_PREFIX + key);
- return item ? JSON.parse(item) : null;
+ return item ? JSON.parse(item) : defaultValue;
} catch (e) {
console.error(`Error reading ${key} from storage:`, e);
- return null;
+ return defaultValue;
}
},
diff --git a/src/pages/Admin/index.jsx b/src/pages/Admin/index.jsx
new file mode 100644
index 0000000..c4d9439
--- /dev/null
+++ b/src/pages/Admin/index.jsx
@@ -0,0 +1,176 @@
+import React, { useState, useEffect } from 'react';
+import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save } from 'lucide-react';
+import Card from '../../components/ui/Card';
+import Tag from '../../components/ui/Tag';
+import Button from '../../components/ui/Button';
+import Input from '../../components/ui/Input';
+import { storage } from '../../lib/storage';
+import UploadZone from '../../components/admin/UploadZone';
+import KnowledgeGraph from '../../components/admin/KnowledgeGraph';
+
+const Admin = () => {
+ const [activeTab, setActiveTab] = useState('bronnen');
+ const [sources, setSources] = useState([]);
+ const [apiKey, setApiKey] = useState('');
+ const [saveStatus, setSaveStatus] = useState(null);
+
+ const loadSources = () => {
+ setSources(storage.get('admin:sources', []));
+ };
+
+ useEffect(() => {
+ if (activeTab === 'bronnen') {
+ loadSources();
+ }
+ if (activeTab === 'instellingen') {
+ setApiKey(storage.get('admin:anthropic_key', ''));
+ }
+ }, [activeTab]);
+
+ const saveSettings = (e) => {
+ e.preventDefault();
+ storage.set('admin:anthropic_key', apiKey);
+ setSaveStatus('Opgeslagen!');
+ setTimeout(() => setSaveStatus(null), 3000);
+ };
+
+ return (
+
+ {/* Admin Sidebar */}
+
+
Kennisbeheer
+
+
+
+
+
+
+
+
+
+
+ {/* Admin Main Content */}
+
+ {activeTab === 'bronnen' && (
+
+
Bronmateriaal
+
Upload bestanden of geef URL's op voor de AI-kennisextractie.
+
+
+
+
Recente Bronnen
+
+
+ {sources.length === 0 ? (
+
Geen bronnen geüpload.
+ ) : (
+ sources.map((source) => (
+
+
+
+
+
+
+
{source.name}
+
+ {new Date(source.date).toLocaleString()}
+
+
+
+
+ {source.status === 'completed' &&
Voltooid}
+ {source.status === 'processing' &&
Bezig}
+ {source.status === 'failed' &&
Mislukt}
+
+
+ ))
+ )}
+
+
+
+ )}
+
+ {activeTab === 'graaf' && (
+
+
Kennisgraaf
+
Visualisatie van de opgebouwde Respellion kennis.
+
+
+
+
+ )}
+
+ {activeTab === 'gebruikers' && (
+
+
Gebruikersbeheer
+
+ Hier kunnen medewerkers worden toegevoegd en beheerd.
+
+
+ )}
+
+ {activeTab === 'instellingen' && (
+
+
Instellingen
+
Beheer applicatie-instellingen en API-sleutels.
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default Admin;