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

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,
},
},
},
})