initial commit
This commit is contained in:
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+2033
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user