feat: implement AI-driven learning content generation service and interactive leaderboard functionality
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user