initial
This commit is contained in:
57
.dockerignore
Normal file
57
.dockerignore
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
.venv
|
||||||
|
.python-version
|
||||||
|
*.egg-info
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Database (will be persisted via volumes)
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Frontend build output (not needed during image build)
|
||||||
|
frontend/frontend/dist
|
||||||
|
backend/static/index.html
|
||||||
|
backend/static/assets
|
||||||
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Authelia OIDC Configuration
|
||||||
|
# REQUIRED: Set these values to match your Authelia instance
|
||||||
|
|
||||||
|
# Authelia issuer URL (e.g., https://auth.example.com)
|
||||||
|
OIDC_ISSUER=
|
||||||
|
|
||||||
|
# OIDC client ID (must match the client configured in Authelia)
|
||||||
|
OIDC_CLIENT_ID=trivia-app
|
||||||
|
|
||||||
|
# OIDC client secret (generated when configuring the client in Authelia)
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# OAuth redirect URI (adjust if deploying to production)
|
||||||
|
OIDC_REDIRECT_URI=http://localhost:5001/api/auth/callback
|
||||||
|
|
||||||
|
# Frontend URL for callbacks
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Cookie security (set to 'true' in production with HTTPS)
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
233
API_EXAMPLES.md
Normal file
233
API_EXAMPLES.md
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
# API Examples for Bulk Operations
|
||||||
|
|
||||||
|
## Creating Categories
|
||||||
|
|
||||||
|
Categories can be created using the existing categories API:
|
||||||
|
|
||||||
|
### Create a Single Category
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:5000/api/categories \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Science"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Multiple Categories
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# History
|
||||||
|
curl -X POST http://localhost:5000/api/categories \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "History"}'
|
||||||
|
|
||||||
|
# Geography
|
||||||
|
curl -X POST http://localhost:5000/api/categories \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "Geography"}'
|
||||||
|
|
||||||
|
# Sports
|
||||||
|
curl -X POST http://localhost:5000/api/categories \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "Sports"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bulk Creating Questions
|
||||||
|
|
||||||
|
Use the new bulk import endpoint to create many questions at once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:5000/api/questions/bulk \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"question_content": "What is the capital of France?",
|
||||||
|
"answer": "Paris",
|
||||||
|
"category": "Geography",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question_content": "Who painted the Mona Lisa?",
|
||||||
|
"answer": "Leonardo da Vinci",
|
||||||
|
"category": "Art",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question_content": "What year did World War II end?",
|
||||||
|
"answer": "1945",
|
||||||
|
"category": "History",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question_content": "What is the chemical symbol for gold?",
|
||||||
|
"answer": "Au",
|
||||||
|
"category": "Science",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question_content": "How many players are on a soccer team?",
|
||||||
|
"answer": "11",
|
||||||
|
"category": "Sports",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python Script Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
API_BASE_URL = "http://localhost:5000/api"
|
||||||
|
|
||||||
|
# Create categories
|
||||||
|
categories = ["Science", "History", "Geography", "Sports", "Entertainment", "Art"]
|
||||||
|
for category in categories:
|
||||||
|
response = requests.post(
|
||||||
|
f"{API_BASE_URL}/categories",
|
||||||
|
json={"name": category}
|
||||||
|
)
|
||||||
|
print(f"Created category: {category}")
|
||||||
|
|
||||||
|
# Bulk create questions
|
||||||
|
questions = [
|
||||||
|
{
|
||||||
|
"question_content": "What is the capital of France?",
|
||||||
|
"answer": "Paris",
|
||||||
|
"category": "Geography",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question_content": "Who painted the Mona Lisa?",
|
||||||
|
"answer": "Leonardo da Vinci",
|
||||||
|
"category": "Art",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question_content": "What year did World War II end?",
|
||||||
|
"answer": "1945",
|
||||||
|
"category": "History",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question_content": "What is H2O?",
|
||||||
|
"answer": "Water",
|
||||||
|
"category": "Science",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question_content": "How many rings are in the Olympic logo?",
|
||||||
|
"answer": "5",
|
||||||
|
"category": "Sports",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
f"{API_BASE_URL}/questions/bulk",
|
||||||
|
json={"questions": questions}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
print(f"Created {result['created']} questions")
|
||||||
|
if result['errors']:
|
||||||
|
print(f"Errors: {result['errors']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## JavaScript/Node.js Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const API_BASE_URL = "http://localhost:5000/api";
|
||||||
|
|
||||||
|
// Create categories
|
||||||
|
const categories = ["Science", "History", "Geography", "Sports", "Entertainment", "Art"];
|
||||||
|
for (const category of categories) {
|
||||||
|
await fetch(`${API_BASE_URL}/categories`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: category })
|
||||||
|
});
|
||||||
|
console.log(`Created category: ${category}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk create questions
|
||||||
|
const questions = [
|
||||||
|
{
|
||||||
|
question_content: "What is the capital of France?",
|
||||||
|
answer: "Paris",
|
||||||
|
category: "Geography",
|
||||||
|
type: "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question_content: "Who painted the Mona Lisa?",
|
||||||
|
answer: "Leonardo da Vinci",
|
||||||
|
category: "Art",
|
||||||
|
type: "text"
|
||||||
|
},
|
||||||
|
// ... more questions
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/questions/bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ questions })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log(`Created ${result.created} questions`);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
### Bulk Create Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Successfully created 5 questions",
|
||||||
|
"created": 5,
|
||||||
|
"errors": [],
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "text",
|
||||||
|
"question_content": "What is the capital of France?",
|
||||||
|
"answer": "Paris",
|
||||||
|
"category": "Geography",
|
||||||
|
"image_path": null,
|
||||||
|
"created_at": "2024-12-08T12:00:00"
|
||||||
|
}
|
||||||
|
// ... more questions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Errors
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Successfully created 3 questions",
|
||||||
|
"created": 3,
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"error": "question_content is required"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 4,
|
||||||
|
"error": "answer is required"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"questions": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The bulk import endpoint does **not** support image questions
|
||||||
|
- Categories must be created before using them in questions (or use existing ones)
|
||||||
|
- The `type` field defaults to "text" if not specified
|
||||||
|
- The `category` field is optional
|
||||||
|
- Invalid questions in the bulk request will be skipped, and their errors will be reported
|
||||||
183
AUTHELIA_SETUP.md
Normal file
183
AUTHELIA_SETUP.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# Authelia OIDC Setup Guide
|
||||||
|
|
||||||
|
This guide will help you configure Authelia OIDC authentication for the Trivia Game application.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- An existing Authelia instance running and accessible
|
||||||
|
- Access to Authelia's configuration file (`configuration.yml`)
|
||||||
|
- Users and groups configured in Authelia
|
||||||
|
|
||||||
|
## Step 1: Configure Authelia Client
|
||||||
|
|
||||||
|
Add the following client configuration to your Authelia `configuration.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
identity_providers:
|
||||||
|
oidc:
|
||||||
|
clients:
|
||||||
|
- id: trivia-app
|
||||||
|
description: Trivia Game Application
|
||||||
|
secret: <HASHED_SECRET> # Generate using: authelia crypto hash generate --password 'your-secret-here'
|
||||||
|
redirect_uris:
|
||||||
|
- http://localhost:3000/auth/callback
|
||||||
|
- http://localhost:5001/api/auth/callback
|
||||||
|
# Add production URLs when deploying:
|
||||||
|
# - https://trivia.example.com/auth/callback
|
||||||
|
# - https://trivia-api.example.com/api/auth/callback
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- email
|
||||||
|
- profile
|
||||||
|
grant_types:
|
||||||
|
- authorization_code
|
||||||
|
- refresh_token
|
||||||
|
response_types:
|
||||||
|
- code
|
||||||
|
token_endpoint_auth_method: client_secret_basic
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generate the hashed secret:**
|
||||||
|
```bash
|
||||||
|
authelia crypto hash generate --password 'your-random-secret-here'
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the **plaintext secret** (not the hash) for the next step.
|
||||||
|
|
||||||
|
## Step 2: Configure Environment Variables
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env`:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` with your Authelia details:
|
||||||
|
```bash
|
||||||
|
# Replace with your actual Authelia URL
|
||||||
|
OIDC_ISSUER=https://auth.example.com
|
||||||
|
|
||||||
|
# Must match the client ID in Authelia config
|
||||||
|
OIDC_CLIENT_ID=trivia-app
|
||||||
|
|
||||||
|
# Use the PLAINTEXT secret (not the hashed one)
|
||||||
|
OIDC_CLIENT_SECRET=your-random-secret-here
|
||||||
|
|
||||||
|
# Adjust for production deployment
|
||||||
|
OIDC_REDIRECT_URI=http://localhost:5001/api/auth/callback
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Set to 'true' in production with HTTPS
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Restart Authelia
|
||||||
|
|
||||||
|
After updating Authelia's configuration:
|
||||||
|
```bash
|
||||||
|
# Restart Authelia to apply the new client configuration
|
||||||
|
systemctl restart authelia
|
||||||
|
# or
|
||||||
|
docker restart authelia
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Start the Trivia Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start with Docker Compose
|
||||||
|
docker compose up
|
||||||
|
|
||||||
|
# Or start backend and frontend separately
|
||||||
|
PORT=5001 uv run python main.py # Backend
|
||||||
|
cd frontend/frontend && npm run dev # Frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Test Authentication
|
||||||
|
|
||||||
|
1. Navigate to `http://localhost:3000`
|
||||||
|
2. You should be redirected to the login page
|
||||||
|
3. Click "Login with Authelia"
|
||||||
|
4. You'll be redirected to your Authelia instance
|
||||||
|
5. Log in with an Authelia user
|
||||||
|
6. After successful login, you'll be redirected back to the trivia app
|
||||||
|
|
||||||
|
**All authenticated users** can:
|
||||||
|
- Access all routes (question management, game setup, admin controls)
|
||||||
|
- Create/edit questions
|
||||||
|
- Start/control games
|
||||||
|
- View contestant screens
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "OAuth authentication disabled" warning
|
||||||
|
- **Cause**: `OIDC_ISSUER` is not set
|
||||||
|
- **Solution**: Ensure your `.env` file exists and has `OIDC_ISSUER` configured
|
||||||
|
|
||||||
|
### "Invalid or expired token" errors
|
||||||
|
- **Cause**: JWT validation failing
|
||||||
|
- **Solutions**:
|
||||||
|
- Verify `OIDC_ISSUER` matches exactly (no trailing slash)
|
||||||
|
- Check Authelia logs for JWKS endpoint errors
|
||||||
|
- Ensure clocks are synchronized between Authelia and trivia app
|
||||||
|
|
||||||
|
### Redirect loop on login
|
||||||
|
- **Cause**: Redirect URI mismatch
|
||||||
|
- **Solutions**:
|
||||||
|
- Ensure `OIDC_REDIRECT_URI` matches one of the `redirect_uris` in Authelia config
|
||||||
|
- Check that both frontend and backend URLs are correct
|
||||||
|
|
||||||
|
### WebSocket connection fails
|
||||||
|
- **Cause**: JWT token not being sent or invalid
|
||||||
|
- **Solutions**:
|
||||||
|
- Check browser console for socket errors
|
||||||
|
- Verify user is authenticated before joining game
|
||||||
|
- Check backend logs for JWT validation errors
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
When deploying to production:
|
||||||
|
|
||||||
|
1. Update Authelia client redirect URIs:
|
||||||
|
```yaml
|
||||||
|
redirect_uris:
|
||||||
|
- https://trivia.example.com/auth/callback
|
||||||
|
- https://trivia-api.example.com/api/auth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update environment variables:
|
||||||
|
```bash
|
||||||
|
OIDC_ISSUER=https://auth.example.com
|
||||||
|
OIDC_REDIRECT_URI=https://trivia-api.example.com/api/auth/callback
|
||||||
|
FRONTEND_URL=https://trivia.example.com
|
||||||
|
SESSION_COOKIE_SECURE=true # Important for HTTPS!
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Ensure HTTPS is configured:
|
||||||
|
- Use a reverse proxy (nginx, Traefik, Caddy)
|
||||||
|
- Configure SSL certificates
|
||||||
|
- Update CORS origins to match production domains
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
✅ **Do:**
|
||||||
|
- Use strong, random client secrets
|
||||||
|
- Enable `SESSION_COOKIE_SECURE=true` in production
|
||||||
|
- Use HTTPS for all production deployments
|
||||||
|
- Regularly rotate client secrets
|
||||||
|
- Monitor Authelia logs for suspicious activity
|
||||||
|
|
||||||
|
❌ **Don't:**
|
||||||
|
- Commit `.env` file to version control
|
||||||
|
- Use default secrets in production
|
||||||
|
- Disable HTTPS in production
|
||||||
|
- Share client secrets publicly
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For Authelia-specific issues, refer to:
|
||||||
|
- [Authelia Documentation](https://www.authelia.com/docs/)
|
||||||
|
- [Authelia OIDC Configuration](https://www.authelia.com/configuration/identity-providers/oidc/)
|
||||||
|
|
||||||
|
For trivia app issues, check the backend logs:
|
||||||
|
```bash
|
||||||
|
docker compose logs backend -f
|
||||||
|
```
|
||||||
214
CLAUDE.md
Normal file
214
CLAUDE.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
A real-time trivia game web application with Flask backend and React frontend, featuring WebSocket support for live score updates and question displays. The app supports creating question banks, setting up games with teams, and running live trivia games with separate contestant (TV display) and admin control views.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Flask 3.0+ with Flask-SocketIO for WebSocket support
|
||||||
|
- SQLAlchemy ORM with Flask-Migrate for database migrations
|
||||||
|
- SQLite database (located at `backend/instance/trivia.db`)
|
||||||
|
- Eventlet for async/WebSocket server
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- React 19 with React Router 7
|
||||||
|
- Vite for build tooling and dev server
|
||||||
|
- Socket.IO client for WebSocket communication
|
||||||
|
- Axios for HTTP requests
|
||||||
|
- **Note:** Frontend source is nested at `frontend/frontend/` (double directory structure)
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Backend Setup and Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Python dependencies
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Initialize database (first time only)
|
||||||
|
export FLASK_APP=backend.app:create_app
|
||||||
|
uv run flask db init
|
||||||
|
|
||||||
|
# Create and apply migrations
|
||||||
|
uv run flask db migrate -m "Description of changes"
|
||||||
|
uv run flask db upgrade
|
||||||
|
|
||||||
|
# Run backend server (use PORT=5001 to avoid macOS AirPlay conflict on 5000)
|
||||||
|
PORT=5001 uv run python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Setup and Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install frontend dependencies
|
||||||
|
cd frontend/frontend # Note the nested directory
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run frontend dev server (with proxy to backend)
|
||||||
|
npm run dev # Runs on port 3000
|
||||||
|
|
||||||
|
# Build for production (outputs to backend/static/)
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
When you modify models in `backend/models.py`:
|
||||||
|
```bash
|
||||||
|
export FLASK_APP=backend.app:create_app
|
||||||
|
uv run flask db migrate -m "Description of changes"
|
||||||
|
uv run flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Development (Recommended)
|
||||||
|
|
||||||
|
Docker Compose provides the easiest development setup with automatic hot reload for both frontend and backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services (backend + frontend with hot reload)
|
||||||
|
docker compose up
|
||||||
|
|
||||||
|
# Start in detached mode
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Stop all services
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Rebuild images after dependency changes
|
||||||
|
docker compose up --build
|
||||||
|
|
||||||
|
# Run database migrations inside container
|
||||||
|
docker compose exec backend uv run flask db migrate -m "Description"
|
||||||
|
docker compose exec backend uv run flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
**Services:**
|
||||||
|
- Backend: http://localhost:5001 (Flask + SocketIO)
|
||||||
|
- Frontend: http://localhost:3000 (Vite dev server)
|
||||||
|
|
||||||
|
**Hot Reload:**
|
||||||
|
- Backend: Changes to `backend/`, `main.py`, and `migrations/` automatically reload Flask
|
||||||
|
- Frontend: Changes to `src/`, `public/`, and config files automatically reload Vite
|
||||||
|
- Database and uploaded images persist in volumes
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- `Dockerfile.backend`: Python 3.14 with uv, Flask in debug mode
|
||||||
|
- `Dockerfile.frontend`: Node 18 with Vite dev server
|
||||||
|
- `docker-compose.yml`: Orchestrates both services with volume mounts for hot reload
|
||||||
|
- Named volume for `node_modules` to avoid host/container conflicts
|
||||||
|
- Health check on backend before starting frontend
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Application Factory Pattern
|
||||||
|
|
||||||
|
The Flask app uses an application factory (`backend/app.py:create_app()`) which:
|
||||||
|
- Initializes extensions (SQLAlchemy, Flask-Migrate, Flask-SocketIO, CORS)
|
||||||
|
- Registers blueprints for routes
|
||||||
|
- Serves React frontend in production from `backend/static/`
|
||||||
|
- Configured via `backend/config.py` with environment-based settings
|
||||||
|
|
||||||
|
### Database Models (`backend/models.py`)
|
||||||
|
|
||||||
|
Core entities:
|
||||||
|
- **Question**: Stores trivia questions (text or image type) with answers and optional categories
|
||||||
|
- **Game**: Represents a trivia game session with `is_active` flag and `current_question_index`
|
||||||
|
- **GameQuestion**: Junction table linking questions to games with ordering
|
||||||
|
- **Team**: Teams participating in a game
|
||||||
|
- **Score**: Tracks points awarded per team per question (composite unique constraint on team_id + question_index)
|
||||||
|
- **Category**: Optional categorization for questions
|
||||||
|
|
||||||
|
Key relationships:
|
||||||
|
- Games can only be active one at a time (`Game.get_active()` class method)
|
||||||
|
- Teams calculate `total_score` as a property by summing all Score records
|
||||||
|
- Questions can be reused across multiple games via GameQuestion junction table
|
||||||
|
|
||||||
|
### WebSocket Architecture (`backend/sockets/events.py`, `backend/services/game_service.py`)
|
||||||
|
|
||||||
|
**Room-based design:**
|
||||||
|
- Each game has two rooms: `game_{id}_contestant` and `game_{id}_admin`
|
||||||
|
- Contestants join the contestant room and see questions WITHOUT answers
|
||||||
|
- Admin joins the admin room and sees questions WITH answers
|
||||||
|
- Score updates broadcast to both rooms
|
||||||
|
|
||||||
|
**Key events:**
|
||||||
|
- `join_game` / `leave_game`: Client joins/leaves game rooms with role (contestant/admin)
|
||||||
|
- `game_started`: Emitted when game starts
|
||||||
|
- `question_changed`: Sent to contestant room (no answer)
|
||||||
|
- `question_with_answer`: Sent to admin room (with answer)
|
||||||
|
- `score_updated`: Broadcasts score changes to both rooms
|
||||||
|
- `answer_visibility_changed`: Toggles answer display on contestant screen
|
||||||
|
|
||||||
|
**Game flow:**
|
||||||
|
- Admin starts game via `/api/admin/game/<id>/start` → broadcasts first question
|
||||||
|
- Admin navigates questions with `/next` and `/prev` → broadcasts to all clients
|
||||||
|
- Admin awards points via `/award` → creates/updates Score records → broadcasts to all
|
||||||
|
- Optional answer reveal to contestants via `/toggle-answer`
|
||||||
|
|
||||||
|
### API Routes
|
||||||
|
|
||||||
|
Routes are organized into blueprints:
|
||||||
|
- `backend/routes/questions.py`: Question CRUD (`/api/questions`)
|
||||||
|
- `backend/routes/games.py`: Game management (`/api/games`)
|
||||||
|
- `backend/routes/teams.py`: Team management (`/api/teams`)
|
||||||
|
- `backend/routes/admin.py`: Game control endpoints (`/api/admin/game/<id>/...`)
|
||||||
|
- `backend/routes/categories.py`: Category management (`/api/categories`)
|
||||||
|
|
||||||
|
Admin endpoints use `game_service.py` functions which handle both database updates AND WebSocket broadcasts.
|
||||||
|
|
||||||
|
### Frontend Build Configuration
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
- Vite dev server runs on port 3000
|
||||||
|
- Proxies `/api` and `/socket.io` requests to Flask backend on port 5001
|
||||||
|
- Configuration in `frontend/frontend/vite.config.js`
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
- `npm run build` outputs to `backend/static/` (configured via `build.outDir`)
|
||||||
|
- Flask serves the built React app and handles client-side routing
|
||||||
|
- All non-API routes return `index.html` for React Router
|
||||||
|
|
||||||
|
### Image Handling
|
||||||
|
|
||||||
|
- Images uploaded to `backend/static/images/` via `backend/services/image_service.py`
|
||||||
|
- Image questions store `image_path` in the Question model
|
||||||
|
- Images served as static files by Flask in production
|
||||||
|
|
||||||
|
## Common Development Patterns
|
||||||
|
|
||||||
|
### Adding a New Model Field
|
||||||
|
|
||||||
|
1. Modify the model in `backend/models.py`
|
||||||
|
2. Update the model's `to_dict()` method if the field should be serialized
|
||||||
|
3. Create migration: `uv run flask db migrate -m "Add field_name to Model"`
|
||||||
|
4. Apply migration: `uv run flask db upgrade`
|
||||||
|
|
||||||
|
### Adding a New WebSocket Event
|
||||||
|
|
||||||
|
1. Define event handler in `backend/sockets/events.py` using `@socketio.on('event_name')`
|
||||||
|
2. Use `emit()` to send responses or `join_room()`/`leave_room()` for room management
|
||||||
|
3. To broadcast to specific rooms, use `emit('event', data, room='room_name')`
|
||||||
|
4. Update frontend Socket.IO hook to listen for the new event
|
||||||
|
|
||||||
|
### Adding a New API Endpoint
|
||||||
|
|
||||||
|
1. Add route function to appropriate blueprint in `backend/routes/`
|
||||||
|
2. Use `db.session.commit()` for database changes with try/except for rollback
|
||||||
|
3. Return JSON responses with appropriate status codes
|
||||||
|
4. For admin actions affecting game state, use functions from `game_service.py` to ensure WebSocket broadcasts
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **Port 5000 conflict**: macOS AirPlay Receiver uses port 5000 by default. Use `PORT=5001` when running the backend.
|
||||||
|
- **Nested frontend directory**: Frontend source code is at `frontend/frontend/`, not `frontend/src/`
|
||||||
|
- **Single active game**: Only one game can be active at a time (enforced in `start_game()`)
|
||||||
|
- **Score uniqueness**: A team can only have one score per question (enforced by database constraint)
|
||||||
|
- **WebSocket server**: Must use `socketio.run(app)` not `app.run()` for WebSocket support (configured in `main.py`)
|
||||||
|
- **CORS**: Configured to allow all origins in development via `CORS_ORIGINS=*`
|
||||||
28
Dockerfile.backend
Normal file
28
Dockerfile.backend
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies (ffmpeg for pydub audio processing)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install uv
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
# Copy dependency files
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN uv sync --frozen
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY backend ./backend
|
||||||
|
COPY main.py ./
|
||||||
|
COPY migrations ./migrations
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 5001
|
||||||
|
|
||||||
|
# Run with hot reload enabled
|
||||||
|
CMD ["uv", "run", "python", "main.py"]
|
||||||
18
Dockerfile.frontend
Normal file
18
Dockerfile.frontend
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files from nested directory
|
||||||
|
COPY frontend/frontend/package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY frontend/frontend ./
|
||||||
|
|
||||||
|
# Expose Vite dev server port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Run dev server with host 0.0.0.0 to allow access from outside container
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
43
MIGRATION_GUIDE.md
Normal file
43
MIGRATION_GUIDE.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Database Migration Guide
|
||||||
|
|
||||||
|
After updating the code, run these commands to create and apply the database migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and start containers
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
# Create migration
|
||||||
|
docker compose exec backend uv run flask db migrate -m "Add YouTube audio support and download job tracking"
|
||||||
|
|
||||||
|
# Apply migration
|
||||||
|
docker compose exec backend uv run flask db upgrade
|
||||||
|
|
||||||
|
# Verify services are running
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f backend
|
||||||
|
docker compose logs -f celery-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
- **Backend**: http://localhost:5001
|
||||||
|
- **Frontend**: http://localhost:3000
|
||||||
|
- **Celery Flower (monitoring)**: http://localhost:5555
|
||||||
|
- **Redis**: localhost:6379
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If migration fails:
|
||||||
|
```bash
|
||||||
|
# Check if database is accessible
|
||||||
|
docker compose exec backend ls -la backend/instance/
|
||||||
|
|
||||||
|
# Reset if needed (WARNING: destroys data)
|
||||||
|
docker compose down -v
|
||||||
|
docker compose up --build -d
|
||||||
|
docker compose exec backend uv run flask db init
|
||||||
|
docker compose exec backend uv run flask db migrate -m "Initial migration"
|
||||||
|
docker compose exec backend uv run flask db upgrade
|
||||||
|
```
|
||||||
303
YOUTUBE_AUDIO_IMPLEMENTATION_STATUS.md
Normal file
303
YOUTUBE_AUDIO_IMPLEMENTATION_STATUS.md
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
# YouTube Audio Feature - Implementation Status
|
||||||
|
|
||||||
|
## ✅ COMPLETED (Backend + Infrastructure)
|
||||||
|
|
||||||
|
### Infrastructure (100%)
|
||||||
|
- ✅ Added Python dependencies: celery, redis, yt-dlp, pydub
|
||||||
|
- ✅ Updated Dockerfile.backend to install ffmpeg
|
||||||
|
- ✅ Added Redis, Celery worker, and Celery Flower services to docker-compose.yml
|
||||||
|
- ✅ Configured audio volume mounts in Docker
|
||||||
|
|
||||||
|
### Configuration (100%)
|
||||||
|
- ✅ Added audio and Celery configuration to `backend/config.py`
|
||||||
|
- Audio folder, file size limits, allowed extensions
|
||||||
|
- Celery broker/backend URLs
|
||||||
|
- yt-dlp settings (format, quality)
|
||||||
|
|
||||||
|
### Database Models (100%)
|
||||||
|
- ✅ Added `YOUTUBE_AUDIO` to `QuestionType` enum
|
||||||
|
- ✅ Added YouTube fields to Question model:
|
||||||
|
- `youtube_url` - YouTube video URL
|
||||||
|
- `audio_path` - Path to trimmed audio file
|
||||||
|
- `start_time` - Start time in seconds
|
||||||
|
- `end_time` - End time in seconds
|
||||||
|
- ✅ Created `DownloadJob` model to track async processing
|
||||||
|
- Tracks status (pending/processing/completed/failed)
|
||||||
|
- Progress (0-100%)
|
||||||
|
- Error messages
|
||||||
|
- Celery task ID
|
||||||
|
|
||||||
|
### Backend Services (100%)
|
||||||
|
- ✅ Created `backend/celery_app.py` - Celery configuration
|
||||||
|
- ✅ Created `backend/services/audio_service.py` - Audio file utilities
|
||||||
|
- UUID-based filename generation
|
||||||
|
- File deletion cleanup
|
||||||
|
- ✅ Created `backend/services/youtube_service.py` - YouTube validation
|
||||||
|
- URL validation with regex
|
||||||
|
- Video duration fetching
|
||||||
|
- Timestamp range validation
|
||||||
|
- ✅ Created `backend/tasks/youtube_tasks.py` - Celery download task
|
||||||
|
- Downloads full audio via yt-dlp
|
||||||
|
- Trims to timestamp range using pydub
|
||||||
|
- Updates progress in database
|
||||||
|
- Handles errors and cleanup
|
||||||
|
|
||||||
|
### API Routes (100%)
|
||||||
|
- ✅ Updated `backend/routes/questions.py`:
|
||||||
|
- Accepts YouTube audio question type
|
||||||
|
- Validates URL and timestamps
|
||||||
|
- Creates question with pending status
|
||||||
|
- Spawns Celery download task
|
||||||
|
- Returns 202 with job ID
|
||||||
|
- Deletes audio files on question deletion
|
||||||
|
- ✅ Created `backend/routes/download_jobs.py`:
|
||||||
|
- `GET /api/download-jobs/<id>` - Get job status
|
||||||
|
- `GET /api/download-jobs/question/<question_id>` - Get job by question
|
||||||
|
- ✅ Updated `backend/routes/admin.py` with audio control endpoints:
|
||||||
|
- `POST /api/admin/game/<id>/audio/play` - Broadcast play command
|
||||||
|
- `POST /api/admin/game/<id>/audio/pause` - Broadcast pause command
|
||||||
|
- `POST /api/admin/game/<id>/audio/stop` - Broadcast stop command
|
||||||
|
- `POST /api/admin/game/<id>/audio/seek` - Broadcast seek command
|
||||||
|
|
||||||
|
### WebSocket Broadcasts (100%)
|
||||||
|
- ✅ Updated `backend/services/game_service.py` with audio broadcast functions:
|
||||||
|
- `broadcast_audio_play()` - Sends play event to contestant room
|
||||||
|
- `broadcast_audio_pause()` - Sends pause event
|
||||||
|
- `broadcast_audio_stop()` - Sends stop event
|
||||||
|
- `broadcast_audio_seek()` - Sends seek event with position
|
||||||
|
|
||||||
|
### App Integration (100%)
|
||||||
|
- ✅ Updated `backend/app.py`:
|
||||||
|
- Creates audio folder on startup
|
||||||
|
- Registers download_jobs blueprint
|
||||||
|
|
||||||
|
### Frontend API Client (100%)
|
||||||
|
- ✅ Updated `frontend/frontend/src/services/api.js`:
|
||||||
|
- Added `downloadJobsAPI` for job status polling
|
||||||
|
- Added `audioControlAPI` for playback controls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 REMAINING (Frontend UI Components)
|
||||||
|
|
||||||
|
### 1. Question Creation Form
|
||||||
|
**File**: `frontend/frontend/src/components/questionbank/QuestionBankView.jsx`
|
||||||
|
|
||||||
|
**Required Changes**:
|
||||||
|
- Add `"youtube_audio"` option to type dropdown
|
||||||
|
- Add form state for YouTube fields:
|
||||||
|
```javascript
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
// ... existing fields
|
||||||
|
youtube_url: '',
|
||||||
|
start_time: 0,
|
||||||
|
end_time: 0
|
||||||
|
});
|
||||||
|
const [downloadJob, setDownloadJob] = useState(null);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
|
```
|
||||||
|
- Add conditional form fields when type === 'youtube_audio':
|
||||||
|
- YouTube URL input
|
||||||
|
- Start time number input (seconds)
|
||||||
|
- End time number input (seconds)
|
||||||
|
- Display calculated duration
|
||||||
|
- Update submit handler to POST YouTube data as JSON
|
||||||
|
- Implement polling logic:
|
||||||
|
```javascript
|
||||||
|
const pollDownloadStatus = async (jobId) => {
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
const response = await downloadJobsAPI.getStatus(jobId);
|
||||||
|
setDownloadProgress(response.data.progress);
|
||||||
|
if (response.data.status === 'completed') {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
// Reload questions, show success message
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
- Add download progress indicator UI (fixed position, bottom-right)
|
||||||
|
|
||||||
|
### 2. Audio Player Component
|
||||||
|
**File**: `frontend/frontend/src/components/audio/AudioPlayer.jsx` (NEW)
|
||||||
|
|
||||||
|
**Component Structure**:
|
||||||
|
```jsx
|
||||||
|
export default function AudioPlayer({ audioPath, isAdmin, socket, gameId }) {
|
||||||
|
const audioRef = useRef(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
|
||||||
|
// Contestant mode: Listen to WebSocket events
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin && socket) {
|
||||||
|
socket.on('audio_play', () => audioRef.current?.play());
|
||||||
|
socket.on('audio_pause', () => audioRef.current?.pause());
|
||||||
|
socket.on('audio_stop', () => {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
});
|
||||||
|
socket.on('audio_seek', (data) => {
|
||||||
|
audioRef.current.currentTime = data.position;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [socket, isAdmin]);
|
||||||
|
|
||||||
|
// Admin controls
|
||||||
|
const handlePlay = async () => {
|
||||||
|
await audioControlAPI.play(gameId);
|
||||||
|
audioRef.current?.play();
|
||||||
|
};
|
||||||
|
// ... similar for pause, stop
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
||||||
|
<audio ref={audioRef} src={audioPath} preload="auto" />
|
||||||
|
{/* Progress bar */}
|
||||||
|
{/* Time display */}
|
||||||
|
{/* Admin controls (conditional) */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Styling**:
|
||||||
|
- Progress bar: clickable (admin only), blue fill (#2196F3)
|
||||||
|
- Time display: MM:SS format
|
||||||
|
- Buttons: Play (green #4CAF50), Pause (orange #ff9800), Stop (red #f44336)
|
||||||
|
|
||||||
|
### 3. Contestant View Update
|
||||||
|
**File**: `frontend/frontend/src/components/contestant/ContestantView.jsx`
|
||||||
|
|
||||||
|
**Required Changes**:
|
||||||
|
```jsx
|
||||||
|
import AudioPlayer from '../audio/AudioPlayer';
|
||||||
|
|
||||||
|
// In render, after image block:
|
||||||
|
{currentQuestion.type === 'youtube_audio' && currentQuestion.audio_path && (
|
||||||
|
<AudioPlayer
|
||||||
|
audioPath={currentQuestion.audio_path}
|
||||||
|
isAdmin={false}
|
||||||
|
socket={socket}
|
||||||
|
gameId={gameId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Admin View Update
|
||||||
|
**File**: `frontend/frontend/src/components/admin/GameAdminView.jsx`
|
||||||
|
|
||||||
|
**Required Changes**:
|
||||||
|
```jsx
|
||||||
|
import AudioPlayer from '../audio/AudioPlayer';
|
||||||
|
|
||||||
|
// In current question display section:
|
||||||
|
{currentQuestion?.type === 'youtube_audio' && currentQuestion?.audio_path && (
|
||||||
|
<AudioPlayer
|
||||||
|
audioPath={currentQuestion.audio_path}
|
||||||
|
isAdmin={true}
|
||||||
|
socket={socket}
|
||||||
|
gameId={gameId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Testing Checklist
|
||||||
|
|
||||||
|
### Backend Tests
|
||||||
|
- [ ] Start containers: `docker compose up --build`
|
||||||
|
- [ ] Create migration: `docker compose exec backend uv run flask db migrate -m "Add YouTube audio support"`
|
||||||
|
- [ ] Apply migration: `docker compose exec backend uv run flask db upgrade`
|
||||||
|
- [ ] Test invalid YouTube URL: Should return 400 error
|
||||||
|
- [ ] Test invalid timestamps: Should return validation error
|
||||||
|
- [ ] Test job status endpoint: `curl http://localhost:5001/api/download-jobs/1`
|
||||||
|
|
||||||
|
### Frontend Tests (after UI completion)
|
||||||
|
- [ ] Create YouTube audio question
|
||||||
|
- Submit URL with timestamps
|
||||||
|
- Verify progress indicator appears
|
||||||
|
- Wait for completion alert
|
||||||
|
- Verify question appears in list
|
||||||
|
- [ ] Test audio playback
|
||||||
|
- Start game with YouTube question
|
||||||
|
- Open contestant view (tab 1)
|
||||||
|
- Open admin view (tab 2)
|
||||||
|
- Admin clicks Play → Contestant audio plays
|
||||||
|
- Admin clicks Pause → Contestant audio pauses
|
||||||
|
- Admin clicks Stop → Contestant audio resets
|
||||||
|
- [ ] Test question navigation
|
||||||
|
- Admin clicks Next → Audio stops
|
||||||
|
- Return to audio question → Audio ready to play again
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Next Steps
|
||||||
|
|
||||||
|
1. **Complete frontend UI components** (4 files to update/create)
|
||||||
|
2. **Run database migration** (see MIGRATION_GUIDE.md)
|
||||||
|
3. **Test end-to-end workflow**
|
||||||
|
4. **Optional enhancements**:
|
||||||
|
- Add audio waveform visualization
|
||||||
|
- Add timestamp selector UI with embedded YouTube player
|
||||||
|
- Add video thumbnail preview
|
||||||
|
- Support multiple clips per question
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Files Modified/Created
|
||||||
|
|
||||||
|
### Backend (15 files)
|
||||||
|
- ✅ `pyproject.toml`
|
||||||
|
- ✅ `docker-compose.yml`
|
||||||
|
- ✅ `Dockerfile.backend`
|
||||||
|
- ✅ `backend/config.py`
|
||||||
|
- ✅ `backend/models.py`
|
||||||
|
- ✅ `backend/app.py`
|
||||||
|
- ✅ `backend/celery_app.py` (NEW)
|
||||||
|
- ✅ `backend/services/audio_service.py` (NEW)
|
||||||
|
- ✅ `backend/services/youtube_service.py` (NEW)
|
||||||
|
- ✅ `backend/tasks/__init__.py` (NEW)
|
||||||
|
- ✅ `backend/tasks/youtube_tasks.py` (NEW)
|
||||||
|
- ✅ `backend/routes/questions.py`
|
||||||
|
- ✅ `backend/routes/download_jobs.py` (NEW)
|
||||||
|
- ✅ `backend/routes/admin.py`
|
||||||
|
- ✅ `backend/services/game_service.py`
|
||||||
|
|
||||||
|
### Frontend (4 files - 1 complete, 3 remaining)
|
||||||
|
- ✅ `frontend/frontend/src/services/api.js`
|
||||||
|
- 🚧 `frontend/frontend/src/components/questionbank/QuestionBankView.jsx`
|
||||||
|
- 🚧 `frontend/frontend/src/components/audio/AudioPlayer.jsx` (NEW)
|
||||||
|
- 🚧 `frontend/frontend/src/components/contestant/ContestantView.jsx`
|
||||||
|
- 🚧 `frontend/frontend/src/components/admin/GameAdminView.jsx`
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- ✅ `MIGRATION_GUIDE.md` (NEW)
|
||||||
|
- ✅ `YOUTUBE_AUDIO_IMPLEMENTATION_STATUS.md` (NEW)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 Key Implementation Details
|
||||||
|
|
||||||
|
### Async Flow
|
||||||
|
1. User submits YouTube question → API creates Question with null `audio_path`
|
||||||
|
2. Celery task starts → Downloads full audio → Trims clip → Saves MP3
|
||||||
|
3. Frontend polls job status every 2 seconds
|
||||||
|
4. On completion → Question's `audio_path` updated → Frontend shows success
|
||||||
|
|
||||||
|
### Audio Playback Architecture
|
||||||
|
- **Admin**: Play/Pause/Stop buttons → API → WebSocket broadcast to contestants
|
||||||
|
- **Contestants**: Listen to WebSocket events → Control HTML5 `<audio>` element
|
||||||
|
- No direct contestant controls (fully synchronized)
|
||||||
|
|
||||||
|
### File Storage
|
||||||
|
- Audio files: `backend/static/audio/{uuid}.mp3`
|
||||||
|
- Served at: `/static/audio/{uuid}.mp3`
|
||||||
|
- Max size: 50MB, Max duration: 5 minutes
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Invalid URL → 400 error with message
|
||||||
|
- Timestamp validation → 400 error
|
||||||
|
- Download failure → Job status set to "failed" with error message
|
||||||
|
- Question deletion → Cleanup audio file automatically
|
||||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
89
backend/app.py
Normal file
89
backend/app.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import os
|
||||||
|
from flask import Flask, send_from_directory
|
||||||
|
from flask_cors import CORS
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_socketio import SocketIO
|
||||||
|
|
||||||
|
from backend.models import db
|
||||||
|
from backend.config import config
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
migrate = Migrate()
|
||||||
|
socketio = SocketIO(cors_allowed_origins="*", async_mode='eventlet')
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_name=None):
|
||||||
|
"""Flask application factory"""
|
||||||
|
if config_name is None:
|
||||||
|
config_name = os.environ.get('FLASK_ENV', 'development')
|
||||||
|
|
||||||
|
app = Flask(__name__,
|
||||||
|
static_folder='static',
|
||||||
|
static_url_path='/static')
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
app.config.from_object(config[config_name])
|
||||||
|
|
||||||
|
# Configure session for OAuth state management
|
||||||
|
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Allow cross-site on redirects
|
||||||
|
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||||
|
|
||||||
|
# Ensure required directories exist
|
||||||
|
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||||
|
os.makedirs(app.config['AUDIO_FOLDER'], exist_ok=True)
|
||||||
|
|
||||||
|
# Ensure database instance directory exists
|
||||||
|
db_path = app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')
|
||||||
|
if db_path and db_path != ':memory:':
|
||||||
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
db.init_app(app)
|
||||||
|
migrate.init_app(app, db)
|
||||||
|
socketio.init_app(app)
|
||||||
|
CORS(app,
|
||||||
|
resources={r"/api/*": {"origins": app.config['CORS_ORIGINS']}},
|
||||||
|
supports_credentials=True)
|
||||||
|
|
||||||
|
# Initialize OAuth/OIDC
|
||||||
|
from backend.auth import init_oauth
|
||||||
|
init_oauth(app)
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
from backend.routes import questions, games, teams, admin, categories, download_jobs, auth
|
||||||
|
app.register_blueprint(auth.bp)
|
||||||
|
app.register_blueprint(questions.bp)
|
||||||
|
app.register_blueprint(games.bp)
|
||||||
|
app.register_blueprint(teams.bp)
|
||||||
|
app.register_blueprint(admin.bp)
|
||||||
|
app.register_blueprint(categories.bp)
|
||||||
|
app.register_blueprint(download_jobs.bp)
|
||||||
|
|
||||||
|
# Register socket events
|
||||||
|
from backend.sockets import events
|
||||||
|
|
||||||
|
# Serve React frontend in production
|
||||||
|
@app.route('/', defaults={'path': ''})
|
||||||
|
@app.route('/<path:path>')
|
||||||
|
def serve_frontend(path):
|
||||||
|
"""Serve React frontend"""
|
||||||
|
if path and os.path.exists(os.path.join(app.static_folder, path)):
|
||||||
|
return send_from_directory(app.static_folder, path)
|
||||||
|
elif path.startswith('api/'):
|
||||||
|
# API routes should 404 if not found
|
||||||
|
return {'error': 'Not found'}, 404
|
||||||
|
else:
|
||||||
|
# Serve index.html for all other routes (React Router)
|
||||||
|
index_path = os.path.join(app.static_folder, 'index.html')
|
||||||
|
if os.path.exists(index_path):
|
||||||
|
return send_from_directory(app.static_folder, 'index.html')
|
||||||
|
else:
|
||||||
|
return {'message': 'Trivia Game API', 'status': 'running'}, 200
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
@app.route('/api/health')
|
||||||
|
def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return {'status': 'ok', 'message': 'Trivia Game API is running'}, 200
|
||||||
|
|
||||||
|
return app
|
||||||
25
backend/auth/__init__.py
Normal file
25
backend/auth/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from authlib.integrations.flask_client import OAuth
|
||||||
|
|
||||||
|
oauth = OAuth()
|
||||||
|
|
||||||
|
|
||||||
|
def init_oauth(app):
|
||||||
|
"""Initialize OAuth/OIDC client"""
|
||||||
|
oauth.init_app(app)
|
||||||
|
|
||||||
|
# Only register Authelia provider if OIDC_ISSUER is configured
|
||||||
|
if app.config.get('OIDC_ISSUER'):
|
||||||
|
oauth.register(
|
||||||
|
name='authelia',
|
||||||
|
client_id=app.config['OIDC_CLIENT_ID'],
|
||||||
|
client_secret=app.config['OIDC_CLIENT_SECRET'],
|
||||||
|
server_metadata_url=app.config['OIDC_ISSUER'] + '/.well-known/openid-configuration',
|
||||||
|
client_kwargs={
|
||||||
|
'scope': 'openid email profile',
|
||||||
|
'token_endpoint_auth_method': 'client_secret_basic'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
app.logger.warning('OIDC_ISSUER not configured - OAuth authentication disabled')
|
||||||
|
|
||||||
|
return oauth
|
||||||
131
backend/auth/middleware.py
Normal file
131
backend/auth/middleware.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
from functools import wraps
|
||||||
|
from flask import request, jsonify, current_app, g
|
||||||
|
from authlib.jose import jwt, JoseError, JsonWebKey
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
from backend.models import db, User
|
||||||
|
|
||||||
|
|
||||||
|
# Simple in-memory cache for JWKS (in production, use Redis with TTL)
|
||||||
|
_jwks_cache = {'data': None, 'timestamp': None}
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwks():
|
||||||
|
"""Fetch JWKS from Authelia (with basic caching)"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
# Check cache (24-hour TTL)
|
||||||
|
if _jwks_cache['data'] and _jwks_cache['timestamp']:
|
||||||
|
if datetime.utcnow() - _jwks_cache['timestamp'] < timedelta(hours=24):
|
||||||
|
return _jwks_cache['data']
|
||||||
|
|
||||||
|
# Fetch JWKS
|
||||||
|
jwks_uri = current_app.config.get('OIDC_JWKS_URI')
|
||||||
|
if not jwks_uri:
|
||||||
|
# Fetch from discovery document
|
||||||
|
issuer = current_app.config['OIDC_ISSUER']
|
||||||
|
discovery_url = f"{issuer}/.well-known/openid-configuration"
|
||||||
|
discovery = requests.get(discovery_url, timeout=10).json()
|
||||||
|
jwks_uri = discovery['jwks_uri']
|
||||||
|
|
||||||
|
jwks_data = requests.get(jwks_uri, timeout=10).json()
|
||||||
|
|
||||||
|
# Update cache
|
||||||
|
_jwks_cache['data'] = jwks_data
|
||||||
|
_jwks_cache['timestamp'] = datetime.utcnow()
|
||||||
|
|
||||||
|
return jwks_data
|
||||||
|
|
||||||
|
|
||||||
|
def validate_jwt(token):
|
||||||
|
"""Validate JWT token and return claims"""
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
jwks = get_jwks()
|
||||||
|
|
||||||
|
# Decode and validate JWT
|
||||||
|
claims = jwt.decode(
|
||||||
|
token,
|
||||||
|
jwks,
|
||||||
|
claims_options={
|
||||||
|
'iss': {'essential': True, 'value': current_app.config['OIDC_ISSUER']},
|
||||||
|
'aud': {'essential': True, 'values': [current_app.config['OIDC_AUDIENCE']]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
claims.validate()
|
||||||
|
return claims
|
||||||
|
|
||||||
|
except JoseError as e:
|
||||||
|
current_app.logger.error(f"JWT validation failed: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Unexpected error during JWT validation: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(f):
|
||||||
|
"""Decorator to require authentication on any route"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
auth_header = request.headers.get('Authorization')
|
||||||
|
|
||||||
|
if not auth_header or not auth_header.startswith('Bearer '):
|
||||||
|
return jsonify({'error': 'Missing or invalid Authorization header'}), 401
|
||||||
|
|
||||||
|
token = auth_header.split(' ')[1]
|
||||||
|
claims = validate_jwt(token)
|
||||||
|
|
||||||
|
if not claims:
|
||||||
|
return jsonify({'error': 'Invalid or expired token'}), 401
|
||||||
|
|
||||||
|
# Get or create user from claims
|
||||||
|
user = User.query.filter_by(authelia_sub=claims['sub']).first()
|
||||||
|
if not user:
|
||||||
|
# Auto-create user on first login
|
||||||
|
groups_claim = current_app.config.get('OIDC_GROUPS_CLAIM', 'groups')
|
||||||
|
user = User(
|
||||||
|
authelia_sub=claims['sub'],
|
||||||
|
email=claims.get('email'),
|
||||||
|
name=claims.get('name'),
|
||||||
|
preferred_username=claims.get('preferred_username'),
|
||||||
|
groups=claims.get(groups_claim, [])
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
else:
|
||||||
|
# Update user info from latest token
|
||||||
|
groups_claim = current_app.config.get('OIDC_GROUPS_CLAIM', 'groups')
|
||||||
|
user.email = claims.get('email')
|
||||||
|
user.name = claims.get('name')
|
||||||
|
user.preferred_username = claims.get('preferred_username')
|
||||||
|
user.groups = claims.get(groups_claim, [])
|
||||||
|
user.last_login = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Check if user is active
|
||||||
|
if not user.is_active:
|
||||||
|
return jsonify({'error': 'User account is disabled'}), 403
|
||||||
|
|
||||||
|
# Store user in Flask's g object for access in route handlers
|
||||||
|
g.current_user = user
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(f):
|
||||||
|
"""Decorator to require admin role (must be used WITH @require_auth)"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not hasattr(g, 'current_user'):
|
||||||
|
return jsonify({'error': 'Authentication required'}), 401
|
||||||
|
|
||||||
|
if not g.current_user.is_admin:
|
||||||
|
return jsonify({'error': 'Admin access required'}), 403
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
29
backend/celery_app.py
Normal file
29
backend/celery_app.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from celery import Celery
|
||||||
|
from backend.config import config
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def make_celery():
|
||||||
|
"""Create Celery instance"""
|
||||||
|
config_name = os.environ.get('FLASK_ENV', 'development')
|
||||||
|
app_config = config.get(config_name, config['default'])
|
||||||
|
|
||||||
|
celery = Celery(
|
||||||
|
'trivia_tasks',
|
||||||
|
broker=app_config.CELERY_BROKER_URL,
|
||||||
|
backend=app_config.CELERY_RESULT_BACKEND
|
||||||
|
)
|
||||||
|
|
||||||
|
celery.conf.update(
|
||||||
|
task_track_started=True,
|
||||||
|
task_time_limit=app_config.CELERY_TASK_TIME_LIMIT,
|
||||||
|
result_expires=3600, # Results expire after 1 hour
|
||||||
|
)
|
||||||
|
|
||||||
|
return celery
|
||||||
|
|
||||||
|
|
||||||
|
celery = make_celery()
|
||||||
|
|
||||||
|
# Import tasks to register them with Celery
|
||||||
|
from backend.tasks import youtube_tasks # noqa: E402, F401
|
||||||
84
backend/config.py
Normal file
84
backend/config.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Base directory
|
||||||
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Base configuration"""
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||||
|
|
||||||
|
# Database configuration
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URI') or \
|
||||||
|
f'sqlite:///{(BASE_DIR / "backend" / "instance" / "trivia.db").absolute()}'
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
# File upload configuration
|
||||||
|
UPLOAD_FOLDER = BASE_DIR / "backend" / "static" / "images"
|
||||||
|
MAX_CONTENT_LENGTH = 5 * 1024 * 1024 # 5MB max file size
|
||||||
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
||||||
|
|
||||||
|
# Audio upload configuration
|
||||||
|
AUDIO_FOLDER = BASE_DIR / "backend" / "static" / "audio"
|
||||||
|
ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'm4a', 'aac', 'wav'}
|
||||||
|
MAX_AUDIO_LENGTH = 300 # 5 minutes in seconds
|
||||||
|
MAX_AUDIO_SIZE = 50 * 1024 * 1024 # 50MB
|
||||||
|
|
||||||
|
# Celery configuration
|
||||||
|
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
|
||||||
|
CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0')
|
||||||
|
CELERY_TASK_TRACK_STARTED = True
|
||||||
|
CELERY_TASK_TIME_LIMIT = 600 # 10 minutes max per task
|
||||||
|
|
||||||
|
# yt-dlp configuration
|
||||||
|
YTDLP_FORMAT = 'bestaudio/best'
|
||||||
|
YTDLP_POSTPROCESSOR = 'mp3'
|
||||||
|
YTDLP_QUALITY = '192' # kbps
|
||||||
|
|
||||||
|
# CORS configuration
|
||||||
|
CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*').split(',')
|
||||||
|
CORS_SUPPORTS_CREDENTIALS = True
|
||||||
|
|
||||||
|
# OIDC/Authelia configuration
|
||||||
|
OIDC_ISSUER = os.environ.get('OIDC_ISSUER')
|
||||||
|
OIDC_CLIENT_ID = os.environ.get('OIDC_CLIENT_ID', 'trivia-app')
|
||||||
|
OIDC_CLIENT_SECRET = os.environ.get('OIDC_CLIENT_SECRET')
|
||||||
|
OIDC_REDIRECT_URI = os.environ.get('OIDC_REDIRECT_URI', 'http://localhost:5001/api/auth/callback')
|
||||||
|
OIDC_AUDIENCE = os.environ.get('OIDC_AUDIENCE', OIDC_CLIENT_ID)
|
||||||
|
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000')
|
||||||
|
OIDC_JWKS_URI = os.environ.get('OIDC_JWKS_URI') # Optional, auto-fetched if not set
|
||||||
|
|
||||||
|
# Cookie security
|
||||||
|
SESSION_COOKIE_SECURE = os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
|
||||||
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
|
SESSION_COOKIE_SAMESITE = 'Strict'
|
||||||
|
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
"""Development configuration"""
|
||||||
|
DEBUG = True
|
||||||
|
FLASK_ENV = 'development'
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
"""Production configuration"""
|
||||||
|
DEBUG = False
|
||||||
|
FLASK_ENV = 'production'
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY') # Must be set in production
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig(Config):
|
||||||
|
"""Test configuration"""
|
||||||
|
TESTING = True
|
||||||
|
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||||
|
WTF_CSRF_ENABLED = False
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration dictionary
|
||||||
|
config = {
|
||||||
|
'development': DevelopmentConfig,
|
||||||
|
'production': ProductionConfig,
|
||||||
|
'test': TestConfig,
|
||||||
|
'default': DevelopmentConfig
|
||||||
|
}
|
||||||
278
backend/models.py
Normal file
278
backend/models.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from sqlalchemy import Enum
|
||||||
|
import enum
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
"""User model for authenticated users via Authelia OIDC"""
|
||||||
|
__tablename__ = 'users'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
authelia_sub = db.Column(db.String(255), unique=True, nullable=False) # OIDC 'sub' claim
|
||||||
|
email = db.Column(db.String(255), nullable=True) # Email may not always be provided by OIDC
|
||||||
|
name = db.Column(db.String(255), nullable=True)
|
||||||
|
preferred_username = db.Column(db.String(100), nullable=True)
|
||||||
|
groups = db.Column(db.JSON, default=list, nullable=False) # Array of group names from OIDC
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
last_login = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_admin(self):
|
||||||
|
"""Check if user is in trivia-admins group"""
|
||||||
|
return 'trivia-admins' in (self.groups or [])
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert user to dictionary"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'email': self.email,
|
||||||
|
'name': self.name,
|
||||||
|
'username': self.preferred_username,
|
||||||
|
'groups': self.groups,
|
||||||
|
'is_admin': self.is_admin,
|
||||||
|
'last_login': self.last_login.isoformat() if self.last_login else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionType(enum.Enum):
|
||||||
|
"""Enum for question types"""
|
||||||
|
TEXT = "text"
|
||||||
|
IMAGE = "image"
|
||||||
|
YOUTUBE_AUDIO = "youtube_audio"
|
||||||
|
|
||||||
|
|
||||||
|
class Category(db.Model):
|
||||||
|
"""Category model for organizing questions"""
|
||||||
|
__tablename__ = 'categories'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert category to dictionary"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Question(db.Model):
|
||||||
|
"""Question model for trivia questions"""
|
||||||
|
__tablename__ = 'questions'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
type = db.Column(Enum(QuestionType), nullable=False, default=QuestionType.TEXT)
|
||||||
|
question_content = db.Column(db.Text, nullable=False)
|
||||||
|
answer = db.Column(db.Text, nullable=False)
|
||||||
|
image_path = db.Column(db.String(255), nullable=True) # For image questions
|
||||||
|
category = db.Column(db.String(100), nullable=True) # Question category (e.g., "History", "Science")
|
||||||
|
|
||||||
|
# YouTube audio fields
|
||||||
|
youtube_url = db.Column(db.String(500), nullable=True) # YouTube video URL
|
||||||
|
audio_path = db.Column(db.String(255), nullable=True) # Path to trimmed audio file
|
||||||
|
start_time = db.Column(db.Integer, nullable=True) # Start time in seconds
|
||||||
|
end_time = db.Column(db.Integer, nullable=True) # End time in seconds
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
game_questions = db.relationship('GameQuestion', back_populates='question', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
def to_dict(self, include_answer=False):
|
||||||
|
"""Convert question to dictionary"""
|
||||||
|
data = {
|
||||||
|
'id': self.id,
|
||||||
|
'type': self.type.value,
|
||||||
|
'question_content': self.question_content,
|
||||||
|
'image_path': self.image_path,
|
||||||
|
'youtube_url': self.youtube_url,
|
||||||
|
'audio_path': self.audio_path,
|
||||||
|
'start_time': self.start_time,
|
||||||
|
'end_time': self.end_time,
|
||||||
|
'category': self.category,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
|
if include_answer:
|
||||||
|
data['answer'] = self.answer
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class Game(db.Model):
|
||||||
|
"""Game model representing a trivia game session"""
|
||||||
|
__tablename__ = 'games'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(200), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
is_active = db.Column(db.Boolean, default=False) # Only one game active at a time
|
||||||
|
current_question_index = db.Column(db.Integer, default=0) # Track current question
|
||||||
|
is_template = db.Column(db.Boolean, default=False) # Mark as reusable template
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
teams = db.relationship('Team', back_populates='game', cascade='all, delete-orphan')
|
||||||
|
game_questions = db.relationship('GameQuestion', back_populates='game',
|
||||||
|
cascade='all, delete-orphan',
|
||||||
|
order_by='GameQuestion.order')
|
||||||
|
scores = db.relationship('Score', back_populates='game', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_active(cls):
|
||||||
|
"""Get the currently active game"""
|
||||||
|
return cls.query.filter_by(is_active=True).first()
|
||||||
|
|
||||||
|
def get_current_question(self):
|
||||||
|
"""Get the current question in the game"""
|
||||||
|
if 0 <= self.current_question_index < len(self.game_questions):
|
||||||
|
return self.game_questions[self.current_question_index].question
|
||||||
|
return None
|
||||||
|
|
||||||
|
def to_dict(self, include_questions=False, include_teams=False):
|
||||||
|
"""Convert game to dictionary"""
|
||||||
|
data = {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'is_active': self.is_active,
|
||||||
|
'current_question_index': self.current_question_index,
|
||||||
|
'total_questions': len(self.game_questions),
|
||||||
|
'is_template': self.is_template
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_questions:
|
||||||
|
data['questions'] = [gq.to_dict() for gq in self.game_questions]
|
||||||
|
|
||||||
|
if include_teams:
|
||||||
|
data['teams'] = [team.to_dict() for team in self.teams]
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class GameQuestion(db.Model):
|
||||||
|
"""Junction table linking games to questions with ordering"""
|
||||||
|
__tablename__ = 'game_questions'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=False)
|
||||||
|
question_id = db.Column(db.Integer, db.ForeignKey('questions.id'), nullable=False)
|
||||||
|
order = db.Column(db.Integer, nullable=False) # Question order in game
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
game = db.relationship('Game', back_populates='game_questions')
|
||||||
|
question = db.relationship('Question', back_populates='game_questions')
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('game_id', 'order', name='unique_game_question_order'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert to dictionary"""
|
||||||
|
return {
|
||||||
|
'order': self.order,
|
||||||
|
'question': self.question.to_dict()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Team(db.Model):
|
||||||
|
"""Team model representing a trivia team"""
|
||||||
|
__tablename__ = 'teams'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
phone_a_friend_count = db.Column(db.Integer, default=5) # Number of phone-a-friend lifelines
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
game = db.relationship('Game', back_populates='teams')
|
||||||
|
scores = db.relationship('Score', back_populates='team', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_score(self):
|
||||||
|
"""Calculate total score for the team"""
|
||||||
|
return sum(score.points for score in self.scores)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert team to dictionary"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'game_id': self.game_id,
|
||||||
|
'total_score': self.total_score,
|
||||||
|
'phone_a_friend_count': self.phone_a_friend_count,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Score(db.Model):
|
||||||
|
"""Score model tracking points per team per question"""
|
||||||
|
__tablename__ = 'scores'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
|
||||||
|
game_id = db.Column(db.Integer, db.ForeignKey('games.id'), nullable=False)
|
||||||
|
question_index = db.Column(db.Integer, nullable=False) # Which question this score is for
|
||||||
|
points = db.Column(db.Integer, default=0)
|
||||||
|
awarded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
team = db.relationship('Team', back_populates='scores')
|
||||||
|
game = db.relationship('Game', back_populates='scores')
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('team_id', 'question_index', name='unique_team_question_score'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert score to dictionary"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'team_id': self.team_id,
|
||||||
|
'game_id': self.game_id,
|
||||||
|
'question_index': self.question_index,
|
||||||
|
'points': self.points,
|
||||||
|
'awarded_at': self.awarded_at.isoformat() if self.awarded_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadJobStatus(enum.Enum):
|
||||||
|
"""Enum for download job statuses"""
|
||||||
|
PENDING = "pending"
|
||||||
|
PROCESSING = "processing"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadJob(db.Model):
|
||||||
|
"""Track YouTube audio download jobs"""
|
||||||
|
__tablename__ = 'download_jobs'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
question_id = db.Column(db.Integer, db.ForeignKey('questions.id'), nullable=False)
|
||||||
|
celery_task_id = db.Column(db.String(255), nullable=False, unique=True)
|
||||||
|
status = db.Column(Enum(DownloadJobStatus), default=DownloadJobStatus.PENDING, nullable=False)
|
||||||
|
progress = db.Column(db.Integer, default=0) # 0-100
|
||||||
|
error_message = db.Column(db.Text, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
completed_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
question = db.relationship('Question', backref='download_job')
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert download job to dictionary"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'question_id': self.question_id,
|
||||||
|
'task_id': self.celery_task_id,
|
||||||
|
'status': self.status.value,
|
||||||
|
'progress': self.progress,
|
||||||
|
'error_message': self.error_message,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'completed_at': self.completed_at.isoformat() if self.completed_at else None
|
||||||
|
}
|
||||||
0
backend/routes/__init__.py
Normal file
0
backend/routes/__init__.py
Normal file
304
backend/routes/admin.py
Normal file
304
backend/routes/admin.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from backend.models import db, Game, Team
|
||||||
|
from backend.services import game_service
|
||||||
|
from backend.app import socketio
|
||||||
|
from backend.auth.middleware import require_auth
|
||||||
|
|
||||||
|
bp = Blueprint('admin', __name__, url_prefix='/api/admin')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/start', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def start_game(game_id):
|
||||||
|
"""Start/activate a game"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
game_service.start_game(game, socketio)
|
||||||
|
return jsonify({'message': 'Game started successfully', 'game': game.to_dict()}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/next', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def next_question(game_id):
|
||||||
|
"""Move to next question"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if game_service.next_question(game, socketio):
|
||||||
|
return jsonify({'message': 'Moved to next question', 'current_index': game.current_question_index}), 200
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'Already at last question'}), 400
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/prev', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def previous_question(game_id):
|
||||||
|
"""Move to previous question"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if game_service.previous_question(game, socketio):
|
||||||
|
return jsonify({'message': 'Moved to previous question', 'current_index': game.current_question_index}), 200
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'Already at first question'}), 400
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/award', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def award_points(game_id):
|
||||||
|
"""Award points to a team
|
||||||
|
|
||||||
|
Expected JSON: { "team_id": int, "points": int }
|
||||||
|
"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or 'team_id' not in data or 'points' not in data:
|
||||||
|
return jsonify({'error': 'team_id and points are required'}), 400
|
||||||
|
|
||||||
|
team_id = data['team_id']
|
||||||
|
points = data['points']
|
||||||
|
|
||||||
|
team = Team.query.get_or_404(team_id)
|
||||||
|
|
||||||
|
# Verify team belongs to this game
|
||||||
|
if team.game_id != game_id:
|
||||||
|
return jsonify({'error': 'Team does not belong to this game'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
game_service.award_points(game, team, points, socketio)
|
||||||
|
return jsonify({'message': 'Points awarded successfully', 'team': team.to_dict()}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/current', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_current_state(game_id):
|
||||||
|
"""Get current game state with answer (admin only)"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = game_service.get_admin_game_state(game)
|
||||||
|
return jsonify(state), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/toggle-answer', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def toggle_answer_visibility(game_id):
|
||||||
|
"""Toggle answer visibility on contestant screen
|
||||||
|
|
||||||
|
Expected JSON: { "show_answer": bool }
|
||||||
|
"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or 'show_answer' not in data:
|
||||||
|
return jsonify({'error': 'show_answer is required'}), 400
|
||||||
|
|
||||||
|
show_answer = data['show_answer']
|
||||||
|
|
||||||
|
try:
|
||||||
|
game_service.toggle_answer_visibility(game, show_answer, socketio)
|
||||||
|
return jsonify({'message': 'Answer visibility toggled', 'show_answer': show_answer}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/pause-timer', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def pause_timer(game_id):
|
||||||
|
"""Pause or resume the timer
|
||||||
|
|
||||||
|
Expected JSON: { "paused": bool }
|
||||||
|
"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or 'paused' not in data:
|
||||||
|
return jsonify({'error': 'paused is required'}), 400
|
||||||
|
|
||||||
|
paused = data['paused']
|
||||||
|
|
||||||
|
try:
|
||||||
|
game_service.toggle_timer_pause(game, paused, socketio)
|
||||||
|
return jsonify({'message': 'Timer pause state updated', 'paused': paused}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/reset-timer', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def reset_timer(game_id):
|
||||||
|
"""Reset the timer to 30 seconds"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
game_service.reset_timer(game, socketio)
|
||||||
|
return jsonify({'message': 'Timer reset'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/end', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def end_game(game_id):
|
||||||
|
"""End/deactivate a game"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
game_service.end_game(game, socketio)
|
||||||
|
return jsonify({'message': 'Game ended successfully', 'game': game.to_dict()}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/restart', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def restart_game(game_id):
|
||||||
|
"""Restart a game (clear scores and reset to waiting state)"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
game_service.restart_game(game, socketio)
|
||||||
|
return jsonify({'message': 'Game restarted successfully', 'game': game.to_dict()}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/team/<int:team_id>/use-lifeline', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def use_lifeline(game_id, team_id):
|
||||||
|
"""Use a phone-a-friend lifeline for a team"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
team = Team.query.get_or_404(team_id)
|
||||||
|
|
||||||
|
# Verify team belongs to this game
|
||||||
|
if team.game_id != game_id:
|
||||||
|
return jsonify({'error': 'Team does not belong to this game'}), 400
|
||||||
|
|
||||||
|
if team.phone_a_friend_count <= 0:
|
||||||
|
return jsonify({'error': 'No lifelines remaining'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
team.phone_a_friend_count -= 1
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Broadcast lifeline update
|
||||||
|
game_service.broadcast_lifeline_update(game, team, socketio)
|
||||||
|
|
||||||
|
return jsonify({'message': 'Lifeline used', 'team': team.to_dict()}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/team/<int:team_id>/add-lifeline', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def add_lifeline(game_id, team_id):
|
||||||
|
"""Add a phone-a-friend lifeline to a team"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
team = Team.query.get_or_404(team_id)
|
||||||
|
|
||||||
|
# Verify team belongs to this game
|
||||||
|
if team.game_id != game_id:
|
||||||
|
return jsonify({'error': 'Team does not belong to this game'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
team.phone_a_friend_count += 1
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Broadcast lifeline update
|
||||||
|
game_service.broadcast_lifeline_update(game, team, socketio)
|
||||||
|
|
||||||
|
return jsonify({'message': 'Lifeline added', 'team': team.to_dict()}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/audio/play', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def play_audio(game_id):
|
||||||
|
"""Admin controls audio playback for contestants"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
game_service.broadcast_audio_play(game, socketio)
|
||||||
|
return jsonify({'message': 'Audio play command sent'}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/audio/pause', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def pause_audio(game_id):
|
||||||
|
"""Pause audio for all contestants"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
game_service.broadcast_audio_pause(game, socketio)
|
||||||
|
return jsonify({'message': 'Audio pause command sent'}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/audio/stop', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def stop_audio(game_id):
|
||||||
|
"""Stop and reset audio for all contestants"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
game_service.broadcast_audio_stop(game, socketio)
|
||||||
|
return jsonify({'message': 'Audio stop command sent'}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/audio/seek', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def seek_audio(game_id):
|
||||||
|
"""Seek audio to specific position
|
||||||
|
|
||||||
|
Expected JSON: { "position": float }
|
||||||
|
"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or 'position' not in data:
|
||||||
|
return jsonify({'error': 'position is required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
position = float(data['position'])
|
||||||
|
game_service.broadcast_audio_seek(game, position, socketio)
|
||||||
|
return jsonify({'message': f'Audio seeked to {position}s'}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
124
backend/routes/auth.py
Normal file
124
backend/routes/auth.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
from flask import Blueprint, request, jsonify, redirect, make_response, current_app, g
|
||||||
|
from backend.auth import oauth
|
||||||
|
from backend.auth.middleware import require_auth
|
||||||
|
from backend.models import db, User
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
bp = Blueprint('auth', __name__, url_prefix='/api/auth')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/login')
|
||||||
|
def login():
|
||||||
|
"""Redirect to Authelia login page"""
|
||||||
|
redirect_uri = current_app.config['OIDC_REDIRECT_URI']
|
||||||
|
return oauth.authelia.authorize_redirect(redirect_uri)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/callback')
|
||||||
|
def callback():
|
||||||
|
"""Handle OIDC callback from Authelia"""
|
||||||
|
try:
|
||||||
|
# Exchange authorization code for tokens
|
||||||
|
token = oauth.authelia.authorize_access_token()
|
||||||
|
|
||||||
|
# Parse ID token to get user info
|
||||||
|
user_info = token.get('userinfo')
|
||||||
|
if not user_info:
|
||||||
|
user_info = oauth.authelia.parse_id_token(token)
|
||||||
|
|
||||||
|
# Get or create user
|
||||||
|
user = User.query.filter_by(authelia_sub=user_info['sub']).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
user = User(
|
||||||
|
authelia_sub=user_info['sub'],
|
||||||
|
email=user_info.get('email'),
|
||||||
|
name=user_info.get('name'),
|
||||||
|
preferred_username=user_info.get('preferred_username'),
|
||||||
|
groups=user_info.get('groups', [])
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
else:
|
||||||
|
user.email = user_info.get('email')
|
||||||
|
user.name = user_info.get('name')
|
||||||
|
user.preferred_username = user_info.get('preferred_username')
|
||||||
|
user.groups = user_info.get('groups', [])
|
||||||
|
user.last_login = datetime.utcnow()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Redirect to frontend with tokens in URL fragment (SPA pattern)
|
||||||
|
frontend_url = current_app.config.get('FRONTEND_URL', 'http://localhost:3000')
|
||||||
|
|
||||||
|
# Create response with refresh token in HTTP-only cookie
|
||||||
|
response = make_response(redirect(
|
||||||
|
f"{frontend_url}/auth/callback#access_token={token['access_token']}"
|
||||||
|
f"&id_token={token['id_token']}"
|
||||||
|
f"&expires_in={token.get('expires_in', 900)}"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Set refresh token as HTTP-only cookie
|
||||||
|
if token.get('refresh_token'):
|
||||||
|
response.set_cookie(
|
||||||
|
'refresh_token',
|
||||||
|
value=token['refresh_token'],
|
||||||
|
httponly=True,
|
||||||
|
secure=current_app.config.get('SESSION_COOKIE_SECURE', False),
|
||||||
|
samesite='Strict',
|
||||||
|
max_age=7*24*60*60 # 7 days
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"OIDC callback error: {e}")
|
||||||
|
frontend_url = current_app.config.get('FRONTEND_URL', 'http://localhost:3000')
|
||||||
|
return redirect(f"{frontend_url}/login?error=auth_failed")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/refresh', methods=['POST'])
|
||||||
|
def refresh():
|
||||||
|
"""Refresh access token using refresh token"""
|
||||||
|
refresh_token = request.cookies.get('refresh_token')
|
||||||
|
|
||||||
|
if not refresh_token:
|
||||||
|
return jsonify({'error': 'No refresh token'}), 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Exchange refresh token for new access token
|
||||||
|
new_token = oauth.authelia.fetch_access_token(
|
||||||
|
grant_type='refresh_token',
|
||||||
|
refresh_token=refresh_token
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'access_token': new_token['access_token'],
|
||||||
|
'expires_in': new_token.get('expires_in', 900)
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Token refresh failed: {e}")
|
||||||
|
return jsonify({'error': 'Token refresh failed'}), 401
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/logout', methods=['POST'])
|
||||||
|
def logout():
|
||||||
|
"""Logout user and revoke tokens"""
|
||||||
|
# Clear refresh token cookie
|
||||||
|
response = make_response(jsonify({'message': 'Logged out'}), 200)
|
||||||
|
response.set_cookie('refresh_token', '', expires=0)
|
||||||
|
|
||||||
|
# Return Authelia logout URL for frontend to redirect
|
||||||
|
authelia_logout_url = f"{current_app.config['OIDC_ISSUER']}/logout"
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': 'Logged out',
|
||||||
|
'logout_url': authelia_logout_url
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/me')
|
||||||
|
@require_auth
|
||||||
|
def get_current_user():
|
||||||
|
"""Get current user info (requires auth)"""
|
||||||
|
return jsonify(g.current_user.to_dict()), 200
|
||||||
91
backend/routes/categories.py
Normal file
91
backend/routes/categories.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from backend.models import db, Category
|
||||||
|
|
||||||
|
bp = Blueprint('categories', __name__, url_prefix='/api/categories')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('', methods=['GET'])
|
||||||
|
def list_categories():
|
||||||
|
"""Get all categories"""
|
||||||
|
categories = Category.query.order_by(Category.name).all()
|
||||||
|
return jsonify([c.to_dict() for c in categories]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:category_id>', methods=['GET'])
|
||||||
|
def get_category(category_id):
|
||||||
|
"""Get a single category by ID"""
|
||||||
|
category = Category.query.get_or_404(category_id)
|
||||||
|
return jsonify(category.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('', methods=['POST'])
|
||||||
|
def create_category():
|
||||||
|
"""Create a new category"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'No data provided'}), 400
|
||||||
|
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({'error': 'Category name is required'}), 400
|
||||||
|
|
||||||
|
# Check if category already exists
|
||||||
|
existing = Category.query.filter_by(name=name).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({'error': 'Category already exists'}), 409
|
||||||
|
|
||||||
|
try:
|
||||||
|
category = Category(name=name)
|
||||||
|
db.session.add(category)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(category.to_dict()), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:category_id>', methods=['PUT'])
|
||||||
|
def update_category(category_id):
|
||||||
|
"""Update an existing category"""
|
||||||
|
category = Category.query.get_or_404(category_id)
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'No data provided'}), 400
|
||||||
|
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({'error': 'Category name is required'}), 400
|
||||||
|
|
||||||
|
# Check if another category with this name exists
|
||||||
|
existing = Category.query.filter(Category.name == name, Category.id != category_id).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({'error': 'Category already exists'}), 409
|
||||||
|
|
||||||
|
try:
|
||||||
|
category.name = name
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(category.to_dict()), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:category_id>', methods=['DELETE'])
|
||||||
|
def delete_category(category_id):
|
||||||
|
"""Delete a category"""
|
||||||
|
category = Category.query.get_or_404(category_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.delete(category)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'message': 'Category deleted successfully'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
18
backend/routes/download_jobs.py
Normal file
18
backend/routes/download_jobs.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from flask import Blueprint, jsonify
|
||||||
|
from backend.models import DownloadJob
|
||||||
|
|
||||||
|
bp = Blueprint('download_jobs', __name__, url_prefix='/api/download-jobs')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:job_id>', methods=['GET'])
|
||||||
|
def get_job_status(job_id):
|
||||||
|
"""Get download job status"""
|
||||||
|
job = DownloadJob.query.get_or_404(job_id)
|
||||||
|
return jsonify(job.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/question/<int:question_id>', methods=['GET'])
|
||||||
|
def get_job_by_question(question_id):
|
||||||
|
"""Get download job for a question"""
|
||||||
|
job = DownloadJob.query.filter_by(question_id=question_id).first_or_404()
|
||||||
|
return jsonify(job.to_dict()), 200
|
||||||
183
backend/routes/games.py
Normal file
183
backend/routes/games.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from backend.models import db, Game, GameQuestion, Team, Question
|
||||||
|
|
||||||
|
bp = Blueprint('games', __name__, url_prefix='/api/games')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('', methods=['GET'])
|
||||||
|
def list_games():
|
||||||
|
"""Get all games"""
|
||||||
|
games = Game.query.order_by(Game.created_at.desc()).all()
|
||||||
|
return jsonify([g.to_dict(include_teams=True) for g in games]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:game_id>', methods=['GET'])
|
||||||
|
def get_game(game_id):
|
||||||
|
"""Get a single game by ID with full details"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
return jsonify(game.to_dict(include_questions=True, include_teams=True)), 200
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('', methods=['POST'])
|
||||||
|
def create_game():
|
||||||
|
"""Create a new game"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'No data provided'}), 400
|
||||||
|
|
||||||
|
name = data.get('name')
|
||||||
|
if not name:
|
||||||
|
return jsonify({'error': 'Game name is required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
game = Game(name=name)
|
||||||
|
db.session.add(game)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(game.to_dict()), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:game_id>', methods=['DELETE'])
|
||||||
|
def delete_game(game_id):
|
||||||
|
"""Delete a game"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.delete(game)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'message': 'Game deleted successfully'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:game_id>/questions', methods=['POST'])
|
||||||
|
def add_questions_to_game(game_id):
|
||||||
|
"""Add questions to a game
|
||||||
|
|
||||||
|
Expects JSON: { "question_ids": [1, 2, 3, ...] }
|
||||||
|
"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or 'question_ids' not in data:
|
||||||
|
return jsonify({'error': 'question_ids array is required'}), 400
|
||||||
|
|
||||||
|
question_ids = data['question_ids']
|
||||||
|
if not isinstance(question_ids, list):
|
||||||
|
return jsonify({'error': 'question_ids must be an array'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Remove existing questions for this game
|
||||||
|
GameQuestion.query.filter_by(game_id=game_id).delete()
|
||||||
|
|
||||||
|
# Add new questions with order
|
||||||
|
for order, question_id in enumerate(question_ids):
|
||||||
|
question = Question.query.get(question_id)
|
||||||
|
if not question:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': f'Question with ID {question_id} not found'}), 404
|
||||||
|
|
||||||
|
game_question = GameQuestion(
|
||||||
|
game_id=game_id,
|
||||||
|
question_id=question_id,
|
||||||
|
order=order
|
||||||
|
)
|
||||||
|
db.session.add(game_question)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(game.to_dict(include_questions=True, include_teams=True)), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:game_id>/teams', methods=['POST'])
|
||||||
|
def add_team_to_game(game_id):
|
||||||
|
"""Add a team to a game
|
||||||
|
|
||||||
|
Expects JSON: { "name": "Team Name" }
|
||||||
|
"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or 'name' not in data:
|
||||||
|
return jsonify({'error': 'Team name is required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
team = Team(name=data['name'], game_id=game_id)
|
||||||
|
db.session.add(team)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(team.to_dict()), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:game_id>/save-template', methods=['POST'])
|
||||||
|
def save_as_template(game_id):
|
||||||
|
"""Mark a game as a template for reuse"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
game.is_template = True
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(game.to_dict(include_questions=True)), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/templates', methods=['GET'])
|
||||||
|
def list_templates():
|
||||||
|
"""Get all game templates"""
|
||||||
|
templates = Game.query.filter_by(is_template=True).order_by(Game.created_at.desc()).all()
|
||||||
|
return jsonify([g.to_dict(include_questions=True) for g in templates]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:template_id>/clone', methods=['POST'])
|
||||||
|
def clone_template(template_id):
|
||||||
|
"""Clone a template to create a new game
|
||||||
|
|
||||||
|
Expects JSON: { "name": "New Game Name" }
|
||||||
|
"""
|
||||||
|
template = Game.query.get_or_404(template_id)
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or 'name' not in data:
|
||||||
|
return jsonify({'error': 'Game name is required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create new game
|
||||||
|
new_game = Game(name=data['name'], is_template=False)
|
||||||
|
db.session.add(new_game)
|
||||||
|
db.session.flush() # Get new game ID
|
||||||
|
|
||||||
|
# Clone questions
|
||||||
|
for gq in template.game_questions:
|
||||||
|
new_gq = GameQuestion(
|
||||||
|
game_id=new_game.id,
|
||||||
|
question_id=gq.question_id,
|
||||||
|
order=gq.order
|
||||||
|
)
|
||||||
|
db.session.add(new_gq)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(new_game.to_dict(include_questions=True)), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
343
backend/routes/questions.py
Normal file
343
backend/routes/questions.py
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
from backend.models import db, Question, QuestionType
|
||||||
|
from backend.services.image_service import save_image, delete_image
|
||||||
|
|
||||||
|
bp = Blueprint('questions', __name__, url_prefix='/api/questions')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('', methods=['GET'])
|
||||||
|
def list_questions():
|
||||||
|
"""Get all questions"""
|
||||||
|
questions = Question.query.order_by(Question.created_at.desc()).all()
|
||||||
|
return jsonify([q.to_dict(include_answer=True) for q in questions]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:question_id>', methods=['GET'])
|
||||||
|
def get_question(question_id):
|
||||||
|
"""Get a single question by ID"""
|
||||||
|
question = Question.query.get_or_404(question_id)
|
||||||
|
return jsonify(question.to_dict(include_answer=True)), 200
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('', methods=['POST'])
|
||||||
|
def create_question():
|
||||||
|
"""Create a new question"""
|
||||||
|
try:
|
||||||
|
# Check if it's a multipart form (for image uploads) or JSON
|
||||||
|
if request.content_type and 'multipart/form-data' in request.content_type:
|
||||||
|
# Image question
|
||||||
|
data = request.form
|
||||||
|
question_type = data.get('type', 'text')
|
||||||
|
question_content = data.get('question_content', '')
|
||||||
|
answer = data.get('answer', '')
|
||||||
|
category = data.get('category', '')
|
||||||
|
|
||||||
|
image_path = None
|
||||||
|
if question_type == 'image':
|
||||||
|
if 'image' not in request.files:
|
||||||
|
return jsonify({'error': 'Image file required for image questions'}), 400
|
||||||
|
|
||||||
|
file = request.files['image']
|
||||||
|
try:
|
||||||
|
image_path = save_image(
|
||||||
|
file,
|
||||||
|
current_app.config['UPLOAD_FOLDER'],
|
||||||
|
current_app.config['ALLOWED_EXTENSIONS']
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
|
||||||
|
youtube_url = None
|
||||||
|
audio_path = None
|
||||||
|
start_time = None
|
||||||
|
end_time = None
|
||||||
|
|
||||||
|
else:
|
||||||
|
# JSON request for text or YouTube audio questions
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'No data provided'}), 400
|
||||||
|
|
||||||
|
question_type = data.get('type', 'text')
|
||||||
|
question_content = data.get('question_content', '')
|
||||||
|
answer = data.get('answer', '')
|
||||||
|
category = data.get('category', '')
|
||||||
|
image_path = None
|
||||||
|
|
||||||
|
# Handle YouTube audio questions
|
||||||
|
youtube_url = None
|
||||||
|
audio_path = None
|
||||||
|
start_time = None
|
||||||
|
end_time = None
|
||||||
|
|
||||||
|
if question_type == 'youtube_audio':
|
||||||
|
youtube_url = data.get('youtube_url', '')
|
||||||
|
|
||||||
|
if not youtube_url:
|
||||||
|
return jsonify({'error': 'youtube_url required for YouTube audio questions'}), 400
|
||||||
|
|
||||||
|
# Validate YouTube URL
|
||||||
|
from backend.services.youtube_service import validate_youtube_url, validate_timestamps, get_video_duration
|
||||||
|
|
||||||
|
is_valid, result = validate_youtube_url(youtube_url)
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({'error': result}), 400
|
||||||
|
|
||||||
|
# Get and validate timestamps
|
||||||
|
try:
|
||||||
|
start_time = int(data.get('start_time', 0))
|
||||||
|
end_time = int(data.get('end_time', 0))
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'error': 'start_time and end_time must be integers'}), 400
|
||||||
|
|
||||||
|
# Validate timestamp range
|
||||||
|
video_duration = get_video_duration(youtube_url)
|
||||||
|
is_valid, error = validate_timestamps(start_time, end_time, video_duration)
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({'error': error}), 400
|
||||||
|
|
||||||
|
# Note: audio_path will be null until download completes
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if not question_content:
|
||||||
|
return jsonify({'error': 'question_content is required'}), 400
|
||||||
|
if not answer:
|
||||||
|
return jsonify({'error': 'answer is required'}), 400
|
||||||
|
|
||||||
|
# Create question
|
||||||
|
question = Question(
|
||||||
|
type=QuestionType(question_type),
|
||||||
|
question_content=question_content,
|
||||||
|
answer=answer,
|
||||||
|
image_path=image_path,
|
||||||
|
youtube_url=youtube_url,
|
||||||
|
audio_path=audio_path, # Will be None initially for YouTube
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
category=category if category else None
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(question)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# For YouTube audio, start async download
|
||||||
|
if question_type == 'youtube_audio':
|
||||||
|
from backend.tasks.youtube_tasks import download_youtube_audio
|
||||||
|
from backend.models import DownloadJob, DownloadJobStatus
|
||||||
|
|
||||||
|
# Start Celery task
|
||||||
|
task = download_youtube_audio.delay(question.id, youtube_url, start_time, end_time)
|
||||||
|
|
||||||
|
# Create job tracking record
|
||||||
|
job = DownloadJob(
|
||||||
|
question_id=question.id,
|
||||||
|
celery_task_id=task.id,
|
||||||
|
status=DownloadJobStatus.PENDING
|
||||||
|
)
|
||||||
|
db.session.add(job)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'question': question.to_dict(include_answer=True),
|
||||||
|
'job': job.to_dict()
|
||||||
|
}), 202 # 202 Accepted (async processing)
|
||||||
|
|
||||||
|
return jsonify(question.to_dict(include_answer=True)), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:question_id>', methods=['PUT'])
|
||||||
|
def update_question(question_id):
|
||||||
|
"""Update an existing question"""
|
||||||
|
question = Question.query.get_or_404(question_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Handle multipart form data or JSON
|
||||||
|
if request.content_type and 'multipart/form-data' in request.content_type:
|
||||||
|
data = request.form
|
||||||
|
|
||||||
|
# Update fields if provided
|
||||||
|
if 'question_content' in data:
|
||||||
|
question.question_content = data['question_content']
|
||||||
|
if 'answer' in data:
|
||||||
|
question.answer = data['answer']
|
||||||
|
if 'type' in data:
|
||||||
|
question.type = QuestionType(data['type'])
|
||||||
|
if 'category' in data:
|
||||||
|
question.category = data['category'] if data['category'] else None
|
||||||
|
|
||||||
|
# Handle new image upload
|
||||||
|
if 'image' in request.files:
|
||||||
|
file = request.files['image']
|
||||||
|
if file and file.filename:
|
||||||
|
# Delete old image if exists
|
||||||
|
if question.image_path:
|
||||||
|
delete_image(question.image_path, current_app.root_path)
|
||||||
|
|
||||||
|
# Save new image
|
||||||
|
try:
|
||||||
|
question.image_path = save_image(
|
||||||
|
file,
|
||||||
|
current_app.config['UPLOAD_FOLDER'],
|
||||||
|
current_app.config['ALLOWED_EXTENSIONS']
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
|
||||||
|
else:
|
||||||
|
# JSON request
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'No data provided'}), 400
|
||||||
|
|
||||||
|
if 'question_content' in data:
|
||||||
|
question.question_content = data['question_content']
|
||||||
|
if 'answer' in data:
|
||||||
|
question.answer = data['answer']
|
||||||
|
if 'type' in data:
|
||||||
|
question.type = QuestionType(data['type'])
|
||||||
|
if 'category' in data:
|
||||||
|
question.category = data['category'] if data['category'] else None
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(question.to_dict(include_answer=True)), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:question_id>', methods=['DELETE'])
|
||||||
|
def delete_question(question_id):
|
||||||
|
"""Delete a question"""
|
||||||
|
question = Question.query.get_or_404(question_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete associated image if exists
|
||||||
|
if question.image_path:
|
||||||
|
delete_image(question.image_path, current_app.root_path)
|
||||||
|
|
||||||
|
# Delete associated audio if exists
|
||||||
|
if question.audio_path:
|
||||||
|
from backend.services.audio_service import delete_audio
|
||||||
|
delete_audio(question.audio_path, current_app.root_path)
|
||||||
|
|
||||||
|
db.session.delete(question)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'message': 'Question deleted successfully'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/random', methods=['GET'])
|
||||||
|
def get_random_questions():
|
||||||
|
"""Get random questions by category
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- category: Category name (required)
|
||||||
|
- count: Number of random questions to return (default: 5)
|
||||||
|
"""
|
||||||
|
category = request.args.get('category')
|
||||||
|
if not category:
|
||||||
|
return jsonify({'error': 'category parameter is required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
count = int(request.args.get('count', 5))
|
||||||
|
if count < 1:
|
||||||
|
return jsonify({'error': 'count must be at least 1'}), 400
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'error': 'count must be a valid integer'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get all questions for the category
|
||||||
|
questions = Question.query.filter_by(category=category).all()
|
||||||
|
|
||||||
|
if not questions:
|
||||||
|
return jsonify({'error': f'No questions found for category: {category}'}), 404
|
||||||
|
|
||||||
|
# Randomly select questions
|
||||||
|
import random
|
||||||
|
selected = random.sample(questions, min(count, len(questions)))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'category': category,
|
||||||
|
'requested': count,
|
||||||
|
'returned': len(selected),
|
||||||
|
'questions': [q.to_dict(include_answer=True) for q in selected]
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/bulk', methods=['POST'])
|
||||||
|
def bulk_create_questions():
|
||||||
|
"""Bulk create questions
|
||||||
|
|
||||||
|
Expected JSON: {
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"question_content": "What is 2+2?",
|
||||||
|
"answer": "4",
|
||||||
|
"category": "Math",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'questions' not in data:
|
||||||
|
return jsonify({'error': 'questions array is required'}), 400
|
||||||
|
|
||||||
|
questions_data = data['questions']
|
||||||
|
if not isinstance(questions_data, list):
|
||||||
|
return jsonify({'error': 'questions must be an array'}), 400
|
||||||
|
|
||||||
|
created_questions = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for idx, q_data in enumerate(questions_data):
|
||||||
|
try:
|
||||||
|
# Validate required fields
|
||||||
|
if not q_data.get('question_content'):
|
||||||
|
errors.append({'index': idx, 'error': 'question_content is required'})
|
||||||
|
continue
|
||||||
|
if not q_data.get('answer'):
|
||||||
|
errors.append({'index': idx, 'error': 'answer is required'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create question
|
||||||
|
question = Question(
|
||||||
|
type=QuestionType(q_data.get('type', 'text')),
|
||||||
|
question_content=q_data['question_content'],
|
||||||
|
answer=q_data['answer'],
|
||||||
|
category=q_data.get('category') if q_data.get('category') else None,
|
||||||
|
image_path=None # Bulk import doesn't support images
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(question)
|
||||||
|
created_questions.append(question)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append({'index': idx, 'error': str(e)})
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': f'Successfully created {len(created_questions)} questions',
|
||||||
|
'created': len(created_questions),
|
||||||
|
'errors': errors,
|
||||||
|
'questions': [q.to_dict(include_answer=True) for q in created_questions]
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
20
backend/routes/teams.py
Normal file
20
backend/routes/teams.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from flask import Blueprint, jsonify
|
||||||
|
from backend.models import db, Team
|
||||||
|
|
||||||
|
bp = Blueprint('teams', __name__, url_prefix='/api/teams')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:team_id>', methods=['DELETE'])
|
||||||
|
def delete_team(team_id):
|
||||||
|
"""Delete a team"""
|
||||||
|
team = Team.query.get_or_404(team_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.delete(team)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'message': 'Team deleted successfully'}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
39
backend/services/audio_service.py
Normal file
39
backend/services/audio_service.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_audio_file(filename, allowed_extensions):
|
||||||
|
"""Check if file has allowed audio extension"""
|
||||||
|
return '.' in filename and \
|
||||||
|
filename.rsplit('.', 1)[1].lower() in allowed_extensions
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_path(unique_filename):
|
||||||
|
"""Get URL path for serving audio"""
|
||||||
|
return f"/static/audio/{unique_filename}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_audio_filename(extension='mp3'):
|
||||||
|
"""Generate unique audio filename"""
|
||||||
|
return f"{uuid.uuid4()}.{extension}"
|
||||||
|
|
||||||
|
|
||||||
|
def delete_audio(audio_path, base_folder):
|
||||||
|
"""
|
||||||
|
Delete an audio file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_path: Relative path to audio (e.g., /static/audio/abc123.mp3)
|
||||||
|
base_folder: Base folder for the application
|
||||||
|
"""
|
||||||
|
if not audio_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
relative_path = audio_path.lstrip('/')
|
||||||
|
full_path = os.path.join(base_folder, relative_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.exists(full_path):
|
||||||
|
os.remove(full_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error deleting audio {full_path}: {str(e)}")
|
||||||
285
backend/services/game_service.py
Normal file
285
backend/services/game_service.py
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
from backend.models import db, Game, Score
|
||||||
|
from flask_socketio import emit
|
||||||
|
|
||||||
|
|
||||||
|
def get_game_state(game):
|
||||||
|
"""Get current game state with all necessary information"""
|
||||||
|
current_question = game.get_current_question()
|
||||||
|
|
||||||
|
state = {
|
||||||
|
'game_id': game.id,
|
||||||
|
'game_name': game.name,
|
||||||
|
'current_question_index': game.current_question_index,
|
||||||
|
'total_questions': len(game.game_questions),
|
||||||
|
'is_active': game.is_active,
|
||||||
|
'teams': [team.to_dict() for team in game.teams]
|
||||||
|
}
|
||||||
|
|
||||||
|
if current_question:
|
||||||
|
state['current_question'] = current_question.to_dict(include_answer=False)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_game_state(game):
|
||||||
|
"""Get game state with answer (for admin only)"""
|
||||||
|
state = get_game_state(game)
|
||||||
|
current_question = game.get_current_question()
|
||||||
|
|
||||||
|
if current_question:
|
||||||
|
state['current_question'] = current_question.to_dict(include_answer=True)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def start_game(game, socketio_instance):
|
||||||
|
"""Start/activate a game"""
|
||||||
|
# Deactivate any other active games
|
||||||
|
active_games = Game.query.filter_by(is_active=True).all()
|
||||||
|
for g in active_games:
|
||||||
|
if g.id != game.id:
|
||||||
|
g.is_active = False
|
||||||
|
|
||||||
|
game.is_active = True
|
||||||
|
game.current_question_index = 0
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Emit game_started event
|
||||||
|
socketio_instance.emit('game_started', {
|
||||||
|
'game_id': game.id,
|
||||||
|
'game_name': game.name,
|
||||||
|
'total_questions': len(game.game_questions)
|
||||||
|
}, room=f'game_{game.id}_contestant')
|
||||||
|
|
||||||
|
socketio_instance.emit('game_started', {
|
||||||
|
'game_id': game.id,
|
||||||
|
'game_name': game.name,
|
||||||
|
'total_questions': len(game.game_questions)
|
||||||
|
}, room=f'game_{game.id}_admin')
|
||||||
|
|
||||||
|
# Emit first question
|
||||||
|
broadcast_question_change(game, socketio_instance)
|
||||||
|
|
||||||
|
|
||||||
|
def next_question(game, socketio_instance):
|
||||||
|
"""Move to next question"""
|
||||||
|
if game.current_question_index < len(game.game_questions) - 1:
|
||||||
|
game.current_question_index += 1
|
||||||
|
db.session.commit()
|
||||||
|
broadcast_question_change(game, socketio_instance)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def previous_question(game, socketio_instance):
|
||||||
|
"""Move to previous question"""
|
||||||
|
if game.current_question_index > 0:
|
||||||
|
game.current_question_index -= 1
|
||||||
|
db.session.commit()
|
||||||
|
broadcast_question_change(game, socketio_instance)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_question_change(game, socketio_instance):
|
||||||
|
"""Broadcast question change to all connected clients"""
|
||||||
|
current_question = game.get_current_question()
|
||||||
|
|
||||||
|
if not current_question:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Emit to contestant room (without answer)
|
||||||
|
socketio_instance.emit('question_changed', {
|
||||||
|
'question_index': game.current_question_index,
|
||||||
|
'question': current_question.to_dict(include_answer=False),
|
||||||
|
'total_questions': len(game.game_questions)
|
||||||
|
}, room=f'game_{game.id}_contestant')
|
||||||
|
|
||||||
|
# Emit to admin room (with answer)
|
||||||
|
socketio_instance.emit('question_with_answer', {
|
||||||
|
'question_index': game.current_question_index,
|
||||||
|
'question': current_question.to_dict(include_answer=True),
|
||||||
|
'total_questions': len(game.game_questions)
|
||||||
|
}, room=f'game_{game.id}_admin')
|
||||||
|
|
||||||
|
|
||||||
|
def award_points(game, team, points, socketio_instance):
|
||||||
|
"""Award points to a team for the current question"""
|
||||||
|
# Check if score already exists for this team and question
|
||||||
|
existing_score = Score.query.filter_by(
|
||||||
|
team_id=team.id,
|
||||||
|
question_index=game.current_question_index
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_score:
|
||||||
|
# Add to existing score
|
||||||
|
existing_score.points += points
|
||||||
|
else:
|
||||||
|
# Create new score
|
||||||
|
score = Score(
|
||||||
|
team_id=team.id,
|
||||||
|
game_id=game.id,
|
||||||
|
question_index=game.current_question_index,
|
||||||
|
points=points
|
||||||
|
)
|
||||||
|
db.session.add(score)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Get all team scores with full data including lifelines
|
||||||
|
all_scores = [t.to_dict() for t in game.teams]
|
||||||
|
|
||||||
|
# Broadcast score update
|
||||||
|
score_data = {
|
||||||
|
'team_id': team.id,
|
||||||
|
'team_name': team.name,
|
||||||
|
'new_score': team.total_score,
|
||||||
|
'points_awarded': points,
|
||||||
|
'all_scores': all_scores
|
||||||
|
}
|
||||||
|
|
||||||
|
socketio_instance.emit('score_updated', score_data, room=f'game_{game.id}_contestant')
|
||||||
|
socketio_instance.emit('score_updated', score_data, room=f'game_{game.id}_admin')
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_answer_visibility(game, show_answer, socketio_instance):
|
||||||
|
"""Toggle answer visibility on contestant screen"""
|
||||||
|
current_question = game.get_current_question()
|
||||||
|
|
||||||
|
if not current_question:
|
||||||
|
return
|
||||||
|
|
||||||
|
answer_data = {
|
||||||
|
'show_answer': show_answer
|
||||||
|
}
|
||||||
|
|
||||||
|
if show_answer:
|
||||||
|
answer_data['answer'] = current_question.answer
|
||||||
|
|
||||||
|
# Broadcast to contestant room only
|
||||||
|
socketio_instance.emit('answer_visibility_changed', answer_data, room=f'game_{game.id}_contestant')
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_timer_pause(game, paused, socketio_instance):
|
||||||
|
"""Pause or resume the timer"""
|
||||||
|
# Broadcast timer pause state to both rooms
|
||||||
|
socketio_instance.emit('timer_paused', {
|
||||||
|
'paused': paused
|
||||||
|
}, room=f'game_{game.id}_contestant')
|
||||||
|
|
||||||
|
socketio_instance.emit('timer_paused', {
|
||||||
|
'paused': paused
|
||||||
|
}, room=f'game_{game.id}_admin')
|
||||||
|
|
||||||
|
|
||||||
|
def reset_timer(game, socketio_instance):
|
||||||
|
"""Reset the timer to 30 seconds"""
|
||||||
|
# Broadcast timer reset to both rooms
|
||||||
|
socketio_instance.emit('timer_reset', {
|
||||||
|
'seconds': 30
|
||||||
|
}, room=f'game_{game.id}_contestant')
|
||||||
|
|
||||||
|
socketio_instance.emit('timer_reset', {
|
||||||
|
'seconds': 30
|
||||||
|
}, room=f'game_{game.id}_admin')
|
||||||
|
|
||||||
|
|
||||||
|
def end_game(game, socketio_instance):
|
||||||
|
"""End/deactivate a game"""
|
||||||
|
game.is_active = False
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Emit game_ended event to all rooms
|
||||||
|
socketio_instance.emit('game_ended', {
|
||||||
|
'game_id': game.id,
|
||||||
|
'game_name': game.name
|
||||||
|
}, room=f'game_{game.id}_contestant')
|
||||||
|
|
||||||
|
socketio_instance.emit('game_ended', {
|
||||||
|
'game_id': game.id,
|
||||||
|
'game_name': game.name
|
||||||
|
}, room=f'game_{game.id}_admin')
|
||||||
|
|
||||||
|
|
||||||
|
def restart_game(game, socketio_instance):
|
||||||
|
"""Restart a game (clear scores and reset to waiting state)"""
|
||||||
|
# Clear all scores for this game
|
||||||
|
Score.query.filter_by(game_id=game.id).delete()
|
||||||
|
|
||||||
|
# Reset game state
|
||||||
|
game.is_active = False
|
||||||
|
game.current_question_index = 0
|
||||||
|
|
||||||
|
# Reset phone-a-friend lifelines for all teams
|
||||||
|
for team in game.teams:
|
||||||
|
team.phone_a_friend_count = 5
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Emit game_ended event to reset contestant view
|
||||||
|
socketio_instance.emit('game_ended', {
|
||||||
|
'game_id': game.id,
|
||||||
|
'game_name': game.name
|
||||||
|
}, room=f'game_{game.id}_contestant')
|
||||||
|
|
||||||
|
# Emit score update to show cleared scores and reset lifelines
|
||||||
|
socketio_instance.emit('score_updated', {
|
||||||
|
'team_id': None,
|
||||||
|
'team_name': None,
|
||||||
|
'new_score': 0,
|
||||||
|
'points_awarded': 0,
|
||||||
|
'all_scores': [t.to_dict() for t in game.teams]
|
||||||
|
}, room=f'game_{game.id}_contestant')
|
||||||
|
|
||||||
|
socketio_instance.emit('score_updated', {
|
||||||
|
'team_id': None,
|
||||||
|
'team_name': None,
|
||||||
|
'new_score': 0,
|
||||||
|
'points_awarded': 0,
|
||||||
|
'all_scores': [t.to_dict() for t in game.teams]
|
||||||
|
}, room=f'game_{game.id}_admin')
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_lifeline_update(game, team, socketio_instance):
|
||||||
|
"""Broadcast phone-a-friend lifeline update"""
|
||||||
|
# Get all team scores with updated lifeline counts
|
||||||
|
all_scores = [t.to_dict() for t in game.teams]
|
||||||
|
|
||||||
|
lifeline_data = {
|
||||||
|
'team_id': team.id,
|
||||||
|
'team_name': team.name,
|
||||||
|
'phone_a_friend_count': team.phone_a_friend_count,
|
||||||
|
'all_scores': all_scores
|
||||||
|
}
|
||||||
|
|
||||||
|
socketio_instance.emit('lifeline_updated', lifeline_data, room=f'game_{game.id}_contestant')
|
||||||
|
socketio_instance.emit('lifeline_updated', lifeline_data, room=f'game_{game.id}_admin')
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_audio_play(game, socketio_instance):
|
||||||
|
"""Broadcast audio play command to contestants"""
|
||||||
|
socketio_instance.emit('audio_play', {
|
||||||
|
'game_id': game.id
|
||||||
|
}, room=f'game_{game.id}_contestant')
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_audio_pause(game, socketio_instance):
|
||||||
|
"""Broadcast audio pause command to contestants"""
|
||||||
|
socketio_instance.emit('audio_pause', {
|
||||||
|
'game_id': game.id
|
||||||
|
}, room=f'game_{game.id}_contestant')
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_audio_stop(game, socketio_instance):
|
||||||
|
"""Broadcast audio stop command to contestants"""
|
||||||
|
socketio_instance.emit('audio_stop', {
|
||||||
|
'game_id': game.id
|
||||||
|
}, room=f'game_{game.id}_contestant')
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_audio_seek(game, position, socketio_instance):
|
||||||
|
"""Broadcast audio seek command to contestants"""
|
||||||
|
socketio_instance.emit('audio_seek', {
|
||||||
|
'game_id': game.id,
|
||||||
|
'position': position
|
||||||
|
}, room=f'game_{game.id}_contestant')
|
||||||
78
backend/services/image_service.py
Normal file
78
backend/services/image_service.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_file(filename, allowed_extensions):
|
||||||
|
"""Check if file has allowed extension"""
|
||||||
|
return '.' in filename and \
|
||||||
|
filename.rsplit('.', 1)[1].lower() in allowed_extensions
|
||||||
|
|
||||||
|
|
||||||
|
def save_image(file, upload_folder, allowed_extensions):
|
||||||
|
"""
|
||||||
|
Save uploaded image file with validation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: FileStorage object from Flask request
|
||||||
|
upload_folder: Directory to save images
|
||||||
|
allowed_extensions: Set of allowed file extensions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Relative path to saved image, or None if validation fails
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If file validation fails
|
||||||
|
"""
|
||||||
|
if not file or file.filename == '':
|
||||||
|
raise ValueError("No file provided")
|
||||||
|
|
||||||
|
if not allowed_file(file.filename, allowed_extensions):
|
||||||
|
raise ValueError(f"File type not allowed. Allowed types: {', '.join(allowed_extensions)}")
|
||||||
|
|
||||||
|
# Verify it's actually an image
|
||||||
|
try:
|
||||||
|
img = Image.open(file.stream)
|
||||||
|
img.verify()
|
||||||
|
file.stream.seek(0) # Reset stream after verification
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Invalid image file: {str(e)}")
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
original_filename = secure_filename(file.filename)
|
||||||
|
extension = original_filename.rsplit('.', 1)[1].lower()
|
||||||
|
unique_filename = f"{uuid.uuid4()}.{extension}"
|
||||||
|
|
||||||
|
# Ensure upload folder exists
|
||||||
|
os.makedirs(upload_folder, exist_ok=True)
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
filepath = os.path.join(upload_folder, unique_filename)
|
||||||
|
file.save(filepath)
|
||||||
|
|
||||||
|
# Return relative path (for storing in database)
|
||||||
|
return f"/static/images/{unique_filename}"
|
||||||
|
|
||||||
|
|
||||||
|
def delete_image(image_path, base_folder):
|
||||||
|
"""
|
||||||
|
Delete an image file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Relative path to image (e.g., /static/images/abc123.jpg)
|
||||||
|
base_folder: Base folder for the application
|
||||||
|
"""
|
||||||
|
if not image_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove leading slash and construct full path
|
||||||
|
relative_path = image_path.lstrip('/')
|
||||||
|
full_path = os.path.join(base_folder, relative_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.exists(full_path):
|
||||||
|
os.remove(full_path)
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but don't fail the operation
|
||||||
|
print(f"Error deleting image {full_path}: {str(e)}")
|
||||||
71
backend/services/youtube_service.py
Normal file
71
backend/services/youtube_service.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import re
|
||||||
|
import yt_dlp
|
||||||
|
|
||||||
|
|
||||||
|
def validate_youtube_url(url):
|
||||||
|
"""
|
||||||
|
Validate YouTube URL and extract video ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(bool, str): (is_valid, video_id or error_message)
|
||||||
|
"""
|
||||||
|
patterns = [
|
||||||
|
r'(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)',
|
||||||
|
r'youtube\.com\/embed\/([\w-]+)',
|
||||||
|
r'youtube\.com\/v\/([\w-]+)'
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, url)
|
||||||
|
if match:
|
||||||
|
return True, match.group(1)
|
||||||
|
|
||||||
|
return False, "Invalid YouTube URL format"
|
||||||
|
|
||||||
|
|
||||||
|
def get_video_duration(url):
|
||||||
|
"""
|
||||||
|
Get video duration without downloading
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Duration in seconds, or None if failed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ydl_opts = {
|
||||||
|
'quiet': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
'extract_flat': True,
|
||||||
|
}
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(url, download=False)
|
||||||
|
return info.get('duration')
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting video duration: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_timestamps(start_time, end_time, video_duration=None):
|
||||||
|
"""
|
||||||
|
Validate timestamp range
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_time: Start time in seconds
|
||||||
|
end_time: End time in seconds
|
||||||
|
video_duration: Optional video duration for validation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(bool, str): (is_valid, error_message if invalid)
|
||||||
|
"""
|
||||||
|
if start_time < 0:
|
||||||
|
return False, "Start time must be non-negative"
|
||||||
|
|
||||||
|
if end_time <= start_time:
|
||||||
|
return False, "End time must be greater than start time"
|
||||||
|
|
||||||
|
if end_time - start_time > 300: # 5 minutes
|
||||||
|
return False, "Clip duration cannot exceed 5 minutes"
|
||||||
|
|
||||||
|
if video_duration and end_time > video_duration:
|
||||||
|
return False, f"End time exceeds video duration ({video_duration}s)"
|
||||||
|
|
||||||
|
return True, None
|
||||||
0
backend/sockets/__init__.py
Normal file
0
backend/sockets/__init__.py
Normal file
114
backend/sockets/events.py
Normal file
114
backend/sockets/events.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
from flask_socketio import emit, join_room, leave_room
|
||||||
|
from backend.app import socketio
|
||||||
|
from backend.auth.middleware import validate_jwt
|
||||||
|
from backend.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('connect')
|
||||||
|
def handle_connect(auth):
|
||||||
|
"""Handle client connection with JWT authentication"""
|
||||||
|
# Extract token from auth parameter
|
||||||
|
token = auth.get('token') if auth else None
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
print('Connection rejected: No token provided')
|
||||||
|
return False # Reject connection
|
||||||
|
|
||||||
|
# Validate JWT
|
||||||
|
claims = validate_jwt(token)
|
||||||
|
if not claims:
|
||||||
|
print('Connection rejected: Invalid token')
|
||||||
|
return False # Reject connection
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
user = User.query.filter_by(authelia_sub=claims['sub']).first()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
print('Connection rejected: User not found or inactive')
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f'Client connected: {user.email}')
|
||||||
|
emit('connected', {
|
||||||
|
'message': 'Connected to trivia game server',
|
||||||
|
'user': user.to_dict()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('disconnect')
|
||||||
|
def handle_disconnect():
|
||||||
|
"""Handle client disconnection"""
|
||||||
|
print('Client disconnected')
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('join_game')
|
||||||
|
def handle_join_game(data):
|
||||||
|
"""Handle client joining a game room with role validation
|
||||||
|
|
||||||
|
Expected data: { 'game_id': int, 'role': 'contestant' | 'admin', 'token': str }
|
||||||
|
"""
|
||||||
|
game_id = data.get('game_id')
|
||||||
|
role = data.get('role', 'contestant')
|
||||||
|
token = data.get('token')
|
||||||
|
|
||||||
|
if not game_id or not token:
|
||||||
|
emit('error', {'message': 'game_id and token are required'})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate JWT
|
||||||
|
claims = validate_jwt(token)
|
||||||
|
if not claims:
|
||||||
|
emit('error', {'message': 'Invalid or expired token'})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
user = User.query.filter_by(authelia_sub=claims['sub']).first()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
emit('error', {'message': 'User not found or inactive'})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Join appropriate room based on role
|
||||||
|
if role == 'admin':
|
||||||
|
room = f'game_{game_id}_admin'
|
||||||
|
join_room(room)
|
||||||
|
print(f'User {user.email} joined admin room: {room}')
|
||||||
|
emit('joined', {
|
||||||
|
'game_id': game_id,
|
||||||
|
'room': 'admin',
|
||||||
|
'user': user.to_dict(),
|
||||||
|
'message': f'Joined admin room for game {game_id}'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
room = f'game_{game_id}_contestant'
|
||||||
|
join_room(room)
|
||||||
|
print(f'User {user.email} joined contestant room: {room}')
|
||||||
|
emit('joined', {
|
||||||
|
'game_id': game_id,
|
||||||
|
'room': 'contestant',
|
||||||
|
'user': user.to_dict(),
|
||||||
|
'message': f'Joined contestant room for game {game_id}'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('leave_game')
|
||||||
|
def handle_leave_game(data):
|
||||||
|
"""Handle client leaving a game room
|
||||||
|
|
||||||
|
Expected data: { 'game_id': int, 'role': 'contestant' | 'admin' }
|
||||||
|
"""
|
||||||
|
game_id = data.get('game_id')
|
||||||
|
role = data.get('role', 'contestant')
|
||||||
|
|
||||||
|
if not game_id:
|
||||||
|
emit('error', {'message': 'game_id is required'})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Leave appropriate room based on role
|
||||||
|
if role == 'admin':
|
||||||
|
room = f'game_{game_id}_admin'
|
||||||
|
leave_room(room)
|
||||||
|
print(f'Client left admin room: {room}')
|
||||||
|
else:
|
||||||
|
room = f'game_{game_id}_contestant'
|
||||||
|
leave_room(room)
|
||||||
|
print(f'Client left contestant room: {room}')
|
||||||
|
|
||||||
|
emit('left', {'game_id': game_id, 'message': f'Left game {game_id}'})
|
||||||
0
backend/static/images/.gitkeep
Normal file
0
backend/static/images/.gitkeep
Normal file
1
backend/tasks/__init__.py
Normal file
1
backend/tasks/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Tasks package
|
||||||
120
backend/tasks/youtube_tasks.py
Normal file
120
backend/tasks/youtube_tasks.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import os
|
||||||
|
import yt_dlp
|
||||||
|
from pydub import AudioSegment
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from backend.celery_app import celery
|
||||||
|
from backend.models import db, Question, DownloadJob, DownloadJobStatus
|
||||||
|
from backend.services.audio_service import generate_audio_filename, get_audio_path
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(bind=True)
|
||||||
|
def download_youtube_audio(self, question_id, youtube_url, start_time, end_time):
|
||||||
|
"""
|
||||||
|
Download and trim YouTube audio clip
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question_id: Question ID to update
|
||||||
|
youtube_url: YouTube video URL
|
||||||
|
start_time: Start time in seconds
|
||||||
|
end_time: End time in seconds
|
||||||
|
"""
|
||||||
|
from backend.app import create_app
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
job = DownloadJob.query.filter_by(celery_task_id=self.request.id).first()
|
||||||
|
question = Question.query.get(question_id)
|
||||||
|
|
||||||
|
if not job or not question:
|
||||||
|
return {'success': False, 'error': 'Job or question not found'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update status to processing
|
||||||
|
job.status = DownloadJobStatus.PROCESSING
|
||||||
|
job.progress = 10
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Create temp and final directories
|
||||||
|
audio_folder = app.config['AUDIO_FOLDER']
|
||||||
|
os.makedirs(audio_folder, exist_ok=True)
|
||||||
|
temp_dir = os.path.join(audio_folder, 'temp')
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Download full audio
|
||||||
|
temp_filename = f"{self.request.id}_full"
|
||||||
|
temp_path = os.path.join(temp_dir, temp_filename)
|
||||||
|
|
||||||
|
def progress_hook(d):
|
||||||
|
"""Update progress during download"""
|
||||||
|
if d['status'] == 'downloading':
|
||||||
|
try:
|
||||||
|
# Extract percentage from string like "50.5%"
|
||||||
|
percent_str = d.get('_percent_str', '0%').strip('%')
|
||||||
|
percent = float(percent_str)
|
||||||
|
# Map download progress to 10-60% range
|
||||||
|
progress = 10 + int(percent * 0.5)
|
||||||
|
job.progress = progress
|
||||||
|
db.session.commit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
'format': app.config['YTDLP_FORMAT'],
|
||||||
|
'outtmpl': temp_path + '.%(ext)s',
|
||||||
|
'postprocessors': [{
|
||||||
|
'key': 'FFmpegExtractAudio',
|
||||||
|
'preferredcodec': 'mp3',
|
||||||
|
'preferredquality': app.config['YTDLP_QUALITY'],
|
||||||
|
}],
|
||||||
|
'progress_hooks': [progress_hook],
|
||||||
|
'quiet': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
ydl.download([youtube_url])
|
||||||
|
|
||||||
|
job.progress = 60
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Find downloaded file
|
||||||
|
full_audio_path = temp_path + '.mp3'
|
||||||
|
|
||||||
|
# Trim audio using pydub
|
||||||
|
audio = AudioSegment.from_mp3(full_audio_path)
|
||||||
|
clip = audio[start_time * 1000:end_time * 1000] # pydub uses milliseconds
|
||||||
|
|
||||||
|
job.progress = 80
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Save trimmed clip
|
||||||
|
final_filename = generate_audio_filename('mp3')
|
||||||
|
final_path = os.path.join(audio_folder, final_filename)
|
||||||
|
clip.export(final_path, format='mp3', bitrate='192k')
|
||||||
|
|
||||||
|
# Clean up temp file
|
||||||
|
if os.path.exists(full_audio_path):
|
||||||
|
os.remove(full_audio_path)
|
||||||
|
|
||||||
|
# Update question with audio path
|
||||||
|
audio_url = get_audio_path(final_filename)
|
||||||
|
question.audio_path = audio_url
|
||||||
|
|
||||||
|
# Update job status
|
||||||
|
job.status = DownloadJobStatus.COMPLETED
|
||||||
|
job.progress = 100
|
||||||
|
job.completed_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'audio_path': audio_url,
|
||||||
|
'question_id': question_id
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
job.status = DownloadJobStatus.FAILED
|
||||||
|
job.error_message = str(e)
|
||||||
|
db.session.commit()
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
125
docker-compose.yml
Normal file
125
docker-compose.yml
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
networks:
|
||||||
|
- trivia-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.backend
|
||||||
|
ports:
|
||||||
|
- "5001:5001"
|
||||||
|
environment:
|
||||||
|
- FLASK_ENV=development
|
||||||
|
- PORT=5001
|
||||||
|
- DATABASE_URI=sqlite:////app/backend/instance/trivia.db
|
||||||
|
- CORS_ORIGINS=*
|
||||||
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
|
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
# OIDC/Authelia configuration
|
||||||
|
- OIDC_ISSUER=${OIDC_ISSUER}
|
||||||
|
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-trivia-app}
|
||||||
|
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
||||||
|
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-http://localhost:5001/api/auth/callback}
|
||||||
|
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
|
||||||
|
- SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-false}
|
||||||
|
volumes:
|
||||||
|
# Mount source code for hot reload
|
||||||
|
- ./backend:/app/backend
|
||||||
|
- ./main.py:/app/main.py
|
||||||
|
- ./migrations:/app/migrations
|
||||||
|
# Persist database
|
||||||
|
- ./backend/instance:/app/backend/instance
|
||||||
|
# Persist uploaded images
|
||||||
|
- ./backend/static/images:/app/backend/static/images
|
||||||
|
# Persist audio files
|
||||||
|
- ./backend/static/audio:/app/backend/static/audio
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- trivia-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5001/api/health')"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
celery-worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.backend
|
||||||
|
command: uv run celery -A backend.celery_app worker --loglevel=info
|
||||||
|
environment:
|
||||||
|
- FLASK_ENV=development
|
||||||
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
|
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
- DATABASE_URI=sqlite:////app/backend/instance/trivia.db
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app/backend
|
||||||
|
- ./main.py:/app/main.py
|
||||||
|
- ./backend/instance:/app/backend/instance
|
||||||
|
- ./backend/static/audio:/app/backend/static/audio
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- trivia-network
|
||||||
|
|
||||||
|
celery-flower:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.backend
|
||||||
|
command: uv run celery -A backend.celery_app flower --port=5555
|
||||||
|
ports:
|
||||||
|
- "5555:5555"
|
||||||
|
environment:
|
||||||
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
|
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
- celery-worker
|
||||||
|
networks:
|
||||||
|
- trivia-network
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- VITE_BACKEND_URL=http://backend:5001
|
||||||
|
# OIDC configuration
|
||||||
|
- VITE_OIDC_AUTHORITY=${OIDC_ISSUER}
|
||||||
|
- VITE_OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-trivia-app}
|
||||||
|
volumes:
|
||||||
|
# Mount source code for hot reload
|
||||||
|
- ./frontend/frontend/src:/app/src
|
||||||
|
- ./frontend/frontend/public:/app/public
|
||||||
|
- ./frontend/frontend/index.html:/app/index.html
|
||||||
|
- ./frontend/frontend/vite.config.js:/app/vite.config.js:ro
|
||||||
|
- ./frontend/frontend/eslint.config.js:/app/eslint.config.js
|
||||||
|
# Use named volume for node_modules to avoid conflicts
|
||||||
|
- frontend-node-modules:/app/node_modules
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- trivia-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
trivia-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
frontend-node-modules:
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
16
frontend/README.md
Normal file
16
frontend/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
231
frontend/frontend/biome.json
Normal file
231
frontend/frontend/biome.json
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
|
||||||
|
"vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false },
|
||||||
|
"files": { "ignoreUnknown": false },
|
||||||
|
"formatter": { "enabled": true, "indentStyle": "tab" },
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": { "recommended": false },
|
||||||
|
"includes": ["**", "!dist"]
|
||||||
|
},
|
||||||
|
"javascript": { "formatter": { "quoteStyle": "double" } },
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["**/*.{js,jsx}"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"complexity": {
|
||||||
|
"noAdjacentSpacesInRegex": "error",
|
||||||
|
"noExtraBooleanCast": "error",
|
||||||
|
"noUselessCatch": "error",
|
||||||
|
"noUselessEscapeInRegex": "error"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noConstAssign": "error",
|
||||||
|
"noConstantCondition": "error",
|
||||||
|
"noEmptyCharacterClassInRegex": "error",
|
||||||
|
"noEmptyPattern": "error",
|
||||||
|
"noGlobalObjectCalls": "error",
|
||||||
|
"noInvalidBuiltinInstantiation": "error",
|
||||||
|
"noInvalidConstructorSuper": "error",
|
||||||
|
"noNonoctalDecimalEscape": "error",
|
||||||
|
"noPrecisionLoss": "error",
|
||||||
|
"noSelfAssign": "error",
|
||||||
|
"noSetterReturn": "error",
|
||||||
|
"noSwitchDeclarations": "error",
|
||||||
|
"noUndeclaredVariables": "error",
|
||||||
|
"noUnreachable": "error",
|
||||||
|
"noUnreachableSuper": "error",
|
||||||
|
"noUnsafeFinally": "error",
|
||||||
|
"noUnsafeOptionalChaining": "error",
|
||||||
|
"noUnusedLabels": "error",
|
||||||
|
"noUnusedPrivateClassMembers": "error",
|
||||||
|
"noUnusedVariables": "error",
|
||||||
|
"useIsNan": "error",
|
||||||
|
"useValidForDirection": "error",
|
||||||
|
"useValidTypeof": "error",
|
||||||
|
"useYield": "error"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noAsyncPromiseExecutor": "error",
|
||||||
|
"noCatchAssign": "error",
|
||||||
|
"noClassAssign": "error",
|
||||||
|
"noCompareNegZero": "error",
|
||||||
|
"noConstantBinaryExpressions": "error",
|
||||||
|
"noControlCharactersInRegex": "error",
|
||||||
|
"noDebugger": "error",
|
||||||
|
"noDuplicateCase": "error",
|
||||||
|
"noDuplicateClassMembers": "error",
|
||||||
|
"noDuplicateElseIf": "error",
|
||||||
|
"noDuplicateObjectKeys": "error",
|
||||||
|
"noDuplicateParameters": "error",
|
||||||
|
"noEmptyBlockStatements": "error",
|
||||||
|
"noFallthroughSwitchClause": "error",
|
||||||
|
"noFunctionAssign": "error",
|
||||||
|
"noGlobalAssign": "error",
|
||||||
|
"noImportAssign": "error",
|
||||||
|
"noIrregularWhitespace": "error",
|
||||||
|
"noMisleadingCharacterClass": "error",
|
||||||
|
"noPrototypeBuiltins": "error",
|
||||||
|
"noRedeclare": "error",
|
||||||
|
"noShadowRestrictedNames": "error",
|
||||||
|
"noSparseArray": "error",
|
||||||
|
"noUnsafeNegation": "error",
|
||||||
|
"noUselessRegexBackrefs": "error",
|
||||||
|
"noWith": "error",
|
||||||
|
"useGetterReturn": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": ["**/*.{js,jsx}"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"correctness": {
|
||||||
|
"useExhaustiveDependencies": "warn",
|
||||||
|
"useHookAtTopLevel": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ "includes": ["**/*.{js,jsx}"], "linter": { "rules": {} } },
|
||||||
|
{
|
||||||
|
"includes": ["**/*.{js,jsx}"],
|
||||||
|
"javascript": {
|
||||||
|
"globals": [
|
||||||
|
"onanimationend",
|
||||||
|
"ongamepadconnected",
|
||||||
|
"onlostpointercapture",
|
||||||
|
"onanimationiteration",
|
||||||
|
"onkeyup",
|
||||||
|
"onmousedown",
|
||||||
|
"onanimationstart",
|
||||||
|
"onslotchange",
|
||||||
|
"onprogress",
|
||||||
|
"ontransitionstart",
|
||||||
|
"onpause",
|
||||||
|
"onended",
|
||||||
|
"onpointerover",
|
||||||
|
"onscrollend",
|
||||||
|
"onformdata",
|
||||||
|
"ontransitionrun",
|
||||||
|
"onanimationcancel",
|
||||||
|
"ondrag",
|
||||||
|
"onchange",
|
||||||
|
"onbeforeinstallprompt",
|
||||||
|
"onbeforexrselect",
|
||||||
|
"onmessage",
|
||||||
|
"ontransitioncancel",
|
||||||
|
"onpointerdown",
|
||||||
|
"onabort",
|
||||||
|
"onpointerout",
|
||||||
|
"oncuechange",
|
||||||
|
"ongotpointercapture",
|
||||||
|
"onscrollsnapchanging",
|
||||||
|
"onsearch",
|
||||||
|
"onsubmit",
|
||||||
|
"onstalled",
|
||||||
|
"onsuspend",
|
||||||
|
"onreset",
|
||||||
|
"onerror",
|
||||||
|
"onresize",
|
||||||
|
"onmouseenter",
|
||||||
|
"ongamepaddisconnected",
|
||||||
|
"ondragover",
|
||||||
|
"onbeforetoggle",
|
||||||
|
"onmouseover",
|
||||||
|
"onpagehide",
|
||||||
|
"onmousemove",
|
||||||
|
"onratechange",
|
||||||
|
"oncommand",
|
||||||
|
"onmessageerror",
|
||||||
|
"onwheel",
|
||||||
|
"ondevicemotion",
|
||||||
|
"onauxclick",
|
||||||
|
"ontransitionend",
|
||||||
|
"onpaste",
|
||||||
|
"onpageswap",
|
||||||
|
"ononline",
|
||||||
|
"ondeviceorientationabsolute",
|
||||||
|
"onkeydown",
|
||||||
|
"onclose",
|
||||||
|
"onselect",
|
||||||
|
"onpageshow",
|
||||||
|
"onpointercancel",
|
||||||
|
"onbeforematch",
|
||||||
|
"onpointerrawupdate",
|
||||||
|
"ondragleave",
|
||||||
|
"onscrollsnapchange",
|
||||||
|
"onseeked",
|
||||||
|
"onwaiting",
|
||||||
|
"onbeforeunload",
|
||||||
|
"onplaying",
|
||||||
|
"onvolumechange",
|
||||||
|
"ondragend",
|
||||||
|
"onstorage",
|
||||||
|
"onloadeddata",
|
||||||
|
"onfocus",
|
||||||
|
"onoffline",
|
||||||
|
"onplay",
|
||||||
|
"onafterprint",
|
||||||
|
"onclick",
|
||||||
|
"oncut",
|
||||||
|
"onmouseout",
|
||||||
|
"ondblclick",
|
||||||
|
"oncanplay",
|
||||||
|
"onloadstart",
|
||||||
|
"onappinstalled",
|
||||||
|
"onpointermove",
|
||||||
|
"ontoggle",
|
||||||
|
"oncontextmenu",
|
||||||
|
"onblur",
|
||||||
|
"oncancel",
|
||||||
|
"onbeforeprint",
|
||||||
|
"oncontextrestored",
|
||||||
|
"onloadedmetadata",
|
||||||
|
"onpointerup",
|
||||||
|
"onlanguagechange",
|
||||||
|
"oncopy",
|
||||||
|
"onselectstart",
|
||||||
|
"onscroll",
|
||||||
|
"onload",
|
||||||
|
"ondragstart",
|
||||||
|
"onbeforeinput",
|
||||||
|
"oncanplaythrough",
|
||||||
|
"oninput",
|
||||||
|
"oninvalid",
|
||||||
|
"ontimeupdate",
|
||||||
|
"ondurationchange",
|
||||||
|
"onselectionchange",
|
||||||
|
"onmouseup",
|
||||||
|
"location",
|
||||||
|
"onkeypress",
|
||||||
|
"onpointerleave",
|
||||||
|
"oncontextlost",
|
||||||
|
"ondrop",
|
||||||
|
"onsecuritypolicyviolation",
|
||||||
|
"oncontentvisibilityautostatechange",
|
||||||
|
"ondeviceorientation",
|
||||||
|
"onseeking",
|
||||||
|
"onrejectionhandled",
|
||||||
|
"onunload",
|
||||||
|
"onmouseleave",
|
||||||
|
"onhashchange",
|
||||||
|
"onpointerenter",
|
||||||
|
"onmousewheel",
|
||||||
|
"onunhandledrejection",
|
||||||
|
"ondragenter",
|
||||||
|
"onpopstate",
|
||||||
|
"onpagereveal",
|
||||||
|
"onemptied"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"linter": { "rules": { "correctness": { "noUnusedVariables": "error" } } }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": { "source": { "organizeImports": "on" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
29
frontend/frontend/eslint.config.js
Normal file
29
frontend/frontend/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(["dist"]),
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,jsx}"],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-unused-vars": ["error", { varsIgnorePattern: "^[A-Z_]" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
17
frontend/frontend/index.html
Normal file
17
frontend/frontend/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image"
|
||||||
|
href="https://torrtle.co/content/images/size/w256h256/2024/08/mntCXWP-1-1.png"
|
||||||
|
/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ryan's trivia thang</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3533
frontend/frontend/package-lock.json
generated
Normal file
3533
frontend/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/frontend/package.json
Normal file
33
frontend/frontend/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
|
"oidc-client-ts": "^3.4.1",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.10.1",
|
||||||
|
"socket.io-client": "^4.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.3.10",
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/frontend/public/vite.svg
Normal file
1
frontend/frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
43
frontend/frontend/src/App.css
Normal file
43
frontend/frontend/src/App.css
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#root {
|
||||||
|
/*max-width: 1280px;*/
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
/*padding: 2rem;*/
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
143
frontend/frontend/src/App.jsx
Normal file
143
frontend/frontend/src/App.jsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||||
|
import { ProtectedRoute } from "./components/auth/ProtectedRoute";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import AuthCallback from "./pages/AuthCallback";
|
||||||
|
import QuestionBankView from "./components/questionbank/QuestionBankView";
|
||||||
|
import GameSetupView from "./components/questionbank/GameSetupView";
|
||||||
|
import ContestantView from "./components/contestant/ContestantView";
|
||||||
|
import GameAdminView from "./components/admin/GameAdminView";
|
||||||
|
import CategoryManagementView from "./components/categories/CategoryManagementView";
|
||||||
|
import TemplatesView from "./components/templates/TemplatesView";
|
||||||
|
import AdminNavbar from "./components/common/AdminNavbar";
|
||||||
|
import { setAuthTokenGetter } from "./services/api";
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
|
function HomePage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminNavbar />
|
||||||
|
<div style={{ padding: "1rem 2rem", minHeight: "calc(100vh - 60px)" }}>
|
||||||
|
<h1 style={{ margin: "0 0 1.5rem 0" }}>
|
||||||
|
Trivia Game - Welcome {user?.profile?.name || user?.profile?.email}
|
||||||
|
</h1>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#f0f0f0",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "1.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>Quick Start</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Create questions in the Question Bank</li>
|
||||||
|
<li>Create a new game and select questions</li>
|
||||||
|
<li>Add teams to your game</li>
|
||||||
|
<li>Open the Contestant View on your TV</li>
|
||||||
|
<li>Open the Admin View on your laptop to control the game</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
const { getTokenSilently } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Set up token getter for API client
|
||||||
|
setAuthTokenGetter(getTokenSilently);
|
||||||
|
}, [getTokenSilently]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
|
|
||||||
|
{/* Protected routes */}
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<HomePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/questions"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<QuestionBankView />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/categories"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<CategoryManagementView />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/templates"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<TemplatesView />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/games/setup"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<GameSetupView />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Contestant view - all authenticated users */}
|
||||||
|
<Route
|
||||||
|
path="/games/:gameId/contestant"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ContestantView />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Admin view */}
|
||||||
|
<Route
|
||||||
|
path="/games/:gameId/admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<GameAdminView />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Catch all - redirect to home */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
1
frontend/frontend/src/assets/react.svg
Normal file
1
frontend/frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
802
frontend/frontend/src/components/admin/GameAdminView.jsx
Normal file
802
frontend/frontend/src/components/admin/GameAdminView.jsx
Normal file
@@ -0,0 +1,802 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useSocket } from "../../hooks/useSocket";
|
||||||
|
import { adminAPI, gamesAPI } from "../../services/api";
|
||||||
|
import AdminNavbar from "../common/AdminNavbar";
|
||||||
|
import AudioPlayer from "../audio/AudioPlayer";
|
||||||
|
|
||||||
|
export default function GameAdminView() {
|
||||||
|
const { gameId } = useParams();
|
||||||
|
const { socket, isConnected } = useSocket(gameId, "admin");
|
||||||
|
const [gameState, setGameState] = useState(null);
|
||||||
|
const [teams, setTeams] = useState([]);
|
||||||
|
const [currentQuestion, setCurrentQuestion] = useState(null);
|
||||||
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
|
const [totalQuestions, setTotalQuestions] = useState(0);
|
||||||
|
const [contestantViewUrl, setContestantViewUrl] = useState("");
|
||||||
|
const [showAnswer, setShowAnswer] = useState(false);
|
||||||
|
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
|
||||||
|
const [timerSeconds, setTimerSeconds] = useState(30);
|
||||||
|
const [timerExpired, setTimerExpired] = useState(false);
|
||||||
|
const [timerPaused, setTimerPaused] = useState(false);
|
||||||
|
const [newTeamName, setNewTeamName] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGameState();
|
||||||
|
setContestantViewUrl(
|
||||||
|
`${window.location.origin}/games/${gameId}/contestant`,
|
||||||
|
);
|
||||||
|
}, [gameId]);
|
||||||
|
|
||||||
|
const loadGameState = async () => {
|
||||||
|
try {
|
||||||
|
const response = await adminAPI.getCurrentState(gameId);
|
||||||
|
setGameState(response.data);
|
||||||
|
setTeams(response.data.teams || []);
|
||||||
|
setCurrentQuestion(response.data.current_question);
|
||||||
|
setQuestionIndex(response.data.current_question_index);
|
||||||
|
setTotalQuestions(response.data.total_questions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading game state:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
socket.on("question_with_answer", (data) => {
|
||||||
|
console.log("Question changed (admin):", data);
|
||||||
|
setCurrentQuestion(data.question);
|
||||||
|
setQuestionIndex(data.question_index);
|
||||||
|
setTotalQuestions(data.total_questions);
|
||||||
|
setTimerSeconds(30);
|
||||||
|
setTimerExpired(false);
|
||||||
|
setTimerPaused(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("score_updated", (data) => {
|
||||||
|
console.log("Score updated:", data);
|
||||||
|
setTeams(data.all_scores);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("timer_paused", (data) => {
|
||||||
|
console.log("Timer paused:", data);
|
||||||
|
setTimerPaused(data.paused);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("lifeline_updated", (data) => {
|
||||||
|
console.log("Lifeline updated:", data);
|
||||||
|
setTeams(data.all_scores);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("timer_reset", (data) => {
|
||||||
|
console.log("Timer reset:", data);
|
||||||
|
setTimerSeconds(data.seconds);
|
||||||
|
setTimerExpired(false);
|
||||||
|
setTimerPaused(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off("question_with_answer");
|
||||||
|
socket.off("score_updated");
|
||||||
|
socket.off("timer_paused");
|
||||||
|
socket.off("lifeline_updated");
|
||||||
|
socket.off("timer_reset");
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
// Reset expired state when timer is reset
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerSeconds > 0 && timerExpired) {
|
||||||
|
setTimerExpired(false);
|
||||||
|
}
|
||||||
|
}, [timerSeconds, timerExpired]);
|
||||||
|
|
||||||
|
// Timer countdown effect
|
||||||
|
useEffect(() => {
|
||||||
|
// Don't run timer if paused, no question is showing, or game is not active
|
||||||
|
if (timerPaused || !currentQuestion || !gameState?.is_active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setTimerSeconds((prev) => {
|
||||||
|
const newValue = prev - 1;
|
||||||
|
if (newValue <= 0) {
|
||||||
|
setTimerExpired(true);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return newValue;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [timerPaused, currentQuestion, gameState?.is_active]);
|
||||||
|
|
||||||
|
const handleStartGame = async () => {
|
||||||
|
try {
|
||||||
|
// If game is active, restart (clear scores and reset to waiting state)
|
||||||
|
if (gameState?.is_active) {
|
||||||
|
await adminAPI.restartGame(gameId);
|
||||||
|
loadGameState();
|
||||||
|
} else {
|
||||||
|
await adminAPI.startGame(gameId);
|
||||||
|
loadGameState();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error starting game:", error);
|
||||||
|
alert("Error starting game");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
try {
|
||||||
|
await adminAPI.nextQuestion(gameId);
|
||||||
|
// Auto-hide answer when navigating to next question
|
||||||
|
if (showAnswer) {
|
||||||
|
await adminAPI.toggleAnswer(gameId, false);
|
||||||
|
setShowAnswer(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
if (error.response?.status === 400) {
|
||||||
|
alert("Already at last question");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrev = async () => {
|
||||||
|
try {
|
||||||
|
await adminAPI.prevQuestion(gameId);
|
||||||
|
// Auto-hide answer when navigating to previous question
|
||||||
|
if (showAnswer) {
|
||||||
|
await adminAPI.toggleAnswer(gameId, false);
|
||||||
|
setShowAnswer(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
if (error.response?.status === 400) {
|
||||||
|
alert("Already at first question");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAwardPoints = async (teamId, points) => {
|
||||||
|
try {
|
||||||
|
await adminAPI.awardPoints(gameId, teamId, points);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error awarding points:", error);
|
||||||
|
alert("Error awarding points");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTeam = async () => {
|
||||||
|
if (!newTeamName.trim()) {
|
||||||
|
alert("Please enter a team name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gamesAPI.addTeam(gameId, newTeamName.trim());
|
||||||
|
setNewTeamName("");
|
||||||
|
loadGameState(); // Reload to get updated team list
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding team:", error);
|
||||||
|
alert("Error adding team");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseLifeline = async (teamId) => {
|
||||||
|
try {
|
||||||
|
await adminAPI.useLifeline(gameId, teamId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error using lifeline:", error);
|
||||||
|
if (error.response?.data?.error) {
|
||||||
|
alert(error.response.data.error);
|
||||||
|
} else {
|
||||||
|
alert("Error using lifeline");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddLifeline = async (teamId) => {
|
||||||
|
try {
|
||||||
|
await adminAPI.addLifeline(gameId, teamId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding lifeline:", error);
|
||||||
|
alert("Error adding lifeline");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleAnswer = async () => {
|
||||||
|
const newShowAnswer = !showAnswer;
|
||||||
|
try {
|
||||||
|
await adminAPI.toggleAnswer(gameId, newShowAnswer);
|
||||||
|
setShowAnswer(newShowAnswer);
|
||||||
|
|
||||||
|
// Pause timer when showing answer
|
||||||
|
if (newShowAnswer && !timerPaused) {
|
||||||
|
await adminAPI.pauseTimer(gameId, true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error toggling answer:", error);
|
||||||
|
alert("Error toggling answer");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndGame = async () => {
|
||||||
|
if (!window.confirm("Are you sure you want to end this game?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await adminAPI.endGame(gameId);
|
||||||
|
alert("Game ended successfully");
|
||||||
|
loadGameState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error ending game:", error);
|
||||||
|
alert("Error ending game");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAsTemplate = async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"Save this game as a template? This will allow you to reuse this question set later.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIsSavingTemplate(true);
|
||||||
|
await gamesAPI.saveAsTemplate(gameId);
|
||||||
|
alert("Game saved as template successfully!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving template:", error);
|
||||||
|
alert("Error saving template");
|
||||||
|
} finally {
|
||||||
|
setIsSavingTemplate(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminNavbar />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem 2rem",
|
||||||
|
maxWidth: "1400px",
|
||||||
|
margin: "0 auto",
|
||||||
|
minHeight: "calc(100vh - 60px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
borderBottom: "2px solid #ccc",
|
||||||
|
paddingBottom: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ margin: "0 0 0.75rem 0" }}>
|
||||||
|
Game Admin - {gameState?.game_name}
|
||||||
|
</h1>
|
||||||
|
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||||
|
<span>{isConnected ? "● Connected" : "○ Disconnected"}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(contestantViewUrl, "_blank")}
|
||||||
|
style={{ padding: "0.5rem 1rem" }}
|
||||||
|
>
|
||||||
|
Open Contestant View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: "2rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Current Question with Answer */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: 0 }}>Current Question</h2>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
background: timerExpired
|
||||||
|
? "#ffebee"
|
||||||
|
: timerSeconds <= 10
|
||||||
|
? "#fff3e0"
|
||||||
|
: "#e8f5e9",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
fontSize: "1.2rem",
|
||||||
|
color: timerExpired
|
||||||
|
? "#c62828"
|
||||||
|
: timerSeconds <= 10
|
||||||
|
? "#e65100"
|
||||||
|
: "#2e7d32",
|
||||||
|
minWidth: "120px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⏱️ {String(Math.floor(timerSeconds / 60)).padStart(2, "0")}:
|
||||||
|
{String(timerSeconds % 60).padStart(2, "0")}
|
||||||
|
{timerExpired && " (EXPIRED)"}
|
||||||
|
{timerPaused && " (PAUSED)"}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await adminAPI.pauseTimer(gameId, !timerPaused);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error toggling timer pause:", error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={timerExpired}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: timerExpired
|
||||||
|
? "#ccc"
|
||||||
|
: timerPaused
|
||||||
|
? "#4CAF50"
|
||||||
|
: "#ff9800",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: timerExpired ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timerPaused ? "▶ Resume" : "⏸ Pause"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await adminAPI.resetTimer(gameId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error resetting timer:", error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#2196F3",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔄 Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{currentQuestion ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1.5rem",
|
||||||
|
border: "2px solid #2196F3",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: "#e3f2fd",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentQuestion.type === "image" &&
|
||||||
|
currentQuestion.image_path && (
|
||||||
|
<img
|
||||||
|
src={currentQuestion.image_path}
|
||||||
|
alt="Question"
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentQuestion.type === "youtube_audio" &&
|
||||||
|
currentQuestion.audio_path && (
|
||||||
|
<AudioPlayer
|
||||||
|
audioPath={currentQuestion.audio_path}
|
||||||
|
isAdmin={true}
|
||||||
|
socket={socket}
|
||||||
|
gameId={gameId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "1.3rem",
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentQuestion.question_content}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem",
|
||||||
|
background: "#4CAF50",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Answer:</strong> {currentQuestion.answer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>No question selected. Click "Start Game" to begin.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team Scoring */}
|
||||||
|
<div>
|
||||||
|
<h2>Team Scoring</h2>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTeamName}
|
||||||
|
onChange={(e) => setNewTeamName(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === "Enter" && handleAddTeam()}
|
||||||
|
placeholder="Enter team name"
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem",
|
||||||
|
flex: 1,
|
||||||
|
fontSize: "1rem",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAddTeam}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#4CAF50",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Team
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{teams.length === 0 ? (
|
||||||
|
<p>No teams in this game</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{teams.map((team) => (
|
||||||
|
<div
|
||||||
|
key={team.id}
|
||||||
|
style={{
|
||||||
|
padding: "1rem",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ fontSize: "1.2rem" }}>
|
||||||
|
{team.name}
|
||||||
|
</strong>
|
||||||
|
<span style={{ fontSize: "1rem" }}>
|
||||||
|
{Array.from({
|
||||||
|
length: team.phone_a_friend_count || 0,
|
||||||
|
}).map((_, i) => (
|
||||||
|
<span key={i}>📞</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#2196F3",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{team.total_score} pts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 5, 10].map((points) => (
|
||||||
|
<button
|
||||||
|
key={points}
|
||||||
|
onClick={() => handleAwardPoints(team.id, points)}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#4CAF50",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+{points}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => handleAwardPoints(team.id, -1)}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#f44336",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
-1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUseLifeline(team.id)}
|
||||||
|
disabled={team.phone_a_friend_count <= 0}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background:
|
||||||
|
team.phone_a_friend_count <= 0 ? "#ccc" : "#ff9800",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor:
|
||||||
|
team.phone_a_friend_count <= 0
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📞 Use Lifeline
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAddLifeline(team.id)}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#9C27B0",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📞 Add Lifeline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Game Controls */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#f5f5f5",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "1.5rem",
|
||||||
|
marginTop: "2rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ marginTop: 0, marginBottom: "1rem" }}>Game Controls</h2>
|
||||||
|
|
||||||
|
{/* Question indicator and timer */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "1rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
background: "white",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Question {questionIndex + 1} of {totalQuestions}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
background: timerExpired
|
||||||
|
? "#ffebee"
|
||||||
|
: timerSeconds <= 10
|
||||||
|
? "#fff3e0"
|
||||||
|
: "#e8f5e9",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
fontSize: "1.2rem",
|
||||||
|
color: timerExpired
|
||||||
|
? "#c62828"
|
||||||
|
: timerSeconds <= 10
|
||||||
|
? "#e65100"
|
||||||
|
: "#2e7d32",
|
||||||
|
minWidth: "120px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⏱️ {String(Math.floor(timerSeconds / 60)).padStart(2, "0")}:
|
||||||
|
{String(timerSeconds % 60).padStart(2, "0")}
|
||||||
|
{timerExpired && " (EXPIRED)"}
|
||||||
|
{timerPaused && " (PAUSED)"}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await adminAPI.pauseTimer(gameId, !timerPaused);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error toggling timer pause:", error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={timerExpired}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: timerExpired
|
||||||
|
? "#ccc"
|
||||||
|
: timerPaused
|
||||||
|
? "#4CAF50"
|
||||||
|
: "#ff9800",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: timerExpired ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timerPaused ? "▶ Resume" : "⏸ Pause"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Button grid - 2 columns */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentQuestion && (
|
||||||
|
<button
|
||||||
|
onClick={handleToggleAnswer}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
background: showAnswer ? "#ff9800" : "#4CAF50",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAnswer ? "Hide Answer" : "Show Answer"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={questionIndex >= totalQuestions - 1}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
background:
|
||||||
|
questionIndex >= totalQuestions - 1 ? "#ccc" : "#2196F3",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor:
|
||||||
|
questionIndex >= totalQuestions - 1
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
fontSize: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={questionIndex === 0}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
background: questionIndex === 0 ? "#ccc" : "#2196F3",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: questionIndex === 0 ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveAsTemplate}
|
||||||
|
disabled={isSavingTemplate}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
background: isSavingTemplate ? "#ccc" : "#9C27B0",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: isSavingTemplate ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSavingTemplate ? "Saving..." : "Save as Template"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleStartGame}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
background: "#4CAF50",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gameState?.is_active ? "Restart Game" : "Start Game"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleEndGame}
|
||||||
|
disabled={!gameState?.is_active}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
background: !gameState?.is_active ? "#ccc" : "#f44336",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: !gameState?.is_active ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
End Game
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
frontend/frontend/src/components/audio/AudioPlayer.jsx
Normal file
247
frontend/frontend/src/components/audio/AudioPlayer.jsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { audioControlAPI } from "../../services/api";
|
||||||
|
|
||||||
|
export default function AudioPlayer({ audioPath, isAdmin, socket, gameId }) {
|
||||||
|
const audioRef = useRef(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket || !audioRef.current) return;
|
||||||
|
|
||||||
|
// Contestant receives playback commands
|
||||||
|
if (!isAdmin) {
|
||||||
|
socket.on("audio_play", () => {
|
||||||
|
audioRef.current?.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("audio_pause", () => {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("audio_stop", () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
}
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("audio_seek", (data) => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = data.position;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off("audio_play");
|
||||||
|
socket.off("audio_pause");
|
||||||
|
socket.off("audio_stop");
|
||||||
|
socket.off("audio_seek");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [socket, isAdmin]);
|
||||||
|
|
||||||
|
// Track playback progress
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
const updateTime = () => setCurrentTime(audio.currentTime);
|
||||||
|
const updateDuration = () => setDuration(audio.duration);
|
||||||
|
const handleEnded = () => setIsPlaying(false);
|
||||||
|
|
||||||
|
audio.addEventListener("timeupdate", updateTime);
|
||||||
|
audio.addEventListener("loadedmetadata", updateDuration);
|
||||||
|
audio.addEventListener("ended", handleEnded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener("timeupdate", updateTime);
|
||||||
|
audio.removeEventListener("loadedmetadata", updateDuration);
|
||||||
|
audio.removeEventListener("ended", handleEnded);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Admin controls
|
||||||
|
const handlePlay = async () => {
|
||||||
|
try {
|
||||||
|
await audioControlAPI.play(gameId);
|
||||||
|
audioRef.current?.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error playing audio:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = async () => {
|
||||||
|
try {
|
||||||
|
await audioControlAPI.pause(gameId);
|
||||||
|
audioRef.current?.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error pausing audio:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
try {
|
||||||
|
await audioControlAPI.stop(gameId);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
}
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error stopping audio:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgressClick = async (e) => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const percentage = x / rect.width;
|
||||||
|
const newTime = percentage * duration;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await audioControlAPI.seek(gameId, newTime);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = newTime;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error seeking audio:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (seconds) => {
|
||||||
|
if (!seconds || isNaN(seconds)) return "0:00";
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#f5f5f5",
|
||||||
|
padding: "1.5rem",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginTop: "2rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<audio ref={audioRef} src={audioPath} preload="auto" />
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "8px",
|
||||||
|
background: "#e0e0e0",
|
||||||
|
borderRadius: "4px",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
cursor: isAdmin ? "pointer" : "default",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
onClick={handleProgressClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: "#2196F3",
|
||||||
|
borderRadius: "4px",
|
||||||
|
transition: "width 0.1s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time display */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "#666",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls (admin only) */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: "0.5rem", justifyContent: "center" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handlePlay}
|
||||||
|
disabled={isPlaying}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: isPlaying ? "#ccc" : "#4CAF50",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: isPlaying ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "1rem",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Play
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePause}
|
||||||
|
disabled={!isPlaying}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: !isPlaying ? "#ccc" : "#ff9800",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: !isPlaying ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "1rem",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#f44336",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "1rem",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAdmin && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#666",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Audio controlled by game host
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
frontend/frontend/src/components/auth/ProtectedRoute.jsx
Normal file
47
frontend/frontend/src/components/auth/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '4px solid #f3f3f3',
|
||||||
|
borderTop: '4px solid #3498db',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p>Loading...</p>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { categoriesAPI } from "../../services/api";
|
||||||
|
import AdminNavbar from "../common/AdminNavbar";
|
||||||
|
|
||||||
|
export default function CategoryManagementView() {
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [formData, setFormData] = useState({ name: "" });
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const response = await categoriesAPI.getAll();
|
||||||
|
setCategories(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading categories:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
await categoriesAPI.update(editingId, formData);
|
||||||
|
} else {
|
||||||
|
await categoriesAPI.create(formData);
|
||||||
|
}
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setFormData({ name: "" });
|
||||||
|
loadCategories();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving category:", error);
|
||||||
|
if (error.response?.status === 409) {
|
||||||
|
setError("Category already exists");
|
||||||
|
} else {
|
||||||
|
setError("Error saving category");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (category) => {
|
||||||
|
setEditingId(category.id);
|
||||||
|
setFormData({ name: category.name });
|
||||||
|
setShowForm(true);
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setFormData({ name: "" });
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm("Are you sure you want to delete this category?")) return;
|
||||||
|
try {
|
||||||
|
await categoriesAPI.delete(id);
|
||||||
|
loadCategories();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting category:", error);
|
||||||
|
alert("Error deleting category. It may be in use by questions.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminNavbar />
|
||||||
|
<div style={{ padding: "1rem 2rem", minHeight: "calc(100vh - 60px)" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ margin: "0" }}>Category Management</h1>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(!showForm);
|
||||||
|
if (showForm) handleCancelEdit();
|
||||||
|
}}
|
||||||
|
style={{ marginRight: "1rem", padding: "0.5rem 1rem" }}
|
||||||
|
>
|
||||||
|
{showForm ? "Cancel" : "Add Category"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{
|
||||||
|
marginBottom: "2rem",
|
||||||
|
padding: "1rem",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
||||||
|
{editingId ? "Edit Category" : "Add Category"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
background: "#ffebee",
|
||||||
|
color: "#c62828",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||||
|
Category Name:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ name: e.target.value })}
|
||||||
|
required
|
||||||
|
style={{ padding: "0.5rem", width: "100%", maxWidth: "400px" }}
|
||||||
|
placeholder="e.g., History, Science, Sports"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button type="submit" style={{ padding: "0.5rem 1rem" }}>
|
||||||
|
{editingId ? "Update Category" : "Create Category"}
|
||||||
|
</button>
|
||||||
|
{editingId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
style={{ padding: "0.5rem 1rem", background: "#ccc" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Categories ({categories.length})</h2>
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<p>No categories yet. Create your first category above!</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "grid", gap: "1rem", maxWidth: "600px" }}>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<div
|
||||||
|
key={category.id}
|
||||||
|
style={{
|
||||||
|
padding: "1rem",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: "8px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: "#e8f5e9",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "1rem",
|
||||||
|
color: "#2e7d32",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(category)}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#2196F3",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(category.id)}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#f44336",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
frontend/frontend/src/components/common/AdminNavbar.jsx
Normal file
116
frontend/frontend/src/components/common/AdminNavbar.jsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
|
||||||
|
export default function AdminNavbar() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: "/", label: "Home" },
|
||||||
|
{ path: "/questions", label: "Questions" },
|
||||||
|
{ path: "/categories", label: "Categories" },
|
||||||
|
{ path: "/templates", label: "Templates" },
|
||||||
|
{ path: "/games/setup", label: "New Game" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (path) => {
|
||||||
|
if (path === "/") {
|
||||||
|
return location.pathname === "/";
|
||||||
|
}
|
||||||
|
return location.pathname.startsWith(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
background: "black",
|
||||||
|
padding: "1rem 2rem",
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
|
<span
|
||||||
|
style={{ fontSize: "1.5rem", fontWeight: "bold", color: "white" }}
|
||||||
|
>
|
||||||
|
🎮 Trivia Admin
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
style={{
|
||||||
|
textDecoration: "none",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: isActive(item.path) ? "white" : "transparent",
|
||||||
|
color: isActive(item.path) ? "black" : "white",
|
||||||
|
fontWeight: isActive(item.path) ? "bold" : "normal",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isActive(item.path)) {
|
||||||
|
e.target.style.background = "#333";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isActive(item.path)) {
|
||||||
|
e.target.style.background = "transparent";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{/* User info and logout */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.75rem",
|
||||||
|
alignItems: "center",
|
||||||
|
marginLeft: "1rem",
|
||||||
|
paddingLeft: "1rem",
|
||||||
|
borderLeft: "1px solid #444",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "white", fontSize: "0.9rem" }}>
|
||||||
|
{user?.profile?.name || user?.profile?.email}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#e74c3c",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
fontWeight: "500",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.target.style.background = "#c0392b";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.target.style.background = "#e74c3c";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
508
frontend/frontend/src/components/contestant/ContestantView.jsx
Normal file
508
frontend/frontend/src/components/contestant/ContestantView.jsx
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useSocket } from "../../hooks/useSocket";
|
||||||
|
import { gamesAPI } from "../../services/api";
|
||||||
|
import AudioPlayer from "../audio/AudioPlayer";
|
||||||
|
|
||||||
|
export default function ContestantView() {
|
||||||
|
const { gameId } = useParams();
|
||||||
|
const { socket, isConnected } = useSocket(gameId, "contestant");
|
||||||
|
const [currentQuestion, setCurrentQuestion] = useState(null);
|
||||||
|
const [scores, setScores] = useState([]);
|
||||||
|
const [gameName, setGameName] = useState("");
|
||||||
|
const [showAnswer, setShowAnswer] = useState(false);
|
||||||
|
const [answer, setAnswer] = useState("");
|
||||||
|
const [gameStartTime, setGameStartTime] = useState(null);
|
||||||
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
|
const [timerSeconds, setTimerSeconds] = useState(30);
|
||||||
|
const [timerActive, setTimerActive] = useState(false);
|
||||||
|
const [timerPaused, setTimerPaused] = useState(false);
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load initial game state
|
||||||
|
const loadGameState = async () => {
|
||||||
|
try {
|
||||||
|
const response = await gamesAPI.getOne(gameId);
|
||||||
|
setGameName(response.data.name);
|
||||||
|
setScores(response.data.teams || []);
|
||||||
|
|
||||||
|
// Extract unique categories from game questions
|
||||||
|
if (response.data.questions) {
|
||||||
|
const uniqueCategories = [
|
||||||
|
...new Set(
|
||||||
|
response.data.questions
|
||||||
|
.map((q) => q.question?.category)
|
||||||
|
.filter((cat) => cat), // Remove null/undefined
|
||||||
|
),
|
||||||
|
];
|
||||||
|
setCategories(uniqueCategories);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading game:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadGameState();
|
||||||
|
}, [gameId]);
|
||||||
|
|
||||||
|
// Update system clock every second
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Timer countdown effect
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(
|
||||||
|
"Timer effect running: timerActive=",
|
||||||
|
timerActive,
|
||||||
|
"timerPaused=",
|
||||||
|
timerPaused,
|
||||||
|
"currentQuestion=",
|
||||||
|
currentQuestion,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Don't run timer if not active, if paused, or if no question is showing
|
||||||
|
if (!timerActive || timerPaused || !currentQuestion) {
|
||||||
|
console.log(
|
||||||
|
"Timer not starting - either not active, paused, or no question",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the countdown
|
||||||
|
console.log("Starting timer countdown");
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setTimerSeconds((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
setTimerActive(false);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Cleanup: stop the timer when effect re-runs or component unmounts
|
||||||
|
return () => {
|
||||||
|
console.log("Cleaning up timer interval");
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [timerActive, timerPaused, currentQuestion]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
socket.on("game_started", (data) => {
|
||||||
|
console.log("Game started:", data);
|
||||||
|
setGameName(data.game_name);
|
||||||
|
setGameStartTime(new Date());
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("question_changed", (data) => {
|
||||||
|
console.log("Question changed:", data);
|
||||||
|
setCurrentQuestion(data.question);
|
||||||
|
setShowAnswer(false); // Hide answer when question changes
|
||||||
|
setAnswer("");
|
||||||
|
setTimerSeconds(30);
|
||||||
|
setTimerActive(true);
|
||||||
|
setTimerPaused(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("score_updated", (data) => {
|
||||||
|
console.log("Score updated:", data);
|
||||||
|
setScores(data.all_scores);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("answer_visibility_changed", (data) => {
|
||||||
|
console.log("Answer visibility changed:", data);
|
||||||
|
setShowAnswer(data.show_answer);
|
||||||
|
if (data.show_answer && data.answer) {
|
||||||
|
setAnswer(data.answer);
|
||||||
|
} else {
|
||||||
|
setAnswer("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("timer_paused", (data) => {
|
||||||
|
console.log("Timer paused event received:", data);
|
||||||
|
console.log("Setting timerPaused to:", data.paused);
|
||||||
|
setTimerPaused(data.paused);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("game_ended", (data) => {
|
||||||
|
console.log("Game ended:", data);
|
||||||
|
setCurrentQuestion(null);
|
||||||
|
setShowAnswer(false);
|
||||||
|
setAnswer("");
|
||||||
|
setGameStartTime(null);
|
||||||
|
setTimerSeconds(30);
|
||||||
|
setTimerActive(false);
|
||||||
|
setTimerPaused(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("lifeline_updated", (data) => {
|
||||||
|
console.log("Lifeline updated:", data);
|
||||||
|
setScores(data.all_scores);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("timer_reset", (data) => {
|
||||||
|
console.log("Timer reset:", data);
|
||||||
|
setTimerSeconds(data.seconds);
|
||||||
|
setTimerActive(true);
|
||||||
|
setTimerPaused(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off("game_started");
|
||||||
|
socket.off("question_changed");
|
||||||
|
socket.off("score_updated");
|
||||||
|
socket.off("answer_visibility_changed");
|
||||||
|
socket.off("timer_paused");
|
||||||
|
socket.off("game_ended");
|
||||||
|
socket.off("lifeline_updated");
|
||||||
|
socket.off("timer_reset");
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
// Sort scores by score descending (auto-sorted leaderboard)
|
||||||
|
const sortedScores = [...scores].sort((a, b) => {
|
||||||
|
const scoreA = a.score || a.total_score || 0;
|
||||||
|
const scoreB = b.score || b.total_score || 0;
|
||||||
|
return scoreB - scoreA;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format system clock
|
||||||
|
const formatSystemTime = () => {
|
||||||
|
return currentTime.toLocaleTimeString("en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format game clock (elapsed time)
|
||||||
|
const formatGameTime = () => {
|
||||||
|
if (!gameStartTime) return "00:00:00";
|
||||||
|
const elapsed = Math.floor((currentTime - gameStartTime) / 1000);
|
||||||
|
const hours = Math.floor(elapsed / 3600);
|
||||||
|
const minutes = Math.floor((elapsed % 3600) / 60);
|
||||||
|
const seconds = elapsed % 60;
|
||||||
|
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: "white",
|
||||||
|
color: "black",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
// padding: "3rem",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
paddingBottom: "0.5rem",
|
||||||
|
paddingTop: "1rem",
|
||||||
|
paddingLeft: "2rem",
|
||||||
|
paddingRight: "2rem",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "3rem",
|
||||||
|
margin: "0",
|
||||||
|
fontWeight: "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gameName || "Trivia Game"}
|
||||||
|
</h1>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
color: "#666",
|
||||||
|
display: "flex",
|
||||||
|
gap: "2rem",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "2.5rem",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color:
|
||||||
|
timerSeconds <= 10
|
||||||
|
? timerSeconds === 0
|
||||||
|
? "#666"
|
||||||
|
: "#f44336"
|
||||||
|
: "#4CAF50",
|
||||||
|
minWidth: "80px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(Math.floor(timerSeconds / 60)).padStart(2, "0")}:
|
||||||
|
{String(timerSeconds % 60).padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
<div>{formatSystemTime()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<div
|
||||||
|
style={{ flex: 1, display: "flex", gap: "4rem", overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
{/* Left side: Question Display */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: "2",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem 2rem 0 2rem",
|
||||||
|
width: "100%",
|
||||||
|
minHeight: "400px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentQuestion ? (
|
||||||
|
<div style={{ width: "100%", textAlign: "center" }}>
|
||||||
|
{/* Timer progress bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "8px",
|
||||||
|
background: "#e0e0e0",
|
||||||
|
borderRadius: "4px",
|
||||||
|
marginBottom: "2rem",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${(timerSeconds / 30) * 100}%`,
|
||||||
|
height: "100%",
|
||||||
|
background:
|
||||||
|
timerSeconds > 10
|
||||||
|
? "#4CAF50"
|
||||||
|
: timerSeconds > 5
|
||||||
|
? "#ff9800"
|
||||||
|
: "#f44336",
|
||||||
|
transition: "width 1s linear, background 0.3s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentQuestion.category && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: "normal",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "5px",
|
||||||
|
color: "#999",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentQuestion.category}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentQuestion.type === "image" &&
|
||||||
|
currentQuestion.image_path && (
|
||||||
|
<img
|
||||||
|
src={currentQuestion.image_path}
|
||||||
|
alt="Question"
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
maxHeight: "500px",
|
||||||
|
margin: "0 auto 3rem",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentQuestion.type === "youtube_audio" &&
|
||||||
|
currentQuestion.audio_path && (
|
||||||
|
<AudioPlayer
|
||||||
|
audioPath={currentQuestion.audio_path}
|
||||||
|
isAdmin={false}
|
||||||
|
socket={socket}
|
||||||
|
gameId={gameId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "4rem",
|
||||||
|
fontWeight: "normal",
|
||||||
|
textAlign: "center",
|
||||||
|
margin: 0,
|
||||||
|
lineHeight: "1.2",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentQuestion.question_content}
|
||||||
|
</p>
|
||||||
|
{showAnswer && answer && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "4rem",
|
||||||
|
padding: "3rem",
|
||||||
|
background: "black",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: "normal",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
letterSpacing: "3px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ANSWER:
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "3.5rem", fontWeight: "normal" }}>
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: "center", width: "100%" }}>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "2.5rem",
|
||||||
|
color: "#666",
|
||||||
|
marginBottom: "3rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Waiting for game to start...
|
||||||
|
</p>
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: "2rem",
|
||||||
|
color: "#999",
|
||||||
|
marginBottom: "2rem",
|
||||||
|
letterSpacing: "3px",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Categories
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "1.5rem",
|
||||||
|
justifyContent: "center",
|
||||||
|
maxWidth: "800px",
|
||||||
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categories.map((category, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
padding: "1rem 2rem",
|
||||||
|
background: "#f5f5f5",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "1.8rem",
|
||||||
|
color: "#333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side: Scoreboard */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: "1",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
paddingRight: "100px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: "0 0 2rem 0",
|
||||||
|
fontSize: "2rem",
|
||||||
|
fontWeight: "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Teams
|
||||||
|
</h2>
|
||||||
|
{sortedScores.length === 0 ? (
|
||||||
|
<p style={{ color: "#999", margin: 0 }}>No teams yet</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1.5rem",
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sortedScores.map((team, index) => (
|
||||||
|
<div
|
||||||
|
key={team.team_id || team.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "baseline",
|
||||||
|
fontSize: "1.8rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{team.team_name || team.name}</span>
|
||||||
|
<span style={{ fontSize: "1.2rem" }}>
|
||||||
|
{Array.from({
|
||||||
|
length: team.phone_a_friend_count || 0,
|
||||||
|
}).map((_, i) => (
|
||||||
|
<span key={i}>📞</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: "bold" }}>
|
||||||
|
{team.score || team.total_score || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
741
frontend/frontend/src/components/questionbank/GameSetupView.jsx
Normal file
741
frontend/frontend/src/components/questionbank/GameSetupView.jsx
Normal file
@@ -0,0 +1,741 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { questionsAPI, gamesAPI } from "../../services/api";
|
||||||
|
import AdminNavbar from "../common/AdminNavbar";
|
||||||
|
|
||||||
|
export default function GameSetupView() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [questions, setQuestions] = useState([]);
|
||||||
|
const [selectedQuestions, setSelectedQuestions] = useState([]);
|
||||||
|
const [gameName, setGameName] = useState("");
|
||||||
|
const [teams, setTeams] = useState([]);
|
||||||
|
const [newTeamName, setNewTeamName] = useState("");
|
||||||
|
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
||||||
|
const [templates, setTemplates] = useState([]);
|
||||||
|
const [randomSelections, setRandomSelections] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadQuestions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadQuestions = async () => {
|
||||||
|
try {
|
||||||
|
const response = await questionsAPI.getAll();
|
||||||
|
setQuestions(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading questions:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTemplates = async () => {
|
||||||
|
try {
|
||||||
|
const response = await gamesAPI.getTemplates();
|
||||||
|
setTemplates(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading templates:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadTemplate = async (template) => {
|
||||||
|
try {
|
||||||
|
const response = await gamesAPI.getOne(template.id);
|
||||||
|
// Pre-fill form with template data
|
||||||
|
setGameName(template.name + " (Copy)");
|
||||||
|
const templateQuestions = response.data.game_questions.map(
|
||||||
|
(gq) => gq.question,
|
||||||
|
);
|
||||||
|
setQuestions((prev) => {
|
||||||
|
// Merge template questions with existing questions, avoiding duplicates
|
||||||
|
const existingIds = new Set(prev.map((q) => q.id));
|
||||||
|
const newQuestions = templateQuestions.filter(
|
||||||
|
(q) => !existingIds.has(q.id),
|
||||||
|
);
|
||||||
|
return [...prev, ...newQuestions];
|
||||||
|
});
|
||||||
|
setSelectedQuestions(templateQuestions.map((q) => q.id));
|
||||||
|
setShowTemplateModal(false);
|
||||||
|
alert(
|
||||||
|
`Loaded ${templateQuestions.length} questions from template "${template.name}"`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading template:", error);
|
||||||
|
alert("Error loading template");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group questions by category
|
||||||
|
const groupedQuestions = questions.reduce((groups, question) => {
|
||||||
|
const category = question.category || "Uncategorized";
|
||||||
|
if (!groups[category]) {
|
||||||
|
groups[category] = [];
|
||||||
|
}
|
||||||
|
groups[category].push(question);
|
||||||
|
return groups;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const categoryNames = Object.keys(groupedQuestions).sort();
|
||||||
|
|
||||||
|
const toggleQuestion = (questionId) => {
|
||||||
|
setSelectedQuestions((prev) =>
|
||||||
|
prev.includes(questionId)
|
||||||
|
? prev.filter((id) => id !== questionId)
|
||||||
|
: [...prev, questionId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveQuestion = (index, direction) => {
|
||||||
|
const newOrder = [...selectedQuestions];
|
||||||
|
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
if (targetIndex >= 0 && targetIndex < newOrder.length) {
|
||||||
|
[newOrder[index], newOrder[targetIndex]] = [
|
||||||
|
newOrder[targetIndex],
|
||||||
|
newOrder[index],
|
||||||
|
];
|
||||||
|
setSelectedQuestions(newOrder);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeQuestion = (questionId) => {
|
||||||
|
setSelectedQuestions((prev) => prev.filter((id) => id !== questionId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTeam = () => {
|
||||||
|
if (newTeamName.trim()) {
|
||||||
|
setTeams([...teams, newTeamName.trim()]);
|
||||||
|
setNewTeamName("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTeam = (index) => {
|
||||||
|
setTeams(teams.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRandomSelection = (category, count) => {
|
||||||
|
setRandomSelections((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[category]: count,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRandomQuestions = async () => {
|
||||||
|
try {
|
||||||
|
const newQuestionIds = [];
|
||||||
|
|
||||||
|
for (const [category, count] of Object.entries(randomSelections)) {
|
||||||
|
if (count > 0) {
|
||||||
|
const response = await questionsAPI.getRandomByCategory(
|
||||||
|
category,
|
||||||
|
count,
|
||||||
|
);
|
||||||
|
const randomQuestions = response.data.questions;
|
||||||
|
|
||||||
|
// Add to questions list if not already there
|
||||||
|
setQuestions((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((q) => q.id));
|
||||||
|
const newQuestions = randomQuestions.filter(
|
||||||
|
(q) => !existingIds.has(q.id),
|
||||||
|
);
|
||||||
|
return [...prev, ...newQuestions];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add question IDs to selected list
|
||||||
|
randomQuestions.forEach((q) => {
|
||||||
|
if (!selectedQuestions.includes(q.id)) {
|
||||||
|
newQuestionIds.push(q.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newQuestionIds.length > 0) {
|
||||||
|
setSelectedQuestions((prev) => [...prev, ...newQuestionIds]);
|
||||||
|
alert(`Added ${newQuestionIds.length} random questions to your game!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset selections
|
||||||
|
setRandomSelections({});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding random questions:", error);
|
||||||
|
alert(
|
||||||
|
"Error adding random questions: " +
|
||||||
|
(error.response?.data?.error || error.message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateGame = async () => {
|
||||||
|
if (!gameName.trim()) {
|
||||||
|
alert("Please enter a game name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedQuestions.length === 0) {
|
||||||
|
alert("Please select at least one question");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (teams.length === 0) {
|
||||||
|
alert("Please add at least one team");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create game
|
||||||
|
const gameResponse = await gamesAPI.create({ name: gameName });
|
||||||
|
const gameId = gameResponse.data.id;
|
||||||
|
|
||||||
|
// Add questions to game in the specified order
|
||||||
|
await gamesAPI.addQuestions(gameId, selectedQuestions);
|
||||||
|
|
||||||
|
// Add teams to game
|
||||||
|
for (const teamName of teams) {
|
||||||
|
await gamesAPI.addTeam(gameId, teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
alert("Game created successfully!");
|
||||||
|
navigate(`/games/${gameId}/admin`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating game:", error);
|
||||||
|
alert("Error creating game");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQuestionById = (id) => questions.find((q) => q.id === id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminNavbar />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem 2rem",
|
||||||
|
minHeight: "calc(100vh - 60px)",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: "900px", width: "100%" }}>
|
||||||
|
<h1 style={{ margin: "0 0 1.5rem 0" }}>Create New Game</h1>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
loadTemplates();
|
||||||
|
setShowTemplateModal(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
background: "#9C27B0",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Load from Template
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<h2 style={{ marginTop: "0", marginBottom: "0.75rem" }}>
|
||||||
|
1. Game Name
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={gameName}
|
||||||
|
onChange={(e) => setGameName(e.target.value)}
|
||||||
|
placeholder="Enter game name"
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem",
|
||||||
|
width: "100%",
|
||||||
|
fontSize: "1rem",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
padding: "1rem",
|
||||||
|
background: "#fff3e0",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "2px solid #ff9800",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
marginTop: "0",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
color: "#e65100",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
2. Add Random Questions (Quick Setup)
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "0 0 1rem 0",
|
||||||
|
color: "#666",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select how many random questions to add from each category
|
||||||
|
</p>
|
||||||
|
{categoryNames.length === 0 ? (
|
||||||
|
<p>No questions available yet.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: "0.75rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categoryNames.map((category) => {
|
||||||
|
const categoryCount = groupedQuestions[category].length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={category}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "1rem",
|
||||||
|
padding: "0.75rem",
|
||||||
|
background: "white",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<strong>{category}</strong>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#666",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
marginLeft: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
({categoryCount} available)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor={`random-${category}`}
|
||||||
|
style={{ fontSize: "0.9rem" }}
|
||||||
|
>
|
||||||
|
Add:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`random-${category}`}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max={categoryCount}
|
||||||
|
value={randomSelections[category] || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRandomSelection(
|
||||||
|
category,
|
||||||
|
parseInt(e.target.value) || 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: "70px",
|
||||||
|
padding: "0.5rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: "0.9rem", color: "#666" }}>
|
||||||
|
questions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={addRandomQuestions}
|
||||||
|
disabled={Object.values(randomSelections).every(
|
||||||
|
(count) => count === 0,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
background: Object.values(randomSelections).every(
|
||||||
|
(count) => count === 0,
|
||||||
|
)
|
||||||
|
? "#ccc"
|
||||||
|
: "#ff9800",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: Object.values(randomSelections).every(
|
||||||
|
(count) => count === 0,
|
||||||
|
)
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Random Questions to Game
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<h2 style={{ marginTop: "0", marginBottom: "0.75rem" }}>
|
||||||
|
3. Select Questions Manually ({selectedQuestions.length} selected)
|
||||||
|
</h2>
|
||||||
|
{questions.length === 0 ? (
|
||||||
|
<p>
|
||||||
|
No questions available.{" "}
|
||||||
|
<Link to="/questions">Create some questions first</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
|
{categoryNames.map((category) => (
|
||||||
|
<div
|
||||||
|
key={category}
|
||||||
|
style={{
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
marginTop: 0,
|
||||||
|
marginBottom: "1rem",
|
||||||
|
color: "#2e7d32",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||||
|
{groupedQuestions[category].map((q) => (
|
||||||
|
<div
|
||||||
|
key={q.id}
|
||||||
|
onClick={() => toggleQuestion(q.id)}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem",
|
||||||
|
border: `2px solid ${selectedQuestions.includes(q.id) ? "#4CAF50" : "#ccc"}`,
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: selectedQuestions.includes(q.id)
|
||||||
|
? "#e8f5e9"
|
||||||
|
: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedQuestions.includes(q.id)}
|
||||||
|
readOnly
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
q.type === "image" ? "#e3f2fd" : "#f3e5f5",
|
||||||
|
padding: "0.25rem 0.5rem",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
marginRight: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{q.type.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span>{q.question_content}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedQuestions.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
padding: "1rem",
|
||||||
|
background: "#f5f5f5",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginTop: 0, marginBottom: "0.75rem" }}>
|
||||||
|
Question Order
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||||
|
{selectedQuestions.map((qId, index) => {
|
||||||
|
const question = getQuestionById(qId);
|
||||||
|
if (!question) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={qId}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem",
|
||||||
|
background: "white",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: "bold", minWidth: "30px" }}>
|
||||||
|
#{index + 1}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
{question.category && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: "#e8f5e9",
|
||||||
|
padding: "0.25rem 0.5rem",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "#2e7d32",
|
||||||
|
marginRight: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{question.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
question.type === "image" ? "#e3f2fd" : "#f3e5f5",
|
||||||
|
padding: "0.25rem 0.5rem",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
marginRight: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{question.type.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span>{question.question_content}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.25rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => moveQuestion(index, "up")}
|
||||||
|
disabled={index === 0}
|
||||||
|
style={{
|
||||||
|
padding: "0.25rem 0.5rem",
|
||||||
|
background: index === 0 ? "#ddd" : "#2196F3",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: index === 0 ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => moveQuestion(index, "down")}
|
||||||
|
disabled={index === selectedQuestions.length - 1}
|
||||||
|
style={{
|
||||||
|
padding: "0.25rem 0.5rem",
|
||||||
|
background:
|
||||||
|
index === selectedQuestions.length - 1
|
||||||
|
? "#ddd"
|
||||||
|
: "#2196F3",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor:
|
||||||
|
index === selectedQuestions.length - 1
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeQuestion(qId)}
|
||||||
|
style={{
|
||||||
|
padding: "0.25rem 0.5rem",
|
||||||
|
background: "#f44336",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<h2 style={{ marginTop: "0", marginBottom: "0.75rem" }}>
|
||||||
|
4. Add Teams
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTeamName}
|
||||||
|
onChange={(e) => setNewTeamName(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === "Enter" && addTeam()}
|
||||||
|
placeholder="Enter team name"
|
||||||
|
style={{ padding: "0.5rem", flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button onClick={addTeam} style={{ padding: "0.5rem 1rem" }}>
|
||||||
|
Add Team
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||||
|
{teams.map((team, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#e3f2fd",
|
||||||
|
borderRadius: "20px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{team}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeTeam(index)}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#f44336",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCreateGame}
|
||||||
|
disabled={
|
||||||
|
!gameName || selectedQuestions.length === 0 || teams.length === 0
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
padding: "1rem 2rem",
|
||||||
|
fontSize: "1.1rem",
|
||||||
|
background:
|
||||||
|
!gameName ||
|
||||||
|
selectedQuestions.length === 0 ||
|
||||||
|
teams.length === 0
|
||||||
|
? "#ccc"
|
||||||
|
: "#4CAF50",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
cursor:
|
||||||
|
!gameName ||
|
||||||
|
selectedQuestions.length === 0 ||
|
||||||
|
teams.length === 0
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create Game & Go to Admin View
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showTemplateModal && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: "rgba(0,0,0,0.5)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "white",
|
||||||
|
padding: "2rem",
|
||||||
|
borderRadius: "8px",
|
||||||
|
maxWidth: "600px",
|
||||||
|
maxHeight: "80vh",
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Select Template</h2>
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<p>No templates available</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
style={{
|
||||||
|
padding: "1rem",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => handleLoadTemplate(template)}
|
||||||
|
>
|
||||||
|
<strong>{template.name}</strong>
|
||||||
|
<p style={{ margin: "0.5rem 0 0 0", color: "#666" }}>
|
||||||
|
{template.total_questions} questions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTemplateModal(false)}
|
||||||
|
style={{
|
||||||
|
marginTop: "1rem",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#ccc",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,922 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
questionsAPI,
|
||||||
|
categoriesAPI,
|
||||||
|
downloadJobsAPI,
|
||||||
|
} from "../../services/api";
|
||||||
|
import AdminNavbar from "../common/AdminNavbar";
|
||||||
|
|
||||||
|
export default function QuestionBankView() {
|
||||||
|
const [questions, setQuestions] = useState([]);
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
type: "text",
|
||||||
|
question_content: "",
|
||||||
|
answer: "",
|
||||||
|
category: "",
|
||||||
|
image: null,
|
||||||
|
youtube_url: "",
|
||||||
|
start_time: 0,
|
||||||
|
end_time: 0,
|
||||||
|
});
|
||||||
|
const [imagePreview, setImagePreview] = useState(null);
|
||||||
|
const [selectedQuestions, setSelectedQuestions] = useState([]);
|
||||||
|
const [showBulkCategory, setShowBulkCategory] = useState(false);
|
||||||
|
const [bulkCategory, setBulkCategory] = useState("");
|
||||||
|
const [downloadJob, setDownloadJob] = useState(null);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadQuestions();
|
||||||
|
loadCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadQuestions = async () => {
|
||||||
|
try {
|
||||||
|
const response = await questionsAPI.getAll();
|
||||||
|
setQuestions(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading questions:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const response = await categoriesAPI.getAll();
|
||||||
|
setCategories(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading categories:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
// Update existing question
|
||||||
|
if (formData.type === "image" && formData.image) {
|
||||||
|
const formDataToSend = new FormData();
|
||||||
|
formDataToSend.append("type", "image");
|
||||||
|
formDataToSend.append("question_content", formData.question_content);
|
||||||
|
formDataToSend.append("answer", formData.answer);
|
||||||
|
formDataToSend.append("category", formData.category);
|
||||||
|
formDataToSend.append("image", formData.image);
|
||||||
|
await questionsAPI.updateWithImage(editingId, formDataToSend);
|
||||||
|
} else {
|
||||||
|
await questionsAPI.update(editingId, {
|
||||||
|
type: formData.type,
|
||||||
|
question_content: formData.question_content,
|
||||||
|
answer: formData.answer,
|
||||||
|
category: formData.category,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new question
|
||||||
|
if (formData.type === "image" && formData.image) {
|
||||||
|
const formDataToSend = new FormData();
|
||||||
|
formDataToSend.append("type", "image");
|
||||||
|
formDataToSend.append("question_content", formData.question_content);
|
||||||
|
formDataToSend.append("answer", formData.answer);
|
||||||
|
formDataToSend.append("category", formData.category);
|
||||||
|
formDataToSend.append("image", formData.image);
|
||||||
|
await questionsAPI.createWithImage(formDataToSend);
|
||||||
|
} else if (formData.type === "youtube_audio") {
|
||||||
|
// Create YouTube audio question
|
||||||
|
const response = await questionsAPI.create({
|
||||||
|
type: "youtube_audio",
|
||||||
|
question_content: formData.question_content,
|
||||||
|
answer: formData.answer,
|
||||||
|
category: formData.category,
|
||||||
|
youtube_url: formData.youtube_url,
|
||||||
|
start_time: formData.start_time,
|
||||||
|
end_time: formData.end_time,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start polling for download progress if job was created
|
||||||
|
if (response.data.job) {
|
||||||
|
setDownloadJob(response.data.job);
|
||||||
|
pollDownloadStatus(response.data.job.id);
|
||||||
|
return; // Don't close form yet, wait for download
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await questionsAPI.create({
|
||||||
|
type: formData.type,
|
||||||
|
question_content: formData.question_content,
|
||||||
|
answer: formData.answer,
|
||||||
|
category: formData.category,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setFormData({
|
||||||
|
type: "text",
|
||||||
|
question_content: "",
|
||||||
|
answer: "",
|
||||||
|
category: "",
|
||||||
|
image: null,
|
||||||
|
youtube_url: "",
|
||||||
|
start_time: 0,
|
||||||
|
end_time: 0,
|
||||||
|
});
|
||||||
|
setImagePreview(null);
|
||||||
|
loadQuestions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving question:", error);
|
||||||
|
alert("Error saving question");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (question) => {
|
||||||
|
setEditingId(question.id);
|
||||||
|
setFormData({
|
||||||
|
type: question.type,
|
||||||
|
question_content: question.question_content,
|
||||||
|
answer: question.answer,
|
||||||
|
category: question.category || "",
|
||||||
|
image: null,
|
||||||
|
});
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setFormData({
|
||||||
|
type: "text",
|
||||||
|
question_content: "",
|
||||||
|
answer: "",
|
||||||
|
category: "",
|
||||||
|
image: null,
|
||||||
|
youtube_url: "",
|
||||||
|
start_time: 0,
|
||||||
|
end_time: 0,
|
||||||
|
});
|
||||||
|
setImagePreview(null);
|
||||||
|
setDownloadJob(null);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollDownloadStatus = async (jobId) => {
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await downloadJobsAPI.getStatus(jobId);
|
||||||
|
const job = response.data;
|
||||||
|
|
||||||
|
setDownloadProgress(job.progress);
|
||||||
|
|
||||||
|
if (job.status === "completed") {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
setDownloadJob(null);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
setShowForm(false);
|
||||||
|
setFormData({
|
||||||
|
type: "text",
|
||||||
|
question_content: "",
|
||||||
|
answer: "",
|
||||||
|
category: "",
|
||||||
|
image: null,
|
||||||
|
youtube_url: "",
|
||||||
|
start_time: 0,
|
||||||
|
end_time: 0,
|
||||||
|
});
|
||||||
|
loadQuestions();
|
||||||
|
alert("Audio downloaded successfully!");
|
||||||
|
} else if (job.status === "failed") {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
setDownloadJob(null);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
alert(`Download failed: ${job.error_message || "Unknown error"}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
console.error("Error polling download status:", error);
|
||||||
|
setDownloadJob(null);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
}
|
||||||
|
}, 2000); // Poll every 2 seconds
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageChange = (file) => {
|
||||||
|
if (file && file.type.startsWith("image/")) {
|
||||||
|
setFormData({ ...formData, image: file });
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setImagePreview(reader.result);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = (e) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].type.startsWith("image/")) {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = items[i].getAsFile();
|
||||||
|
if (file) {
|
||||||
|
handleImageChange(file);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm("Are you sure you want to delete this question?")) return;
|
||||||
|
try {
|
||||||
|
await questionsAPI.delete(id);
|
||||||
|
loadQuestions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting question:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleQuestionSelection = (id) => {
|
||||||
|
setSelectedQuestions((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((qId) => qId !== id) : [...prev, id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (selectedQuestions.length === questions.length) {
|
||||||
|
setSelectedQuestions([]);
|
||||||
|
} else {
|
||||||
|
setSelectedQuestions(questions.map((q) => q.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkDelete = async () => {
|
||||||
|
if (selectedQuestions.length === 0) return;
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to delete ${selectedQuestions.length} question(s)?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(selectedQuestions.map((id) => questionsAPI.delete(id)));
|
||||||
|
setSelectedQuestions([]);
|
||||||
|
loadQuestions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error bulk deleting questions:", error);
|
||||||
|
alert("Error deleting questions");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkCategoryAssign = async () => {
|
||||||
|
if (selectedQuestions.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
selectedQuestions.map((id) => {
|
||||||
|
const question = questions.find((q) => q.id === id);
|
||||||
|
return questionsAPI.update(id, {
|
||||||
|
question_content: question.question_content,
|
||||||
|
answer: question.answer,
|
||||||
|
type: question.type,
|
||||||
|
category: bulkCategory,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setSelectedQuestions([]);
|
||||||
|
setShowBulkCategory(false);
|
||||||
|
setBulkCategory("");
|
||||||
|
loadQuestions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error bulk updating categories:", error);
|
||||||
|
alert("Error updating categories");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminNavbar />
|
||||||
|
<div style={{ padding: "1rem 2rem", minHeight: "calc(100vh - 60px)" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ margin: "0" }}>Question Bank</h1>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(!showForm);
|
||||||
|
if (showForm) handleCancelEdit();
|
||||||
|
}}
|
||||||
|
style={{ marginRight: "1rem", padding: "0.5rem 1rem" }}
|
||||||
|
>
|
||||||
|
{showForm ? "Cancel" : "Add Question"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{
|
||||||
|
marginBottom: "2rem",
|
||||||
|
padding: "1rem",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
||||||
|
{editingId ? "Edit Question" : "Add Question"}
|
||||||
|
</h3>
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||||
|
Question Type:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, type: e.target.value })
|
||||||
|
}
|
||||||
|
style={{ padding: "0.5rem", width: "200px" }}
|
||||||
|
>
|
||||||
|
<option value="text">Text Question</option>
|
||||||
|
<option value="image">Image Question</option>
|
||||||
|
<option value="youtube_audio">YouTube Audio</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||||
|
Category (optional):
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.category}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, category: e.target.value })
|
||||||
|
}
|
||||||
|
style={{ padding: "0.5rem", width: "100%" }}
|
||||||
|
>
|
||||||
|
<option value="">-- No Category --</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.name}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Link
|
||||||
|
to="/categories"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
display: "inline-block",
|
||||||
|
color: "#2196F3",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage Categories
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||||
|
Question:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.question_content}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, question_content: e.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
style={{ padding: "0.5rem", width: "100%" }}
|
||||||
|
placeholder="Enter question text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.type === "image" && (
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||||
|
Image:
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
onPaste={handlePaste}
|
||||||
|
style={{
|
||||||
|
border: "2px dashed #ccc",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "1.5rem",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
background: "#f9f9f9",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ margin: "0 0 0.5rem 0", color: "#666" }}>
|
||||||
|
📋 Paste an image here (Ctrl/Cmd+V) or upload below
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => handleImageChange(e.target.files[0])}
|
||||||
|
required={!editingId && !formData.image}
|
||||||
|
style={{ padding: "0.5rem" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{imagePreview && (
|
||||||
|
<div style={{ marginTop: "0.5rem" }}>
|
||||||
|
<p style={{ fontSize: "0.9rem", marginBottom: "0.5rem" }}>
|
||||||
|
Preview:
|
||||||
|
</p>
|
||||||
|
<img
|
||||||
|
src={imagePreview}
|
||||||
|
alt="Preview"
|
||||||
|
style={{
|
||||||
|
maxWidth: "300px",
|
||||||
|
maxHeight: "200px",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editingId && !imagePreview && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "#666",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Leave empty to keep current image
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.type === "youtube_audio" && (
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||||
|
YouTube URL:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.youtube_url}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, youtube_url: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: "1rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Start Time (seconds):
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.start_time}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
start_time: parseInt(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
End Time (seconds):
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.end_time}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
end_time: parseInt(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "#666",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clip duration: {formData.end_time - formData.start_time}{" "}
|
||||||
|
seconds (max 300)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||||
|
Answer:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.answer}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, answer: e.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
style={{ padding: "0.5rem", width: "100%" }}
|
||||||
|
placeholder="Enter answer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button type="submit" style={{ padding: "0.5rem 1rem" }}>
|
||||||
|
{editingId ? "Update Question" : "Create Question"}
|
||||||
|
</button>
|
||||||
|
{editingId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
style={{ padding: "0.5rem 1rem", background: "#ccc" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: 0 }}>Questions ({questions.length})</h2>
|
||||||
|
{selectedQuestions.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "0.9rem", color: "#666" }}>
|
||||||
|
{selectedQuestions.length} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBulkCategory(!showBulkCategory)}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#2196F3",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Assign Category
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBulkDelete}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#f44336",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete Selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showBulkCategory && selectedQuestions.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "1rem",
|
||||||
|
padding: "1rem",
|
||||||
|
background: "#f5f5f5",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>Category:</label>
|
||||||
|
<select
|
||||||
|
value={bulkCategory}
|
||||||
|
onChange={(e) => setBulkCategory(e.target.value)}
|
||||||
|
style={{ padding: "0.5rem", flex: 1, maxWidth: "300px" }}
|
||||||
|
>
|
||||||
|
<option value="">-- No Category --</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.name}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={handleBulkCategoryAssign}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#4CAF50",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowBulkCategory(false);
|
||||||
|
setBulkCategory("");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#ccc",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{questions.length === 0 ? (
|
||||||
|
<p>No questions yet. Create your first question above!</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: "#f5f5f5" }}>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem",
|
||||||
|
textAlign: "center",
|
||||||
|
borderBottom: "2px solid #ddd",
|
||||||
|
width: "50px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={
|
||||||
|
selectedQuestions.length === questions.length &&
|
||||||
|
questions.length > 0
|
||||||
|
}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem",
|
||||||
|
textAlign: "left",
|
||||||
|
borderBottom: "2px solid #ddd",
|
||||||
|
width: "80px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem",
|
||||||
|
textAlign: "left",
|
||||||
|
borderBottom: "2px solid #ddd",
|
||||||
|
width: "120px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Category
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem",
|
||||||
|
textAlign: "left",
|
||||||
|
borderBottom: "2px solid #ddd",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Question
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem",
|
||||||
|
textAlign: "left",
|
||||||
|
borderBottom: "2px solid #ddd",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Answer
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem",
|
||||||
|
textAlign: "center",
|
||||||
|
borderBottom: "2px solid #ddd",
|
||||||
|
width: "150px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{questions.map((q) => (
|
||||||
|
<tr key={q.id} style={{ borderBottom: "1px solid #ddd" }}>
|
||||||
|
<td style={{ padding: "0.75rem", textAlign: "center" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedQuestions.includes(q.id)}
|
||||||
|
onChange={() => toggleQuestionSelection(q.id)}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0.75rem" }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
q.type === "image" ? "#e3f2fd" : "#f3e5f5",
|
||||||
|
padding: "0.25rem 0.5rem",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{q.type.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0.75rem" }}>
|
||||||
|
{q.category ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: "#e8f5e9",
|
||||||
|
padding: "0.25rem 0.5rem",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "#2e7d32",
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{q.category}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "#999", fontSize: "0.8rem" }}>
|
||||||
|
None
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0.75rem" }}>
|
||||||
|
<div>{q.question_content}</div>
|
||||||
|
{q.image_path && (
|
||||||
|
<img
|
||||||
|
src={q.image_path}
|
||||||
|
alt="Question"
|
||||||
|
style={{
|
||||||
|
maxWidth: "100px",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0.75rem", fontWeight: "bold" }}>
|
||||||
|
{q.answer}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0.75rem", textAlign: "center" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(q)}
|
||||||
|
style={{
|
||||||
|
padding: "0.4rem 0.8rem",
|
||||||
|
background: "#2196F3",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(q.id)}
|
||||||
|
style={{
|
||||||
|
padding: "0.4rem 0.8rem",
|
||||||
|
background: "#f44336",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{downloadJob && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: "2rem",
|
||||||
|
right: "2rem",
|
||||||
|
background: "white",
|
||||||
|
padding: "1.5rem",
|
||||||
|
borderRadius: "8px",
|
||||||
|
boxShadow: "0 4px 6px rgba(0,0,0,0.1)",
|
||||||
|
minWidth: "300px",
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h4 style={{ margin: "0 0 1rem 0" }}>Downloading Audio...</h4>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "8px",
|
||||||
|
background: "#e0e0e0",
|
||||||
|
borderRadius: "4px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${downloadProgress}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: "#4CAF50",
|
||||||
|
transition: "width 0.3s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "0.5rem 0 0 0",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "#666",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{downloadProgress}% complete
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
frontend/frontend/src/components/templates/TemplatesView.jsx
Normal file
139
frontend/frontend/src/components/templates/TemplatesView.jsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { gamesAPI } from "../../services/api";
|
||||||
|
import AdminNavbar from "../common/AdminNavbar";
|
||||||
|
|
||||||
|
export default function TemplatesView() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [templates, setTemplates] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTemplates();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTemplates = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await gamesAPI.getTemplates();
|
||||||
|
setTemplates(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading templates:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseTemplate = async (template) => {
|
||||||
|
const gameName = prompt(
|
||||||
|
`Enter a name for the new game (based on "${template.name}"):`,
|
||||||
|
);
|
||||||
|
if (!gameName) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await gamesAPI.cloneTemplate(template.id, gameName);
|
||||||
|
alert("Game created successfully!");
|
||||||
|
navigate(`/games/${response.data.id}/admin`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error cloning template:", error);
|
||||||
|
alert("Error creating game from template");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTemplate = async (templateId, templateName) => {
|
||||||
|
if (!window.confirm(`Delete template "${templateName}"?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gamesAPI.delete(templateId);
|
||||||
|
loadTemplates();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting template:", error);
|
||||||
|
alert("Error deleting template");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminNavbar />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0 2rem 1rem 2rem",
|
||||||
|
maxWidth: "1400px",
|
||||||
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ margin: "0 0 1rem 0" }}>Game Templates</h1>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p>Loading templates...</p>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<p>
|
||||||
|
No templates yet. Save a game as a template from the Game Admin
|
||||||
|
view.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||||
|
>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
style={{
|
||||||
|
padding: "1.5rem",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: "0 0 0.5rem 0" }}>{template.name}</h3>
|
||||||
|
<p style={{ margin: 0, color: "#666" }}>
|
||||||
|
{template.total_questions} questions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUseTemplate(template)}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#4CAF50",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use Template
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteTemplate(template.id, template.name)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#f44336",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
frontend/frontend/src/contexts/AuthContext.jsx
Normal file
159
frontend/frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { jwtDecode } from 'jwt-decode';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [accessToken, setAccessToken] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if user is already logged in (token in localStorage)
|
||||||
|
const storedToken = localStorage.getItem('access_token');
|
||||||
|
const storedIdToken = localStorage.getItem('id_token');
|
||||||
|
|
||||||
|
console.log('AuthContext: Checking stored tokens', {
|
||||||
|
hasAccessToken: !!storedToken,
|
||||||
|
hasIdToken: !!storedIdToken
|
||||||
|
});
|
||||||
|
|
||||||
|
if (storedToken && storedIdToken) {
|
||||||
|
try {
|
||||||
|
const decoded = jwtDecode(storedIdToken);
|
||||||
|
// Check if token is expired
|
||||||
|
if (decoded.exp * 1000 > Date.now()) {
|
||||||
|
console.log('AuthContext: Tokens valid, setting authenticated', decoded);
|
||||||
|
setAccessToken(storedToken);
|
||||||
|
setUser({ profile: decoded });
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
} else {
|
||||||
|
// Token expired, clear storage
|
||||||
|
console.log('AuthContext: Tokens expired');
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('id_token');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error decoding token:', error);
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('id_token');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('AuthContext: No tokens found in storage');
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = () => {
|
||||||
|
// Redirect to backend login endpoint
|
||||||
|
window.location.href = '/api/auth/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCallback = async () => {
|
||||||
|
try {
|
||||||
|
// Extract tokens from URL hash (set by backend redirect)
|
||||||
|
const hash = window.location.hash.substring(1);
|
||||||
|
const params = new URLSearchParams(hash);
|
||||||
|
|
||||||
|
console.log('handleCallback: Hash params', hash);
|
||||||
|
|
||||||
|
const access_token = params.get('access_token');
|
||||||
|
const id_token = params.get('id_token');
|
||||||
|
|
||||||
|
console.log('handleCallback: Tokens extracted', {
|
||||||
|
hasAccessToken: !!access_token,
|
||||||
|
hasIdToken: !!id_token
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!access_token || !id_token) {
|
||||||
|
throw new Error('No tokens found in callback');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store tokens
|
||||||
|
localStorage.setItem('access_token', access_token);
|
||||||
|
localStorage.setItem('id_token', id_token);
|
||||||
|
|
||||||
|
console.log('handleCallback: Tokens stored in localStorage');
|
||||||
|
|
||||||
|
// Decode ID token to get user info
|
||||||
|
const decoded = jwtDecode(id_token);
|
||||||
|
|
||||||
|
console.log('handleCallback: Decoded user', decoded);
|
||||||
|
|
||||||
|
setAccessToken(access_token);
|
||||||
|
setUser({ profile: decoded });
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
|
||||||
|
console.log('handleCallback: Auth state updated, isAuthenticated=true');
|
||||||
|
|
||||||
|
// Clear hash from URL
|
||||||
|
window.history.replaceState(null, '', window.location.pathname);
|
||||||
|
|
||||||
|
return { profile: decoded };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Callback error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
// Clear local storage
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('id_token');
|
||||||
|
|
||||||
|
setAccessToken(null);
|
||||||
|
setUser(null);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
|
||||||
|
// Redirect to backend logout to clear cookies and get Authelia logout URL
|
||||||
|
window.location.href = '/api/auth/logout';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTokenSilently = async () => {
|
||||||
|
// Return ID token (JWT) for backend authentication
|
||||||
|
// Note: Authelia's access_token is opaque and can't be validated by backend
|
||||||
|
// We use id_token instead which is a proper JWT
|
||||||
|
const storedIdToken = localStorage.getItem('id_token');
|
||||||
|
if (storedIdToken) {
|
||||||
|
try {
|
||||||
|
const decoded = jwtDecode(storedIdToken);
|
||||||
|
// Check if token is expired
|
||||||
|
if (decoded.exp * 1000 > Date.now()) {
|
||||||
|
return storedIdToken;
|
||||||
|
} else {
|
||||||
|
console.log('ID token expired');
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('id_token');
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error decoding ID token:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid token available
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
accessToken,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
handleCallback,
|
||||||
|
getTokenSilently,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
81
frontend/frontend/src/hooks/useSocket.js
Normal file
81
frontend/frontend/src/hooks/useSocket.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
export function useSocket(gameId, role = "contestant") {
|
||||||
|
const [socket, setSocket] = useState(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const socketRef = useRef(null);
|
||||||
|
const { getTokenSilently, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gameId || !isAuthenticated) return;
|
||||||
|
|
||||||
|
// Get ID token for authentication
|
||||||
|
const initSocket = async () => {
|
||||||
|
const token = await getTokenSilently();
|
||||||
|
if (!token) {
|
||||||
|
console.error('No token available for WebSocket connection');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create socket connection with auth token
|
||||||
|
const newSocket = io({
|
||||||
|
transports: ["websocket", "polling"],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
auth: {
|
||||||
|
token, // Send ID token (JWT) in connection auth
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
socketRef.current = newSocket;
|
||||||
|
|
||||||
|
newSocket.on("connect", () => {
|
||||||
|
console.log("Socket connected");
|
||||||
|
setIsConnected(true);
|
||||||
|
// Join the game room with token
|
||||||
|
newSocket.emit("join_game", {
|
||||||
|
game_id: parseInt(gameId),
|
||||||
|
role,
|
||||||
|
token, // Send ID token (JWT) in join_game event
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on("disconnect", () => {
|
||||||
|
console.log("Socket disconnected");
|
||||||
|
setIsConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on("joined", (data) => {
|
||||||
|
console.log("Joined game room:", data);
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on("error", (error) => {
|
||||||
|
console.error("Socket error:", error);
|
||||||
|
// If error is auth-related, disconnect
|
||||||
|
if (error.message?.includes('token') || error.message?.includes('auth')) {
|
||||||
|
newSocket.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSocket(newSocket);
|
||||||
|
};
|
||||||
|
|
||||||
|
initSocket();
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.emit("leave_game", {
|
||||||
|
game_id: parseInt(gameId),
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [gameId, role, isAuthenticated, getTokenSilently]);
|
||||||
|
|
||||||
|
return { socket, isConnected };
|
||||||
|
}
|
||||||
69
frontend/frontend/src/index.css
Normal file
69
frontend/frontend/src/index.css
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
:root {
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/frontend/src/main.jsx
Normal file
10
frontend/frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
import App from "./App.jsx";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
72
frontend/frontend/src/pages/AuthCallback.jsx
Normal file
72
frontend/frontend/src/pages/AuthCallback.jsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
export default function AuthCallback() {
|
||||||
|
const { handleCallback } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const hasRun = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Prevent double-run in React Strict Mode
|
||||||
|
if (hasRun.current) return;
|
||||||
|
hasRun.current = true;
|
||||||
|
|
||||||
|
handleCallback()
|
||||||
|
.then(() => {
|
||||||
|
navigate('/');
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Authentication failed:', error);
|
||||||
|
setError(error.message || 'Authentication failed');
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/login');
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}, [handleCallback, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<>
|
||||||
|
<div style={{ color: '#e74c3c', fontSize: '1.2rem' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<p>Redirecting to login...</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '4px solid #f3f3f3',
|
||||||
|
borderTop: '4px solid #3498db',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: '50px',
|
||||||
|
height: '50px',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p>Completing login...</p>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
frontend/frontend/src/pages/Login.jsx
Normal file
70
frontend/frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login, isAuthenticated } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
padding: '3rem',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.2)',
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '400px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ margin: '0 0 0.5rem 0', fontSize: '2.5rem', color: '#333' }}>
|
||||||
|
Trivia Game
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: '#666', marginBottom: '2rem', fontSize: '1.1rem' }}>
|
||||||
|
Please log in to continue
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={login}
|
||||||
|
style={{
|
||||||
|
padding: '1rem 2rem',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: '#667eea',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontWeight: '500',
|
||||||
|
width: '100%',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.target.style.background = '#5568d3';
|
||||||
|
e.target.style.transform = 'translateY(-2px)';
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.target.style.background = '#667eea';
|
||||||
|
e.target.style.transform = 'translateY(0)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Login with Authelia
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
frontend/frontend/src/services/api.js
Normal file
136
frontend/frontend/src/services/api.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
// Base URL will use proxy in development, direct path in production
|
||||||
|
const API_BASE_URL = "/api";
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth token getter (set by App.jsx)
|
||||||
|
let getAuthToken = null;
|
||||||
|
|
||||||
|
export function setAuthTokenGetter(tokenGetter) {
|
||||||
|
getAuthToken = tokenGetter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request interceptor to add JWT token
|
||||||
|
api.interceptors.request.use(
|
||||||
|
async (config) => {
|
||||||
|
if (getAuthToken) {
|
||||||
|
const token = await getAuthToken();
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor to handle 401 errors
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
console.error('Authentication error - logging out');
|
||||||
|
// Dispatch custom event for auth context to handle
|
||||||
|
window.dispatchEvent(new Event('auth:unauthorized'));
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Questions API
|
||||||
|
export const questionsAPI = {
|
||||||
|
getAll: () => api.get("/questions"),
|
||||||
|
getOne: (id) => api.get(`/questions/${id}`),
|
||||||
|
create: (data) => api.post("/questions", data),
|
||||||
|
createWithImage: (formData) =>
|
||||||
|
api.post("/questions", formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
}),
|
||||||
|
bulkCreate: (questions) => api.post("/questions/bulk", { questions }),
|
||||||
|
getRandomByCategory: (category, count) =>
|
||||||
|
api.get("/questions/random", {
|
||||||
|
params: { category, count },
|
||||||
|
}),
|
||||||
|
update: (id, data) => api.put(`/questions/${id}`, data),
|
||||||
|
updateWithImage: (id, formData) =>
|
||||||
|
api.put(`/questions/${id}`, formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
}),
|
||||||
|
delete: (id) => api.delete(`/questions/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Games API
|
||||||
|
export const gamesAPI = {
|
||||||
|
getAll: () => api.get("/games"),
|
||||||
|
getOne: (id) => api.get(`/games/${id}`),
|
||||||
|
create: (data) => api.post("/games", data),
|
||||||
|
delete: (id) => api.delete(`/games/${id}`),
|
||||||
|
addQuestions: (id, questionIds) =>
|
||||||
|
api.post(`/games/${id}/questions`, { question_ids: questionIds }),
|
||||||
|
addTeam: (id, teamName) => api.post(`/games/${id}/teams`, { name: teamName }),
|
||||||
|
saveAsTemplate: (id) => api.post(`/games/${id}/save-template`),
|
||||||
|
getTemplates: () => api.get("/games/templates"),
|
||||||
|
cloneTemplate: (id, name) => api.post(`/games/${id}/clone`, { name }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Teams API
|
||||||
|
export const teamsAPI = {
|
||||||
|
delete: (id) => api.delete(`/teams/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin API
|
||||||
|
export const adminAPI = {
|
||||||
|
startGame: (id) => api.post(`/admin/game/${id}/start`),
|
||||||
|
endGame: (id) => api.post(`/admin/game/${id}/end`),
|
||||||
|
restartGame: (id) => api.post(`/admin/game/${id}/restart`),
|
||||||
|
nextQuestion: (id) => api.post(`/admin/game/${id}/next`),
|
||||||
|
prevQuestion: (id) => api.post(`/admin/game/${id}/prev`),
|
||||||
|
awardPoints: (id, teamId, points) =>
|
||||||
|
api.post(`/admin/game/${id}/award`, { team_id: teamId, points }),
|
||||||
|
getCurrentState: (id) => api.get(`/admin/game/${id}/current`),
|
||||||
|
toggleAnswer: (id, showAnswer) =>
|
||||||
|
api.post(`/admin/game/${id}/toggle-answer`, { show_answer: showAnswer }),
|
||||||
|
pauseTimer: (id, paused) =>
|
||||||
|
api.post(`/admin/game/${id}/pause-timer`, { paused }),
|
||||||
|
resetTimer: (id) => api.post(`/admin/game/${id}/reset-timer`),
|
||||||
|
useLifeline: (gameId, teamId) =>
|
||||||
|
api.post(`/admin/game/${gameId}/team/${teamId}/use-lifeline`),
|
||||||
|
addLifeline: (gameId, teamId) =>
|
||||||
|
api.post(`/admin/game/${gameId}/team/${teamId}/add-lifeline`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Categories API
|
||||||
|
export const categoriesAPI = {
|
||||||
|
getAll: () => api.get("/categories"),
|
||||||
|
getOne: (id) => api.get(`/categories/${id}`),
|
||||||
|
create: (data) => api.post("/categories", data),
|
||||||
|
update: (id, data) => api.put(`/categories/${id}`, data),
|
||||||
|
delete: (id) => api.delete(`/categories/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download Jobs API
|
||||||
|
export const downloadJobsAPI = {
|
||||||
|
getStatus: (jobId) => api.get(`/download-jobs/${jobId}`),
|
||||||
|
getByQuestion: (questionId) =>
|
||||||
|
api.get(`/download-jobs/question/${questionId}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Audio Control API
|
||||||
|
export const audioControlAPI = {
|
||||||
|
play: (gameId) => api.post(`/admin/game/${gameId}/audio/play`),
|
||||||
|
pause: (gameId) => api.post(`/admin/game/${gameId}/audio/pause`),
|
||||||
|
stop: (gameId) => api.post(`/admin/game/${gameId}/audio/stop`),
|
||||||
|
seek: (gameId, position) =>
|
||||||
|
api.post(`/admin/game/${gameId}/audio/seek`, { position }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
33
frontend/frontend/vite.config.js
Normal file
33
frontend/frontend/vite.config.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// Use environment variable for backend URL, default to localhost for local dev
|
||||||
|
const backendUrl = process.env.VITE_BACKEND_URL || "http://localhost:5001";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: backendUrl,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
"/socket.io": {
|
||||||
|
target: backendUrl,
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
"/static": {
|
||||||
|
target: backendUrl,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "../backend/static",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
146
import_taylor_swift.py
Normal file
146
import_taylor_swift.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Import Taylor Swift trivia questions"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
# Backend URL
|
||||||
|
BASE_URL = "http://localhost:5001/api"
|
||||||
|
|
||||||
|
# Parse the questions from the pasted text
|
||||||
|
questions_text = """1. What is Taylor Swift's middle name? - Answer: Alison
|
||||||
|
2. In which year was Taylor Swift born? - Answer: 1989
|
||||||
|
3. What is the name of Taylor Swift's debut single? - Answer: Tim McGraw
|
||||||
|
4. Which Taylor Swift album features the song "Shake It Off"? - Answer: 1989
|
||||||
|
5. What instrument did Taylor Swift learn to play at age 12? - Answer: Guitar
|
||||||
|
6. What is the name of Taylor Swift's third studio album? - Answer: Speak Now
|
||||||
|
7. Which city is Taylor Swift originally from? - Answer: West Reading, Pennsylvania
|
||||||
|
8. What is the title of Taylor Swift's documentary released in 2020? - Answer: Miss Americana
|
||||||
|
9. How many Grammy Awards has Taylor Swift won for Album of the Year as of 2023? - Answer: 4
|
||||||
|
10. What is the name of Taylor Swift's cat named after a character from Law & Order: SVU? - Answer: Olivia Benson
|
||||||
|
11. Which Taylor Swift album was entirely written by her without co-writers? - Answer: Speak Now
|
||||||
|
12. What is Taylor Swift's lucky number? - Answer: 13
|
||||||
|
13. Which Taylor Swift song begins with "I stay out too late"? - Answer: Shake It Off
|
||||||
|
14. What was the name of Taylor Swift's first record label? - Answer: Big Machine Records
|
||||||
|
15. In what year did Taylor Swift release the album "Folklore"? - Answer: 2020
|
||||||
|
16. What is the name of Taylor Swift's brother? - Answer: Austin
|
||||||
|
17. Which Taylor Swift album features the song "Love Story"? - Answer: Fearless
|
||||||
|
18. What is the name of the farm Taylor Swift grew up on? - Answer: Pine Ridge Farm
|
||||||
|
19. Which Taylor Swift music video features a British actor as her love interest? - Answer: Tom Hiddleston
|
||||||
|
20. What year did Taylor Swift win her first Grammy Award? - Answer: 2010"""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_questions(text):
|
||||||
|
"""Parse questions from the formatted text"""
|
||||||
|
questions = []
|
||||||
|
for line in text.strip().split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Split on " - Answer: " to separate question from answer
|
||||||
|
parts = line.split(' - Answer: ')
|
||||||
|
if len(parts) != 2:
|
||||||
|
print(f"Skipping malformed line: {line}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Remove the number prefix from the question
|
||||||
|
question_part = parts[0].strip()
|
||||||
|
answer_part = parts[1].strip()
|
||||||
|
|
||||||
|
# Remove leading number and dot (e.g., "1. ")
|
||||||
|
if '. ' in question_part:
|
||||||
|
question_part = question_part.split('. ', 1)[1]
|
||||||
|
|
||||||
|
questions.append({
|
||||||
|
'question_content': question_part,
|
||||||
|
'answer': answer_part,
|
||||||
|
'category': 'Taylor Swift',
|
||||||
|
'type': 'text'
|
||||||
|
})
|
||||||
|
|
||||||
|
return questions
|
||||||
|
|
||||||
|
|
||||||
|
def create_category(category_name):
|
||||||
|
"""Create a category (optional, but good for organization)"""
|
||||||
|
try:
|
||||||
|
data = json.dumps({'name': category_name}).encode('utf-8')
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{BASE_URL}/categories",
|
||||||
|
data=data,
|
||||||
|
headers={'Content-Type': 'application/json'}
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
print(f"✓ Created category: {category_name}")
|
||||||
|
return json.loads(response.read().decode('utf-8'))
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 409:
|
||||||
|
print(f"• Category '{category_name}' already exists")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
print(f"⚠ Failed to create category: {e.read().decode('utf-8')}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠ Error creating category: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_import_questions(questions):
|
||||||
|
"""Import questions using the bulk endpoint"""
|
||||||
|
try:
|
||||||
|
data = json.dumps({'questions': questions}).encode('utf-8')
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{BASE_URL}/questions/bulk",
|
||||||
|
data=data,
|
||||||
|
headers={'Content-Type': 'application/json'}
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
result = json.loads(response.read().decode('utf-8'))
|
||||||
|
print(f"✓ Successfully imported {result['created']} questions")
|
||||||
|
if result['errors']:
|
||||||
|
print(f"⚠ {len(result['errors'])} errors:")
|
||||||
|
for error in result['errors']:
|
||||||
|
print(f" - Question {error['index']}: {error['error']}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f"✗ Failed to import questions: {e.read().decode('utf-8')}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error importing questions: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("Taylor Swift Trivia Questions Import")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Create the category
|
||||||
|
print("\n1. Creating category...")
|
||||||
|
create_category("Taylor Swift")
|
||||||
|
|
||||||
|
# Parse questions
|
||||||
|
print("\n2. Parsing questions...")
|
||||||
|
questions = parse_questions(questions_text)
|
||||||
|
print(f" Parsed {len(questions)} questions")
|
||||||
|
|
||||||
|
# Import questions
|
||||||
|
print("\n3. Importing questions...")
|
||||||
|
result = bulk_import_questions(questions)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✓ Import complete!")
|
||||||
|
print(f" Total questions imported: {result['created']}")
|
||||||
|
print("=" * 60)
|
||||||
|
else:
|
||||||
|
print("\n✗ Import failed!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
98
import_taylor_swift_2.py
Normal file
98
import_taylor_swift_2.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Import additional Taylor Swift trivia questions"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
# Backend URL
|
||||||
|
BASE_URL = "http://localhost:5001/api"
|
||||||
|
|
||||||
|
# Questions as list of tuples (question, answer)
|
||||||
|
questions_data = [
|
||||||
|
("Who is Taylor Swift engaged to?", "Travis Kelce"),
|
||||||
|
("Which song did Taylor Swift help write under the pseudonym Nils Sjoberg?", "This is what you came for"),
|
||||||
|
('Which ex was featured in Taylor Swift\'s "I can see you" music video?', "Taylor Lautner"),
|
||||||
|
("Taylor was releasing Taylor's Versions for all the masters she didn't have. What was the last album she released a TV for before she bought back her masters?", "1989"),
|
||||||
|
("In the TV's, there's usually a few newly released songs at the end. What's the name she gave these songs?", "Vault/From the vault"),
|
||||||
|
("What is Taylor Swift's lucky number?", "13"),
|
||||||
|
("Who did Taylor Swift beef with that birthed the Reputation album?", "Kanye/Kim"),
|
||||||
|
("Which country paid Taylor Swift a lot of money to not perform in neighboring countries?", "Singapore"),
|
||||||
|
("Hamlet is having a year! Which Hamlet character was featured on Taylor Swift's latest album?", "Ophelia"),
|
||||||
|
("Which Taylor Swift song was re-released to have a ten-minute version?", "All Too Well"),
|
||||||
|
("Taylor has had months in her song titles before, but she has mentioned a particular one in two song names. Which month is it?", "December (Back to December, Last December)"),
|
||||||
|
('Identify the song: "She wears high heels, I wear sneakers"', "You Belong With Me"),
|
||||||
|
("What was Taylor Swift's first released song, also the name of a country singer?", "Tim McGraw"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_questions():
|
||||||
|
"""Convert question tuples to API format"""
|
||||||
|
questions = []
|
||||||
|
for question_text, answer_text in questions_data:
|
||||||
|
questions.append({
|
||||||
|
'question_content': question_text,
|
||||||
|
'answer': answer_text,
|
||||||
|
'category': 'Taylor Swift',
|
||||||
|
'type': 'text'
|
||||||
|
})
|
||||||
|
return questions
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_import_questions(questions):
|
||||||
|
"""Import questions using the bulk endpoint"""
|
||||||
|
try:
|
||||||
|
data = json.dumps({'questions': questions}).encode('utf-8')
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{BASE_URL}/questions/bulk",
|
||||||
|
data=data,
|
||||||
|
headers={'Content-Type': 'application/json'}
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
result = json.loads(response.read().decode('utf-8'))
|
||||||
|
print(f"✓ Successfully imported {result['created']} questions")
|
||||||
|
if result['errors']:
|
||||||
|
print(f"⚠ {len(result['errors'])} errors:")
|
||||||
|
for error in result['errors']:
|
||||||
|
print(f" - Question {error['index']}: {error['error']}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f"✗ Failed to import questions: {e.read().decode('utf-8')}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error importing questions: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("Additional Taylor Swift Questions Import")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Parse questions
|
||||||
|
print("\n1. Parsing questions...")
|
||||||
|
questions = parse_questions()
|
||||||
|
print(f" Parsed {len(questions)} questions")
|
||||||
|
|
||||||
|
# Show a preview
|
||||||
|
print("\n Preview:")
|
||||||
|
for i, q in enumerate(questions[:3], 1):
|
||||||
|
print(f" {i}. {q['question_content'][:50]}...")
|
||||||
|
|
||||||
|
# Import questions
|
||||||
|
print("\n2. Importing questions...")
|
||||||
|
result = bulk_import_questions(questions)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✓ Import complete!")
|
||||||
|
print(f" Total questions imported: {result['created']}")
|
||||||
|
print("=" * 60)
|
||||||
|
else:
|
||||||
|
print("\n✗ Import failed!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
20
main.py
Normal file
20
main.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import os
|
||||||
|
from backend.app import create_app, socketio
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run the Flask application with SocketIO"""
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
# Get port from environment or default to 5000
|
||||||
|
port = int(os.environ.get('PORT', 5000))
|
||||||
|
|
||||||
|
print(f"Starting Trivia Game server on http://localhost:{port}")
|
||||||
|
print(f"Environment: {os.environ.get('FLASK_ENV', 'development')}")
|
||||||
|
|
||||||
|
# Run with socketio instead of app.run() for WebSocket support
|
||||||
|
socketio.run(app, host='0.0.0.0', port=port, debug=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Single-database configuration for Flask.
|
||||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# template used to generate migration files
|
||||||
|
# file_template = %%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic,flask_migrate
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[logger_flask_migrate]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = flask_migrate
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import logging
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
logger = logging.getLogger('alembic.env')
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
try:
|
||||||
|
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||||
|
return current_app.extensions['migrate'].db.get_engine()
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
# this works with Flask-SQLAlchemy>=3
|
||||||
|
return current_app.extensions['migrate'].db.engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine_url():
|
||||||
|
try:
|
||||||
|
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||||
|
'%', '%%')
|
||||||
|
except AttributeError:
|
||||||
|
return str(get_engine().url).replace('%', '%%')
|
||||||
|
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||||
|
target_db = current_app.extensions['migrate'].db
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata():
|
||||||
|
if hasattr(target_db, 'metadatas'):
|
||||||
|
return target_db.metadatas[None]
|
||||||
|
return target_db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# this callback is used to prevent an auto-migration from being generated
|
||||||
|
# when there are no changes to the schema
|
||||||
|
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||||
|
def process_revision_directives(context, revision, directives):
|
||||||
|
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||||
|
script = directives[0]
|
||||||
|
if script.upgrade_ops.is_empty():
|
||||||
|
directives[:] = []
|
||||||
|
logger.info('No changes in schema detected.')
|
||||||
|
|
||||||
|
conf_args = current_app.extensions['migrate'].configure_args
|
||||||
|
if conf_args.get("process_revision_directives") is None:
|
||||||
|
conf_args["process_revision_directives"] = process_revision_directives
|
||||||
|
|
||||||
|
connectable = get_engine()
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=get_metadata(),
|
||||||
|
**conf_args
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
34
migrations/versions/01a0ed14f3eb_add_category_model.py
Normal file
34
migrations/versions/01a0ed14f3eb_add_category_model.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Add Category model
|
||||||
|
|
||||||
|
Revision ID: 01a0ed14f3eb
|
||||||
|
Revises: 6347615a68b5
|
||||||
|
Create Date: 2025-12-08 20:13:22.898643
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '01a0ed14f3eb'
|
||||||
|
down_revision = '6347615a68b5'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('categories',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('categories')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""add phone_a_friend_count to teams
|
||||||
|
|
||||||
|
Revision ID: 1252454a2589
|
||||||
|
Revises: 01a0ed14f3eb
|
||||||
|
Create Date: 2025-12-14 19:23:42.244820
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '1252454a2589'
|
||||||
|
down_revision = '01a0ed14f3eb'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('teams', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('phone_a_friend_count', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('teams', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('phone_a_friend_count')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Add category and is_template fields
|
||||||
|
|
||||||
|
Revision ID: 6347615a68b5
|
||||||
|
Revises: c949c8fe106a
|
||||||
|
Create Date: 2025-12-08 20:08:41.878117
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '6347615a68b5'
|
||||||
|
down_revision = 'c949c8fe106a'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('games', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('is_template', sa.Boolean(), nullable=True))
|
||||||
|
|
||||||
|
with op.batch_alter_table('questions', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('category', sa.String(length=100), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('questions', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('category')
|
||||||
|
|
||||||
|
with op.batch_alter_table('games', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('is_template')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Add User model for OIDC authentication
|
||||||
|
|
||||||
|
Revision ID: 6d457de4e2b1
|
||||||
|
Revises: fc2ec8c4e929
|
||||||
|
Create Date: 2025-12-21 10:01:19.224313
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '6d457de4e2b1'
|
||||||
|
down_revision = 'fc2ec8c4e929'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('authelia_sub', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('preferred_username', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('groups', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('last_login', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('authelia_sub'),
|
||||||
|
sa.UniqueConstraint('email')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('users')
|
||||||
|
# ### end Alembic commands ###
|
||||||
36
migrations/versions/90b81e097444_make_user_email_nullable.py
Normal file
36
migrations/versions/90b81e097444_make_user_email_nullable.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Make user email nullable
|
||||||
|
|
||||||
|
Revision ID: 90b81e097444
|
||||||
|
Revises: 6d457de4e2b1
|
||||||
|
Create Date: 2025-12-21 20:18:36.820920
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '90b81e097444'
|
||||||
|
down_revision = '6d457de4e2b1'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('email',
|
||||||
|
existing_type=sa.VARCHAR(length=255),
|
||||||
|
nullable=True)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('email',
|
||||||
|
existing_type=sa.VARCHAR(length=255),
|
||||||
|
nullable=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Initial migration with all models
|
||||||
|
|
||||||
|
Revision ID: c949c8fe106a
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-12-08 09:35:40.713069
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c949c8fe106a'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('games',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('current_question_index', sa.Integer(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('questions',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('type', sa.Enum('TEXT', 'IMAGE', name='questiontype'), nullable=False),
|
||||||
|
sa.Column('question_content', sa.Text(), nullable=False),
|
||||||
|
sa.Column('answer', sa.Text(), nullable=False),
|
||||||
|
sa.Column('image_path', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('game_questions',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('game_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('question_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('order', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('game_id', 'order', name='unique_game_question_order')
|
||||||
|
)
|
||||||
|
op.create_table('teams',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('game_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('scores',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('team_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('game_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('question_index', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('points', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('awarded_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('team_id', 'question_index', name='unique_team_question_score')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('scores')
|
||||||
|
op.drop_table('teams')
|
||||||
|
op.drop_table('game_questions')
|
||||||
|
op.drop_table('questions')
|
||||||
|
op.drop_table('games')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Add YouTube audio support
|
||||||
|
|
||||||
|
Revision ID: fc2ec8c4e929
|
||||||
|
Revises: 1252454a2589
|
||||||
|
Create Date: 2025-12-20 21:07:01.804139
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'fc2ec8c4e929'
|
||||||
|
down_revision = '1252454a2589'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('download_jobs',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('question_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('celery_task_id', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', name='downloadjobstatus'), nullable=False),
|
||||||
|
sa.Column('progress', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('error_message', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('celery_task_id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('questions', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('youtube_url', sa.String(length=500), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('audio_path', sa.String(length=255), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('start_time', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('end_time', sa.Integer(), nullable=True))
|
||||||
|
batch_op.alter_column('type',
|
||||||
|
existing_type=sa.VARCHAR(length=5),
|
||||||
|
type_=sa.Enum('TEXT', 'IMAGE', 'YOUTUBE_AUDIO', name='questiontype'),
|
||||||
|
existing_nullable=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('questions', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('type',
|
||||||
|
existing_type=sa.Enum('TEXT', 'IMAGE', 'YOUTUBE_AUDIO', name='questiontype'),
|
||||||
|
type_=sa.VARCHAR(length=5),
|
||||||
|
existing_nullable=False)
|
||||||
|
batch_op.drop_column('end_time')
|
||||||
|
batch_op.drop_column('start_time')
|
||||||
|
batch_op.drop_column('audio_path')
|
||||||
|
batch_op.drop_column('youtube_url')
|
||||||
|
|
||||||
|
op.drop_table('download_jobs')
|
||||||
|
# ### end Alembic commands ###
|
||||||
31
pyproject.toml
Normal file
31
pyproject.toml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[project]
|
||||||
|
name = "trivia-thang"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Flask + React trivia game web app with real-time WebSocket updates"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"flask>=3.0.0",
|
||||||
|
"flask-socketio>=5.3.0",
|
||||||
|
"flask-sqlalchemy>=3.1.0",
|
||||||
|
"flask-migrate>=4.0.0",
|
||||||
|
"flask-cors>=4.0.0",
|
||||||
|
"python-socketio>=5.11.0",
|
||||||
|
"eventlet>=0.36.0",
|
||||||
|
"pillow>=10.0.0",
|
||||||
|
"werkzeug>=3.0.0",
|
||||||
|
"celery>=5.3.0",
|
||||||
|
"redis>=5.0.0",
|
||||||
|
"yt-dlp>=2024.12.0",
|
||||||
|
"pydub>=0.25.0",
|
||||||
|
"authlib>=1.3.0",
|
||||||
|
"cryptography>=42.0.0",
|
||||||
|
"requests>=2.31.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.4.0",
|
||||||
|
"pytest-flask>=1.3.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user