feat: implement AI-driven learning content generation service and interactive leaderboard functionality
This commit is contained in:
58
AI_AGENT.md
Normal file
58
AI_AGENT.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# AI Agent Context Guide: Respellion Learning Platform
|
||||
|
||||
Welcome, fellow AI agent! If you are reading this, you are tasked with maintaining or extending the Respellion Learning Platform. This document provides the critical context, architectural patterns, and design standards you need to successfully work on this codebase.
|
||||
|
||||
## 1. Architectural Overview
|
||||
This is a single-page React application built with **Vite**. It is completely self-contained and currently runs without a dedicated backend database.
|
||||
* **Frontend:** React, React Router, Tailwind CSS.
|
||||
* **Animations:** Framer Motion (used extensively for page transitions, podium effects, and gamification feedback).
|
||||
* **Icons:** Lucide React.
|
||||
* **Visualizations:** D3.js (used strictly for the Admin Knowledge Graph).
|
||||
|
||||
## 2. State Management & Storage (Critical)
|
||||
There is **no backend database**. All data is persisted locally in the browser using a custom wrapper around `window.localStorage` located at `src/lib/storage.js`.
|
||||
|
||||
**Namespacing Convention:**
|
||||
You must strictly adhere to these prefixes when reading/writing to `storage.js`:
|
||||
* `kb:*` - Knowledge base data (e.g., `kb:topics`, `kb:relations`, `kb:content:{topicId}`).
|
||||
* `admin:*` - Admin settings (e.g., `admin:anthropic_key`, `admin:sources`, `admin:use_simulation`).
|
||||
* `user:{id}:*` - Specific user progress (e.g., `user:{id}:week:{n}:learn_done`).
|
||||
* `quiz:bank:{topicId}` - Cached banks of AI-generated multiple-choice questions.
|
||||
* `quiz:result:{userId}:week:{n}` - The exact score and payload of a user's weekly test.
|
||||
* `leaderboard:current` - The active points ledger. Array of objects: `{ userId, name, points, testsCompleted, perfectScores }`.
|
||||
* `users:registry` - Array of registered users, including admins.
|
||||
|
||||
## 3. The AI Integration (Anthropic)
|
||||
The application acts as a proxy to the Anthropic API (`claude-sonnet-4`).
|
||||
* **Location:** `src/lib/api.js`.
|
||||
* **Proxy:** In Docker, `/api/anthropic` is proxied via Nginx to bypass CORS restrictions. If the user reports CORS errors during local dev, ensure the Vite proxy in `vite.config.js` matches the Nginx spec.
|
||||
* **JSON Enforcement:** Prompts *must* strictly enforce that Claude returns *only* raw JSON. Do not let the AI wrap the response in markdown blocks (`\`\`\``) unless you build a regex to strip them (which is currently implemented via `.match(/\{[\s\S]*\}/)`).
|
||||
|
||||
## 4. Design System & Aesthetics
|
||||
Respellion relies on a premium, modern aesthetic.
|
||||
* **CSS Variables:** Rely heavily on the variables defined in `src/index.css`.
|
||||
* Colors: `var(--color-bg)`, `var(--color-paper)`, `var(--color-teal)`, `var(--color-accent)`.
|
||||
* Radii: `var(--r-sm)`, `var(--r-lg)`, `var(--r-org)` (an organic, pill-like shape used for badges and podiums).
|
||||
* **Tailwind:** Tailwind is mapped to these CSS variables. Avoid hardcoding random hex codes. Use the existing Tailwind classes like `bg-teal`, `text-fg-muted`, and `border-bg-warm`.
|
||||
* **Components:** Always reuse the UI primitives in `src/components/ui/` (`Card.jsx`, `Button.jsx`, `Tag.jsx`, `Input.jsx`).
|
||||
|
||||
## 5. Gamification Rules
|
||||
If you are extending the Gamification system (`src/pages/Leaderboard.jsx`):
|
||||
* Tests grant **+2 points** per correct answer.
|
||||
* A 100% score grants the **Perfectionist** badge.
|
||||
* Completing 1 test grants **First Steps**, completing 5 grants **Veteran**.
|
||||
* Do not reset points dynamically. Read from `leaderboard:current`, mutate, and save back immediately.
|
||||
|
||||
## 6. Docker & Deployment
|
||||
The app is fully containerized.
|
||||
* **Build:** `docker build -t respellion-app:latest .`
|
||||
* **Run:** `docker run -d -p 8080:80 --name respellion respellion-app:latest`
|
||||
* **Nginx:** Ensure any new frontend routes are caught by the Nginx `try_files $uri $uri/ /index.html;` directive defined in `nginx.conf` to prevent 404s on page refresh.
|
||||
|
||||
## 7. How to Add New Features
|
||||
1. **Define State:** Decide where the data lives in `localStorage`.
|
||||
2. **Build UI:** Create components using `Card`, `Button`, and `framer-motion` for smooth entry (`animate-in fade-in slide-in-from-bottom-4`).
|
||||
3. **Wire Logic:** Connect to `src/store/AppContext.jsx` if it requires global user context, otherwise manage locally and persist to `storage.js`.
|
||||
4. **Test:** Run the Docker container to ensure no build errors exist.
|
||||
|
||||
Good luck! You are building a platform that empowers continuous learning. Keep it fast, keep it beautiful, and keep the user engaged.
|
||||
@@ -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