mirror of
https://github.com/ovosimpatico/SongQuiz.git
synced 2026-01-15 16:32:55 -03:00
Initial Commit
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
31
frontend/Dockerfile
Normal 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
54
frontend/README.md
Normal 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
28
frontend/eslint.config.js
Normal 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
15
frontend/index.html
Normal 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
42
frontend/nginx.conf
Normal 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
4078
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal 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
10
frontend/public/logo.svg
Normal 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
1
frontend/public/vite.svg
Normal 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
42
frontend/src/App.css
Normal 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
21
frontend/src/App.scss
Normal 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
50
frontend/src/App.tsx
Normal 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
94
frontend/src/api/api.ts
Normal 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;
|
||||
};
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
98
frontend/src/components/DynamicTheme.tsx
Normal file
98
frontend/src/components/DynamicTheme.tsx
Normal 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;
|
||||
623
frontend/src/components/game/GameScreen/GameScreen.scss
Normal file
623
frontend/src/components/game/GameScreen/GameScreen.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
568
frontend/src/components/game/GameScreen/GameScreen.tsx
Normal file
568
frontend/src/components/game/GameScreen/GameScreen.tsx
Normal 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;
|
||||
1
frontend/src/components/game/GameScreen/index.ts
Normal file
1
frontend/src/components/game/GameScreen/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './GameScreen';
|
||||
523
frontend/src/components/game/GameSummary/GameSummary.scss
Normal file
523
frontend/src/components/game/GameSummary/GameSummary.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
207
frontend/src/components/game/GameSummary/GameSummary.tsx
Normal file
207
frontend/src/components/game/GameSummary/GameSummary.tsx
Normal 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;
|
||||
1
frontend/src/components/game/GameSummary/index.ts
Normal file
1
frontend/src/components/game/GameSummary/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './GameSummary';
|
||||
56
frontend/src/components/game/SongCard/SongCard.scss
Normal file
56
frontend/src/components/game/SongCard/SongCard.scss
Normal 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; }
|
||||
}
|
||||
86
frontend/src/components/game/SongCard/SongCard.tsx
Normal file
86
frontend/src/components/game/SongCard/SongCard.tsx
Normal 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;
|
||||
1
frontend/src/components/game/SongCard/index.ts
Normal file
1
frontend/src/components/game/SongCard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './SongCard';
|
||||
106
frontend/src/components/game/SongOption/SongOption.scss
Normal file
106
frontend/src/components/game/SongOption/SongOption.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
59
frontend/src/components/game/SongOption/SongOption.tsx
Normal file
59
frontend/src/components/game/SongOption/SongOption.tsx
Normal 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;
|
||||
1
frontend/src/components/game/SongOption/index.ts
Normal file
1
frontend/src/components/game/SongOption/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './SongOption';
|
||||
@@ -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;
|
||||
411
frontend/src/components/game/WelcomeScreen/WelcomeScreen.scss
Normal file
411
frontend/src/components/game/WelcomeScreen/WelcomeScreen.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
280
frontend/src/components/game/WelcomeScreen/WelcomeScreen.tsx
Normal file
280
frontend/src/components/game/WelcomeScreen/WelcomeScreen.tsx
Normal 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;
|
||||
1
frontend/src/components/game/WelcomeScreen/index.ts
Normal file
1
frontend/src/components/game/WelcomeScreen/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './WelcomeScreen';
|
||||
146
frontend/src/components/layout/AppLayout/AppLayout.scss
Normal file
146
frontend/src/components/layout/AppLayout/AppLayout.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
36
frontend/src/components/layout/AppLayout/AppLayout.tsx
Normal file
36
frontend/src/components/layout/AppLayout/AppLayout.tsx
Normal 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;
|
||||
1
frontend/src/components/layout/AppLayout/index.ts
Normal file
1
frontend/src/components/layout/AppLayout/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './AppLayout';
|
||||
126
frontend/src/components/ui/Button/Button.scss
Normal file
126
frontend/src/components/ui/Button/Button.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
57
frontend/src/components/ui/Button/Button.tsx
Normal file
57
frontend/src/components/ui/Button/Button.tsx
Normal 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;
|
||||
1
frontend/src/components/ui/Button/index.ts
Normal file
1
frontend/src/components/ui/Button/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Button';
|
||||
64
frontend/src/components/ui/Card/Card.scss
Normal file
64
frontend/src/components/ui/Card/Card.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
frontend/src/components/ui/Card/Card.tsx
Normal file
30
frontend/src/components/ui/Card/Card.tsx
Normal 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;
|
||||
1
frontend/src/components/ui/Card/index.ts
Normal file
1
frontend/src/components/ui/Card/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Card';
|
||||
127
frontend/src/components/ui/LoadingScreen/LoadingScreen.scss
Normal file
127
frontend/src/components/ui/LoadingScreen/LoadingScreen.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
92
frontend/src/components/ui/LoadingScreen/LoadingScreen.tsx
Normal file
92
frontend/src/components/ui/LoadingScreen/LoadingScreen.tsx
Normal 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;
|
||||
2
frontend/src/components/ui/LoadingScreen/index.ts
Normal file
2
frontend/src/components/ui/LoadingScreen/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import LoadingScreen from './LoadingScreen';
|
||||
export default LoadingScreen;
|
||||
60
frontend/src/components/ui/ProgressBar/ProgressBar.scss
Normal file
60
frontend/src/components/ui/ProgressBar/ProgressBar.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
45
frontend/src/components/ui/ProgressBar/ProgressBar.tsx
Normal file
45
frontend/src/components/ui/ProgressBar/ProgressBar.tsx
Normal 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;
|
||||
1
frontend/src/components/ui/ProgressBar/index.ts
Normal file
1
frontend/src/components/ui/ProgressBar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default, ProgressBar, type ProgressBarProps } from './ProgressBar';
|
||||
76
frontend/src/contexts/ThemeContext.tsx
Normal file
76
frontend/src/contexts/ThemeContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
196
frontend/src/hooks/useAudioPlayer.ts
Normal file
196
frontend/src/hooks/useAudioPlayer.ts
Normal 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;
|
||||
194
frontend/src/hooks/useGame.ts
Normal file
194
frontend/src/hooks/useGame.ts
Normal 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
|
||||
};
|
||||
}
|
||||
187
frontend/src/hooks/useTimer.ts
Normal file
187
frontend/src/hooks/useTimer.ts
Normal 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
72
frontend/src/index.css
Normal 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
12
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
133
frontend/src/services/api.ts
Normal file
133
frontend/src/services/api.ts
Normal 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,
|
||||
};
|
||||
148
frontend/src/styles/global.scss
Normal file
148
frontend/src/styles/global.scss
Normal 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;
|
||||
}
|
||||
130
frontend/src/styles/mixins.scss
Normal file
130
frontend/src/styles/mixins.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
70
frontend/src/styles/reset.scss
Normal file
70
frontend/src/styles/reset.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
68
frontend/src/styles/variables.scss
Normal file
68
frontend/src/styles/variables.scss
Normal 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
179
frontend/src/types/game.ts
Normal 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,
|
||||
});
|
||||
1
frontend/src/types/index.ts
Normal file
1
frontend/src/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './song';
|
||||
41
frontend/src/types/song.ts
Normal file
41
frontend/src/types/song.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
26
frontend/tsconfig.app.json
Normal file
26
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal 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
17
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user