Compare commits
5 Commits
758e1a18e4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02fcbad9ba | ||
|
|
d4345a4e09 | ||
|
|
e431ba45e9 | ||
|
|
96716d95b6 | ||
|
|
ab962725e6 |
147
MIGRATIONS.md
Normal file
147
MIGRATIONS.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Database Migrations Guide
|
||||||
|
|
||||||
|
This document covers the proper workflow for database migrations and how to fix common issues.
|
||||||
|
|
||||||
|
## Migration Workflow Checklist
|
||||||
|
|
||||||
|
When making model changes, **always follow this order**:
|
||||||
|
|
||||||
|
### 1. Make model changes
|
||||||
|
Edit `backend/models.py` with your changes.
|
||||||
|
|
||||||
|
### 2. Create the migration
|
||||||
|
```bash
|
||||||
|
# Local development
|
||||||
|
uv run flask --app backend.app:create_app db migrate -m "Description of changes"
|
||||||
|
|
||||||
|
# Docker development
|
||||||
|
docker compose exec backend uv run flask --app backend.app:create_app db migrate -m "Description of changes"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Review the generated migration
|
||||||
|
Check `migrations/versions/` for the new file. Verify it does what you expect.
|
||||||
|
|
||||||
|
### 4. Apply locally and test
|
||||||
|
```bash
|
||||||
|
uv run flask --app backend.app:create_app db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Commit BOTH the model AND migration together
|
||||||
|
```bash
|
||||||
|
git add backend/models.py migrations/versions/
|
||||||
|
git commit -m "Add field_name to Model"
|
||||||
|
```
|
||||||
|
|
||||||
|
**CRITICAL: Never commit model changes without the corresponding migration file!**
|
||||||
|
|
||||||
|
### 6. Deploy and run migrations on production
|
||||||
|
```bash
|
||||||
|
# Rebuild to include new migration file
|
||||||
|
docker compose -f docker-compose.production.yml up --build -d
|
||||||
|
|
||||||
|
# Apply the migration
|
||||||
|
docker compose -f docker-compose.production.yml exec backend uv run flask --app backend.app:create_app db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues and Fixes
|
||||||
|
|
||||||
|
### Error: "Can't locate revision identified by 'xxxxx'"
|
||||||
|
|
||||||
|
**Cause:** The database references a migration that doesn't exist in the codebase (migration was applied but never committed).
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```bash
|
||||||
|
# 1. Check what revision the DB thinks it's at
|
||||||
|
docker compose -f docker-compose.production.yml exec backend python -c "
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('backend/instance/trivia.db')
|
||||||
|
print(conn.execute('SELECT * FROM alembic_version').fetchall())
|
||||||
|
"
|
||||||
|
|
||||||
|
# 2. Find the latest valid migration in your codebase
|
||||||
|
ls migrations/versions/
|
||||||
|
|
||||||
|
# 3. Force the DB to point to a valid migration
|
||||||
|
docker compose -f docker-compose.production.yml exec backend python -c "
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('backend/instance/trivia.db')
|
||||||
|
conn.execute(\"UPDATE alembic_version SET version_num = 'YOUR_VALID_REVISION'\")
|
||||||
|
conn.commit()
|
||||||
|
print('Done')
|
||||||
|
"
|
||||||
|
|
||||||
|
# 4. Manually add any missing columns
|
||||||
|
docker compose -f docker-compose.production.yml exec backend python -c "
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('backend/instance/trivia.db')
|
||||||
|
conn.execute('ALTER TABLE tablename ADD COLUMN columnname TYPE')
|
||||||
|
conn.commit()
|
||||||
|
print('Column added')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: "table has no column named X"
|
||||||
|
|
||||||
|
**Cause:** Model has columns that don't exist in the database.
|
||||||
|
|
||||||
|
**Fix:** Add the columns manually:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.production.yml exec backend python -c "
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('backend/instance/trivia.db')
|
||||||
|
cursor = conn.execute('PRAGMA table_info(tablename)')
|
||||||
|
print('Current columns:', [col[1] for col in cursor.fetchall()])
|
||||||
|
"
|
||||||
|
|
||||||
|
# Then add missing columns:
|
||||||
|
docker compose -f docker-compose.production.yml exec backend python -c "
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('backend/instance/trivia.db')
|
||||||
|
conn.execute('ALTER TABLE games ADD COLUMN completed_at DATETIME')
|
||||||
|
conn.execute('ALTER TABLE games ADD COLUMN winners JSON')
|
||||||
|
conn.commit()
|
||||||
|
print('Done')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking current migration state
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# What migration does the DB think it's at?
|
||||||
|
docker compose -f docker-compose.production.yml exec backend uv run flask --app backend.app:create_app db current
|
||||||
|
|
||||||
|
# What's the latest migration in the codebase?
|
||||||
|
docker compose -f docker-compose.production.yml exec backend uv run flask --app backend.app:create_app db heads
|
||||||
|
|
||||||
|
# Show migration history
|
||||||
|
docker compose -f docker-compose.production.yml exec backend uv run flask --app backend.app:create_app db history
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nuclear option: Reset migrations (destroys all data!)
|
||||||
|
|
||||||
|
Only use if you can recreate all data:
|
||||||
|
```bash
|
||||||
|
# Delete database
|
||||||
|
docker compose -f docker-compose.production.yml exec backend rm -f backend/instance/trivia.db
|
||||||
|
|
||||||
|
# Recreate from scratch
|
||||||
|
docker compose -f docker-compose.production.yml exec backend uv run flask --app backend.app:create_app db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
## SQLite Column Type Reference
|
||||||
|
|
||||||
|
When adding columns manually, use these SQLite types:
|
||||||
|
- `INTEGER` - for integers, booleans
|
||||||
|
- `TEXT` or `VARCHAR(N)` - for strings
|
||||||
|
- `DATETIME` - for dates/times
|
||||||
|
- `JSON` - for JSON data
|
||||||
|
- `FLOAT` or `REAL` - for decimals
|
||||||
|
|
||||||
|
## Pre-deployment Checklist
|
||||||
|
|
||||||
|
Before deploying model changes:
|
||||||
|
|
||||||
|
- [ ] Migration file exists in `migrations/versions/`
|
||||||
|
- [ ] Migration file is committed to git
|
||||||
|
- [ ] Migration tested locally with `db upgrade` and `db downgrade`
|
||||||
|
- [ ] Model changes and migration are in the same commit
|
||||||
@@ -113,13 +113,18 @@ class Game(db.Model):
|
|||||||
is_active = db.Column(db.Boolean, default=False) # Only one game active at a time
|
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
|
current_question_index = db.Column(db.Integer, default=0) # Track current question
|
||||||
is_template = db.Column(db.Boolean, default=False) # Mark as reusable template
|
is_template = db.Column(db.Boolean, default=False) # Mark as reusable template
|
||||||
|
current_turn_team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) # Track whose turn it is
|
||||||
|
completed_at = db.Column(db.DateTime, nullable=True) # When game ended
|
||||||
|
winners = db.Column(db.JSON, nullable=True) # [{"team_name": str, "score": int}, ...]
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
teams = db.relationship('Team', back_populates='game', cascade='all, delete-orphan')
|
teams = db.relationship('Team', back_populates='game', cascade='all, delete-orphan',
|
||||||
|
foreign_keys='Team.game_id')
|
||||||
game_questions = db.relationship('GameQuestion', back_populates='game',
|
game_questions = db.relationship('GameQuestion', back_populates='game',
|
||||||
cascade='all, delete-orphan',
|
cascade='all, delete-orphan',
|
||||||
order_by='GameQuestion.order')
|
order_by='GameQuestion.order')
|
||||||
scores = db.relationship('Score', back_populates='game', cascade='all, delete-orphan')
|
scores = db.relationship('Score', back_populates='game', cascade='all, delete-orphan')
|
||||||
|
current_turn_team = db.relationship('Team', foreign_keys=[current_turn_team_id], post_update=True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_active(cls):
|
def get_active(cls):
|
||||||
@@ -141,7 +146,11 @@ class Game(db.Model):
|
|||||||
'is_active': self.is_active,
|
'is_active': self.is_active,
|
||||||
'current_question_index': self.current_question_index,
|
'current_question_index': self.current_question_index,
|
||||||
'total_questions': len(self.game_questions),
|
'total_questions': len(self.game_questions),
|
||||||
'is_template': self.is_template
|
'is_template': self.is_template,
|
||||||
|
'current_turn_team_id': self.current_turn_team_id,
|
||||||
|
'current_turn_team_name': self.current_turn_team.name if self.current_turn_team else None,
|
||||||
|
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
|
||||||
|
'winners': self.winners
|
||||||
}
|
}
|
||||||
|
|
||||||
if include_questions:
|
if include_questions:
|
||||||
@@ -189,7 +198,7 @@ class Team(db.Model):
|
|||||||
phone_a_friend_count = db.Column(db.Integer, default=5) # Number of phone-a-friend lifelines
|
phone_a_friend_count = db.Column(db.Integer, default=5) # Number of phone-a-friend lifelines
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
game = db.relationship('Game', back_populates='teams')
|
game = db.relationship('Game', back_populates='teams', foreign_keys=[game_id])
|
||||||
scores = db.relationship('Score', back_populates='team', cascade='all, delete-orphan')
|
scores = db.relationship('Score', back_populates='team', cascade='all, delete-orphan')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -302,3 +302,25 @@ def seek_audio(game_id):
|
|||||||
return jsonify({'message': f'Audio seeked to {position}s'}), 200
|
return jsonify({'message': f'Audio seeked to {position}s'}), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/game/<int:game_id>/advance-turn', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def advance_turn(game_id):
|
||||||
|
"""Advance to the next team's turn"""
|
||||||
|
game = Game.query.get_or_404(game_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
next_team = game_service.advance_turn(game, socketio)
|
||||||
|
if next_team:
|
||||||
|
return jsonify({
|
||||||
|
'message': 'Turn advanced',
|
||||||
|
'current_turn_team_id': next_team.id,
|
||||||
|
'current_turn_team_name': next_team.name
|
||||||
|
}), 200
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'No teams in game'}), 400
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|||||||
@@ -7,8 +7,65 @@ bp = Blueprint('questions', __name__, url_prefix='/api/questions')
|
|||||||
|
|
||||||
@bp.route('', methods=['GET'])
|
@bp.route('', methods=['GET'])
|
||||||
def list_questions():
|
def list_questions():
|
||||||
"""Get all questions"""
|
"""Get all questions with optional filtering and sorting
|
||||||
questions = Question.query.order_by(Question.created_at.desc()).all()
|
|
||||||
|
Query parameters:
|
||||||
|
- search: Search in question content and answer (case-insensitive)
|
||||||
|
- category: Filter by category name (exact match, or 'none' for uncategorized)
|
||||||
|
- type: Filter by question type (text, image, youtube_audio)
|
||||||
|
- sort_by: Field to sort by (created_at, category, type, question_content, answer)
|
||||||
|
- sort_order: Sort direction (asc, desc) - default: desc
|
||||||
|
"""
|
||||||
|
query = Question.query
|
||||||
|
|
||||||
|
# Search filter
|
||||||
|
search = request.args.get('search', '').strip()
|
||||||
|
if search:
|
||||||
|
search_pattern = f'%{search}%'
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
Question.question_content.ilike(search_pattern),
|
||||||
|
Question.answer.ilike(search_pattern)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Category filter
|
||||||
|
category = request.args.get('category', '').strip()
|
||||||
|
if category:
|
||||||
|
if category.lower() == 'none':
|
||||||
|
query = query.filter(Question.category.is_(None))
|
||||||
|
else:
|
||||||
|
query = query.filter(Question.category == category)
|
||||||
|
|
||||||
|
# Type filter
|
||||||
|
question_type = request.args.get('type', '').strip()
|
||||||
|
if question_type:
|
||||||
|
try:
|
||||||
|
query = query.filter(Question.type == QuestionType(question_type))
|
||||||
|
except ValueError:
|
||||||
|
pass # Invalid type, ignore filter
|
||||||
|
|
||||||
|
# Sorting
|
||||||
|
sort_by = request.args.get('sort_by', 'created_at').strip()
|
||||||
|
sort_order = request.args.get('sort_order', 'desc').strip().lower()
|
||||||
|
|
||||||
|
# Map sort_by to column
|
||||||
|
sort_columns = {
|
||||||
|
'created_at': Question.created_at,
|
||||||
|
'category': Question.category,
|
||||||
|
'type': Question.type,
|
||||||
|
'question_content': Question.question_content,
|
||||||
|
'answer': Question.answer,
|
||||||
|
}
|
||||||
|
|
||||||
|
sort_column = sort_columns.get(sort_by, Question.created_at)
|
||||||
|
|
||||||
|
if sort_order == 'asc':
|
||||||
|
query = query.order_by(sort_column.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(sort_column.desc())
|
||||||
|
|
||||||
|
questions = query.all()
|
||||||
return jsonify([q.to_dict(include_answer=True) for q in questions]), 200
|
return jsonify([q.to_dict(include_answer=True) for q in questions]), 200
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ from backend.models import db, Team
|
|||||||
bp = Blueprint('teams', __name__, url_prefix='/api/teams')
|
bp = Blueprint('teams', __name__, url_prefix='/api/teams')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/past-names', methods=['GET'])
|
||||||
|
def get_past_team_names():
|
||||||
|
"""Get distinct team names from past games"""
|
||||||
|
past_names = db.session.query(Team.name)\
|
||||||
|
.distinct()\
|
||||||
|
.order_by(Team.name)\
|
||||||
|
.all()
|
||||||
|
return jsonify([name[0] for name in past_names]), 200
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<int:team_id>', methods=['DELETE'])
|
@bp.route('/<int:team_id>', methods=['DELETE'])
|
||||||
def delete_team(team_id):
|
def delete_team(team_id):
|
||||||
"""Delete a team"""
|
"""Delete a team"""
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
from backend.models import db, Game, Score
|
from backend.models import db, Game, Score, Team
|
||||||
from flask_socketio import emit
|
from flask_socketio import emit
|
||||||
|
|
||||||
|
|
||||||
|
def get_ordered_teams(game):
|
||||||
|
"""Get teams sorted by ID for consistent turn ordering"""
|
||||||
|
return sorted(game.teams, key=lambda t: t.id)
|
||||||
|
|
||||||
|
|
||||||
def get_game_state(game):
|
def get_game_state(game):
|
||||||
"""Get current game state with all necessary information"""
|
"""Get current game state with all necessary information"""
|
||||||
current_question = game.get_current_question()
|
current_question = game.get_current_question()
|
||||||
@@ -12,7 +17,9 @@ def get_game_state(game):
|
|||||||
'current_question_index': game.current_question_index,
|
'current_question_index': game.current_question_index,
|
||||||
'total_questions': len(game.game_questions),
|
'total_questions': len(game.game_questions),
|
||||||
'is_active': game.is_active,
|
'is_active': game.is_active,
|
||||||
'teams': [team.to_dict() for team in game.teams]
|
'teams': [team.to_dict() for team in game.teams],
|
||||||
|
'current_turn_team_id': game.current_turn_team_id,
|
||||||
|
'current_turn_team_name': game.current_turn_team.name if game.current_turn_team else None
|
||||||
}
|
}
|
||||||
|
|
||||||
if current_question:
|
if current_question:
|
||||||
@@ -42,6 +49,14 @@ def start_game(game, socketio_instance):
|
|||||||
|
|
||||||
game.is_active = True
|
game.is_active = True
|
||||||
game.current_question_index = 0
|
game.current_question_index = 0
|
||||||
|
|
||||||
|
# Set initial turn to the first team (by ID order)
|
||||||
|
ordered_teams = get_ordered_teams(game)
|
||||||
|
if ordered_teams:
|
||||||
|
game.current_turn_team_id = ordered_teams[0].id
|
||||||
|
else:
|
||||||
|
game.current_turn_team_id = None
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Emit game_started event
|
# Emit game_started event
|
||||||
@@ -60,6 +75,9 @@ def start_game(game, socketio_instance):
|
|||||||
# Emit first question
|
# Emit first question
|
||||||
broadcast_question_change(game, socketio_instance)
|
broadcast_question_change(game, socketio_instance)
|
||||||
|
|
||||||
|
# Emit initial turn
|
||||||
|
broadcast_turn_change(game, socketio_instance)
|
||||||
|
|
||||||
|
|
||||||
def next_question(game, socketio_instance):
|
def next_question(game, socketio_instance):
|
||||||
"""Move to next question"""
|
"""Move to next question"""
|
||||||
@@ -185,20 +203,33 @@ def reset_timer(game, socketio_instance):
|
|||||||
|
|
||||||
|
|
||||||
def end_game(game, socketio_instance):
|
def end_game(game, socketio_instance):
|
||||||
"""End/deactivate a game"""
|
"""End/deactivate a game and record winners"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Calculate winners (team(s) with highest score)
|
||||||
|
if game.teams:
|
||||||
|
team_scores = [(team.name, team.total_score) for team in game.teams]
|
||||||
|
if team_scores:
|
||||||
|
max_score = max(score for _, score in team_scores)
|
||||||
|
winners = [
|
||||||
|
{"team_name": name, "score": score}
|
||||||
|
for name, score in team_scores
|
||||||
|
if score == max_score
|
||||||
|
]
|
||||||
|
game.winners = winners
|
||||||
|
|
||||||
game.is_active = False
|
game.is_active = False
|
||||||
|
game.completed_at = datetime.utcnow()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Emit game_ended event to all rooms
|
# Emit game_ended event with winners
|
||||||
socketio_instance.emit('game_ended', {
|
end_data = {
|
||||||
'game_id': game.id,
|
'game_id': game.id,
|
||||||
'game_name': game.name
|
'game_name': game.name,
|
||||||
}, room=f'game_{game.id}_contestant')
|
'winners': game.winners
|
||||||
|
}
|
||||||
socketio_instance.emit('game_ended', {
|
socketio_instance.emit('game_ended', end_data, room=f'game_{game.id}_contestant')
|
||||||
'game_id': game.id,
|
socketio_instance.emit('game_ended', end_data, room=f'game_{game.id}_admin')
|
||||||
'game_name': game.name
|
|
||||||
}, room=f'game_{game.id}_admin')
|
|
||||||
|
|
||||||
|
|
||||||
def restart_game(game, socketio_instance):
|
def restart_game(game, socketio_instance):
|
||||||
@@ -209,6 +240,7 @@ def restart_game(game, socketio_instance):
|
|||||||
# Reset game state
|
# Reset game state
|
||||||
game.is_active = False
|
game.is_active = False
|
||||||
game.current_question_index = 0
|
game.current_question_index = 0
|
||||||
|
game.current_turn_team_id = None # Reset turn
|
||||||
|
|
||||||
# Reset phone-a-friend lifelines for all teams
|
# Reset phone-a-friend lifelines for all teams
|
||||||
for team in game.teams:
|
for team in game.teams:
|
||||||
@@ -283,3 +315,52 @@ def broadcast_audio_seek(game, position, socketio_instance):
|
|||||||
'game_id': game.id,
|
'game_id': game.id,
|
||||||
'position': position
|
'position': position
|
||||||
}, room=f'game_{game.id}_contestant')
|
}, room=f'game_{game.id}_contestant')
|
||||||
|
|
||||||
|
|
||||||
|
def advance_turn(game, socketio_instance):
|
||||||
|
"""Advance to the next team's turn (cycles through teams by ID order)"""
|
||||||
|
ordered_teams = get_ordered_teams(game)
|
||||||
|
|
||||||
|
if not ordered_teams:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if game.current_turn_team_id is None:
|
||||||
|
# No current turn, set to first team
|
||||||
|
next_team = ordered_teams[0]
|
||||||
|
else:
|
||||||
|
# Find current team index and advance to next
|
||||||
|
current_index = None
|
||||||
|
for i, team in enumerate(ordered_teams):
|
||||||
|
if team.id == game.current_turn_team_id:
|
||||||
|
current_index = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if current_index is None:
|
||||||
|
# Current team not found, set to first
|
||||||
|
next_team = ordered_teams[0]
|
||||||
|
else:
|
||||||
|
# Cycle to next team (modulo for wrap-around)
|
||||||
|
next_index = (current_index + 1) % len(ordered_teams)
|
||||||
|
next_team = ordered_teams[next_index]
|
||||||
|
|
||||||
|
game.current_turn_team_id = next_team.id
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Broadcast turn change to both rooms
|
||||||
|
broadcast_turn_change(game, socketio_instance)
|
||||||
|
|
||||||
|
return next_team
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_turn_change(game, socketio_instance):
|
||||||
|
"""Broadcast turn change to all connected clients"""
|
||||||
|
ordered_teams = get_ordered_teams(game)
|
||||||
|
|
||||||
|
turn_data = {
|
||||||
|
'current_turn_team_id': game.current_turn_team_id,
|
||||||
|
'current_turn_team_name': game.current_turn_team.name if game.current_turn_team else None,
|
||||||
|
'all_teams': [{'id': t.id, 'name': t.name} for t in ordered_teams]
|
||||||
|
}
|
||||||
|
|
||||||
|
socketio_instance.emit('turn_changed', turn_data, room=f'game_{game.id}_contestant')
|
||||||
|
socketio_instance.emit('turn_changed', turn_data, room=f'game_{game.id}_admin')
|
||||||
|
|||||||
362
frontend/frontend/src/components/admin/GameAdminView.css
Normal file
362
frontend/frontend/src/components/admin/GameAdminView.css
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
/* GameAdminView Mobile Styles */
|
||||||
|
|
||||||
|
.admin-container {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-bottom: 2px solid #ccc;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h1 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-display {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
min-width: 120px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-card {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 2px solid #2196F3;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e3f2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-text {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-box {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-section h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-team-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-team-input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-active-turn {
|
||||||
|
border: 3px solid #673AB7;
|
||||||
|
background: #EDE7F6;
|
||||||
|
box-shadow: 0 0 10px rgba(103, 58, 183, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-active-turn .team-name {
|
||||||
|
color: #673AB7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-name-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-name {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-buttons button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-points {
|
||||||
|
background: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-minus {
|
||||||
|
background: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lifeline-use {
|
||||||
|
background: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lifeline-use:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lifeline-add {
|
||||||
|
background: #9C27B0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-controls {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-controls h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-indicator {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-button-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-button-grid button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile styles */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-container {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h1 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-main-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-controls {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-display {
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
min-width: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-controls button {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-text {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-buttons {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-buttons button {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-controls {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-button-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-button-grid button {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Very small screens */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admin-container {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h1 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-display {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-buttons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-buttons button {
|
||||||
|
padding: 0.5rem 0.3rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-team-form {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-team-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useSocket } from "../../hooks/useSocket";
|
|||||||
import { adminAPI, gamesAPI } from "../../services/api";
|
import { adminAPI, gamesAPI } from "../../services/api";
|
||||||
import AdminNavbar from "../common/AdminNavbar";
|
import AdminNavbar from "../common/AdminNavbar";
|
||||||
import AudioPlayer from "../audio/AudioPlayer";
|
import AudioPlayer from "../audio/AudioPlayer";
|
||||||
|
import "./GameAdminView.css";
|
||||||
|
|
||||||
export default function GameAdminView() {
|
export default function GameAdminView() {
|
||||||
const { gameId } = useParams();
|
const { gameId } = useParams();
|
||||||
@@ -20,6 +21,8 @@ export default function GameAdminView() {
|
|||||||
const [timerExpired, setTimerExpired] = useState(false);
|
const [timerExpired, setTimerExpired] = useState(false);
|
||||||
const [timerPaused, setTimerPaused] = useState(false);
|
const [timerPaused, setTimerPaused] = useState(false);
|
||||||
const [newTeamName, setNewTeamName] = useState("");
|
const [newTeamName, setNewTeamName] = useState("");
|
||||||
|
const [currentTurnTeamId, setCurrentTurnTeamId] = useState(null);
|
||||||
|
const [currentTurnTeamName, setCurrentTurnTeamName] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadGameState();
|
loadGameState();
|
||||||
@@ -36,6 +39,8 @@ export default function GameAdminView() {
|
|||||||
setCurrentQuestion(response.data.current_question);
|
setCurrentQuestion(response.data.current_question);
|
||||||
setQuestionIndex(response.data.current_question_index);
|
setQuestionIndex(response.data.current_question_index);
|
||||||
setTotalQuestions(response.data.total_questions);
|
setTotalQuestions(response.data.total_questions);
|
||||||
|
setCurrentTurnTeamId(response.data.current_turn_team_id);
|
||||||
|
setCurrentTurnTeamName(response.data.current_turn_team_name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading game state:", error);
|
console.error("Error loading game state:", error);
|
||||||
}
|
}
|
||||||
@@ -76,12 +81,19 @@ export default function GameAdminView() {
|
|||||||
setTimerPaused(false);
|
setTimerPaused(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on("turn_changed", (data) => {
|
||||||
|
console.log("Turn changed:", data);
|
||||||
|
setCurrentTurnTeamId(data.current_turn_team_id);
|
||||||
|
setCurrentTurnTeamName(data.current_turn_team_name);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("question_with_answer");
|
socket.off("question_with_answer");
|
||||||
socket.off("score_updated");
|
socket.off("score_updated");
|
||||||
socket.off("timer_paused");
|
socket.off("timer_paused");
|
||||||
socket.off("lifeline_updated");
|
socket.off("lifeline_updated");
|
||||||
socket.off("timer_reset");
|
socket.off("timer_reset");
|
||||||
|
socket.off("turn_changed");
|
||||||
};
|
};
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
@@ -208,6 +220,15 @@ export default function GameAdminView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAdvanceTurn = async () => {
|
||||||
|
try {
|
||||||
|
await adminAPI.advanceTurn(gameId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error advancing turn:", error);
|
||||||
|
alert("Error advancing turn");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleAnswer = async () => {
|
const handleToggleAnswer = async () => {
|
||||||
const newShowAnswer = !showAnswer;
|
const newShowAnswer = !showAnswer;
|
||||||
try {
|
try {
|
||||||
@@ -261,26 +282,11 @@ export default function GameAdminView() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminNavbar />
|
<AdminNavbar />
|
||||||
<div
|
<div className="admin-container">
|
||||||
style={{
|
|
||||||
padding: "1rem 2rem",
|
|
||||||
maxWidth: "1400px",
|
|
||||||
margin: "0 auto",
|
|
||||||
minHeight: "calc(100vh - 60px)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div className="admin-header">
|
||||||
style={{
|
<h1>Game Admin - {gameState?.game_name}</h1>
|
||||||
marginBottom: "1.5rem",
|
<div className="admin-header-controls">
|
||||||
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>
|
<span>{isConnected ? "● Connected" : "○ Disconnected"}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.open(contestantViewUrl, "_blank")}
|
onClick={() => window.open(contestantViewUrl, "_blank")}
|
||||||
@@ -291,45 +297,25 @@ export default function GameAdminView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="admin-main-grid">
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr 1fr",
|
|
||||||
gap: "2rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Current Question with Answer */}
|
{/* Current Question with Answer */}
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div className="question-header">
|
||||||
style={{
|
<h2>Current Question</h2>
|
||||||
display: "flex",
|
<div className="timer-controls">
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 style={{ margin: 0 }}>Current Question</h2>
|
|
||||||
<div
|
|
||||||
style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
|
className="timer-display"
|
||||||
style={{
|
style={{
|
||||||
padding: "0.75rem 1rem",
|
|
||||||
background: timerExpired
|
background: timerExpired
|
||||||
? "#ffebee"
|
? "#ffebee"
|
||||||
: timerSeconds <= 10
|
: timerSeconds <= 10
|
||||||
? "#fff3e0"
|
? "#fff3e0"
|
||||||
: "#e8f5e9",
|
: "#e8f5e9",
|
||||||
borderRadius: "4px",
|
|
||||||
fontWeight: "bold",
|
|
||||||
fontSize: "1.2rem",
|
|
||||||
color: timerExpired
|
color: timerExpired
|
||||||
? "#c62828"
|
? "#c62828"
|
||||||
: timerSeconds <= 10
|
: timerSeconds <= 10
|
||||||
? "#e65100"
|
? "#e65100"
|
||||||
: "#2e7d32",
|
: "#2e7d32",
|
||||||
minWidth: "120px",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
⏱️ {String(Math.floor(timerSeconds / 60)).padStart(2, "0")}:
|
⏱️ {String(Math.floor(timerSeconds / 60)).padStart(2, "0")}:
|
||||||
@@ -385,14 +371,7 @@ export default function GameAdminView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{currentQuestion ? (
|
{currentQuestion ? (
|
||||||
<div
|
<div className="question-card">
|
||||||
style={{
|
|
||||||
padding: "1.5rem",
|
|
||||||
border: "2px solid #2196F3",
|
|
||||||
borderRadius: "8px",
|
|
||||||
background: "#e3f2fd",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentQuestion.type === "image" &&
|
{currentQuestion.type === "image" &&
|
||||||
currentQuestion.image_path && (
|
currentQuestion.image_path && (
|
||||||
<img
|
<img
|
||||||
@@ -414,23 +393,10 @@ export default function GameAdminView() {
|
|||||||
gameId={gameId}
|
gameId={gameId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p
|
<p className="question-text">
|
||||||
style={{
|
|
||||||
fontSize: "1.3rem",
|
|
||||||
fontWeight: "bold",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentQuestion.question_content}
|
{currentQuestion.question_content}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div className="answer-box">
|
||||||
style={{
|
|
||||||
padding: "1rem",
|
|
||||||
background: "#4CAF50",
|
|
||||||
color: "white",
|
|
||||||
borderRadius: "4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>Answer:</strong> {currentQuestion.answer}
|
<strong>Answer:</strong> {currentQuestion.answer}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,24 +406,16 @@ export default function GameAdminView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Team Scoring */}
|
{/* Team Scoring */}
|
||||||
<div>
|
<div className="team-section">
|
||||||
<h2>Team Scoring</h2>
|
<h2>Team Scoring</h2>
|
||||||
<div
|
<div className="add-team-form">
|
||||||
style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newTeamName}
|
value={newTeamName}
|
||||||
onChange={(e) => setNewTeamName(e.target.value)}
|
onChange={(e) => setNewTeamName(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === "Enter" && handleAddTeam()}
|
onKeyPress={(e) => e.key === "Enter" && handleAddTeam()}
|
||||||
placeholder="Enter team name"
|
placeholder="Enter team name"
|
||||||
style={{
|
className="add-team-input"
|
||||||
padding: "0.5rem",
|
|
||||||
flex: 1,
|
|
||||||
fontSize: "1rem",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "1px solid #ccc",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddTeam}
|
onClick={handleAddTeam}
|
||||||
@@ -477,42 +435,13 @@ export default function GameAdminView() {
|
|||||||
{teams.length === 0 ? (
|
{teams.length === 0 ? (
|
||||||
<p>No teams in this game</p>
|
<p>No teams in this game</p>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="teams-list">
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{teams.map((team) => (
|
{teams.map((team) => (
|
||||||
<div
|
<div key={team.id} className={`team-card ${team.id === currentTurnTeamId ? 'team-card-active-turn' : ''}`}>
|
||||||
key={team.id}
|
<div className="team-card-header">
|
||||||
style={{
|
<div className="team-name-section">
|
||||||
padding: "1rem",
|
<strong className="team-name">{team.name}</strong>
|
||||||
border: "1px solid #ccc",
|
<span>
|
||||||
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({
|
{Array.from({
|
||||||
length: team.phone_a_friend_count || 0,
|
length: team.phone_a_friend_count || 0,
|
||||||
}).map((_, i) => (
|
}).map((_, i) => (
|
||||||
@@ -520,82 +449,46 @@ export default function GameAdminView() {
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span className="team-score">
|
||||||
style={{
|
|
||||||
fontSize: "1.5rem",
|
|
||||||
fontWeight: "bold",
|
|
||||||
color: "#2196F3",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{team.total_score} pts
|
{team.total_score} pts
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="team-buttons">
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: "0.5rem",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[1, 2, 3, 5, 10].map((points) => (
|
{[1, 2, 3, 5, 10].map((points) => (
|
||||||
<button
|
<button
|
||||||
key={points}
|
key={points}
|
||||||
onClick={() => handleAwardPoints(team.id, points)}
|
onClick={() => handleAwardPoints(team.id, points)}
|
||||||
style={{
|
className="btn-points"
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
background: "#4CAF50",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
+{points}
|
+{points}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAwardPoints(team.id, -1)}
|
onClick={() => handleAwardPoints(team.id, -1)}
|
||||||
style={{
|
className="btn-minus"
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
background: "#f44336",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
-1
|
-1
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUseLifeline(team.id)}
|
onClick={() => handleUseLifeline(team.id)}
|
||||||
disabled={team.phone_a_friend_count <= 0}
|
disabled={team.phone_a_friend_count <= 0}
|
||||||
|
className="btn-lifeline-use"
|
||||||
style={{
|
style={{
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
background:
|
background:
|
||||||
team.phone_a_friend_count <= 0 ? "#ccc" : "#ff9800",
|
team.phone_a_friend_count <= 0 ? "#ccc" : "#ff9800",
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor:
|
cursor:
|
||||||
team.phone_a_friend_count <= 0
|
team.phone_a_friend_count <= 0
|
||||||
? "not-allowed"
|
? "not-allowed"
|
||||||
: "pointer",
|
: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
📞 Use Lifeline
|
📞 Use
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAddLifeline(team.id)}
|
onClick={() => handleAddLifeline(team.id)}
|
||||||
style={{
|
className="btn-lifeline-add"
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
background: "#9C27B0",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
📞 Add Lifeline
|
📞 Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -606,57 +499,28 @@ export default function GameAdminView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Game Controls */}
|
{/* Game Controls */}
|
||||||
<div
|
<div className="game-controls">
|
||||||
style={{
|
<h2>Game Controls</h2>
|
||||||
background: "#f5f5f5",
|
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "1.5rem",
|
|
||||||
marginTop: "2rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 style={{ marginTop: 0, marginBottom: "1rem" }}>Game Controls</h2>
|
|
||||||
|
|
||||||
{/* Question indicator and timer */}
|
{/* Question indicator and timer */}
|
||||||
<div
|
<div className="controls-header">
|
||||||
style={{
|
<div className="question-indicator">
|
||||||
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}
|
Question {questionIndex + 1} of {totalQuestions}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="timer-controls">
|
||||||
style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
|
className="timer-display"
|
||||||
style={{
|
style={{
|
||||||
padding: "0.75rem 1rem",
|
|
||||||
background: timerExpired
|
background: timerExpired
|
||||||
? "#ffebee"
|
? "#ffebee"
|
||||||
: timerSeconds <= 10
|
: timerSeconds <= 10
|
||||||
? "#fff3e0"
|
? "#fff3e0"
|
||||||
: "#e8f5e9",
|
: "#e8f5e9",
|
||||||
borderRadius: "4px",
|
|
||||||
fontWeight: "bold",
|
|
||||||
fontSize: "1.2rem",
|
|
||||||
color: timerExpired
|
color: timerExpired
|
||||||
? "#c62828"
|
? "#c62828"
|
||||||
: timerSeconds <= 10
|
: timerSeconds <= 10
|
||||||
? "#e65100"
|
? "#e65100"
|
||||||
: "#2e7d32",
|
: "#2e7d32",
|
||||||
minWidth: "120px",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
⏱️ {String(Math.floor(timerSeconds / 60)).padStart(2, "0")}:
|
⏱️ {String(Math.floor(timerSeconds / 60)).padStart(2, "0")}:
|
||||||
@@ -692,14 +556,25 @@ export default function GameAdminView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Button grid - 2 columns */}
|
{/* Button grid - 2 columns on desktop, 1 on mobile */}
|
||||||
<div
|
<div className="controls-button-grid">
|
||||||
style={{
|
{gameState?.is_active && teams.length > 0 && (
|
||||||
display: "grid",
|
<button
|
||||||
gridTemplateColumns: "1fr 1fr",
|
onClick={handleAdvanceTurn}
|
||||||
gap: "0.75rem",
|
className="btn-next-turn"
|
||||||
}}
|
style={{
|
||||||
>
|
padding: "0.75rem 1.5rem",
|
||||||
|
background: "#673AB7",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next Turn {currentTurnTeamName ? `(${currentTurnTeamName})` : ""}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{currentQuestion && (
|
{currentQuestion && (
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleAnswer}
|
onClick={handleToggleAnswer}
|
||||||
|
|||||||
169
frontend/frontend/src/components/common/AdminNavbar.css
Normal file
169
frontend/frontend/src/components/common/AdminNavbar.css
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/* AdminNavbar Styles */
|
||||||
|
|
||||||
|
.admin-navbar {
|
||||||
|
background: black;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-menu {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-link {
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: white;
|
||||||
|
font-weight: normal;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-link:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-link.active {
|
||||||
|
background: white;
|
||||||
|
color: black;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-link.active:hover {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-user {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 1rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
border-left: 1px solid #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-user-name {
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-logout {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-logout:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile menu button - hidden by default */
|
||||||
|
.navbar-mobile-toggle {
|
||||||
|
display: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile styles */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-navbar {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-mobile-toggle {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: black;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-menu.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-links {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-link {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-user {
|
||||||
|
flex-direction: column;
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid #444;
|
||||||
|
width: 100%;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-user-name {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-logout {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Very small screens */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import "./AdminNavbar.css";
|
||||||
|
|
||||||
export default function AdminNavbar() {
|
export default function AdminNavbar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: "/", label: "Home" },
|
{ path: "/", label: "Home" },
|
||||||
@@ -20,92 +23,45 @@ export default function AdminNavbar() {
|
|||||||
return location.pathname.startsWith(path);
|
return location.pathname.startsWith(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLinkClick = () => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav className="admin-navbar">
|
||||||
style={{
|
<div className="navbar-container">
|
||||||
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" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<span
|
<span className="navbar-brand">Trivia Admin</span>
|
||||||
style={{ fontSize: "1.5rem", fontWeight: "bold", color: "white" }}
|
|
||||||
>
|
|
||||||
🎮 Trivia Admin
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
|
||||||
{navItems.map((item) => (
|
{/* Mobile menu toggle */}
|
||||||
<Link
|
<button
|
||||||
key={item.path}
|
className="navbar-mobile-toggle"
|
||||||
to={item.path}
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
style={{
|
aria-label="Toggle menu"
|
||||||
textDecoration: "none",
|
>
|
||||||
padding: "0.5rem 1rem",
|
{mobileMenuOpen ? "✕" : "☰"}
|
||||||
borderRadius: "8px",
|
</button>
|
||||||
background: isActive(item.path) ? "white" : "transparent",
|
|
||||||
color: isActive(item.path) ? "black" : "white",
|
<div className={`navbar-menu ${mobileMenuOpen ? "open" : ""}`}>
|
||||||
fontWeight: isActive(item.path) ? "bold" : "normal",
|
<div className="navbar-links">
|
||||||
transition: "all 0.2s ease",
|
{navItems.map((item) => (
|
||||||
}}
|
<Link
|
||||||
onMouseEnter={(e) => {
|
key={item.path}
|
||||||
if (!isActive(item.path)) {
|
to={item.path}
|
||||||
e.target.style.background = "#333";
|
className={`navbar-link ${isActive(item.path) ? "active" : ""}`}
|
||||||
}
|
onClick={handleLinkClick}
|
||||||
}}
|
>
|
||||||
onMouseLeave={(e) => {
|
{item.label}
|
||||||
if (!isActive(item.path)) {
|
</Link>
|
||||||
e.target.style.background = "transparent";
|
))}
|
||||||
}
|
</div>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
{/* User info and logout */}
|
{/* User info and logout */}
|
||||||
<div
|
<div className="navbar-user">
|
||||||
style={{
|
<span className="navbar-user-name">
|
||||||
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}
|
{user?.profile?.name || user?.profile?.email}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button onClick={logout} className="navbar-logout">
|
||||||
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
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export default function ContestantView() {
|
|||||||
const [timerActive, setTimerActive] = useState(false);
|
const [timerActive, setTimerActive] = useState(false);
|
||||||
const [timerPaused, setTimerPaused] = useState(false);
|
const [timerPaused, setTimerPaused] = useState(false);
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [currentTurnTeamId, setCurrentTurnTeamId] = useState(null);
|
||||||
|
const [currentTurnTeamName, setCurrentTurnTeamName] = useState(null);
|
||||||
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
|
const [totalQuestions, setTotalQuestions] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load initial game state
|
// Load initial game state
|
||||||
@@ -98,11 +102,14 @@ export default function ContestantView() {
|
|||||||
console.log("Game started:", data);
|
console.log("Game started:", data);
|
||||||
setGameName(data.game_name);
|
setGameName(data.game_name);
|
||||||
setGameStartTime(new Date());
|
setGameStartTime(new Date());
|
||||||
|
setTotalQuestions(data.total_questions);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("question_changed", (data) => {
|
socket.on("question_changed", (data) => {
|
||||||
console.log("Question changed:", data);
|
console.log("Question changed:", data);
|
||||||
setCurrentQuestion(data.question);
|
setCurrentQuestion(data.question);
|
||||||
|
setQuestionIndex(data.question_index);
|
||||||
|
setTotalQuestions(data.total_questions);
|
||||||
setShowAnswer(false); // Hide answer when question changes
|
setShowAnswer(false); // Hide answer when question changes
|
||||||
setAnswer("");
|
setAnswer("");
|
||||||
setTimerSeconds(30);
|
setTimerSeconds(30);
|
||||||
@@ -140,6 +147,8 @@ export default function ContestantView() {
|
|||||||
setTimerSeconds(30);
|
setTimerSeconds(30);
|
||||||
setTimerActive(false);
|
setTimerActive(false);
|
||||||
setTimerPaused(false);
|
setTimerPaused(false);
|
||||||
|
setCurrentTurnTeamId(null);
|
||||||
|
setCurrentTurnTeamName(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("lifeline_updated", (data) => {
|
socket.on("lifeline_updated", (data) => {
|
||||||
@@ -154,6 +163,12 @@ export default function ContestantView() {
|
|||||||
setTimerPaused(false);
|
setTimerPaused(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on("turn_changed", (data) => {
|
||||||
|
console.log("Turn changed:", data);
|
||||||
|
setCurrentTurnTeamId(data.current_turn_team_id);
|
||||||
|
setCurrentTurnTeamName(data.current_turn_team_name);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("game_started");
|
socket.off("game_started");
|
||||||
socket.off("question_changed");
|
socket.off("question_changed");
|
||||||
@@ -163,6 +178,7 @@ export default function ContestantView() {
|
|||||||
socket.off("game_ended");
|
socket.off("game_ended");
|
||||||
socket.off("lifeline_updated");
|
socket.off("lifeline_updated");
|
||||||
socket.off("timer_reset");
|
socket.off("timer_reset");
|
||||||
|
socket.off("turn_changed");
|
||||||
};
|
};
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
@@ -283,6 +299,25 @@ export default function ContestantView() {
|
|||||||
>
|
>
|
||||||
{currentQuestion ? (
|
{currentQuestion ? (
|
||||||
<div style={{ width: "100%", textAlign: "center" }}>
|
<div style={{ width: "100%", textAlign: "center" }}>
|
||||||
|
{/* Turn indicator */}
|
||||||
|
{currentTurnTeamName && (
|
||||||
|
<div
|
||||||
|
className="turn-indicator"
|
||||||
|
style={{
|
||||||
|
fontSize: "2rem",
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
padding: "1rem 2rem",
|
||||||
|
background: "#673AB7",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: "8px",
|
||||||
|
display: "inline-block",
|
||||||
|
animation: "pulse 2s infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentTurnTeamName}'s Turn
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Timer progress bar */}
|
{/* Timer progress bar */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -309,6 +344,17 @@ export default function ContestantView() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{totalQuestions > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
color: "#999",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Question {questionIndex + 1} of {totalQuestions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{currentQuestion.category && (
|
{currentQuestion.category && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -476,6 +522,11 @@ export default function ContestantView() {
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "baseline",
|
alignItems: "baseline",
|
||||||
fontSize: "1.8rem",
|
fontSize: "1.8rem",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: (team.team_id || team.id) === currentTurnTeamId ? "#673AB7" : "transparent",
|
||||||
|
color: (team.team_id || team.id) === currentTurnTeamId ? "white" : "inherit",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { questionsAPI, gamesAPI } from "../../services/api";
|
import { questionsAPI, gamesAPI, teamsAPI } from "../../services/api";
|
||||||
import AdminNavbar from "../common/AdminNavbar";
|
import AdminNavbar from "../common/AdminNavbar";
|
||||||
|
|
||||||
export default function GameSetupView() {
|
export default function GameSetupView() {
|
||||||
@@ -13,11 +13,24 @@ export default function GameSetupView() {
|
|||||||
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
||||||
const [templates, setTemplates] = useState([]);
|
const [templates, setTemplates] = useState([]);
|
||||||
const [randomSelections, setRandomSelections] = useState({});
|
const [randomSelections, setRandomSelections] = useState({});
|
||||||
|
const [pastTeamNames, setPastTeamNames] = useState([]);
|
||||||
|
const [showTeamSuggestions, setShowTeamSuggestions] = useState(false);
|
||||||
|
const teamInputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadQuestions();
|
loadQuestions();
|
||||||
|
loadPastTeamNames();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadPastTeamNames = async () => {
|
||||||
|
try {
|
||||||
|
const response = await teamsAPI.getPastNames();
|
||||||
|
setPastTeamNames(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading past team names:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadQuestions = async () => {
|
const loadQuestions = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await questionsAPI.getAll();
|
const response = await questionsAPI.getAll();
|
||||||
@@ -100,10 +113,12 @@ export default function GameSetupView() {
|
|||||||
setSelectedQuestions((prev) => prev.filter((id) => id !== questionId));
|
setSelectedQuestions((prev) => prev.filter((id) => id !== questionId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addTeam = () => {
|
const addTeam = (name = newTeamName) => {
|
||||||
if (newTeamName.trim()) {
|
const teamName = name.trim();
|
||||||
setTeams([...teams, newTeamName.trim()]);
|
if (teamName && !teams.includes(teamName)) {
|
||||||
|
setTeams([...teams, teamName]);
|
||||||
setNewTeamName("");
|
setNewTeamName("");
|
||||||
|
setShowTeamSuggestions(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,6 +126,18 @@ export default function GameSetupView() {
|
|||||||
setTeams(teams.filter((_, i) => i !== index));
|
setTeams(teams.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filter past team names based on input and exclude already added teams
|
||||||
|
const filteredSuggestions = pastTeamNames.filter(
|
||||||
|
(name) =>
|
||||||
|
name.toLowerCase().includes(newTeamName.toLowerCase()) &&
|
||||||
|
!teams.includes(name)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get quick-add suggestions (past names not yet added to this game)
|
||||||
|
const quickAddSuggestions = pastTeamNames
|
||||||
|
.filter((name) => !teams.includes(name))
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
const updateRandomSelection = (category, count) => {
|
const updateRandomSelection = (category, count) => {
|
||||||
setRandomSelections((prev) => ({
|
setRandomSelections((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -589,20 +616,97 @@ export default function GameSetupView() {
|
|||||||
4. Add Teams
|
4. Add Teams
|
||||||
</h2>
|
</h2>
|
||||||
<div
|
<div
|
||||||
style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}
|
style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem", position: "relative" }}
|
||||||
>
|
>
|
||||||
<input
|
<div style={{ flex: 1, position: "relative" }}>
|
||||||
type="text"
|
<input
|
||||||
value={newTeamName}
|
ref={teamInputRef}
|
||||||
onChange={(e) => setNewTeamName(e.target.value)}
|
type="text"
|
||||||
onKeyPress={(e) => e.key === "Enter" && addTeam()}
|
value={newTeamName}
|
||||||
placeholder="Enter team name"
|
onChange={(e) => {
|
||||||
style={{ padding: "0.5rem", flex: 1 }}
|
setNewTeamName(e.target.value);
|
||||||
/>
|
setShowTeamSuggestions(e.target.value.length > 0);
|
||||||
<button onClick={addTeam} style={{ padding: "0.5rem 1rem" }}>
|
}}
|
||||||
|
onFocus={() => setShowTeamSuggestions(newTeamName.length > 0)}
|
||||||
|
onBlur={() => {
|
||||||
|
// Delay hiding to allow click on suggestion
|
||||||
|
setTimeout(() => setShowTeamSuggestions(false), 200);
|
||||||
|
}}
|
||||||
|
onKeyPress={(e) => e.key === "Enter" && addTeam()}
|
||||||
|
placeholder="Enter team name"
|
||||||
|
style={{ padding: "0.5rem", width: "100%", boxSizing: "border-box" }}
|
||||||
|
/>
|
||||||
|
{showTeamSuggestions && filteredSuggestions.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "100%",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: "white",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: "4px",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||||
|
zIndex: 10,
|
||||||
|
maxHeight: "200px",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredSuggestions.slice(0, 8).map((name) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
onClick={() => addTeam(name)}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderBottom: "1px solid #eee",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.target.style.background = "#f5f5f5")}
|
||||||
|
onMouseLeave={(e) => (e.target.style.background = "white")}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => addTeam()} style={{ padding: "0.5rem 1rem" }}>
|
||||||
Add Team
|
Add Team
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{quickAddSuggestions.length > 0 && (
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<p style={{ margin: "0 0 0.5rem 0", fontSize: "0.9rem", color: "#666" }}>
|
||||||
|
Quick add from past games:
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||||
|
{quickAddSuggestions.map((name) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => addTeam(name)}
|
||||||
|
style={{
|
||||||
|
padding: "0.4rem 0.75rem",
|
||||||
|
background: "#f5f5f5",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "16px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.target.style.background = "#e3f2fd";
|
||||||
|
e.target.style.borderColor = "#2196F3";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.target.style.background = "#f5f5f5";
|
||||||
|
e.target.style.borderColor = "#ddd";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ {name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||||
{teams.map((team, index) => (
|
{teams.map((team, index) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -29,14 +29,29 @@ export default function QuestionBankView() {
|
|||||||
const [downloadJob, setDownloadJob] = useState(null);
|
const [downloadJob, setDownloadJob] = useState(null);
|
||||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
|
|
||||||
|
// Filter and sort state
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [filterCategory, setFilterCategory] = useState("");
|
||||||
|
const [filterType, setFilterType] = useState("");
|
||||||
|
const [sortBy, setSortBy] = useState("created_at");
|
||||||
|
const [sortOrder, setSortOrder] = useState("desc");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadQuestions();
|
loadQuestions();
|
||||||
loadCategories();
|
loadCategories();
|
||||||
}, []);
|
}, [searchTerm, filterCategory, filterType, sortBy, sortOrder]);
|
||||||
|
|
||||||
const loadQuestions = async () => {
|
const loadQuestions = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await questionsAPI.getAll();
|
const params = {
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
};
|
||||||
|
if (searchTerm) params.search = searchTerm;
|
||||||
|
if (filterCategory) params.category = filterCategory;
|
||||||
|
if (filterType) params.type = filterType;
|
||||||
|
|
||||||
|
const response = await questionsAPI.getAll(params);
|
||||||
setQuestions(response.data);
|
setQuestions(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading questions:", error);
|
console.error("Error loading questions:", error);
|
||||||
@@ -295,6 +310,28 @@ export default function QuestionBankView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (column) => {
|
||||||
|
if (sortBy === column) {
|
||||||
|
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||||
|
} else {
|
||||||
|
setSortBy(column);
|
||||||
|
setSortOrder("asc");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortIndicator = ({ column }) => {
|
||||||
|
if (sortBy !== column) return <span style={{ opacity: 0.3, marginLeft: "0.25rem" }}>↕</span>;
|
||||||
|
return <span style={{ marginLeft: "0.25rem" }}>{sortOrder === "asc" ? "↑" : "↓"}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortableHeaderStyle = {
|
||||||
|
padding: "0.75rem",
|
||||||
|
textAlign: "left",
|
||||||
|
borderBottom: "2px solid #ddd",
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminNavbar />
|
<AdminNavbar />
|
||||||
@@ -581,6 +618,101 @@ export default function QuestionBankView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
{/* Filter and Sort Controls */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "1rem",
|
||||||
|
padding: "1rem",
|
||||||
|
background: "#f9f9f9",
|
||||||
|
borderRadius: "8px",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "1rem",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ flex: "1", minWidth: "200px" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search questions or answers..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={filterCategory}
|
||||||
|
onChange={(e) => setFilterCategory(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "4px",
|
||||||
|
minWidth: "150px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
<option value="none">Uncategorized</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.name}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type Filter */}
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={filterType}
|
||||||
|
onChange={(e) => setFilterType(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "4px",
|
||||||
|
minWidth: "130px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="image">Image</option>
|
||||||
|
<option value="youtube_audio">YouTube Audio</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear Filters */}
|
||||||
|
{(searchTerm || filterCategory || filterType || sortBy !== "created_at" || sortOrder !== "desc") && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchTerm("");
|
||||||
|
setFilterCategory("");
|
||||||
|
setFilterType("");
|
||||||
|
setSortBy("created_at");
|
||||||
|
setSortOrder("desc");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
background: "#f44336",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -715,42 +847,38 @@ export default function QuestionBankView() {
|
|||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
|
onClick={() => handleSort("type")}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.75rem",
|
...sortableHeaderStyle,
|
||||||
textAlign: "left",
|
|
||||||
borderBottom: "2px solid #ddd",
|
|
||||||
width: "80px",
|
width: "80px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Type
|
Type
|
||||||
|
<SortIndicator column="type" />
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
|
onClick={() => handleSort("category")}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.75rem",
|
...sortableHeaderStyle,
|
||||||
textAlign: "left",
|
|
||||||
borderBottom: "2px solid #ddd",
|
|
||||||
width: "120px",
|
width: "120px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Category
|
Category
|
||||||
|
<SortIndicator column="category" />
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
style={{
|
onClick={() => handleSort("question_content")}
|
||||||
padding: "0.75rem",
|
style={sortableHeaderStyle}
|
||||||
textAlign: "left",
|
|
||||||
borderBottom: "2px solid #ddd",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Question
|
Question
|
||||||
|
<SortIndicator column="question_content" />
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
style={{
|
onClick={() => handleSort("answer")}
|
||||||
padding: "0.75rem",
|
style={sortableHeaderStyle}
|
||||||
textAlign: "left",
|
|
||||||
borderBottom: "2px solid #ddd",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Answer
|
Answer
|
||||||
|
<SortIndicator column="answer" />
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
color-scheme: light dark;
|
color-scheme: light;
|
||||||
color: rgba(255, 255, 255, 0.87);
|
color: #213547;
|
||||||
background-color: #242424;
|
background-color: #ffffff;
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
@@ -43,7 +43,7 @@ button {
|
|||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background-color: #1a1a1a;
|
background-color: #f9f9f9;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.25s;
|
transition: border-color 0.25s;
|
||||||
}
|
}
|
||||||
@@ -55,15 +55,19 @@ button:focus-visible {
|
|||||||
outline: 4px auto -webkit-focus-ring-color;
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
/* Turn indicator pulse animation */
|
||||||
:root {
|
@keyframes pulse {
|
||||||
color: #213547;
|
0% {
|
||||||
background-color: #ffffff;
|
box-shadow: 0 0 0 0 rgba(103, 58, 183, 0.4);
|
||||||
}
|
}
|
||||||
a:hover {
|
70% {
|
||||||
color: #747bff;
|
box-shadow: 0 0 0 15px rgba(103, 58, 183, 0);
|
||||||
}
|
}
|
||||||
button {
|
100% {
|
||||||
background-color: #f9f9f9;
|
box-shadow: 0 0 0 0 rgba(103, 58, 183, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.turn-indicator {
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ api.interceptors.response.use(
|
|||||||
|
|
||||||
// Questions API
|
// Questions API
|
||||||
export const questionsAPI = {
|
export const questionsAPI = {
|
||||||
getAll: () => api.get("/questions"),
|
getAll: (params = {}) => api.get("/questions", { params }),
|
||||||
getOne: (id) => api.get(`/questions/${id}`),
|
getOne: (id) => api.get(`/questions/${id}`),
|
||||||
create: (data) => api.post("/questions", data),
|
create: (data) => api.post("/questions", data),
|
||||||
createWithImage: (formData) =>
|
createWithImage: (formData) =>
|
||||||
@@ -85,6 +85,7 @@ export const gamesAPI = {
|
|||||||
// Teams API
|
// Teams API
|
||||||
export const teamsAPI = {
|
export const teamsAPI = {
|
||||||
delete: (id) => api.delete(`/teams/${id}`),
|
delete: (id) => api.delete(`/teams/${id}`),
|
||||||
|
getPastNames: () => api.get("/teams/past-names"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Admin API
|
// Admin API
|
||||||
@@ -106,6 +107,7 @@ export const adminAPI = {
|
|||||||
api.post(`/admin/game/${gameId}/team/${teamId}/use-lifeline`),
|
api.post(`/admin/game/${gameId}/team/${teamId}/use-lifeline`),
|
||||||
addLifeline: (gameId, teamId) =>
|
addLifeline: (gameId, teamId) =>
|
||||||
api.post(`/admin/game/${gameId}/team/${teamId}/add-lifeline`),
|
api.post(`/admin/game/${gameId}/team/${teamId}/add-lifeline`),
|
||||||
|
advanceTurn: (id) => api.post(`/admin/game/${id}/advance-turn`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Categories API
|
// Categories API
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Add current_turn_team_id to games
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: 1252454a2589
|
||||||
|
Create Date: 2026-01-18 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'a1b2c3d4e5f6'
|
||||||
|
down_revision = '90b81e097444'
|
||||||
|
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('current_turn_team_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.create_foreign_key('fk_games_current_turn_team_id', 'teams', ['current_turn_team_id'], ['id'])
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('games', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint('fk_games_current_turn_team_id', type_='foreignkey')
|
||||||
|
batch_op.drop_column('current_turn_team_id')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
Reference in New Issue
Block a user