Compare commits

..

5 Commits

Author SHA1 Message Date
ryan
02fcbad9ba Add migrations troubleshooting guide 2026-01-24 10:19:11 -05:00
ryan
d4345a4e09 Adding completed_at and winners to g ames 2026-01-24 09:57:25 -05:00
ryan
e431ba45e9 oops 2026-01-18 16:22:43 -05:00
ryan
96716d95b6 Add mobile-responsive admin view and disable dark mode
Improve mobile experience for admin interface with responsive layouts
that stack on smaller screens, hamburger menu navigation, and force
light mode across the app.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 14:49:03 -05:00
ryan
ab962725e6 Add filtering and sorting to question bank
- Add search, category, and type filters to questions API
- Add clickable sortable table headers with sort indicators
- Add filter controls UI with clear filters button

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 13:01:11 -05:00
16 changed files with 1354 additions and 343 deletions

147
MIGRATIONS.md Normal file
View 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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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={{

View File

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

View File

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

View File

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