feat: implement knowledge testing system with leaderboard, quiz generation, and PocketBase integration
This commit is contained in:
@@ -5,6 +5,17 @@ services:
|
|||||||
expose:
|
expose:
|
||||||
- "80"
|
- "80"
|
||||||
|
|
||||||
|
pocketbase:
|
||||||
|
image: ghcr.io/muchobien/pocketbase:latest
|
||||||
|
restart: always
|
||||||
|
expose:
|
||||||
|
- "8090"
|
||||||
|
ports:
|
||||||
|
- "8090:8090" # Remove after initial setup via admin UI
|
||||||
|
volumes:
|
||||||
|
- pb_data:/pb/pb_data
|
||||||
|
command: ["serve", "--http=0.0.0.0:8090"]
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
restart: always
|
restart: always
|
||||||
@@ -17,21 +28,25 @@ services:
|
|||||||
- /bin/sh
|
- /bin/sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
# Build Caddyfile on container start
|
|
||||||
CLEAN_DOMAIN=$(printf '%s' "$DOMAIN_NAME" | tr -d ';')
|
CLEAN_DOMAIN=$(printf '%s' "$DOMAIN_NAME" | tr -d ';')
|
||||||
cat > /etc/caddy/Caddyfile <<EOF
|
cat > /etc/caddy/Caddyfile <<EOF
|
||||||
$${CLEAN_DOMAIN} {
|
$${CLEAN_DOMAIN} {
|
||||||
|
handle /pb/* {
|
||||||
|
uri strip_prefix /pb
|
||||||
|
reverse_proxy pocketbase:8090
|
||||||
|
}
|
||||||
reverse_proxy /* frontend:80
|
reverse_proxy /* frontend:80
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
# Run Caddy with the generated file
|
|
||||||
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
|
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||||
volumes:
|
volumes:
|
||||||
- caddy_data:/data
|
- caddy_data:/data
|
||||||
- caddy_config:/config
|
- caddy_config:/config
|
||||||
depends_on:
|
depends_on:
|
||||||
- frontend
|
- frontend
|
||||||
|
- pocketbase
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
caddy_data:
|
caddy_data:
|
||||||
caddy_config:
|
caddy_config:
|
||||||
|
pb_data:
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
|
"pocketbase": "^0.26.9",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-router-dom": "^7.15.0"
|
"react-router-dom": "^7.15.0"
|
||||||
@@ -2348,7 +2349,6 @@
|
|||||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
@@ -2899,6 +2899,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pocketbase": {
|
||||||
|
"version": "0.26.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.9.tgz",
|
||||||
|
"integrity": "sha512-Tiv1/hNuUzRdvT0d8hF03dfzuefQ1WdSRp1A1q3wzFC/WYhQcbU/Qlaubl/3ZDo6xvFXBS8JAgBS/L+ms7nkVQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.14",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
|
"pocketbase": "^0.26.9",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-router-dom": "^7.15.0"
|
"react-router-dom": "^7.15.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react';
|
import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react';
|
||||||
import { storage } from '../../lib/storage';
|
import * as db from '../../lib/db';
|
||||||
import { anthropicApi } from '../../lib/api';
|
import { anthropicApi } from '../../lib/api';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
|
||||||
@@ -16,13 +16,12 @@ const KnowledgeGraph = () => {
|
|||||||
const [analyzeError, setAnalyzeError] = useState(null);
|
const [analyzeError, setAnalyzeError] = useState(null);
|
||||||
const [newRelData, setNewRelData] = useState({ target: '', type: 'related_to' });
|
const [newRelData, setNewRelData] = useState({ target: '', type: 'related_to' });
|
||||||
|
|
||||||
// Load data into state so it can update
|
|
||||||
const [topics, setTopics] = useState([]);
|
const [topics, setTopics] = useState([]);
|
||||||
const [relations, setRelations] = useState([]);
|
const [relations, setRelations] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTopics(storage.get('kb:topics', []));
|
db.getTopics().then(setTopics);
|
||||||
setRelations(storage.get('kb:relations', []));
|
db.getRelations().then(setRelations);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,9 +57,7 @@ const KnowledgeGraph = () => {
|
|||||||
|
|
||||||
const zoom = d3.zoom()
|
const zoom = d3.zoom()
|
||||||
.scaleExtent([0.1, 4])
|
.scaleExtent([0.1, 4])
|
||||||
.on('zoom', (event) => {
|
.on('zoom', (event) => { g.attr('transform', event.transform); });
|
||||||
g.attr('transform', event.transform);
|
|
||||||
});
|
|
||||||
|
|
||||||
svg.call(zoom);
|
svg.call(zoom);
|
||||||
|
|
||||||
@@ -141,52 +138,49 @@ const KnowledgeGraph = () => {
|
|||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEdit = () => {
|
const saveEdit = async () => {
|
||||||
const updatedTopics = topics.map(t =>
|
await db.upsertTopic({ ...selectedNode, ...editData });
|
||||||
t.id === selectedNode.id ? { ...t, ...editData } : t
|
const updated = topics.map(t => t.id === selectedNode.id ? { ...t, ...editData } : t);
|
||||||
);
|
setTopics(updated);
|
||||||
storage.set('kb:topics', updatedTopics);
|
|
||||||
setTopics(updatedTopics);
|
|
||||||
setSelectedNode({ ...selectedNode, ...editData });
|
setSelectedNode({ ...selectedNode, ...editData });
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteNode = () => {
|
const deleteNode = async () => {
|
||||||
if (confirm(`Are you sure you want to delete "${selectedNode.label}"? This will also remove any relations connected to it.`)) {
|
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 updatedTopics = topics.filter(t => t.id !== selectedNode.id);
|
||||||
const updatedRelations = relations.filter(r => r.source !== selectedNode.id && r.target !== selectedNode.id);
|
const updatedRelations = relations.filter(r => r.source !== selectedNode.id && r.target !== selectedNode.id);
|
||||||
|
|
||||||
storage.set('kb:topics', updatedTopics);
|
await db.saveTopics(updatedTopics);
|
||||||
storage.set('kb:relations', updatedRelations);
|
await db.saveRelations(updatedRelations);
|
||||||
|
await db.deleteContent(selectedNode.id);
|
||||||
|
await db.setQuizBank(selectedNode.id, []);
|
||||||
|
|
||||||
setTopics(updatedTopics);
|
setTopics(updatedTopics);
|
||||||
setRelations(updatedRelations);
|
setRelations(updatedRelations);
|
||||||
setSelectedNode(null);
|
setSelectedNode(null);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|
||||||
// Clear associated content to keep it clean
|
|
||||||
storage.remove(`kb:content:${selectedNode.id}`);
|
|
||||||
storage.remove(`quiz:bank:${selectedNode.id}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addRelation = () => {
|
const addRelation = async () => {
|
||||||
if (!newRelData.target) return;
|
if (!newRelData.target) return;
|
||||||
const rawRelations = storage.get('kb:relations', []);
|
const isDup = relations.some(r => (r.source.id || r.source) === selectedNode.id && (r.target.id || r.target) === newRelData.target && r.type === newRelData.type);
|
||||||
const isDup = rawRelations.some(r => r.source === selectedNode.id && r.target === newRelData.target && r.type === newRelData.type);
|
|
||||||
if (isDup) return;
|
if (isDup) return;
|
||||||
|
|
||||||
const updated = [...rawRelations, { source: selectedNode.id, target: newRelData.target, type: newRelData.type }];
|
const newRel = { source: selectedNode.id, target: newRelData.target, type: newRelData.type };
|
||||||
storage.set('kb:relations', updated);
|
await db.addRelation(newRel);
|
||||||
setRelations(updated);
|
setRelations([...relations, newRel]);
|
||||||
setNewRelData({ target: '', type: 'related_to' });
|
setNewRelData({ target: '', type: 'related_to' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeRelation = (sourceId, targetId, type) => {
|
const removeRelation = async (sourceId, targetId, type) => {
|
||||||
const rawRelations = storage.get('kb:relations', []);
|
await db.removeRelation(sourceId, targetId, type);
|
||||||
const updated = rawRelations.filter(r => !(r.source === sourceId && r.target === targetId && r.type === type));
|
setRelations(relations.filter(r => !(
|
||||||
storage.set('kb:relations', updated);
|
(r.source.id || r.source) === sourceId &&
|
||||||
setRelations(updated);
|
(r.target.id || r.target) === targetId &&
|
||||||
|
r.type === type
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const analyzeGraph = async () => {
|
const analyzeGraph = async () => {
|
||||||
@@ -196,8 +190,8 @@ const KnowledgeGraph = () => {
|
|||||||
setAnalyzeError(null);
|
setAnalyzeError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentTopics = storage.get('kb:topics', []);
|
const currentTopics = await db.getTopics();
|
||||||
const currentRelations = storage.get('kb:relations', []);
|
const currentRelations = await db.getRelations();
|
||||||
|
|
||||||
const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph.
|
const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph.
|
||||||
Your goal is to evaluate the provided topics and relations, identify duplicates to merge, useless nodes to delete, and new logical relations to add.
|
Your goal is to evaluate the provided topics and relations, identify duplicates to merge, useless nodes to delete, and new logical relations to add.
|
||||||
@@ -227,11 +221,9 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
|
|||||||
let updatedTopics = [...currentTopics];
|
let updatedTopics = [...currentTopics];
|
||||||
let updatedRelations = [...currentRelations];
|
let updatedRelations = [...currentRelations];
|
||||||
|
|
||||||
// Process Merges
|
|
||||||
if (actions.merges && Array.isArray(actions.merges)) {
|
if (actions.merges && Array.isArray(actions.merges)) {
|
||||||
for (const merge of actions.merges) {
|
for (const merge of actions.merges) {
|
||||||
updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId);
|
updatedTopics = updatedTopics.filter(t => t.id !== merge.deleteId);
|
||||||
// Redirect relations
|
|
||||||
updatedRelations = updatedRelations.map(r => {
|
updatedRelations = updatedRelations.map(r => {
|
||||||
if (r.source === merge.deleteId) return { ...r, source: merge.keepId };
|
if (r.source === merge.deleteId) return { ...r, source: merge.keepId };
|
||||||
if (r.target === merge.deleteId) return { ...r, target: merge.keepId };
|
if (r.target === merge.deleteId) return { ...r, target: merge.keepId };
|
||||||
@@ -240,28 +232,22 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process Deletions
|
|
||||||
if (actions.deletions && Array.isArray(actions.deletions)) {
|
if (actions.deletions && Array.isArray(actions.deletions)) {
|
||||||
updatedTopics = updatedTopics.filter(t => !actions.deletions.includes(t.id));
|
updatedTopics = updatedTopics.filter(t => !actions.deletions.includes(t.id));
|
||||||
updatedRelations = updatedRelations.filter(r => !actions.deletions.includes(r.source) && !actions.deletions.includes(r.target));
|
updatedRelations = updatedRelations.filter(r => !actions.deletions.includes(r.source) && !actions.deletions.includes(r.target));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process New Relations
|
|
||||||
if (actions.newRelations && Array.isArray(actions.newRelations)) {
|
if (actions.newRelations && Array.isArray(actions.newRelations)) {
|
||||||
for (const newRel of actions.newRelations) {
|
for (const newRel of actions.newRelations) {
|
||||||
// Prevent exact duplicates
|
|
||||||
const isDup = updatedRelations.some(r => r.source === newRel.source && r.target === newRel.target && r.type === newRel.type);
|
const isDup = updatedRelations.some(r => r.source === newRel.source && r.target === newRel.target && r.type === newRel.type);
|
||||||
if (!isDup) {
|
if (!isDup) updatedRelations.push(newRel);
|
||||||
updatedRelations.push(newRel);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final cleanup to remove self-referencing relations that might occur after merges
|
|
||||||
updatedRelations = updatedRelations.filter(r => r.source !== r.target);
|
updatedRelations = updatedRelations.filter(r => r.source !== r.target);
|
||||||
|
|
||||||
storage.set('kb:topics', updatedTopics);
|
await db.saveTopics(updatedTopics);
|
||||||
storage.set('kb:relations', updatedRelations);
|
await db.saveRelations(updatedRelations);
|
||||||
|
|
||||||
setTopics(updatedTopics);
|
setTopics(updatedTopics);
|
||||||
setRelations(updatedRelations);
|
setRelations(updatedRelations);
|
||||||
@@ -286,9 +272,7 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 flex flex-col">
|
<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">
|
||||||
{/* Global Action */}
|
|
||||||
<div className="mb-6 pb-4 border-b border-bg-warm">
|
<div className="mb-6 pb-4 border-b border-bg-warm">
|
||||||
<Button
|
<Button
|
||||||
onClick={analyzeGraph}
|
onClick={analyzeGraph}
|
||||||
@@ -364,9 +348,7 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Type</p>
|
<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">
|
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono">{selectedNode.type}</span>
|
||||||
{selectedNode.type}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Description</p>
|
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Description</p>
|
||||||
@@ -380,7 +362,6 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
|
|||||||
<div className="pt-4 border-t border-bg-warm">
|
<div className="pt-4 border-t border-bg-warm">
|
||||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-3">Relations</p>
|
<p className="text-xs text-fg-muted uppercase tracking-wider mb-3">Relations</p>
|
||||||
|
|
||||||
{/* Existing Relations where selectedNode is source */}
|
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
{relations.filter(r => (r.source.id || r.source) === selectedNode.id).map((rel, idx) => {
|
{relations.filter(r => (r.source.id || r.source) === selectedNode.id).map((rel, idx) => {
|
||||||
const targetId = rel.target.id || rel.target;
|
const targetId = rel.target.id || rel.target;
|
||||||
@@ -402,7 +383,6 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add New Relation */}
|
|
||||||
<div className="bg-bg rounded-[var(--r-sm)] p-3 border border-bg-warm space-y-2">
|
<div className="bg-bg rounded-[var(--r-sm)] p-3 border border-bg-warm space-y-2">
|
||||||
<p className="text-xs font-medium flex items-center gap-1"><LinkIcon size={12}/> Add Relation</p>
|
<p className="text-xs font-medium flex items-center gap-1"><LinkIcon size={12}/> Add Relation</p>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import Card from '../ui/Card';
|
|||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import Input from '../ui/Input';
|
import Input from '../ui/Input';
|
||||||
import Tag from '../ui/Tag';
|
import Tag from '../ui/Tag';
|
||||||
import { storage } from '../../lib/storage';
|
import * as db from '../../lib/db';
|
||||||
|
import { pb } from '../../lib/pb';
|
||||||
import { useApp } from '../../store/AppContext';
|
import { useApp } from '../../store/AppContext';
|
||||||
|
|
||||||
const TeamManager = () => {
|
const TeamManager = () => {
|
||||||
@@ -12,44 +13,32 @@ const TeamManager = () => {
|
|||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({ name: '', role: 'user', pin: '' });
|
const [formData, setFormData] = useState({ name: '', role: 'user', pin: '' });
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
const loadUsers = () => {
|
const loadUsers = async () => {
|
||||||
setUsers(storage.get('users:registry', []));
|
const members = await db.getTeamMembers();
|
||||||
|
setUsers(members);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { loadUsers(); }, []);
|
useEffect(() => { loadUsers(); }, []);
|
||||||
|
|
||||||
const handleSave = (e) => {
|
const handleSave = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData.name || !formData.pin) return;
|
if (!formData.name || !formData.pin) return;
|
||||||
|
|
||||||
let updatedUsers = [...users];
|
|
||||||
|
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
updatedUsers = updatedUsers.map(u => u.id === editingId ? { ...u, ...formData } : u);
|
await db.updateTeamMember(editingId, { name: formData.name, role: formData.role, pin: formData.pin });
|
||||||
setMessage('User updated successfully.');
|
setMessage('User updated successfully.');
|
||||||
} else {
|
} else {
|
||||||
const newUser = {
|
await db.addTeamMember({ name: formData.name, role: formData.role, pin: formData.pin });
|
||||||
id: `u_${Date.now()}`,
|
|
||||||
name: formData.name,
|
|
||||||
role: formData.role,
|
|
||||||
pin: formData.pin,
|
|
||||||
registeredAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
updatedUsers.push(newUser);
|
|
||||||
setMessage('User added successfully.');
|
setMessage('User added successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.set('users:registry', updatedUsers);
|
await loadUsers();
|
||||||
setUsers(updatedUsers);
|
|
||||||
setFormData({ name: '', role: 'user', pin: '' });
|
setFormData({ name: '', role: 'user', pin: '' });
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setEditingId(null);
|
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);
|
setTimeout(() => setMessage(''), 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,20 +48,21 @@ const TeamManager = () => {
|
|||||||
setEditingId(user.id);
|
setEditingId(user.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id) => {
|
const handleDelete = async (id) => {
|
||||||
if (id === state.currentUser?.id) {
|
if (id === state.currentUser?.id) {
|
||||||
alert("You cannot delete yourself.");
|
alert("You cannot delete yourself.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (confirm("Are you sure you want to delete this user?")) {
|
if (confirm("Are you sure you want to delete this user?")) {
|
||||||
const updated = users.filter(u => u.id !== id);
|
await db.deleteTeamMember(id);
|
||||||
storage.set('users:registry', updated);
|
|
||||||
setUsers(updated);
|
|
||||||
|
|
||||||
// Also remove them from leaderboard
|
// Also remove from leaderboard
|
||||||
const lb = storage.get('leaderboard:current', []);
|
try {
|
||||||
storage.set('leaderboard:current', lb.filter(e => e.userId !== id));
|
const entry = await pb.collection('leaderboard').getFirstListItem(`user_id="${id}"`);
|
||||||
|
await pb.collection('leaderboard').delete(entry.id);
|
||||||
|
} catch { /* no leaderboard entry, nothing to do */ }
|
||||||
|
|
||||||
|
await loadUsers();
|
||||||
setMessage('User deleted.');
|
setMessage('User deleted.');
|
||||||
setTimeout(() => setMessage(''), 3000);
|
setTimeout(() => setMessage(''), 3000);
|
||||||
}
|
}
|
||||||
@@ -92,7 +82,6 @@ const TeamManager = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<Card className="border border-bg-warm">
|
<Card className="border border-bg-warm">
|
||||||
<h2 className="text-xl font-bold flex items-center gap-2 mb-4">
|
<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 ? <Edit2 size={20} className="text-teal" /> : <UserPlus size={20} className="text-teal" />}
|
||||||
@@ -143,7 +132,6 @@ const TeamManager = () => {
|
|||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* List */}
|
|
||||||
<Card className="p-0 border border-bg-warm overflow-hidden">
|
<Card className="p-0 border border-bg-warm overflow-hidden">
|
||||||
<div className="divide-y divide-bg-warm">
|
<div className="divide-y divide-bg-warm">
|
||||||
{users.map(user => (
|
{users.map(user => (
|
||||||
|
|||||||
245
src/lib/db.js
Normal file
245
src/lib/db.js
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { pb } from './pb';
|
||||||
|
|
||||||
|
// ── Topics ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getTopics() {
|
||||||
|
return pb.collection('topics').getFullList({ sort: 'created' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTopics(topics) {
|
||||||
|
const existing = await pb.collection('topics').getFullList({ fields: 'id' });
|
||||||
|
await Promise.all(existing.map(r => pb.collection('topics').delete(r.id)));
|
||||||
|
return Promise.all(topics.map(t => pb.collection('topics').create({
|
||||||
|
id: t.id,
|
||||||
|
label: t.label,
|
||||||
|
type: t.type,
|
||||||
|
description: t.description,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertTopic(topic) {
|
||||||
|
try {
|
||||||
|
await pb.collection('topics').getOne(topic.id);
|
||||||
|
return pb.collection('topics').update(topic.id, topic);
|
||||||
|
} catch {
|
||||||
|
return pb.collection('topics').create({ id: topic.id, ...topic });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Relations ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getRelations() {
|
||||||
|
return pb.collection('relations').getFullList();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRelations(relations) {
|
||||||
|
const existing = await pb.collection('relations').getFullList({ fields: 'id' });
|
||||||
|
await Promise.all(existing.map(r => pb.collection('relations').delete(r.id)));
|
||||||
|
return Promise.all(relations.map(r => pb.collection('relations').create(r)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addRelation(relation) {
|
||||||
|
return pb.collection('relations').create(relation);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeRelation(source, target, type) {
|
||||||
|
const records = await pb.collection('relations').getFullList({
|
||||||
|
filter: `source="${source}" && target="${target}" && type="${type}"`,
|
||||||
|
});
|
||||||
|
return Promise.all(records.map(r => pb.collection('relations').delete(r.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Content ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getContent(topicId) {
|
||||||
|
try {
|
||||||
|
const r = await pb.collection('content').getFirstListItem(`topic_id="${topicId}"`);
|
||||||
|
return r.data;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setContent(topicId, data) {
|
||||||
|
try {
|
||||||
|
const r = await pb.collection('content').getFirstListItem(`topic_id="${topicId}"`);
|
||||||
|
return pb.collection('content').update(r.id, { data });
|
||||||
|
} catch {
|
||||||
|
return pb.collection('content').create({ topic_id: topicId, data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteContent(topicId) {
|
||||||
|
try {
|
||||||
|
const r = await pb.collection('content').getFirstListItem(`topic_id="${topicId}"`);
|
||||||
|
return pb.collection('content').delete(r.id);
|
||||||
|
} catch { /* no record, nothing to do */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quiz Banks ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getQuizBank(topicId) {
|
||||||
|
try {
|
||||||
|
const r = await pb.collection('quiz_banks').getFirstListItem(`topic_id="${topicId}"`);
|
||||||
|
return r.questions || [];
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setQuizBank(topicId, questions) {
|
||||||
|
try {
|
||||||
|
const r = await pb.collection('quiz_banks').getFirstListItem(`topic_id="${topicId}"`);
|
||||||
|
return pb.collection('quiz_banks').update(r.id, { questions });
|
||||||
|
} catch {
|
||||||
|
return pb.collection('quiz_banks').create({ topic_id: topicId, questions });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteQuestionFromBank(topicId, questionId) {
|
||||||
|
const questions = await getQuizBank(topicId);
|
||||||
|
return setQuizBank(topicId, questions.filter(q => q.id !== questionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sources ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getSources() {
|
||||||
|
return pb.collection('sources').getFullList({ sort: '-created' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addSource(source) {
|
||||||
|
return pb.collection('sources').create(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSourceStatus(id, status, error = '') {
|
||||||
|
return pb.collection('sources').update(id, { status, error });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSource(id) {
|
||||||
|
return pb.collection('sources').delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Team Members ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getTeamMembers() {
|
||||||
|
return pb.collection('team_members').getFullList({ sort: 'created' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTeamMember(member) {
|
||||||
|
return pb.collection('team_members').create(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTeamMember(id, data) {
|
||||||
|
return pb.collection('team_members').update(id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTeamMember(id) {
|
||||||
|
return pb.collection('team_members').delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quiz Results ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getQuizResult(userId, weekNumber) {
|
||||||
|
try {
|
||||||
|
return await pb.collection('quiz_results').getFirstListItem(
|
||||||
|
`user_id="${userId}" && week_number=${weekNumber}`
|
||||||
|
);
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveQuizResult(userId, weekNumber, result) {
|
||||||
|
return pb.collection('quiz_results').create({
|
||||||
|
user_id: userId,
|
||||||
|
week_number: weekNumber,
|
||||||
|
score: result.score,
|
||||||
|
total: result.total,
|
||||||
|
percentage: result.percentage,
|
||||||
|
time_used: result.timeUsed,
|
||||||
|
completed_at: result.completedAt,
|
||||||
|
breakdown: result.breakdown,
|
||||||
|
points_earned: result.pointsEarned,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quiz Cache ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getCachedQuiz(userId, weekNumber) {
|
||||||
|
try {
|
||||||
|
return await pb.collection('quiz_cache').getFirstListItem(
|
||||||
|
`user_id="${userId}" && week_number=${weekNumber}`
|
||||||
|
);
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCachedQuiz(userId, weekNumber, quiz) {
|
||||||
|
try {
|
||||||
|
const r = await pb.collection('quiz_cache').getFirstListItem(
|
||||||
|
`user_id="${userId}" && week_number=${weekNumber}`
|
||||||
|
);
|
||||||
|
return pb.collection('quiz_cache').update(r.id, { questions: quiz.questions, meta: quiz.meta });
|
||||||
|
} catch {
|
||||||
|
return pb.collection('quiz_cache').create({
|
||||||
|
user_id: userId, week_number: weekNumber, questions: quiz.questions, meta: quiz.meta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Learn Progress ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getLearnDone(userId, weekNumber) {
|
||||||
|
try {
|
||||||
|
const r = await pb.collection('learn_progress').getFirstListItem(
|
||||||
|
`user_id="${userId}" && week_number=${weekNumber}`
|
||||||
|
);
|
||||||
|
return r.done;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLearnDone(userId, weekNumber) {
|
||||||
|
try {
|
||||||
|
const r = await pb.collection('learn_progress').getFirstListItem(
|
||||||
|
`user_id="${userId}" && week_number=${weekNumber}`
|
||||||
|
);
|
||||||
|
return pb.collection('learn_progress').update(r.id, { done: true });
|
||||||
|
} catch {
|
||||||
|
return pb.collection('learn_progress').create({ user_id: userId, week_number: weekNumber, done: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getLeaderboard() {
|
||||||
|
return pb.collection('leaderboard').getFullList({ sort: '-points' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertLeaderboardEntry(userId, name, pointsDelta, testsCompletedDelta = 0) {
|
||||||
|
try {
|
||||||
|
const r = await pb.collection('leaderboard').getFirstListItem(`user_id="${userId}"`);
|
||||||
|
return pb.collection('leaderboard').update(r.id, {
|
||||||
|
points: (r.points || 0) + pointsDelta,
|
||||||
|
tests_completed: (r.tests_completed || 0) + testsCompletedDelta,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return pb.collection('leaderboard').create({
|
||||||
|
user_id: userId,
|
||||||
|
name,
|
||||||
|
points: pointsDelta,
|
||||||
|
tests_completed: testsCompletedDelta,
|
||||||
|
learnings_completed: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getSetting(key, defaultValue = null) {
|
||||||
|
try {
|
||||||
|
const r = await pb.collection('settings').getFirstListItem(`key="${key}"`);
|
||||||
|
return r.value ?? defaultValue;
|
||||||
|
} catch { return defaultValue; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSetting(key, value) {
|
||||||
|
try {
|
||||||
|
const r = await pb.collection('settings').getFirstListItem(`key="${key}"`);
|
||||||
|
return pb.collection('settings').update(r.id, { value: String(value) });
|
||||||
|
} catch {
|
||||||
|
return pb.collection('settings').create({ key, value: String(value) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { anthropicApi } from './api';
|
import { anthropicApi } from './api';
|
||||||
import { storage } from './storage';
|
import * as db from './db';
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `You are an AI knowledge extractor for Respellion, an IT company built on radical transparency.
|
const SYSTEM_PROMPT = `You are an AI knowledge extractor for Respellion, an IT company built on radical transparency.
|
||||||
You receive a source text. Your task is to extract core concepts, roles, and processes, and return them as a structured JSON Knowledge Graph.
|
You receive a source text. Your task is to extract core concepts, roles, and processes, and return them as a structured JSON Knowledge Graph.
|
||||||
@@ -25,32 +25,15 @@ ALWAYS return a valid JSON object in the following format:
|
|||||||
}
|
}
|
||||||
Return JSON only. No markdown blocks or other text.`;
|
Return JSON only. No markdown blocks or other text.`;
|
||||||
|
|
||||||
/**
|
|
||||||
* Voert tekst door de Anthropic API en slaat de resulterende topics op.
|
|
||||||
* @param {string} textContent De te analyseren tekst.
|
|
||||||
* @param {string} sourceName Naam van de bron (voor tracking).
|
|
||||||
*/
|
|
||||||
export async function processSourceText(textContent, sourceName) {
|
export async function processSourceText(textContent, sourceName) {
|
||||||
// 1. Sla de bron eerst op als "processing"
|
const rec = await db.addSource({ name: sourceName, status: 'processing' });
|
||||||
const sourceId = `src_${Date.now()}`;
|
const sourceId = rec.id;
|
||||||
const newSource = {
|
|
||||||
id: sourceId,
|
|
||||||
name: sourceName,
|
|
||||||
status: 'processing',
|
|
||||||
date: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
const sources = storage.get('admin:sources', []);
|
|
||||||
storage.set('admin:sources', [newSource, ...sources]);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. Roep Anthropic API aan
|
|
||||||
const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyze the following text:\n\n${textContent}`);
|
const responseText = await anthropicApi.generateContent(SYSTEM_PROMPT, `Analyze the following text:\n\n${textContent}`);
|
||||||
|
|
||||||
// 3. Parse JSON (veilig)
|
|
||||||
let extractedData;
|
let extractedData;
|
||||||
try {
|
try {
|
||||||
// Zoek naar JSON in de response (indien het toch in markdown was verpakt)
|
|
||||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||||
const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
|
const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
|
||||||
extractedData = JSON.parse(jsonStr);
|
extractedData = JSON.parse(jsonStr);
|
||||||
@@ -58,34 +41,21 @@ export async function processSourceText(textContent, sourceName) {
|
|||||||
throw new Error('AI response was not valid JSON.');
|
throw new Error('AI response was not valid JSON.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Deduplicatie en opslag van Topics en Relaties
|
await mergeKnowledgeGraph(extractedData);
|
||||||
mergeKnowledgeGraph(extractedData);
|
await db.updateSourceStatus(sourceId, 'completed');
|
||||||
|
|
||||||
// 5. Markeer bron als voltooid
|
|
||||||
updateSourceStatus(sourceId, 'completed');
|
|
||||||
|
|
||||||
return { success: true, data: extractedData };
|
return { success: true, data: extractedData };
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Markeer bron als mislukt
|
await db.updateSourceStatus(sourceId, 'failed', error.message);
|
||||||
updateSourceStatus(sourceId, 'failed', error.message);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSourceStatus(sourceId, status, errorMsg = '') {
|
async function mergeKnowledgeGraph(newData) {
|
||||||
const sources = storage.get('admin:sources', []);
|
const existingTopics = await db.getTopics();
|
||||||
const updated = sources.map(s =>
|
const existingRelations = await db.getRelations();
|
||||||
s.id === sourceId ? { ...s, status, error: errorMsg } : s
|
|
||||||
);
|
|
||||||
storage.set('admin:sources', updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeKnowledgeGraph(newData) {
|
|
||||||
const existingTopics = storage.get('kb:topics', []);
|
|
||||||
const existingRelations = storage.get('kb:relations', []);
|
|
||||||
|
|
||||||
// Simpele deduplicatie op ID
|
|
||||||
const topicsMap = new Map(existingTopics.map(t => [t.id, t]));
|
const topicsMap = new Map(existingTopics.map(t => [t.id, t]));
|
||||||
|
|
||||||
if (newData.topics && Array.isArray(newData.topics)) {
|
if (newData.topics && Array.isArray(newData.topics)) {
|
||||||
@@ -96,11 +66,9 @@ function mergeKnowledgeGraph(newData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voeg relaties toe (we gaan er nu vanuit dat ze uniek genoeg zijn of dubbele negeren)
|
|
||||||
const newRelations = [...existingRelations];
|
const newRelations = [...existingRelations];
|
||||||
if (newData.relations && Array.isArray(newData.relations)) {
|
if (newData.relations && Array.isArray(newData.relations)) {
|
||||||
for (const r of newData.relations) {
|
for (const r of newData.relations) {
|
||||||
// Simpele check om exacte duplicaten te voorkomen
|
|
||||||
const isDup = newRelations.some(ex => ex.source === r.source && ex.target === r.target && ex.type === r.type);
|
const isDup = newRelations.some(ex => ex.source === r.source && ex.target === r.target && ex.type === r.type);
|
||||||
if (!isDup) {
|
if (!isDup) {
|
||||||
newRelations.push(r);
|
newRelations.push(r);
|
||||||
@@ -108,6 +76,6 @@ function mergeKnowledgeGraph(newData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.set('kb:topics', Array.from(topicsMap.values()));
|
await db.saveTopics(Array.from(topicsMap.values()));
|
||||||
storage.set('kb:relations', newRelations);
|
await db.saveRelations(newRelations);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { anthropicApi } from './api';
|
import { anthropicApi } from './api';
|
||||||
import { storage } from './storage';
|
import * as db from './db';
|
||||||
|
|
||||||
const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
|
const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
|
||||||
You write training material for employees based on knowledge topics.
|
You write training material for employees based on knowledge topics.
|
||||||
@@ -33,12 +33,8 @@ const CONTENT_SCHEMA = `{
|
|||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
/**
|
export async function getAssignedTopic(userId, weekNumber) {
|
||||||
* Get the assigned topic for a user for a given week using round-robin.
|
const topics = await db.getTopics();
|
||||||
* hash(userId + weekNumber) % topicCount
|
|
||||||
*/
|
|
||||||
export function getAssignedTopic(userId, weekNumber) {
|
|
||||||
const topics = storage.get('kb:topics', []);
|
|
||||||
if (!topics || topics.length === 0) return null;
|
if (!topics || topics.length === 0) return null;
|
||||||
|
|
||||||
const str = `${userId}:${weekNumber}`;
|
const str = `${userId}:${weekNumber}`;
|
||||||
@@ -51,43 +47,24 @@ export function getAssignedTopic(userId, weekNumber) {
|
|||||||
return topics[index];
|
return topics[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getCachedContent(topicId) {
|
||||||
* Returns the cache key for a topic's content.
|
return db.getContent(topicId);
|
||||||
*/
|
|
||||||
export function getContentCacheKey(topicId) {
|
|
||||||
return `kb:content:${topicId}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getAllGeneratedContent() {
|
||||||
* Returns cached content for a topic, or null if none exists.
|
const topics = await db.getTopics();
|
||||||
*/
|
const results = await Promise.all(
|
||||||
export function getCachedContent(topicId) {
|
topics.map(async topic => {
|
||||||
return storage.get(getContentCacheKey(topicId), null);
|
const content = await db.getContent(topic.id);
|
||||||
|
return { topic, content, hasContent: !!content };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return results.filter(item => item.hasContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* List all topics that have generated content.
|
|
||||||
*/
|
|
||||||
export function getAllGeneratedContent() {
|
|
||||||
const topics = storage.get('kb:topics', []);
|
|
||||||
return topics
|
|
||||||
.map(topic => ({
|
|
||||||
topic,
|
|
||||||
content: getCachedContent(topic.id),
|
|
||||||
hasContent: !!getCachedContent(topic.id),
|
|
||||||
}))
|
|
||||||
.filter(item => item.hasContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a complete learning module for a topic.
|
|
||||||
* Uses cached version if available (unless force = true).
|
|
||||||
*/
|
|
||||||
export async function generateLearningContent(topic, force = false) {
|
export async function generateLearningContent(topic, force = false) {
|
||||||
const cacheKey = getContentCacheKey(topic.id);
|
|
||||||
|
|
||||||
if (!force) {
|
if (!force) {
|
||||||
const cached = storage.get(cacheKey);
|
const cached = await db.getContent(topic.id);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
console.log(`[Learn] Cache hit for topic: ${topic.id}`);
|
console.log(`[Learn] Cache hit for topic: ${topic.id}`);
|
||||||
return cached;
|
return cached;
|
||||||
@@ -115,17 +92,12 @@ Provide at least 3 article sections, 4 slides, 3 stats, and 3-5 steps in the inf
|
|||||||
throw new Error('AI could not generate valid learning content. Please try again.');
|
throw new Error('AI could not generate valid learning content. Please try again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.set(cacheKey, content);
|
await db.setContent(topic.id, content);
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Refine existing content for a topic using a natural language instruction.
|
|
||||||
* Sends current content + refinement prompt to AI, saves new version.
|
|
||||||
*/
|
|
||||||
export async function refineLearningContent(topic, refinementInstruction) {
|
export async function refineLearningContent(topic, refinementInstruction) {
|
||||||
const cacheKey = getContentCacheKey(topic.id);
|
const existing = await db.getContent(topic.id);
|
||||||
const existing = storage.get(cacheKey);
|
|
||||||
|
|
||||||
const prompt = `You have previously generated the following learning module for the topic "${topic.label}":
|
const prompt = `You have previously generated the following learning module for the topic "${topic.label}":
|
||||||
|
|
||||||
@@ -146,20 +118,14 @@ Apply the refinement and return the complete updated JSON object using the same
|
|||||||
throw new Error('AI could not process the refinement. Please try a different instruction.');
|
throw new Error('AI could not process the refinement. Please try a different instruction.');
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.set(cacheKey, content);
|
await db.setContent(topic.id, content);
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function deleteCachedContent(topicId) {
|
||||||
* Delete cached content for a topic, forcing a fresh generation next time.
|
return db.deleteContent(topicId);
|
||||||
*/
|
|
||||||
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) {
|
export async function generateCustomTopic(label) {
|
||||||
const prompt = `A user wants to learn about "${label}".
|
const prompt = `A user wants to learn about "${label}".
|
||||||
Create a short description (2-3 sentences) and categorize it.
|
Create a short description (2-3 sentences) and categorize it.
|
||||||
@@ -185,9 +151,6 @@ Return ONLY a JSON object with this structure:
|
|||||||
throw new Error('Could not process custom topic. Please try again.');
|
throw new Error('Could not process custom topic. Please try again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to global knowledge base so others can see it too
|
await db.upsertTopic(newTopic);
|
||||||
const topics = storage.get('kb:topics', []);
|
|
||||||
storage.set('kb:topics', [...topics, newTopic]);
|
|
||||||
|
|
||||||
return newTopic;
|
return newTopic;
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/lib/pb.js
Normal file
6
src/lib/pb.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
|
||||||
|
const pbUrl = import.meta.env.VITE_PB_URL ||
|
||||||
|
(typeof window !== 'undefined' ? window.location.origin + '/pb' : 'http://localhost:8090');
|
||||||
|
|
||||||
|
export const pb = new PocketBase(pbUrl);
|
||||||
@@ -1,21 +1,15 @@
|
|||||||
import { anthropicApi } from './api';
|
import { anthropicApi } from './api';
|
||||||
import { storage } from './storage';
|
import * as db from './db';
|
||||||
|
|
||||||
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
|
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
|
||||||
You generate multiple-choice questions to test employee knowledge on specific topics.
|
You generate multiple-choice questions to test employee knowledge on specific topics.
|
||||||
Always write in clear, professional English.
|
Always write in clear, professional English.
|
||||||
ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
|
ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
|
||||||
|
|
||||||
/**
|
async function selectTestTopics(userId, weekNumber) {
|
||||||
* Select topics for the weekly test:
|
const topics = await db.getTopics();
|
||||||
* - 50% from the user's assigned learning topic this week
|
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [] };
|
||||||
* - 50% from random other topics (review)
|
|
||||||
*/
|
|
||||||
function selectTestTopics(userId, weekNumber) {
|
|
||||||
const topics = storage.get('kb:topics', []);
|
|
||||||
if (!topics || topics.length === 0) return [];
|
|
||||||
|
|
||||||
// Deterministic hash for the user's current topic
|
|
||||||
const str = `${userId}:${weekNumber}`;
|
const str = `${userId}:${weekNumber}`;
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
@@ -25,7 +19,6 @@ function selectTestTopics(userId, weekNumber) {
|
|||||||
const primaryIndex = Math.abs(hash) % topics.length;
|
const primaryIndex = Math.abs(hash) % topics.length;
|
||||||
const primaryTopic = topics[primaryIndex];
|
const primaryTopic = topics[primaryIndex];
|
||||||
|
|
||||||
// Pick up to 5 "review" topics (random, different from primary)
|
|
||||||
const others = topics.filter((_, i) => i !== primaryIndex);
|
const others = topics.filter((_, i) => i !== primaryIndex);
|
||||||
const shuffled = others.sort(() => 0.5 - Math.random());
|
const shuffled = others.sort(() => 0.5 - Math.random());
|
||||||
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
|
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
|
||||||
@@ -33,19 +26,12 @@ function selectTestTopics(userId, weekNumber) {
|
|||||||
return { primaryTopic, reviewTopics };
|
return { primaryTopic, reviewTopics };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getCachedQuiz(userId, weekNumber) {
|
||||||
* Retrieve cached quiz, or null.
|
return db.getCachedQuiz(userId, weekNumber);
|
||||||
*/
|
|
||||||
export function getCachedQuiz(userId, weekNumber) {
|
|
||||||
return storage.get(`quiz:${userId}:week:${weekNumber}`, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Exported helper for admin: manually trigger generation for a topic.
|
|
||||||
*/
|
|
||||||
export async function forceGenerateTopicQuestions(topic, count = 10) {
|
export async function forceGenerateTopicQuestions(topic, count = 10) {
|
||||||
const bankKey = `quiz:bank:${topic.id}`;
|
let bank = await db.getQuizBank(topic.id);
|
||||||
let bank = storage.get(bankKey, []);
|
|
||||||
|
|
||||||
const prompt = `Generate exactly ${count} multiple-choice quiz questions based on this knowledge topic:
|
const prompt = `Generate exactly ${count} multiple-choice quiz questions based on this knowledge topic:
|
||||||
|
|
||||||
@@ -88,76 +74,51 @@ Rules:
|
|||||||
}
|
}
|
||||||
|
|
||||||
bank = [...bank, ...newQuestions];
|
bank = [...bank, ...newQuestions];
|
||||||
storage.set(bankKey, bank);
|
await db.setQuizBank(topic.id, bank);
|
||||||
return newQuestions;
|
return newQuestions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure a topic has enough questions in its bank, generating more if needed.
|
|
||||||
* Returns exactly `count` questions.
|
|
||||||
*/
|
|
||||||
async function getOrGenerateTopicQuestions(topic, count) {
|
async function getOrGenerateTopicQuestions(topic, count) {
|
||||||
const bankKey = `quiz:bank:${topic.id}`;
|
let bank = await db.getQuizBank(topic.id);
|
||||||
let bank = storage.get(bankKey, []);
|
|
||||||
|
|
||||||
// If we don't have enough questions, ask AI to generate a batch of 10
|
|
||||||
if (bank.length < count) {
|
if (bank.length < count) {
|
||||||
await forceGenerateTopicQuestions(topic, 10);
|
await forceGenerateTopicQuestions(topic, 10);
|
||||||
bank = storage.get(bankKey, []); // reload
|
bank = await db.getQuizBank(topic.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shuffle and pick `count` questions
|
|
||||||
const shuffled = [...bank].sort(() => 0.5 - Math.random());
|
const shuffled = [...bank].sort(() => 0.5 - Math.random());
|
||||||
return shuffled.slice(0, Math.min(count, shuffled.length));
|
return shuffled.slice(0, Math.min(count, shuffled.length));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getTopicQuestionBank(topicId) {
|
||||||
* Admin Helper: get question bank for a topic
|
return db.getQuizBank(topicId);
|
||||||
*/
|
|
||||||
export function getTopicQuestionBank(topicId) {
|
|
||||||
return storage.get(`quiz:bank:${topicId}`, []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function deleteQuestion(topicId, questionId) {
|
||||||
* Admin Helper: delete a single question
|
return db.deleteQuestionFromBank(topicId, questionId);
|
||||||
*/
|
|
||||||
export function deleteQuestion(topicId, questionId) {
|
|
||||||
const bankKey = `quiz:bank:${topicId}`;
|
|
||||||
const bank = storage.get(bankKey, []);
|
|
||||||
storage.set(bankKey, bank.filter(q => q.id !== questionId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate 10 MCQ questions for a user's weekly test.
|
|
||||||
* Caches the result so the same quiz is served on retry.
|
|
||||||
* Pulls from topic-specific question banks, generating more if banks are low.
|
|
||||||
*/
|
|
||||||
export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
|
export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
|
||||||
const cacheKey = `quiz:${userId}:week:${weekNumber}`;
|
|
||||||
if (!force) {
|
if (!force) {
|
||||||
const cached = storage.get(cacheKey);
|
const cached = await db.getCachedQuiz(userId, weekNumber);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { primaryTopic, reviewTopics } = selectTestTopics(userId, weekNumber);
|
const { primaryTopic, reviewTopics } = await selectTestTopics(userId, weekNumber);
|
||||||
if (!primaryTopic) throw new Error('No topics available to generate a quiz.');
|
if (!primaryTopic) throw new Error('No topics available to generate a quiz.');
|
||||||
|
|
||||||
const questions = [];
|
const questions = [];
|
||||||
|
|
||||||
// Get 5 questions for the primary topic
|
|
||||||
const primaryQs = await getOrGenerateTopicQuestions(primaryTopic, 5);
|
const primaryQs = await getOrGenerateTopicQuestions(primaryTopic, 5);
|
||||||
questions.push(...primaryQs);
|
questions.push(...primaryQs);
|
||||||
|
|
||||||
// Get 1 question for each of the up to 5 review topics
|
|
||||||
for (const rt of reviewTopics) {
|
for (const rt of reviewTopics) {
|
||||||
const rtQs = await getOrGenerateTopicQuestions(rt, 1);
|
const rtQs = await getOrGenerateTopicQuestions(rt, 1);
|
||||||
questions.push(...rtQs);
|
questions.push(...rtQs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there are fewer than 10 questions (e.g. no review topics yet), pad with more from primary
|
|
||||||
if (questions.length < 10) {
|
if (questions.length < 10) {
|
||||||
const needed = 10 - questions.length;
|
const needed = 10 - questions.length;
|
||||||
// We already took 5, so let's try to get enough extra unique questions
|
|
||||||
const extraQs = await getOrGenerateTopicQuestions(primaryTopic, needed + 5);
|
const extraQs = await getOrGenerateTopicQuestions(primaryTopic, needed + 5);
|
||||||
const existingIds = new Set(questions.map(q => q.id));
|
const existingIds = new Set(questions.map(q => q.id));
|
||||||
for (const eq of extraQs) {
|
for (const eq of extraQs) {
|
||||||
@@ -167,58 +128,33 @@ export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shuffle the final 10 questions
|
|
||||||
questions.sort(() => 0.5 - Math.random());
|
questions.sort(() => 0.5 - Math.random());
|
||||||
|
|
||||||
const quiz = { questions };
|
const quiz = {
|
||||||
quiz.meta = {
|
questions,
|
||||||
|
meta: {
|
||||||
userId,
|
userId,
|
||||||
weekNumber,
|
weekNumber,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
primaryTopic: primaryTopic.label,
|
primaryTopic: primaryTopic.label,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.set(cacheKey, quiz);
|
await db.setCachedQuiz(userId, weekNumber, quiz);
|
||||||
return quiz;
|
return quiz;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function saveTestResult(userId, weekNumber, result) {
|
||||||
* Save a completed test result.
|
await db.saveQuizResult(userId, weekNumber, result);
|
||||||
*/
|
|
||||||
export function saveTestResult(userId, weekNumber, result) {
|
|
||||||
const key = `quiz:result:${userId}:week:${weekNumber}`;
|
|
||||||
storage.set(key, result);
|
|
||||||
|
|
||||||
// Update leaderboard points
|
const pointsEarned = result.score * 2;
|
||||||
const leaderboard = storage.get('leaderboard:current', []);
|
const members = await db.getTeamMembers();
|
||||||
const entry = leaderboard.find(e => e.userId === userId);
|
const member = members.find(m => m.id === userId);
|
||||||
const pointsEarned = result.score * 2; // 2 pts per correct answer
|
await db.upsertLeaderboardEntry(userId, member?.name || 'Unknown', pointsEarned, 1);
|
||||||
|
|
||||||
if (entry) {
|
|
||||||
entry.points += pointsEarned;
|
|
||||||
entry.testsCompleted = (entry.testsCompleted || 0) + 1;
|
|
||||||
} else {
|
|
||||||
const users = storage.get('users:registry', []);
|
|
||||||
const user = users.find(u => u.id === userId);
|
|
||||||
leaderboard.push({
|
|
||||||
userId,
|
|
||||||
name: user?.name || 'Unknown',
|
|
||||||
points: pointsEarned,
|
|
||||||
testsCompleted: 1,
|
|
||||||
learningsCompleted: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort desc
|
|
||||||
leaderboard.sort((a, b) => b.points - a.points);
|
|
||||||
storage.set('leaderboard:current', leaderboard);
|
|
||||||
|
|
||||||
return { pointsEarned };
|
return { pointsEarned };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getTestResult(userId, weekNumber) {
|
||||||
* Get a previously completed test result, or null.
|
return db.getQuizResult(userId, weekNumber);
|
||||||
*/
|
|
||||||
export function getTestResult(userId, weekNumber) {
|
|
||||||
return storage.get(`quiz:result:${userId}:week:${weekNumber}`, null);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Tag from '../../components/ui/Tag';
|
|||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
import { storage } from '../../lib/storage';
|
import { storage } from '../../lib/storage';
|
||||||
|
import * as db from '../../lib/db';
|
||||||
import UploadZone from '../../components/admin/UploadZone';
|
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';
|
||||||
@@ -20,8 +21,9 @@ const Admin = () => {
|
|||||||
const [useSimulation, setUseSimulation] = useState(false);
|
const [useSimulation, setUseSimulation] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState(null);
|
const [saveStatus, setSaveStatus] = useState(null);
|
||||||
|
|
||||||
const loadSources = () => {
|
const loadSources = async () => {
|
||||||
setSources(storage.get('admin:sources', []));
|
const data = await db.getSources();
|
||||||
|
setSources(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,11 +46,10 @@ const Admin = () => {
|
|||||||
setTimeout(() => setSaveStatus(null), 3000);
|
setTimeout(() => setSaveStatus(null), 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteSource = (id) => {
|
const handleDeleteSource = async (id) => {
|
||||||
if (confirm('Are you sure you want to delete this source? This will not delete topics already extracted.')) {
|
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);
|
await db.deleteSource(id);
|
||||||
storage.set('admin:sources', updated);
|
await loadSources();
|
||||||
setSources(updated);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,7 +64,6 @@ const Admin = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col md:flex-row h-[calc(100vh-64px)] overflow-hidden">
|
<div className="flex flex-col md:flex-row h-[calc(100vh-64px)] overflow-hidden">
|
||||||
{/* Admin Sidebar */}
|
|
||||||
<div className="w-full md:w-64 bg-paper border-r border-bg-warm flex-shrink-0 flex flex-row md:flex-col p-4 gap-2 overflow-x-auto md:overflow-y-auto">
|
<div className="w-full md:w-64 bg-paper border-r border-bg-warm flex-shrink-0 flex flex-row md:flex-col p-4 gap-2 overflow-x-auto md:overflow-y-auto">
|
||||||
<h2 className="hidden md:block text-teal font-bold text-lg mb-4 px-2">Knowledge Mgmt</h2>
|
<h2 className="hidden md:block text-teal font-bold text-lg mb-4 px-2">Knowledge Mgmt</h2>
|
||||||
|
|
||||||
@@ -79,10 +79,8 @@ const Admin = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin Main Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 md:p-8 bg-bg pb-24 md:pb-8">
|
<div className="flex-1 overflow-y-auto p-4 md:p-8 bg-bg pb-24 md:pb-8">
|
||||||
|
|
||||||
{/* ── Sources ─────────────────────────────── */}
|
|
||||||
{activeTab === 'sources' && (
|
{activeTab === 'sources' && (
|
||||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||||
<h1 className="text-3xl text-teal mb-2">Source Material</h1>
|
<h1 className="text-3xl text-teal mb-2">Source Material</h1>
|
||||||
@@ -109,7 +107,7 @@ const Admin = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{source.name}</p>
|
<p className="font-medium">{source.name}</p>
|
||||||
<p className="text-xs text-fg-muted flex items-center gap-1 mt-1">
|
<p className="text-xs text-fg-muted flex items-center gap-1 mt-1">
|
||||||
<Clock size={12} /> {new Date(source.date).toLocaleString()}
|
<Clock size={12} /> {new Date(source.created).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,7 +127,6 @@ const Admin = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Content Manager ──────────────────────── */}
|
|
||||||
{activeTab === 'content' && (
|
{activeTab === 'content' && (
|
||||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||||
<h1 className="text-3xl text-teal mb-2">Learning Content</h1>
|
<h1 className="text-3xl text-teal mb-2">Learning Content</h1>
|
||||||
@@ -138,7 +135,6 @@ const Admin = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Test Manager ─────────────────────────── */}
|
|
||||||
{activeTab === 'tests' && (
|
{activeTab === 'tests' && (
|
||||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||||
<h1 className="text-3xl text-teal mb-2">Question Banks</h1>
|
<h1 className="text-3xl text-teal mb-2">Question Banks</h1>
|
||||||
@@ -147,7 +143,6 @@ const Admin = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Knowledge Graph ──────────────────────── */}
|
|
||||||
{activeTab === 'graph' && (
|
{activeTab === 'graph' && (
|
||||||
<div className="animate-in fade-in duration-300 h-full flex flex-col">
|
<div className="animate-in fade-in duration-300 h-full flex flex-col">
|
||||||
<h1 className="text-3xl text-teal mb-2">Knowledge Graph</h1>
|
<h1 className="text-3xl text-teal mb-2">Knowledge Graph</h1>
|
||||||
@@ -158,7 +153,6 @@ const Admin = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Team ────────────────────────────────── */}
|
|
||||||
{activeTab === 'team' && (
|
{activeTab === 'team' && (
|
||||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
<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>
|
||||||
@@ -167,7 +161,6 @@ const Admin = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Settings ────────────────────────────── */}
|
|
||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && (
|
||||||
<div className="animate-in fade-in duration-300 max-w-2xl">
|
<div className="animate-in fade-in duration-300 max-w-2xl">
|
||||||
<h1 className="text-3xl text-teal mb-2">Settings</h1>
|
<h1 className="text-3xl text-teal mb-2">Settings</h1>
|
||||||
|
|||||||
@@ -1,47 +1,62 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useApp } from '../store/AppContext';
|
import { useApp } from '../store/AppContext';
|
||||||
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 * as db from '../lib/db';
|
||||||
import { storage } from '../lib/storage';
|
|
||||||
import { getAssignedTopic } from '../lib/learningService';
|
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 [dashData, setDashData] = useState({
|
||||||
const learnDone = storage.get(`user:${currentUser?.id}:week:${weekNumber}:learn_done`, false);
|
topic: null,
|
||||||
const testResult = getTestResult(currentUser?.id, weekNumber);
|
learnDone: false,
|
||||||
|
testResult: null,
|
||||||
|
top3: [],
|
||||||
|
myRank: 0,
|
||||||
|
myPoints: 0,
|
||||||
|
activity: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const [topic, learnDone, testResult, allUsers, leaderboard] = await Promise.all([
|
||||||
|
getAssignedTopic(currentUser.id, weekNumber),
|
||||||
|
db.getLearnDone(currentUser.id, weekNumber),
|
||||||
|
db.getQuizResult(currentUser.id, weekNumber),
|
||||||
|
db.getTeamMembers(),
|
||||||
|
db.getLeaderboard(),
|
||||||
|
]);
|
||||||
|
|
||||||
const allUsers = storage.get('users:registry', []);
|
|
||||||
const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id);
|
const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id);
|
||||||
|
const filtered = leaderboard.filter(u => nonAdminIds.includes(u.user_id));
|
||||||
|
const top3 = filtered.slice(0, 3);
|
||||||
|
const myRank = filtered.findIndex(u => u.user_id === currentUser.id) + 1;
|
||||||
|
const myPoints = filtered.find(u => u.user_id === currentUser.id)?.points || 0;
|
||||||
|
|
||||||
const leaderboard = storage.get('leaderboard:current', [])
|
|
||||||
.filter(u => nonAdminIds.includes(u.userId))
|
|
||||||
.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 = [];
|
const activity = [];
|
||||||
for (let w = weekNumber; w >= Math.max(1, weekNumber - 3); w--) {
|
for (let w = weekNumber; w >= Math.max(1, weekNumber - 3); w--) {
|
||||||
const pastLearn = storage.get(`user:${currentUser?.id}:week:${w}:learn_done`, false);
|
const [pastLearn, pastTest, pastTopic] = await Promise.all([
|
||||||
const pastTest = getTestResult(currentUser?.id, w);
|
db.getLearnDone(currentUser.id, w),
|
||||||
const pastTopic = getAssignedTopic(currentUser?.id, w);
|
db.getQuizResult(currentUser.id, w),
|
||||||
|
getAssignedTopic(currentUser.id, w),
|
||||||
|
]);
|
||||||
|
if (pastTest) activity.push({ type: 'test', week: w, topic: pastTopic?.label, score: pastTest.percentage, points: pastTest.points_earned });
|
||||||
|
if (pastLearn) activity.push({ type: 'learn', week: w, topic: pastTopic?.label });
|
||||||
|
}
|
||||||
|
|
||||||
if (pastTest) {
|
setDashData({ topic, learnDone, testResult, top3, myRank, myPoints, activity });
|
||||||
activity.push({ type: 'test', week: w, topic: pastTopic?.label, score: pastTest.percentage, points: pastTest.pointsEarned });
|
};
|
||||||
}
|
|
||||||
if (pastLearn) {
|
load();
|
||||||
activity.push({ type: 'learn', week: w, topic: pastTopic?.label });
|
}, [currentUser, weekNumber]);
|
||||||
}
|
|
||||||
}
|
const { topic, learnDone, testResult, top3, myRank, myPoints, activity } = dashData;
|
||||||
|
|
||||||
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">
|
||||||
@@ -127,10 +142,10 @@ const Dashboard = () => {
|
|||||||
<div className="text-center text-fg-muted py-4">No points yet</div>
|
<div className="text-center text-fg-muted py-4">No points yet</div>
|
||||||
) : (
|
) : (
|
||||||
top3.map((u, i) => (
|
top3.map((u, i) => (
|
||||||
<div key={u.userId} className="flex items-center justify-between">
|
<div key={u.user_id} className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<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-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>
|
<span className="font-medium">{u.name} {u.user_id === currentUser?.id && '(You)'}</span>
|
||||||
</div>
|
</div>
|
||||||
<Tag variant="dark">{u.points} pts</Tag>
|
<Tag variant="dark">{u.points} pts</Tag>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ 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';
|
||||||
import { useApp } from '../store/AppContext';
|
import { useApp } from '../store/AppContext';
|
||||||
import { storage } from '../lib/storage';
|
import * as db from '../lib/db';
|
||||||
|
|
||||||
const BADGE_RULES = [
|
const BADGE_RULES = [
|
||||||
{ id: 'first_test', icon: Star, label: 'First Steps', condition: (u) => u.testsCompleted > 0 },
|
{ id: 'first_test', icon: Star, label: 'First Steps', condition: (u) => u.tests_completed > 0 },
|
||||||
{ id: 'perfectionist', icon: Award, label: 'Perfectionist', condition: (u) => u.perfectScores > 0 },
|
{ id: 'perfectionist', icon: Award, label: 'Perfectionist', condition: (u) => u.perfectScores > 0 },
|
||||||
{ id: 'veteran', icon: Medal, label: 'Veteran', condition: (u) => u.testsCompleted >= 5 },
|
{ id: 'veteran', icon: Medal, label: 'Veteran', condition: (u) => u.tests_completed >= 5 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const Leaderboard = () => {
|
const Leaderboard = () => {
|
||||||
@@ -17,40 +17,38 @@ const Leaderboard = () => {
|
|||||||
const [board, setBoard] = useState([]);
|
const [board, setBoard] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Re-evaluate badges before displaying
|
const load = async () => {
|
||||||
let data = storage.get('leaderboard:current', []);
|
const [leaderboardData, allUsers] = await Promise.all([
|
||||||
const allUsers = storage.get('users:registry', []);
|
db.getLeaderboard(),
|
||||||
|
db.getTeamMembers(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Filter out admins from the leaderboard entirely
|
|
||||||
const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id);
|
const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id);
|
||||||
data = data.filter(entry => nonAdminIds.includes(entry.userId));
|
let data = leaderboardData.filter(entry => nonAdminIds.includes(entry.user_id));
|
||||||
|
|
||||||
const userMap = new Map(data.map(u => [u.userId, u]));
|
// Ensure all non-admin users appear even if they have no points yet
|
||||||
|
const inBoard = new Set(data.map(e => e.user_id));
|
||||||
allUsers.forEach(user => {
|
for (const user of allUsers) {
|
||||||
if (!userMap.has(user.id) && user.role !== 'admin') {
|
if (!inBoard.has(user.id) && user.role !== 'admin') {
|
||||||
data.push({
|
data.push({ user_id: user.id, name: user.name, points: 0, tests_completed: 0, perfectScores: 0 });
|
||||||
userId: user.id,
|
}
|
||||||
name: user.name,
|
|
||||||
points: 0,
|
|
||||||
testsCompleted: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Check for perfect scores by peeking at test results
|
// Compute perfect scores per user
|
||||||
data = data.map(entry => {
|
data = await Promise.all(data.map(async entry => {
|
||||||
let perfectScores = 0;
|
let perfectScores = 0;
|
||||||
// Loop over all possible weeks
|
|
||||||
for (let w = 1; w <= state.weekNumber; w++) {
|
for (let w = 1; w <= state.weekNumber; w++) {
|
||||||
const result = storage.get(`quiz:result:${entry.userId}:week:${w}`);
|
const result = await db.getQuizResult(entry.user_id, w);
|
||||||
if (result && result.percentage === 100) perfectScores++;
|
if (result && result.percentage === 100) perfectScores++;
|
||||||
}
|
}
|
||||||
return { ...entry, perfectScores };
|
return { ...entry, perfectScores };
|
||||||
});
|
}));
|
||||||
|
|
||||||
data.sort((a, b) => b.points - a.points);
|
data.sort((a, b) => b.points - a.points);
|
||||||
setBoard(data);
|
setBoard(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
}, [state.weekNumber]);
|
}, [state.weekNumber]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,10 +59,8 @@ const Leaderboard = () => {
|
|||||||
<p className="text-fg-muted">Compete, learn, and earn badges based on your weekly knowledge tests.</p>
|
<p className="text-fg-muted">Compete, learn, and earn badges based on your weekly knowledge tests.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top 3 Podium */}
|
|
||||||
{board.length >= 3 && (
|
{board.length >= 3 && (
|
||||||
<div className="flex justify-center items-end gap-2 md:gap-6 mb-16 h-64 mt-12">
|
<div className="flex justify-center items-end gap-2 md:gap-6 mb-16 h-64 mt-12">
|
||||||
{/* 2nd Place */}
|
|
||||||
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.2 }} className="w-1/3 max-w-[140px] flex flex-col items-center">
|
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.2 }} className="w-1/3 max-w-[140px] flex flex-col items-center">
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
<div className="w-16 h-16 rounded-full bg-slate-200 border-4 border-paper flex items-center justify-center text-slate-500 font-bold text-2xl shadow-lg z-10 relative">2</div>
|
<div className="w-16 h-16 rounded-full bg-slate-200 border-4 border-paper flex items-center justify-center text-slate-500 font-bold text-2xl shadow-lg z-10 relative">2</div>
|
||||||
@@ -75,7 +71,6 @@ const Leaderboard = () => {
|
|||||||
<div className="w-full h-24 bg-gradient-to-t from-slate-200/50 to-transparent rounded-t-[var(--r-sm)] mt-4"></div>
|
<div className="w-full h-24 bg-gradient-to-t from-slate-200/50 to-transparent rounded-t-[var(--r-sm)] mt-4"></div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 1st Place */}
|
|
||||||
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="w-1/3 max-w-[160px] flex flex-col items-center z-10">
|
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="w-1/3 max-w-[160px] flex flex-col items-center z-10">
|
||||||
<div className="relative mb-6">
|
<div className="relative mb-6">
|
||||||
<div className="w-20 h-20 rounded-full bg-yellow-400 border-4 border-paper flex items-center justify-center text-yellow-800 font-bold text-3xl shadow-xl z-10 relative">1</div>
|
<div className="w-20 h-20 rounded-full bg-yellow-400 border-4 border-paper flex items-center justify-center text-yellow-800 font-bold text-3xl shadow-xl z-10 relative">1</div>
|
||||||
@@ -87,7 +82,6 @@ const Leaderboard = () => {
|
|||||||
<div className="w-full h-32 bg-gradient-to-t from-yellow-400/20 to-transparent rounded-t-[var(--r-sm)] mt-4 border-t border-yellow-400/30"></div>
|
<div className="w-full h-32 bg-gradient-to-t from-yellow-400/20 to-transparent rounded-t-[var(--r-sm)] mt-4 border-t border-yellow-400/30"></div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 3rd Place */}
|
|
||||||
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.4 }} className="w-1/3 max-w-[140px] flex flex-col items-center">
|
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.4 }} className="w-1/3 max-w-[140px] flex flex-col items-center">
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
<div className="w-16 h-16 rounded-full bg-amber-700 border-4 border-paper flex items-center justify-center text-white font-bold text-2xl shadow-lg z-10 relative">3</div>
|
<div className="w-16 h-16 rounded-full bg-amber-700 border-4 border-paper flex items-center justify-center text-white font-bold text-2xl shadow-lg z-10 relative">3</div>
|
||||||
@@ -100,22 +94,20 @@ const Leaderboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Full List */}
|
|
||||||
<Card className="border border-bg-warm p-0 overflow-hidden">
|
<Card className="border border-bg-warm p-0 overflow-hidden">
|
||||||
<div className="divide-y divide-bg-warm">
|
<div className="divide-y divide-bg-warm">
|
||||||
{board.map((user, index) => {
|
{board.map((user, index) => {
|
||||||
const earnedBadges = BADGE_RULES.filter(r => r.condition(user));
|
const earnedBadges = BADGE_RULES.filter(r => r.condition(user));
|
||||||
const isMe = user.userId === state.currentUser?.id;
|
const isMe = user.user_id === state.currentUser?.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ delay: index * 0.05 }}
|
transition={{ delay: index * 0.05 }}
|
||||||
key={user.userId}
|
key={user.user_id}
|
||||||
className={`p-4 flex flex-col sm:flex-row sm:items-center gap-4 transition-colors ${isMe ? 'bg-teal/5 border-l-4 border-teal' : 'hover:bg-bg-warm/30'}`}
|
className={`p-4 flex flex-col sm:flex-row sm:items-center gap-4 transition-colors ${isMe ? 'bg-teal/5 border-l-4 border-teal' : 'hover:bg-bg-warm/30'}`}
|
||||||
>
|
>
|
||||||
{/* Rank & Name */}
|
|
||||||
<div className="flex items-center gap-4 min-w-[200px]">
|
<div className="flex items-center gap-4 min-w-[200px]">
|
||||||
<span className={`font-mono text-lg font-bold w-6 text-center ${
|
<span className={`font-mono text-lg font-bold w-6 text-center ${
|
||||||
index === 0 ? 'text-yellow-500' :
|
index === 0 ? 'text-yellow-500' :
|
||||||
@@ -130,12 +122,11 @@ const Leaderboard = () => {
|
|||||||
{isMe && <Tag variant="accent" className="text-[10px]">You</Tag>}
|
{isMe && <Tag variant="accent" className="text-[10px]">You</Tag>}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-fg-muted flex items-center gap-1">
|
<p className="text-xs text-fg-muted flex items-center gap-1">
|
||||||
<CheckSquare size={12} /> {user.testsCompleted || 0} tests completed
|
<CheckSquare size={12} /> {user.tests_completed || 0} tests completed
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Points */}
|
|
||||||
<div className="flex-1 flex sm:justify-center">
|
<div className="flex-1 flex sm:justify-center">
|
||||||
<div className="flex items-center gap-1.5 text-teal font-bold bg-teal/10 px-3 py-1 rounded-full text-sm">
|
<div className="flex items-center gap-1.5 text-teal font-bold bg-teal/10 px-3 py-1 rounded-full text-sm">
|
||||||
<TrendingUp size={14} />
|
<TrendingUp size={14} />
|
||||||
@@ -143,7 +134,6 @@ const Leaderboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Badges */}
|
|
||||||
<div className="flex gap-2 sm:justify-end min-w-[120px]">
|
<div className="flex gap-2 sm:justify-end min-w-[120px]">
|
||||||
{earnedBadges.length > 0 ? (
|
{earnedBadges.length > 0 ? (
|
||||||
earnedBadges.map(b => (
|
earnedBadges.map(b => (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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, generateCustomTopic } from '../lib/learningService';
|
import { getAssignedTopic, generateLearningContent, getCachedContent, generateCustomTopic } from '../lib/learningService';
|
||||||
import { storage } from '../lib/storage';
|
import * as db from '../lib/db';
|
||||||
|
|
||||||
const Leren = () => {
|
const Leren = () => {
|
||||||
const { state } = useApp();
|
const { state } = useApp();
|
||||||
@@ -39,12 +39,17 @@ const Leren = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.currentUser) {
|
if (state.currentUser) {
|
||||||
const assigned = getAssignedTopic(state.currentUser.id, state.weekNumber);
|
const load = async () => {
|
||||||
|
const [assigned, topics, done] = await Promise.all([
|
||||||
|
getAssignedTopic(state.currentUser.id, state.weekNumber),
|
||||||
|
db.getTopics(),
|
||||||
|
db.getLearnDone(state.currentUser.id, state.weekNumber),
|
||||||
|
]);
|
||||||
setAssignedTopic(assigned);
|
setAssignedTopic(assigned);
|
||||||
setAllTopics(storage.get('kb:topics', []));
|
setAllTopics(topics);
|
||||||
|
|
||||||
const done = storage.get(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, false);
|
|
||||||
if (done) setWeeklyDone(true);
|
if (done) setWeeklyDone(true);
|
||||||
|
};
|
||||||
|
load();
|
||||||
}
|
}
|
||||||
}, [state.currentUser, state.weekNumber]);
|
}, [state.currentUser, state.weekNumber]);
|
||||||
|
|
||||||
@@ -97,11 +102,11 @@ const Leren = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const doComplete = () => {
|
const doComplete = async () => {
|
||||||
setSessionDone(true);
|
setSessionDone(true);
|
||||||
if (!weeklyDone) {
|
if (!weeklyDone) {
|
||||||
setWeeklyDone(true);
|
setWeeklyDone(true);
|
||||||
storage.set(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, true);
|
await db.setLearnDone(state.currentUser.id, state.weekNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,14 +119,12 @@ const Leren = () => {
|
|||||||
doComplete();
|
doComplete();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitFeedback = () => {
|
const handleSubmitFeedback = async () => {
|
||||||
if (feedbackText.trim()) {
|
if (feedbackText.trim()) {
|
||||||
storage.set(`user:${state.currentUser.id}:feedback:${activeTopic.id}`, {
|
await db.setSetting(
|
||||||
text: feedbackText.trim(),
|
`feedback:${state.currentUser.id}:${activeTopic.id}:${state.weekNumber}`,
|
||||||
topicId: activeTopic.id,
|
feedbackText.trim()
|
||||||
week: state.weekNumber,
|
);
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
setShowFeedbackModal(false);
|
setShowFeedbackModal(false);
|
||||||
doComplete();
|
doComplete();
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { createContext, useContext, useReducer, useEffect } from 'react';
|
import React, { createContext, useContext, useReducer, useEffect } from 'react';
|
||||||
import { storage } from '../lib/storage';
|
import * as db from '../lib/db';
|
||||||
|
|
||||||
const AppContext = createContext();
|
const AppContext = createContext();
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
currentUser: null, // will hold user object if logged in
|
currentUser: null,
|
||||||
users: [], // array of registered users
|
users: [],
|
||||||
weekNumber: 1, // current learning week
|
weekNumber: 1,
|
||||||
isLoading: true
|
isLoading: true
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,25 +20,11 @@ function appReducer(state, action) {
|
|||||||
isLoading: false
|
isLoading: false
|
||||||
};
|
};
|
||||||
case 'LOGIN':
|
case 'LOGIN':
|
||||||
return {
|
return { ...state, currentUser: action.payload };
|
||||||
...state,
|
|
||||||
currentUser: action.payload
|
|
||||||
};
|
|
||||||
case 'LOGOUT':
|
case 'LOGOUT':
|
||||||
return {
|
return { ...state, currentUser: null };
|
||||||
...state,
|
|
||||||
currentUser: null
|
|
||||||
};
|
|
||||||
case 'REGISTER_USER':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
users: [...state.users, action.payload]
|
|
||||||
};
|
|
||||||
case 'ADVANCE_WEEK':
|
case 'ADVANCE_WEEK':
|
||||||
return {
|
return { ...state, weekNumber: state.weekNumber + 1 };
|
||||||
...state,
|
|
||||||
weekNumber: state.weekNumber + 1
|
|
||||||
};
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -47,48 +33,31 @@ function appReducer(state, action) {
|
|||||||
export function AppProvider({ children }) {
|
export function AppProvider({ children }) {
|
||||||
const [state, dispatch] = useReducer(appReducer, initialState);
|
const [state, dispatch] = useReducer(appReducer, initialState);
|
||||||
|
|
||||||
// Initialize app state from storage
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadState = () => {
|
const loadState = async () => {
|
||||||
let users = storage.get('users:registry');
|
let members = await db.getTeamMembers();
|
||||||
|
|
||||||
// Seed first admin if no users exist
|
if (!members || members.length === 0) {
|
||||||
if (!users || users.length === 0) {
|
const created = await db.addTeamMember({ name: 'Admin', role: 'admin', pin: '0000' });
|
||||||
const initialAdmin = {
|
members = [created];
|
||||||
id: 'u_1',
|
|
||||||
name: 'Admin',
|
|
||||||
role: 'admin',
|
|
||||||
pin: '0000',
|
|
||||||
registeredAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
users = [initialAdmin];
|
|
||||||
storage.set('users:registry', users);
|
|
||||||
|
|
||||||
// Also seed initial empty leaderboard
|
|
||||||
storage.set('leaderboard:current', []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedWeek = storage.get('admin:current_week') || 1;
|
const storedWeek = Number(await db.getSetting('admin:current_week', 1));
|
||||||
|
|
||||||
// Automatically login if we saved session (optional, simpler to require login per session)
|
|
||||||
const sessionUserId = sessionStorage.getItem('respellion_session');
|
const sessionUserId = sessionStorage.getItem('respellion_session');
|
||||||
if (sessionUserId) {
|
if (sessionUserId) {
|
||||||
const user = users.find(u => u.id === sessionUserId);
|
const user = members.find(u => u.id === sessionUserId);
|
||||||
if (user) {
|
if (user) {
|
||||||
dispatch({ type: 'LOGIN', payload: user });
|
dispatch({ type: 'LOGIN', payload: user });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch({
|
dispatch({ type: 'INIT_APP', payload: { users: members, weekNumber: storedWeek } });
|
||||||
type: 'INIT_APP',
|
|
||||||
payload: { users, weekNumber: storedWeek }
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
loadState();
|
loadState().catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Expose dispatch actions as convenient methods
|
|
||||||
const login = (userId, pin) => {
|
const login = (userId, pin) => {
|
||||||
const user = state.users.find(u => u.id === userId && u.pin === pin);
|
const user = state.users.find(u => u.id === userId && u.pin === pin);
|
||||||
if (user) {
|
if (user) {
|
||||||
|
|||||||
Reference in New Issue
Block a user