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

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

58
AI_AGENT.md Normal file
View 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.

View File

@@ -1,16 +1,25 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import * as d3 from 'd3'; import * as d3 from 'd3';
import { Trash2, Edit2, Save, X } from 'lucide-react';
import { storage } from '../../lib/storage'; import { storage } from '../../lib/storage';
import Button from '../ui/Button';
const KnowledgeGraph = () => { const KnowledgeGraph = () => {
const svgRef = useRef(null); const svgRef = useRef(null);
const wrapperRef = useRef(null); const wrapperRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
const [selectedNode, setSelectedNode] = useState(null); const [selectedNode, setSelectedNode] = useState(null);
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState({});
// Load data // Load data into state so it can update
const topics = storage.get('kb:topics', []); const [topics, setTopics] = useState([]);
const relations = storage.get('kb:relations', []); const [relations, setRelations] = useState([]);
useEffect(() => {
setTopics(storage.get('kb:topics', []));
setRelations(storage.get('kb:relations', []));
}, []);
useEffect(() => { useEffect(() => {
if (!wrapperRef.current) return; if (!wrapperRef.current) return;
@@ -31,16 +40,11 @@ const KnowledgeGraph = () => {
if (!svgRef.current || topics.length === 0) return; if (!svgRef.current || topics.length === 0) return;
const { width, height } = dimensions; const { width, height } = dimensions;
// Clear previous render
d3.select(svgRef.current).selectAll('*').remove(); d3.select(svgRef.current).selectAll('*').remove();
// Setup nodes and links
// Copy objects because d3 modifies them
const nodes = topics.map(d => ({ ...d })); const nodes = topics.map(d => ({ ...d }));
const links = relations.map(d => ({ ...d })); const links = relations.map(d => ({ ...d }));
// Create SVG container with zoom support
const svg = d3.select(svgRef.current) const svg = d3.select(svgRef.current)
.attr('viewBox', [0, 0, width, height]) .attr('viewBox', [0, 0, width, height])
.attr('width', width) .attr('width', width)
@@ -56,19 +60,16 @@ const KnowledgeGraph = () => {
svg.call(zoom); svg.call(zoom);
// Color scale for node types
const color = d3.scaleOrdinal() const color = d3.scaleOrdinal()
.domain(['concept', 'role', 'process', 'fact']) .domain(['concept', 'role', 'process', 'fact'])
.range(['#1F5560', '#5C1DD8', '#AFC0FF', '#E2E8F0']); .range(['#1F5560', '#5C1DD8', '#AFC0FF', '#E2E8F0']);
// Setup force simulation
const simulation = d3.forceSimulation(nodes) const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(100)) .force('link', d3.forceLink(links).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300)) .force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2)) .force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide().radius(40)); .force('collide', d3.forceCollide().radius(40));
// Draw links
const link = g.append('g') const link = g.append('g')
.attr('stroke', 'var(--color-bg-warm)') .attr('stroke', 'var(--color-bg-warm)')
.attr('stroke-opacity', 0.6) .attr('stroke-opacity', 0.6)
@@ -77,14 +78,12 @@ const KnowledgeGraph = () => {
.join('line') .join('line')
.attr('stroke-width', 2); .attr('stroke-width', 2);
// Draw nodes
const node = g.append('g') const node = g.append('g')
.selectAll('g') .selectAll('g')
.data(nodes) .data(nodes)
.join('g') .join('g')
.call(drag(simulation)); .call(drag(simulation));
// Add circles to nodes
node.append('circle') node.append('circle')
.attr('r', 20) .attr('r', 20)
.attr('fill', d => color(d.type) || '#1F5560') .attr('fill', d => color(d.type) || '#1F5560')
@@ -92,9 +91,9 @@ const KnowledgeGraph = () => {
.attr('stroke-width', 2) .attr('stroke-width', 2)
.on('click', (event, d) => { .on('click', (event, d) => {
setSelectedNode(d); setSelectedNode(d);
setIsEditing(false);
}); });
// Add labels to nodes
node.append('text') node.append('text')
.text(d => d.label) .text(d => d.label)
.attr('x', 25) .attr('x', 25)
@@ -109,41 +108,64 @@ const KnowledgeGraph = () => {
.attr('y1', d => d.source.y) .attr('y1', d => d.source.y)
.attr('x2', d => d.target.x) .attr('x2', d => d.target.x)
.attr('y2', d => d.target.y); .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 drag(simulation) {
function dragstarted(event) { function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart(); if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x; event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y; event.subject.fy = event.subject.y;
} }
function dragged(event) { function dragged(event) {
event.subject.fx = event.x; event.subject.fx = event.x;
event.subject.fy = event.y; event.subject.fy = event.y;
} }
function dragended(event) { function dragended(event) {
if (!event.active) simulation.alphaTarget(0); if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null; event.subject.fx = null;
event.subject.fy = 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 () => { return () => simulation.stop();
simulation.stop();
};
}, [dimensions, topics, relations]); }, [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 ( return (
<div className="relative w-full h-full flex flex-col md:flex-row"> <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"> <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> </div>
{/* Node Info Panel */} {/* 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"> <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">
<h3 className="font-bold text-lg mb-4 text-teal">Node Details</h3> <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 ? ( {selectedNode ? (
<div className="space-y-4"> isEditing ? (
<div> <div className="space-y-4 flex-1">
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Label</p> <div>
<p className="font-medium">{selectedNode.label}</p> <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>
<div> ) : (
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Type</p> <div className="space-y-4 flex-1">
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono"> <div>
{selectedNode.type} <p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Label</p>
</span> <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>
<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>
</div> </div>

View File

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

View File

@@ -12,18 +12,18 @@ export const anthropicApi = {
// Check if simulation mode is on // Check if simulation mode is on
const useSimulation = storage.get('admin:use_simulation') === true; const useSimulation = storage.get('admin:use_simulation') === true;
if (useSimulation) { 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(); return await simulateResponse();
} }
const apiKey = storage.get('admin:anthropic_key') || import.meta.env.VITE_ANTHROPIC_API_KEY; const apiKey = storage.get('admin:anthropic_key') || import.meta.env.VITE_ANTHROPIC_API_KEY;
if (!apiKey) { 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 // Model is configurable from Admin > Settings, defaults to the original spec model
const model = storage.get('admin:model') || DEFAULT_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; let retries = 0;
while (retries <= maxRetries) { while (retries <= maxRetries) {

View File

@@ -156,3 +156,38 @@ Apply the refinement and return the complete updated JSON object using the same
export function deleteCachedContent(topicId) { export function deleteCachedContent(topicId) {
storage.remove(getContentCacheKey(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;
}

View File

@@ -9,6 +9,8 @@ import UploadZone from '../../components/admin/UploadZone';
import KnowledgeGraph from '../../components/admin/KnowledgeGraph'; import KnowledgeGraph from '../../components/admin/KnowledgeGraph';
import ContentManager from '../../components/admin/ContentManager'; import ContentManager from '../../components/admin/ContentManager';
import TestManager from '../../components/admin/TestManager'; import TestManager from '../../components/admin/TestManager';
import TeamManager from '../../components/admin/TeamManager';
import { Trash2 } from 'lucide-react';
const Admin = () => { const Admin = () => {
const [activeTab, setActiveTab] = useState('sources'); const [activeTab, setActiveTab] = useState('sources');
@@ -42,6 +44,14 @@ const Admin = () => {
setTimeout(() => setSaveStatus(null), 3000); 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 = [ const navItems = [
{ key: 'sources', icon: Database, label: 'Sources' }, { key: 'sources', icon: Database, label: 'Sources' },
{ key: 'content', icon: Layers, label: 'Content' }, { key: 'content', icon: Layers, label: 'Content' },
@@ -103,10 +113,13 @@ const Admin = () => {
</p> </p>
</div> </div>
</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 === '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 === '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>} {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>
</div> </div>
)) ))
@@ -147,11 +160,10 @@ const Admin = () => {
{/* ── Team ────────────────────────────────── */} {/* ── Team ────────────────────────────────── */}
{activeTab === '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> <h1 className="text-3xl text-teal mb-2">Team Management</h1>
<Card className="border border-bg-warm"> <p className="text-fg-muted mb-8">Manage team members, roles, and login PINs.</p>
<p className="text-fg-muted">Team members can be added and managed here.</p> <TeamManager />
</Card>
</div> </div>
)} )}

View File

@@ -5,10 +5,38 @@ import Card from '../components/ui/Card';
import Button from '../components/ui/Button'; import Button from '../components/ui/Button';
import Tag from '../components/ui/Tag'; import Tag from '../components/ui/Tag';
import { storage } from '../lib/storage';
import { getAssignedTopic } from '../lib/learningService';
import { getTestResult } from '../lib/testService';
const Dashboard = () => { const Dashboard = () => {
const { state } = useApp(); const { state } = useApp();
const { currentUser, weekNumber } = state; 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 ( return (
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500"> <div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<header> <header>
@@ -23,11 +51,13 @@ const Dashboard = () => {
<h3 className="text-xl">Learning</h3> <h3 className="text-xl">Learning</h3>
<p className="text-fg-muted text-sm mt-1">Your topic this week:</p> <p className="text-fg-muted text-sm mt-1">Your topic this week:</p>
</div> </div>
<Tag variant="accent">To Do</Tag> {learnDone ? <Tag variant="success">Completed</Tag> : <Tag variant="accent">To Do</Tag>}
</div> </div>
<h2 className="text-2xl mt-2 mb-6">The Role of the Product Owner</h2> <h2 className="text-2xl mt-2 mb-6">{topic ? topic.label : 'No topic assigned'}</h2>
<Link to="/learn"> <Link to="/learn" className="mt-auto">
<Button className="mt-auto w-full">Start Learning Session</Button> <Button className="w-full" variant={learnDone ? 'outline' : 'primary'}>
{learnDone ? 'Review Learning Material' : 'Start Learning Session'}
</Button>
</Link> </Link>
</Card> </Card>
@@ -37,12 +67,20 @@ const Dashboard = () => {
<h3 className="text-xl">Testing</h3> <h3 className="text-xl">Testing</h3>
<p className="text-fg-muted text-sm mt-1">Weekly test 10 questions</p> <p className="text-fg-muted text-sm mt-1">Weekly test 10 questions</p>
</div> </div>
<Tag variant="default">To Do</Tag> {testResult ? <Tag variant="success">Score: {testResult.percentage}%</Tag> : <Tag variant="default">To Do</Tag>}
</div> </div>
<div className="flex-1 flex items-center justify-center py-6 text-fg-subtle"> <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> </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> </Card>
</div> </div>
@@ -51,26 +89,26 @@ const Dashboard = () => {
<h3 className="text-2xl mb-4">Recent Activity</h3> <h3 className="text-2xl mb-4">Recent Activity</h3>
<Card className="p-0 overflow-hidden border border-bg-warm"> <Card className="p-0 overflow-hidden border border-bg-warm">
<div className="divide-y divide-bg-warm"> <div className="divide-y divide-bg-warm">
<div className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors"> {activity.length === 0 ? (
<div className="w-10 h-10 rounded-[var(--r-org)] bg-accent-soft flex items-center justify-center text-purple-700 font-bold"> <div className="p-8 text-center text-fg-muted">No recent activity.</div>
T ) : (
</div> activity.slice(0, 5).map((act, i) => (
<div> <div key={i} className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
<p className="font-medium">Test completed: Information Security</p> <div className={`w-10 h-10 rounded-[var(--r-org)] flex items-center justify-center font-bold ${
<p className="text-sm text-fg-muted">Last week · Score: 90%</p> act.type === 'test' ? 'bg-accent-soft text-purple-700' : 'bg-sage text-teal-900'
</div> }`}>
<div className="ml-auto text-teal font-bold">+15 pts</div> {act.type === 'test' ? 'T' : 'L'}
</div> </div>
<div className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors"> <div>
<div className="w-10 h-10 rounded-[var(--r-org)] bg-sage flex items-center justify-center text-teal-900 font-bold"> <p className="font-medium">{act.type === 'test' ? 'Test completed' : 'Learning session'}: {act.topic}</p>
L <p className="text-sm text-fg-muted">
</div> Week {act.week} {act.type === 'test' && `· Score: ${act.score}%`}
<div> </p>
<p className="font-medium">Learning session: Information Security</p> </div>
<p className="text-sm text-fg-muted">Last week</p> {act.points > 0 && <div className="ml-auto text-teal font-bold">+{act.points} pts</div>}
</div> </div>
<div className="ml-auto text-teal font-bold">+5 pts</div> ))
</div> )}
</div> </div>
</Card> </Card>
</div> </div>
@@ -79,20 +117,28 @@ const Dashboard = () => {
<h3 className="text-2xl mb-4">Mini Leaderboard</h3> <h3 className="text-2xl mb-4">Mini Leaderboard</h3>
<Card className="border border-bg-warm"> <Card className="border border-bg-warm">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> {top3.length === 0 ? (
<div className="flex items-center gap-3"> <div className="text-center text-fg-muted py-4">No points yet</div>
<span className="font-mono font-bold text-accent">1.</span> ) : (
<span className="font-medium">Admin</span> 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> </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> </div>
<Link to="/leaderboard"> <Link to="/leaderboard">
<Button variant="ghost" className="w-full mt-4 text-sm">View full leaderboard</Button> <Button variant="ghost" className="w-full mt-4 text-sm">View full leaderboard</Button>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; 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 { motion } from 'framer-motion';
import Card from '../components/ui/Card'; import Card from '../components/ui/Card';
import Tag from '../components/ui/Tag'; import Tag from '../components/ui/Tag';

View File

@@ -1,43 +1,67 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { BookOpen, CheckCircle, Loader, ArrowRight } from 'lucide-react'; import { BookOpen, CheckCircle, Loader, ArrowRight, Plus, Search, ChevronLeft } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Card from '../components/ui/Card'; import Card from '../components/ui/Card';
import Button from '../components/ui/Button'; import Button from '../components/ui/Button';
import Tag from '../components/ui/Tag'; import Tag from '../components/ui/Tag';
import Input from '../components/ui/Input';
import LearningContentViewer from '../components/ui/LearningContentViewer'; import LearningContentViewer from '../components/ui/LearningContentViewer';
import { useApp } from '../store/AppContext'; 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'; import { storage } from '../lib/storage';
const Leren = () => { const Leren = () => {
const { state } = useApp(); 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); const [content, setContent] = useState(null);
// Loading & Error
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null); 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(() => { useEffect(() => {
if (state.currentUser) { if (state.currentUser) {
const assigned = getAssignedTopic(state.currentUser.id, state.weekNumber); const assigned = getAssignedTopic(state.currentUser.id, state.weekNumber);
setTopic(assigned); setAssignedTopic(assigned);
if (assigned) { setAllTopics(storage.get('kb:topics', []));
const cached = getCachedContent(assigned.id);
if (cached) setContent(cached);
}
// Check if already completed this week
const done = storage.get(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, false); 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]); }, [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 () => { const loadContent = async () => {
if (!topic) return; if (!activeTopic) return;
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const generated = await generateLearningContent(topic); const generated = await generateLearningContent(activeTopic);
setContent(generated); setContent(generated);
} catch (e) { } catch (e) {
setError(e.message); setError(e.message);
@@ -46,90 +70,208 @@ const Leren = () => {
} }
}; };
const handleComplete = () => { const handleCreateCustom = async (e) => {
setCompleted(true); e.preventDefault();
storage.set(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, true); 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 ─────────────────────────────────── const handleComplete = () => {
if (!topic) { 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 ( return (
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20"> <div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
<BookOpen size={64} className="mx-auto text-teal/30 mb-6" /> <button onClick={() => setView('overview')} className="flex items-center gap-2 text-fg-muted hover:text-teal mb-6 transition-colors">
<h1 className="text-3xl text-teal font-bold mb-4">Learning Station</h1> <ChevronLeft size={16} /> Back to overview
<p className="text-fg-muted">No knowledge topics are available yet. Ask an admin to upload source material.</p> </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 1030 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> </div>
); );
} }
// ── Session completed ───────────────────────────────────── // ── Creating Topic Loading ─────────────────────────────────
if (completed) { if (view === 'creating') {
return ( return (
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20"> <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 }}> <Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<CheckCircle size={80} className="mx-auto text-teal mb-6" /> <h1 className="text-2xl font-bold text-teal mb-2">Architecting New Topic</h1>
</motion.div> <p className="text-fg-muted">The AI is creating a structure for "{customTopicQuery}"...</p>
<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>
</div> </div>
); );
} }
// ── Overview ──────────────────────────────────────────────
const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id);
return ( return (
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8"> <div className="p-4 md:p-8 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in duration-300">
{/* Header */} <div className="mb-10">
<div className="mb-8"> <h1 className="text-3xl md:text-4xl font-bold text-teal mb-3">Learning Station</h1>
<div className="flex items-center gap-3 mb-2"> <p className="text-fg-muted text-lg">
<Tag variant="accent">Week {state.weekNumber}</Tag> You must complete at least 1 topic per week. Feel free to explore more or create your own!
<Tag variant="dark" className="font-mono text-xs">{topic.type}</Tag> </p>
</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> </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 1030 seconds.</p>
</Card>
)}
{/* Error */}
{error && ( {error && (
<Card className="border border-red-200 bg-red-50 text-red-900 p-6"> <div className="mb-6 bg-red-50 text-red-800 p-4 rounded-[var(--r-sm)] border border-red-200">
<p className="font-bold mb-1">Generation failed</p> {error}
<p className="text-sm">{error}</p> </div>
<Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700">
Try again
</Button>
</Card>
)} )}
{/* Content Viewer */} {/* Required Topic */}
{content && <LearningContentViewer content={content} topic={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 */} {/* Create Custom Topic */}
{content && !completed && ( <div className="mb-12">
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end"> <h2 className="text-xl font-bold mb-4">Explore Something New</h2>
<Button onClick={handleComplete}> <Card className="border border-bg-warm bg-paper">
<CheckCircle size={18} className="mr-2" /> Complete Session <form onSubmit={handleCreateCustom} className="flex flex-col md:flex-row gap-4">
</Button> <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>
)} )}
</div> </div>