feat: implement AI-driven learning content generation service and interactive leaderboard functionality

This commit is contained in:
RaymondVerhoef
2026-05-11 20:16:56 +02:00
parent 0cf5758742
commit 2597dc751a
9 changed files with 727 additions and 176 deletions

View File

@@ -1,16 +1,25 @@
import React, { useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
import { Trash2, Edit2, Save, X } from 'lucide-react';
import { storage } from '../../lib/storage';
import Button from '../ui/Button';
const KnowledgeGraph = () => {
const svgRef = useRef(null);
const wrapperRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
const [selectedNode, setSelectedNode] = useState(null);
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState({});
// Load data
const topics = storage.get('kb:topics', []);
const relations = storage.get('kb:relations', []);
// Load data into state so it can update
const [topics, setTopics] = useState([]);
const [relations, setRelations] = useState([]);
useEffect(() => {
setTopics(storage.get('kb:topics', []));
setRelations(storage.get('kb:relations', []));
}, []);
useEffect(() => {
if (!wrapperRef.current) return;
@@ -31,16 +40,11 @@ const KnowledgeGraph = () => {
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)
@@ -56,19 +60,16 @@ const KnowledgeGraph = () => {
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)
@@ -77,14 +78,12 @@ const KnowledgeGraph = () => {
.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')
@@ -92,9 +91,9 @@ const KnowledgeGraph = () => {
.attr('stroke-width', 2)
.on('click', (event, d) => {
setSelectedNode(d);
setIsEditing(false);
});
// Add labels to nodes
node.append('text')
.text(d => d.label)
.attr('x', 25)
@@ -109,41 +108,64 @@ const KnowledgeGraph = () => {
.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})`);
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 d3.drag().on('start', dragstarted).on('drag', dragged).on('end', dragended);
}
return () => {
simulation.stop();
};
return () => simulation.stop();
}, [dimensions, topics, relations]);
const startEdit = () => {
setEditData({ label: selectedNode.label, type: selectedNode.type, description: selectedNode.description });
setIsEditing(true);
};
const saveEdit = () => {
const updatedTopics = topics.map(t =>
t.id === selectedNode.id ? { ...t, ...editData } : t
);
storage.set('kb:topics', updatedTopics);
setTopics(updatedTopics);
setSelectedNode({ ...selectedNode, ...editData });
setIsEditing(false);
};
const deleteNode = () => {
if (confirm(`Are you sure you want to delete "${selectedNode.label}"? This will also remove any relations connected to it.`)) {
const updatedTopics = topics.filter(t => t.id !== selectedNode.id);
const updatedRelations = relations.filter(r => r.source !== selectedNode.id && r.target !== selectedNode.id);
storage.set('kb:topics', updatedTopics);
storage.set('kb:relations', updatedRelations);
setTopics(updatedTopics);
setRelations(updatedRelations);
setSelectedNode(null);
setIsEditing(false);
// Clear associated content to keep it clean
storage.remove(`kb:content:${selectedNode.id}`);
storage.remove(`quiz:bank:${selectedNode.id}`);
}
};
return (
<div className="relative w-full h-full flex flex-col md:flex-row">
<div ref={wrapperRef} className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing border-r border-bg-warm">
@@ -157,31 +179,82 @@ const KnowledgeGraph = () => {
</div>
{/* Node Info Panel */}
<div className="w-full md:w-80 bg-paper p-6 overflow-y-auto border-t md:border-t-0 border-bg-warm">
<h3 className="font-bold text-lg mb-4 text-teal">Node Details</h3>
<div className="w-full md:w-80 bg-paper p-6 overflow-y-auto border-t md:border-t-0 border-bg-warm flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-lg text-teal">Node Details</h3>
{selectedNode && !isEditing && (
<div className="flex items-center gap-1">
<button onClick={startEdit} className="p-1.5 text-fg-muted hover:text-teal rounded-[var(--r-sm)]" title="Edit">
<Edit2 size={16} />
</button>
<button onClick={deleteNode} className="p-1.5 text-fg-muted hover:text-red-500 rounded-[var(--r-sm)]" title="Delete">
<Trash2 size={16} />
</button>
</div>
)}
</div>
{selectedNode ? (
<div className="space-y-4">
<div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Label</p>
<p className="font-medium">{selectedNode.label}</p>
isEditing ? (
<div className="space-y-4 flex-1">
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Label</label>
<input
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg"
value={editData.label}
onChange={e => setEditData({...editData, label: e.target.value})}
/>
</div>
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Type</label>
<select
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg"
value={editData.type}
onChange={e => setEditData({...editData, type: e.target.value})}
>
<option value="concept">Concept</option>
<option value="role">Role</option>
<option value="process">Process</option>
<option value="fact">Fact</option>
</select>
</div>
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Description</label>
<textarea
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 text-sm bg-bg min-h-[100px]"
value={editData.description}
onChange={e => setEditData({...editData, description: e.target.value})}
/>
</div>
<div className="flex gap-2 pt-2">
<Button onClick={saveEdit} className="flex-1 flex items-center justify-center gap-2"><Save size={16}/> Save</Button>
<Button variant="outline" onClick={() => setIsEditing(false)} className="flex-1 flex items-center justify-center gap-2"><X size={16}/> Cancel</Button>
</div>
</div>
<div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Type</p>
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono">
{selectedNode.type}
</span>
) : (
<div className="space-y-4 flex-1">
<div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Label</p>
<p className="font-medium">{selectedNode.label}</p>
</div>
<div>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Type</p>
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono">
{selectedNode.type}
</span>
</div>
<div>
<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>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">ID</p>
<p className="text-xs font-mono text-fg-muted break-all">{selectedNode.id}</p>
</div>
</div>
<div>
<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>
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">ID</p>
<p className="text-xs font-mono text-fg-muted break-all">{selectedNode.id}</p>
</div>
</div>
)
) : (
<p className="text-sm text-fg-muted">Click a node in the graph to view its details.</p>
<p className="text-sm text-fg-muted">Click a node in the graph to view or edit its details.</p>
)}
</div>
</div>

View File

@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import { Users, UserPlus, Trash2, Edit2, Shield, User, CheckCircle } from 'lucide-react';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Input from '../ui/Input';
import Tag from '../ui/Tag';
import { storage } from '../../lib/storage';
import { useApp } from '../../store/AppContext';
const TeamManager = () => {
const { state } = useApp();
const [users, setUsers] = useState([]);
const [isEditing, setIsEditing] = useState(false);
const [editingId, setEditingId] = useState(null);
const [formData, setFormData] = useState({ name: '', role: 'user', pin: '' });
const [message, setMessage] = useState('');
const loadUsers = () => {
setUsers(storage.get('users:registry', []));
};
useEffect(() => { loadUsers(); }, []);
const handleSave = (e) => {
e.preventDefault();
if (!formData.name || !formData.pin) return;
let updatedUsers = [...users];
if (editingId) {
updatedUsers = updatedUsers.map(u => u.id === editingId ? { ...u, ...formData } : u);
setMessage('User updated successfully.');
} else {
const newUser = {
id: `u_${Date.now()}`,
name: formData.name,
role: formData.role,
pin: formData.pin,
registeredAt: new Date().toISOString()
};
updatedUsers.push(newUser);
setMessage('User added successfully.');
}
storage.set('users:registry', updatedUsers);
setUsers(updatedUsers);
setFormData({ name: '', role: 'user', pin: '' });
setIsEditing(false);
setEditingId(null);
// In a real app we'd trigger a global state update, but here the user has to refresh or re-login if they edit themselves.
setTimeout(() => setMessage(''), 3000);
};
const handleEdit = (user) => {
setFormData({ name: user.name, role: user.role, pin: user.pin });
setIsEditing(true);
setEditingId(user.id);
};
const handleDelete = (id) => {
if (id === state.currentUser?.id) {
alert("You cannot delete yourself.");
return;
}
if (confirm("Are you sure you want to delete this user?")) {
const updated = users.filter(u => u.id !== id);
storage.set('users:registry', updated);
setUsers(updated);
// Also remove them from leaderboard
const lb = storage.get('leaderboard:current', []);
storage.set('leaderboard:current', lb.filter(e => e.userId !== id));
setMessage('User deleted.');
setTimeout(() => setMessage(''), 3000);
}
};
const cancelEdit = () => {
setIsEditing(false);
setEditingId(null);
setFormData({ name: '', role: 'user', pin: '' });
};
return (
<div className="space-y-8">
{message && (
<div className="bg-teal-50 text-teal-800 p-3 rounded-[var(--r-sm)] flex items-center gap-2 text-sm">
<CheckCircle size={16} /> {message}
</div>
)}
{/* Form */}
<Card className="border border-bg-warm">
<h2 className="text-xl font-bold flex items-center gap-2 mb-4">
{isEditing ? <Edit2 size={20} className="text-teal" /> : <UserPlus size={20} className="text-teal" />}
{isEditing ? 'Edit Team Member' : 'Add New Team Member'}
</h2>
<form onSubmit={handleSave} className="flex flex-col sm:flex-row gap-4 items-end">
<div className="flex-1 w-full">
<Input
label="Full Name"
placeholder="e.g. Jane Doe"
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
required
/>
</div>
<div className="flex-1 w-full">
<label className="block text-sm font-medium mb-1.5 text-fg">Role</label>
<select
value={formData.role}
onChange={e => setFormData({...formData, role: e.target.value})}
className="w-full h-11 px-3 rounded-[var(--r-sm)] border border-bg-warm bg-paper text-sm focus:outline-none focus:ring-2 focus:ring-teal/30 focus:border-teal"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="flex-1 w-full">
<Input
label="Login PIN"
type="text"
placeholder="e.g. 1234"
value={formData.pin}
onChange={e => setFormData({...formData, pin: e.target.value})}
required
/>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<Button type="submit" className="w-full sm:w-auto h-11 px-6">
{isEditing ? 'Update' : 'Add'}
</Button>
{isEditing && (
<Button type="button" variant="outline" onClick={cancelEdit} className="h-11 px-4">
Cancel
</Button>
)}
</div>
</form>
</Card>
{/* List */}
<Card className="p-0 border border-bg-warm overflow-hidden">
<div className="divide-y divide-bg-warm">
{users.map(user => (
<div key={user.id} className="p-4 flex items-center justify-between hover:bg-bg-warm/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-teal/10 text-teal'}`}>
{user.role === 'admin' ? <Shield size={18} /> : <User size={18} />}
</div>
<div>
<p className="font-medium flex items-center gap-2">
{user.name}
{user.id === state.currentUser?.id && <Tag variant="accent" className="text-[10px] py-0">You</Tag>}
</p>
<p className="text-sm text-fg-muted">PIN: {user.pin}</p>
</div>
</div>
<div className="flex items-center gap-3">
<Tag variant={user.role === 'admin' ? 'dark' : 'default'} className="hidden sm:inline-flex">{user.role}</Tag>
<button onClick={() => handleEdit(user)} className="p-2 text-fg-muted hover:text-teal transition-colors">
<Edit2 size={16} />
</button>
<button
onClick={() => handleDelete(user.id)}
disabled={user.id === state.currentUser?.id}
className="p-2 text-fg-muted hover:text-red-500 disabled:opacity-30 transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
</Card>
</div>
);
};
export default TeamManager;