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>
|
||||
|
||||
185
src/components/admin/TeamManager.jsx
Normal file
185
src/components/admin/TeamManager.jsx
Normal 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;
|
||||
@@ -12,18 +12,18 @@ export const anthropicApi = {
|
||||
// Check if simulation mode is on
|
||||
const useSimulation = storage.get('admin:use_simulation') === true;
|
||||
if (useSimulation) {
|
||||
console.log('[API] Simulatie mode actief. Mock data wordt geretourneerd.');
|
||||
console.log('[API] Simulation mode active. Mock data will be returned.');
|
||||
return await simulateResponse();
|
||||
}
|
||||
|
||||
const apiKey = storage.get('admin:anthropic_key') || import.meta.env.VITE_ANTHROPIC_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('Geen Anthropic API key gevonden. Ga naar Admin -> Settings.');
|
||||
throw new Error('No Anthropic API key found. Please configure it in Admin -> Settings.');
|
||||
}
|
||||
|
||||
// Model is configurable from Admin > Settings, defaults to the original spec model
|
||||
const model = storage.get('admin:model') || DEFAULT_MODEL;
|
||||
console.log(`[API] Aanroep met model: ${model}`);
|
||||
console.log(`[API] Calling with model: ${model}`);
|
||||
|
||||
let retries = 0;
|
||||
while (retries <= maxRetries) {
|
||||
|
||||
@@ -156,3 +156,38 @@ Apply the refinement and return the complete updated JSON object using the same
|
||||
export function deleteCachedContent(topicId) {
|
||||
storage.remove(getContentCacheKey(topicId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new custom topic metadata on the fly from user input.
|
||||
*/
|
||||
export async function generateCustomTopic(label) {
|
||||
const prompt = `A user wants to learn about "${label}".
|
||||
Create a short description (2-3 sentences) and categorize it.
|
||||
|
||||
Return ONLY a JSON object with this structure:
|
||||
{
|
||||
"label": "Polished topic title",
|
||||
"type": "concept", // one of: concept, role, process, fact
|
||||
"description": "Short description"
|
||||
}`;
|
||||
|
||||
const responseText = await anthropicApi.generateContent(
|
||||
"You are a knowledge graph AI categorizing topics.",
|
||||
prompt
|
||||
);
|
||||
|
||||
let newTopic;
|
||||
try {
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||
newTopic = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
|
||||
newTopic.id = 'custom_' + Date.now().toString(36);
|
||||
} catch (e) {
|
||||
throw new Error('Could not process custom topic. Please try again.');
|
||||
}
|
||||
|
||||
// Add to global knowledge base so others can see it too
|
||||
const topics = storage.get('kb:topics', []);
|
||||
storage.set('kb:topics', [...topics, newTopic]);
|
||||
|
||||
return newTopic;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import UploadZone from '../../components/admin/UploadZone';
|
||||
import KnowledgeGraph from '../../components/admin/KnowledgeGraph';
|
||||
import ContentManager from '../../components/admin/ContentManager';
|
||||
import TestManager from '../../components/admin/TestManager';
|
||||
import TeamManager from '../../components/admin/TeamManager';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
|
||||
const Admin = () => {
|
||||
const [activeTab, setActiveTab] = useState('sources');
|
||||
@@ -42,6 +44,14 @@ const Admin = () => {
|
||||
setTimeout(() => setSaveStatus(null), 3000);
|
||||
};
|
||||
|
||||
const handleDeleteSource = (id) => {
|
||||
if (confirm('Are you sure you want to delete this source? This will not delete topics already extracted.')) {
|
||||
const updated = sources.filter(s => s.id !== id);
|
||||
storage.set('admin:sources', updated);
|
||||
setSources(updated);
|
||||
}
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ key: 'sources', icon: Database, label: 'Sources' },
|
||||
{ key: 'content', icon: Layers, label: 'Content' },
|
||||
@@ -103,10 +113,13 @@ const Admin = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
{source.status === 'completed' && <Tag variant="success" className="flex items-center gap-1"><CheckCircle2 size={12}/> Completed</Tag>}
|
||||
{source.status === 'processing' && <Tag variant="accent" className="flex items-center gap-1"><Clock size={12}/> Processing</Tag>}
|
||||
{source.status === 'failed' && <Tag variant="dark" className="bg-red-100 text-red-800 flex items-center gap-1"><AlertCircle size={12}/> Failed</Tag>}
|
||||
<button onClick={() => handleDeleteSource(source.id)} className="p-1.5 text-fg-muted hover:text-red-500 hover:bg-red-50 rounded-[var(--r-sm)] transition-colors" title="Delete Source">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -147,11 +160,10 @@ const Admin = () => {
|
||||
|
||||
{/* ── Team ────────────────────────────────── */}
|
||||
{activeTab === 'team' && (
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl text-teal mb-2">Team Management</h1>
|
||||
<Card className="border border-bg-warm">
|
||||
<p className="text-fg-muted">Team members can be added and managed here.</p>
|
||||
</Card>
|
||||
<p className="text-fg-muted mb-8">Manage team members, roles, and login PINs.</p>
|
||||
<TeamManager />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,10 +5,38 @@ import Card from '../components/ui/Card';
|
||||
import Button from '../components/ui/Button';
|
||||
import Tag from '../components/ui/Tag';
|
||||
|
||||
import { storage } from '../lib/storage';
|
||||
import { getAssignedTopic } from '../lib/learningService';
|
||||
import { getTestResult } from '../lib/testService';
|
||||
|
||||
const Dashboard = () => {
|
||||
const { state } = useApp();
|
||||
const { currentUser, weekNumber } = state;
|
||||
|
||||
const topic = getAssignedTopic(currentUser?.id, weekNumber);
|
||||
const learnDone = storage.get(`user:${currentUser?.id}:week:${weekNumber}:learn_done`, false);
|
||||
const testResult = getTestResult(currentUser?.id, weekNumber);
|
||||
|
||||
const leaderboard = storage.get('leaderboard:current', []).sort((a, b) => b.points - a.points);
|
||||
const top3 = leaderboard.slice(0, 3);
|
||||
const myRank = leaderboard.findIndex(u => u.userId === currentUser?.id) + 1;
|
||||
const myPoints = leaderboard.find(u => u.userId === currentUser?.id)?.points || 0;
|
||||
|
||||
// Gather recent activity from past weeks
|
||||
const activity = [];
|
||||
for (let w = weekNumber; w >= Math.max(1, weekNumber - 3); w--) {
|
||||
const pastLearn = storage.get(`user:${currentUser?.id}:week:${w}:learn_done`, false);
|
||||
const pastTest = getTestResult(currentUser?.id, w);
|
||||
const pastTopic = getAssignedTopic(currentUser?.id, w);
|
||||
|
||||
if (pastTest) {
|
||||
activity.push({ type: 'test', week: w, topic: pastTopic?.label, score: pastTest.percentage, points: pastTest.pointsEarned });
|
||||
}
|
||||
if (pastLearn) {
|
||||
activity.push({ type: 'learn', week: w, topic: pastTopic?.label });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<header>
|
||||
@@ -23,11 +51,13 @@ const Dashboard = () => {
|
||||
<h3 className="text-xl">Learning</h3>
|
||||
<p className="text-fg-muted text-sm mt-1">Your topic this week:</p>
|
||||
</div>
|
||||
<Tag variant="accent">To Do</Tag>
|
||||
{learnDone ? <Tag variant="success">Completed</Tag> : <Tag variant="accent">To Do</Tag>}
|
||||
</div>
|
||||
<h2 className="text-2xl mt-2 mb-6">The Role of the Product Owner</h2>
|
||||
<Link to="/learn">
|
||||
<Button className="mt-auto w-full">Start Learning Session</Button>
|
||||
<h2 className="text-2xl mt-2 mb-6">{topic ? topic.label : 'No topic assigned'}</h2>
|
||||
<Link to="/learn" className="mt-auto">
|
||||
<Button className="w-full" variant={learnDone ? 'outline' : 'primary'}>
|
||||
{learnDone ? 'Review Learning Material' : 'Start Learning Session'}
|
||||
</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
@@ -37,12 +67,20 @@ const Dashboard = () => {
|
||||
<h3 className="text-xl">Testing</h3>
|
||||
<p className="text-fg-muted text-sm mt-1">Weekly test — 10 questions</p>
|
||||
</div>
|
||||
<Tag variant="default">To Do</Tag>
|
||||
{testResult ? <Tag variant="success">Score: {testResult.percentage}%</Tag> : <Tag variant="default">To Do</Tag>}
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center py-6 text-fg-subtle">
|
||||
Complete your learning session first
|
||||
{!learnDone ? 'Complete your learning session first' : testResult ? 'Test completed for this week' : 'Ready to test your knowledge'}
|
||||
</div>
|
||||
<Button variant="outline" className="mt-auto" disabled>Start Test</Button>
|
||||
{learnDone && !testResult ? (
|
||||
<Link to="/test" className="mt-auto">
|
||||
<Button className="w-full">Start Test</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/test" className="mt-auto">
|
||||
<Button variant="outline" className="w-full">{testResult ? 'View Results' : 'Start Test'}</Button>
|
||||
</Link>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -51,26 +89,26 @@ const Dashboard = () => {
|
||||
<h3 className="text-2xl mb-4">Recent Activity</h3>
|
||||
<Card className="p-0 overflow-hidden border border-bg-warm">
|
||||
<div className="divide-y divide-bg-warm">
|
||||
<div className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
|
||||
<div className="w-10 h-10 rounded-[var(--r-org)] bg-accent-soft flex items-center justify-center text-purple-700 font-bold">
|
||||
T
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Test completed: Information Security</p>
|
||||
<p className="text-sm text-fg-muted">Last week · Score: 90%</p>
|
||||
</div>
|
||||
<div className="ml-auto text-teal font-bold">+15 pts</div>
|
||||
</div>
|
||||
<div className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
|
||||
<div className="w-10 h-10 rounded-[var(--r-org)] bg-sage flex items-center justify-center text-teal-900 font-bold">
|
||||
L
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Learning session: Information Security</p>
|
||||
<p className="text-sm text-fg-muted">Last week</p>
|
||||
</div>
|
||||
<div className="ml-auto text-teal font-bold">+5 pts</div>
|
||||
</div>
|
||||
{activity.length === 0 ? (
|
||||
<div className="p-8 text-center text-fg-muted">No recent activity.</div>
|
||||
) : (
|
||||
activity.slice(0, 5).map((act, i) => (
|
||||
<div key={i} className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
|
||||
<div className={`w-10 h-10 rounded-[var(--r-org)] flex items-center justify-center font-bold ${
|
||||
act.type === 'test' ? 'bg-accent-soft text-purple-700' : 'bg-sage text-teal-900'
|
||||
}`}>
|
||||
{act.type === 'test' ? 'T' : 'L'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{act.type === 'test' ? 'Test completed' : 'Learning session'}: {act.topic}</p>
|
||||
<p className="text-sm text-fg-muted">
|
||||
Week {act.week} {act.type === 'test' && `· Score: ${act.score}%`}
|
||||
</p>
|
||||
</div>
|
||||
{act.points > 0 && <div className="ml-auto text-teal font-bold">+{act.points} pts</div>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -79,20 +117,28 @@ const Dashboard = () => {
|
||||
<h3 className="text-2xl mb-4">Mini Leaderboard</h3>
|
||||
<Card className="border border-bg-warm">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-bold text-accent">1.</span>
|
||||
<span className="font-medium">Admin</span>
|
||||
{top3.length === 0 ? (
|
||||
<div className="text-center text-fg-muted py-4">No points yet</div>
|
||||
) : (
|
||||
top3.map((u, i) => (
|
||||
<div key={u.userId} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`font-mono font-bold ${i === 0 ? 'text-yellow-500' : 'text-teal'}`}>{i + 1}.</span>
|
||||
<span className="font-medium">{u.name} {u.userId === currentUser?.id && '(You)'}</span>
|
||||
</div>
|
||||
<Tag variant="dark">{u.points} pts</Tag>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{myRank > 3 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-bg-warm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-bold text-fg-muted">{myRank}.</span>
|
||||
<span className="font-medium text-teal">You</span>
|
||||
</div>
|
||||
<Tag variant="default">{myPoints} pts</Tag>
|
||||
</div>
|
||||
<Tag variant="dark">120 pts</Tag>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-bold text-teal">2.</span>
|
||||
<span className="font-medium">{currentUser?.name}</span>
|
||||
</div>
|
||||
<Tag variant="default">85 pts</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/leaderboard">
|
||||
<Button variant="ghost" className="w-full mt-4 text-sm">View full leaderboard</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Trophy, Medal, Award, TrendingUp, Star } from 'lucide-react';
|
||||
import { Trophy, Medal, Award, TrendingUp, Star, CheckSquare } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Card from '../components/ui/Card';
|
||||
import Tag from '../components/ui/Tag';
|
||||
|
||||
@@ -1,43 +1,67 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BookOpen, CheckCircle, Loader, ArrowRight } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { BookOpen, CheckCircle, Loader, ArrowRight, Plus, Search, ChevronLeft } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Card from '../components/ui/Card';
|
||||
import Button from '../components/ui/Button';
|
||||
import Tag from '../components/ui/Tag';
|
||||
import Input from '../components/ui/Input';
|
||||
import LearningContentViewer from '../components/ui/LearningContentViewer';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import { getAssignedTopic, generateLearningContent, getCachedContent } from '../lib/learningService';
|
||||
import { getAssignedTopic, generateLearningContent, getCachedContent, generateCustomTopic } from '../lib/learningService';
|
||||
import { storage } from '../lib/storage';
|
||||
|
||||
const Leren = () => {
|
||||
const { state } = useApp();
|
||||
const [topic, setTopic] = useState(null);
|
||||
const [assignedTopic, setAssignedTopic] = useState(null);
|
||||
const [allTopics, setAllTopics] = useState([]);
|
||||
|
||||
// View state
|
||||
const [view, setView] = useState('overview'); // overview, detail, creating
|
||||
const [activeTopic, setActiveTopic] = useState(null);
|
||||
const [content, setContent] = useState(null);
|
||||
|
||||
// Loading & Error
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
// Custom Topic
|
||||
const [customTopicQuery, setCustomTopicQuery] = useState('');
|
||||
|
||||
// Weekly status
|
||||
const [weeklyDone, setWeeklyDone] = useState(false);
|
||||
const [sessionDone, setSessionDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.currentUser) {
|
||||
const assigned = getAssignedTopic(state.currentUser.id, state.weekNumber);
|
||||
setTopic(assigned);
|
||||
if (assigned) {
|
||||
const cached = getCachedContent(assigned.id);
|
||||
if (cached) setContent(cached);
|
||||
}
|
||||
// Check if already completed this week
|
||||
setAssignedTopic(assigned);
|
||||
setAllTopics(storage.get('kb:topics', []));
|
||||
|
||||
const done = storage.get(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, false);
|
||||
if (done) setCompleted(true);
|
||||
if (done) setWeeklyDone(true);
|
||||
}
|
||||
}, [state.currentUser, state.weekNumber]);
|
||||
|
||||
const handleOpenTopic = (topic) => {
|
||||
setActiveTopic(topic);
|
||||
setView('detail');
|
||||
setSessionDone(false);
|
||||
setError(null);
|
||||
const cached = getCachedContent(topic.id);
|
||||
if (cached) {
|
||||
setContent(cached);
|
||||
} else {
|
||||
setContent(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadContent = async () => {
|
||||
if (!topic) return;
|
||||
if (!activeTopic) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const generated = await generateLearningContent(topic);
|
||||
const generated = await generateLearningContent(activeTopic);
|
||||
setContent(generated);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
@@ -46,90 +70,208 @@ const Leren = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
setCompleted(true);
|
||||
storage.set(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, true);
|
||||
const handleCreateCustom = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!customTopicQuery.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setView('creating');
|
||||
setError(null);
|
||||
try {
|
||||
const newTopic = await generateCustomTopic(customTopicQuery);
|
||||
setAllTopics(storage.get('kb:topics', []));
|
||||
setCustomTopicQuery('');
|
||||
handleOpenTopic(newTopic);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
setView('overview');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── No topics available ───────────────────────────────────
|
||||
if (!topic) {
|
||||
const handleComplete = () => {
|
||||
setSessionDone(true);
|
||||
if (!weeklyDone) {
|
||||
setWeeklyDone(true);
|
||||
storage.set(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, true);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Detail View ──────────────────────────────────────────
|
||||
if (view === 'detail' && activeTopic) {
|
||||
if (sessionDone) {
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
|
||||
<CheckCircle size={80} className="mx-auto text-teal mb-6" />
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold mb-4">Learning session complete!</h1>
|
||||
<p className="text-fg-muted mb-8">
|
||||
You have successfully reviewed "<strong>{activeTopic.label}</strong>".
|
||||
{weeklyDone && ' Your weekly minimum is met.'}
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button variant="outline" onClick={() => setView('overview')}>Learn Another</Button>
|
||||
<Link to="/test">
|
||||
<Button>Start Weekly Test <ArrowRight size={18} className="ml-2" /></Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||
<BookOpen size={64} className="mx-auto text-teal/30 mb-6" />
|
||||
<h1 className="text-3xl text-teal font-bold mb-4">Learning Station</h1>
|
||||
<p className="text-fg-muted">No knowledge topics are available yet. Ask an admin to upload source material.</p>
|
||||
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
|
||||
<button onClick={() => setView('overview')} className="flex items-center gap-2 text-fg-muted hover:text-teal mb-6 transition-colors">
|
||||
<ChevronLeft size={16} /> Back to overview
|
||||
</button>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Tag variant="dark" className="font-mono text-xs">{activeTopic.type}</Tag>
|
||||
{activeTopic.id === assignedTopic?.id && <Tag variant="accent">Weekly Required</Tag>}
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-teal">{activeTopic.label}</h1>
|
||||
<p className="text-fg-muted mt-2">{activeTopic.description}</p>
|
||||
</div>
|
||||
|
||||
{!content && !isLoading && (
|
||||
<Card className="border border-bg-warm text-center py-16">
|
||||
<BookOpen size={48} className="mx-auto text-teal/30 mb-4" />
|
||||
<p className="text-fg-muted mb-6">Click the button to generate personalized AI learning content for this topic.</p>
|
||||
<Button onClick={loadContent}>Generate Learning Content</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<Card className="border border-bg-warm text-center py-16">
|
||||
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||
<p className="font-medium">AI is generating your learning module...</p>
|
||||
<p className="text-fg-muted text-sm mt-2">This may take 10–30 seconds.</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card className="border border-red-200 bg-red-50 text-red-900 p-6">
|
||||
<p className="font-bold mb-1">Generation failed</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
<Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700">Try again</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{content && <LearningContentViewer content={content} topic={activeTopic} />}
|
||||
|
||||
{content && (
|
||||
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end">
|
||||
<Button onClick={handleComplete}>
|
||||
<CheckCircle size={18} className="mr-2" /> Complete Session
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Session completed ─────────────────────────────────────
|
||||
if (completed) {
|
||||
// ── Creating Topic Loading ─────────────────────────────────
|
||||
if (view === 'creating') {
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
|
||||
<CheckCircle size={80} className="mx-auto text-teal mb-6" />
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold mb-4">Learning session complete!</h1>
|
||||
<p className="text-fg-muted mb-8">
|
||||
You have successfully reviewed "<strong>{topic.label}</strong>". Now take the weekly test!
|
||||
</p>
|
||||
<Link to="/test">
|
||||
<Button>Start Weekly Test <ArrowRight size={18} className="ml-2" /></Button>
|
||||
</Link>
|
||||
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||
<h1 className="text-2xl font-bold text-teal mb-2">Architecting New Topic</h1>
|
||||
<p className="text-fg-muted">The AI is creating a structure for "{customTopicQuery}"...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Overview ──────────────────────────────────────────────
|
||||
const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id);
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Tag variant="accent">Week {state.weekNumber}</Tag>
|
||||
<Tag variant="dark" className="font-mono text-xs">{topic.type}</Tag>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-teal">{topic.label}</h1>
|
||||
<p className="text-fg-muted mt-2">{topic.description}</p>
|
||||
<div className="p-4 md:p-8 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in duration-300">
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-3">Learning Station</h1>
|
||||
<p className="text-fg-muted text-lg">
|
||||
You must complete at least 1 topic per week. Feel free to explore more or create your own!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Generate prompt */}
|
||||
{!content && !isLoading && (
|
||||
<Card className="border border-bg-warm text-center py-16">
|
||||
<BookOpen size={48} className="mx-auto text-teal/30 mb-4" />
|
||||
<p className="text-fg-muted mb-6">Click the button to generate your personalized AI learning content.</p>
|
||||
<Button onClick={loadContent}>Generate Learning Content</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<Card className="border border-bg-warm text-center py-16">
|
||||
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||
<p className="font-medium">AI is generating your personalized learning content...</p>
|
||||
<p className="text-fg-muted text-sm mt-2">This may take 10–30 seconds.</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Card className="border border-red-200 bg-red-50 text-red-900 p-6">
|
||||
<p className="font-bold mb-1">Generation failed</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
<Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700">
|
||||
Try again
|
||||
</Button>
|
||||
</Card>
|
||||
<div className="mb-6 bg-red-50 text-red-800 p-4 rounded-[var(--r-sm)] border border-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Viewer */}
|
||||
{content && <LearningContentViewer content={content} topic={topic} />}
|
||||
{/* Required Topic */}
|
||||
{assignedTopic && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
Weekly Assignment {weeklyDone && <CheckCircle size={20} className="text-teal" />}
|
||||
</h2>
|
||||
<Card
|
||||
hoverable
|
||||
className={`border-2 cursor-pointer transition-all ${weeklyDone ? 'border-teal/30 bg-teal/5' : 'border-teal shadow-md'}`}
|
||||
onClick={() => handleOpenTopic(assignedTopic)}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<Tag variant={weeklyDone ? 'success' : 'accent'} className="mb-2">
|
||||
{weeklyDone ? 'Completed' : 'Required'}
|
||||
</Tag>
|
||||
<h3 className="text-2xl font-bold text-teal">{assignedTopic.label}</h3>
|
||||
<p className="text-fg-muted mt-1">{assignedTopic.description}</p>
|
||||
</div>
|
||||
<Button className="whitespace-nowrap flex-shrink-0">
|
||||
{weeklyDone ? 'Review' : 'Start Learning'} <ArrowRight size={18} className="ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Complete button */}
|
||||
{content && !completed && (
|
||||
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end">
|
||||
<Button onClick={handleComplete}>
|
||||
<CheckCircle size={18} className="mr-2" /> Complete Session
|
||||
</Button>
|
||||
{/* Create Custom Topic */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl font-bold mb-4">Explore Something New</h2>
|
||||
<Card className="border border-bg-warm bg-paper">
|
||||
<form onSubmit={handleCreateCustom} className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="What do you want to learn about today? (e.g. Advanced Git Workflows)"
|
||||
value={customTopicQuery}
|
||||
onChange={e => setCustomTopicQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={!customTopicQuery.trim() || isLoading} className="flex-shrink-0">
|
||||
<Plus size={18} className="mr-2" /> Generate Topic
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-xs text-fg-muted mt-3 flex items-center gap-1">
|
||||
<BookOpen size={12}/> The AI will instantly build a new learning module for anything you type.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Other Available Topics */}
|
||||
{otherTopics.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-4">Knowledge Base Library</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{otherTopics.map(topic => (
|
||||
<Card
|
||||
key={topic.id}
|
||||
hoverable
|
||||
className="border border-bg-warm cursor-pointer flex flex-col h-full"
|
||||
onClick={() => handleOpenTopic(topic)}
|
||||
>
|
||||
<Tag variant="dark" className="self-start text-[10px] mb-2">{topic.type}</Tag>
|
||||
<h3 className="font-bold text-lg mb-1">{topic.label}</h3>
|
||||
<p className="text-sm text-fg-muted line-clamp-2 mb-4 flex-1">{topic.description}</p>
|
||||
<div className="flex items-center text-teal font-medium text-sm mt-auto group">
|
||||
Learn <ArrowRight size={14} className="ml-1 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user