initial commit

This commit is contained in:
Marius Rometsch
2026-02-05 22:42:30 +01:00
commit 0068785924
66 changed files with 13795 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# Multi-stage build for frontend
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build argument for API URL
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
# Build application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --quiet --tries=1 --spider http://localhost:80 || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Security Awareness Learning Platform" />
<title>Security Learning Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Handle React Router - all routes go to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Prevent access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Prevent access to backup files
location ~ ~$ {
deny all;
access_log off;
log_not_found off;
}
}
+2033
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "lernplattform-frontend",
"version": "1.0.0",
"description": "Frontend for Security Awareness Learning Platform",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.1",
"axios": "^1.6.2"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8"
}
}
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { ParticipantProvider } from './contexts/ParticipantContext';
import { AdminProvider } from './contexts/AdminContext';
import AppRoutes from './routes/AppRoutes';
function App() {
return (
<BrowserRouter>
<ParticipantProvider>
<AdminProvider>
<AppRoutes />
</AdminProvider>
</ParticipantProvider>
</BrowserRouter>
);
}
export default App;
@@ -0,0 +1,367 @@
import React, { useState } from 'react';
const BitBDemo = ({ lessonData }) => {
const [activeScenario, setActiveScenario] = useState(null);
const [dragAttempted, setDragAttempted] = useState(false);
const [inspectAttempted, setInspectAttempted] = useState(false);
const interactiveData = lessonData?.interactiveData || {};
const scenarios = interactiveData.scenarios || [];
const testInstructions = interactiveData.testInstructions || [];
const closeScenario = () => {
setActiveScenario(null);
setDragAttempted(false);
setInspectAttempted(false);
};
const handleDragStart = (e, isReal) => {
setDragAttempted(true);
if (!isReal) {
e.preventDefault(); // Fake window can't be dragged
}
};
const handleAddressBarRightClick = (e, isReal) => {
if (!isReal) {
setInspectAttempted(true);
// Allow right-click menu to show "Inspect Element" on fake popup
}
};
const renderFakeBrowser = (scenario) => {
const providerStyles = {
Google: { primary: '#4285f4', secondary: '#ea4335' },
Microsoft: { primary: '#00a4ef', secondary: '#7fba00' }
};
const colors = providerStyles[scenario.provider] || providerStyles.Google;
return (
<div
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '500px',
background: 'white',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
borderRadius: '8px',
zIndex: 9999,
overflow: 'hidden',
cursor: scenario.isReal ? 'move' : 'default'
}}
draggable={scenario.isReal}
onDragStart={(e) => handleDragStart(e, scenario.isReal)}
>
{/* Fake Browser Chrome */}
<div
style={{
background: '#f1f3f4',
padding: '8px 12px',
borderBottom: '1px solid #dadce0',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
onContextMenu={(e) => {
if (!scenario.isReal) {
handleAddressBarRightClick(e, scenario.isReal);
}
}}
>
{/* Window Controls */}
<div style={{ display: 'flex', gap: '6px', marginRight: '8px' }}>
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: '#ff5f56' }}></div>
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: '#ffbd2e' }}></div>
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: '#27c93f' }}></div>
</div>
{/* Fake Address Bar */}
<div
style={{
flex: 1,
background: 'white',
borderRadius: '20px',
padding: '6px 12px',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '13px',
color: '#5f6368',
border: '1px solid #dadce0',
userSelect: scenario.isReal ? 'text' : 'none'
}}
>
<span style={{ color: '#0f9d58', fontWeight: 'bold' }}>🔒</span>
<span style={{ color: '#202124' }}>{scenario.domain}</span>
</div>
</div>
{/* Login Form Content */}
<div style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ fontSize: '24px', marginBottom: '8px' }}>
{scenario.provider === 'Google' ? (
<span style={{ color: colors.primary, fontWeight: '500' }}>Google</span>
) : (
<span style={{ color: colors.primary, fontWeight: '500' }}>Microsoft</span>
)}
</div>
<div style={{ fontSize: '16px', color: '#5f6368', marginBottom: '24px' }}>
Sign in to continue
</div>
<input
type="email"
placeholder="Email or phone"
style={{
width: '100%',
padding: '12px',
border: '1px solid #dadce0',
borderRadius: '4px',
marginBottom: '16px',
fontSize: '14px'
}}
/>
<input
type="password"
placeholder="Password"
style={{
width: '100%',
padding: '12px',
border: '1px solid #dadce0',
borderRadius: '4px',
marginBottom: '24px',
fontSize: '14px'
}}
/>
<button
style={{
width: '100%',
padding: '12px',
background: colors.primary,
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer'
}}
>
Sign in
</button>
</div>
{/* Warning Badge */}
<div
style={{
position: 'absolute',
top: '50%',
right: '-120px',
transform: 'translateY(-50%)',
background: scenario.isReal ? '#10b981' : '#ef4444',
color: 'white',
padding: '8px 16px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '600',
boxShadow: '0 4px 12px rgba(0,0,0,0.2)'
}}
>
{scenario.isReal ? '✅ REAL' : '⚠️ FAKE'}
</div>
{/* Close Button */}
<button
onClick={closeScenario}
style={{
position: 'absolute',
top: '16px',
right: '16px',
background: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '4px',
padding: '8px 16px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '600',
zIndex: 10000
}}
>
Close Demo
</button>
{/* Feedback Messages */}
{dragAttempted && !scenario.isReal && (
<div
style={{
position: 'absolute',
bottom: '16px',
left: '50%',
transform: 'translateX(-50%)',
background: '#fef3c7',
color: '#92400e',
padding: '8px 16px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}}
>
⚠️ Notice: This fake window cannot be dragged!
</div>
)}
{inspectAttempted && !scenario.isReal && (
<div
style={{
position: 'absolute',
top: '80px',
left: '50%',
transform: 'translateX(-50%)',
background: '#fef3c7',
color: '#92400e',
padding: '8px 16px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500',
width: '80%',
textAlign: 'center'
}}
>
⚠️ Right-click works! This reveals it's just HTML, not a real browser.
</div>
)}
</div>
);
};
return (
<div style={{ border: '1px solid #e5e7eb', borderRadius: '0.5rem', padding: '1.5rem', background: 'white' }}>
<h4 style={{ marginBottom: '1rem', color: '#1f2937' }}>🎭 Browser-in-the-Browser Attack Demo</h4>
{/* Test Instructions */}
<div style={{ marginBottom: '1.5rem', padding: '1rem', background: '#eff6ff', border: '1px solid #3b82f6', borderRadius: '0.375rem' }}>
<div style={{ fontWeight: '600', color: '#1e40af', marginBottom: '0.5rem' }}>
🔍 How to Test:
</div>
<ul style={{ margin: 0, paddingLeft: '1.5rem', fontSize: '0.875rem', color: '#1e3a8a' }}>
{testInstructions.map((instruction, idx) => (
<li key={idx} style={{ marginBottom: '0.25rem' }}>{instruction}</li>
))}
</ul>
</div>
{/* Scenario Buttons */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1.5rem' }}>
{scenarios.map((scenario) => (
<div
key={scenario.id}
style={{
border: '2px solid #e5e7eb',
borderRadius: '0.5rem',
padding: '1rem',
background: 'white',
transition: 'all 0.2s'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '1.5rem' }}>
{scenario.isReal ? '✅' : '⚠️'}
</span>
<h5 style={{ margin: 0, color: '#1f2937' }}>{scenario.title}</h5>
</div>
<p style={{ fontSize: '0.875rem', color: '#6b7280', marginBottom: '1rem' }}>
{scenario.description}
</p>
<button
onClick={() => setActiveScenario(scenario)}
style={{
width: '100%',
padding: '0.75rem',
background: scenario.isReal ? '#10b981' : '#ef4444',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer',
fontSize: '0.875rem',
fontWeight: '500'
}}
>
Launch {scenario.provider} Login
</button>
{/* Indicators */}
<div style={{ marginTop: '1rem', fontSize: '0.75rem', color: '#6b7280' }}>
<div style={{ fontWeight: '600', marginBottom: '0.25rem' }}>Key Indicators:</div>
<ul style={{ margin: 0, paddingLeft: '1.25rem' }}>
{scenario.indicators.slice(0, 3).map((indicator, idx) => (
<li key={idx} style={{ marginBottom: '0.125rem' }}>{indicator}</li>
))}
</ul>
</div>
</div>
))}
</div>
{/* Educational Note */}
<div style={{ padding: '1rem', background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: '0.375rem', fontSize: '0.875rem' }}>
<div style={{ fontWeight: '600', color: '#92400e', marginBottom: '0.5rem' }}>
⚠️ Important
</div>
<div style={{ color: '#78350f' }}>
In a real Browser-in-the-Browser attack, the fake popup would look identical to the real one.
The ONLY reliable way to detect it is by testing the physical behavior: Can you drag it outside the browser window?
Can you right-click the address bar and inspect it as HTML?
</div>
</div>
{/* Real World Examples */}
{interactiveData.realWorldExamples && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.375rem' }}>
<div style={{ fontWeight: '600', fontSize: '0.875rem', marginBottom: '0.75rem', color: '#1f2937' }}>
📰 Real-World BitB Attacks:
</div>
<div style={{ display: 'grid', gap: '0.5rem' }}>
{interactiveData.realWorldExamples.map((example, idx) => (
<div key={idx} style={{ fontSize: '0.875rem', padding: '0.5rem', background: 'white', borderRadius: '0.25rem', border: '1px solid #e5e7eb' }}>
<span style={{ fontWeight: '600', color: '#ef4444' }}>{example.year}</span>
{' - '}
<span style={{ color: '#1f2937' }}>{example.target}</span>
{' via '}
<span style={{ color: '#2563eb' }}>{example.provider}</span>
</div>
))}
</div>
</div>
)}
{/* Active Scenario Overlay */}
{activeScenario && (
<>
{/* Dark Overlay */}
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
zIndex: 9998
}}
onClick={closeScenario}
></div>
{/* Fake Browser Window */}
{renderFakeBrowser(activeScenario)}
</>
)}
</div>
);
};
export default BitBDemo;
@@ -0,0 +1,272 @@
import React, { useState } from 'react';
import { participantAPI } from '../../../services/api.service';
const SQLShopDemo = ({ lessonData, eventLessonId }) => {
const [searchTerm, setSearchTerm] = useState('');
const [queryResult, setQueryResult] = useState(null);
const [loading, setLoading] = useState(false);
const [showSafeComparison, setShowSafeComparison] = useState(false);
const interactiveData = lessonData?.interactiveData || {};
const examples = interactiveData.examples || [];
const executeSearch = async (term = searchTerm) => {
if (!term.trim()) return;
setLoading(true);
try {
const response = await participantAPI.executeLessonAction(
eventLessonId,
'execute-query',
{ searchTerm: term, mode: 'vulnerable' }
);
setQueryResult(response.data.data);
} catch (error) {
console.error('Failed to execute query:', error);
setQueryResult({
query: 'Error executing query',
results: [],
explanation: 'Failed to execute search'
});
} finally {
setLoading(false);
}
};
const executeSafeSearch = async () => {
if (!searchTerm.trim()) return;
setLoading(true);
try {
const response = await participantAPI.executeLessonAction(
eventLessonId,
'execute-query',
{ searchTerm, mode: 'safe' }
);
setQueryResult(response.data.data);
setShowSafeComparison(true);
} catch (error) {
console.error('Failed to execute safe query:', error);
} finally {
setLoading(false);
}
};
const loadExample = (example) => {
setSearchTerm(example.input);
setShowSafeComparison(false);
};
return (
<div style={{ border: '1px solid #e5e7eb', borderRadius: '0.5rem', padding: '1.5rem', background: 'white' }}>
<div style={{ marginBottom: '1.5rem' }}>
<h4 style={{ marginBottom: '1rem', color: '#1f2937' }}>🛒 TechShop - Product Search</h4>
<p style={{ fontSize: '0.875rem', color: '#6b7280', marginBottom: '1rem' }}>
This is a vulnerable online shop. Try searching for products, then experiment with SQL injection.
</p>
{/* Example Queries */}
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: '0.875rem', fontWeight: '500', marginBottom: '0.5rem' }}>
Try these examples:
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{examples.map((example, idx) => (
<button
key={idx}
onClick={() => loadExample(example)}
style={{
padding: '0.5rem 0.75rem',
fontSize: '0.75rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.375rem',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseOver={(e) => {
e.target.style.background = '#e5e7eb';
}}
onMouseOut={(e) => {
e.target.style.background = '#f3f4f6';
}}
title={example.description}
>
{example.label}
</button>
))}
</div>
</div>
{/* Search Input */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && executeSearch()}
placeholder="Search products... (try SQL injection!)"
style={{
flex: 1,
padding: '0.75rem',
border: '2px solid #e5e7eb',
borderRadius: '0.375rem',
fontSize: '0.875rem',
fontFamily: 'monospace'
}}
/>
<button
onClick={() => executeSearch()}
disabled={loading || !searchTerm.trim()}
style={{
padding: '0.75rem 1.5rem',
background: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '0.875rem',
fontWeight: '500'
}}
>
{loading ? 'Searching...' : '🔍 Vulnerable Search'}
</button>
<button
onClick={executeSafeSearch}
disabled={loading || !searchTerm.trim()}
style={{
padding: '0.75rem 1.5rem',
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '0.875rem',
fontWeight: '500'
}}
>
{loading ? 'Searching...' : '✅ Safe Search'}
</button>
</div>
</div>
{/* Query Results */}
{queryResult && (
<div>
{/* SQL Query Display */}
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#1f2937', color: '#f3f4f6', borderRadius: '0.375rem', fontFamily: 'monospace', fontSize: '0.875rem' }}>
<div style={{ color: '#9ca3af', marginBottom: '0.5rem' }}>Executed SQL Query:</div>
<div style={{ color: '#fbbf24' }}>{queryResult.query}</div>
{queryResult.parameter && (
<div style={{ marginTop: '0.5rem' }}>
<span style={{ color: '#9ca3af' }}>Parameter: </span>
<span style={{ color: '#34d399' }}>'{queryResult.parameter}'</span>
</div>
)}
</div>
{/* Injection Detection */}
{queryResult.injectionDetected && (
<div style={{
padding: '1rem',
background: '#fee2e2',
border: '2px solid #ef4444',
borderRadius: '0.375rem',
marginBottom: '1rem'
}}>
<div style={{ fontWeight: '600', color: '#991b1b', marginBottom: '0.5rem' }}>
⚠️ SQL Injection Detected: {queryResult.injectionType?.replace(/_/g, ' ')}
</div>
<div style={{ fontSize: '0.875rem', color: '#7f1d1d' }}>
{queryResult.explanation}
</div>
</div>
)}
{/* Safe Query Explanation */}
{showSafeComparison && queryResult.explanation && queryResult.explanation.includes('✅') && (
<div style={{
padding: '1rem',
background: '#d1fae5',
border: '2px solid #10b981',
borderRadius: '0.375rem',
marginBottom: '1rem'
}}>
<div style={{ fontWeight: '600', color: '#065f46', marginBottom: '0.5rem' }}>
✅ Secure Query
</div>
<div style={{ fontSize: '0.875rem', color: '#047857' }}>
{queryResult.explanation}
</div>
</div>
)}
{/* Results Table */}
<div>
<div style={{ fontSize: '0.875rem', fontWeight: '500', marginBottom: '0.5rem', color: '#374151' }}>
Results: {queryResult.recordCount} {queryResult.recordCount === 1 ? 'record' : 'records'}
</div>
{queryResult.results.length > 0 ? (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ background: '#f9fafb', borderBottom: '2px solid #e5e7eb' }}>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>ID</th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>Name</th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>Price</th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>Category</th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>Stock</th>
</tr>
</thead>
<tbody>
{queryResult.results.map((product, idx) => (
<tr
key={idx}
style={{
borderBottom: '1px solid #e5e7eb',
background: product.id === 'INJECTED' ? '#fee2e2' : 'white'
}}
>
<td style={{ padding: '0.75rem' }}>{product.id}</td>
<td style={{ padding: '0.75rem', fontFamily: product.id === 'INJECTED' ? 'monospace' : 'inherit' }}>
{product.name}
</td>
<td style={{ padding: '0.75rem', fontFamily: product.id === 'INJECTED' ? 'monospace' : 'inherit' }}>
{typeof product.price === 'number' ? `$${product.price.toFixed(2)}` : product.price}
</td>
<td style={{ padding: '0.75rem' }}>{product.category}</td>
<td style={{ padding: '0.75rem' }}>{product.stock}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div style={{ padding: '2rem', textAlign: 'center', color: '#6b7280', background: '#f9fafb', borderRadius: '0.375rem' }}>
{queryResult.injectionType === 'DROP_TABLE'
? '💥 Table would be deleted! (Simulated - no actual data was harmed)'
: 'No products found'
}
</div>
)}
</div>
</div>
)}
{/* Educational Note */}
{!queryResult && (
<div style={{ padding: '1.5rem', background: '#eff6ff', border: '1px solid #3b82f6', borderRadius: '0.375rem', fontSize: '0.875rem' }}>
<div style={{ fontWeight: '600', color: '#1e40af', marginBottom: '0.5rem' }}>
💡 Learning Tip
</div>
<div style={{ color: '#1e3a8a' }}>
Start with a normal search like "laptop" to see how the query works. Then try the SQL injection examples to understand how attackers manipulate queries.
</div>
</div>
)}
</div>
);
};
export default SQLShopDemo;
+80
View File
@@ -0,0 +1,80 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { adminSessionService } from '../services/session.service';
import { adminAPI } from '../services/api.service';
const AdminContext = createContext();
export const useAdmin = () => {
const context = useContext(AdminContext);
if (!context) {
throw new Error('useAdmin must be used within AdminProvider');
}
return context;
};
export const AdminProvider = ({ children }) => {
const [admin, setAdmin] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check for existing session
const token = adminSessionService.getToken();
if (token) {
const savedAdmin = adminSessionService.getAdmin();
if (savedAdmin) {
setAdmin(savedAdmin);
setIsAuthenticated(true);
// Set token in axios
localStorage.setItem('token', token);
}
}
setLoading(false);
}, []);
const login = async (username, password) => {
try {
const response = await adminAPI.login(username, password);
const { token, admin: newAdmin } = response.data.data;
// Save to localStorage
adminSessionService.saveSession(token, newAdmin);
// Also set for axios interceptor
localStorage.setItem('token', token);
// Update state
setAdmin(newAdmin);
setIsAuthenticated(true);
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data?.error?.message || 'Login failed'
};
}
};
const logout = () => {
adminSessionService.clearSession();
localStorage.removeItem('token');
setAdmin(null);
setIsAuthenticated(false);
};
const value = {
admin,
isAuthenticated,
loading,
login,
logout
};
return (
<AdminContext.Provider value={value}>
{children}
</AdminContext.Provider>
);
};
export default AdminContext;
@@ -0,0 +1,81 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { sessionService } from '../services/session.service';
import { participantAPI } from '../services/api.service';
const ParticipantContext = createContext();
export const useParticipant = () => {
const context = useContext(ParticipantContext);
if (!context) {
throw new Error('useParticipant must be used within ParticipantProvider');
}
return context;
};
export const ParticipantProvider = ({ children }) => {
const [participant, setParticipant] = useState(null);
const [event, setEvent] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check for existing session
const token = sessionService.getToken();
if (token) {
const savedParticipant = sessionService.getParticipant();
const savedEvent = sessionService.getEvent();
if (savedParticipant && savedEvent) {
setParticipant(savedParticipant);
setEvent(savedEvent);
setIsAuthenticated(true);
}
}
setLoading(false);
}, []);
const joinEvent = async (pseudonym, eventId) => {
try {
const response = await participantAPI.joinEvent(pseudonym, eventId);
const { sessionToken, participant: newParticipant, event: newEvent } = response.data.data;
// Save to localStorage
sessionService.saveSession(sessionToken, newParticipant, newEvent);
// Update state
setParticipant(newParticipant);
setEvent(newEvent);
setIsAuthenticated(true);
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data?.error?.message || 'Failed to join event'
};
}
};
const logout = () => {
sessionService.clearSession();
setParticipant(null);
setEvent(null);
setIsAuthenticated(false);
};
const value = {
participant,
event,
isAuthenticated,
loading,
joinEvent,
logout
};
return (
<ParticipantContext.Provider value={value}>
{children}
</ParticipantContext.Provider>
);
};
export default ParticipantContext;
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+120
View File
@@ -0,0 +1,120 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useParticipant } from '../contexts/ParticipantContext';
import { participantAPI } from '../services/api.service';
const EventLanding = () => {
const [lessons, setLessons] = useState([]);
const [progress, setProgress] = useState(null);
const [loading, setLoading] = useState(true);
const { participant, event, logout } = useParticipant();
const navigate = useNavigate();
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [lessonsRes, progressRes] = await Promise.all([
participantAPI.getEventLessons(event.id),
participantAPI.getProgress()
]);
setLessons(lessonsRes.data.data);
setProgress(progressRes.data.data);
} catch (error) {
console.error('Failed to load data:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return <div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>;
}
return (
<div style={{ minHeight: '100vh', background: '#f9fafb' }}>
<nav style={{ background: 'white', padding: '1rem 2rem', borderBottom: '1px solid #e5e7eb', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, color: '#2563eb' }}>{event.name}</h2>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<span style={{ color: '#6b7280' }}>👤 {participant.pseudonym}</span>
<button onClick={logout} style={{ padding: '0.5rem 1rem', background: '#ef4444', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}>
Logout
</button>
</div>
</div>
</nav>
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '2rem' }}>
<div className="card" style={{ marginBottom: '2rem' }}>
<h3>Your Progress</h3>
<div style={{ display: 'flex', gap: '2rem', marginTop: '1rem' }}>
<div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#2563eb' }}>{progress.total_score}</div>
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>Total Score</div>
</div>
<div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#10b981' }}>
{progress.lessons_completed}/{progress.total_lessons_available}
</div>
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>Lessons Completed</div>
</div>
</div>
</div>
<h3 style={{ marginBottom: '1rem' }}>Lessons</h3>
<div style={{ display: 'grid', gap: '1rem' }}>
{lessons.map(lesson => (
<div
key={lesson.eventLessonId}
className="card"
style={{
opacity: lesson.isUnlocked ? 1 : 0.6,
cursor: lesson.isUnlocked ? 'pointer' : 'not-allowed',
transition: 'all 0.2s'
}}
onClick={() => lesson.isUnlocked && navigate(`/lesson/${lesson.eventLessonId}`)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<h4 style={{ margin: 0 }}>{lesson.title}</h4>
{!lesson.isUnlocked && (
<span style={{ fontSize: '1.25rem' }}>🔒</span>
)}
</div>
<p style={{ color: '#6b7280', marginBottom: '0.5rem' }}>{lesson.description}</p>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.875rem', color: '#6b7280' }}>
<span>⏱️ {lesson.estimatedDuration} min</span>
<span>⭐ {lesson.maxPoints} points</span>
<span style={{ textTransform: 'capitalize' }}>📊 {lesson.difficultyLevel}</span>
</div>
</div>
{lesson.progress && (
<div style={{ textAlign: 'right' }}>
{lesson.progress.status === 'completed' ? (
<div>
<div style={{ fontSize: '1.5rem' }}>✅</div>
<div style={{ fontSize: '0.875rem', color: '#10b981', fontWeight: '500' }}>
{lesson.progress.score}/{lesson.maxPoints}
</div>
</div>
) : lesson.progress.status === 'in_progress' ? (
<span style={{ padding: '0.25rem 0.75rem', background: '#fef3c7', color: '#f59e0b', borderRadius: '9999px', fontSize: '0.875rem' }}>
In Progress
</span>
) : null}
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
};
export default EventLanding;
+116
View File
@@ -0,0 +1,116 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useParticipant } from '../contexts/ParticipantContext';
import { participantAPI } from '../services/api.service';
const Hub = () => {
const [events, setEvents] = useState([]);
const [pseudonym, setPseudonym] = useState('');
const [selectedEvent, setSelectedEvent] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { isAuthenticated, joinEvent } = useParticipant();
const navigate = useNavigate();
useEffect(() => {
if (isAuthenticated) {
navigate('/event');
}
loadEvents();
}, [isAuthenticated, navigate]);
const loadEvents = async () => {
try {
const response = await participantAPI.getEvents();
setEvents(response.data.data);
} catch (err) {
console.error('Failed to load events:', err);
}
};
const handleJoin = async (e) => {
e.preventDefault();
setError('');
if (!pseudonym || !selectedEvent) {
setError('Please enter a pseudonym and select an event');
return;
}
setLoading(true);
const result = await joinEvent(pseudonym, selectedEvent);
if (result.success) {
navigate('/event');
} else {
setError(result.error);
setLoading(false);
}
};
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f9fafb' }}>
<div className="card" style={{ maxWidth: '500px', width: '100%' }}>
<h1 style={{ color: '#2563eb', marginBottom: '0.5rem' }}>Security Learning Platform</h1>
<p style={{ color: '#6b7280', marginBottom: '2rem' }}>Join an event to start learning</p>
{error && (
<div style={{ padding: '0.75rem', background: '#fee2e2', border: '1px solid #ef4444', borderRadius: '0.375rem', color: '#dc2626', marginBottom: '1rem' }}>
{error}
</div>
)}
<form onSubmit={handleJoin}>
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>
Your Pseudonym
</label>
<input
type="text"
value={pseudonym}
onChange={(e) => setPseudonym(e.target.value)}
placeholder="Enter a pseudonym"
disabled={loading}
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
/>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>
Select Event
</label>
<select
value={selectedEvent}
onChange={(e) => setSelectedEvent(e.target.value)}
disabled={loading}
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
>
<option value="">Choose an event...</option>
{events.map(event => (
<option key={event.id} value={event.id}>
{event.name}
</option>
))}
</select>
</div>
<button
type="submit"
disabled={loading}
style={{ width: '100%', padding: '0.75rem', background: '#2563eb', color: 'white', border: 'none', borderRadius: '0.375rem', fontWeight: '500', cursor: 'pointer' }}
>
{loading ? 'Joining...' : 'Join Event'}
</button>
</form>
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
<a href="/admin/login" style={{ color: '#6b7280', fontSize: '0.875rem' }}>
Admin Login
</a>
</div>
</div>
</div>
);
};
export default Hub;
+258
View File
@@ -0,0 +1,258 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { participantAPI } from '../services/api.service';
import SQLShopDemo from '../components/lessons/InteractiveContent/SQLShopDemo';
import BitBDemo from '../components/lessons/InteractiveContent/BitBDemo';
const LessonView = () => {
const { eventLessonId } = useParams();
const navigate = useNavigate();
const [lesson, setLesson] = useState(null);
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [answers, setAnswers] = useState({});
const [feedback, setFeedback] = useState({});
const [totalScore, setTotalScore] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadLesson();
}, [eventLessonId]);
const loadLesson = async () => {
try {
const response = await participantAPI.getLessonContent(eventLessonId);
setLesson(response.data.data);
await participantAPI.startLesson(eventLessonId);
} catch (error) {
console.error('Failed to load lesson:', error);
} finally {
setLoading(false);
}
};
const handleAnswer = async (questionId, answer) => {
try {
const response = await participantAPI.submitAnswer(eventLessonId, questionId, answer);
const result = response.data.data;
setFeedback(prev => ({ ...prev, [questionId]: result }));
setTotalScore(result.totalScore);
} catch (error) {
console.error('Failed to submit answer:', error);
}
};
const handleComplete = async () => {
try {
await participantAPI.completeLesson(eventLessonId);
navigate('/event');
} catch (error) {
console.error('Failed to complete lesson:', error);
}
};
if (loading || !lesson) {
return <div style={{ padding: '2rem', textAlign: 'center' }}>Loading lesson...</div>;
}
const currentStep = lesson.steps[currentStepIndex];
const isLastStep = currentStepIndex === lesson.steps.length - 1;
return (
<div style={{ minHeight: '100vh', background: '#f9fafb' }}>
<nav style={{ background: 'white', padding: '1rem 2rem', borderBottom: '1px solid #e5e7eb' }}>
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
<h3 style={{ margin: 0, color: '#2563eb' }}>{lesson.title}</h3>
<div style={{ fontSize: '0.875rem', color: '#6b7280', marginTop: '0.5rem' }}>
Step {currentStepIndex + 1} of {lesson.steps.length} | Score: {totalScore}/{lesson.maxPoints}
</div>
</div>
</nav>
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '2rem' }}>
<div className="card">
<h2>{currentStep.title}</h2>
{currentStep.type === 'content' && (
<div style={{ whiteSpace: 'pre-wrap', lineHeight: '1.8' }}>
{currentStep.content}
</div>
)}
{currentStep.type === 'interactive' && (
<div>
{currentStep.content && (
<div style={{ whiteSpace: 'pre-wrap', lineHeight: '1.8', marginBottom: '1.5rem' }}>
{currentStep.content}
</div>
)}
{currentStep.interactiveComponent === 'SQLShopDemo' && (
<SQLShopDemo lessonData={lesson} eventLessonId={eventLessonId} />
)}
{currentStep.interactiveComponent === 'BitBDemo' && (
<BitBDemo lessonData={lesson} eventLessonId={eventLessonId} />
)}
</div>
)}
{currentStep.type === 'question' && (
<div>
<p style={{ fontWeight: '500', marginBottom: '1rem' }}>{currentStep.question}</p>
{currentStep.questionType === 'single_choice' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{currentStep.options.map(option => (
<label
key={option.id}
style={{
padding: '1rem',
border: '2px solid #e5e7eb',
borderRadius: '0.375rem',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
<input
type="radio"
name={currentStep.id}
value={option.id}
onChange={(e) => setAnswers(prev => ({ ...prev, [currentStep.id]: e.target.value }))}
style={{ marginRight: '0.5rem' }}
/>
{option.text}
</label>
))}
</div>
)}
{currentStep.questionType === 'multiple_choice' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{currentStep.options.map(option => (
<label
key={option.id}
style={{
padding: '1rem',
border: '2px solid #e5e7eb',
borderRadius: '0.375rem',
cursor: 'pointer'
}}
>
<input
type="checkbox"
value={option.id}
onChange={(e) => {
const current = answers[currentStep.id] || [];
const updated = e.target.checked
? [...current, option.id]
: current.filter(id => id !== option.id);
setAnswers(prev => ({ ...prev, [currentStep.id]: updated }));
}}
style={{ marginRight: '0.5rem' }}
/>
{option.text}
</label>
))}
</div>
)}
{currentStep.questionType === 'free_text' && (
<textarea
value={answers[currentStep.id] || ''}
onChange={(e) => setAnswers(prev => ({ ...prev, [currentStep.id]: e.target.value }))}
rows={5}
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
placeholder="Type your answer here..."
/>
)}
{!feedback[currentStep.id] && (
<button
onClick={() => handleAnswer(currentStep.id, answers[currentStep.id])}
disabled={!answers[currentStep.id]}
style={{
marginTop: '1rem',
padding: '0.75rem 1.5rem',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer'
}}
>
Submit Answer
</button>
)}
{feedback[currentStep.id] && (
<div style={{
marginTop: '1rem',
padding: '1rem',
background: feedback[currentStep.id].isCorrect ? '#d1fae5' : '#fee2e2',
border: `1px solid ${feedback[currentStep.id].isCorrect ? '#10b981' : '#ef4444'}`,
borderRadius: '0.375rem'
}}>
<div style={{ fontWeight: '500', marginBottom: '0.5rem' }}>
{feedback[currentStep.id].isCorrect ? '✅ Correct!' : '❌ Incorrect'}
</div>
<div>{feedback[currentStep.id].feedback}</div>
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem' }}>
Points awarded: {feedback[currentStep.id].pointsAwarded}
</div>
</div>
)}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '2rem' }}>
<button
onClick={() => setCurrentStepIndex(Math.max(0, currentStepIndex - 1))}
disabled={currentStepIndex === 0}
style={{
padding: '0.75rem 1.5rem',
background: '#6b7280',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer'
}}
>
Previous
</button>
{isLastStep ? (
<button
onClick={handleComplete}
style={{
padding: '0.75rem 1.5rem',
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer'
}}
>
Complete Lesson
</button>
) : (
<button
onClick={() => setCurrentStepIndex(currentStepIndex + 1)}
style={{
padding: '0.75rem 1.5rem',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer'
}}
>
Next
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default LessonView;
@@ -0,0 +1,18 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
const ParticipantProgress = () => {
const navigate = useNavigate();
return (
<div style={{ padding: '2rem' }}>
<button onClick={() => navigate('/event')} style={{ marginBottom: '1rem' }}>
← Back to Event
</button>
<h2>Participant Progress</h2>
<p>Detailed progress view (placeholder)</p>
</div>
);
};
export default ParticipantProgress;
@@ -0,0 +1,84 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAdmin } from '../../contexts/AdminContext';
import { adminAPI } from '../../services/api.service';
const AdminDashboard = () => {
const [events, setEvents] = useState([]);
const { logout } = useAdmin();
const navigate = useNavigate();
useEffect(() => {
loadEvents();
}, []);
const loadEvents = async () => {
try {
const response = await adminAPI.getEvents();
setEvents(response.data.data);
} catch (error) {
console.error('Failed to load events:', error);
}
};
return (
<div style={{ minHeight: '100vh', background: '#f9fafb' }}>
<nav style={{ background: 'white', padding: '1rem 2rem', borderBottom: '1px solid #e5e7eb' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, color: '#2563eb' }}>Admin Dashboard</h2>
<button onClick={logout} style={{ padding: '0.5rem 1rem', background: '#ef4444', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}>
Logout
</button>
</div>
</nav>
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h3>Events</h3>
<button
onClick={() => navigate('/admin/events')}
style={{ padding: '0.75rem 1.5rem', background: '#2563eb', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
>
Manage Events
</button>
</div>
{events.length === 0 ? (
<div className="card">
<p>No events yet. Create your first event!</p>
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{events.map(event => (
<div key={event.id} className="card">
<h4>{event.name}</h4>
<p style={{ color: '#6b7280', marginBottom: '1rem' }}>{event.description}</p>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.875rem', color: '#6b7280' }}>
<span>👥 {event.participant_count} participants</span>
<span>📚 {event.lesson_count} lessons</span>
<span>{event.is_active ? '🟢 Active' : '🔴 Inactive'}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button
onClick={() => navigate(`/admin/events/${event.id}/lessons`)}
style={{ padding: '0.5rem 1rem', background: '#10b981', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
>
Manage Lessons
</button>
<button
onClick={() => navigate(`/admin/events/${event.id}/participants`)}
style={{ padding: '0.5rem 1rem', background: '#6b7280', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
>
View Participants
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default AdminDashboard;
+87
View File
@@ -0,0 +1,87 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAdmin } from '../../contexts/AdminContext';
const AdminLogin = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login, isAuthenticated } = useAdmin();
const navigate = useNavigate();
useEffect(() => {
if (isAuthenticated) {
navigate('/admin');
}
}, [isAuthenticated, navigate]);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
const result = await login(username, password);
if (result.success) {
navigate('/admin');
} else {
setError(result.error);
setLoading(false);
}
};
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f9fafb' }}>
<div className="card" style={{ maxWidth: '400px', width: '100%' }}>
<h1 style={{ color: '#2563eb', marginBottom: '2rem' }}>Admin Login</h1>
{error && (
<div style={{ padding: '0.75rem', background: '#fee2e2', border: '1px solid #ef4444', borderRadius: '0.375rem', color: '#dc2626', marginBottom: '1rem' }}>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
/>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
/>
</div>
<button
type="submit"
disabled={loading}
style={{ width: '100%', padding: '0.75rem', background: '#2563eb', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
<a href="/" style={{ color: '#6b7280', fontSize: '0.875rem' }}>
← Back to Hub
</a>
</div>
</div>
</div>
);
};
export default AdminLogin;
@@ -0,0 +1,232 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { adminAPI } from '../../services/api.service';
const EventManagement = () => {
const [events, setEvents] = useState([]);
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState(null);
const [formData, setFormData] = useState({
name: '',
description: '',
startDate: '',
endDate: '',
isActive: true
});
const navigate = useNavigate();
useEffect(() => {
loadEvents();
}, []);
const loadEvents = async () => {
try {
const response = await adminAPI.getEvents();
setEvents(response.data.data);
} catch (error) {
console.error('Failed to load events:', error);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (editingEvent) {
await adminAPI.updateEvent(editingEvent.id, formData);
} else {
await adminAPI.createEvent(formData);
}
setShowForm(false);
setEditingEvent(null);
setFormData({ name: '', description: '', startDate: '', endDate: '', isActive: true });
loadEvents();
} catch (error) {
console.error('Failed to save event:', error);
}
};
const handleEdit = (event) => {
setEditingEvent(event);
setFormData({
name: event.name,
description: event.description,
startDate: event.start_date?.split('T')[0] || '',
endDate: event.end_date?.split('T')[0] || '',
isActive: event.is_active
});
setShowForm(true);
};
const handleDelete = async (eventId) => {
if (!window.confirm('Are you sure you want to delete this event? This will delete all associated data.')) {
return;
}
try {
await adminAPI.deleteEvent(eventId);
loadEvents();
} catch (error) {
console.error('Failed to delete event:', error);
}
};
const handleCancel = () => {
setShowForm(false);
setEditingEvent(null);
setFormData({ name: '', description: '', startDate: '', endDate: '', isActive: true });
};
return (
<div style={{ minHeight: '100vh', background: '#f9fafb' }}>
<nav style={{ background: 'white', padding: '1rem 2rem', borderBottom: '1px solid #e5e7eb' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, color: '#2563eb' }}>Event Management</h2>
<button onClick={() => navigate('/admin')} style={{ padding: '0.5rem 1rem', background: '#6b7280', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}>
Back to Dashboard
</button>
</div>
</nav>
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '2rem' }}>
{!showForm && (
<div style={{ marginBottom: '2rem' }}>
<button
onClick={() => setShowForm(true)}
style={{ padding: '0.75rem 1.5rem', background: '#10b981', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
>
+ Create New Event
</button>
</div>
)}
{showForm && (
<div className="card" style={{ marginBottom: '2rem' }}>
<h3>{editingEvent ? 'Edit Event' : 'Create New Event'}</h3>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Event Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
<div>
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Start Date</label>
<input
type="date"
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
/>
</div>
<div>
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>End Date</label>
<input
type="date"
value={formData.endDate}
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
/>
</div>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', fontWeight: '500' }}>
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
style={{ marginRight: '0.5rem' }}
/>
Active Event
</label>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
type="submit"
style={{ padding: '0.75rem 1.5rem', background: '#2563eb', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
>
{editingEvent ? 'Update Event' : 'Create Event'}
</button>
<button
type="button"
onClick={handleCancel}
style={{ padding: '0.75rem 1.5rem', background: '#6b7280', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
>
Cancel
</button>
</div>
</form>
</div>
)}
<div style={{ display: 'grid', gap: '1rem' }}>
{events.map(event => (
<div key={event.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div style={{ flex: 1 }}>
<h4 style={{ marginBottom: '0.5rem' }}>{event.name}</h4>
<p style={{ color: '#6b7280', marginBottom: '1rem' }}>{event.description}</p>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.875rem', color: '#6b7280' }}>
<span>👥 {event.participant_count} participants</span>
<span>📚 {event.lesson_count} lessons</span>
<span>{event.is_active ? '🟢 Active' : '🔴 Inactive'}</span>
</div>
{event.start_date && (
<div style={{ fontSize: '0.875rem', color: '#6b7280', marginTop: '0.5rem' }}>
📅 {new Date(event.start_date).toLocaleDateString()} - {event.end_date ? new Date(event.end_date).toLocaleDateString() : 'No end date'}
</div>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => handleEdit(event)}
style={{ padding: '0.5rem 1rem', background: '#2563eb', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
>
Edit
</button>
<button
onClick={() => navigate(`/admin/events/${event.id}/lessons`)}
style={{ padding: '0.5rem 1rem', background: '#10b981', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
>
Lessons
</button>
<button
onClick={() => handleDelete(event.id)}
style={{ padding: '0.5rem 1rem', background: '#ef4444', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
{events.length === 0 && (
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
<p style={{ color: '#6b7280' }}>No events yet. Create your first event to get started!</p>
</div>
)}
</div>
</div>
);
};
export default EventManagement;
@@ -0,0 +1,271 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { adminAPI } from '../../services/api.service';
const LessonConfiguration = () => {
const { eventId } = useParams();
const navigate = useNavigate();
const [event, setEvent] = useState(null);
const [assignedLessons, setAssignedLessons] = useState([]);
const [availableLessons, setAvailableLessons] = useState([]);
const [showAssignForm, setShowAssignForm] = useState(false);
const [selectedLesson, setSelectedLesson] = useState('');
const [assignFormData, setAssignFormData] = useState({
orderIndex: 1,
maxPoints: 100,
weight: 1.0,
isRequired: true
});
useEffect(() => {
loadData();
}, [eventId]);
const loadData = async () => {
try {
const [eventsRes, lessonsRes, eventLessonsRes] = await Promise.all([
adminAPI.getEvents(),
adminAPI.getAllLessons(),
adminAPI.getEventLessons(eventId)
]);
const currentEvent = eventsRes.data.data.find(e => e.id === parseInt(eventId));
setEvent(currentEvent);
setAssignedLessons(eventLessonsRes.data.data);
setAvailableLessons(lessonsRes.data.data);
} catch (error) {
console.error('Failed to load data:', error);
}
};
const handleAssignLesson = async (e) => {
e.preventDefault();
try {
await adminAPI.assignLessonToEvent(eventId, {
lessonId: parseInt(selectedLesson),
...assignFormData
});
setShowAssignForm(false);
setSelectedLesson('');
setAssignFormData({ orderIndex: 1, maxPoints: 100, weight: 1.0, isRequired: true });
loadData();
} catch (error) {
console.error('Failed to assign lesson:', error);
}
};
const handleUpdateLesson = async (eventLessonId, updates) => {
try {
await adminAPI.updateEventLesson(eventLessonId, updates);
loadData();
} catch (error) {
console.error('Failed to update lesson:', error);
}
};
const handleRemoveLesson = async (eventLessonId) => {
if (!window.confirm('Are you sure you want to remove this lesson from the event?')) {
return;
}
try {
await adminAPI.removeEventLesson(eventLessonId);
loadData();
} catch (error) {
console.error('Failed to remove lesson:', error);
}
};
const getAvailableToAssign = () => {
const assignedIds = assignedLessons.map(al => al.lesson_id);
return availableLessons.filter(l => !assignedIds.includes(l.id));
};
if (!event) {
return <div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>;
}
return (
<div style={{ minHeight: '100vh', background: '#f9fafb' }}>
<nav style={{ background: 'white', padding: '1rem 2rem', borderBottom: '1px solid #e5e7eb' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2 style={{ margin: 0, color: '#2563eb' }}>Lesson Configuration</h2>
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.875rem', color: '#6b7280' }}>{event.name}</p>
</div>
<button onClick={() => navigate('/admin')} style={{ padding: '0.5rem 1rem', background: '#6b7280', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}>
Back to Dashboard
</button>
</div>
</nav>
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '2rem' }}>
{!showAssignForm && getAvailableToAssign().length > 0 && (
<div style={{ marginBottom: '2rem' }}>
<button
onClick={() => setShowAssignForm(true)}
style={{ padding: '0.75rem 1.5rem', background: '#10b981', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
>
+ Assign Lesson
</button>
</div>
)}
{showAssignForm && (
<div className="card" style={{ marginBottom: '2rem' }}>
<h3>Assign Lesson to Event</h3>
<form onSubmit={handleAssignLesson}>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Select Lesson</label>
<select
value={selectedLesson}
onChange={(e) => setSelectedLesson(e.target.value)}
required
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
>
<option value="">Choose a lesson...</option>
{getAvailableToAssign().map(lesson => (
<option key={lesson.id} value={lesson.id}>
{lesson.title} ({lesson.difficulty_level})
</option>
))}
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
<div>
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Order Index</label>
<input
type="number"
min="1"
value={assignFormData.orderIndex}
onChange={(e) => setAssignFormData({ ...assignFormData, orderIndex: parseInt(e.target.value) })}
required
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
/>
</div>
<div>
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Max Points</label>
<input
type="number"
min="1"
value={assignFormData.maxPoints}
onChange={(e) => setAssignFormData({ ...assignFormData, maxPoints: parseInt(e.target.value) })}
required
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
/>
</div>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontWeight: '500', marginBottom: '0.5rem' }}>Weight (multiplier for final score)</label>
<input
type="number"
min="0.1"
step="0.1"
value={assignFormData.weight}
onChange={(e) => setAssignFormData({ ...assignFormData, weight: parseFloat(e.target.value) })}
required
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
/>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', fontWeight: '500' }}>
<input
type="checkbox"
checked={assignFormData.isRequired}
onChange={(e) => setAssignFormData({ ...assignFormData, isRequired: e.target.checked })}
style={{ marginRight: '0.5rem' }}
/>
Required Lesson
</label>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
type="submit"
style={{ padding: '0.75rem 1.5rem', background: '#2563eb', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
>
Assign Lesson
</button>
<button
type="button"
onClick={() => {
setShowAssignForm(false);
setSelectedLesson('');
}}
style={{ padding: '0.75rem 1.5rem', background: '#6b7280', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
>
Cancel
</button>
</div>
</form>
</div>
)}
<h3 style={{ marginBottom: '1rem' }}>Assigned Lessons</h3>
{assignedLessons.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
<p style={{ color: '#6b7280' }}>No lessons assigned yet. Add lessons to this event to get started!</p>
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{assignedLessons
.sort((a, b) => a.order_index - b.order_index)
.map(eventLesson => (
<div key={eventLesson.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<span style={{ background: '#e5e7eb', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.875rem', fontWeight: '500' }}>
#{eventLesson.order_index}
</span>
<h4 style={{ margin: 0 }}>{eventLesson.lesson_title}</h4>
{eventLesson.is_required && (
<span style={{ background: '#fef3c7', color: '#92400e', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.75rem' }}>
Required
</span>
)}
</div>
<p style={{ color: '#6b7280', marginBottom: '1rem', fontSize: '0.875rem' }}>
{eventLesson.lesson_description}
</p>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.875rem', color: '#6b7280' }}>
<span>🎯 Max Points: {eventLesson.max_points}</span>
<span>⚖️ Weight: {eventLesson.weight}×</span>
<span>📊 Difficulty: {eventLesson.lesson_difficulty}</span>
<span>⏱️ Est. {eventLesson.lesson_duration} min</span>
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => {
const newWeight = parseFloat(prompt('Enter new weight:', eventLesson.weight));
if (newWeight && !isNaN(newWeight)) {
handleUpdateLesson(eventLesson.id, { weight: newWeight });
}
}}
style={{ padding: '0.5rem 1rem', background: '#2563eb', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer', fontSize: '0.875rem' }}
>
Edit Weight
</button>
<button
onClick={() => handleRemoveLesson(eventLesson.id)}
style={{ padding: '0.5rem 1rem', background: '#ef4444', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer', fontSize: '0.875rem' }}
>
Remove
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default LessonConfiguration;
@@ -0,0 +1,234 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { adminAPI } from '../../services/api.service';
const ParticipantData = () => {
const { eventId } = useParams();
const navigate = useNavigate();
const [event, setEvent] = useState(null);
const [participants, setParticipants] = useState([]);
const [selectedParticipant, setSelectedParticipant] = useState(null);
const [participantDetails, setParticipantDetails] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, [eventId]);
const loadData = async () => {
try {
const [eventsRes, participantsRes] = await Promise.all([
adminAPI.getEvents(),
adminAPI.getEventParticipants(eventId)
]);
const currentEvent = eventsRes.data.data.find(e => e.id === parseInt(eventId));
setEvent(currentEvent);
setParticipants(participantsRes.data.data);
} catch (error) {
console.error('Failed to load data:', error);
} finally {
setLoading(false);
}
};
const loadParticipantDetails = async (participantId) => {
try {
const response = await adminAPI.getParticipantProgress(participantId);
setParticipantDetails(response.data.data);
setSelectedParticipant(participantId);
} catch (error) {
console.error('Failed to load participant details:', error);
}
};
const getStatusColor = (status) => {
switch (status) {
case 'completed': return '#10b981';
case 'in_progress': return '#f59e0b';
default: return '#6b7280';
}
};
const getStatusText = (status) => {
switch (status) {
case 'completed': return '✅ Completed';
case 'in_progress': return '🔄 In Progress';
default: return '⭕ Not Started';
}
};
if (loading || !event) {
return <div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>;
}
return (
<div style={{ minHeight: '100vh', background: '#f9fafb' }}>
<nav style={{ background: 'white', padding: '1rem 2rem', borderBottom: '1px solid #e5e7eb' }}>
<div style={{ maxWidth: '1400px', margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2 style={{ margin: 0, color: '#2563eb' }}>Participant Data</h2>
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.875rem', color: '#6b7280' }}>{event.name}</p>
</div>
<button onClick={() => navigate('/admin')} style={{ padding: '0.5rem 1rem', background: '#6b7280', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}>
Back to Dashboard
</button>
</div>
</nav>
<div style={{ maxWidth: '1400px', margin: '0 auto', padding: '2rem' }}>
{participants.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
<p style={{ color: '#6b7280' }}>No participants have joined this event yet.</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: selectedParticipant ? '400px 1fr' : '1fr', gap: '1.5rem' }}>
{/* Participants List */}
<div>
<h3 style={{ marginBottom: '1rem' }}>Participants ({participants.length})</h3>
<div style={{ display: 'grid', gap: '0.75rem' }}>
{participants
.sort((a, b) => b.total_score - a.total_score)
.map((participant, index) => (
<div
key={participant.id}
className="card"
style={{
cursor: 'pointer',
background: selectedParticipant === participant.id ? '#eff6ff' : 'white',
border: selectedParticipant === participant.id ? '2px solid #2563eb' : '1px solid #e5e7eb'
}}
onClick={() => loadParticipantDetails(participant.id)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '1.25rem', fontWeight: '600', color: '#6b7280' }}>
#{index + 1}
</span>
<h4 style={{ margin: 0 }}>{participant.pseudonym}</h4>
</div>
<div style={{ fontSize: '0.875rem', color: '#6b7280', marginTop: '0.5rem' }}>
<div>Score: {participant.total_score} points</div>
<div>{participant.completed_lessons}/{participant.total_lessons} lessons completed</div>
</div>
</div>
<div style={{ textAlign: 'right', fontSize: '0.75rem', color: '#6b7280' }}>
Last active:<br />
{new Date(participant.last_active).toLocaleDateString()}
</div>
</div>
</div>
))}
</div>
</div>
{/* Participant Details */}
{selectedParticipant && participantDetails && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h3>Detailed Progress</h3>
<button
onClick={() => {
setSelectedParticipant(null);
setParticipantDetails(null);
}}
style={{ padding: '0.5rem 1rem', background: '#6b7280', color: 'white', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
>
Close
</button>
</div>
{/* Summary Card */}
<div className="card" style={{ marginBottom: '1.5rem' }}>
<h4 style={{ marginBottom: '1rem' }}>
{participants.find(p => p.id === selectedParticipant)?.pseudonym}
</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' }}>
<div>
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>Total Score</div>
<div style={{ fontSize: '1.5rem', fontWeight: '600', color: '#2563eb' }}>
{participantDetails.totalScore}
</div>
</div>
<div>
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>Completed</div>
<div style={{ fontSize: '1.5rem', fontWeight: '600', color: '#10b981' }}>
{participantDetails.completedLessons}/{participantDetails.totalLessons}
</div>
</div>
<div>
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>Completion Rate</div>
<div style={{ fontSize: '1.5rem', fontWeight: '600', color: '#6b7280' }}>
{Math.round((participantDetails.completedLessons / participantDetails.totalLessons) * 100)}%
</div>
</div>
</div>
</div>
{/* Lesson Progress */}
<h4 style={{ marginBottom: '1rem' }}>Lesson Progress</h4>
<div style={{ display: 'grid', gap: '1rem' }}>
{participantDetails.lessons.map(lesson => (
<div key={lesson.event_lesson_id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div style={{ flex: 1 }}>
<h4 style={{ marginBottom: '0.5rem' }}>{lesson.lesson_title}</h4>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.875rem', color: '#6b7280', marginBottom: '1rem' }}>
<span style={{ color: getStatusColor(lesson.status) }}>
{getStatusText(lesson.status)}
</span>
{lesson.score !== null && (
<span>Score: {lesson.score}/{lesson.max_points} ({lesson.weighted_score} weighted)</span>
)}
</div>
{lesson.status === 'completed' && (
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>
<div>Started: {new Date(lesson.started_at).toLocaleString()}</div>
<div>Completed: {new Date(lesson.completed_at).toLocaleString()}</div>
<div>Attempts: {lesson.attempts}</div>
</div>
)}
{lesson.status === 'in_progress' && (
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>
<div>Started: {new Date(lesson.started_at).toLocaleString()}</div>
<div>Current step: {lesson.current_step}</div>
</div>
)}
{/* Show answers for completed lessons */}
{lesson.status === 'completed' && lesson.answers && lesson.answers.length > 0 && (
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.375rem' }}>
<div style={{ fontSize: '0.875rem', fontWeight: '600', marginBottom: '0.5rem' }}>Answers:</div>
{lesson.answers.map((answer, idx) => (
<div key={idx} style={{ fontSize: '0.875rem', marginBottom: '0.5rem', padding: '0.5rem', background: 'white', borderRadius: '0.25rem' }}>
<div style={{ fontWeight: '500', marginBottom: '0.25rem' }}>
Q{idx + 1}: {answer.question_key}
</div>
<div style={{ color: '#6b7280' }}>
Answer: {typeof answer.answer_data === 'object' ? JSON.stringify(answer.answer_data) : answer.answer_data}
</div>
<div style={{ color: answer.is_correct ? '#10b981' : '#ef4444', marginTop: '0.25rem' }}>
{answer.is_correct ? '✅' : '❌'} {answer.points_awarded} points
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
};
export default ParticipantData;
+112
View File
@@ -0,0 +1,112 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useParticipant } from '../contexts/ParticipantContext';
import { useAdmin } from '../contexts/AdminContext';
// Pages
import Hub from '../pages/Hub';
import EventLanding from '../pages/EventLanding';
import LessonView from '../pages/LessonView';
import ParticipantProgress from '../pages/ParticipantProgress';
import AdminLogin from '../pages/admin/AdminLogin';
import AdminDashboard from '../pages/admin/AdminDashboard';
import EventManagement from '../pages/admin/EventManagement';
import LessonConfiguration from '../pages/admin/LessonConfiguration';
import ParticipantData from '../pages/admin/ParticipantData';
// Protected route for participants
const ParticipantRoute = ({ children }) => {
const { isAuthenticated, loading } = useParticipant();
if (loading) {
return <div className="loading">Loading...</div>;
}
return isAuthenticated ? children : <Navigate to="/" />;
};
// Protected route for admin
const AdminRoute = ({ children }) => {
const { isAuthenticated, loading } = useAdmin();
if (loading) {
return <div className="loading">Loading...</div>;
}
return isAuthenticated ? children : <Navigate to="/admin/login" />;
};
const AppRoutes = () => {
return (
<Routes>
{/* Public routes */}
<Route path="/" element={<Hub />} />
<Route path="/admin/login" element={<AdminLogin />} />
{/* Participant routes */}
<Route
path="/event"
element={
<ParticipantRoute>
<EventLanding />
</ParticipantRoute>
}
/>
<Route
path="/lesson/:eventLessonId"
element={
<ParticipantRoute>
<LessonView />
</ParticipantRoute>
}
/>
<Route
path="/progress"
element={
<ParticipantRoute>
<ParticipantProgress />
</ParticipantRoute>
}
/>
{/* Admin routes */}
<Route
path="/admin"
element={
<AdminRoute>
<AdminDashboard />
</AdminRoute>
}
/>
<Route
path="/admin/events"
element={
<AdminRoute>
<EventManagement />
</AdminRoute>
}
/>
<Route
path="/admin/events/:eventId/lessons"
element={
<AdminRoute>
<LessonConfiguration />
</AdminRoute>
}
/>
<Route
path="/admin/events/:eventId/participants"
element={
<AdminRoute>
<ParticipantData />
</AdminRoute>
}
/>
{/* 404 */}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
);
};
export default AppRoutes;
+141
View File
@@ -0,0 +1,141 @@
import axios from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
// Create axios instance
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'
}
});
// Add auth token to requests
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Handle errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Clear token on unauthorized
localStorage.removeItem('token');
localStorage.removeItem('user');
}
return Promise.reject(error);
}
);
// Participant API
export const participantAPI = {
// Join an event
joinEvent: (pseudonym, eventId) =>
api.post('/participant/join', { pseudonym, eventId }),
// Get active events
getEvents: () =>
api.get('/participant/events'),
// Get profile
getProfile: () =>
api.get('/participant/profile'),
// Get progress
getProgress: () =>
api.get('/participant/progress'),
// Get lessons for event
getEventLessons: (eventId) =>
api.get(`/lesson/event/${eventId}/lessons`),
// Get lesson content
getLessonContent: (eventLessonId) =>
api.get(`/lesson/${eventLessonId}`),
// Start lesson
startLesson: (eventLessonId) =>
api.post(`/lesson/${eventLessonId}/start`),
// Submit answer
submitAnswer: (eventLessonId, questionId, answer) =>
api.post(`/lesson/${eventLessonId}/answer`, { questionId, answer }),
// Complete lesson
completeLesson: (eventLessonId) =>
api.post(`/lesson/${eventLessonId}/complete`),
// Execute lesson-specific actions (e.g., SQL query, interactive demos)
executeLessonAction: (eventLessonId, action, data) =>
api.post(`/lesson/${eventLessonId}/action/${action}`, data)
};
// Admin API
export const adminAPI = {
// Login
login: (username, password) =>
api.post('/admin/login', { username, password }),
// Get profile
getProfile: () =>
api.get('/admin/profile'),
// Verify token
verifyToken: () =>
api.get('/admin/verify'),
// Events
getEvents: () =>
api.get('/admin/events'),
getEvent: (eventId) =>
api.get(`/admin/events/${eventId}`),
createEvent: (eventData) =>
api.post('/admin/events', eventData),
updateEvent: (eventId, eventData) =>
api.put(`/admin/events/${eventId}`, eventData),
deleteEvent: (eventId) =>
api.delete(`/admin/events/${eventId}`),
// Participants
getEventParticipants: (eventId) =>
api.get(`/admin/events/${eventId}/participants`),
getEventAnalytics: (eventId) =>
api.get(`/admin/events/${eventId}/analytics`),
// Lessons
getAllLessons: () =>
api.get('/admin/lessons'),
getEventLessons: (eventId) =>
api.get(`/admin/events/${eventId}/lessons`),
assignLessonToEvent: (eventId, lessonData) =>
api.post(`/admin/events/${eventId}/lessons`, lessonData),
updateEventLesson: (eventLessonId, updates) =>
api.put(`/admin/event-lessons/${eventLessonId}`, updates),
removeEventLesson: (eventLessonId) =>
api.delete(`/admin/event-lessons/${eventLessonId}`),
// Participant progress
getParticipantProgress: (participantId) =>
api.get(`/admin/participants/${participantId}/progress`)
};
export default api;
+71
View File
@@ -0,0 +1,71 @@
// Session management for participant
export const sessionService = {
// Save session
saveSession: (token, participant, event) => {
localStorage.setItem('token', token);
localStorage.setItem('participant', JSON.stringify(participant));
localStorage.setItem('event', JSON.stringify(event));
},
// Get token
getToken: () => {
return localStorage.getItem('token');
},
// Get participant
getParticipant: () => {
const data = localStorage.getItem('participant');
return data ? JSON.parse(data) : null;
},
// Get event
getEvent: () => {
const data = localStorage.getItem('event');
return data ? JSON.parse(data) : null;
},
// Clear session
clearSession: () => {
localStorage.removeItem('token');
localStorage.removeItem('participant');
localStorage.removeItem('event');
},
// Check if has session
hasSession: () => {
return !!localStorage.getItem('token');
}
};
// Admin session management
export const adminSessionService = {
// Save admin session
saveSession: (token, admin) => {
localStorage.setItem('adminToken', token);
localStorage.setItem('admin', JSON.stringify(admin));
},
// Get admin token
getToken: () => {
return localStorage.getItem('adminToken');
},
// Get admin
getAdmin: () => {
const data = localStorage.getItem('admin');
return data ? JSON.parse(data) : null;
},
// Clear admin session
clearSession: () => {
localStorage.removeItem('adminToken');
localStorage.removeItem('admin');
},
// Check if has admin session
hasSession: () => {
return !!localStorage.getItem('adminToken');
}
};
export default sessionService;
+95
View File
@@ -0,0 +1,95 @@
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #2563eb;
--secondary-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--bg-color: #f9fafb;
--text-color: #111827;
--border-color: #e5e7eb;
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
}
#root {
min-height: 100vh;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
h1, h2, h3, h4, h5, h6 {
margin-bottom: 0.5rem;
font-weight: 600;
}
h1 {
font-size: 2.5rem;
color: var(--primary-color);
}
p {
margin-bottom: 1rem;
}
button {
cursor: pointer;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s;
}
button:hover {
opacity: 0.9;
transform: translateY(-1px);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
input, textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.2s;
}
input:focus, textarea:focus {
outline: none;
border-color: var(--primary-color);
}
.card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api')
}
}
},
build: {
outDir: 'dist',
sourcemap: false,
chunkSizeWarningLimit: 1000
}
});