This commit is contained in:
2025-12-22 14:47:25 -05:00
parent d4e859f9a7
commit 00e9eb8986
81 changed files with 13933 additions and 0 deletions

57
.dockerignore Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
3.14

233
API_EXAMPLES.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
```

BIN
README.md Normal file

Binary file not shown.

View 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
View File

89
backend/app.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
}

View File

304
backend/routes/admin.py Normal file
View 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
View 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

View 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

View 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
View 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
View 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
View 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

View File

View 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)}")

View 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')

View 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)}")

View 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

View File

114
backend/sockets/events.py Normal file
View 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}'})

View File

View File

@@ -0,0 +1 @@
# Tasks package

View 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
View 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
View 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
View 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.

View 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" } }
}
}

View 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_]" }],
},
},
]);

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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

View 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;
}

View 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;

View 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

View 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>
</>
);
}

View 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>
);
}

View 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;
}

View File

@@ -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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View File

@@ -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>
</>
);
}

View 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>
</>
);
}

View 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;
}

View 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 };
}

View 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;
}
}

View 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>,
);

View 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>
);
}

View 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>
);
}

View 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;

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View 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
View 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
View 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"}

View 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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View 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 ###

View File

@@ -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 ###

View File

@@ -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
View 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",
]

1027
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff