Initial Commit

This commit is contained in:
2025-04-02 01:40:37 -03:00
commit d12f6c3c68
91 changed files with 69286 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.conda
.venv
.env
__pycache__
*.pyc
*.log
*.log.*

39
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
ENV/
.env
# IDE files
.idea/
.vscode/
*.swp
*.swo
# Logs
logs/
*.log
# Static files
static/

10
backend/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

25
backend/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Music Guesser Game
A web-based music guessing game where players try to identify songs from short previews and blurred covers.
## Backend Setup
1. Install the required dependencies:
```
pip install -r requirements.txt
```
2. Run the FastAPI server:
```
uvicorn app.main:app --reload
```
3. Access the API documentation at `http://localhost:8000/docs`
## Game Rules
- Random songs (5, 10, or 25) are selected from the database
- A preview of the song will play and a blurred cover will be displayed
- Players must choose the correct song name from 6 options
- Players have 15 seconds to make their choice
- No login required - anyone can play

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Main app package

64
backend/app/main.py Normal file
View File

@@ -0,0 +1,64 @@
import os
from pathlib import Path
from app.routes import game_routes, playlist_routes, preview_routes, song_routes, stats_routes
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
# Create the FastAPI app
app = FastAPI(
title="Music Guesser API",
description="API for the Music Guesser game",
version="1.0.0"
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
static_dir = Path("static")
static_dir.mkdir(exist_ok=True)
# Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static")
# Include routers
app.include_router(game_routes.router)
app.include_router(song_routes.router)
app.include_router(preview_routes.router)
app.include_router(playlist_routes.router)
app.include_router(stats_routes.router)
@app.get("/")
async def root():
"""Root endpoint that returns basic API information."""
return {
"message": "Music Guesser API",
"docs": "/docs",
"version": "1.0.0"
}
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "ok"}
# Handle 404 errors
@app.exception_handler(404)
async def not_found_exception_handler(request, exc):
return {
"detail": "The requested resource was not found",
"path": str(request.url)
}
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)

View File

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

View File

@@ -0,0 +1,80 @@
import uuid
from typing import Dict, List, Optional
from pydantic import BaseModel
class GameSettings(BaseModel):
num_songs: int = 5 # 5, 10, or 25 songs
num_choices: int = 6 # Number of song choices to present
genres: Optional[List[str]] = None # List of genres to filter songs by
playlist_id: Optional[str] = None # ID of a predefined playlist
start_year: Optional[int] = None # Start year for filtering
end_year: Optional[int] = None # End year for filtering
class GameOption(BaseModel):
song_id: int
name: str
is_correct: bool
class GameQuestion(BaseModel):
song_id: int
preview_url: str
blurred_cover_url: str
clear_cover_url: str = "" # Original unblurred cover URL
correct_option_index: int
options: List[GameOption]
time_limit: int = 15 # seconds
song_color: str = "" # Add song color for theming
artists: str = "" # Artists of the song
class GameSession(BaseModel):
session_id: str
questions: List[GameQuestion]
current_question: int = 0
score: int = 0
total_questions: int
started_at: float # Unix timestamp
@classmethod
def create(cls, questions: List[GameQuestion]):
return cls(
session_id=str(uuid.uuid4()),
questions=questions,
total_questions=len(questions),
started_at=0 # Will be set when the game starts
)
class GameResponse(BaseModel):
session_id: str
current_question: int
total_questions: int
question: GameQuestion
score: int
time_remaining: Optional[int] = None
class AnswerRequest(BaseModel):
session_id: str
question_index: int
selected_option_index: int
class AnswerResponse(BaseModel):
correct: bool
correct_option_index: int
score: int
next_question_index: Optional[int] = None
game_complete: bool = False
points_earned: int = 0
class GameSummary(BaseModel):
session_id: str
score: int
total_questions: int
accuracy: float # Percentage correct

View File

@@ -0,0 +1,71 @@
from typing import Any, Dict, List, Optional, Tuple
from pydantic import BaseModel
class Contributor(BaseModel):
id: int
name: str
role: str
class Song(BaseModel):
SongId: int
Name: str
Artists: str
Color: str
DarkColor: str
SongMetaId: Optional[Any] = None
SpotifyId: Optional[str] = None
DeezerID: Optional[int] = None
DeezerURL: Optional[str] = None
CoverSmall: Optional[str] = None
CoverMedium: Optional[str] = None
CoverBig: Optional[str] = None
CoverXL: Optional[str] = None
ISRC: Optional[str] = None
BPM: Optional[float] = 0
Duration: Optional[int] = 0
ReleaseDate: Optional[str] = None
AlbumName: Optional[str] = None
Explicit: Optional[bool] = False
Rank: Optional[int] = None
Tags: Optional[List[str]] = None
Contributors: Optional[List[Contributor]] = None
AlbumGenres: Optional[List[str]] = None
class Playlist(BaseModel):
"""A predefined playlist with specific filters for song selection."""
id: str
name: str
description: str
genres: Optional[List[str]] = None
start_year: Optional[int] = None
end_year: Optional[int] = None
cover_image: Optional[str] = None
class Artist(BaseModel):
ArtistId: int
Name: str
HasPublicSongs: Optional[bool] = None
SongId: Optional[int] = None
Color: Optional[str] = None
DarkColor: Optional[str] = None
DeezerID: Optional[int] = None
DeezerURL: Optional[str] = None
PictureSmall: Optional[str] = None
PictureMedium: Optional[str] = None
PictureBig: Optional[str] = None
PictureXL: Optional[str] = None
NbAlbums: Optional[int] = None
NbFans: Optional[int] = None
Radio: Optional[bool] = None
TopGenres: Optional[List[str]] = None
Rank: Optional[Any] = None
class SongsData(BaseModel):
Songs: List[Song]
Artists: Optional[List[Artist]] = None

View File

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

View File

@@ -0,0 +1,73 @@
from typing import List, Optional
from app.models.game import AnswerRequest, AnswerResponse, GameResponse, GameSession, GameSettings, GameSummary
from app.services.game_service import game_service
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
router = APIRouter(
prefix="/api/game",
tags=["game"]
)
@router.post("/create", response_model=GameSession)
async def create_game(settings: GameSettings):
"""Create a new game session with the specified settings."""
try:
return await game_service.create_game(settings)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to create game: {str(e)}")
@router.post("/start/{session_id}", response_model=GameResponse)
async def start_game(session_id: str, background_tasks: BackgroundTasks):
"""Start a game session and get the first question."""
# Start the game
session = game_service.start_game(session_id)
if not session:
raise HTTPException(status_code=404, detail="Game session not found")
# Schedule cleanup of old sessions
background_tasks.add_task(game_service.cleanup_old_sessions)
# Get the game response
response = game_service.get_game_response(session_id)
if not response:
raise HTTPException(status_code=404, detail="Failed to get game state")
return response
@router.get("/state/{session_id}", response_model=GameResponse)
async def get_game_state(session_id: str):
"""Get the current state of a game session."""
response = game_service.get_game_response(session_id)
if not response:
raise HTTPException(status_code=404, detail="Game session not found or invalid state")
return response
@router.post("/answer", response_model=AnswerResponse)
async def answer_question(answer: AnswerRequest):
"""Submit an answer to the current question."""
response = game_service.answer_question(
answer.session_id,
answer.question_index,
answer.selected_option_index
)
if not response:
raise HTTPException(status_code=404, detail="Game session not found or invalid answer")
return response
@router.get("/summary/{session_id}", response_model=GameSummary)
async def get_game_summary(session_id: str):
"""Get a summary of a completed game."""
summary = game_service.get_game_summary(session_id)
if not summary:
raise HTTPException(status_code=404, detail="Game session not found")
return summary

View File

@@ -0,0 +1,26 @@
"""Routes for playlist operations."""
from typing import List
from app.models.song import Playlist
from app.services.playlist_service import playlist_service
from fastapi import APIRouter, HTTPException
router = APIRouter(
prefix="/api/playlists",
tags=["playlists"]
)
@router.get("", response_model=List[Playlist])
async def get_playlists():
"""Get all available predefined playlists."""
return playlist_service.get_all_playlists()
@router.get("/{playlist_id}", response_model=Playlist)
async def get_playlist(playlist_id: str):
"""Get a playlist by its ID."""
playlist = playlist_service.get_playlist_by_id(playlist_id)
if not playlist:
raise HTTPException(status_code=404, detail="Playlist not found")
return playlist

View File

@@ -0,0 +1,45 @@
from typing import Dict
from app.services.song_service import song_service
from fastapi import APIRouter, HTTPException
router = APIRouter(
prefix="/api/preview",
tags=["preview"]
)
@router.get("/audio/{song_id}")
async def get_audio_preview(song_id: int):
"""Get the audio preview URL for a song."""
song = song_service.get_song_by_id(song_id)
if not song:
raise HTTPException(status_code=404, detail="Song not found")
preview_url = await song_service.get_deezer_preview_url(song)
if not preview_url:
raise HTTPException(status_code=404, detail="Preview not available for this song")
# Return the preview URL
return {"preview_url": preview_url}
@router.get("/cover/{song_id}")
async def get_blurred_cover(song_id: int, blur_level: int = 10):
"""Get a blurred version of the song cover image."""
song = song_service.get_song_by_id(song_id)
if not song:
raise HTTPException(status_code=404, detail="Song not found")
# Get the original cover
original_cover = song.CoverMedium or song.CoverBig or song.CoverXL or song.CoverSmall
# Get the blurred URL
blurred_url = original_cover
# Return the blurred cover URL and the original URL
return {
"blurred_url": blurred_url,
"original_url": original_cover,
"blur_level": blur_level
}

View File

@@ -0,0 +1,46 @@
from typing import List, Optional
from app.models.song import Song
from app.services.song_service import song_service
from fastapi import APIRouter, HTTPException, Query
router = APIRouter(
prefix="/api/songs",
tags=["songs"]
)
@router.get("", response_model=List[dict])
async def get_songs(limit: int = 10, offset: int = 0):
"""Get a paginated list of songs."""
songs = song_service.songs[offset:offset + limit]
return [song.dict() for song in songs]
@router.get("/count", response_model=int)
async def get_song_count():
"""Get the total number of songs."""
return song_service.get_total_song_count()
@router.get("/genres", response_model=List[str])
async def get_available_genres():
"""Get a list of available genres that have at least 30 songs."""
return song_service.get_available_genres()
@router.get("/random", response_model=List[dict])
async def get_random_songs(count: int = 10):
"""Get a list of random songs."""
count = min(count, 50) # Limit to 50 songs max
songs = song_service.get_random_songs(count)
return [song.dict() for song in songs]
@router.get("/{song_id}", response_model=dict)
async def get_song(song_id: int):
"""Get a song by ID."""
song = song_service.get_song_by_id(song_id)
if not song:
raise HTTPException(status_code=404, detail="Song not found")
return song.dict()

View File

@@ -0,0 +1,35 @@
"""Routes for song statistics and analytics."""
from typing import Dict, List
from app.services.song_service import song_service
from fastapi import APIRouter, HTTPException
router = APIRouter(
prefix="/api/stats",
tags=["stats"]
)
@router.get("/years")
async def get_available_years():
"""Get a list of years that have songs in the database, suitable for filtering."""
years = song_service.get_available_years()
return {
"min_year": min(years) if years else None,
"max_year": max(years) if years else None,
"years": sorted(years)
}
@router.get("/decade-counts")
async def get_decade_counts():
"""Get counts of songs by decade."""
decades = song_service.get_decade_counts()
return decades
@router.get("/genre-distribution")
async def get_genre_distribution():
"""Get distribution of songs by genre."""
genre_distribution = song_service.get_genre_distribution()
return genre_distribution

View File

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

View File

@@ -0,0 +1,293 @@
import random
import time
import uuid
from typing import Dict, List, Optional, Tuple
from app.models.game import (
AnswerResponse,
GameOption,
GameQuestion,
GameResponse,
GameSession,
GameSettings,
GameSummary,
)
from app.models.song import Song
from app.services.playlist_service import playlist_service
from app.services.song_service import song_service
class GameService:
def __init__(self):
# Store active game sessions
self.active_sessions: Dict[str, GameSession] = {}
async def create_game(self, settings: GameSettings) -> GameSession:
"""Create a new game session with the specified settings."""
# Override settings to ensure exactly 4 questions
settings.num_songs = 4
settings.num_choices = 4
# Process playlist if specified
genres = settings.genres
start_year = settings.start_year
end_year = settings.end_year
if settings.playlist_id:
playlist = playlist_service.get_playlist_by_id(settings.playlist_id)
if playlist:
genres = playlist.genres or genres
start_year = playlist.start_year or start_year
end_year = playlist.end_year or end_year
else:
print(f"Playlist not found: {settings.playlist_id}")
# Get random songs for the game, filtered by criteria
game_songs = song_service.get_random_songs(
settings.num_songs,
genres=genres,
start_year=start_year,
end_year=end_year
)
# Ensure we have at least one song
if not game_songs:
# Return songs without filtering if no songs match the criteria
game_songs = song_service.get_random_songs(settings.num_songs)
# Create game questions
questions = []
for song in game_songs:
# Get choices for this question, using the same filters
choices = song_service.get_random_song_choices(
song,
settings.num_choices,
genres=genres,
start_year=start_year,
end_year=end_year
)
# Make sure we have exactly 4 different song options
# First, ensure the correct song is in the list
if song not in choices:
choices.append(song)
# Filter out duplicates by name
unique_choices = []
seen_names = set()
for choice in choices:
if choice.Name not in seen_names:
seen_names.add(choice.Name)
unique_choices.append(choice)
# If we don't have enough unique options after filtering, add more random songs
while len(unique_choices) < settings.num_choices:
additional_songs = song_service.get_random_songs(
settings.num_choices - len(unique_choices),
genres=genres,
start_year=start_year,
end_year=end_year
)
for additional_song in additional_songs:
if additional_song.Name not in seen_names and additional_song.SongId != song.SongId:
seen_names.add(additional_song.Name)
unique_choices.append(additional_song)
# Break if we can't find any more unique songs
if len(unique_choices) < settings.num_choices:
additional_songs = song_service.get_random_songs(
settings.num_choices - len(unique_choices)
)
for additional_song in additional_songs:
if additional_song.Name not in seen_names and additional_song.SongId != song.SongId:
seen_names.add(additional_song.Name)
unique_choices.append(additional_song)
# Ensure we have exactly 4 options
choices = unique_choices[:settings.num_choices]
# Make sure the correct song is still in the list
if song not in choices:
choices[-1] = song
# Find the index of the correct option
correct_index = next(i for i, s in enumerate(choices) if s.SongId == song.SongId)
# Create option objects
options = [
GameOption(
song_id=s.SongId,
name=s.Name,
is_correct=(s.SongId == song.SongId)
) for s in choices
]
# Create the question
preview_url = await song_service.get_deezer_preview_url(song)
question = GameQuestion(
song_id=song.SongId,
preview_url=preview_url,
blurred_cover_url=song.CoverMedium or "",
clear_cover_url=song.CoverBig or song.CoverXL or song.CoverMedium or "", # Use best available cover
correct_option_index=correct_index,
options=options,
song_color=song.DarkColor or song.Color, # Prefer DarkColor when available
artists=song.Artists # Add the artists field from the song
)
questions.append(question)
# Create the game session
session = GameSession.create(questions)
# Store the session
self.active_sessions[session.session_id] = session
return session
def start_game(self, session_id: str) -> Optional[GameSession]:
"""Start the game by setting the start time."""
if session_id not in self.active_sessions:
return None
session = self.active_sessions[session_id]
session.started_at = time.time()
return session
def get_game_response(self, session_id: str) -> Optional[GameResponse]:
"""Get the current game state as a response object."""
if session_id not in self.active_sessions:
return None
session = self.active_sessions[session_id]
# Check if we have a valid current question
if session.current_question >= len(session.questions):
return None
question = session.questions[session.current_question]
# Calculate time remaining if game has started
time_remaining = None
if session.started_at > 0:
elapsed = time.time() - session.started_at
question_time = session.current_question * question.time_limit
current_question_elapsed = elapsed - question_time
if current_question_elapsed < question.time_limit:
time_remaining = int(question.time_limit - current_question_elapsed)
else:
time_remaining = 0
return GameResponse(
session_id=session.session_id,
current_question=session.current_question,
total_questions=session.total_questions,
question=question,
score=session.score,
time_remaining=time_remaining
)
def answer_question(self, session_id: str, question_index: int, selected_option_index: int) -> Optional[AnswerResponse]:
"""Process a player's answer to a question."""
if session_id not in self.active_sessions:
return None
session = self.active_sessions[session_id]
# Validate question index
if question_index < 0 or question_index >= len(session.questions):
return None
# Check if this is the current question
if question_index != session.current_question:
return None
question = session.questions[question_index]
# Special case for timeouts: -1 option index means timeout
is_correct = False
if selected_option_index == -1:
# Timeout - always incorrect
is_correct = False
else:
# Check if the selected option is valid
if selected_option_index < 0 or selected_option_index >= len(question.options):
return None
# Check if the answer is correct
is_correct = question.correct_option_index == selected_option_index
# Update score if correct - add time bonus based on remaining time
time_remaining = 0
if session.started_at > 0:
elapsed = time.time() - session.started_at
question_time = session.current_question * question.time_limit
current_question_elapsed = elapsed - question_time
time_remaining = max(0, question.time_limit - current_question_elapsed)
# Calculate points: 10 base points + time bonus if correct
points = 0
if is_correct:
# Base points (10) + time bonus (up to 5 more points based on time)
points = 10 + int(time_remaining / question.time_limit * 5)
session.score += points
# Move to the next question
session.current_question += 1
game_complete = session.current_question >= session.total_questions
# Prepare the response
next_question = None if game_complete else session.current_question
return AnswerResponse(
correct=is_correct,
correct_option_index=question.correct_option_index,
score=session.score,
next_question_index=next_question,
game_complete=game_complete,
points_earned=points
)
def get_game_summary(self, session_id: str) -> Optional[GameSummary]:
"""Get a summary of the completed game."""
if session_id not in self.active_sessions:
return None
session = self.active_sessions[session_id]
# Calculate accuracy
accuracy = (session.score / session.total_questions) * 100 if session.total_questions > 0 else 0
return GameSummary(
session_id=session.session_id,
score=session.score,
total_questions=session.total_questions,
accuracy=accuracy
)
def cleanup_old_sessions(self, max_age_seconds: int = 3600) -> None:
"""Remove old game sessions to free up memory."""
current_time = time.time()
to_remove = []
for session_id, session in self.active_sessions.items():
# Skip sessions that haven't started
if session.started_at == 0:
continue
# Check if the session is too old
age = current_time - session.started_at
if age > max_age_seconds:
to_remove.append(session_id)
# Remove old sessions
for session_id in to_remove:
del self.active_sessions[session_id]
# Create a global instance of the game service
game_service = GameService()

View File

@@ -0,0 +1,64 @@
"""Service for managing predefined playlists."""
from typing import Dict, List, Optional
from app.models.song import Playlist
# Predefined playlists
PLAYLISTS: List[Playlist] = [
Playlist(
id="pop-2010s",
name="Best Pop 2010s",
description="The most popular pop hits from the 2010s decade",
genres=["Pop"],
start_year=2010,
end_year=2019,
),
Playlist(
id="old-school-rap",
name="Old School Rap",
description="Classic rap hits from the 80s and 90s",
genres=["Rap/Hip Hop"],
start_year=1980,
end_year=1999,
),
Playlist(
id="latest-hits",
name="Latest Hits",
description="The newest songs from the past year",
start_year=2023,
),
Playlist(
id="rock-classics",
name="Rock Classics",
description="Timeless rock anthems from the 70s to 90s",
genres=["Rock"],
start_year=1970,
end_year=1999,
),
Playlist(
id="2000s-nostalgia",
name="2000s Nostalgia",
description="Hits that defined the 2000s",
start_year=2000,
end_year=2009,
),
]
class PlaylistService:
"""Service for managing predefined playlists."""
def __init__(self):
self.playlists: Dict[str, Playlist] = {p.id: p for p in PLAYLISTS}
def get_all_playlists(self) -> List[Playlist]:
"""Get all available playlists."""
return list(self.playlists.values())
def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]:
"""Get a playlist by its ID."""
return self.playlists.get(playlist_id)
# Create a global instance of the playlist service
playlist_service = PlaylistService()

View File

@@ -0,0 +1,294 @@
import json
import random
import re
from collections import defaultdict
from math import floor
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
import httpx
from app.models.song import Song, SongsData
from fastapi import HTTPException
class SongService:
def __init__(self):
self.songs_data: Optional[SongsData] = None
self.songs: List[Song] = []
self.genres: Dict[str, List[Song]] = {} # Map of genre to songs with that genre
self.available_genres: List[str] = [] # Genres with at least 30 songs
self._load_songs()
def _load_songs(self) -> None:
"""Load songs data from the JSON file and index genres."""
try:
json_path = Path("songs.json")
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.songs_data = SongsData(**data)
self.songs = self.songs_data.Songs
# Index genres
self._index_genres()
print(f"Loaded {len(self.songs)} songs successfully")
print(f"Found {len(self.available_genres)} genres with at least 30 songs")
except Exception as e:
print(f"Error loading songs: {e}")
# Initialize with empty list to avoid crashes
self.songs = []
self.genres = {}
self.available_genres = []
def _index_genres(self) -> None:
"""Index all songs by genre and find available genres with at least 30 songs."""
self.genres = {}
genre_count: Dict[str, int] = {}
# Process each song
for song in self.songs:
# Use AlbumGenres field if available, or Tags as fallback
song_genres = song.AlbumGenres or song.Tags or []
# Add song to each of its genres
for genre in song_genres:
if genre not in self.genres:
self.genres[genre] = []
self.genres[genre].append(song)
# Track genre count
genre_count[genre] = genre_count.get(genre, 0) + 1
# Find genres with at least 30 songs
self.available_genres = [
genre for genre, count in genre_count.items()
if count >= 30
]
# Sort genres alphabetically
self.available_genres.sort()
def get_song_by_id(self, song_id: int) -> Optional[Song]:
"""Get a song by its ID."""
for song in self.songs:
if song.SongId == song_id:
return song
return None
def get_song_release_year(self, song: Song) -> Optional[int]:
"""Extract the release year from a song.
Tries to get it from the ReleaseDate field first, then from Tags.
"""
# Try to get year from ReleaseDate (format: YYYY-MM-DD)
if song.ReleaseDate and len(song.ReleaseDate) >= 4:
try:
return int(song.ReleaseDate[:4])
except ValueError:
pass
# Try to get year from Tags (format: "year:YYYY")
if song.Tags:
for tag in song.Tags:
if tag.startswith("year:"):
try:
return int(tag[5:])
except ValueError:
pass
return None
def filter_songs_by_criteria(
self,
genres: Optional[List[str]] = None,
start_year: Optional[int] = None,
end_year: Optional[int] = None
) -> List[Song]:
"""Filter songs based on multiple criteria."""
filtered_songs = self.songs.copy()
# Filter by genres if specified
if genres:
genre_song_ids = set()
for genre in genres:
if genre in self.genres:
for song in self.genres[genre]:
genre_song_ids.add(song.SongId)
filtered_songs = [song for song in filtered_songs if song.SongId in genre_song_ids]
# Filter by year range if specified
if start_year or end_year:
year_filtered = []
for song in filtered_songs:
year = self.get_song_release_year(song)
if year:
if start_year and end_year:
if start_year <= year <= end_year:
year_filtered.append(song)
elif start_year:
if year >= start_year:
year_filtered.append(song)
elif end_year:
if year <= end_year:
year_filtered.append(song)
filtered_songs = year_filtered
return filtered_songs
def get_random_songs(
self,
count: int,
genres: Optional[List[str]] = None,
start_year: Optional[int] = None,
end_year: Optional[int] = None
) -> List[Song]:
"""Get a random sample of songs, optionally filtered by genres and/or year range."""
# Filter songs based on criteria
filtered_songs = self.filter_songs_by_criteria(genres, start_year, end_year)
# If we don't have enough songs after filtering, return all we have
if count >= len(filtered_songs):
return filtered_songs
return random.sample(filtered_songs, count)
def get_random_song_choices(
self,
correct_song: Song,
num_choices: int,
genres: Optional[List[str]] = None,
start_year: Optional[int] = None,
end_year: Optional[int] = None
) -> List[Song]:
"""Get a list of random song choices including the correct song, optionally filtered."""
# Get the filtered song pool
song_pool = self.filter_songs_by_criteria(genres, start_year, end_year)
# Remove the correct song from the pool for now
other_songs = [s for s in song_pool if s.SongId != correct_song.SongId]
# Make sure we have enough songs for choices
if len(other_songs) < num_choices - 1:
# Get more songs from the general pool
additional_songs = [s for s in self.songs if s.SongId != correct_song.SongId and s not in other_songs]
other_songs.extend(additional_songs)
# Filter songs to ensure unique names
unique_other_songs = []
seen_names = set()
# First add the correct song name to seen_names
seen_names.add(correct_song.Name)
# Shuffle to get random candidates
random.shuffle(other_songs)
# Select songs with unique names
for song in other_songs:
if song.Name not in seen_names:
seen_names.add(song.Name)
unique_other_songs.append(song)
# Break when we have enough choices
if len(unique_other_songs) >= num_choices - 1:
break
# If we still don't have enough unique names, try again with the full song list
if len(unique_other_songs) < num_choices - 1:
remaining_songs = [s for s in self.songs if s.SongId != correct_song.SongId and s.Name not in seen_names]
random.shuffle(remaining_songs)
for song in remaining_songs:
if song.Name not in seen_names:
seen_names.add(song.Name)
unique_other_songs.append(song)
# Break when we have enough choices
if len(unique_other_songs) >= num_choices - 1:
break
# Get the choices we have
wrong_choices = unique_other_songs[:num_choices - 1]
# Add the correct song
choices = wrong_choices + [correct_song]
# Verify all song names are unique
choice_names = [s.Name for s in choices]
if len(set(choice_names)) != len(choices):
print("WARNING: Duplicate song names in choices!")
# Shuffle the choices
random.shuffle(choices)
return choices
def get_available_genres(self) -> List[str]:
"""Get the list of available genres (those with at least 30 songs)."""
return self.available_genres
async def get_deezer_preview_url(self, song: Song) -> str:
"""Get the Deezer preview URL for a song."""
if not song.DeezerID:
return ""
async with httpx.AsyncClient() as client:
try:
response = await client.get(f"https://api.deezer.com/track/{song.DeezerID}")
if response.status_code != 200:
return ""
data = response.json()
preview_url = data.get("preview")
if not preview_url:
return ""
return preview_url
except Exception as e:
print(f"Error fetching preview URL: {e}")
return ""
def get_total_song_count(self) -> int:
"""Get the total number of songs in the database."""
return len(self.songs)
def get_available_years(self) -> Set[int]:
"""Get a set of all years that have at least one song."""
years = set()
for song in self.songs:
year = self.get_song_release_year(song)
if year:
years.add(year)
return years
def get_decade_counts(self) -> Dict[str, int]:
"""Get counts of songs by decade."""
decade_counts = defaultdict(int)
for song in self.songs:
year = self.get_song_release_year(song)
if year:
# Calculate the decade (e.g., 1980s, 1990s, etc.)
decade = floor(year / 10) * 10
decade_counts[f"{decade}s"] += 1
# Sort by decade
sorted_decades = dict(sorted(decade_counts.items(),
key=lambda item: int(item[0][:-1])))
return sorted_decades
def get_genre_distribution(self) -> Dict[str, int]:
"""Get distribution of songs by genre."""
genre_distribution = defaultdict(int)
for genre, songs in self.genres.items():
genre_distribution[genre] = len(songs)
# Sort by count descending
sorted_genres = dict(sorted(genre_distribution.items(),
key=lambda item: item[1],
reverse=True))
return sorted_genres
# Create a global instance of the song service
song_service = SongService()

View File

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

View File

@@ -0,0 +1,21 @@
from typing import Optional
def get_blurred_image_url(original_url: Optional[str], blur_level: int = 10) -> str:
"""
Generate a URL for a blurred version of the image.
In a production environment, this would typically:
1. Download the image
2. Apply a blur filter
3. Save the blurred image
4. Return a URL to the blurred image
For this implementation, we'll assume a frontend solution where
the blur is applied via CSS, and we'll just return the original URL.
"""
if not original_url:
# Return a default image URL if the original is None
return "/static/default-cover.jpg"
return original_url

8
backend/requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
fastapi==0.104.1
uvicorn==0.23.2
pydantic==2.4.2
python-dotenv==1.0.0
httpx==0.25.0
python-multipart==0.0.6
pytest==7.4.0
pytest-asyncio==0.21.1

5
backend/run.py Normal file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env python3
import uvicorn
if __name__ == "__main__":
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)

58120
backend/songs.json Normal file

File diff suppressed because it is too large Load Diff

12
docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
services:
frontend:
container_name: frontend
build: ./frontend
ports:
- "8083:80"
depends_on:
- backend
backend:
container_name: backend
build: ./backend

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

31
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built assets
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

54
frontend/README.md Normal file
View File

@@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

28
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Test your music knowledge!" />
<meta name="theme-color" content="#000000" />
<title>Ovo Quiz</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

42
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,42 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# API proxy
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Handle React router
location / {
try_files $uri $uri/ /index.html;
}
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 10240;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml;
gzip_disable "MSIE [1-6]\.";
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# Cache control for static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg)$ {
expires 7d;
add_header Cache-Control "public, no-transform";
}
}

4078
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
frontend/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/inter": "^5.2.5",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"axios": "^1.8.4",
"framer-motion": "^12.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.4.0",
"sass": "^1.86.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.2.0"
}
}

10
frontend/public/logo.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="50" fill="url(#paint0_linear)" />
<path d="M65 30L45 35V70C45 70 45 75 40 75C35 75 35 70 35 70C35 65 40 65 40 65H45V50L30 53V75C30 75 30 80 25 80C20 80 20 75 20 75C20 70 25 70 25 70H30V45L65 35V30Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear" x1="0" y1="0" x2="100" y2="100" gradientUnits="userSpaceOnUse">
<stop stop-color="#30B5FF"/>
<stop offset="1" stop-color="#7A4BFF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 576 B

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

21
frontend/src/App.scss Normal file
View File

@@ -0,0 +1,21 @@
@use 'styles/variables.scss';
@use 'styles/mixins.scss';
@use 'styles/reset.scss';
@use 'styles/global.scss';
.app-container {
width: 100%;
height: 100%;
padding: variables.$spacing-md;
display: flex;
justify-content: center;
align-items: center;
@include mixins.tablet {
padding: variables.$spacing-lg;
}
@include mixins.desktop {
padding: variables.$spacing-xl;
}
}

50
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,50 @@
import React, { useState } from 'react';
import { AnimatePresence } from 'framer-motion';
import AppLayout from './components/layout/AppLayout';
import WelcomeScreen from './components/game/WelcomeScreen';
import GameScreen from './components/game/GameScreen';
import DynamicTheme from './components/DynamicTheme';
import { GameSettings } from './types/game';
import './App.scss';
// App name centralized here for easy updates
export const APP_NAME = 'Ovo Quiz';
const App: React.FC = () => {
const [gameState, setGameState] = useState<'welcome' | 'playing'>('welcome');
const [gameSettings, setGameSettings] = useState<GameSettings>({
numSongs: 5,
numChoices: 4
});
const handleStartGame = (numSongs: number, numChoices: number, genres?: string[], playlistId?: string) => {
setGameSettings({ numSongs, numChoices, genres, playlist_id: playlistId });
setGameState('playing');
};
const handleExitGame = () => {
setGameState('welcome');
};
return (
<AppLayout background={gameState === 'welcome' ? 'gradient' : 'default'}>
<DynamicTheme />
<div className="app-container">
<AnimatePresence mode="wait">
{gameState === 'welcome' && (
<WelcomeScreen key="welcome" onStart={handleStartGame} />
)}
{gameState === 'playing' && (
<GameScreen
key="game"
settings={gameSettings}
onExit={handleExitGame}
/>
)}
</AnimatePresence>
</div>
</AppLayout>
);
};
export default App;

94
frontend/src/api/api.ts Normal file
View File

@@ -0,0 +1,94 @@
import axios from 'axios';
import {
Song,
GameSettings,
GameSession,
GameResponse,
AnswerRequest,
AnswerResponse,
GameSummary
} from '../types/game';
// Create axios instance with base URL
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
headers: {
'Content-Type': 'application/json',
},
});
// Song API
export const getSongs = async (limit = 20, offset = 0): Promise<Song[]> => {
const response = await api.get(`/songs?limit=${limit}&offset=${offset}`);
return response.data;
};
export const getSongCount = async (): Promise<number> => {
const response = await api.get('/songs/count');
return response.data.count;
};
export const getGenres = async (): Promise<string[]> => {
const response = await api.get('/songs/genres');
return response.data;
};
export const getSongById = async (songId: number): Promise<Song> => {
const response = await api.get(`/songs/${songId}`);
return response.data;
};
export const getRandomSongs = async (count = 5): Promise<Song[]> => {
const response = await api.get(`/songs/random?count=${count}`);
return response.data;
};
// Game API
export const createGame = async (settings: GameSettings): Promise<GameSession> => {
// Map frontend settings to API settings format
const apiSettings = {
num_songs: settings.numSongs,
num_choices: settings.numChoices,
genres: settings.genres
};
console.log('Creating game with settings:', apiSettings);
const response = await api.post('/game/create', apiSettings);
return response.data;
};
export const startGame = async (sessionId: string): Promise<GameResponse> => {
const response = await api.post(`/game/start/${sessionId}`);
return response.data;
};
export const getGameState = async (sessionId: string): Promise<GameResponse> => {
const response = await api.get(`/game/state/${sessionId}`);
return response.data;
};
export const answerQuestion = async (answerRequest: AnswerRequest): Promise<AnswerResponse> => {
const response = await api.post('/game/answer', answerRequest);
return response.data;
};
export const getGameSummary = async (sessionId: string): Promise<GameSummary> => {
const response = await api.get(`/game/summary/${sessionId}`);
return response.data;
};
// Preview API
export const getAudioPreview = async (songId: number): Promise<string> => {
const response = await api.get(`/preview/audio/${songId}`);
return response.data.preview_url;
};
export const getBlurredCover = async (songId: number, blurLevel = 10): Promise<{
blurred_url: string;
original_url: string;
blur_level: number;
}> => {
const response = await api.get(`/preview/cover/${songId}?blur_level=${blurLevel}`);
return response.data;
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { useTheme } from '../contexts/ThemeContext';
const DynamicTheme: React.FC = () => {
const { songColor, getColorRgb, getLighterColor } = useTheme();
const { r, g, b } = getColorRgb();
// Create CSS variables for different shades and opacities
const variables = {
'--accent-color': `#${songColor}`,
'--accent-rgb': `${r}, ${g}, ${b}`,
'--accent-light': getLighterColor(0.2),
'--accent-glow': `0 0 20px rgba(${r}, ${g}, ${b}, 0.4)`,
'--accent-border': `rgba(${r}, ${g}, ${b}, 0.7)`,
};
// Generate CSS string
const cssVariables = Object.entries(variables)
.map(([key, value]) => `${key}: ${value};`)
.join('\n');
return (
<style>
{`
:root {
${cssVariables}
}
/* Keep background consistent, only use accent for specific elements */
.game-screen {
/* No background - let parent background show through */
}
/* Subtle accent elements */
.timer-bar .timer-progress {
background: linear-gradient(90deg, var(--accent-color), var(--accent-light));
}
.volume-slider::-webkit-slider-thumb {
background: var(--accent-color);
}
.volume-slider::-moz-range-thumb {
background: var(--accent-color);
}
/* Make the cover image glow with the song color */
.cover-image {
box-shadow: var(--accent-glow);
border: 1px solid var(--accent-border);
}
/* Play status indicator */
.play-status {
color: var(--accent-color);
background-color: rgba(var(--accent-rgb), 0.1);
border: 1px solid rgba(var(--accent-rgb), 0.2);
}
/* Selected option highlight */
.option-button.selected {
border-color: var(--accent-color);
box-shadow: 0 0 10px rgba(var(--accent-rgb), 0.3);
}
/* Score display */
.game-header .score-display span {
border: 1px solid rgba(var(--accent-rgb), 0.5);
}
/* Next button accent */
.next-button {
border-bottom: 3px solid var(--accent-color);
}
/* Final score container subtle accent */
.final-score-container {
border: 4px solid rgba(var(--accent-rgb), 0.4);
box-shadow: 0 0 30px rgba(var(--accent-rgb), 0.2);
}
.final-score-container .score-value {
color: var(--accent-color);
}
/* Keep glass effects consistent */
.question-container, .game-header, .game-summary {
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
`}
</style>
);
};
export default DynamicTheme;

View File

@@ -0,0 +1,623 @@
@use "sass:color";
@use '../../../styles/variables.scss' as variables;
@use '../../../styles/mixins.scss' as mixins;
.game-screen {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
height: 100%;
display: flex;
flex-direction: column;
color: rgba(255, 255, 255, 0.9);
position: relative;
overflow: hidden;
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
h2 {
color: var(--text-primary);
font-size: 1.5rem;
}
}
.game-header {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border-radius: 12px;
margin-bottom: 1.25rem;
flex-wrap: wrap;
gap: 0.5rem;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.05);
transition: all 0.3s ease;
.back-button, .exit-button {
padding: 0.4rem 0.8rem;
background-color: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
font-weight: 500;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
transform: translateY(-1px);
}
&:active {
transform: translateY(1px);
}
}
.score-display {
font-size: 1rem;
font-weight: bold;
color: var(--text-primary);
margin-left: auto;
transition: all 0.3s ease;
span {
padding: 0.5rem 1rem;
background-color: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(var(--accent-rgb), 0.5);
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
display: inline-block;
}
}
.timer-bar {
width: 100%;
height: 8px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 4px;
margin-top: 0.5rem;
overflow: hidden;
order: 3;
position: relative;
.timer-progress {
height: 100%;
background: linear-gradient(90deg, var(--accent-color), var(--accent-light));
border-radius: 4px;
transition: width 1s linear;
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%
);
animation: shimmer 2s infinite;
}
}
}
.volume-control {
display: flex;
align-items: center;
margin-left: 0.5rem;
gap: 8px;
}
.volume-icon {
color: rgba(255, 255, 255, 0.7);
font-size: 16px;
}
.volume-slider {
-webkit-appearance: none;
appearance: none;
width: 80px;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.25);
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent-color);
cursor: pointer;
box-shadow: 0 0 5px rgba(var(--accent-rgb), 0.5);
transition: all 0.2s ease;
}
&::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent-color);
cursor: pointer;
box-shadow: 0 0 5px rgba(var(--accent-rgb), 0.5);
transition: all 0.2s ease;
}
&::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
&::-moz-range-thumb:hover {
transform: scale(1.1);
}
}
}
.game-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem;
.question-container {
width: 100%;
max-width: 600px;
background-color: rgba(0, 0, 0, 0.25);
border-radius: 16px;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
h2 {
margin-bottom: 1.5rem;
font-size: 1.8rem;
color: var(--text-primary);
position: relative;
font-weight: 700;
&::after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 3px;
background: var(--accent-color);
border-radius: 2px;
}
}
.audio-player {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 2rem;
.play-status {
padding: 0.6rem 1.2rem;
text-align: center;
font-weight: bold;
color: var(--accent-color);
background-color: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(var(--accent-rgb), 0.2);
border-radius: 30px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.25);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
}
}
.loading-spinner {
font-style: italic;
color: rgba(255, 255, 255, 0.7);
display: flex;
align-items: center;
gap: 8px;
&::before {
content: '';
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
}
.cover-image-container {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 1.5rem;
transition: all 0.5s ease;
&.reveal-mode {
margin-bottom: 2.5rem;
}
}
.cover-image {
width: 220px;
height: 220px;
margin-bottom: 0.5rem;
border-radius: 12px;
overflow: hidden;
box-shadow: var(--accent-glow), 0 8px 20px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(var(--accent-rgb), 0.3);
transition: all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
transform-style: preserve-3d;
perspective: 1000px;
&.blurred {
filter: blur(10px);
transform: scale(0.95);
}
&.clear {
filter: blur(0);
transform: scale(1) rotateY(360deg);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: all 0.5s ease;
}
}
.song-details {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin-top: 1rem;
padding: 0.8rem 1.5rem;
border-radius: 12px;
background: rgba(0, 0, 0, 0.35);
min-width: 220px;
max-width: 90%;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.05);
transform: translateY(0);
transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
.song-title {
font-size: 1.3rem;
font-weight: 700;
color: white;
margin: 0 0 0.3rem 0;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.song-artists {
font-size: 1rem;
color: rgba(255, 255, 255, 0.85);
margin: 0;
font-weight: 400;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.options-container {
width: 100%;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
.option-button {
padding: 1rem 1.2rem;
background-color: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: white;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
font-weight: 500;
font-size: 0.95rem;
position: relative;
overflow: hidden;
&:hover {
background-color: rgba(255, 255, 255, 0.12);
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.15);
}
&:active {
transform: translateY(1px);
}
&.selected {
border-color: var(--accent-color);
background-color: rgba(var(--accent-rgb), 0.15);
box-shadow: 0 0 15px rgba(var(--accent-rgb), 0.3);
}
&.correct {
border-color: #10b981;
background-color: rgba(16, 185, 129, 0.2);
box-shadow: 0 0 15px rgba(16, 185, 129, 0.3);
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(45deg,
rgba(16, 185, 129, 0) 0%,
rgba(16, 185, 129, 0.15) 50%,
rgba(16, 185, 129, 0) 100%
);
animation: shimmer 2s infinite;
}
}
&.incorrect {
border-color: #ef4444;
background-color: rgba(239, 68, 68, 0.2);
box-shadow: 0 0 15px rgba(239, 68, 68, 0.15);
}
&:disabled {
cursor: default;
transform: translateY(0);
}
}
}
.next-button {
padding: 0.9rem 2.5rem;
background-color: var(--accent-color);
color: white;
border: none;
border-radius: 30px;
font-weight: bold;
font-size: 1.05rem;
cursor: pointer;
transition: all 0.25s ease;
box-shadow: 0 5px 15px rgba(var(--accent-rgb), 0.4), 0 3px 0 rgba(0, 0, 0, 0.2);
text-transform: uppercase;
letter-spacing: 0.5px;
&:hover {
background-color: var(--accent-light);
transform: translateY(-2px);
box-shadow: 0 7px 20px rgba(var(--accent-rgb), 0.45), 0 3px 0 rgba(0, 0, 0, 0.2);
}
&:active {
transform: translateY(1px);
box-shadow: 0 3px 10px rgba(var(--accent-rgb), 0.4), 0 2px 0 rgba(0, 0, 0, 0.2);
}
}
.result-container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.result-message {
width: 100%;
padding: 0.5rem;
border-radius: 12px;
text-align: center;
font-weight: bold;
.correct-message {
background-color: rgba(16, 185, 129, 0.15);
color: #10b981;
padding: 1.2rem;
border-radius: 12px;
border: 1px solid rgba(16, 185, 129, 0.3);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
font-size: 1.1rem;
}
.incorrect-message {
background-color: rgba(239, 68, 68, 0.15);
color: #ef4444;
padding: 1.2rem;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.3);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
font-size: 1.1rem;
}
}
}
}
// Responsive adjustments
@media (max-width: 768px) {
.game-header {
flex-direction: row;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
.back-button {
order: 1;
font-size: 0.8rem;
padding: 0.3rem 0.6rem;
}
.score-display {
order: 2;
font-size: 0.9rem;
span {
padding: 0.4rem 0.8rem;
}
}
.timer-bar {
order: 4;
width: 100%;
margin: 0.3rem 0;
height: 6px;
}
.volume-control {
order: 3;
margin-left: auto;
.volume-slider {
width: 60px;
}
}
}
.game-content {
padding: 0.5rem;
.question-container {
padding: 1.5rem 1rem;
h2 {
font-size: 1.5rem;
margin-bottom: 1.2rem;
}
.audio-player {
margin-bottom: 1.5rem;
.play-status {
padding: 0.5rem 1rem;
font-size: 0.9rem;
}
}
.cover-image {
width: 180px;
height: 180px;
}
.song-details {
min-width: 180px;
padding: 0.6rem 1rem;
.song-title {
font-size: 1.1rem;
}
.song-artists {
font-size: 0.9rem;
}
}
.options-container {
grid-template-columns: 1fr;
gap: 0.8rem;
.option-button {
padding: 0.8rem 1rem;
font-size: 0.9rem;
}
}
.next-button {
padding: 0.7rem 2rem;
font-size: 0.95rem;
}
.result-message {
.correct-message,
.incorrect-message {
padding: 1rem;
font-size: 0.95rem;
}
}
}
}
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes pulse {
0% {
opacity: 0.6;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(1.05);
}
100% {
opacity: 0.6;
transform: scale(1);
}
}

View File

@@ -0,0 +1,568 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft, faVolumeUp } from '@fortawesome/free-solid-svg-icons';
import useGame from '../../../hooks/useGame';
import useAudioPlayer from '../../../hooks/useAudioPlayer';
import useTimer from '../../../hooks/useTimer';
import { useTheme } from '../../../contexts/ThemeContext';
import { GameSettings, GameSummaryType, Song } from '../../../types/game';
import LoadingScreen from '../../ui/LoadingScreen';
import GameSummary from '../GameSummary';
import './GameScreen.scss';
interface GameScreenProps {
onGameComplete?: (summary?: GameSummaryType) => void;
onExit: () => void;
settings: GameSettings;
}
const GameScreen: React.FC<GameScreenProps> = ({ onGameComplete, onExit, settings }) => {
// Game state
const [selectedOption, setSelectedOption] = useState<string | null>(null);
const [isAnswerSubmitted, setIsAnswerSubmitted] = useState(false);
const [isCorrect, setIsCorrect] = useState<boolean | null>(null);
const [score, setScore] = useState(0);
const [previewUrl, setPreviewUrl] = useState<string>('');
const [volume, setVolume] = useState<number>(0.7); // Default volume
const [showNextButton, setShowNextButton] = useState(false);
const [gameCompleted, setGameCompleted] = useState(false);
const [showSummary, setShowSummary] = useState(false); // New state to control summary visibility
const [answeredQuestions, setAnsweredQuestions] = useState<{id: string, correct: boolean, answer: string, correctAnswer: string}[]>([]);
const [currentQuestion, setCurrentQuestion] = useState<any>(null);
const [showClearCover, setShowClearCover] = useState(false); // State to control whether to show the clear cover
const [isLoading, setIsLoading] = useState(true); // State to track loading status
// Get theme functions
const { setSongColor } = useTheme();
// Store the current displayed question in a ref to prevent updates when answering
const displayedQuestionRef = useRef<any>(null);
// Ref to track if we've initiated game creation
const hasInitiatedGameRef = useRef<boolean>(false);
// Ref to track points earned in the last answer
const pointsEarnedRef = useRef<number>(0);
// Ref to track if we've shown the initial loading screen already
const initialLoadCompleteRef = useRef<boolean>(false);
// Initialize game hook
const { createGame, answerQuestion, resetGame, getCurrentQuestion, hasActiveGame, gameState } = useGame({
onGameComplete: (summary) => {
setGameCompleted(true);
if (onGameComplete) {
onGameComplete(summary);
}
}
});
// Initialize audio player with the current preview URL
const [audioState, audioControls] = useAudioPlayer(previewUrl);
// Initialize timer - 30 seconds per question, but don't start automatically
const [timerState, timerControls] = useTimer(30, () => {
// Auto-submit if time runs out
if (!isAnswerSubmitted && displayedQuestionRef.current) {
// For timeout, we'll use handleTimerComplete instead
handleTimerComplete();
}
}, { autoStart: false });
// Handle option selection - directly submit the answer on click
const handleOptionSelect = useCallback((optionId: string) => {
if (isAnswerSubmitted) return;
// Set selected option
setSelectedOption(optionId);
// Find the index of the selected option
const selectedIndex = currentQuestion.options.findIndex((option: Song) => option.id === optionId);
if (selectedIndex === -1) return;
// Submit answer immediately
answerQuestion(selectedIndex).then(result => {
if (result) {
const isCorrect = result.correct;
setIsCorrect(isCorrect);
// Update score with base score + time bonus if correct
setScore(result.score);
setIsAnswerSubmitted(true);
// Add a small delay before showing the clear cover for better visual effect
setTimeout(() => {
setShowClearCover(true); // Show the clear cover when the answer is submitted
}, 300);
// Store points earned for display
if (isCorrect) {
// Store points in a ref to access in the UI
pointsEarnedRef.current = result.points_earned;
}
// If game is complete
if (result.game_complete) {
setGameCompleted(true);
} else {
// Show the next button after a short delay
setTimeout(() => {
setShowNextButton(true);
}, 1800);
}
// Add this question to the answered questions
setAnsweredQuestions(prev => [
...prev,
{
id: currentQuestion.correctOption.id,
correct: isCorrect,
answer: optionId ? currentQuestion.options.find((o: Song) => o.id === optionId)?.title || "" : "",
correctAnswer: currentQuestion.correctOption.title
}
]);
}
});
}, [isAnswerSubmitted, currentQuestion, answerQuestion]);
// Handle timer expiration
const handleTimerComplete = useCallback(() => {
if (!isAnswerSubmitted) {
audioControls.pause();
// For timeout, submit with -1 index
answerQuestion(-1).then(result => {
if (result) {
setIsCorrect(false); // Always incorrect for timeout
setScore(result.score);
setIsAnswerSubmitted(true);
setTimeout(() => {
setShowClearCover(true);
}, 300);
pointsEarnedRef.current = 0; // No points for timeout
if (result.game_complete) {
setGameCompleted(true);
} else {
setTimeout(() => {
setShowNextButton(true);
}, 1800);
}
setAnsweredQuestions(prev => [
...prev,
{
id: currentQuestion.correctOption.id,
correct: false,
answer: "timeout",
correctAnswer: currentQuestion.correctOption.title
}
]);
}
});
}
}, [isAnswerSubmitted, audioControls, answerQuestion, currentQuestion]);
// Set volume for audio player
useEffect(() => {
audioControls.setVolume(volume);
}, [volume, audioControls]);
// Start a new game when component mounts
useEffect(() => {
// Only create a new game if:
// 1. We haven't initiated a game creation yet
// 2. There's no active game
// 3. We're not in the completed state
if (!hasInitiatedGameRef.current && !hasActiveGame() && !gameCompleted) {
console.log('Initiating game creation');
hasInitiatedGameRef.current = true;
createGame(settings);
}
}, [createGame, settings, hasActiveGame, gameCompleted]);
// Update current question only when starting a new question
useEffect(() => {
if (!isAnswerSubmitted) {
const question = getCurrentQuestion();
if (question) {
setCurrentQuestion(question);
displayedQuestionRef.current = question; // Set displayed question on update
setShowClearCover(false); // Ensure cover is blurred for new questions
// Only set loading to false if this is the initial load
if (!initialLoadCompleteRef.current) {
setIsLoading(false); // Question loaded successfully
initialLoadCompleteRef.current = true; // Mark initial load as complete
}
// Update the theme color with the song's color
if (question.songColor) {
setSongColor(question.songColor);
}
}
}
}, [getCurrentQuestion, isAnswerSubmitted, setSongColor]);
// Update preview URL when current question changes
useEffect(() => {
if (!isAnswerSubmitted && currentQuestion?.correctOption?.audioUrl) {
setPreviewUrl(currentQuestion.correctOption.audioUrl);
}
}, [currentQuestion, isAnswerSubmitted]);
// Play audio and start timer when audio is ready
useEffect(() => {
if (previewUrl && !audioState.isLoading && !isAnswerSubmitted && !audioState.isPlaying && audioState.duration > 0) {
// Play audio
audioControls.play();
// Start timer
timerControls.start();
}
}, [previewUrl, audioState.isLoading, audioState.duration, audioState.isPlaying, audioControls, timerControls, isAnswerSubmitted]);
// Handle proceeding to next question
const handleNextQuestion = useCallback(() => {
// Stop any playing audio
audioControls.stop();
// Reset state for next question
timerControls.reset();
setShowNextButton(false);
setIsAnswerSubmitted(false);
setSelectedOption(null);
setIsCorrect(null);
setShowClearCover(false); // Reset cover image to blurred state
pointsEarnedRef.current = 0; // Reset points earned
// Don't set loading to true for song transitions
}, [audioControls, timerControls]);
// Handle exit game
const handleExitGame = useCallback(() => {
audioControls.stop();
resetGame();
// Reset our state trackers
hasInitiatedGameRef.current = false;
initialLoadCompleteRef.current = false; // Reset initial load flag
onExit(); // Call the onExit prop directly
}, [audioControls, resetGame, onExit]);
// Handle volume change
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value);
setVolume(newVolume);
}, []);
// Use the displayed question from the ref for rendering
const questionToDisplay = displayedQuestionRef.current;
// Handle playing again
const handlePlayAgain = useCallback(() => {
// Stop any playing audio
audioControls.stop();
// Reset all game state
setShowSummary(false);
setGameCompleted(false);
setAnsweredQuestions([]);
setScore(0);
setSelectedOption(null);
setIsAnswerSubmitted(false);
setIsCorrect(null);
setShowNextButton(false);
setPreviewUrl('');
pointsEarnedRef.current = 0;
setShowClearCover(false); // Ensure cover is blurred when starting a new game
setIsLoading(true); // Show loading screen for new game
initialLoadCompleteRef.current = false; // Reset initial load flag
// Reset the initiated game ref
hasInitiatedGameRef.current = false;
// Create a new game
createGame(settings);
}, [audioControls, createGame, settings]);
// Handle viewing game summary
const handleViewSummary = useCallback(() => {
setShowSummary(true);
}, []);
// Update loading state when game state changes
useEffect(() => {
// Only show loading during initial load
if (!initialLoadCompleteRef.current) {
setIsLoading(gameState.loading);
}
}, [gameState.loading]);
// Update loading state based on audio loading
useEffect(() => {
// Only show loading during initial load
if (!initialLoadCompleteRef.current) {
// If audio is loading, show loading screen
if (previewUrl && audioState.isLoading) {
setIsLoading(true);
}
// If audio has loaded successfully
else if (previewUrl && !audioState.isLoading && audioState.duration > 0) {
setIsLoading(false);
initialLoadCompleteRef.current = true; // Mark initial load as complete
}
}
}, [previewUrl, audioState.isLoading, audioState.duration]);
if (!questionToDisplay) {
return (
<div className="game-screen">
<LoadingScreen isLoading={true} />
<div className="loading">
<h2>Starting Game</h2>
</div>
</div>
);
}
// Find the correct option for display purposes
const correctOptionId = questionToDisplay.correctOption.id;
return (
<div className="game-screen">
<LoadingScreen isLoading={isLoading} />
<motion.div
className="game-header"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4 }}
>
<button className="back-button" onClick={handleExitGame}>
<FontAwesomeIcon icon={faArrowLeft} /> Back
</button>
<motion.div
className="score-display"
animate={{
scale: gameState.score !== score ? [1, 1.2, 1] : 1,
}}
transition={{ duration: 0.4, type: "tween", ease: "easeInOut" }}
>
<span>{score}</span>
</motion.div>
<div className="volume-control">
<FontAwesomeIcon icon={faVolumeUp} className="volume-icon" />
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={handleVolumeChange}
className="volume-slider"
/>
</div>
<div className="timer-bar">
<motion.div
className="timer-progress"
style={{ width: `${timerState.progress}%` }}
/>
</div>
</motion.div>
<AnimatePresence mode="wait">
{showSummary ? (
<GameSummary
summary={{
score,
totalQuestions: answeredQuestions.length,
correctAnswers: answeredQuestions.filter(q => q.correct).length,
accuracy: answeredQuestions.length ? (answeredQuestions.filter(q => q.correct).length / answeredQuestions.length) * 100 : 0
}}
onPlayAgain={handlePlayAgain}
onExit={handleExitGame}
answeredQuestions={answeredQuestions}
/>
) : (
<motion.div
key="game-content"
className="game-content"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
<motion.div
className="question-container"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<motion.h2
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.4 }}
>
Guess the song
</motion.h2>
<motion.div
className="audio-player"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3, duration: 0.4 }}
>
{audioState.isLoading ? (
<div className="loading-spinner">Loading preview...</div>
) : (
<motion.div
className="play-status"
animate={{
scale: audioState.isPlaying ? [1, 1.05, 1] : 1,
}}
transition={{ duration: 0.4, repeat: audioState.isPlaying ? Infinity : 0, repeatDelay: 1.5 }}
>
{audioState.isPlaying ? 'Now Playing' : 'Paused'}
</motion.div>
)}
</motion.div>
{questionToDisplay.coverUrl && (
<motion.div
className={`cover-image-container ${showClearCover ? 'reveal-mode' : ''}`}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.4, duration: 0.5 }}
>
<div className={`cover-image ${showClearCover ? 'clear' : 'blurred'}`}>
<motion.img
key={questionToDisplay.coverUrl}
src={showClearCover ? questionToDisplay.clearCoverUrl : questionToDisplay.coverUrl}
alt={showClearCover ? "Album cover" : "Blurred album cover"}
initial={{ filter: "blur(10px)", scale: 1 }}
animate={{
filter: showClearCover ? "blur(0px)" : "blur(10px)",
scale: showClearCover ? 1.05 : 1
}}
transition={{ duration: 0.8, ease: "easeOut" }}
/>
</div>
<AnimatePresence>
{showClearCover && (
<motion.div
className="song-details"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.4, duration: 0.5 }}
>
<h3 className="song-title">{questionToDisplay.correctOption.title}</h3>
<p className="song-artists">{questionToDisplay.artists || questionToDisplay.correctOption.artist}</p>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
<div className="options-container">
{questionToDisplay.options.map((option: Song, index: number) => (
<motion.button
key={option.id}
className={`option-button ${selectedOption === option.id ? 'selected' : ''} ${
isAnswerSubmitted && option.id === correctOptionId ? 'correct' : ''
} ${
isAnswerSubmitted && selectedOption === option.id && selectedOption !== correctOptionId ? 'incorrect' : ''
}`}
onClick={() => handleOptionSelect(option.id)}
disabled={isAnswerSubmitted}
whileHover={!isAnswerSubmitted ? { scale: 1.03, y: -2 } : {}}
whileTap={!isAnswerSubmitted ? { scale: 0.98 } : {}}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: 0.5 + (index * 0.1),
duration: 0.4,
type: "spring",
stiffness: 200,
damping: 15
}}
>
{option.title}
</motion.button>
))}
</div>
<AnimatePresence>
{isAnswerSubmitted && (
<motion.div
className="result-container"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.4 }}
>
<div className="result-message">
{isCorrect ? (
<motion.div
className="correct-message"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 15 }}
>
Correct! +{pointsEarnedRef.current} points
</motion.div>
) : (
<motion.div
className="incorrect-message"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 15 }}
>
Incorrect! The correct answer was: {questionToDisplay.correctOption.title}
</motion.div>
)}
</div>
<AnimatePresence>
{showNextButton ? (
<motion.button
className="next-button"
onClick={handleNextQuestion}
whileHover={{ scale: 1.05, y: -2 }}
whileTap={{ scale: 0.95 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.2, type: "spring" }}
>
Next Song
</motion.button>
) : gameCompleted && (
<motion.button
className="next-button"
onClick={handleViewSummary}
whileHover={{ scale: 1.05, y: -2 }}
whileTap={{ scale: 0.95 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.2, type: "spring" }}
>
See Results
</motion.button>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default GameScreen;

View File

@@ -0,0 +1 @@
export { default } from './GameScreen';

View File

@@ -0,0 +1,523 @@
@use '../../../styles/variables.scss' as variables;
@use '../../../styles/mixins.scss' as mixins;
.game-summary {
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 2.5rem 2rem;
background-color: rgba(0, 0, 0, 0.25);
border-radius: 20px;
display: flex;
flex-direction: column;
align-items: center;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
position: relative;
overflow: hidden;
color: rgba(255, 255, 255, 0.9);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(
circle at top right,
rgba(var(--accent-rgb), 0.15),
transparent 70%
);
z-index: -1;
}
&__title {
font-size: 2.5rem;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 1.5rem;
text-align: center;
background: linear-gradient(135deg, #fff, rgba(var(--accent-rgb), 0.8));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
position: relative;
&::after {
content: '🎵';
position: absolute;
font-size: 1.5rem;
top: -10px;
right: -30px;
-webkit-text-fill-color: initial;
transform: rotate(15deg);
}
}
&__message {
color: variables.$text-secondary;
font-size: 1.2rem;
margin-bottom: 2rem;
text-align: center;
opacity: 0.9;
}
&__score-circle {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 3rem;
width: 200px;
height: 200px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.3);
border: 4px solid rgba(var(--accent-rgb), 0.4);
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 0 40px rgba(var(--accent-rgb), 0.3),
inset 0 0 20px rgba(var(--accent-rgb), 0.1);
position: relative;
transition: all 0.5s ease;
&:hover {
transform: scale(1.03) rotate(2deg);
box-shadow: 0 0 50px rgba(var(--accent-rgb), 0.4),
inset 0 0 25px rgba(var(--accent-rgb), 0.15);
}
&::before {
content: '';
position: absolute;
top: -10px;
left: -10px;
right: -10px;
bottom: -10px;
border-radius: 50%;
border: 2px solid rgba(var(--accent-rgb), 0.2);
animation: pulse 3s infinite;
}
}
&__score-label {
font-size: 1.2rem;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 0.8rem;
text-transform: uppercase;
letter-spacing: 2px;
font-weight: 600;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
&__score-value {
font-size: 4.5rem;
font-weight: 800;
color: var(--accent-color);
text-shadow: 0 2px 10px rgba(var(--accent-rgb), 0.5);
line-height: 1;
}
&__stats {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-bottom: 2.5rem;
width: 100%;
}
&__rank {
display: flex;
justify-content: center;
margin-bottom: 0.5rem;
&-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1.5rem;
background: linear-gradient(135deg, var(--accent-color), var(--accent-light));
border-radius: 30px;
box-shadow: 0 5px 15px rgba(var(--accent-rgb), 0.35);
span {
font-weight: 700;
font-size: 1.1rem;
color: #fff;
text-transform: uppercase;
letter-spacing: 1px;
}
}
}
&__rank-emoji {
font-size: 1.3rem;
}
&__score-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
width: 100%;
max-width: 500px;
margin: 0 auto;
}
&__score-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 1rem;
background-color: rgba(255, 255, 255, 0.08);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
&:hover {
transform: translateY(-3px);
background-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
}
.game-summary__score-value {
font-size: 2.2rem;
margin-bottom: 0.3rem;
}
.game-summary__score-label {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
margin-bottom: 0;
}
}
&__questions {
width: 100%;
max-width: 700px;
h3 {
margin-bottom: 1.8rem;
font-size: 1.8rem;
color: var(--text-primary);
text-align: center;
position: relative;
padding-bottom: 0.8rem;
font-weight: 700;
&:after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100px;
height: 3px;
background: linear-gradient(90deg, var(--accent-color), var(--accent-light));
border-radius: 3px;
box-shadow: 0 2px 5px rgba(var(--accent-rgb), 0.3);
}
}
}
&__answers-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem;
margin-bottom: 2rem;
}
&__answer-card {
padding: 1.2rem;
border-radius: 12px;
display: flex;
flex-direction: column;
background-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.15);
overflow: hidden;
transition: all 0.3s ease;
transform: translateZ(0);
border: 1px solid rgba(255, 255, 255, 0.05);
position: relative;
&:hover {
transform: translateY(-3px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
background-color: rgba(255, 255, 255, 0.1);
}
&.correct {
border-left: 6px solid #10b981;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(
circle at top left,
rgba(16, 185, 129, 0.15),
transparent 80%
);
z-index: -1;
}
}
&.incorrect {
border-left: 6px solid #ef4444;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(
circle at top left,
rgba(239, 68, 68, 0.1),
transparent 80%
);
z-index: -1;
}
}
}
&__answer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
&__question-number {
font-weight: 700;
color: var(--text-primary);
font-size: 1.1rem;
}
&__result-badge {
padding: 0.3rem 1rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: bold;
transition: all 0.3s ease;
.correct & {
background-color: rgba(16, 185, 129, 0.2);
color: #10b981;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.2);
}
.incorrect & {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.2);
}
}
&__answer-content {
padding: 0.5rem;
}
&__answer-label {
display: block;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 0.5rem;
}
&__song-title {
font-size: 1.2rem;
font-weight: bold;
color: var(--text-primary);
position: relative;
padding-left: 0.5rem;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
border-radius: 3px;
background-color: var(--accent-color);
}
}
&__actions {
margin-top: 1rem;
display: flex;
gap: 1.5rem;
button {
padding: 0.9rem 2rem;
border: none;
border-radius: 30px;
font-weight: bold;
cursor: pointer;
transition: all 0.25s ease;
font-size: 1rem;
letter-spacing: 0.5px;
}
}
&__play-again {
background-color: var(--accent-color);
color: white;
box-shadow: 0 5px 15px rgba(var(--accent-rgb), 0.4), 0 3px 0 rgba(0, 0, 0, 0.2);
&:hover {
background-color: var(--accent-light);
transform: translateY(-2px);
box-shadow: 0 7px 20px rgba(var(--accent-rgb), 0.45), 0 3px 0 rgba(0, 0, 0, 0.2);
}
&:active {
transform: translateY(1px);
box-shadow: 0 3px 10px rgba(var(--accent-rgb), 0.4), 0 2px 0 rgba(0, 0, 0, 0.2);
}
}
&__exit, &__share {
background-color: rgba(0, 0, 0, 0.3);
color: white;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
&:hover {
background-color: rgba(255, 255, 255, 0.15);
transform: translateY(-2px);
box-shadow: 0 7px 20px rgba(0, 0, 0, 0.25);
}
&:active {
transform: translateY(1px);
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
}
}
&__share {
background-color: rgba(var(--accent-rgb), 0.2);
border: 1px solid rgba(var(--accent-rgb), 0.3);
}
// Responsive styles
@media (max-width: 768px) {
padding: 1.5rem 1rem;
&__title {
font-size: 2rem;
margin-bottom: 1rem;
&::after {
font-size: 1.2rem;
right: -25px;
}
}
&__message {
font-size: 1rem;
margin-bottom: 1.5rem;
}
&__score-circle {
width: 150px;
height: 150px;
margin-bottom: 2rem;
&__score-label {
font-size: 1rem;
margin-bottom: 0.5rem;
}
&__score-value {
font-size: 3.5rem;
}
}
&__score-grid {
grid-template-columns: repeat(3, 1fr);
gap: 0.8rem;
}
&__score-item {
padding: 0.7rem;
.game-summary__score-value {
font-size: 1.8rem;
}
.game-summary__score-label {
font-size: 0.7rem;
}
}
&__questions h3 {
font-size: 1.5rem;
margin-bottom: 1.2rem;
}
&__answer-card {
padding: 1rem;
}
&__answer-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding-bottom: 0.8rem;
}
&__question-number {
font-size: 1rem;
}
&__result-badge {
font-size: 0.8rem;
padding: 0.25rem 0.8rem;
}
&__song-title {
font-size: 1.1rem;
}
&__actions {
flex-direction: column;
width: 100%;
gap: 1rem;
button {
width: 100%;
padding: 0.8rem 1.5rem;
}
}
}
}
@keyframes pulse {
0% {
opacity: 0.6;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(1.05);
}
100% {
opacity: 0.6;
transform: scale(1);
}
}

View File

@@ -0,0 +1,207 @@
import React from 'react';
import { motion } from 'framer-motion';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMusic, faHeadphones, faGuitar, faMicrophone, faKeyboard } from '@fortawesome/free-solid-svg-icons';
import { GameSummaryType } from '../../../types/game';
import { APP_NAME } from '../../../App';
import './GameSummary.scss';
interface GameSummaryProps {
summary: GameSummaryType;
onPlayAgain: () => void;
onExit: () => void;
answeredQuestions?: {id: string, correct: boolean, answer: string, correctAnswer: string}[];
}
const GameSummary: React.FC<GameSummaryProps> = ({ summary, onPlayAgain, onExit, answeredQuestions = [] }) => {
// Ensure accuracy is a valid number
const safeAccuracy = isNaN(summary.accuracy) || !isFinite(summary.accuracy) ? 0 : summary.accuracy;
const getScoreMessage = () => {
if (safeAccuracy >= 80) {
return "Amazing! You're a music genius!";
} else if (safeAccuracy >= 60) {
return "Great job! You know your music well!";
} else if (safeAccuracy >= 40) {
return "Not bad! Keep practicing to improve your score.";
} else {
return "You might want to listen to more music!";
}
};
const getRankLabel = () => {
if (safeAccuracy >= 90) return "Music Maestro";
if (safeAccuracy >= 70) return "Melody Master";
if (safeAccuracy >= 50) return "Rhythm Rookie";
if (safeAccuracy >= 30) return "Beat Beginner";
return "Tune Trainee";
};
const getEmojiForRank = () => {
if (safeAccuracy >= 90) return <FontAwesomeIcon icon={faMusic} />;
if (safeAccuracy >= 70) return <FontAwesomeIcon icon={faHeadphones} />;
if (safeAccuracy >= 50) return <FontAwesomeIcon icon={faGuitar} />;
if (safeAccuracy >= 30) return <FontAwesomeIcon icon={faMicrophone} />;
return <FontAwesomeIcon icon={faKeyboard} />;
};
return (
<motion.div
className="game-summary"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.6 }}
>
<motion.h2
className="game-summary__title"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
>
Game Complete!
</motion.h2>
<motion.div
className="game-summary__message"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
{getScoreMessage()}
</motion.div>
<motion.div
className="game-summary__score-circle"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, type: "spring" }}
>
<div className="game-summary__score-label">Final Score</div>
<div className="game-summary__score-value">{summary.score}</div>
</motion.div>
<motion.div
className="game-summary__stats"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.5 }}
>
<div className="game-summary__rank">
<div className="game-summary__rank-badge">
<span className="game-summary__rank-emoji">{getEmojiForRank()}</span>
<span>{getRankLabel()}</span>
</div>
</div>
<div className="game-summary__score-grid">
<div className="game-summary__score-item">
<div className="game-summary__score-value">{summary.correctAnswers}</div>
<div className="game-summary__score-label">Correct</div>
</div>
<div className="game-summary__score-item">
<div className="game-summary__score-value">{summary.totalQuestions}</div>
<div className="game-summary__score-label">Total</div>
</div>
<div className="game-summary__score-item">
<div className="game-summary__score-value">{safeAccuracy.toFixed(0)}%</div>
<div className="game-summary__score-label">Accuracy</div>
</div>
</div>
</motion.div>
{answeredQuestions.length > 0 && (
<motion.div
className="game-summary__questions"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.6 }}
>
<h3>Your Answers</h3>
<div className="game-summary__answers-container">
{answeredQuestions.map((question, index) => (
<motion.div
key={index}
className={`game-summary__answer-card ${question.correct ? 'correct' : 'incorrect'}`}
initial={{ x: -30, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: 0.8 + index * 0.1 }}
>
<div className="game-summary__answer-header">
<span className="game-summary__question-number">Question {index + 1}</span>
<span className="game-summary__result-badge">{question.correct ? 'Correct' : 'Incorrect'}</span>
</div>
<div className="game-summary__answer-content">
{question.correct ? (
<div className="game-summary__correct-answer">
<span className="game-summary__answer-label">You correctly guessed:</span>
<span className="game-summary__song-title">{question.correctAnswer}</span>
</div>
) : (
<div className="game-summary__incorrect-answer">
<span className="game-summary__answer-label">Correct song was:</span>
<span className="game-summary__song-title">{question.correctAnswer}</span>
</div>
)}
</div>
</motion.div>
))}
</div>
</motion.div>
)}
<motion.div
className="game-summary__actions"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.8 + (answeredQuestions.length > 0 ? answeredQuestions.length * 0.1 : 0) }}
>
<motion.button
className="game-summary__play-again"
onClick={onPlayAgain}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Play Again
</motion.button>
<motion.button
className="game-summary__exit"
onClick={onExit}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Return to Menu
</motion.button>
<motion.button
className="game-summary__share"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => {
const text = `I scored ${summary.score} points with ${safeAccuracy.toFixed(0)}% accuracy in ${APP_NAME}! Can you beat my score?`;
if (navigator.share) {
navigator.share({
title: `My ${APP_NAME} Score`,
text: text,
url: window.location.href,
}).catch(() => {
// Fallback if share fails
navigator.clipboard.writeText(text);
alert('Score copied to clipboard!');
});
} else {
// Fallback - copy to clipboard
navigator.clipboard.writeText(text);
alert('Score copied to clipboard!');
}
}}
>
Share Score
</motion.button>
</motion.div>
</motion.div>
);
};
export default GameSummary;

View File

@@ -0,0 +1 @@
export { default } from './GameSummary';

View File

@@ -0,0 +1,56 @@
@use '../../../styles/variables.scss';
@use '../../../styles/mixins.scss';
.song-card {
width: 100%;
&__container {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
border-radius: variables.$border-radius-md;
box-shadow: variables.$shadow-md;
background: variables.$background-secondary-color;
}
&__cover {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
transition: filter 0.5s ease-in-out;
will-change: filter;
}
&--loaded .song-card__cover {
animation: fadeIn 0.5s ease-in-out;
}
&__playing {
position: absolute;
bottom: variables.$spacing-md;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: flex-end;
gap: 4px;
height: 45px;
padding: 0 variables.$spacing-sm;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(10px);
border-radius: variables.$border-radius-full;
}
&__bar {
width: 4px;
height: 20px;
background-color: variables.$text-primary;
border-radius: variables.$border-radius-full;
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}

View File

@@ -0,0 +1,86 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import Card from '../../ui/Card';
import './SongCard.scss';
interface SongCardProps {
coverUrl: string;
blurLevel: number; // 0-20, where 20 is the most blurred
isRevealed?: boolean;
audioPlaying?: boolean;
className?: string;
}
const SongCard: React.FC<SongCardProps> = ({
coverUrl,
blurLevel = 10,
isRevealed = false,
audioPlaying = false,
className = '',
}) => {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
// Preload the image
const img = new Image();
img.src = coverUrl;
img.onload = () => setIsLoaded(true);
}, [coverUrl]);
// Calculate the blur amount based on the blur level
const blurAmount = isRevealed ? 0 : blurLevel * 2;
return (
<Card className={`song-card ${className} ${isLoaded ? 'song-card--loaded' : ''}`}>
<div className="song-card__container">
<div
className="song-card__cover"
style={{
backgroundImage: isLoaded ? `url(${coverUrl})` : 'none',
filter: `blur(${blurAmount}px)`
}}
/>
{audioPlaying && (
<div className="song-card__playing">
<motion.div
className="song-card__bar"
animate={{
height: [20, 40, 15, 30, 20]
}}
transition={{
duration: 1.5,
repeat: Infinity,
repeatType: "reverse"
}}
/>
<motion.div
className="song-card__bar"
animate={{
height: [30, 10, 25, 35, 30]
}}
transition={{
duration: 1.7,
repeat: Infinity,
repeatType: "reverse"
}}
/>
<motion.div
className="song-card__bar"
animate={{
height: [15, 30, 45, 20, 15]
}}
transition={{
duration: 1.3,
repeat: Infinity,
repeatType: "reverse"
}}
/>
</div>
)}
</div>
</Card>
);
};
export default SongCard;

View File

@@ -0,0 +1 @@
export { default } from './SongCard';

View File

@@ -0,0 +1,106 @@
@use '../../../styles/variables.scss';
@use '../../../styles/mixins.scss';
.song-option {
width: 100%;
padding: variables.$spacing-md;
border-radius: variables.$border-radius-md;
border: 1px solid variables.$border-color;
background: rgba(255, 255, 255, 0.08);
color: variables.$text-primary;
font-weight: variables.$font-weight-medium;
text-align: left;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: all variables.$transition-base;
position: relative;
overflow: hidden;
&__name {
width: calc(100% - 30px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 14px;
&--correct {
background-color: variables.$success-color;
color: white;
}
&--incorrect {
background-color: variables.$danger-color;
color: white;
}
}
// Option variants
&--default {
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
}
&--selected {
background: rgba(variables.$primary-color, 0.3);
border-color: variables.$primary-color;
box-shadow: 0 0 0 1px rgba(variables.$primary-color, 0.5);
&:hover:not(:disabled) {
background: rgba(variables.$primary-color, 0.4);
}
}
&--correct {
background: rgba(variables.$success-color, 0.3);
border-color: variables.$success-color;
&:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
45deg,
rgba(variables.$success-color, 0) 0%,
rgba(variables.$success-color, 0.1) 50%,
rgba(variables.$success-color, 0) 100%
);
animation: shine 2s infinite;
}
}
&--incorrect {
background: rgba(variables.$danger-color, 0.2);
border-color: variables.$danger-color;
opacity: 0.8;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
@keyframes shine {
from {
background-position: -200% 0;
}
to {
background-position: 200% 0;
}
}

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { motion } from 'framer-motion';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCheck, faTimes } from '@fortawesome/free-solid-svg-icons';
import './SongOption.scss';
interface SongOptionProps {
name: string;
isSelected?: boolean;
isCorrect?: boolean;
isRevealed?: boolean;
onClick?: () => void;
disabled?: boolean;
}
const SongOption: React.FC<SongOptionProps> = ({
name,
isSelected = false,
isCorrect = false,
isRevealed = false,
onClick,
disabled = false,
}) => {
// Determine the variant of the option button based on current state
let variant = 'default';
if (isRevealed) {
if (isCorrect) {
variant = 'correct';
} else if (isSelected && !isCorrect) {
variant = 'incorrect';
}
} else if (isSelected) {
variant = 'selected';
}
return (
<motion.button
className={`song-option song-option--${variant}`}
onClick={onClick}
disabled={disabled}
whileHover={!disabled ? { scale: 1.02 } : {}}
whileTap={!disabled ? { scale: 0.98 } : {}}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<span className="song-option__name">{name}</span>
{isRevealed && (
<div className={`song-option__icon song-option__icon--${isCorrect ? 'correct' : 'incorrect'}`}>
{isCorrect ? <FontAwesomeIcon icon={faCheck} /> : <FontAwesomeIcon icon={faTimes} />}
</div>
)}
</motion.button>
);
};
export default SongOption;

View File

@@ -0,0 +1 @@
export { default } from './SongOption';

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { motion } from 'framer-motion';
const VinylAnimation: React.FC = () => {
return (
<div className="welcome-screen__vinyl">
<motion.div
className="vinyl-record"
animate={{ rotate: 360 }}
transition={{
duration: 10,
repeat: Infinity,
ease: "linear"
}}
>
<div className="vinyl-label"></div>
</motion.div>
</div>
);
};
export default VinylAnimation;

View File

@@ -0,0 +1,411 @@
@use '../../../styles/variables.scss';
@use '../../../styles/mixins.scss';
.welcome-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 2rem 1rem;
position: relative;
overflow: hidden;
&__header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
margin-bottom: 2rem;
width: 100%;
}
&__title {
font-size: 3.5rem;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, var(--accent-color), #fff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-align: center;
z-index: 10;
margin-bottom: 1rem;
filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.5));
letter-spacing: -0.05em;
}
&__vinyl {
width: 150px;
height: 150px;
position: relative;
margin: 0 auto;
filter: drop-shadow(0 0 15px rgba(0, 0, 0, 0.7));
}
.vinyl-record {
width: 100%;
height: 100%;
border-radius: 50%;
background: radial-gradient(
circle at center,
#000 0%,
#000 40%,
#222 40%,
#222 43%,
#000 43%,
#000 45%,
#222 45%,
#222 48%,
#000 48%,
#000 50%,
#222 50%,
#222 53%,
#000 53%,
#000 90%,
#333 90%,
#333 100%
);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
&::after {
content: '';
position: absolute;
width: 98%;
height: 98%;
border-radius: 50%;
background: conic-gradient(
from 0deg,
rgba(255, 255, 255, 0.1) 0deg,
transparent 20deg,
rgba(255, 255, 255, 0.1) 40deg,
transparent 60deg,
rgba(255, 255, 255, 0.1) 80deg,
transparent 100deg,
rgba(255, 255, 255, 0.1) 120deg,
transparent 140deg,
rgba(255, 255, 255, 0.1) 160deg,
transparent 180deg,
rgba(255, 255, 255, 0.1) 200deg,
transparent 220deg,
rgba(255, 255, 255, 0.1) 240deg,
transparent 260deg,
rgba(255, 255, 255, 0.1) 280deg,
transparent 300deg,
rgba(255, 255, 255, 0.1) 320deg,
transparent 340deg,
rgba(255, 255, 255, 0.1) 360deg
);
}
}
.vinyl-label {
width: 35%;
height: 35%;
border-radius: 50%;
background: var(--accent-color);
display: flex;
align-items: center;
justify-content: center;
position: relative;
&::after {
content: '';
position: absolute;
width: 20%;
height: 20%;
border-radius: 50%;
background: #000;
}
}
&__card-container {
width: 100%;
max-width: 450px;
margin: 0 auto;
}
&__card {
width: 100%;
backdrop-filter: blur(10px);
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
overflow: hidden;
}
&__content {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
&__options-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
&__label {
font-size: 1rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 0.5rem;
}
&__song-options {
display: flex;
justify-content: center;
gap: 1rem;
.song-option {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
font-size: 1.2rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
&.selected {
background: var(--accent-color);
box-shadow: 0 0 15px rgba(var(--accent-rgb), 0.5);
}
}
}
&__genre-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: none;
border: none;
color: rgba(255, 255, 255, 0.8);
padding: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
margin: 0.5rem 0;
transition: all 0.2s ease;
svg {
transition: transform 0.3s ease;
}
&:hover {
color: white;
}
}
&__genres {
overflow: hidden;
}
&__genre-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.5rem;
max-height: 180px;
overflow-y: auto;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 0.5rem;
margin-bottom: 0.5rem;
.genre-chip {
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 4px;
padding: 0.5rem;
color: white;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
&.selected {
background: var(--accent-color);
box-shadow: 0 0 10px rgba(var(--accent-rgb), 0.3);
}
}
}
&__selected-genres {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.8);
}
&__clear-genres {
background: none;
border: none;
color: var(--accent-color);
cursor: pointer;
font-size: 0.8rem;
padding: 0;
transition: all 0.2s ease;
&:hover {
text-decoration: underline;
}
}
&__start-button {
margin-top: 0.5rem;
padding: 1rem;
background: var(--accent-color);
border: none;
border-radius: 8px;
color: white;
font-weight: 700;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
letter-spacing: 1px;
&:hover {
background: rgba(var(--accent-rgb), 0.9);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.4);
}
}
&__instructions {
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 1rem;
p {
margin: 0.5rem 0;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
gap: 0.5rem;
}
}
&__toggle-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: none;
border: none;
color: rgba(255, 255, 255, 0.8);
padding: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
margin: 0.5rem 0;
transition: all 0.2s ease;
svg {
transition: transform 0.3s ease;
}
&:hover {
color: white;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__dropdown-panel {
overflow: hidden;
}
&__playlist-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 220px;
overflow-y: auto;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 0.5rem;
margin-bottom: 0.5rem;
.playlist-item {
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 4px;
padding: 0.75rem;
color: white;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
&.selected {
background: var(--accent-color);
box-shadow: 0 0 10px rgba(var(--accent-rgb), 0.3);
}
&__title {
font-weight: 500;
font-size: 0.95rem;
margin-bottom: 0.25rem;
}
&__description {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.7);
}
}
}
// Media queries for responsive design
@media (max-width: 768px) {
&__title {
font-size: 2.5rem;
}
&__vinyl {
width: 120px;
height: 120px;
}
&__song-options {
.song-option {
width: 50px;
height: 50px;
font-size: 1rem;
}
}
&__genre-grid {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
max-height: 150px;
}
}
}

View File

@@ -0,0 +1,280 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMusic, faGamepad, faTrophy } from '@fortawesome/free-solid-svg-icons';
import { playlistsApi, songsApi } from '../../../services/api';
import { Playlist } from '../../../types/song';
import VinylAnimation from './VinylAnimation';
import { APP_NAME } from '../../../App';
import './WelcomeScreen.scss';
interface WelcomeScreenProps {
onStart: (numSongs: number, numChoices: number, genres?: string[], playlistId?: string) => void;
}
const WelcomeScreen: React.FC<WelcomeScreenProps> = ({ onStart }) => {
// Always 4 songs per game
const NUM_SONGS = 4;
const NUM_CHOICES = 4;
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
const [availableGenres, setAvailableGenres] = useState<string[]>([]);
const [availablePlaylists, setAvailablePlaylists] = useState<Playlist[]>([]);
const [selectedPlaylistId, setSelectedPlaylistId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [showGenres, setShowGenres] = useState(false);
const [showPlaylists, setShowPlaylists] = useState(false);
useEffect(() => {
// Fetch available genres and playlists when component mounts
const fetchData = async () => {
setIsLoading(true);
try {
// Fetch genres
const genresData = await songsApi.getGenres();
setAvailableGenres(Array.isArray(genresData) ? genresData : []);
// Fetch playlists
const playlistsData = await playlistsApi.getPlaylists();
setAvailablePlaylists(playlistsData);
console.log('Fetched data:', { genres: genresData, playlists: playlistsData }); // Debugging
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
const handleGenreClick = (genre: string) => {
// Clear playlist selection when selecting custom genres
setSelectedPlaylistId(null);
setSelectedGenres(prev =>
prev.includes(genre)
? prev.filter(g => g !== genre)
: [...prev, genre]
);
};
const handleClearGenres = () => {
setSelectedGenres([]);
};
const handlePlaylistSelect = (playlistId: string) => {
// Clear genre selection when selecting a playlist
if (selectedPlaylistId === playlistId) {
setSelectedPlaylistId(null);
} else {
setSelectedPlaylistId(playlistId);
setSelectedGenres([]);
}
};
const toggleGenreVisibility = () => {
setShowGenres(!showGenres);
if (!showGenres) {
setShowPlaylists(false);
}
};
const togglePlaylistVisibility = () => {
setShowPlaylists(!showPlaylists);
if (!showPlaylists) {
setShowGenres(false);
}
};
const getSelectedPlaylistName = () => {
if (!selectedPlaylistId) return null;
const playlist = availablePlaylists.find(p => p.id === selectedPlaylistId);
return playlist ? playlist.name : null;
};
const startGame = () => {
onStart(
NUM_SONGS,
NUM_CHOICES,
selectedGenres.length > 0 ? selectedGenres : undefined,
selectedPlaylistId || undefined
);
};
return (
<div className="welcome-screen">
<div className="welcome-screen__header">
<h1 className="welcome-screen__title">{APP_NAME}</h1>
<VinylAnimation />
</div>
<div className="welcome-screen__card-container">
<div className="welcome-screen__card">
<div className="welcome-screen__content">
<div className="welcome-screen__options-section">
<div>
<p>
Welcome to {APP_NAME}!
</p>
</div>
<div>
<button
className="welcome-screen__toggle-button"
onClick={togglePlaylistVisibility}
>
{selectedPlaylistId ?
`Playlist: ${getSelectedPlaylistName()}` :
'Choose a Playlist'}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
style={{
transform: showPlaylists ? 'rotate(180deg)' : 'rotate(0)',
transition: 'transform 0.3s ease'
}}
>
<path
d="M19 9L12 16L5 9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<AnimatePresence>
{showPlaylists && (
<motion.div
className="welcome-screen__dropdown-panel"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
>
{!isLoading ? (
<div className="welcome-screen__playlist-grid">
{availablePlaylists.map((playlist) => (
<button
key={playlist.id}
className={`playlist-item ${selectedPlaylistId === playlist.id ? 'selected' : ''}`}
onClick={() => handlePlaylistSelect(playlist.id)}
>
<div className="playlist-item__title">{playlist.name}</div>
<div className="playlist-item__description">{playlist.description}</div>
</button>
))}
{availablePlaylists.length === 0 && (
<div className="no-results">No playlists available</div>
)}
</div>
) : (
<div className="loading">Loading playlists...</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
<div>
<button
className="welcome-screen__toggle-button"
onClick={toggleGenreVisibility}
disabled={!!selectedPlaylistId}
>
{selectedGenres.length > 0 ?
`${selectedGenres.length} genre${selectedGenres.length !== 1 ? 's' : ''} selected` :
'Or Select Custom Genres'}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
style={{
transform: showGenres ? 'rotate(180deg)' : 'rotate(0)',
transition: 'transform 0.3s ease'
}}
>
<path
d="M19 9L12 16L5 9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<AnimatePresence>
{showGenres && (
<motion.div
className="welcome-screen__dropdown-panel"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
>
{!isLoading ? (
<>
<div className="welcome-screen__genre-grid">
{availableGenres.map((genre) => (
<button
key={genre}
className={`genre-chip ${selectedGenres.includes(genre) ? 'selected' : ''}`}
onClick={() => handleGenreClick(genre)}
>
{genre}
</button>
))}
</div>
<div className="welcome-screen__selected-genres">
<span>
{selectedGenres.length === 0
? 'No genres selected (all genres will be included)'
: `${selectedGenres.length} genre${selectedGenres.length !== 1 ? 's' : ''} selected`}
</span>
{selectedGenres.length > 0 && (
<button
className="welcome-screen__clear-genres"
onClick={handleClearGenres}
>
Clear all
</button>
)}
</div>
</>
) : (
<div className="loading">Loading genres...</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<button
className="welcome-screen__start-button"
onClick={startGame}
>
START GAME
</button>
<div className="welcome-screen__instructions">
<h3 className="welcome-screen__label">How to play</h3>
<p><FontAwesomeIcon icon={faMusic} /> Listen to a clip and guess the song</p>
<p><FontAwesomeIcon icon={faGamepad} /> Each song has 4 different options to choose from</p>
<p><FontAwesomeIcon icon={faTrophy} /> Try to get the highest score possible!</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default WelcomeScreen;

View File

@@ -0,0 +1 @@
export { default } from './WelcomeScreen';

View File

@@ -0,0 +1,146 @@
@use '../../../styles/variables.scss';
@use '../../../styles/mixins.scss';
.app-layout {
position: relative;
min-height: 100vh;
overflow: hidden;
z-index: 0;
display: flex;
flex-direction: column;
&__background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: variables.$background-color;
z-index: -2;
&:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(
circle at top right,
rgba(122, 75, 255, 0.15) 0%,
rgba(0, 0, 0, 0) 60%
);
}
}
&--gradient &__background {
background: linear-gradient(135deg, #1A1A1A 0%, #000000 100%);
}
&__content {
flex: 1;
position: relative;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding-top: env(safe-area-inset-top);
padding-bottom: calc(env(safe-area-inset-bottom) + 60px);
z-index: 1;
}
&__orbs {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: -1;
pointer-events: none;
}
&__orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.3;
&--1 {
top: -100px;
right: -100px;
width: 400px;
height: 400px;
background: rgba(variables.$primary-color, 0.5);
animation: float1 20s ease-in-out infinite alternate;
}
&--2 {
bottom: -150px;
left: -150px;
width: 500px;
height: 500px;
background: rgba(variables.$secondary-color, 0.3);
animation: float2 25s ease-in-out infinite alternate;
}
&--3 {
top: 50%;
left: 50%;
width: 300px;
height: 300px;
background: rgba(variables.$info-color, 0.2);
animation: float3 22s ease-in-out infinite alternate;
}
}
&__footer {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: variables.$spacing-sm 0;
z-index: 2;
&-content {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 variables.$spacing-md;
display: flex;
justify-content: center;
align-items: center;
p {
color: variables.$text-tertiary;
font-size: 12px;
}
}
}
}
@keyframes float1 {
0% {
transform: translate(0, 0) rotate(0deg);
}
100% {
transform: translate(-100px, 100px) rotate(60deg);
}
}
@keyframes float2 {
0% {
transform: translate(0, 0) rotate(0deg);
}
100% {
transform: translate(100px, -100px) rotate(-60deg);
}
}
@keyframes float3 {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-40%, -60%) rotate(40deg);
}
}

View File

@@ -0,0 +1,36 @@
import React, { ReactNode } from 'react';
import { motion } from 'framer-motion';
import './AppLayout.scss';
interface AppLayoutProps {
children: ReactNode;
background?: 'default' | 'gradient';
}
const AppLayout: React.FC<AppLayoutProps> = ({
children,
background = 'default'
}) => {
return (
<div className={`app-layout app-layout--${background}`}>
<div className="app-layout__background" />
<div className="app-layout__content">
{children}
</div>
<motion.div
className="app-layout__orbs"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1 }}
>
<div className="app-layout__orb app-layout__orb--1" />
<div className="app-layout__orb app-layout__orb--2" />
<div className="app-layout__orb app-layout__orb--3" />
</motion.div>
</div>
);
};
export default AppLayout;

View File

@@ -0,0 +1 @@
export { default } from './AppLayout';

View File

@@ -0,0 +1,126 @@
@use "sass:color";
@use '../../../styles/variables.scss' as variables;
@use '../../../styles/mixins.scss' as mixins;
.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 variables.$spacing-md;
border-radius: variables.$border-radius-md;
font-weight: 600;
transition: all 0.2s ease;
cursor: pointer;
outline: none;
text-decoration: none;
border: none;
position: relative;
overflow: hidden;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
&__text {
position: relative;
z-index: 1;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
&--left {
margin-right: variables.$spacing-xs;
}
&--right {
margin-left: variables.$spacing-xs;
}
}
// Variants
&--primary {
background-color: variables.$primary-color;
color: variables.$text-on-primary;
&:hover {
background-color: color.adjust(variables.$primary-color, $lightness: 5%);
box-shadow: 0 2px 8px rgba(variables.$primary-color, 0.4);
}
&:active {
background-color: color.adjust(variables.$primary-color, $lightness: -5%);
}
}
&--secondary {
background-color: rgba(variables.$background-light, 0.1);
color: variables.$text-primary;
border: 1px solid rgba(variables.$primary-color, 0.3);
&:hover {
background-color: rgba(variables.$background-light, 0.2);
border-color: rgba(variables.$primary-color, 0.5);
}
&:active {
background-color: rgba(variables.$background-light, 0.3);
}
}
&--tertiary {
background-color: transparent;
color: variables.$text-primary;
&:hover {
background-color: rgba(variables.$background-light, 0.1);
}
&:active {
background-color: rgba(variables.$background-light, 0.2);
}
}
&--danger {
background-color: variables.$danger-color;
color: variables.$text-on-primary;
&:hover {
background-color: color.adjust(variables.$danger-color, $lightness: 5%);
box-shadow: 0 2px 8px rgba(variables.$danger-color, 0.4);
}
&:active {
background-color: color.adjust(variables.$danger-color, $lightness: -5%);
}
}
// Sizes
&--small {
height: 36px;
font-size: 14px;
min-width: 80px;
}
&--medium {
height: 46px;
font-size: 16px;
min-width: 120px;
}
&--large {
height: 56px;
font-size: 18px;
min-width: 150px;
}
&--full-width {
width: 100%;
}
}

View File

@@ -0,0 +1,57 @@
import React, { ButtonHTMLAttributes } from 'react';
import { motion, HTMLMotionProps } from 'framer-motion';
import './Button.scss';
export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className'> {
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'tertiary' | 'danger';
size?: 'small' | 'medium' | 'large';
fullWidth?: boolean;
icon?: React.ReactNode;
iconPosition?: 'left' | 'right';
className?: string;
}
const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'medium',
fullWidth = false,
icon,
iconPosition = 'left',
className = '',
...props
}) => {
const buttonClassName = `
button
button--${variant}
button--${size}
${fullWidth ? 'button--full-width' : ''}
${className}
`;
// Motion component props
const motionProps: HTMLMotionProps<"button"> = {
whileHover: { scale: 1.02 },
whileTap: { scale: 0.98 },
transition: { duration: 0.1 }
};
return (
<motion.button
className={buttonClassName}
{...motionProps}
{...props as any}
>
{icon && iconPosition === 'left' && (
<span className="button__icon button__icon--left">{icon}</span>
)}
<span className="button__text">{children}</span>
{icon && iconPosition === 'right' && (
<span className="button__icon button__icon--right">{icon}</span>
)}
</motion.button>
);
};
export default Button;

View File

@@ -0,0 +1 @@
export { default } from './Button';

View File

@@ -0,0 +1,64 @@
@use '../../../styles/variables.scss' as variables;
@use '../../../styles/mixins.scss' as mixins;
.card {
background-color: rgba(variables.$background-light, 0.1);
backdrop-filter: blur(10px);
border-radius: variables.$border-radius-lg;
padding: variables.$spacing-lg;
border: 1px solid rgba(variables.$background-light, 0.2);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
transition: all 0.2s ease;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
to right,
transparent,
rgba(variables.$primary-color, 0.3),
transparent
);
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
to right,
transparent,
rgba(variables.$primary-color, 0.1),
transparent
);
}
&--hover {
&:hover {
box-shadow: variables.$shadow-md;
border-color: rgba(255, 255, 255, 0.2);
}
}
&--interactive {
cursor: pointer;
&:hover {
box-shadow: variables.$shadow-md;
border-color: rgba(255, 255, 255, 0.2);
}
&:active {
transform: scale(0.98);
}
}
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { motion, HTMLMotionProps } from 'framer-motion';
import './Card.scss';
interface CardProps {
children: React.ReactNode;
className?: string;
onClick?: () => void;
}
const Card: React.FC<CardProps> = ({ children, className = '', onClick }) => {
// Motion component props
const motionProps: HTMLMotionProps<"div"> = {
whileHover: onClick ? { scale: 1.01 } : undefined,
whileTap: onClick ? { scale: 0.99 } : undefined,
transition: { duration: 0.1 }
};
return (
<motion.div
className={`card ${className}`}
onClick={onClick}
{...motionProps}
>
{children}
</motion.div>
);
};
export default Card;

View File

@@ -0,0 +1 @@
export { default } from './Card';

View File

@@ -0,0 +1,127 @@
.loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
z-index: 1000;
color: white;
font-family: 'Poppins', sans-serif;
transition: opacity 0.3s ease-in-out;
opacity: 0;
pointer-events: none;
&.visible {
opacity: 1;
pointer-events: all;
}
&__container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
max-width: 80%;
}
&__title {
font-size: 1.5rem;
margin-bottom: 2rem;
font-weight: 600;
text-shadow: 0 0 10px rgba(var(--accent-rgb), 0.8);
}
&__animation {
position: relative;
width: 100px;
height: 100px;
margin-bottom: 2rem;
}
&__blocks {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
&__block {
width: 15px;
height: 15px;
border-radius: 3px;
animation: loading-block-fall 1.8s infinite ease-in-out;
&--1 {
background-color: #4285F4; // Google blue
animation-delay: 0s;
}
&--2 {
background-color: #EA4335; // Google red
animation-delay: 0.2s;
}
&--3 {
background-color: #FBBC05; // Google yellow
animation-delay: 0.4s;
}
&--4 {
background-color: #34A853; // Google green
animation-delay: 0.6s;
}
}
&__message {
font-size: 1rem;
color: rgba(255, 255, 255, 0.8);
margin-top: 1.5rem;
animation: message-fade 0.5s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
&-icon {
font-size: 1.2rem;
color: var(--accent-color, #4f46e5);
}
}
}
@keyframes loading-block-fall {
0% {
transform: translateY(-20px);
opacity: 0;
}
20% {
transform: translateY(0);
opacity: 1;
}
80% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(20px);
opacity: 0;
}
}
@keyframes message-fade {
0% {
opacity: 0;
transform: translateY(5px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,92 @@
import React, { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCompactDisc,
faGuitar,
faRecordVinyl,
faHeadphones,
faMusic,
faDrum,
faVolumeUp,
faLightbulb,
faUsers,
faMicrophone,
faRandom,
faSliders,
faBrain,
faSearch,
faChair,
faVolumeHigh
} from '@fortawesome/free-solid-svg-icons';
import { APP_NAME } from '../../../App';
import './LoadingScreen.scss';
interface LoadingScreenProps {
isLoading: boolean;
}
// Fun music-themed loading messages with corresponding icons
const loadingMessages = [
{ text: "Spinning up the turntables...", icon: faCompactDisc },
{ text: "Tuning the instruments...", icon: faGuitar },
{ text: "Polishing the vinyl records...", icon: faRecordVinyl },
{ text: "Untangling the headphones...", icon: faHeadphones },
{ text: "Cueing up the next track...", icon: faMusic },
{ text: "Finding the perfect beat...", icon: faDrum },
{ text: "Dusting off the album covers...", icon: faRecordVinyl },
{ text: "Warming up the amplifiers...", icon: faVolumeUp },
{ text: "Setting the mood lighting...", icon: faLightbulb },
{ text: "Gathering the band members...", icon: faUsers },
{ text: "Clearing the stage...", icon: faMusic },
{ text: "Testing the microphones...", icon: faMicrophone },
{ text: "Shuffling the playlist...", icon: faRandom },
{ text: "Adjusting the equalizer...", icon: faSliders },
{ text: "Loading music knowledge...", icon: faBrain },
{ text: "Summoning musical genius...", icon: faMusic },
{ text: "Searching the record collection...", icon: faSearch },
{ text: "Dropping the needle...", icon: faRecordVinyl },
{ text: "Finding the best seats in the house...", icon: faChair },
{ text: "Checking sound levels...", icon: faVolumeHigh }
];
const LoadingScreen: React.FC<LoadingScreenProps> = ({ isLoading }) => {
const [message, setMessage] = useState(loadingMessages[0]);
const [key, setKey] = useState(0); // Key for animation reset
// Change loading message every 2 seconds
useEffect(() => {
if (!isLoading) return;
const interval = setInterval(() => {
const randomIndex = Math.floor(Math.random() * loadingMessages.length);
setMessage(loadingMessages[randomIndex]);
setKey(prev => prev + 1); // Reset animation
}, 2000);
return () => clearInterval(interval);
}, [isLoading]);
return (
<div className={`loading-screen ${isLoading ? 'visible' : ''}`}>
<div className="loading-screen__container">
<h2 className="loading-screen__title">{APP_NAME}</h2>
<div className="loading-screen__animation">
<div className="loading-screen__blocks">
<div className="loading-screen__block loading-screen__block--1"></div>
<div className="loading-screen__block loading-screen__block--2"></div>
<div className="loading-screen__block loading-screen__block--3"></div>
<div className="loading-screen__block loading-screen__block--4"></div>
</div>
</div>
<div key={key} className="loading-screen__message">
<FontAwesomeIcon icon={message.icon} className="loading-screen__message-icon" />
<span>{message.text}</span>
</div>
</div>
</div>
);
};
export default LoadingScreen;

View File

@@ -0,0 +1,2 @@
import LoadingScreen from './LoadingScreen';
export default LoadingScreen;

View File

@@ -0,0 +1,60 @@
@use "sass:color";
@use '../../../styles/variables.scss';
@use '../../../styles/mixins.scss';
.progress-bar {
width: 100%;
background-color: rgba(255, 255, 255, 0.1);
border-radius: variables.$border-radius-full;
overflow: hidden;
position: relative;
&__fill {
height: 100%;
border-radius: variables.$border-radius-full;
position: relative;
&--primary {
background: variables.$primary-gradient;
}
&--success {
background: linear-gradient(90deg, variables.$success-color, color.adjust(variables.$success-color, $lightness: -10%));
}
&--warning {
background: linear-gradient(90deg, variables.$warning-color, color.adjust(variables.$warning-color, $lightness: -10%));
}
&--danger {
background: linear-gradient(90deg, variables.$danger-color, color.adjust(variables.$danger-color, $lightness: -10%));
}
}
&__pulse {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.8);
transform: translate(-50%, -50%);
filter: blur(1px);
animation: pulse 1.5s infinite;
}
}
@keyframes pulse {
0% {
transform: translate(-50%, -50%) scale(0.7);
opacity: 0.9;
}
50% {
transform: translate(-50%, -50%) scale(1.2);
opacity: 0.3;
}
100% {
transform: translate(-50%, -50%) scale(0.7);
opacity: 0.9;
}
}

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { motion } from 'framer-motion';
import './ProgressBar.scss';
export interface ProgressBarProps {
progress: number; // 0 to 100
height?: number;
color?: 'primary' | 'success' | 'warning' | 'danger';
className?: string;
animated?: boolean;
}
export const ProgressBar: React.FC<ProgressBarProps> = ({
progress,
height = 6,
color = 'primary',
className = '',
animated = true,
}) => {
// Ensure progress is between 0 and 100
const normalizedProgress = Math.max(0, Math.min(100, progress));
return (
<div
className={`progress-bar ${className}`}
style={{ height: `${height}px` }}
>
<motion.div
className={`progress-bar__fill progress-bar__fill--${color}`}
initial={{ width: '0%' }}
animate={{ width: `${normalizedProgress}%` }}
transition={{
duration: animated ? 0.3 : 0,
ease: 'easeOut'
}}
/>
{animated && (
<div className="progress-bar__pulse" style={{ left: `${normalizedProgress}%` }} />
)}
</div>
);
};
export default ProgressBar;

View File

@@ -0,0 +1 @@
export { default, ProgressBar, type ProgressBarProps } from './ProgressBar';

View File

@@ -0,0 +1,76 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface ThemeContextProps {
songColor: string;
setSongColor: (color: string) => void;
getColorRgb: () => { r: number; g: number; b: number };
getLighterColor: (opacity?: number) => string;
getDarkerColor: (percentage?: number) => string;
}
const defaultContext: ThemeContextProps = {
songColor: '4f46e5', // Default purple color
setSongColor: () => {},
getColorRgb: () => ({ r: 79, g: 70, b: 229 }), // Default purple in RGB
getLighterColor: () => 'rgba(79, 70, 229, 0.2)',
getDarkerColor: () => '#3b35b1',
};
const ThemeContext = createContext<ThemeContextProps>(defaultContext);
export const useTheme = () => useContext(ThemeContext);
interface ThemeProviderProps {
children: ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [songColor, setSongColor] = useState<string>(defaultContext.songColor);
// Convert hex to RGB
const getColorRgb = () => {
// Handle hex with or without '#'
const hex = songColor.charAt(0) === '#' ? songColor.substring(1) : songColor;
// Parse hex string
const bigint = parseInt(hex, 16);
// Extract RGB components
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return { r, g, b };
};
// Get a lighter version of the color
const getLighterColor = (opacity = 0.2) => {
const { r, g, b } = getColorRgb();
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
// Get a darker version of the color
const getDarkerColor = (percentage = 30) => {
const { r, g, b } = getColorRgb();
const darken = (1 - percentage / 100);
const rd = Math.floor(r * darken);
const gd = Math.floor(g * darken);
const bd = Math.floor(b * darken);
return `#${rd.toString(16).padStart(2, '0')}${gd.toString(16).padStart(2, '0')}${bd.toString(16).padStart(2, '0')}`;
};
return (
<ThemeContext.Provider
value={{
songColor,
setSongColor,
getColorRgb,
getLighterColor,
getDarkerColor
}}
>
{children}
</ThemeContext.Provider>
);
};

View File

@@ -0,0 +1,196 @@
import { useState, useEffect, useRef, useCallback } from 'react';
interface AudioState {
isPlaying: boolean;
isLoading: boolean;
duration: number;
currentTime: number;
error: string | null;
}
interface AudioControls {
play: () => void;
pause: () => void;
toggle: () => void;
setVolume: (volume: number) => void;
seek: (time: number) => void;
stop: () => void;
}
/**
* Custom hook for controlling audio playback
* @param url The URL of the audio file to play
* @returns [AudioState, AudioControls]
*/
const useAudioPlayer = (url: string): [AudioState, AudioControls] => {
// Create a single audio element instance
const audioRef = useRef<HTMLAudioElement | null>(null);
// State to track audio playback
const [audioState, setAudioState] = useState<AudioState>({
isPlaying: false,
isLoading: true,
duration: 0,
currentTime: 0,
error: null,
});
// Initialize or update audio element when URL changes
useEffect(() => {
// Clean up previous instance
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.removeAttribute('src');
audioRef.current.load();
}
if (!url) {
setAudioState(prev => ({
...prev,
isLoading: false,
isPlaying: false,
error: null,
}));
return;
}
// Create new audio element if needed
if (!audioRef.current) {
audioRef.current = new Audio();
}
const audio = audioRef.current;
// Set new source
audio.src = url;
audio.load();
// Reset state for new track
setAudioState({
isPlaying: false,
isLoading: true,
duration: 0,
currentTime: 0,
error: null,
});
// Setup event listeners
const onLoadedMetadata = () => {
setAudioState(prev => ({
...prev,
duration: audio.duration,
isLoading: false,
}));
};
const onTimeUpdate = () => {
setAudioState(prev => ({
...prev,
currentTime: audio.currentTime,
}));
};
const onEnded = () => {
setAudioState(prev => ({
...prev,
isPlaying: false,
currentTime: 0,
}));
};
const onError = () => {
setAudioState(prev => ({
...prev,
error: 'Failed to load audio',
isLoading: false,
}));
};
// Add event listeners
audio.addEventListener('loadedmetadata', onLoadedMetadata);
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('ended', onEnded);
audio.addEventListener('error', onError);
// Cleanup function
return () => {
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
audio.removeEventListener('timeupdate', onTimeUpdate);
audio.removeEventListener('ended', onEnded);
audio.removeEventListener('error', onError);
};
}, [url]);
// Audio control functions
const play = useCallback(() => {
if (!audioRef.current) return;
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
setAudioState(prev => ({ ...prev, isPlaying: true }));
})
.catch(error => {
console.error('Playback error:', error);
setAudioState(prev => ({
...prev,
error: 'Failed to play audio',
isPlaying: false,
}));
});
}
}, []);
const pause = useCallback(() => {
if (!audioRef.current) return;
audioRef.current.pause();
setAudioState(prev => ({ ...prev, isPlaying: false }));
}, []);
const toggle = useCallback(() => {
if (audioState.isPlaying) {
pause();
} else {
play();
}
}, [audioState.isPlaying, pause, play]);
const setVolume = useCallback((volume: number) => {
if (!audioRef.current) return;
audioRef.current.volume = Math.max(0, Math.min(1, volume));
}, []);
const seek = useCallback((time: number) => {
if (!audioRef.current) return;
audioRef.current.currentTime = Math.max(0, Math.min(time, audioRef.current.duration || 0));
setAudioState(prev => ({ ...prev, currentTime: audioRef.current?.currentTime || 0 }));
}, []);
const stop = useCallback(() => {
if (!audioRef.current) return;
audioRef.current.pause();
audioRef.current.currentTime = 0;
setAudioState(prev => ({
...prev,
isPlaying: false,
currentTime: 0,
}));
}, []);
// Return audio state and controls
return [
audioState,
{
play,
pause,
toggle,
setVolume,
seek,
stop,
},
];
};
export default useAudioPlayer;

View File

@@ -0,0 +1,194 @@
import { useState, useCallback } from 'react';
import {
ApiAnswerResponse,
ApiGameQuestion,
ApiGameResponse,
ApiGameSession,
GameSettings,
GameState,
GameSummaryType,
Song
} from '../types/game';
import { gameApi } from '../services/api';
interface UseGameProps {
onGameComplete?: (summary: GameSummaryType) => void;
}
export default function useGame({ onGameComplete }: UseGameProps = {}) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [gameState, setGameState] = useState<GameState>({
questions: [],
currentQuestionIndex: 0,
score: 0,
correctAnswers: 0,
loading: false,
error: null,
});
const [currentApiQuestion, setCurrentApiQuestion] = useState<ApiGameQuestion | null>(null);
const [isCreatingGame, setIsCreatingGame] = useState<boolean>(false);
// Create a new game session
const createGame = useCallback(async (settings: GameSettings) => {
// Prevent multiple concurrent game creation requests
if (isCreatingGame) {
console.log('Game creation already in progress, skipping duplicate request');
return null;
}
try {
setIsCreatingGame(true);
setGameState(prev => ({ ...prev, loading: true, error: null }));
// Create a new game session
const session: ApiGameSession = await gameApi.createGame(settings);
setSessionId(session.session_id);
// Start the game
const gameResponse: ApiGameResponse = await gameApi.startGame(session.session_id);
setCurrentApiQuestion(gameResponse.question);
// Update state
setGameState(prev => ({
...prev,
loading: false,
currentQuestionIndex: gameResponse.current_question,
score: gameResponse.score,
}));
setIsCreatingGame(false);
return gameResponse;
} catch (error) {
console.error('Error creating game:', error);
setGameState(prev => ({
...prev,
loading: false,
error: error instanceof Error ? error.message : 'Failed to create game'
}));
setIsCreatingGame(false);
return null;
}
}, [isCreatingGame]);
// Answer a question
const answerQuestion = useCallback(async (optionIndex: number) => {
if (!sessionId || !currentApiQuestion) return null;
try {
setGameState(prev => ({ ...prev, loading: true }));
// Submit the answer
const response: ApiAnswerResponse = await gameApi.answerQuestion(
sessionId,
gameState.currentQuestionIndex,
optionIndex
);
// Update state based on the response
setGameState(prev => ({
...prev,
loading: false,
score: response.score,
correctAnswers: prev.correctAnswers + (response.correct ? 1 : 0),
}));
// If the game is complete, call the completion callback
if (response.game_complete && onGameComplete) {
// Use currentQuestionIndex + 1 as the total questions count
// This represents the current question number, which is the total number of questions answered
const totalQuestions = gameState.currentQuestionIndex + 1;
const correctAnswers = gameState.correctAnswers + (response.correct ? 1 : 0);
const summary: GameSummaryType = {
score: response.score,
totalQuestions,
correctAnswers,
accuracy: (correctAnswers / totalQuestions) * 100,
};
onGameComplete(summary);
}
// If there's a next question, move to it
else if (response.next_question_index !== undefined) {
// Get the next question
const gameResponse = await gameApi.getGameState(sessionId);
setCurrentApiQuestion(gameResponse.question);
// Update state
setGameState(prev => ({
...prev,
currentQuestionIndex: response.next_question_index!,
}));
}
return response;
} catch (error) {
console.error('Error answering question:', error);
setGameState(prev => ({
...prev,
loading: false,
error: error instanceof Error ? error.message : 'Failed to submit answer'
}));
return null;
}
}, [sessionId, currentApiQuestion, gameState.currentQuestionIndex, gameState.questions.length, gameState.correctAnswers, onGameComplete]);
// Reset the game
const resetGame = useCallback(() => {
setSessionId(null);
setCurrentApiQuestion(null);
setGameState({
questions: [],
currentQuestionIndex: 0,
score: 0,
correctAnswers: 0,
loading: false,
error: null,
});
}, []);
// Convert the current API question to a frontend song object
const getCurrentQuestion = useCallback(() => {
if (!currentApiQuestion) return null;
// Extract option data from the API question
const options: Song[] = currentApiQuestion.options.map(option => ({
id: option.song_id.toString(),
title: option.name,
artist: '', // The API doesn't include artist in options
coverUrl: '', // Will be populated from blurred_cover_url
audioUrl: currentApiQuestion.preview_url,
}));
// Find the correct option
const correctOption = options[currentApiQuestion.correct_option_index];
// Add artists to the correct option from the question
if (correctOption) {
correctOption.artist = currentApiQuestion.artists || '';
}
return {
options,
correctOption,
timeLimit: currentApiQuestion.time_limit,
coverUrl: currentApiQuestion.blurred_cover_url,
clearCoverUrl: currentApiQuestion.clear_cover_url,
songColor: currentApiQuestion.song_color || '4f46e5', // Use the song color or default to a purple color
artists: currentApiQuestion.artists || '', // Include artists from API
};
}, [currentApiQuestion]);
// Check if game already exists and is valid
const hasActiveGame = useCallback(() => {
return sessionId !== null && currentApiQuestion !== null;
}, [sessionId, currentApiQuestion]);
return {
gameState,
sessionId,
createGame,
answerQuestion,
resetGame,
getCurrentQuestion,
hasActiveGame
};
}

View File

@@ -0,0 +1,187 @@
import { useState, useEffect, useRef, useCallback } from 'react';
interface TimerState {
timeLeft: number;
progress: number;
isRunning: boolean;
}
interface TimerControls {
start: () => void;
pause: () => void;
reset: () => void;
restart: () => void;
}
interface TimerOptions {
autoStart?: boolean;
interval?: number;
}
/**
* Custom hook for creating a countdown timer
* @param duration Duration in seconds
* @param onComplete Callback when timer completes
* @param options Timer options
* @returns [TimerState, TimerControls]
*/
const useTimer = (
duration: number,
onComplete?: () => void,
options: TimerOptions = {}
): [TimerState, TimerControls] => {
const { autoStart = true, interval = 100 } = options;
// State to track timer
const [state, setState] = useState<TimerState>({
timeLeft: duration,
progress: 100,
isRunning: autoStart,
});
// Refs to prevent stale closures in setInterval
const timeLeftRef = useRef<number>(duration);
const isRunningRef = useRef<boolean>(autoStart);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const onCompleteRef = useRef<(() => void) | undefined>(onComplete);
// Update ref when callback changes
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
// Main timer logic
const setupTimer = useCallback(() => {
// Clear any existing interval
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// Only set up if timer should be running
if (!isRunningRef.current) return;
// Set up new interval
intervalRef.current = setInterval(() => {
// Decrease time left
timeLeftRef.current = Math.max(0, timeLeftRef.current - (interval / 1000));
// Calculate progress percentage
const progressValue = (timeLeftRef.current / duration) * 100;
// Update state
setState({
timeLeft: timeLeftRef.current,
progress: progressValue,
isRunning: timeLeftRef.current > 0 && isRunningRef.current,
});
// Check if timer completed
if (timeLeftRef.current <= 0) {
// Stop the timer
isRunningRef.current = false;
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// Call completion callback
if (onCompleteRef.current) {
onCompleteRef.current();
}
}
}, interval);
}, [duration, interval]);
// Set up timer when running state changes
useEffect(() => {
timeLeftRef.current = state.timeLeft;
isRunningRef.current = state.isRunning;
if (state.isRunning) {
setupTimer();
}
// Cleanup on unmount or when dependencies change
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [state.isRunning, state.timeLeft, setupTimer]);
// Initialize timer when duration changes
useEffect(() => {
// Reset state
timeLeftRef.current = duration;
setState({
timeLeft: duration,
progress: 100,
isRunning: autoStart,
});
isRunningRef.current = autoStart;
// Set up timer if auto-start is enabled
if (autoStart) {
setupTimer();
}
// Cleanup on unmount
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [duration, autoStart, setupTimer]);
// Timer control functions
const start = useCallback(() => {
if (state.isRunning || state.timeLeft <= 0) return;
isRunningRef.current = true;
setState(prev => ({ ...prev, isRunning: true }));
}, [state.isRunning, state.timeLeft]);
const pause = useCallback(() => {
if (!state.isRunning) return;
isRunningRef.current = false;
setState(prev => ({ ...prev, isRunning: false }));
}, [state.isRunning]);
const reset = useCallback(() => {
timeLeftRef.current = duration;
isRunningRef.current = false;
setState({
timeLeft: duration,
progress: 100,
isRunning: false,
});
}, [duration]);
const restart = useCallback(() => {
timeLeftRef.current = duration;
isRunningRef.current = true;
setState({
timeLeft: duration,
progress: 100,
isRunning: true,
});
}, [duration]);
return [
state,
{
start,
pause,
reset,
restart,
},
];
};
export default useTimer;

72
frontend/src/index.css Normal file
View File

@@ -0,0 +1,72 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
/* Default accent color values (will be overridden by DynamicTheme) */
--accent-color: #0A84FF;
--accent-rgb: 10, 132, 255;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

12
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import { ThemeProvider } from './contexts/ThemeContext.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,133 @@
import axios from 'axios';
import { Playlist } from '../types/song';
// Create axios instance with base URL
const API_BASE_URL = '/api';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Game API
export const gameApi = {
// Create a new game session
createGame: async (settings: { numSongs: number; numChoices: number; genres?: string[]; playlist_id?: string; start_year?: number; end_year?: number }) => {
console.log('Creating game with settings:', settings); // Debug log
const response = await api.post('/game/create', {
num_songs: settings.numSongs,
num_choices: settings.numChoices,
genres: settings.genres,
playlist_id: settings.playlist_id,
start_year: settings.start_year,
end_year: settings.end_year
});
return response.data;
},
// Start a game session
startGame: async (sessionId: string) => {
const response = await api.post(`/game/start/${sessionId}`);
return response.data;
},
// Get the current state of a game
getGameState: async (sessionId: string) => {
const response = await api.get(`/game/state/${sessionId}`);
return response.data;
},
// Submit an answer to a question
answerQuestion: async (sessionId: string, questionIndex: number, selectedOptionIndex: number) => {
const response = await api.post('/game/answer', {
session_id: sessionId,
question_index: questionIndex,
selected_option_index: selectedOptionIndex,
});
return response.data;
},
// Get a summary of a completed game
getGameSummary: async (sessionId: string) => {
const response = await api.get(`/game/summary/${sessionId}`);
return response.data;
},
};
// Songs API
export const songsApi = {
// Get a list of songs
getSongs: async (limit = 20, offset = 0) => {
const response = await api.get('/songs', {
params: { limit, offset },
});
return response.data;
},
// Get the total number of songs
getSongCount: async () => {
const response = await api.get('/songs/count');
return response.data.count;
},
// Get available genres
getGenres: async () => {
const response = await api.get('/songs/genres');
return response.data;
},
// Get a song by ID
getSong: async (songId: number) => {
const response = await api.get(`/songs/${songId}`);
return response.data;
},
// Get random songs
getRandomSongs: async (count = 5) => {
const response = await api.get('/songs/random', {
params: { count },
});
return response.data;
},
};
// Preview API
export const previewApi = {
// Get audio preview URL for a song
getAudioPreview: async (songId: number) => {
const response = await api.get(`/preview/audio/${songId}`);
return response.data.preview_url;
},
// Get blurred cover image for a song
getBlurredCover: async (songId: number, blurLevel = 10) => {
const response = await api.get(`/preview/cover/${songId}`, {
params: { blur_level: blurLevel },
});
return response.data;
},
};
// Playlists API
export const playlistsApi = {
// Get all available playlists
getPlaylists: async (): Promise<Playlist[]> => {
const response = await api.get('/playlists');
return response.data;
},
// Get a playlist by ID
getPlaylist: async (playlistId: string): Promise<Playlist> => {
const response = await api.get(`/playlists/${playlistId}`);
return response.data;
},
};
export default {
game: gameApi,
songs: songsApi,
preview: previewApi,
playlists: playlistsApi,
};

View File

@@ -0,0 +1,148 @@
@use "sass:color";
@use 'variables.scss';
@use 'mixins.scss';
@import '@fontsource/inter/400.css';
@import '@fontsource/inter/500.css';
@import '@fontsource/inter/600.css';
@import '@fontsource/inter/700.css';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
width: 100%;
}
body {
font-family: variables.$font-family-base;
font-size: variables.$font-size-base;
font-weight: variables.$font-weight-normal;
color: variables.$text-primary;
background-color: variables.$background-color;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
}
// Animation for page transitions
.page-transition-enter {
opacity: 0;
transform: translateY(20px);
}
.page-transition-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 300ms, transform 300ms;
}
.page-transition-exit {
opacity: 1;
transform: translateY(0);
}
.page-transition-exit-active {
opacity: 0;
transform: translateY(-20px);
transition: opacity 300ms, transform 300ms;
}
// Button styles
button {
cursor: pointer;
font-family: variables.$font-family-base;
}
// Link styles
a {
color: variables.$primary-color;
text-decoration: none;
transition: color variables.$transition-fast;
&:hover {
color: color.adjust(variables.$primary-color, $lightness: -10%);
}
}
// Glass card styles
.glass-card {
@include mixins.glassmorphism;
padding: variables.$spacing-lg;
border-radius: variables.$border-radius-lg;
}
// Container
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 variables.$spacing-lg;
@include mixins.mobile {
padding: 0 variables.$spacing-md;
}
}
// Focus styles
:focus {
outline: 2px solid variables.$primary-color;
outline-offset: 2px;
}
// Remove focus outline for mouse users
:focus:not(:focus-visible) {
outline: none;
}
// Utility classes
.text-center {
text-align: center;
}
.mb-sm {
margin-bottom: variables.$spacing-sm;
}
.mb-md {
margin-bottom: variables.$spacing-md;
}
.mb-lg {
margin-bottom: variables.$spacing-lg;
}
.mt-sm {
margin-top: variables.$spacing-sm;
}
.mt-md {
margin-top: variables.$spacing-md;
}
.mt-lg {
margin-top: variables.$spacing-lg;
}
// Animations
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeIn variables.$transition-base forwards;
}
// Background with gradient
.bg-gradient {
background: variables.$primary-gradient;
}

View File

@@ -0,0 +1,130 @@
@use 'variables.scss';
// Glassmorphism style mixin
@mixin glassmorphism {
background: variables.$glass-gradient;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid variables.$border-color;
box-shadow: variables.$shadow-sm;
}
// Responsive media queries
@mixin mobile {
@media (max-width: #{variables.$breakpoint-md - 1px}) {
@content;
}
}
@mixin tablet {
@media (min-width: #{variables.$breakpoint-md}) and (max-width: #{variables.$breakpoint-lg - 1px}) {
@content;
}
}
@mixin desktop {
@media (min-width: #{variables.$breakpoint-lg}) {
@content;
}
}
// Flex helpers
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
@mixin flex-column {
display: flex;
flex-direction: column;
}
// Typography styles
@mixin heading-1 {
font-size: 36px;
font-weight: variables.$font-weight-bold;
line-height: 1.2;
}
@mixin heading-2 {
font-size: 28px;
font-weight: variables.$font-weight-bold;
line-height: 1.3;
}
@mixin heading-3 {
font-size: 22px;
font-weight: variables.$font-weight-semibold;
line-height: 1.4;
}
@mixin body-large {
font-size: 18px;
line-height: 1.5;
}
@mixin body-regular {
font-size: variables.$font-size-base;
line-height: 1.5;
}
@mixin body-small {
font-size: 14px;
line-height: 1.5;
}
@mixin caption {
font-size: 12px;
line-height: 1.5;
}
// Button styles
@mixin button-base {
display: inline-flex;
align-items: center;
justify-content: center;
padding: variables.$spacing-sm variables.$spacing-lg;
border-radius: variables.$border-radius-full;
font-weight: variables.$font-weight-medium;
transition: all variables.$transition-base;
cursor: pointer;
border: none;
outline: none;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
@mixin button-primary {
@include button-base;
background: variables.$primary-gradient;
color: variables.$text-primary;
&:hover {
transform: translateY(-2px);
box-shadow: variables.$shadow-md;
}
&:active {
transform: translateY(0);
}
}
@mixin button-secondary {
@include button-base;
background: rgba(255, 255, 255, 0.1);
color: variables.$text-primary;
&:hover {
background: rgba(255, 255, 255, 0.15);
}
}

View File

@@ -0,0 +1,70 @@
/* Reset and base styles */
*, *:before, *:after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
position: relative;
overflow-x: hidden;
min-height: 100vh;
/* Support for iOS safe areas */
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
#root {
height: 100%;
width: 100%;
}
a {
color: inherit;
text-decoration: none;
}
button, input, select, textarea {
font-family: inherit;
font-size: inherit;
color: inherit;
background: none;
border: none;
outline: none;
appearance: none;
border-radius: 0;
margin: 0;
}
button {
cursor: pointer;
}
ul, ol {
list-style: none;
}
img, svg {
display: block;
max-width: 100%;
height: auto;
}
/* Remove animations and transitions for people who've turned them off */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -0,0 +1,68 @@
// Apple-inspired Design System Colors
$primary-color: #0A84FF;
$secondary-color: #5E5CE6;
$success-color: #30D158;
$warning-color: #FF9F0A;
$danger-color: #FF453A;
$error-color: #FF453A;
$info-color: #64D2FF;
// Theme Colors
$background-color: #000000;
$background-secondary-color: #1C1C1E;
$background-light: #2C2C2E;
$card-background: rgba(30, 30, 30, 0.8);
$text-primary: #FFFFFF;
$text-secondary: rgba(255, 255, 255, 0.7);
$text-tertiary: rgba(255, 255, 255, 0.5);
$text-on-primary: #FFFFFF;
$border-color: rgba(255, 255, 255, 0.15);
// Gradients
$primary-gradient: linear-gradient(135deg, #30B5FF 0%, #7A4BFF 100%);
$glass-gradient: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
// Typography
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji';
$font-size-base: 16px;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
// Spacing
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 16px;
$spacing-lg: 24px;
$spacing-xl: 32px;
$spacing-xxl: 48px;
// Border Radius
$border-radius-sm: 8px;
$border-radius-md: 12px;
$border-radius-lg: 16px;
$border-radius-xl: 20px;
$border-radius-full: 9999px;
$border-radius-pill: 9999px;
// Shadows
$shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.15);
$shadow-md: 0 8px 16px rgba(0, 0, 0, 0.2);
$shadow-lg: 0 12px 24px rgba(0, 0, 0, 0.25);
// Z-index
$z-index-dropdown: 1000;
$z-index-modal: 1050;
$z-index-toast: 1100;
// Transitions
$transition-fast: 0.15s ease;
$transition-base: 0.3s ease;
$transition-slow: 0.5s ease;
// Breakpoints
$breakpoint-sm: 576px;
$breakpoint-md: 768px;
$breakpoint-lg: 992px;
$breakpoint-xl: 1200px;

179
frontend/src/types/game.ts Normal file
View File

@@ -0,0 +1,179 @@
export interface Song {
id: string;
title: string;
artist: string;
coverUrl: string;
audioUrl: string;
}
export interface GameQuestion {
correctSong: Song;
options: Song[];
}
export interface GameSettings {
numSongs: number;
numChoices: number;
genres?: string[]; // Optional list of genres to filter songs
playlist_id?: string; // Optional playlist ID for predefined filters
start_year?: number; // Optional start year for filtering
end_year?: number; // Optional end year for filtering
}
export interface GameSummaryType {
score: number;
totalQuestions: number;
correctAnswers: number;
accuracy: number;
}
export interface GameState {
questions: GameQuestion[];
currentQuestionIndex: number;
score: number;
correctAnswers: number;
loading: boolean;
error: string | null;
}
export interface GameOption {
song_id: number;
name: string;
is_correct: boolean;
}
export interface GameSession {
session_id: string;
questions: GameQuestion[];
current_question: number;
score: number;
total_questions: number;
started_at: number;
}
export interface GameResponse {
session_id: string;
current_question: number;
total_questions: number;
question: GameQuestion;
score: number;
time_remaining: number | null;
}
export interface AnswerRequest {
session_id: string;
question_index: number;
selected_option_index: number;
}
export interface AnswerResponse {
correct: boolean;
correct_option_index: number;
score: number;
next_question_index: number | null;
game_complete: boolean;
}
export interface GameSummary {
session_id: string;
score: number;
total_questions: number;
accuracy: number;
}
// Backend API types
export interface ApiSong {
SongId: number;
Name: string;
Artists: string;
CoverSmall?: string;
CoverMedium?: string;
CoverBig?: string;
CoverXL?: string;
DeezerID?: number;
DeezerURL?: string;
AlbumName?: string;
}
export interface ApiGameOption {
song_id: number;
name: string;
is_correct: boolean;
}
export interface ApiGameQuestion {
song_id: number;
preview_url: string;
blurred_cover_url: string;
clear_cover_url: string;
correct_option_index: number;
options: ApiGameOption[];
time_limit: number;
song_color: string;
artists: string;
}
export interface ApiGameSession {
session_id: string;
questions: ApiGameQuestion[];
current_question: number;
score: number;
total_questions: number;
started_at: number;
}
export interface ApiGameResponse {
session_id: string;
current_question: number;
total_questions: number;
question: ApiGameQuestion;
score: number;
time_remaining?: number;
}
export interface ApiAnswerRequest {
session_id: string;
question_index: number;
selected_option_index: number;
}
export interface ApiAnswerResponse {
correct: boolean;
correct_option_index: number;
score: number;
next_question_index?: number;
game_complete: boolean;
points_earned: number;
}
export interface ApiGameSummary {
session_id: string;
score: number;
total_questions: number;
accuracy: number;
}
export interface ApiGameSettings {
num_songs: number;
num_choices: number;
genres?: string[]; // Optional list of genres to filter songs
playlist_id?: string; // Optional playlist ID for predefined filters
start_year?: number; // Optional start year for filtering
end_year?: number; // Optional end year for filtering
}
// Mapping functions to convert between API and frontend types
export const mapApiSongToSong = (apiSong: ApiSong): Song => ({
id: apiSong.SongId.toString(),
title: apiSong.Name,
artist: apiSong.Artists,
coverUrl: apiSong.CoverMedium || apiSong.CoverBig || apiSong.CoverXL || apiSong.CoverSmall || '',
audioUrl: apiSong.DeezerURL || '',
});
export const mapApiGameSummaryToGameSummary = (apiSummary: ApiGameSummary): GameSummaryType => ({
score: apiSummary.score,
totalQuestions: apiSummary.total_questions,
correctAnswers: Math.round(apiSummary.accuracy * apiSummary.total_questions / 100),
accuracy: apiSummary.accuracy,
});

View File

@@ -0,0 +1 @@
export * from './song';

View File

@@ -0,0 +1,41 @@
export interface Contributor {
id: number;
name: string;
role: string;
}
export interface Song {
SongId: number;
Name: string;
Artists: string;
Color: string;
DarkColor: string;
SongMetaId: number | null;
SpotifyId: string | null;
DeezerID: number | null;
DeezerURL: string | null;
CoverSmall: string | null;
CoverMedium: string | null;
CoverBig: string | null;
CoverXL: string | null;
ISRC: string | null;
BPM: number | null;
Duration: number | null;
ReleaseDate: string | null;
AlbumName: string | null;
Explicit: boolean | null;
Rank: number | null;
Tags: string[] | null;
Contributors: Contributor[] | null;
AlbumGenres: string[] | null;
}
export interface Playlist {
id: string;
name: string;
description: string;
genres?: string[];
start_year?: number;
end_year?: number;
cover_image?: string;
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

17
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
allowedHosts: ['localhost', '127.0.0.1'],
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false,
},
},
},
})