diff --git a/AI_AGENT.md b/AI_AGENT.md
new file mode 100644
index 0000000..7ca56be
--- /dev/null
+++ b/AI_AGENT.md
@@ -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.
diff --git a/src/components/admin/KnowledgeGraph.jsx b/src/components/admin/KnowledgeGraph.jsx
index b2f336b..efc74e5 100644
--- a/src/components/admin/KnowledgeGraph.jsx
+++ b/src/components/admin/KnowledgeGraph.jsx
@@ -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 (
@@ -157,31 +179,82 @@ const KnowledgeGraph = () => {
{/* Node Info Panel */}
-
-
Node Details
+
+
+
Node Details
+ {selectedNode && !isEditing && (
+
+
+
+
+
+
+
+
+ )}
+
+
{selectedNode ? (
-
-
-
Label
-
{selectedNode.label}
+ isEditing ? (
+
+
+ Label
+ setEditData({...editData, label: e.target.value})}
+ />
+
+
+ Type
+ setEditData({...editData, type: e.target.value})}
+ >
+ Concept
+ Role
+ Process
+ Fact
+
+
+
+ Description
+
+
+ Save
+ setIsEditing(false)} className="flex-1 flex items-center justify-center gap-2"> Cancel
+
-
-
Type
-
- {selectedNode.type}
-
+ ) : (
+
+
+
Label
+
{selectedNode.label}
+
+
+
Type
+
+ {selectedNode.type}
+
+
+
+
Description
+
{selectedNode.description}
+
+
+
ID
+
{selectedNode.id}
+
-
-
Description
-
{selectedNode.description}
-
-
-
ID
-
{selectedNode.id}
-
-
+ )
) : (
-
Click a node in the graph to view its details.
+
Click a node in the graph to view or edit its details.
)}
diff --git a/src/components/admin/TeamManager.jsx b/src/components/admin/TeamManager.jsx
new file mode 100644
index 0000000..050cc29
--- /dev/null
+++ b/src/components/admin/TeamManager.jsx
@@ -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 (
+
+ {message && (
+
+ {message}
+
+ )}
+
+ {/* Form */}
+
+
+ {isEditing ? : }
+ {isEditing ? 'Edit Team Member' : 'Add New Team Member'}
+
+
+
+
+
+ {/* List */}
+
+
+ {users.map(user => (
+
+
+
+ {user.role === 'admin' ? : }
+
+
+
+ {user.name}
+ {user.id === state.currentUser?.id && You }
+
+
PIN: {user.pin}
+
+
+
+
+ {user.role}
+ handleEdit(user)} className="p-2 text-fg-muted hover:text-teal transition-colors">
+
+
+ handleDelete(user.id)}
+ disabled={user.id === state.currentUser?.id}
+ className="p-2 text-fg-muted hover:text-red-500 disabled:opacity-30 transition-colors"
+ >
+
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default TeamManager;
diff --git a/src/lib/api.js b/src/lib/api.js
index 5aeefd8..0d244c6 100644
--- a/src/lib/api.js
+++ b/src/lib/api.js
@@ -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) {
diff --git a/src/lib/learningService.js b/src/lib/learningService.js
index 2d4d7d8..11457c5 100644
--- a/src/lib/learningService.js
+++ b/src/lib/learningService.js
@@ -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;
+}
diff --git a/src/pages/Admin/index.jsx b/src/pages/Admin/index.jsx
index d170f95..eece422 100644
--- a/src/pages/Admin/index.jsx
+++ b/src/pages/Admin/index.jsx
@@ -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 = () => {
-
+
{source.status === 'completed' &&
Completed }
{source.status === 'processing' &&
Processing }
{source.status === 'failed' &&
Failed }
+
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">
+
+
))
@@ -147,11 +160,10 @@ const Admin = () => {
{/* ── Team ────────────────────────────────── */}
{activeTab === 'team' && (
-
+
Team Management
-
- Team members can be added and managed here.
-
+
Manage team members, roles, and login PINs.
+
)}
diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx
index 628dcc1..6720b28 100644
--- a/src/pages/Dashboard.jsx
+++ b/src/pages/Dashboard.jsx
@@ -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 (
@@ -23,11 +51,13 @@ const Dashboard = () => {
Learning
Your topic this week:
-
To Do
+ {learnDone ?
Completed :
To Do }
-
The Role of the Product Owner
-
-
Start Learning Session
+
{topic ? topic.label : 'No topic assigned'}
+
+
+ {learnDone ? 'Review Learning Material' : 'Start Learning Session'}
+
@@ -37,12 +67,20 @@ const Dashboard = () => {
Testing
Weekly test — 10 questions
- To Do
+ {testResult ? Score: {testResult.percentage}% : To Do }
- Complete your learning session first
+ {!learnDone ? 'Complete your learning session first' : testResult ? 'Test completed for this week' : 'Ready to test your knowledge'}
- Start Test
+ {learnDone && !testResult ? (
+
+ Start Test
+
+ ) : (
+
+ {testResult ? 'View Results' : 'Start Test'}
+
+ )}
@@ -51,26 +89,26 @@ const Dashboard = () => {
Recent Activity
-
-
- T
-
-
-
Test completed: Information Security
-
Last week · Score: 90%
-
-
+15 pts
-
-
-
- L
-
-
-
Learning session: Information Security
-
Last week
-
-
+5 pts
-
+ {activity.length === 0 ? (
+
No recent activity.
+ ) : (
+ activity.slice(0, 5).map((act, i) => (
+
+
+ {act.type === 'test' ? 'T' : 'L'}
+
+
+
{act.type === 'test' ? 'Test completed' : 'Learning session'}: {act.topic}
+
+ Week {act.week} {act.type === 'test' && `· Score: ${act.score}%`}
+
+
+ {act.points > 0 &&
+{act.points} pts
}
+
+ ))
+ )}
@@ -79,20 +117,28 @@ const Dashboard = () => {
Mini Leaderboard
-
-
-
1.
-
Admin
+ {top3.length === 0 ? (
+
No points yet
+ ) : (
+ top3.map((u, i) => (
+
+
+ {i + 1}.
+ {u.name} {u.userId === currentUser?.id && '(You)'}
+
+
{u.points} pts
+
+ ))
+ )}
+ {myRank > 3 && (
+
+
+ {myRank}.
+ You
+
+
{myPoints} pts
-
120 pts
-
-
-
- 2.
- {currentUser?.name}
-
-
85 pts
-
+ )}
View full leaderboard
diff --git a/src/pages/Leaderboard.jsx b/src/pages/Leaderboard.jsx
index 2940aac..d26e2c8 100644
--- a/src/pages/Leaderboard.jsx
+++ b/src/pages/Leaderboard.jsx
@@ -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';
diff --git a/src/pages/Leren.jsx b/src/pages/Leren.jsx
index f18b42c..0edab6c 100644
--- a/src/pages/Leren.jsx
+++ b/src/pages/Leren.jsx
@@ -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 (
+
+
+
+
+
Learning session complete!
+
+ You have successfully reviewed "{activeTopic.label} ".
+ {weeklyDone && ' Your weekly minimum is met.'}
+
+
+
setView('overview')}>Learn Another
+
+
Start Weekly Test
+
+
+
+ );
+ }
+
return (
-
-
-
Learning Station
-
No knowledge topics are available yet. Ask an admin to upload source material.
+
+
setView('overview')} className="flex items-center gap-2 text-fg-muted hover:text-teal mb-6 transition-colors">
+ Back to overview
+
+
+
+
+ {activeTopic.type}
+ {activeTopic.id === assignedTopic?.id && Weekly Required }
+
+
{activeTopic.label}
+
{activeTopic.description}
+
+
+ {!content && !isLoading && (
+
+
+ Click the button to generate personalized AI learning content for this topic.
+ Generate Learning Content
+
+ )}
+
+ {isLoading && (
+
+
+ AI is generating your learning module...
+ This may take 10–30 seconds.
+
+ )}
+
+ {error && (
+
+ Generation failed
+ {error}
+ Try again
+
+ )}
+
+ {content &&
}
+
+ {content && (
+
+
+ Complete Session
+
+
+ )}
);
}
- // ── Session completed ─────────────────────────────────────
- if (completed) {
+ // ── Creating Topic Loading ─────────────────────────────────
+ if (view === 'creating') {
return (
-
-
-
-
Learning session complete!
-
- You have successfully reviewed "{topic.label} ". Now take the weekly test!
-
-
-
Start Weekly Test
-
+
+
Architecting New Topic
+
The AI is creating a structure for "{customTopicQuery}"...
);
}
+ // ── Overview ──────────────────────────────────────────────
+ const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id);
+
return (
-
- {/* Header */}
-
-
- Week {state.weekNumber}
- {topic.type}
-
-
{topic.label}
-
{topic.description}
+
+
+
Learning Station
+
+ You must complete at least 1 topic per week. Feel free to explore more or create your own!
+
- {/* Generate prompt */}
- {!content && !isLoading && (
-
-
- Click the button to generate your personalized AI learning content.
- Generate Learning Content
-
- )}
-
- {/* Loading */}
- {isLoading && (
-
-
- AI is generating your personalized learning content...
- This may take 10–30 seconds.
-
- )}
-
- {/* Error */}
{error && (
-
- Generation failed
- {error}
-
- Try again
-
-
+
+ {error}
+
)}
- {/* Content Viewer */}
- {content &&
}
+ {/* Required Topic */}
+ {assignedTopic && (
+
+
+ Weekly Assignment {weeklyDone && }
+
+
handleOpenTopic(assignedTopic)}
+ >
+
+
+
+ {weeklyDone ? 'Completed' : 'Required'}
+
+
{assignedTopic.label}
+
{assignedTopic.description}
+
+
+ {weeklyDone ? 'Review' : 'Start Learning'}
+
+
+
+
+ )}
- {/* Complete button */}
- {content && !completed && (
-
-
- Complete Session
-
+ {/* Create Custom Topic */}
+
+
Explore Something New
+
+
+
+ The AI will instantly build a new learning module for anything you type.
+
+
+
+
+ {/* Other Available Topics */}
+ {otherTopics.length > 0 && (
+
+
Knowledge Base Library
+
+ {otherTopics.map(topic => (
+
handleOpenTopic(topic)}
+ >
+ {topic.type}
+ {topic.label}
+ {topic.description}
+
+
+ ))}
+
)}