Compare commits
19 Commits
569e390a89
...
main
Author | SHA1 | Date | |
---|---|---|---|
f3c87878ce | |||
8f91e27ca1 | |||
0c543a1a01 | |||
301e08b6e6 | |||
f2712bdcec | |||
9f4ebf379f | |||
4f4626260f | |||
e24ecb418c | |||
1a7d7f4df3 | |||
f9d63c6c49 | |||
a7929cf144 | |||
77df851e95 | |||
6c9f1c3348 | |||
fca6baa694 | |||
38ed69bf5b | |||
50e245233c | |||
22eca7d4e0 | |||
44a75ba715 | |||
e818af6900 |
33
.github/workflows/deploy_docker.yml
vendored
Normal file
33
.github/workflows/deploy_docker.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
name: Publish Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
push_to_registry:
|
||||||
|
name: Push Docker image to Docker Hub
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up docker buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
registry: git.gnm.dev
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GT_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: git.gnm.dev/websiteprojects/liedkampf:main
|
||||||
|
|
||||||
|
- name: Image digest
|
||||||
|
run: echo ${{ steps.docker_build.outputs.digest }}
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -130,3 +130,5 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
# editors
|
||||||
|
.idea
|
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --chown=node:node ./package.json ./pnpm-lock.yaml /app/
|
||||||
|
COPY --chown=node:node ./client/package.json ./client/pnpm-lock.yaml /client/
|
||||||
|
|
||||||
|
WORKDIR /client
|
||||||
|
RUN NODE_ENV=development pnpm install
|
||||||
|
|
||||||
|
COPY --chown=node:node ./server /app
|
||||||
|
COPY --chown=node:node ./client /client
|
||||||
|
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
RUN cp -r /client/dist /app/dist
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN pnpm install --production --frozen-lockfile
|
||||||
|
|
||||||
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
USER node
|
||||||
|
EXPOSE 5237
|
||||||
|
|
||||||
|
CMD ["node", "index.js"]
|
15
client/.eslintrc.cjs
Normal file
15
client/.eslintrc.cjs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
module.exports = {
|
||||||
|
env: { browser: true, es2020: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:react/recommended',
|
||||||
|
'plugin:react/jsx-runtime',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
],
|
||||||
|
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||||
|
settings: { react: { version: '18.2' } },
|
||||||
|
plugins: ['react-refresh'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': 'warn',
|
||||||
|
},
|
||||||
|
}
|
24
client/.gitignore
vendored
Normal file
24
client/.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?
|
13
client/index.html
Normal file
13
client/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/logo.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Liedkampf</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
10
client/jsconfig.json
Normal file
10
client/jsconfig.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
32
client/package.json
Normal file
32
client/package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "webui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/press-start-2p": "^5.2.5",
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||||
|
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"sass": "^1.85.1",
|
||||||
|
"socket.io-client": "^4.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"eslint": "^9.21.0",
|
||||||
|
"eslint-plugin-react": "^7.37.4",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
3194
client/pnpm-lock.yaml
generated
Normal file
3194
client/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
client/public/background.svg
Normal file
9
client/public/background.svg
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g fill="#ffffff" fill-opacity="0.05">
|
||||||
|
<polygon points="25,40 35,30 45,40 35,50"/>
|
||||||
|
<polygon points="60,20 75,10 80,25"/>
|
||||||
|
<rect x="10" y="70" width="30" height="4" rx="2" transform="rotate(-10 25 72)"/>
|
||||||
|
<rect x="50" y="60" width="35" height="4" rx="2" transform="rotate(30 67 62)"/>
|
||||||
|
<rect x="70" y="75" width="12" height="12" transform="rotate(45 76 81)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 504 B |
BIN
client/public/logo.png
Normal file
BIN
client/public/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
106
client/src/App.jsx
Normal file
106
client/src/App.jsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faDrum, faGuitar, faHeadphones, faMusic } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { useGame, useSocket } from "./context/GameContext";
|
||||||
|
import LobbyScreen from "./components/LobbyScreen";
|
||||||
|
import HomeScreen from "./components/HomeScreen";
|
||||||
|
import SongSubmissionScreen from "./components/SongSubmissionScreen";
|
||||||
|
import VotingScreen from "./components/VotingScreen";
|
||||||
|
import ResultsScreen from "./components/ResultsScreen";
|
||||||
|
import BattleResultScreen from "./components/BattleResultScreen";
|
||||||
|
import ConnectionStatus from "./components/ConnectionStatus";
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const [cursorPos, setCursorPos] = useState({x: 0, y: 0});
|
||||||
|
const { isConnected } = useSocket();
|
||||||
|
const { lobby, error } = useGame();
|
||||||
|
|
||||||
|
const musicNotes = [
|
||||||
|
{id: 1, top: "8%", left: "20%", icon: faMusic, scale: 1.2},
|
||||||
|
{id: 2, top: "75%", left: "85%", icon: faGuitar, scale: 1},
|
||||||
|
{id: 3, top: "65%", left: "12%", icon: faHeadphones, scale: 1.1},
|
||||||
|
{id: 4, top: "20%", left: "70%", icon: faDrum, scale: 0.9}
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (!handleMouseMove.ticking) {
|
||||||
|
handleMouseMove.ticking = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setCursorPos({x: e.clientX, y: e.clientY});
|
||||||
|
handleMouseMove.ticking = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handleMouseMove.ticking = false;
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
return () => window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Determine which screen to show based on game state
|
||||||
|
const renderGameScreen = () => {
|
||||||
|
if (!lobby) {
|
||||||
|
return <HomeScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (lobby.state) {
|
||||||
|
case 'LOBBY':
|
||||||
|
return <LobbyScreen />;
|
||||||
|
case 'SONG_SUBMISSION':
|
||||||
|
return <SongSubmissionScreen />;
|
||||||
|
case 'VOTING':
|
||||||
|
return <VotingScreen />;
|
||||||
|
case 'FINISHED':
|
||||||
|
return <ResultsScreen />;
|
||||||
|
case 'BATTLE':
|
||||||
|
return <BattleResultScreen />;
|
||||||
|
default:
|
||||||
|
return <HomeScreen />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="background-elements">
|
||||||
|
<div className="glow-point point-1" style={{
|
||||||
|
transform: `translate(${(cursorPos.x / window.innerWidth - 0.5) * -20}px, ${(cursorPos.y / window.innerHeight - 0.5) * -20}px)`
|
||||||
|
}}></div>
|
||||||
|
<div className="glow-point point-2" style={{
|
||||||
|
transform: `translate(${(cursorPos.x / window.innerWidth - 0.5) * 15}px, ${(cursorPos.y / window.innerHeight - 0.5) * 15}px)`
|
||||||
|
}}></div>
|
||||||
|
<div className="glow-point point-3" style={{
|
||||||
|
transform: `translate(${(cursorPos.x / window.innerWidth - 0.5) * -10}px, ${(cursorPos.y / window.innerHeight - 0.5) * -10}px)`
|
||||||
|
}}></div>
|
||||||
|
|
||||||
|
{musicNotes.map((note) => (
|
||||||
|
<div key={`note-${note.id}`} className={`music-note note-${note.id}`}
|
||||||
|
style={{
|
||||||
|
top: note.top,
|
||||||
|
left: note.left,
|
||||||
|
fontSize: `${note.scale * 60}pt`,
|
||||||
|
transform: `translate(${(cursorPos.x / window.innerWidth - 0.5) * -5 * note.scale}px, ${(cursorPos.y / window.innerHeight - 0.5) * -5 * note.scale}px)`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={note.icon}/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content-container">
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderGameScreen()}
|
||||||
|
|
||||||
|
{/* Show connection status component */}
|
||||||
|
<ConnectionStatus />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
146
client/src/common/styles/buttons.sass
Normal file
146
client/src/common/styles/buttons.sass
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
// Button styles for the Song Battle application - Pixel Art Theme
|
||||||
|
|
||||||
|
// Base button style
|
||||||
|
.btn
|
||||||
|
display: inline-block
|
||||||
|
padding: 0.75rem 1.5rem
|
||||||
|
border: 4px solid #000
|
||||||
|
border-radius: 0
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.9rem
|
||||||
|
font-weight: 600
|
||||||
|
cursor: pointer
|
||||||
|
text-transform: uppercase
|
||||||
|
letter-spacing: 1px
|
||||||
|
position: relative
|
||||||
|
text-align: center
|
||||||
|
min-width: 120px
|
||||||
|
background-color: $primary
|
||||||
|
color: #000
|
||||||
|
// Enhanced pixel art style with jagged edges
|
||||||
|
image-rendering: pixelated
|
||||||
|
box-shadow: 4px 4px 0 #000, inset -4px -4px 0 darken($primary, 30%), inset 4px 4px 0 lighten($primary, 10%)
|
||||||
|
transition: all 0.1s ease
|
||||||
|
|
||||||
|
// Add decorative pixel corners
|
||||||
|
&:before, &:after
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
width: 4px
|
||||||
|
height: 4px
|
||||||
|
background-color: #000
|
||||||
|
z-index: 2
|
||||||
|
|
||||||
|
&:before
|
||||||
|
top: -4px
|
||||||
|
left: -4px
|
||||||
|
|
||||||
|
&:after
|
||||||
|
bottom: -4px
|
||||||
|
right: -4px
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
transform: translate(-2px, -2px)
|
||||||
|
box-shadow: 6px 6px 0 #000, inset -4px -4px 0 darken($primary, 30%), inset 4px 4px 0 lighten($primary, 10%)
|
||||||
|
|
||||||
|
&:active
|
||||||
|
transform: translate(4px, 4px)
|
||||||
|
box-shadow: 0 0 0 #000, inset -4px -4px 0 darken($primary, 30%), inset 4px 4px 0 lighten($primary, 10%)
|
||||||
|
|
||||||
|
&:disabled
|
||||||
|
opacity: 0.5
|
||||||
|
|
||||||
|
// Full width modifier
|
||||||
|
&.full-width
|
||||||
|
width: 100%
|
||||||
|
margin-top: 1rem
|
||||||
|
padding: 1rem
|
||||||
|
font-size: 1rem
|
||||||
|
|
||||||
|
// Button types
|
||||||
|
&.primary
|
||||||
|
background-color: $primary
|
||||||
|
color: #000
|
||||||
|
box-shadow: 4px 4px 0 #000, inset -4px -4px 0 darken($primary, 30%), inset 4px 4px 0 lighten($primary, 10%)
|
||||||
|
|
||||||
|
&.secondary
|
||||||
|
background-color: $secondary
|
||||||
|
color: #000
|
||||||
|
box-shadow: 4px 4px 0 #000, inset -4px -4px 0 darken($secondary, 30%), inset 4px 4px 0 lighten($secondary, 10%)
|
||||||
|
|
||||||
|
&.accent
|
||||||
|
background-color: $accent
|
||||||
|
color: #000
|
||||||
|
box-shadow: 4px 4px 0 #000, inset -4px -4px 0 darken($accent, 30%), inset 4px 4px 0 lighten($accent, 10%)
|
||||||
|
|
||||||
|
@keyframes pixel-breathe
|
||||||
|
0%, 100%
|
||||||
|
transform: scale(1)
|
||||||
|
50%
|
||||||
|
transform: scale(1.01)
|
||||||
|
|
||||||
|
// Primary button
|
||||||
|
.btn.primary
|
||||||
|
background: linear-gradient(45deg, darken($primary, 10%), $primary)
|
||||||
|
color: #000
|
||||||
|
box-shadow: 0 4px 15px rgba($primary, 0.5)
|
||||||
|
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: linear-gradient(45deg, $primary, lighten($primary, 10%))
|
||||||
|
|
||||||
|
&:focus
|
||||||
|
box-shadow: 0 0 0 2px rgba($primary, 0.5)
|
||||||
|
|
||||||
|
// Secondary button
|
||||||
|
.btn.secondary
|
||||||
|
background: linear-gradient(45deg, darken($secondary, 10%), $secondary)
|
||||||
|
color: #000
|
||||||
|
box-shadow: 0 4px 15px rgba($secondary, 0.5)
|
||||||
|
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: linear-gradient(45deg, $secondary, lighten($secondary, 10%))
|
||||||
|
|
||||||
|
&:focus
|
||||||
|
box-shadow: 0 0 0 2px rgba($secondary, 0.5)
|
||||||
|
|
||||||
|
// Danger button
|
||||||
|
.btn.danger
|
||||||
|
background: linear-gradient(45deg, darken($danger, 10%), $danger)
|
||||||
|
color: #fff
|
||||||
|
box-shadow: 0 4px 15px rgba($danger, 0.5)
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: linear-gradient(45deg, $danger, lighten($danger, 10%))
|
||||||
|
|
||||||
|
&:focus
|
||||||
|
box-shadow: 0 0 0 2px rgba($danger, 0.5)
|
||||||
|
|
||||||
|
// Special game-themed buttons
|
||||||
|
.btn.game-action
|
||||||
|
background: linear-gradient(45deg, darken($accent, 10%), $accent)
|
||||||
|
color: #fff
|
||||||
|
border-radius: 2rem
|
||||||
|
padding: 0.75rem 2rem
|
||||||
|
transform-style: preserve-3d
|
||||||
|
perspective: 1000px
|
||||||
|
|
||||||
|
&:after
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
left: 0
|
||||||
|
top: 0
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
background: rgba(255, 255, 255, 0.1)
|
||||||
|
border-radius: 2rem
|
||||||
|
transform: translateZ(-10px)
|
||||||
|
z-index: -1
|
||||||
|
transition: transform 0.3s
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
transform: translateY(-5px) rotateX(10deg)
|
||||||
|
|
||||||
|
&:after
|
||||||
|
transform: translateZ(-5px)
|
22
client/src/common/styles/colors.sass
Normal file
22
client/src/common/styles/colors.sass
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// Colors for Song Battle application - Pixel Art Theme
|
||||||
|
|
||||||
|
// Main colors
|
||||||
|
$background: #1a1126 // Dark purple background for pixel art feel
|
||||||
|
$card-bg: rgba(32, 24, 48, 0.9) // Semi-transparent dark purple card background
|
||||||
|
$text: #ffffff
|
||||||
|
$text-muted: rgba(255, 255, 255, 0.8) // Brighter for better contrast with gradient
|
||||||
|
|
||||||
|
// Brand colors - More saturated for pixel art aesthetic
|
||||||
|
$primary: #ff55ff // Bright magenta - pixel art style
|
||||||
|
$secondary: #00ddff // Bright cyan - pixel art style
|
||||||
|
$accent: #ffcc00 // Bright gold - pixel art style
|
||||||
|
|
||||||
|
// Pixel art accent colors
|
||||||
|
$pixel-purple: #9900ff
|
||||||
|
$pixel-blue: #0088ff
|
||||||
|
$pixel-green: #00ff66
|
||||||
|
|
||||||
|
// Status colors
|
||||||
|
$success: #00c853
|
||||||
|
$warning: #ffc107
|
||||||
|
$danger: #ff5252
|
243
client/src/common/styles/components/battle-result-screen.sass
Normal file
243
client/src/common/styles/components/battle-result-screen.sass
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
// Battle Result Screen styles
|
||||||
|
|
||||||
|
.battle-result-screen
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
min-height: 100%
|
||||||
|
padding: 1.5rem
|
||||||
|
position: relative
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
header
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
flex-direction: column
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
h1
|
||||||
|
margin: 0
|
||||||
|
color: $accent
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.75rem
|
||||||
|
font-size: 2.2rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
text-transform: uppercase
|
||||||
|
animation: winner-pulse 2s infinite alternate
|
||||||
|
|
||||||
|
svg
|
||||||
|
color: $accent
|
||||||
|
filter: drop-shadow(2px 2px 0 #000)
|
||||||
|
|
||||||
|
.countdown
|
||||||
|
margin-top: 1rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.9rem
|
||||||
|
color: $text-muted
|
||||||
|
padding: 0.8rem 1.2rem
|
||||||
|
background-color: rgba(0, 0, 0, 0.3)
|
||||||
|
border-radius: 1rem
|
||||||
|
animation: pulse 1s infinite alternate
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
|
||||||
|
svg
|
||||||
|
color: $accent
|
||||||
|
margin-right: 0.5rem
|
||||||
|
|
||||||
|
.winner-announcement
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
.song-cards
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
width: 100%
|
||||||
|
max-width: 700px
|
||||||
|
gap: 2rem
|
||||||
|
|
||||||
|
@media (min-width: 768px)
|
||||||
|
flex-direction: row
|
||||||
|
align-items: flex-start
|
||||||
|
|
||||||
|
.song-card
|
||||||
|
position: relative
|
||||||
|
background-color: rgba(0, 0, 0, 0.3)
|
||||||
|
padding: 1.5rem
|
||||||
|
border-radius: 1rem
|
||||||
|
|
||||||
|
&.winner
|
||||||
|
flex: 3
|
||||||
|
border: 6px solid $accent
|
||||||
|
box-shadow: 0 0 20px rgba($accent, 0.3)
|
||||||
|
transform: scale(1.05)
|
||||||
|
z-index: 1
|
||||||
|
|
||||||
|
.victory-badge
|
||||||
|
position: absolute
|
||||||
|
top: -12px
|
||||||
|
right: 10px
|
||||||
|
background-color: $accent
|
||||||
|
color: white
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.7rem
|
||||||
|
padding: 0.5rem 1rem
|
||||||
|
border-radius: 0.5rem
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.3)
|
||||||
|
|
||||||
|
svg
|
||||||
|
font-size: 0.8rem
|
||||||
|
|
||||||
|
&.loser
|
||||||
|
flex: 2
|
||||||
|
opacity: 0.7
|
||||||
|
filter: saturate(0.7)
|
||||||
|
|
||||||
|
.versus
|
||||||
|
position: absolute
|
||||||
|
top: 50%
|
||||||
|
left: -30px
|
||||||
|
font-family: 'Bangers', cursive
|
||||||
|
font-size: 2.5rem
|
||||||
|
color: $accent
|
||||||
|
transform: translateY(-50%)
|
||||||
|
text-shadow: 0 0 5px rgba($accent, 0.5)
|
||||||
|
|
||||||
|
@media (max-width: 767px)
|
||||||
|
top: -25px
|
||||||
|
left: 50%
|
||||||
|
transform: translateX(-50%)
|
||||||
|
|
||||||
|
.song-info
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
h2, h3
|
||||||
|
margin: 0 0 0.5rem 0
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
|
||||||
|
h2
|
||||||
|
font-size: 1.4rem
|
||||||
|
color: $text
|
||||||
|
margin-right: 70px
|
||||||
|
|
||||||
|
h3
|
||||||
|
font-size: 1.2rem
|
||||||
|
color: $text
|
||||||
|
|
||||||
|
.artist
|
||||||
|
color: $text-muted
|
||||||
|
font-size: 1.1rem
|
||||||
|
margin-bottom: 1rem
|
||||||
|
|
||||||
|
.submitter
|
||||||
|
font-size: 0.9rem
|
||||||
|
color: $text-muted
|
||||||
|
font-style: italic
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
|
||||||
|
.vote-count
|
||||||
|
display: inline-block
|
||||||
|
padding: 0.5rem 1rem
|
||||||
|
background-color: rgba(0, 0, 0, 0.3)
|
||||||
|
border-radius: 1rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.8rem
|
||||||
|
|
||||||
|
.votes
|
||||||
|
color: $text
|
||||||
|
|
||||||
|
.winner-video
|
||||||
|
width: 100%
|
||||||
|
aspect-ratio: 16 / 9
|
||||||
|
border-radius: 0.5rem
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
.no-video
|
||||||
|
width: 100%
|
||||||
|
aspect-ratio: 16 / 9
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
background-color: rgba(0, 0, 0, 0.3)
|
||||||
|
border-radius: 0.5rem
|
||||||
|
color: $text-muted
|
||||||
|
|
||||||
|
.pulse-icon
|
||||||
|
font-size: 2rem
|
||||||
|
margin-bottom: 1rem
|
||||||
|
animation: pulse 2s infinite
|
||||||
|
|
||||||
|
.battle-actions
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
margin-top: auto
|
||||||
|
padding-top: 1.5rem
|
||||||
|
|
||||||
|
.btn
|
||||||
|
padding: 1rem 2rem
|
||||||
|
font-size: 1.1rem
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
|
||||||
|
&.pixelated
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
.pixel-corner
|
||||||
|
position: absolute
|
||||||
|
width: 8px
|
||||||
|
height: 8px
|
||||||
|
background-color: $primary
|
||||||
|
|
||||||
|
&.tl
|
||||||
|
top: -4px
|
||||||
|
left: -4px
|
||||||
|
|
||||||
|
&.tr
|
||||||
|
top: -4px
|
||||||
|
right: -4px
|
||||||
|
|
||||||
|
&.bl
|
||||||
|
bottom: -4px
|
||||||
|
left: -4px
|
||||||
|
|
||||||
|
&.br
|
||||||
|
bottom: -4px
|
||||||
|
right: -4px
|
||||||
|
|
||||||
|
// Confetti animation
|
||||||
|
.confetti
|
||||||
|
position: absolute
|
||||||
|
width: 10px
|
||||||
|
height: 20px
|
||||||
|
transform-origin: center bottom
|
||||||
|
animation: confetti-fall 3s linear forwards
|
||||||
|
z-index: -1
|
||||||
|
|
||||||
|
@keyframes confetti-fall
|
||||||
|
0%
|
||||||
|
transform: translateY(-100vh) rotate(0deg)
|
||||||
|
100%
|
||||||
|
transform: translateY(100vh) rotate(360deg)
|
||||||
|
|
||||||
|
@keyframes winner-pulse
|
||||||
|
0%
|
||||||
|
transform: scale(1)
|
||||||
|
text-shadow: 2px 2px 0 #000, -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000
|
||||||
|
100%
|
||||||
|
transform: scale(1.05)
|
||||||
|
text-shadow: 3px 3px 0 #000, -3px -3px 0 #000, 3px -3px 0 #000, -3px 3px 0 #000
|
||||||
|
|
||||||
|
@keyframes pulse
|
||||||
|
0%
|
||||||
|
opacity: 0.7
|
||||||
|
100%
|
||||||
|
opacity: 1
|
55
client/src/common/styles/components/connection-status.sass
Normal file
55
client/src/common/styles/components/connection-status.sass
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// connection-status.sass - Component for displaying network connection status
|
||||||
|
|
||||||
|
.connection-status
|
||||||
|
position: fixed
|
||||||
|
bottom: 0
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
padding: 0.5rem 1rem
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
gap: 0.5rem
|
||||||
|
z-index: 1000
|
||||||
|
transition: all 0.3s ease
|
||||||
|
animation: slideUp 0.3s forwards
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.7rem
|
||||||
|
|
||||||
|
&.connected
|
||||||
|
background-color: rgba($success, 0.9)
|
||||||
|
color: #fff
|
||||||
|
animation: slideUp 0.3s forwards, fadeOut 0.5s 2.5s forwards
|
||||||
|
|
||||||
|
&.disconnected
|
||||||
|
background-color: rgba(#f44336, 0.9)
|
||||||
|
color: #fff
|
||||||
|
|
||||||
|
&.reconnecting
|
||||||
|
background-color: rgba(#ff9800, 0.9)
|
||||||
|
color: #fff
|
||||||
|
|
||||||
|
&.offline
|
||||||
|
background-color: rgba(#f44336, 0.9)
|
||||||
|
color: #fff
|
||||||
|
|
||||||
|
.connection-icon
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
|
||||||
|
.connection-message
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
@keyframes slideUp
|
||||||
|
from
|
||||||
|
transform: translateY(100%)
|
||||||
|
to
|
||||||
|
transform: translateY(0)
|
||||||
|
|
||||||
|
@keyframes fadeOut
|
||||||
|
from
|
||||||
|
opacity: 1
|
||||||
|
to
|
||||||
|
opacity: 0
|
||||||
|
transform: translateY(100%)
|
149
client/src/common/styles/components/home-screen.sass
Normal file
149
client/src/common/styles/components/home-screen.sass
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
// Home Screen styles
|
||||||
|
|
||||||
|
.home-screen
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
min-height: 100%
|
||||||
|
padding: 2rem
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
// Pixel art floating items in background
|
||||||
|
&:before, &:after
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
width: 12px
|
||||||
|
height: 12px
|
||||||
|
background-color: $pixel-blue
|
||||||
|
animation: pixel-float 3s infinite alternate ease-in-out
|
||||||
|
z-index: -1
|
||||||
|
|
||||||
|
&:before
|
||||||
|
top: 15%
|
||||||
|
left: 20%
|
||||||
|
box-shadow: 0 0 10px $pixel-blue
|
||||||
|
animation-delay: 0s
|
||||||
|
|
||||||
|
&:after
|
||||||
|
bottom: 20%
|
||||||
|
right: 15%
|
||||||
|
background-color: $pixel-purple
|
||||||
|
box-shadow: 0 0 10px $pixel-purple
|
||||||
|
animation-delay: 1.5s
|
||||||
|
|
||||||
|
.logo
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
margin-bottom: 3rem
|
||||||
|
|
||||||
|
.logo-image
|
||||||
|
image-rendering: pixelated
|
||||||
|
width: 180px
|
||||||
|
height: auto
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
animation: pixel-float 3s infinite ease-in-out
|
||||||
|
filter: drop-shadow(0 0 15px rgba(255, 255, 255, 0.5))
|
||||||
|
|
||||||
|
h1
|
||||||
|
font-size: 2.5rem
|
||||||
|
margin: 0
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
color: $primary
|
||||||
|
animation: pixel-flash 3s infinite
|
||||||
|
letter-spacing: 2px
|
||||||
|
text-transform: uppercase
|
||||||
|
|
||||||
|
.card
|
||||||
|
background-color: $card-bg
|
||||||
|
padding: 2rem
|
||||||
|
box-shadow: 8px 8px 0 #000
|
||||||
|
border: 4px solid #000
|
||||||
|
width: 100%
|
||||||
|
max-width: 500px
|
||||||
|
position: relative
|
||||||
|
overflow: visible
|
||||||
|
|
||||||
|
// Pixel art decorations
|
||||||
|
&:before
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
top: -10px
|
||||||
|
left: -10px
|
||||||
|
width: 20px
|
||||||
|
height: 20px
|
||||||
|
background-color: $accent
|
||||||
|
z-index: 1
|
||||||
|
animation: pixel-rotate 8s linear infinite
|
||||||
|
|
||||||
|
&:after
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
bottom: -10px
|
||||||
|
right: -10px
|
||||||
|
width: 20px
|
||||||
|
height: 20px
|
||||||
|
background-color: $primary
|
||||||
|
z-index: 1
|
||||||
|
animation: pixel-rotate 8s linear infinite reverse
|
||||||
|
|
||||||
|
@keyframes pixel-rotate
|
||||||
|
0%
|
||||||
|
transform: rotate(0deg)
|
||||||
|
100%
|
||||||
|
transform: rotate(360deg)
|
||||||
|
|
||||||
|
.tabs
|
||||||
|
display: flex
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
border-bottom: 4px solid #000
|
||||||
|
|
||||||
|
button
|
||||||
|
flex: 1
|
||||||
|
background: none
|
||||||
|
border: 4px solid #000
|
||||||
|
border-bottom: none
|
||||||
|
color: $text-muted
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.7rem
|
||||||
|
padding: 0.75rem 0.5rem
|
||||||
|
position: relative
|
||||||
|
text-transform: uppercase
|
||||||
|
transition: all 0.2s ease
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
&:hover:not(.active)
|
||||||
|
background-color: rgba($primary, 0.2)
|
||||||
|
color: $text
|
||||||
|
|
||||||
|
&.active
|
||||||
|
background-color: $primary
|
||||||
|
color: #000
|
||||||
|
z-index: 1
|
||||||
|
|
||||||
|
&:after
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
bottom: -4px
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
height: 4px
|
||||||
|
background-color: $primary
|
||||||
|
|
||||||
|
.home-footer
|
||||||
|
margin-top: 2rem
|
||||||
|
text-align: center
|
||||||
|
color: $text-muted
|
||||||
|
font-style: italic
|
||||||
|
|
||||||
|
@keyframes pulse
|
||||||
|
0%
|
||||||
|
transform: scale(1)
|
||||||
|
opacity: 1
|
||||||
|
50%
|
||||||
|
transform: scale(1.1)
|
||||||
|
opacity: 0.8
|
||||||
|
100%
|
||||||
|
transform: scale(1)
|
||||||
|
opacity: 1
|
205
client/src/common/styles/components/lobby-screen.sass
Normal file
205
client/src/common/styles/components/lobby-screen.sass
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
// Lobby Screen styles
|
||||||
|
|
||||||
|
.lobby-screen
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
min-height: 100%
|
||||||
|
padding: 1.5rem
|
||||||
|
|
||||||
|
.lobby-header
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: center
|
||||||
|
margin-bottom: 2rem
|
||||||
|
flex-wrap: wrap
|
||||||
|
gap: 1rem
|
||||||
|
|
||||||
|
h1
|
||||||
|
margin: 0
|
||||||
|
color: $primary
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 1.8rem
|
||||||
|
text-transform: uppercase
|
||||||
|
position: relative
|
||||||
|
letter-spacing: 2px
|
||||||
|
|
||||||
|
&:after
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
bottom: -10px
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
height: 4px
|
||||||
|
background-color: $primary
|
||||||
|
|
||||||
|
.lobby-code
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
background-color: $card-bg
|
||||||
|
padding: 0.75rem 1rem
|
||||||
|
border: 4px solid #000
|
||||||
|
box-shadow: 4px 4px 0 #000
|
||||||
|
|
||||||
|
p
|
||||||
|
margin: 0 1rem 0 0
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.7rem
|
||||||
|
text-transform: uppercase
|
||||||
|
|
||||||
|
.code
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-weight: bold
|
||||||
|
color: $secondary
|
||||||
|
font-size: 1rem
|
||||||
|
letter-spacing: 2px
|
||||||
|
background-color: rgba(0, 0, 0, 0.3)
|
||||||
|
padding: 0.5rem
|
||||||
|
border: 2px solid $secondary
|
||||||
|
|
||||||
|
.btn
|
||||||
|
padding: 0.25rem 0.5rem
|
||||||
|
font-size: 0.7rem
|
||||||
|
|
||||||
|
.lobby-content
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
flex-grow: 1
|
||||||
|
gap: 1.5rem
|
||||||
|
|
||||||
|
@media (min-width: 768px)
|
||||||
|
flex-direction: row
|
||||||
|
|
||||||
|
.settings-section, .players-section
|
||||||
|
background-color: $card-bg
|
||||||
|
border: 4px solid #000
|
||||||
|
box-shadow: 8px 8px 0 #000
|
||||||
|
padding: 1.5rem
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
h2
|
||||||
|
margin-top: 0
|
||||||
|
margin-bottom: 1rem
|
||||||
|
color: $secondary
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 1.2rem
|
||||||
|
text-transform: uppercase
|
||||||
|
|
||||||
|
&:after
|
||||||
|
content: ''
|
||||||
|
display: block
|
||||||
|
width: 100%
|
||||||
|
height: 4px
|
||||||
|
background-color: $secondary
|
||||||
|
margin-top: 0.5rem
|
||||||
|
|
||||||
|
.players-list
|
||||||
|
flex: 1
|
||||||
|
background-color: $card-bg
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 1.5rem
|
||||||
|
|
||||||
|
h2
|
||||||
|
margin-top: 0
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
ul
|
||||||
|
list-style: none
|
||||||
|
padding: 0
|
||||||
|
margin: 0
|
||||||
|
|
||||||
|
li
|
||||||
|
padding: 0.75rem 1rem
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
border-radius: 0.5rem
|
||||||
|
background-color: rgba(255, 255, 255, 0.05)
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
&.host
|
||||||
|
border-left: 3px solid $primary
|
||||||
|
|
||||||
|
&.disconnected
|
||||||
|
opacity: 0.6
|
||||||
|
|
||||||
|
.status-disconnected
|
||||||
|
font-size: 0.8rem
|
||||||
|
color: $danger
|
||||||
|
margin-left: auto
|
||||||
|
|
||||||
|
.lobby-info
|
||||||
|
flex: 1
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
|
||||||
|
.settings-preview
|
||||||
|
background-color: $card-bg
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 1.5rem
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
h3
|
||||||
|
margin-top: 0
|
||||||
|
color: $secondary
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
|
||||||
|
.actions
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 1rem
|
||||||
|
margin-top: auto
|
||||||
|
|
||||||
|
.warning
|
||||||
|
color: $warning
|
||||||
|
text-align: center
|
||||||
|
margin-top: 1rem
|
||||||
|
font-size: 0.9rem
|
||||||
|
|
||||||
|
// Modal overlay for settings
|
||||||
|
.modal-overlay
|
||||||
|
position: fixed
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
bottom: 0
|
||||||
|
background-color: rgba(0, 0, 0, 0.7)
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
z-index: 100
|
||||||
|
|
||||||
|
.modal
|
||||||
|
background-color: $card-bg
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 2rem
|
||||||
|
width: 90%
|
||||||
|
max-width: 500px
|
||||||
|
border: 6px solid #000
|
||||||
|
box-shadow: 10px 10px 0 rgba(0, 0, 0, 0.5)
|
||||||
|
animation: modal-appear 0.3s ease-out
|
||||||
|
|
||||||
|
h2
|
||||||
|
margin-top: 0
|
||||||
|
color: $primary
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 1.4rem
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
.modal-actions
|
||||||
|
display: flex
|
||||||
|
justify-content: flex-end
|
||||||
|
gap: 1rem
|
||||||
|
margin-top: 2rem
|
||||||
|
|
||||||
|
@keyframes modal-appear
|
||||||
|
0%
|
||||||
|
transform: scale(0.8)
|
||||||
|
opacity: 0
|
||||||
|
100%
|
||||||
|
transform: scale(1)
|
||||||
|
opacity: 1
|
274
client/src/common/styles/components/results-screen.sass
Normal file
274
client/src/common/styles/components/results-screen.sass
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
// Results Screen styles
|
||||||
|
|
||||||
|
.results-screen
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
min-height: 100%
|
||||||
|
padding: 1.5rem
|
||||||
|
position: relative
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
// Celebratory pixel confetti
|
||||||
|
&:before
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
top: -20px
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
height: 20px
|
||||||
|
background: repeating-linear-gradient(90deg, $primary 0px, $primary 10px, $accent 10px, $accent 20px, $secondary 20px, $secondary 30px)
|
||||||
|
animation: pixel-rain 8s linear infinite
|
||||||
|
|
||||||
|
@keyframes pixel-rain
|
||||||
|
0%
|
||||||
|
transform: translateY(-20px)
|
||||||
|
100%
|
||||||
|
transform: translateY(100vh)
|
||||||
|
|
||||||
|
header
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
margin-bottom: 3rem
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
h1
|
||||||
|
margin: 0
|
||||||
|
color: $accent
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.75rem
|
||||||
|
font-size: 2.5rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
text-transform: uppercase
|
||||||
|
letter-spacing: 3px
|
||||||
|
animation: winner-pulse 2s infinite alternate
|
||||||
|
|
||||||
|
svg
|
||||||
|
color: $accent
|
||||||
|
filter: drop-shadow(2px 2px 0 #000)
|
||||||
|
|
||||||
|
@keyframes winner-pulse
|
||||||
|
0%
|
||||||
|
transform: scale(1)
|
||||||
|
text-shadow: 2px 2px 0 #000, -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000
|
||||||
|
100%
|
||||||
|
transform: scale(1.05)
|
||||||
|
text-shadow: 3px 3px 0 #000, -3px -3px 0 #000, 3px -3px 0 #000, -3px 3px 0 #000
|
||||||
|
|
||||||
|
.winner-card
|
||||||
|
background-color: $card-bg
|
||||||
|
border: 8px solid $accent
|
||||||
|
padding: 2rem
|
||||||
|
margin-bottom: 2rem
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 1.5rem
|
||||||
|
position: relative
|
||||||
|
overflow: hidden
|
||||||
|
box-shadow: 10px 10px 0 #000, 0 0 20px rgba($accent, 0.7)
|
||||||
|
|
||||||
|
// Trophy decoration
|
||||||
|
&:before
|
||||||
|
content: '🏆'
|
||||||
|
position: absolute
|
||||||
|
top: -15px
|
||||||
|
right: 20px
|
||||||
|
font-size: 2.5rem
|
||||||
|
transform: rotate(15deg)
|
||||||
|
filter: drop-shadow(2px 2px 0 #000)
|
||||||
|
z-index: 1
|
||||||
|
|
||||||
|
// Pixel border pattern
|
||||||
|
&:after
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
bottom: 0
|
||||||
|
background: repeating-linear-gradient(45deg, transparent 0px, transparent 10px, rgba($accent, 0.2) 10px, rgba($accent, 0.2) 20px)
|
||||||
|
pointer-events: none
|
||||||
|
z-index: 0
|
||||||
|
|
||||||
|
@media (min-width: 768px)
|
||||||
|
flex-direction: row
|
||||||
|
align-items: stretch
|
||||||
|
|
||||||
|
.winner-content
|
||||||
|
z-index: 1
|
||||||
|
|
||||||
|
.winner-title
|
||||||
|
margin-top: 0
|
||||||
|
color: $accent
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 1.8rem
|
||||||
|
text-transform: uppercase
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
.winner-info
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
.winner-song
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 1.2rem
|
||||||
|
color: $text
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
|
||||||
|
.winner-player
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 1rem
|
||||||
|
color: $primary
|
||||||
|
padding: 0.5rem
|
||||||
|
background-color: rgba($primary, 0.2)
|
||||||
|
border: 3px solid $primary
|
||||||
|
display: inline-block
|
||||||
|
|
||||||
|
.winner-info
|
||||||
|
flex: 1
|
||||||
|
|
||||||
|
h2
|
||||||
|
margin: 0 0 0.5rem 0
|
||||||
|
font-size: 1.8rem
|
||||||
|
color: $text
|
||||||
|
|
||||||
|
h3
|
||||||
|
margin: 0 0 1rem 0
|
||||||
|
color: $text-muted
|
||||||
|
font-size: 1.2rem
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif
|
||||||
|
|
||||||
|
.submitter
|
||||||
|
font-size: 0.9rem
|
||||||
|
color: $text-muted
|
||||||
|
margin-top: auto
|
||||||
|
|
||||||
|
.winner-video
|
||||||
|
flex: 1
|
||||||
|
min-height: 250px
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
.youtube-embed
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
border-radius: 0.5rem
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
.winner-placeholder
|
||||||
|
flex: 1
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
background-color: rgba(0, 0, 0, 0.3)
|
||||||
|
border-radius: 0.5rem
|
||||||
|
color: $accent
|
||||||
|
|
||||||
|
.results-actions
|
||||||
|
display: flex
|
||||||
|
flex-wrap: wrap
|
||||||
|
gap: 1rem
|
||||||
|
justify-content: center
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
.btn
|
||||||
|
padding: 0.75rem 1.5rem
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
|
||||||
|
.battle-history
|
||||||
|
background-color: $card-bg
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 1.5rem
|
||||||
|
|
||||||
|
h3
|
||||||
|
margin-top: 0
|
||||||
|
color: $secondary
|
||||||
|
|
||||||
|
.battles-list
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 1.5rem
|
||||||
|
|
||||||
|
.battle-item
|
||||||
|
background-color: rgba(255, 255, 255, 0.05)
|
||||||
|
border-radius: 0.5rem
|
||||||
|
padding: 1rem
|
||||||
|
|
||||||
|
.battle-header
|
||||||
|
margin-bottom: 1rem
|
||||||
|
|
||||||
|
h4
|
||||||
|
margin: 0
|
||||||
|
font-size: 1.1rem
|
||||||
|
color: $text
|
||||||
|
|
||||||
|
.battle-songs
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 1rem
|
||||||
|
|
||||||
|
.battle-song
|
||||||
|
flex: 1
|
||||||
|
padding: 1rem
|
||||||
|
border-radius: 0.5rem
|
||||||
|
background-color: rgba(0, 0, 0, 0.2)
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
&.winner
|
||||||
|
&::after
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
height: 3px
|
||||||
|
background: $success
|
||||||
|
|
||||||
|
.song-info
|
||||||
|
h5
|
||||||
|
margin: 0 0 0.25rem 0
|
||||||
|
font-size: 1rem
|
||||||
|
|
||||||
|
p
|
||||||
|
margin: 0 0 0.5rem 0
|
||||||
|
color: $text-muted
|
||||||
|
font-size: 0.9rem
|
||||||
|
|
||||||
|
.votes
|
||||||
|
display: inline-block
|
||||||
|
font-size: 0.8rem
|
||||||
|
padding: 0.25rem 0.5rem
|
||||||
|
border-radius: 1rem
|
||||||
|
background-color: rgba(255, 255, 255, 0.1)
|
||||||
|
margin-right: 0.5rem
|
||||||
|
|
||||||
|
.submitter
|
||||||
|
display: block
|
||||||
|
font-size: 0.8rem
|
||||||
|
color: $text-muted
|
||||||
|
margin-top: 0.5rem
|
||||||
|
font-style: italic
|
||||||
|
|
||||||
|
.versus
|
||||||
|
font-family: 'Bangers', cursive
|
||||||
|
font-size: 1.5rem
|
||||||
|
color: $accent
|
||||||
|
text-shadow: 0 0 5px rgba($accent, 0.5)
|
||||||
|
|
||||||
|
// Confetti animation
|
||||||
|
.confetti
|
||||||
|
position: absolute
|
||||||
|
width: 10px
|
||||||
|
height: 20px
|
||||||
|
transform-origin: center bottom
|
||||||
|
animation: fall 5s linear forwards
|
||||||
|
z-index: -1
|
||||||
|
|
||||||
|
@keyframes fall
|
||||||
|
0%
|
||||||
|
transform: translateY(-100vh) rotate(0deg)
|
||||||
|
100%
|
||||||
|
transform: translateY(100vh) rotate(360deg)
|
54
client/src/common/styles/components/song-form-overlay.sass
Normal file
54
client/src/common/styles/components/song-form-overlay.sass
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
// Song Form Overlay styles
|
||||||
|
@import '../colors'
|
||||||
|
|
||||||
|
.song-form-overlay
|
||||||
|
position: fixed
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
background-color: rgba(0, 0, 0, 0.75)
|
||||||
|
z-index: 1000
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
padding: 2rem
|
||||||
|
backdrop-filter: blur(5px)
|
||||||
|
|
||||||
|
.song-form
|
||||||
|
background-color: $card-bg
|
||||||
|
border: 4px solid #000
|
||||||
|
box-shadow: 8px 8px 0 rgba(0, 0, 0, 0.5)
|
||||||
|
padding: 2rem
|
||||||
|
width: 100%
|
||||||
|
max-width: 600px
|
||||||
|
max-height: 90vh
|
||||||
|
overflow-y: auto
|
||||||
|
position: relative
|
||||||
|
image-rendering: pixelated
|
||||||
|
|
||||||
|
h3
|
||||||
|
margin-top: 0
|
||||||
|
color: $primary
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
text-transform: uppercase
|
||||||
|
letter-spacing: 1px
|
||||||
|
text-shadow: 2px 2px 0 #000
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
&:after
|
||||||
|
content: ''
|
||||||
|
display: block
|
||||||
|
width: 100%
|
||||||
|
height: 4px
|
||||||
|
background-color: $primary
|
||||||
|
margin-top: 0.75rem
|
||||||
|
box-shadow: 2px 2px 0 #000
|
||||||
|
image-rendering: pixelated
|
||||||
|
|
||||||
|
.form-actions
|
||||||
|
display: flex
|
||||||
|
justify-content: flex-end
|
||||||
|
gap: 1rem
|
||||||
|
margin-top: 2rem
|
337
client/src/common/styles/components/song-submission-screen.sass
Normal file
337
client/src/common/styles/components/song-submission-screen.sass
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
// Song Submission Screen styles
|
||||||
|
|
||||||
|
.song-submission-screen
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
min-height: 100%
|
||||||
|
padding: 1.5rem
|
||||||
|
|
||||||
|
.song-submission-header
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: center
|
||||||
|
margin-bottom: 2rem
|
||||||
|
flex-wrap: wrap
|
||||||
|
gap: 1rem
|
||||||
|
|
||||||
|
h1
|
||||||
|
margin: 0
|
||||||
|
color: $primary
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 1.8rem
|
||||||
|
text-transform: uppercase
|
||||||
|
letter-spacing: 2px
|
||||||
|
|
||||||
|
svg
|
||||||
|
filter: drop-shadow(2px 2px 0 #000)
|
||||||
|
|
||||||
|
.songs-counter
|
||||||
|
background-color: $card-bg
|
||||||
|
border: 4px solid #000
|
||||||
|
box-shadow: 4px 4px 0 #000
|
||||||
|
padding: 0.75rem 1rem
|
||||||
|
font-size: 1rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
|
||||||
|
.counter
|
||||||
|
font-weight: bold
|
||||||
|
color: $secondary
|
||||||
|
text-shadow: 1px 1px 0 #000
|
||||||
|
|
||||||
|
.submission-content
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
flex-grow: 1
|
||||||
|
gap: 1.5rem
|
||||||
|
|
||||||
|
@media (min-width: 768px)
|
||||||
|
flex-direction: row
|
||||||
|
|
||||||
|
.songs-list
|
||||||
|
flex: 1.5
|
||||||
|
background-color: $card-bg
|
||||||
|
border: 4px solid #000
|
||||||
|
box-shadow: 8px 8px 0 #000
|
||||||
|
padding: 1.5rem
|
||||||
|
|
||||||
|
h2
|
||||||
|
margin-top: 0
|
||||||
|
color: $secondary
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 1.2rem
|
||||||
|
text-transform: uppercase
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
text-shadow: 2px 2px 0 #000
|
||||||
|
|
||||||
|
&:after
|
||||||
|
content: ''
|
||||||
|
display: block
|
||||||
|
width: 100%
|
||||||
|
height: 4px
|
||||||
|
background-color: $secondary
|
||||||
|
margin-top: 0.5rem
|
||||||
|
box-shadow: 2px 2px 0 #000
|
||||||
|
image-rendering: pixelated
|
||||||
|
|
||||||
|
.song-item
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
padding: 1rem
|
||||||
|
background-color: rgba(0, 0, 0, 0.3)
|
||||||
|
border: 3px solid #000
|
||||||
|
position: relative
|
||||||
|
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.5)
|
||||||
|
transition: transform 0.2s ease
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
transform: translateX(2px) translateY(-2px)
|
||||||
|
box-shadow: 6px 6px 0 rgba(0, 0, 0, 0.5)
|
||||||
|
|
||||||
|
.song-title
|
||||||
|
font-weight: bold
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.9rem
|
||||||
|
color: $text
|
||||||
|
margin-bottom: 0.75rem
|
||||||
|
text-shadow: 1px 1px 0 #000
|
||||||
|
letter-spacing: 0.5px
|
||||||
|
|
||||||
|
.song-channel
|
||||||
|
font-size: 0.8rem
|
||||||
|
color: $text-muted
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
letter-spacing: 0.5px
|
||||||
|
text-shadow: 1px 1px 0 rgba(0,0,0,0.5)
|
||||||
|
|
||||||
|
.remove-song
|
||||||
|
position: absolute
|
||||||
|
top: 0.75rem
|
||||||
|
right: 0.75rem
|
||||||
|
background: $danger
|
||||||
|
color: #fff
|
||||||
|
border: 2px solid #000
|
||||||
|
width: 28px
|
||||||
|
height: 28px
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.7rem
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
cursor: pointer
|
||||||
|
box-shadow: 2px 2px 0 #000
|
||||||
|
image-rendering: pixelated
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
transform: translateY(-2px)
|
||||||
|
box-shadow: 2px 4px 0 #000
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
.songs-grid
|
||||||
|
display: grid
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr))
|
||||||
|
gap: 1rem
|
||||||
|
|
||||||
|
.song-card
|
||||||
|
background-color: rgba(255, 255, 255, 0.05)
|
||||||
|
border-radius: 0.5rem
|
||||||
|
box-sizing: border-box
|
||||||
|
padding: 1rem
|
||||||
|
position: relative
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
transform: translateY(-3px)
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3)
|
||||||
|
|
||||||
|
h3
|
||||||
|
margin: 0 0 0.25rem 0
|
||||||
|
font-size: 1.2rem
|
||||||
|
|
||||||
|
p
|
||||||
|
margin: 0 0 1rem 0
|
||||||
|
color: $text-muted
|
||||||
|
|
||||||
|
.song-link
|
||||||
|
font-size: 0.85rem
|
||||||
|
display: inline-flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.25rem
|
||||||
|
color: $secondary
|
||||||
|
|
||||||
|
.remove-btn
|
||||||
|
position: absolute
|
||||||
|
top: 0.75rem
|
||||||
|
right: 0.75rem
|
||||||
|
background: none
|
||||||
|
border: none
|
||||||
|
color: $text-muted
|
||||||
|
cursor: pointer
|
||||||
|
padding: 0.25rem
|
||||||
|
border-radius: 50%
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
color: $danger
|
||||||
|
background-color: rgba(255, 255, 255, 0.1)
|
||||||
|
|
||||||
|
.add-song-btn
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
padding: 1rem
|
||||||
|
background-color: rgba(255, 255, 255, 0.05)
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.2)
|
||||||
|
border-radius: 0.5rem
|
||||||
|
color: $text-muted
|
||||||
|
cursor: pointer
|
||||||
|
gap: 0.5rem
|
||||||
|
transition: all 0.2s
|
||||||
|
margin-top: 1.5rem
|
||||||
|
margin-bottom: 1rem
|
||||||
|
clear: both
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: rgba($primary, 0.1)
|
||||||
|
border-color: rgba($primary, 0.4)
|
||||||
|
color: $text
|
||||||
|
|
||||||
|
.status-section
|
||||||
|
flex: 1
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 1.5rem
|
||||||
|
|
||||||
|
.ready-section
|
||||||
|
background-color: $card-bg
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 1.5rem
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
h3
|
||||||
|
margin-top: 0
|
||||||
|
color: $success
|
||||||
|
|
||||||
|
.ready-btn
|
||||||
|
margin-top: 1rem
|
||||||
|
padding: 1rem 2rem
|
||||||
|
|
||||||
|
.player-status
|
||||||
|
background-color: $card-bg
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 1.5rem
|
||||||
|
flex-grow: 1
|
||||||
|
|
||||||
|
h4
|
||||||
|
margin-top: 0
|
||||||
|
color: $secondary
|
||||||
|
|
||||||
|
.players-ready-list
|
||||||
|
list-style: none
|
||||||
|
padding: 0
|
||||||
|
margin: 0
|
||||||
|
|
||||||
|
li
|
||||||
|
padding: 0.75rem 1rem
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
border-radius: 0.5rem
|
||||||
|
background-color: rgba(255, 255, 255, 0.05)
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
&.ready
|
||||||
|
border-left: 3px solid $success
|
||||||
|
|
||||||
|
&.not-ready
|
||||||
|
border-left: 3px solid $warning
|
||||||
|
|
||||||
|
.ready-icon
|
||||||
|
margin-left: auto
|
||||||
|
color: $success
|
||||||
|
|
||||||
|
.songs-count
|
||||||
|
margin-left: auto
|
||||||
|
font-size: 0.9rem
|
||||||
|
color: $warning
|
||||||
|
|
||||||
|
// Song submission form modal
|
||||||
|
.song-form
|
||||||
|
background-color: $card-bg
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 1.5rem
|
||||||
|
margin-top: 1.5rem
|
||||||
|
|
||||||
|
h3
|
||||||
|
margin-top: 0
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
.search-group
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
.search-container
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
.spinner-icon
|
||||||
|
position: absolute
|
||||||
|
right: 1rem
|
||||||
|
top: 50%
|
||||||
|
transform: translateY(-50%)
|
||||||
|
color: $secondary
|
||||||
|
|
||||||
|
.search-input
|
||||||
|
width: 100%
|
||||||
|
padding-right: 2.5rem
|
||||||
|
|
||||||
|
.search-results
|
||||||
|
position: absolute
|
||||||
|
top: 100%
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
max-height: 300px
|
||||||
|
overflow-y: auto
|
||||||
|
background: rgba($card-bg, 0.95)
|
||||||
|
border-radius: 0.5rem
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2)
|
||||||
|
z-index: 10
|
||||||
|
backdrop-filter: blur(5px)
|
||||||
|
|
||||||
|
.search-result
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
padding: 0.75rem
|
||||||
|
cursor: pointer
|
||||||
|
border-bottom: 1px solid rgba($text, 0.1)
|
||||||
|
transition: background-color 0.2s
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: rgba($primary, 0.1)
|
||||||
|
|
||||||
|
.result-thumbnail
|
||||||
|
width: 60px
|
||||||
|
height: 45px
|
||||||
|
object-fit: cover
|
||||||
|
border-radius: 0.25rem
|
||||||
|
margin-right: 1rem
|
||||||
|
|
||||||
|
.result-info
|
||||||
|
flex: 1
|
||||||
|
|
||||||
|
h4
|
||||||
|
margin: 0
|
||||||
|
font-size: 0.9rem
|
||||||
|
color: $text
|
||||||
|
|
||||||
|
p
|
||||||
|
margin: 0.25rem 0 0
|
||||||
|
font-size: 0.8rem
|
||||||
|
color: $text-muted
|
||||||
|
|
||||||
|
.spinner-icon
|
||||||
|
margin-left: 0.5rem
|
||||||
|
color: $secondary
|
676
client/src/common/styles/components/voting-screen.sass
Normal file
676
client/src/common/styles/components/voting-screen.sass
Normal file
@ -0,0 +1,676 @@
|
|||||||
|
// Voting Screen styles
|
||||||
|
|
||||||
|
.voting-screen
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
min-height: 100%
|
||||||
|
padding: 1.5rem
|
||||||
|
|
||||||
|
.voting-header
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: center
|
||||||
|
margin-bottom: 2rem
|
||||||
|
flex-wrap: wrap
|
||||||
|
gap: 1rem
|
||||||
|
|
||||||
|
h1
|
||||||
|
margin: 0
|
||||||
|
color: $primary
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
text-transform: uppercase
|
||||||
|
font-size: 1.8rem
|
||||||
|
|
||||||
|
svg
|
||||||
|
filter: drop-shadow(2px 2px 0 #000)
|
||||||
|
|
||||||
|
.round-info
|
||||||
|
background-color: $card-bg
|
||||||
|
border: 4px solid #000
|
||||||
|
box-shadow: 4px 4px 0 #000
|
||||||
|
padding: 0.75rem 1rem
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.75rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.8rem
|
||||||
|
|
||||||
|
span
|
||||||
|
font-weight: bold
|
||||||
|
|
||||||
|
.voted-badge
|
||||||
|
background-color: $success
|
||||||
|
color: #fff
|
||||||
|
border: 2px solid #000
|
||||||
|
padding: 0.25rem 0.75rem
|
||||||
|
font-size: 0.75rem
|
||||||
|
animation: pixel-pulse 2s infinite
|
||||||
|
|
||||||
|
@keyframes pixel-pulse
|
||||||
|
0%, 100%
|
||||||
|
transform: scale(1)
|
||||||
|
opacity: 1
|
||||||
|
50%
|
||||||
|
transform: scale(1.1)
|
||||||
|
opacity: 0.8
|
||||||
|
|
||||||
|
.battle-container
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
gap: 2rem
|
||||||
|
margin-bottom: 2rem
|
||||||
|
perspective: 1000px
|
||||||
|
|
||||||
|
.song-card
|
||||||
|
box-sizing: border-box
|
||||||
|
width: 100%
|
||||||
|
max-width: 600px
|
||||||
|
background-color: $card-bg
|
||||||
|
border: 4px solid #000
|
||||||
|
box-shadow: 8px 8px 0 #000
|
||||||
|
padding: 1rem
|
||||||
|
transition: all 0.2s ease
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
&.selected
|
||||||
|
border-color: $primary
|
||||||
|
box-shadow: 0 0 20px $primary, 8px 8px 0 #000
|
||||||
|
transform: translateY(-4px)
|
||||||
|
|
||||||
|
.song-spotlight
|
||||||
|
opacity: 1
|
||||||
|
|
||||||
|
&.bye-winner
|
||||||
|
border-color: $accent
|
||||||
|
box-shadow: 0 0 20px $accent, 8px 8px 0 #000
|
||||||
|
|
||||||
|
.song-spotlight
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
bottom: 0
|
||||||
|
background: linear-gradient(45deg, transparent 0%, rgba($primary, 0.1) 45%, rgba($primary, 0.4) 50%, rgba($primary, 0.1) 55%, transparent 100%)
|
||||||
|
pointer-events: none
|
||||||
|
opacity: 0
|
||||||
|
transition: opacity 0.3s ease
|
||||||
|
|
||||||
|
.song-details
|
||||||
|
h3
|
||||||
|
margin-top: 0
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 1.1rem
|
||||||
|
color: $secondary
|
||||||
|
|
||||||
|
p
|
||||||
|
margin-top: 0
|
||||||
|
margin-bottom: 1rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.8rem
|
||||||
|
color: $text
|
||||||
|
|
||||||
|
.auto-advance-notice
|
||||||
|
margin-top: 1rem
|
||||||
|
padding: 0.75rem
|
||||||
|
background-color: rgba($accent, 0.2)
|
||||||
|
border: 2px solid $accent
|
||||||
|
|
||||||
|
p
|
||||||
|
margin: 0
|
||||||
|
color: $accent
|
||||||
|
font-weight: bold
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
.host-info
|
||||||
|
margin-top: 0.5rem
|
||||||
|
color: #fff
|
||||||
|
font-style: italic
|
||||||
|
font-size: 0.7rem
|
||||||
|
|
||||||
|
.video-container
|
||||||
|
margin-top: 1rem
|
||||||
|
border: 4px solid #000
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
.video-overlay
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
background: repeating-linear-gradient( 0deg, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.1) 2px, transparent 2px, transparent 4px)
|
||||||
|
pointer-events: none
|
||||||
|
opacity: 0.4
|
||||||
|
|
||||||
|
.no-video
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
height: 180px
|
||||||
|
background-color: rgba(0, 0, 0, 0.3)
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.3)
|
||||||
|
margin-top: 1rem
|
||||||
|
|
||||||
|
.pulse-icon
|
||||||
|
font-size: 2rem
|
||||||
|
color: $text-muted
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
animation: pixel-float 2s infinite
|
||||||
|
|
||||||
|
span
|
||||||
|
color: $text-muted
|
||||||
|
font-size: 0.9rem
|
||||||
|
|
||||||
|
.vote-count
|
||||||
|
position: absolute
|
||||||
|
top: 1rem
|
||||||
|
right: 1rem
|
||||||
|
background-color: rgba(#000, 0.7)
|
||||||
|
border: 2px solid $primary
|
||||||
|
padding: 0.5rem
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
.vote-number
|
||||||
|
font-size: 1.5rem
|
||||||
|
font-weight: bold
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
.vote-text
|
||||||
|
font-size: 0.7rem
|
||||||
|
color: $text-muted
|
||||||
|
|
||||||
|
.selection-indicator
|
||||||
|
position: absolute
|
||||||
|
top: 1rem
|
||||||
|
right: 1rem
|
||||||
|
background-color: $primary
|
||||||
|
color: #000
|
||||||
|
width: 2rem
|
||||||
|
height: 2rem
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
border: 2px solid #000
|
||||||
|
animation: pixel-pulse 1s infinite
|
||||||
|
|
||||||
|
.versus
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
margin: 0.5rem 0
|
||||||
|
|
||||||
|
.versus-text
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 1.5rem
|
||||||
|
font-weight: bold
|
||||||
|
color: $accent
|
||||||
|
letter-spacing: 2px
|
||||||
|
animation: pixel-flash 1.5s infinite
|
||||||
|
|
||||||
|
.versus-decoration
|
||||||
|
width: 150px
|
||||||
|
height: 4px
|
||||||
|
background-color: $accent
|
||||||
|
margin: 0.5rem 0
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
&:before, &:after
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
width: 15px
|
||||||
|
height: 15px
|
||||||
|
background-color: $accent
|
||||||
|
transform: rotate(45deg)
|
||||||
|
|
||||||
|
&:before
|
||||||
|
left: -5px
|
||||||
|
top: -5px
|
||||||
|
|
||||||
|
&:after
|
||||||
|
right: -5px
|
||||||
|
top: -5px
|
||||||
|
|
||||||
|
@keyframes pixel-flash
|
||||||
|
0%, 100%
|
||||||
|
opacity: 1
|
||||||
|
50%
|
||||||
|
opacity: 0.5
|
||||||
|
|
||||||
|
@keyframes pixel-float
|
||||||
|
0%, 100%
|
||||||
|
transform: translateY(0)
|
||||||
|
50%
|
||||||
|
transform: translateY(-5px)
|
||||||
|
|
||||||
|
// Voting action buttons area
|
||||||
|
.voting-actions
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
margin: 2rem 0
|
||||||
|
|
||||||
|
.btn
|
||||||
|
min-width: 180px
|
||||||
|
font-size: 1rem
|
||||||
|
|
||||||
|
&.offline
|
||||||
|
background-color: $secondary
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
.offline-notice
|
||||||
|
margin-top: 1rem
|
||||||
|
padding: 0.5rem
|
||||||
|
background-color: rgba($secondary, 0.2)
|
||||||
|
border: 2px solid $secondary
|
||||||
|
max-width: 400px
|
||||||
|
text-align: center
|
||||||
|
font-size: 0.7rem
|
||||||
|
|
||||||
|
svg
|
||||||
|
margin-right: 0.5rem
|
||||||
|
color: $secondary
|
||||||
|
|
||||||
|
.offline-vote-status
|
||||||
|
margin-top: 1rem
|
||||||
|
padding: 0.5rem
|
||||||
|
background-color: rgba($success, 0.2)
|
||||||
|
border: 2px solid $success
|
||||||
|
max-width: 400px
|
||||||
|
text-align: center
|
||||||
|
font-size: 0.7rem
|
||||||
|
color: $success
|
||||||
|
animation: pulse-opacity 2s infinite
|
||||||
|
|
||||||
|
&.error
|
||||||
|
background-color: rgba(#f44336, 0.2)
|
||||||
|
border-color: #f44336
|
||||||
|
color: #f44336
|
||||||
|
|
||||||
|
// Voting status and information
|
||||||
|
.voting-status
|
||||||
|
background-color: $card-bg
|
||||||
|
border: 4px solid #000
|
||||||
|
box-shadow: 4px 4px 0 #000
|
||||||
|
padding: 1rem
|
||||||
|
margin-top: auto
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
p
|
||||||
|
margin: 0 0 1rem 0
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.9rem
|
||||||
|
color: $text
|
||||||
|
|
||||||
|
.reconnecting-notice
|
||||||
|
margin: 0.5rem auto 1rem auto
|
||||||
|
padding: 0.5rem
|
||||||
|
background-color: rgba($secondary, 0.2)
|
||||||
|
border: 2px solid $secondary
|
||||||
|
max-width: 400px
|
||||||
|
text-align: center
|
||||||
|
font-size: 0.7rem
|
||||||
|
color: $secondary
|
||||||
|
animation: pulse-opacity 2s infinite
|
||||||
|
|
||||||
|
.auto-advance-notice
|
||||||
|
margin: 1rem auto
|
||||||
|
max-width: 400px
|
||||||
|
padding: 0.75rem
|
||||||
|
background-color: rgba($secondary, 0.2)
|
||||||
|
border: 2px solid $secondary
|
||||||
|
text-align: center
|
||||||
|
color: $text-muted
|
||||||
|
font-style: italic
|
||||||
|
|
||||||
|
.votes-count
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.8rem
|
||||||
|
margin-bottom: 1rem
|
||||||
|
|
||||||
|
span
|
||||||
|
color: $primary
|
||||||
|
font-weight: bold
|
||||||
|
|
||||||
|
.offline-badge
|
||||||
|
color: $secondary
|
||||||
|
margin-left: 0.5rem
|
||||||
|
font-size: 0.7rem
|
||||||
|
|
||||||
|
// Player votes list styling
|
||||||
|
.player-votes
|
||||||
|
background-color: rgba(0, 0, 0, 0.2)
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.2)
|
||||||
|
border-radius: 0.5rem
|
||||||
|
padding: 0.75rem
|
||||||
|
margin-top: 0.5rem
|
||||||
|
|
||||||
|
h4
|
||||||
|
margin: 0 0 0.75rem 0
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.8rem
|
||||||
|
color: $secondary
|
||||||
|
position: relative
|
||||||
|
display: inline-block
|
||||||
|
|
||||||
|
&:before, &:after
|
||||||
|
content: '>'
|
||||||
|
position: absolute
|
||||||
|
color: $accent
|
||||||
|
animation: pixel-blink 1s infinite
|
||||||
|
|
||||||
|
&:before
|
||||||
|
left: -1rem
|
||||||
|
|
||||||
|
&:after
|
||||||
|
right: -1rem
|
||||||
|
|
||||||
|
.players-voted-list
|
||||||
|
list-style: none
|
||||||
|
padding: 0
|
||||||
|
margin: 0
|
||||||
|
display: grid
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr))
|
||||||
|
gap: 0.5rem
|
||||||
|
|
||||||
|
li
|
||||||
|
padding: 0.5rem
|
||||||
|
border: 2px solid transparent
|
||||||
|
font-size: 0.75rem
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: space-between
|
||||||
|
transition: all 0.2s ease
|
||||||
|
position: relative
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
&.voted
|
||||||
|
background-color: rgba($success, 0.1)
|
||||||
|
border-color: rgba($success, 0.5)
|
||||||
|
|
||||||
|
.vote-icon
|
||||||
|
color: $success
|
||||||
|
margin-left: 0.5rem
|
||||||
|
filter: drop-shadow(0 0 2px rgba($success, 0.8))
|
||||||
|
animation: pixel-pulse 1.5s infinite
|
||||||
|
|
||||||
|
&:before
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
background: repeating-linear-gradient(45deg, transparent, transparent 5px, rgba($success, 0.1) 5px, rgba($success, 0.1) 10px)
|
||||||
|
z-index: -1
|
||||||
|
|
||||||
|
&.not-voted
|
||||||
|
background-color: rgba(255, 255, 255, 0.05)
|
||||||
|
border-color: rgba(255, 255, 255, 0.1)
|
||||||
|
opacity: 0.7
|
||||||
|
|
||||||
|
&.offline-voted
|
||||||
|
background-color: rgba($secondary, 0.1)
|
||||||
|
border-color: rgba($secondary, 0.5)
|
||||||
|
|
||||||
|
.offline-icon
|
||||||
|
color: $secondary
|
||||||
|
margin-left: 0.5rem
|
||||||
|
animation: pixel-pulse 1.5s infinite
|
||||||
|
|
||||||
|
@keyframes pulse-opacity
|
||||||
|
0%, 100%
|
||||||
|
opacity: 1
|
||||||
|
50%
|
||||||
|
opacity: 0.5
|
||||||
|
|
||||||
|
@keyframes pixel-blink
|
||||||
|
0%, 100%
|
||||||
|
opacity: 1
|
||||||
|
50%
|
||||||
|
opacity: 0.3
|
||||||
|
|
||||||
|
// Bye container for automatic advances
|
||||||
|
.bye-container
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
margin: 2rem 0
|
||||||
|
animation: pixel-float 3s infinite ease-in-out
|
||||||
|
|
||||||
|
// Vote Progress Bar
|
||||||
|
.vote-progress-container
|
||||||
|
width: 100%
|
||||||
|
margin-top: 1.5rem
|
||||||
|
margin-bottom: 2rem
|
||||||
|
padding: 0 0.5rem
|
||||||
|
animation: fade-in-up 0.5s ease forwards
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
@keyframes fade-in-up
|
||||||
|
from
|
||||||
|
opacity: 0
|
||||||
|
transform: translateY(10px)
|
||||||
|
to
|
||||||
|
opacity: 1
|
||||||
|
transform: translateY(0)
|
||||||
|
|
||||||
|
.vote-progress-labels
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
font-size: 0.8rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
|
||||||
|
.vote-label
|
||||||
|
padding: 0.5rem 0.75rem
|
||||||
|
background-color: rgba(0, 0, 0, 0.7)
|
||||||
|
border: 2px solid #000
|
||||||
|
transform: translateY(0)
|
||||||
|
transition: transform 0.3s ease
|
||||||
|
position: relative
|
||||||
|
z-index: 2
|
||||||
|
|
||||||
|
&.left
|
||||||
|
color: $primary
|
||||||
|
border-left-color: $primary
|
||||||
|
border-top-color: $primary
|
||||||
|
border-bottom-color: $primary
|
||||||
|
box-shadow: -3px 3px 0 rgba($primary, 0.3)
|
||||||
|
&:hover
|
||||||
|
transform: translateY(-3px)
|
||||||
|
box-shadow: -5px 5px 0 rgba($primary, 0.5)
|
||||||
|
|
||||||
|
&.right
|
||||||
|
color: $accent
|
||||||
|
border-right-color: $accent
|
||||||
|
border-top-color: $accent
|
||||||
|
border-bottom-color: $accent
|
||||||
|
box-shadow: 3px 3px 0 rgba($accent, 0.3)
|
||||||
|
&:hover
|
||||||
|
transform: translateY(-3px)
|
||||||
|
box-shadow: 5px 5px 0 rgba($accent, 0.5)
|
||||||
|
|
||||||
|
&.winning
|
||||||
|
background-color: rgba(#000, 0.8)
|
||||||
|
border-width: 3px
|
||||||
|
transform: translateY(-3px)
|
||||||
|
position: relative
|
||||||
|
z-index: 3
|
||||||
|
animation: winner-pulse 1.5s infinite alternate
|
||||||
|
|
||||||
|
&.left
|
||||||
|
box-shadow: -5px 5px 0 rgba($primary, 0.7), 0 0 15px rgba($primary, 0.7)
|
||||||
|
|
||||||
|
&.right
|
||||||
|
box-shadow: 5px 5px 0 rgba($accent, 0.7), 0 0 15px rgba($accent, 0.7)
|
||||||
|
|
||||||
|
&.landslide
|
||||||
|
transform: translateY(-5px)
|
||||||
|
font-weight: bold
|
||||||
|
animation: winner-landslide-pulse 1s infinite alternate
|
||||||
|
|
||||||
|
&.left
|
||||||
|
box-shadow: -6px 6px 0 rgba($primary, 0.7), 0 0 25px rgba($primary, 0.9)
|
||||||
|
|
||||||
|
&.right
|
||||||
|
box-shadow: 6px 6px 0 rgba($accent, 0.7), 0 0 25px rgba($accent, 0.9)
|
||||||
|
|
||||||
|
@keyframes winner-landslide-pulse
|
||||||
|
from
|
||||||
|
box-shadow: -6px 6px 0 rgba($primary, 0.7), 0 0 25px rgba($primary, 0.9)
|
||||||
|
to
|
||||||
|
box-shadow: -6px 6px 0 rgba($primary, 0.7), 0 0 40px rgba($primary, 1)
|
||||||
|
|
||||||
|
.vote-progress-bar
|
||||||
|
height: 36px
|
||||||
|
background-color: #222
|
||||||
|
border: 4px solid #000
|
||||||
|
position: relative
|
||||||
|
overflow: hidden
|
||||||
|
display: flex
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3)
|
||||||
|
|
||||||
|
.vote-progress-fill
|
||||||
|
height: 100%
|
||||||
|
transition: width 1.2s cubic-bezier(0.34, 1.56, 0.64, 1)
|
||||||
|
position: relative
|
||||||
|
min-width: 0
|
||||||
|
|
||||||
|
&.song1
|
||||||
|
background: linear-gradient(to right, darken($primary, 20%), $primary)
|
||||||
|
box-shadow: 0 8px 24px rgba($primary, 0.3), 0 0 10px rgba($primary, 0.2)
|
||||||
|
animation: pulse-song1 2s infinite alternate
|
||||||
|
|
||||||
|
&.song2
|
||||||
|
background: linear-gradient(to left, darken($accent, 20%), $accent)
|
||||||
|
box-shadow: 0 8px 24px rgba($accent, 0.3), 0 0 10px rgba($accent, 0.2)
|
||||||
|
animation: pulse-song2 2s infinite alternate
|
||||||
|
|
||||||
|
.vote-progress-decoration
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
bottom: 0
|
||||||
|
width: 20px
|
||||||
|
background-size: 4px 4px
|
||||||
|
opacity: 0.6
|
||||||
|
|
||||||
|
&.left
|
||||||
|
right: 10px
|
||||||
|
background-image: linear-gradient(45deg, rgba(255,255,255,0.4) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.4) 50%, rgba(255,255,255,0.4) 75%, transparent 75%, transparent)
|
||||||
|
animation: move-stripes-left 20s linear infinite
|
||||||
|
|
||||||
|
&.right
|
||||||
|
left: 10px
|
||||||
|
background-image: linear-gradient(-45deg, rgba(255,255,255,0.4) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.4) 50%, rgba(255,255,255,0.4) 75%, transparent 75%, transparent)
|
||||||
|
animation: move-stripes-right 20s linear infinite
|
||||||
|
|
||||||
|
&.landslide
|
||||||
|
border-width: 5px
|
||||||
|
animation: landslide-border-pulse 1.5s infinite alternate
|
||||||
|
|
||||||
|
&.song1
|
||||||
|
border-color: lighten($primary, 20%)
|
||||||
|
|
||||||
|
&.song2
|
||||||
|
border-color: lighten($accent, 20%)
|
||||||
|
|
||||||
|
.vote-progress-divider
|
||||||
|
position: absolute
|
||||||
|
top: -8px
|
||||||
|
bottom: -8px
|
||||||
|
width: 6px
|
||||||
|
background-color: #fff
|
||||||
|
transform: translateX(-50%)
|
||||||
|
z-index: 10
|
||||||
|
box-shadow: 0 0 15px rgba(255, 255, 255, 0.7), 0 0 5px rgba(0, 0, 0, 0.5)
|
||||||
|
transition: left 1.2s cubic-bezier(0.34, 1.56, 0.64, 1)
|
||||||
|
&:before, &:after
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
left: 50%
|
||||||
|
width: 12px
|
||||||
|
height: 12px
|
||||||
|
background-color: #fff
|
||||||
|
transform: translateX(-50%) rotate(45deg)
|
||||||
|
box-shadow: 0 0 10px rgba(255, 255, 255, 0.7), 0 0 3px rgba(0, 0, 0, 0.5)
|
||||||
|
|
||||||
|
&:before
|
||||||
|
top: 0
|
||||||
|
|
||||||
|
&:after
|
||||||
|
bottom: 0
|
||||||
|
|
||||||
|
.vote-progress-percentages
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
margin-top: 0.75rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 1rem
|
||||||
|
|
||||||
|
.vote-percent
|
||||||
|
font-weight: bold
|
||||||
|
text-shadow: 2px 2px 0 #000
|
||||||
|
padding: 0.4rem 0.75rem
|
||||||
|
background-color: rgba(0, 0, 0, 0.7)
|
||||||
|
border: 2px solid #000
|
||||||
|
letter-spacing: 1px
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
&.left
|
||||||
|
color: $primary
|
||||||
|
border-left-color: $primary
|
||||||
|
border-bottom-color: $primary
|
||||||
|
transform: skew(-10deg)
|
||||||
|
box-shadow: -3px 3px 0 rgba($primary, 0.4)
|
||||||
|
|
||||||
|
&.right
|
||||||
|
color: $accent
|
||||||
|
border-right-color: $accent
|
||||||
|
border-bottom-color: $accent
|
||||||
|
transform: skew(10deg)
|
||||||
|
box-shadow: 3px 3px 0 rgba($accent, 0.4)
|
||||||
|
|
||||||
|
&.winning
|
||||||
|
font-size: 1.2rem
|
||||||
|
font-weight: bolder
|
||||||
|
animation: winner-text-pulse 2s infinite alternate
|
||||||
|
|
||||||
|
&.left
|
||||||
|
box-shadow: -5px 5px 0 rgba($primary, 0.5), 0 0 10px rgba($primary, 0.5)
|
||||||
|
|
||||||
|
&.right
|
||||||
|
box-shadow: 5px 5px 0 rgba($accent, 0.5), 0 0 10px rgba($accent, 0.5)
|
||||||
|
|
||||||
|
&.landslide
|
||||||
|
font-size: 1.3rem
|
||||||
|
animation: landslide-text-pulse 1s infinite alternate
|
||||||
|
|
||||||
|
&.left
|
||||||
|
box-shadow: -6px 6px 0 rgba($primary, 0.6), 0 0 15px rgba($primary, 0.6)
|
||||||
|
|
||||||
|
&.right
|
||||||
|
box-shadow: 6px 6px 0 rgba($accent, 0.6), 0 0 15px rgba($accent, 0.6)
|
||||||
|
|
||||||
|
@keyframes landslide-text-pulse
|
||||||
|
from
|
||||||
|
text-shadow: 2px 2px 0 #000, 0 0 10px currentColor
|
||||||
|
to
|
||||||
|
text-shadow: 2px 2px 0 #000, 0 0 25px currentColor, 0 0 5px #fff
|
||||||
|
|
||||||
|
// Bye container for automatic advances
|
||||||
|
.bye-container
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
margin: 2rem 0
|
||||||
|
animation: pixel-float 3s infinite ease-in-out
|
41
client/src/common/styles/components/youtube-embed.sass
Normal file
41
client/src/common/styles/components/youtube-embed.sass
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// YouTube Embed component styles
|
||||||
|
|
||||||
|
.youtube-embed
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
background-color: #000
|
||||||
|
overflow: hidden
|
||||||
|
border: 4px solid #000
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
// Pixel art CRT screen effect
|
||||||
|
&:before
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
bottom: 0
|
||||||
|
background: linear-gradient(transparent 50%, rgba(0, 0, 0, 0.1) 50%)
|
||||||
|
background-size: 100% 4px
|
||||||
|
z-index: 2
|
||||||
|
pointer-events: none
|
||||||
|
opacity: 0.4
|
||||||
|
|
||||||
|
// Video glow effect
|
||||||
|
&:after
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
top: -3px
|
||||||
|
left: -3px
|
||||||
|
right: -3px
|
||||||
|
bottom: -3px
|
||||||
|
background-color: transparent
|
||||||
|
box-shadow: 0 0 10px rgba($secondary, 0.5)
|
||||||
|
z-index: -1
|
||||||
|
pointer-events: none
|
||||||
|
|
||||||
|
iframe
|
||||||
|
border: none
|
||||||
|
z-index: 1
|
||||||
|
position: relative
|
210
client/src/common/styles/components/youtube-search.sass
Normal file
210
client/src/common/styles/components/youtube-search.sass
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
// YouTube Search Component Styles
|
||||||
|
@import '../colors'
|
||||||
|
|
||||||
|
.search-group
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
.search-container
|
||||||
|
position: relative
|
||||||
|
margin-bottom: 1rem
|
||||||
|
|
||||||
|
.search-input
|
||||||
|
width: 100%
|
||||||
|
padding: 0.75rem 2.5rem 0.75rem 1rem
|
||||||
|
background: rgba(0, 0, 0, 0.3)
|
||||||
|
border: 3px solid #000
|
||||||
|
color: $text
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.8rem
|
||||||
|
box-shadow: 4px 4px 0 #000
|
||||||
|
|
||||||
|
&:focus
|
||||||
|
outline: none
|
||||||
|
border-color: $secondary
|
||||||
|
box-shadow: 4px 4px 0 #000, 0 0 8px rgba($secondary, 0.5)
|
||||||
|
|
||||||
|
.spinner-icon, .clear-icon
|
||||||
|
position: absolute
|
||||||
|
top: 50%
|
||||||
|
right: 1rem
|
||||||
|
transform: translateY(-50%)
|
||||||
|
color: $text-muted
|
||||||
|
filter: drop-shadow(1px 1px 0 #000)
|
||||||
|
|
||||||
|
.clear-icon
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
color: $text
|
||||||
|
transform: translateY(-50%) scale(1.2)
|
||||||
|
transition: all 0.2s ease
|
||||||
|
|
||||||
|
.selected-video
|
||||||
|
background: $card-bg
|
||||||
|
border: 3px solid #000
|
||||||
|
box-shadow: 4px 4px 0 #000
|
||||||
|
padding: 1rem
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
border: 1px solid rgba($primary, 0.3)
|
||||||
|
|
||||||
|
.preview-header
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: center
|
||||||
|
margin-bottom: 0.75rem
|
||||||
|
|
||||||
|
h4
|
||||||
|
margin: 0
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
.external-link
|
||||||
|
color: $secondary
|
||||||
|
text-decoration: none
|
||||||
|
font-size: 0.85rem
|
||||||
|
padding: 0.25rem 0.5rem
|
||||||
|
border-radius: 0.25rem
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: rgba($secondary, 0.15)
|
||||||
|
|
||||||
|
.video-details
|
||||||
|
display: flex
|
||||||
|
gap: 1rem
|
||||||
|
margin-bottom: 1rem
|
||||||
|
|
||||||
|
.thumbnail-container
|
||||||
|
position: relative
|
||||||
|
width: 120px
|
||||||
|
flex-shrink: 0
|
||||||
|
|
||||||
|
.selected-thumbnail
|
||||||
|
width: 120px
|
||||||
|
height: 90px
|
||||||
|
object-fit: cover
|
||||||
|
border-radius: 0.25rem
|
||||||
|
|
||||||
|
.play-overlay
|
||||||
|
position: absolute
|
||||||
|
top: 50%
|
||||||
|
left: 50%
|
||||||
|
transform: translate(-50%, -50%)
|
||||||
|
color: white
|
||||||
|
background: rgba(0, 0, 0, 0.6)
|
||||||
|
width: 36px
|
||||||
|
height: 36px
|
||||||
|
border-radius: 50%
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
opacity: 0.7
|
||||||
|
|
||||||
|
.selected-info
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
justify-content: center
|
||||||
|
|
||||||
|
h4
|
||||||
|
margin: 0 0 0.25rem 0
|
||||||
|
font-size: 1rem
|
||||||
|
|
||||||
|
p
|
||||||
|
margin: 0
|
||||||
|
color: $text-muted
|
||||||
|
font-size: 0.9rem
|
||||||
|
.video-preview
|
||||||
|
position: relative
|
||||||
|
padding-bottom: 56.25%
|
||||||
|
// 16:9 aspect ratio
|
||||||
|
height: 0
|
||||||
|
overflow: hidden
|
||||||
|
border-radius: 0.5rem
|
||||||
|
margin-top: 1rem
|
||||||
|
|
||||||
|
iframe
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
border-radius: 0.5rem
|
||||||
|
|
||||||
|
.search-results
|
||||||
|
background: rgba($card-bg, 0.8)
|
||||||
|
border-radius: 0.75rem
|
||||||
|
padding: 1rem
|
||||||
|
max-height: 300px
|
||||||
|
overflow-y: auto
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
border: 2px solid #000
|
||||||
|
box-shadow: 4px 4px 0 #000
|
||||||
|
position: relative
|
||||||
|
z-index: 10
|
||||||
|
|
||||||
|
h4
|
||||||
|
margin-top: 0
|
||||||
|
margin-bottom: 0.75rem
|
||||||
|
color: $primary
|
||||||
|
font-size: 0.9rem
|
||||||
|
text-transform: uppercase
|
||||||
|
letter-spacing: 0.05em
|
||||||
|
|
||||||
|
.search-result
|
||||||
|
display: flex
|
||||||
|
padding: 0.75rem
|
||||||
|
border-radius: 0.5rem
|
||||||
|
gap: 1rem
|
||||||
|
cursor: pointer
|
||||||
|
transition: background-color 0.2s ease
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: rgba(255, 255, 255, 0.05)
|
||||||
|
|
||||||
|
&.selected
|
||||||
|
background-color: rgba($primary, 0.15)
|
||||||
|
border-left: 3px solid $primary
|
||||||
|
|
||||||
|
.result-thumbnail-container
|
||||||
|
position: relative
|
||||||
|
width: 120px
|
||||||
|
flex-shrink: 0
|
||||||
|
|
||||||
|
.result-thumbnail
|
||||||
|
width: 120px
|
||||||
|
height: 68px
|
||||||
|
object-fit: cover
|
||||||
|
border-radius: 0.25rem
|
||||||
|
|
||||||
|
.preview-button
|
||||||
|
position: absolute
|
||||||
|
right: 0.25rem
|
||||||
|
bottom: 0.25rem
|
||||||
|
background: rgba(0, 0, 0, 0.6)
|
||||||
|
border: none
|
||||||
|
color: white
|
||||||
|
width: 24px
|
||||||
|
height: 24px
|
||||||
|
border-radius: 50%
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
opacity: 0.7
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
opacity: 1
|
||||||
|
background: rgba($secondary, 0.8)
|
||||||
|
|
||||||
|
.result-info
|
||||||
|
flex-grow: 1
|
||||||
|
|
||||||
|
h4
|
||||||
|
margin: 0 0 0.25rem 0
|
||||||
|
font-size: 0.95rem
|
||||||
|
text-transform: none
|
||||||
|
letter-spacing: normal
|
||||||
|
|
||||||
|
p
|
||||||
|
margin: 0
|
||||||
|
color: $text-muted
|
||||||
|
font-size: 0.85rem
|
176
client/src/common/styles/forms.sass
Normal file
176
client/src/common/styles/forms.sass
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
@import "./colors"
|
||||||
|
|
||||||
|
.form-group
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
label
|
||||||
|
display: block
|
||||||
|
margin-bottom: 0.75rem
|
||||||
|
font-weight: 500
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.8rem
|
||||||
|
color: $secondary
|
||||||
|
text-transform: uppercase
|
||||||
|
|
||||||
|
.required
|
||||||
|
color: $danger
|
||||||
|
margin-left: 0.25rem
|
||||||
|
animation: pixel-flash 1s infinite
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="url"],
|
||||||
|
input[type="password"],
|
||||||
|
textarea,
|
||||||
|
select
|
||||||
|
width: 100%
|
||||||
|
padding: 0.75rem
|
||||||
|
background-color: $card-bg
|
||||||
|
border: 3px solid #000
|
||||||
|
color: $text
|
||||||
|
font-size: 1rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.9rem
|
||||||
|
transition: all 0.2s ease
|
||||||
|
box-sizing: border-box
|
||||||
|
box-shadow: 4px 4px 0 #000
|
||||||
|
|
||||||
|
&:focus
|
||||||
|
border-color: $primary
|
||||||
|
box-shadow: 4px 4px 0 #000, 0 0 8px $primary
|
||||||
|
outline: none
|
||||||
|
|
||||||
|
&::placeholder
|
||||||
|
color: rgba(255, 255, 255, 0.4)
|
||||||
|
|
||||||
|
// Pixel art checkbox
|
||||||
|
input[type="checkbox"]
|
||||||
|
appearance: none
|
||||||
|
width: 24px
|
||||||
|
height: 24px
|
||||||
|
background-color: $card-bg
|
||||||
|
border: 3px solid #000
|
||||||
|
position: relative
|
||||||
|
cursor: pointer
|
||||||
|
box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.2)
|
||||||
|
transition: all 0.2s ease
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
border-color: $secondary
|
||||||
|
|
||||||
|
&:checked
|
||||||
|
background-color: rgba($primary, 0.2)
|
||||||
|
border-color: $primary
|
||||||
|
|
||||||
|
&:after
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
left: 6px
|
||||||
|
top: 2px
|
||||||
|
width: 6px
|
||||||
|
height: 12px
|
||||||
|
border: solid $primary
|
||||||
|
border-width: 0 3px 3px 0
|
||||||
|
transform: rotate(45deg)
|
||||||
|
|
||||||
|
&.checkbox
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
label
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
margin: 0
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
input[type="checkbox"]
|
||||||
|
margin-right: 0.5rem
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
// Form actions
|
||||||
|
.form-actions
|
||||||
|
display: flex
|
||||||
|
justify-content: flex-end
|
||||||
|
gap: 1rem
|
||||||
|
margin-top: 1.5rem
|
||||||
|
|
||||||
|
// Checkbox group with helper text
|
||||||
|
.checkbox-group
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
.checkbox-container
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
|
||||||
|
input[type="checkbox"]
|
||||||
|
margin-right: 0.75rem
|
||||||
|
min-width: 24px
|
||||||
|
height: 24px
|
||||||
|
|
||||||
|
label
|
||||||
|
margin-bottom: 0
|
||||||
|
margin-top: 0
|
||||||
|
cursor: pointer
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.8rem
|
||||||
|
text-transform: uppercase
|
||||||
|
color: $secondary
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
.btn
|
||||||
|
display: inline-flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
padding: 0.75rem 1.5rem
|
||||||
|
border: none
|
||||||
|
border-radius: 0.5rem
|
||||||
|
font-size: 1rem
|
||||||
|
font-weight: 500
|
||||||
|
cursor: pointer
|
||||||
|
transition: all 0.2s
|
||||||
|
|
||||||
|
svg
|
||||||
|
margin-right: 0.5rem
|
||||||
|
|
||||||
|
&.primary
|
||||||
|
background-color: $primary
|
||||||
|
color: white
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: darken($primary, 10%)
|
||||||
|
|
||||||
|
&:active
|
||||||
|
background-color: darken($primary, 15%)
|
||||||
|
|
||||||
|
&:disabled
|
||||||
|
background-color: darken($primary, 30%)
|
||||||
|
cursor: not-allowed
|
||||||
|
opacity: 0.7
|
||||||
|
|
||||||
|
&.secondary
|
||||||
|
background-color: rgba(255, 255, 255, 0.1)
|
||||||
|
color: $text
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: rgba(255, 255, 255, 0.2)
|
||||||
|
|
||||||
|
&:active
|
||||||
|
background-color: rgba(255, 255, 255, 0.25)
|
||||||
|
|
||||||
|
&.danger
|
||||||
|
background-color: rgba($danger, 0.2)
|
||||||
|
color: $danger
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: $danger
|
||||||
|
color: white
|
||||||
|
|
||||||
|
&.icon
|
||||||
|
padding: 0.5rem 1rem
|
||||||
|
|
||||||
|
svg
|
||||||
|
margin-right: 0.5rem
|
868
client/src/common/styles/main.sass
Normal file
868
client/src/common/styles/main.sass
Normal file
@ -0,0 +1,868 @@
|
|||||||
|
// Main styles for the Song Battle application
|
||||||
|
@import './colors'
|
||||||
|
@import './forms'
|
||||||
|
@import './buttons'
|
||||||
|
|
||||||
|
// Component styles
|
||||||
|
@import './components/home-screen'
|
||||||
|
@import './components/lobby-screen'
|
||||||
|
@import './components/song-submission-screen'
|
||||||
|
@import './components/voting-screen'
|
||||||
|
@import './components/results-screen'
|
||||||
|
@import './components/battle-result-screen'
|
||||||
|
@import './components/youtube-embed'
|
||||||
|
@import './components/youtube-search'
|
||||||
|
@import './components/song-form-overlay'
|
||||||
|
@import './components/connection-status'
|
||||||
|
|
||||||
|
// Global styles
|
||||||
|
html, body
|
||||||
|
margin: 0
|
||||||
|
padding: 0
|
||||||
|
font-family: 'Press Start 2P', 'Courier New', monospace
|
||||||
|
background: linear-gradient(45deg, #6600cc 0%, #ff0066 100%) fixed
|
||||||
|
color: $text
|
||||||
|
height: 100%
|
||||||
|
overflow-x: hidden
|
||||||
|
image-rendering: pixelated
|
||||||
|
|
||||||
|
// Pixel art border mixin
|
||||||
|
@mixin pixel-border($color: $primary, $size: 4px)
|
||||||
|
border: $size solid $color
|
||||||
|
box-shadow: 0 0 0 2px darken($color, 30%)
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
#root, .app
|
||||||
|
height: 100%
|
||||||
|
width: 100%
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
background-image: url('/background.svg')
|
||||||
|
background-size: 32px 32px
|
||||||
|
background-repeat: repeat
|
||||||
|
|
||||||
|
a
|
||||||
|
color: $secondary
|
||||||
|
text-decoration: none
|
||||||
|
font-weight: bold
|
||||||
|
text-shadow: 2px 2px 0 #000, -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000
|
||||||
|
&:hover
|
||||||
|
color: lighten($secondary, 20%)
|
||||||
|
transform: scale(1.05)
|
||||||
|
transition: all 0.2s ease
|
||||||
|
|
||||||
|
h1, h2, h3
|
||||||
|
font-family: 'Press Start 2P', 'VT323', monospace
|
||||||
|
letter-spacing: 2px
|
||||||
|
text-shadow: 2px 2px 0 #000, -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000
|
||||||
|
|
||||||
|
// Background elements
|
||||||
|
.background-elements
|
||||||
|
position: fixed
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
z-index: -1
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
.glow-point
|
||||||
|
position: absolute
|
||||||
|
border-radius: 50%
|
||||||
|
opacity: 0.15
|
||||||
|
filter: blur(100px)
|
||||||
|
transition: transform 0.3s ease
|
||||||
|
|
||||||
|
&.point-1
|
||||||
|
background-color: $primary
|
||||||
|
width: 40vh
|
||||||
|
height: 40vh
|
||||||
|
top: 10vh
|
||||||
|
left: 10vw
|
||||||
|
|
||||||
|
&.point-2
|
||||||
|
background-color: $secondary
|
||||||
|
width: 50vh
|
||||||
|
height: 50vh
|
||||||
|
bottom: 5vh
|
||||||
|
right: 5vw
|
||||||
|
|
||||||
|
&.point-3
|
||||||
|
background-color: $accent
|
||||||
|
width: 30vh
|
||||||
|
height: 30vh
|
||||||
|
top: 40vh
|
||||||
|
right: 30vw
|
||||||
|
|
||||||
|
.music-note
|
||||||
|
position: absolute
|
||||||
|
color: rgba(255, 255, 255, 0.1)
|
||||||
|
transition: transform 0.3s ease
|
||||||
|
|
||||||
|
// Content container
|
||||||
|
.content-container
|
||||||
|
flex: 1
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
padding: 1rem
|
||||||
|
position: relative
|
||||||
|
z-index: 1
|
||||||
|
max-width: 1200px
|
||||||
|
margin: 0 auto
|
||||||
|
width: 100%
|
||||||
|
box-sizing: border-box
|
||||||
|
|
||||||
|
// Connection status
|
||||||
|
.connection-status
|
||||||
|
position: fixed
|
||||||
|
bottom: 1rem
|
||||||
|
left: 1rem
|
||||||
|
background-color: rgba($danger, 0.9)
|
||||||
|
color: white
|
||||||
|
padding: 0.5rem 1rem
|
||||||
|
border-radius: 2rem
|
||||||
|
font-size: 0.9rem
|
||||||
|
animation: pulse 2s infinite
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
.error-message
|
||||||
|
position: fixed
|
||||||
|
top: 1rem
|
||||||
|
left: 50%
|
||||||
|
transform: translateX(-50%)
|
||||||
|
background-color: rgba($danger, 0.9)
|
||||||
|
color: white
|
||||||
|
padding: 0.5rem 1rem
|
||||||
|
border-radius: 0.5rem
|
||||||
|
z-index: 100
|
||||||
|
animation: fade-in-out 5s forwards
|
||||||
|
max-width: 80%
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
@keyframes pulse
|
||||||
|
0%
|
||||||
|
opacity: 0.7
|
||||||
|
50%
|
||||||
|
opacity: 1
|
||||||
|
100%
|
||||||
|
opacity: 0.7
|
||||||
|
|
||||||
|
@keyframes fade-in-out
|
||||||
|
0%
|
||||||
|
opacity: 0
|
||||||
|
transform: translate(-50%, -20px)
|
||||||
|
10%
|
||||||
|
opacity: 1
|
||||||
|
transform: translate(-50%, 0)
|
||||||
|
80%
|
||||||
|
opacity: 1
|
||||||
|
100%
|
||||||
|
opacity: 0
|
||||||
|
|
||||||
|
// HomeScreen styles
|
||||||
|
.home-screen
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
height: 100%
|
||||||
|
padding: 1rem
|
||||||
|
|
||||||
|
.logo
|
||||||
|
text-align: center
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
.logo-icon
|
||||||
|
font-size: 4rem
|
||||||
|
color: $primary
|
||||||
|
margin-bottom: 1rem
|
||||||
|
animation: bounce 2s infinite
|
||||||
|
|
||||||
|
h1
|
||||||
|
font-size: 3.5rem
|
||||||
|
margin: 0
|
||||||
|
color: white
|
||||||
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3)
|
||||||
|
|
||||||
|
.card
|
||||||
|
background-color: rgba($card-bg, 0.8)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 2rem
|
||||||
|
width: 100%
|
||||||
|
max-width: 400px
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1)
|
||||||
|
|
||||||
|
.tabs
|
||||||
|
display: flex
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
button
|
||||||
|
flex: 1
|
||||||
|
background: transparent
|
||||||
|
border: none
|
||||||
|
padding: 0.75rem
|
||||||
|
color: $text-muted
|
||||||
|
font-weight: 600
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
&.active
|
||||||
|
color: $primary
|
||||||
|
box-shadow: inset 0 -2px 0 $primary
|
||||||
|
|
||||||
|
.home-footer
|
||||||
|
margin-top: 2rem
|
||||||
|
text-align: center
|
||||||
|
color: $text-muted
|
||||||
|
|
||||||
|
@keyframes bounce
|
||||||
|
0%, 20%, 50%, 80%, 100%
|
||||||
|
transform: translateY(0)
|
||||||
|
40%
|
||||||
|
transform: translateY(-20px)
|
||||||
|
60%
|
||||||
|
transform: translateY(-10px)
|
||||||
|
|
||||||
|
// LobbyScreen styles
|
||||||
|
.lobby-screen
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
height: 100%
|
||||||
|
|
||||||
|
.lobby-header
|
||||||
|
text-align: center
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
h1
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
|
||||||
|
.lobby-code
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
|
||||||
|
.code
|
||||||
|
font-weight: bold
|
||||||
|
font-size: 1.5rem
|
||||||
|
color: $primary
|
||||||
|
margin: 0 0.5rem
|
||||||
|
|
||||||
|
.lobby-content
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
flex: 1
|
||||||
|
|
||||||
|
@media (min-width: 768px)
|
||||||
|
flex-direction: row
|
||||||
|
|
||||||
|
.players-list
|
||||||
|
flex: 1
|
||||||
|
background-color: rgba($card-bg, 0.8)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 1.5rem
|
||||||
|
margin-bottom: 1rem
|
||||||
|
|
||||||
|
@media (min-width: 768px)
|
||||||
|
margin-right: 1rem
|
||||||
|
margin-bottom: 0
|
||||||
|
|
||||||
|
h2
|
||||||
|
margin-top: 0
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
svg
|
||||||
|
margin-right: 0.5rem
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
ul
|
||||||
|
list-style: none
|
||||||
|
padding: 0
|
||||||
|
margin: 0
|
||||||
|
|
||||||
|
li
|
||||||
|
padding: 0.75rem
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1)
|
||||||
|
|
||||||
|
&.host
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
&.disconnected
|
||||||
|
opacity: 0.5
|
||||||
|
|
||||||
|
.lobby-info
|
||||||
|
flex: 1
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
|
||||||
|
.settings-preview
|
||||||
|
background-color: rgba($card-bg, 0.8)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 1.5rem
|
||||||
|
margin-bottom: 1rem
|
||||||
|
|
||||||
|
h3
|
||||||
|
margin-top: 0
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
svg
|
||||||
|
margin-right: 0.5rem
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
.actions
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 1rem
|
||||||
|
margin-bottom: 1rem
|
||||||
|
|
||||||
|
.status-disconnected
|
||||||
|
color: $danger
|
||||||
|
font-size: 0.9rem
|
||||||
|
margin-left: 0.5rem
|
||||||
|
|
||||||
|
.warning
|
||||||
|
color: $warning
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
// SongSubmissionScreen styles
|
||||||
|
.song-submission-screen
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
height: 100%
|
||||||
|
|
||||||
|
.screen-header
|
||||||
|
text-align: center
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
h1
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
|
||||||
|
.status
|
||||||
|
color: $text-muted
|
||||||
|
|
||||||
|
.submission-progress
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
.progress-bar
|
||||||
|
height: 10px
|
||||||
|
background-color: rgba($card-bg, 0.5)
|
||||||
|
border-radius: 5px
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
|
||||||
|
.progress-fill
|
||||||
|
height: 100%
|
||||||
|
background-color: $primary
|
||||||
|
border-radius: 5px
|
||||||
|
transition: width 0.3s ease
|
||||||
|
|
||||||
|
.songs-list
|
||||||
|
display: grid
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr))
|
||||||
|
gap: 1rem
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
.song-card
|
||||||
|
box-sizing: border-box
|
||||||
|
background-color: rgba($card-bg, 0.8)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
border-radius: 0.75rem
|
||||||
|
overflow: hidden
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1)
|
||||||
|
|
||||||
|
.song-thumbnail
|
||||||
|
height: 140px
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
img
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
object-fit: cover
|
||||||
|
|
||||||
|
.thumbnail-placeholder
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
background-color: rgba(0, 0, 0, 0.2)
|
||||||
|
|
||||||
|
svg
|
||||||
|
font-size: 3rem
|
||||||
|
opacity: 0.5
|
||||||
|
|
||||||
|
.song-info
|
||||||
|
padding: 1rem
|
||||||
|
|
||||||
|
h3
|
||||||
|
margin: 0
|
||||||
|
margin-bottom: 0.25rem
|
||||||
|
|
||||||
|
p
|
||||||
|
margin: 0
|
||||||
|
color: $text-muted
|
||||||
|
|
||||||
|
.add-song-btn
|
||||||
|
background-color: rgba($card-bg, 0.5)
|
||||||
|
backdrop-filter: blur(5px)
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.2)
|
||||||
|
border-radius: 0.75rem
|
||||||
|
height: 100%
|
||||||
|
min-height: 200px
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
cursor: pointer
|
||||||
|
color: white
|
||||||
|
transition: all 0.2s ease
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: rgba($card-bg, 0.8)
|
||||||
|
border-color: rgba($primary, 0.5)
|
||||||
|
|
||||||
|
svg
|
||||||
|
font-size: 2rem
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
.song-form
|
||||||
|
background-color: rgba($card-bg, 0.9)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 2rem
|
||||||
|
max-width: 500px
|
||||||
|
width: 100%
|
||||||
|
margin: 0 auto 2rem
|
||||||
|
|
||||||
|
h3
|
||||||
|
margin-top: 0
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
.action-buttons
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
margin-top: 1rem
|
||||||
|
|
||||||
|
.waiting-message
|
||||||
|
background-color: rgba($card-bg, 0.8)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 2rem
|
||||||
|
max-width: 500px
|
||||||
|
width: 100%
|
||||||
|
margin: 0 auto
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
h3
|
||||||
|
margin-top: 0
|
||||||
|
|
||||||
|
.player-status
|
||||||
|
margin-top: 2rem
|
||||||
|
|
||||||
|
h4
|
||||||
|
margin-bottom: 1rem
|
||||||
|
|
||||||
|
.players-ready-list
|
||||||
|
list-style: none
|
||||||
|
padding: 0
|
||||||
|
|
||||||
|
li
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
padding: 0.75rem
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1)
|
||||||
|
|
||||||
|
&.ready
|
||||||
|
color: $success
|
||||||
|
|
||||||
|
.ready-icon
|
||||||
|
color: $success
|
||||||
|
|
||||||
|
&.not-ready
|
||||||
|
color: $text-muted
|
||||||
|
|
||||||
|
.songs-count
|
||||||
|
color: $warning
|
||||||
|
|
||||||
|
// VotingScreen styles
|
||||||
|
.voting-screen
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
height: 100%
|
||||||
|
|
||||||
|
.screen-header
|
||||||
|
text-align: center
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
h1
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
|
||||||
|
.battle-info
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
|
||||||
|
.battle-count
|
||||||
|
margin-right: 0.5rem
|
||||||
|
|
||||||
|
svg
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
.battle-container
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 2rem
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
@media (min-width: 768px)
|
||||||
|
flex-direction: row
|
||||||
|
|
||||||
|
.song-battle-card
|
||||||
|
flex: 1
|
||||||
|
background-color: rgba($card-bg, 0.8)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 1.5rem
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1)
|
||||||
|
transition: all 0.2s ease
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
&.selected
|
||||||
|
box-shadow: 0 0 0 2px $primary, 0 8px 32px rgba(0, 0, 0, 0.1)
|
||||||
|
|
||||||
|
.song-title
|
||||||
|
margin-top: 0
|
||||||
|
text-align: center
|
||||||
|
font-size: 1.8rem
|
||||||
|
|
||||||
|
.song-artist
|
||||||
|
text-align: center
|
||||||
|
color: $text-muted
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
.song-video
|
||||||
|
height: 0
|
||||||
|
padding-bottom: 56.25% // 16:9 aspect ratio
|
||||||
|
position: relative
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
border-radius: 0.5rem
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
.youtube-embed, .video-placeholder
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
|
||||||
|
iframe
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
|
||||||
|
.video-placeholder
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
background-color: rgba(0, 0, 0, 0.2)
|
||||||
|
|
||||||
|
svg
|
||||||
|
font-size: 3rem
|
||||||
|
opacity: 0.5
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
|
||||||
|
.vote-btn
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
.voted-badge
|
||||||
|
position: absolute
|
||||||
|
top: 1rem
|
||||||
|
right: 1rem
|
||||||
|
background-color: $primary
|
||||||
|
color: white
|
||||||
|
padding: 0.25rem 0.75rem
|
||||||
|
border-radius: 2rem
|
||||||
|
font-weight: bold
|
||||||
|
font-size: 0.9rem
|
||||||
|
|
||||||
|
.vs-container
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
@media (min-width: 768px)
|
||||||
|
flex-direction: column
|
||||||
|
|
||||||
|
.vs-badge
|
||||||
|
background-color: rgba($card-bg, 0.9)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
border-radius: 50%
|
||||||
|
width: 60px
|
||||||
|
height: 60px
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
font-weight: bold
|
||||||
|
font-size: 1.5rem
|
||||||
|
color: $primary
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15)
|
||||||
|
|
||||||
|
.voting-status
|
||||||
|
text-align: center
|
||||||
|
margin-top: auto
|
||||||
|
padding-top: 1rem
|
||||||
|
|
||||||
|
p
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
|
||||||
|
.votes-count
|
||||||
|
color: $text-muted
|
||||||
|
font-size: 0.9rem
|
||||||
|
|
||||||
|
span
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
.loading-battle
|
||||||
|
background-color: rgba($card-bg, 0.8)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 3rem 2rem
|
||||||
|
text-align: center
|
||||||
|
max-width: 400px
|
||||||
|
margin: auto
|
||||||
|
|
||||||
|
h2
|
||||||
|
margin-top: 0
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
// ResultsScreen styles
|
||||||
|
.results-screen
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
height: 100%
|
||||||
|
position: relative
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
.confetti-container
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
pointer-events: none
|
||||||
|
|
||||||
|
.confetti
|
||||||
|
position: absolute
|
||||||
|
width: 10px
|
||||||
|
height: 10px
|
||||||
|
background-color: $primary
|
||||||
|
top: -10px
|
||||||
|
animation: fall 5s linear infinite
|
||||||
|
|
||||||
|
.results-header
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
h1
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
|
||||||
|
.trophy-icon
|
||||||
|
font-size: 3rem
|
||||||
|
color: gold
|
||||||
|
filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.5))
|
||||||
|
animation: shine 3s infinite
|
||||||
|
|
||||||
|
.winner-container
|
||||||
|
text-align: center
|
||||||
|
margin-bottom: 3rem
|
||||||
|
|
||||||
|
h2
|
||||||
|
margin-bottom: 2rem
|
||||||
|
font-size: 2rem
|
||||||
|
|
||||||
|
.winner-card
|
||||||
|
background-color: rgba($card-bg, 0.8)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 2rem
|
||||||
|
max-width: 700px
|
||||||
|
margin: 0 auto
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15)
|
||||||
|
|
||||||
|
.winner-title
|
||||||
|
font-size: 2.5rem
|
||||||
|
margin-top: 0
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
.winner-artist
|
||||||
|
color: $text-muted
|
||||||
|
font-size: 1.2rem
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
.winner-video
|
||||||
|
height: 0
|
||||||
|
padding-bottom: 56.25% // 16:9 aspect ratio
|
||||||
|
position: relative
|
||||||
|
margin-bottom: 2rem
|
||||||
|
border-radius: 0.5rem
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
.youtube-embed, .video-placeholder
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
|
||||||
|
.video-placeholder
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
background-color: rgba(0, 0, 0, 0.2)
|
||||||
|
|
||||||
|
.winner-submitted-by
|
||||||
|
color: $text-muted
|
||||||
|
|
||||||
|
strong
|
||||||
|
color: $text
|
||||||
|
|
||||||
|
.results-actions
|
||||||
|
display: flex
|
||||||
|
gap: 1rem
|
||||||
|
justify-content: center
|
||||||
|
margin-bottom: 2rem
|
||||||
|
flex-wrap: wrap
|
||||||
|
|
||||||
|
.battle-history
|
||||||
|
background-color: rgba($card-bg, 0.8)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 1.5rem
|
||||||
|
max-width: 800px
|
||||||
|
margin: 0 auto 2rem
|
||||||
|
|
||||||
|
h3
|
||||||
|
margin-top: 0
|
||||||
|
text-align: center
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
.battles-list
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 1rem
|
||||||
|
|
||||||
|
.battle-result
|
||||||
|
padding: 1rem
|
||||||
|
background-color: rgba($card-bg, 0.7)
|
||||||
|
border-radius: 0.5rem
|
||||||
|
|
||||||
|
&.final-round
|
||||||
|
border: 2px solid $primary
|
||||||
|
|
||||||
|
.battle-round
|
||||||
|
text-align: center
|
||||||
|
font-weight: bold
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
.battle-songs
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: space-between
|
||||||
|
gap: 1rem
|
||||||
|
|
||||||
|
&.left-won .battle-song:first-child
|
||||||
|
color: $success
|
||||||
|
font-weight: bold
|
||||||
|
|
||||||
|
&.right-won .battle-song:last-child
|
||||||
|
color: $success
|
||||||
|
font-weight: bold
|
||||||
|
|
||||||
|
.battle-song
|
||||||
|
flex: 1
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
&.winner
|
||||||
|
color: $success
|
||||||
|
|
||||||
|
&.loser
|
||||||
|
color: $text-muted
|
||||||
|
|
||||||
|
p
|
||||||
|
margin-bottom: 0.25rem
|
||||||
|
white-space: nowrap
|
||||||
|
overflow: hidden
|
||||||
|
text-overflow: ellipsis
|
||||||
|
|
||||||
|
.votes
|
||||||
|
font-size: 0.9rem
|
||||||
|
|
||||||
|
.vs
|
||||||
|
padding: 0.5rem
|
||||||
|
font-size: 0.8rem
|
||||||
|
|
||||||
|
// Modal styles
|
||||||
|
.modal-overlay
|
||||||
|
position: fixed
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
background-color: rgba(0, 0, 0, 0.7)
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
z-index: 1000
|
||||||
|
|
||||||
|
.modal
|
||||||
|
background-color: $card-bg
|
||||||
|
border-radius: 1rem
|
||||||
|
padding: 2rem
|
||||||
|
width: 90%
|
||||||
|
max-width: 500px
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15)
|
||||||
|
|
||||||
|
h2
|
||||||
|
margin-top: 0
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
.modal-actions
|
||||||
|
display: flex
|
||||||
|
justify-content: flex-end
|
||||||
|
gap: 1rem
|
||||||
|
margin-top: 2rem
|
||||||
|
|
||||||
|
// Loader animation
|
||||||
|
.loader
|
||||||
|
width: 50px
|
||||||
|
height: 50px
|
||||||
|
border-radius: 50%
|
||||||
|
border: 5px solid rgba(255, 255, 255, 0.1)
|
||||||
|
border-top-color: $primary
|
||||||
|
animation: spin 1s infinite linear
|
||||||
|
margin: 0 auto
|
||||||
|
|
||||||
|
@keyframes spin
|
||||||
|
0%
|
||||||
|
transform: rotate(0deg)
|
||||||
|
100%
|
||||||
|
transform: rotate(360deg)
|
||||||
|
|
||||||
|
@keyframes fall
|
||||||
|
0%
|
||||||
|
transform: translateY(-10px) rotate(0deg)
|
||||||
|
100%
|
||||||
|
transform: translateY(calc(100vh + 10px)) rotate(360deg)
|
||||||
|
|
||||||
|
@keyframes shine
|
||||||
|
0%, 100%
|
||||||
|
filter: drop-shadow(0 0 5px rgba(255, 215, 0, 0.5))
|
||||||
|
50%
|
||||||
|
filter: drop-shadow(0 0 20px rgba(255, 215, 0, 0.8))
|
198
client/src/components/BattleResultScreen.jsx
Normal file
198
client/src/components/BattleResultScreen.jsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useGame } from '../context/GameContext';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faTrophy, faMusic, faCrown, faClock } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import YouTubeEmbed from './YouTubeEmbed';
|
||||||
|
import { getDisplayName } from '../utils/playerUtils';
|
||||||
|
|
||||||
|
const BattleResultScreen = () => {
|
||||||
|
const { lobby, currentPlayer, isHost, proceedToNextBattle } = useGame();
|
||||||
|
const [countdown, setCountdown] = useState(10); // Auto-advance after 10 seconds
|
||||||
|
const [showConfetti, setShowConfetti] = useState(true);
|
||||||
|
|
||||||
|
// Use previousBattle from the lobby state
|
||||||
|
const previousBattle = lobby?.previousBattle;
|
||||||
|
|
||||||
|
// Extract winning song
|
||||||
|
const winnerSongId = previousBattle?.winner;
|
||||||
|
const winningSong = winnerSongId === previousBattle?.song1?.id
|
||||||
|
? previousBattle?.song1
|
||||||
|
: previousBattle?.song2;
|
||||||
|
|
||||||
|
const losingSong = winnerSongId === previousBattle?.song1?.id
|
||||||
|
? previousBattle?.song2
|
||||||
|
: previousBattle?.song1;
|
||||||
|
|
||||||
|
const winningVotes = winnerSongId === previousBattle?.song1?.id
|
||||||
|
? previousBattle?.song1Votes
|
||||||
|
: previousBattle?.song2Votes;
|
||||||
|
|
||||||
|
const losingVotes = winnerSongId === previousBattle?.song1?.id
|
||||||
|
? previousBattle?.song2Votes
|
||||||
|
: previousBattle?.song1Votes;
|
||||||
|
|
||||||
|
// Get the player who submitted the winning song
|
||||||
|
const findSubmitter = (song) => {
|
||||||
|
if (!lobby || !song) return null;
|
||||||
|
|
||||||
|
const submitter = lobby.players.find(p => p.id === song.submittedById);
|
||||||
|
return submitter;
|
||||||
|
};
|
||||||
|
|
||||||
|
const winnerSubmitter = findSubmitter(winningSong);
|
||||||
|
|
||||||
|
// YouTube video ID extraction for the winner
|
||||||
|
const getYouTubeId = (url) => {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||||
|
const match = url.match(regExp);
|
||||||
|
|
||||||
|
return (match && match[2].length === 11) ? match[2] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const winnerVideoId = getYouTubeId(winningSong?.youtubeLink);
|
||||||
|
|
||||||
|
// Auto-advance countdown
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown > 0) {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCountdown(prev => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
// Only auto-advance if host, otherwise wait for host
|
||||||
|
if (isHost) {
|
||||||
|
proceedToNextBattle();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, [countdown, isHost, proceedToNextBattle]);
|
||||||
|
|
||||||
|
// Confetti effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (showConfetti) {
|
||||||
|
const createConfetti = () => {
|
||||||
|
const confettiContainer = document.createElement('div');
|
||||||
|
confettiContainer.className = 'confetti';
|
||||||
|
|
||||||
|
// Random position, size, and color
|
||||||
|
const left = Math.random() * 100;
|
||||||
|
const colors = ['#f94144', '#f3722c', '#f8961e', '#f9c74f', '#90be6d', '#43aa8b', '#577590'];
|
||||||
|
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
const size = Math.random() * 0.5 + 0.3; // Between 0.3 and 0.8rem
|
||||||
|
const rotation = Math.random() * 360; // Random rotation
|
||||||
|
const duration = Math.random() * 2 + 1; // Between 1 and 3 seconds
|
||||||
|
|
||||||
|
confettiContainer.style.left = `${left}%`;
|
||||||
|
confettiContainer.style.backgroundColor = color;
|
||||||
|
confettiContainer.style.width = `${size}rem`;
|
||||||
|
confettiContainer.style.height = `${size * 0.6}rem`;
|
||||||
|
confettiContainer.style.transform = `rotate(${rotation}deg)`;
|
||||||
|
confettiContainer.style.animation = `confetti-fall ${duration}s linear forwards`;
|
||||||
|
|
||||||
|
document.querySelector('.battle-result-screen').appendChild(confettiContainer);
|
||||||
|
|
||||||
|
// Remove after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
confettiContainer.remove();
|
||||||
|
}, duration * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create confetti pieces
|
||||||
|
const confettiInterval = setInterval(() => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
createConfetti();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// Stop confetti after a few seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(confettiInterval);
|
||||||
|
setShowConfetti(false);
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(confettiInterval);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [showConfetti]);
|
||||||
|
|
||||||
|
if (!previousBattle || !winningSong) {
|
||||||
|
return (
|
||||||
|
<div className="battle-result-screen">
|
||||||
|
<h2>Nächster Kampf wird vorbereitet...</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="battle-result-screen">
|
||||||
|
<header>
|
||||||
|
<h1><FontAwesomeIcon icon={faTrophy} /> Gewinner dieser Runde</h1>
|
||||||
|
{countdown > 0 && (
|
||||||
|
<div className="countdown">
|
||||||
|
<FontAwesomeIcon icon={faClock} />
|
||||||
|
Nächster Kampf in {countdown}s
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="winner-announcement">
|
||||||
|
<div className="song-cards">
|
||||||
|
<div className="song-card winner">
|
||||||
|
<div className="victory-badge">
|
||||||
|
<FontAwesomeIcon icon={faCrown} /> Gewinner
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="song-info">
|
||||||
|
<h2>{winningSong.title}</h2>
|
||||||
|
<p className="artist">{winningSong.artist}</p>
|
||||||
|
|
||||||
|
{!lobby?.settings?.hidePlayerNames && winnerSubmitter && (
|
||||||
|
<p className="submitter">
|
||||||
|
Eingereicht von: {getDisplayName(winnerSubmitter, lobby, currentPlayer)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="vote-count">
|
||||||
|
<span className="votes">{winningVotes} Stimmen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{winnerVideoId ? (
|
||||||
|
<div className="winner-video">
|
||||||
|
<YouTubeEmbed videoId={winnerVideoId} autoplay={true} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="no-video">
|
||||||
|
<FontAwesomeIcon icon={faMusic} className="pulse-icon" />
|
||||||
|
<span>Kein Video verfügbar</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{losingSong && (
|
||||||
|
<div className="song-card loser">
|
||||||
|
<div className="versus">VS</div>
|
||||||
|
<div className="song-info">
|
||||||
|
<h3>{losingSong.title}</h3>
|
||||||
|
<p className="artist">{losingSong.artist}</p>
|
||||||
|
<div className="vote-count">
|
||||||
|
<span className="votes">{losingVotes} Stimmen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BattleResultScreen;
|
70
client/src/components/ConnectionStatus.jsx
Normal file
70
client/src/components/ConnectionStatus.jsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// ConnectionStatus.jsx
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useSocket } from '../context/GameContext';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faWifi, faExclamationTriangle, faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
|
function ConnectionStatus() {
|
||||||
|
const { socket, isConnected, isReconnecting, reconnectionAttempts } = useSocket();
|
||||||
|
const [showStatus, setShowStatus] = useState(false);
|
||||||
|
const [offline, setOffline] = useState(!navigator.onLine);
|
||||||
|
|
||||||
|
// Monitor browser's online/offline status
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setOffline(false);
|
||||||
|
const handleOffline = () => setOffline(true);
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Always show status when disconnected, hide after successful connection
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConnected || isReconnecting || offline) {
|
||||||
|
setShowStatus(true);
|
||||||
|
} else {
|
||||||
|
// Hide the status indicator after a delay
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowStatus(false);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isConnected, isReconnecting, offline]);
|
||||||
|
|
||||||
|
if (!showStatus) return null;
|
||||||
|
|
||||||
|
let statusClass = 'connection-status';
|
||||||
|
let icon, message;
|
||||||
|
|
||||||
|
if (offline) {
|
||||||
|
statusClass += ' offline';
|
||||||
|
icon = <FontAwesomeIcon icon={faExclamationTriangle} />;
|
||||||
|
message = 'Keine Internetverbindung. Spielstand wird lokal gespeichert.';
|
||||||
|
} else if (!isConnected) {
|
||||||
|
statusClass += ' disconnected';
|
||||||
|
icon = <FontAwesomeIcon icon={faExclamationTriangle} />;
|
||||||
|
message = 'Verbindung zum Server verloren. Versuche neu zu verbinden...';
|
||||||
|
} else if (isReconnecting) {
|
||||||
|
statusClass += ' reconnecting';
|
||||||
|
icon = <FontAwesomeIcon icon={faSpinner} spin />;
|
||||||
|
message = `Verbindungsversuch ${reconnectionAttempts}...`;
|
||||||
|
} else {
|
||||||
|
statusClass += ' connected';
|
||||||
|
icon = <FontAwesomeIcon icon={faWifi} />;
|
||||||
|
message = 'Verbunden mit dem Server';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={statusClass}>
|
||||||
|
<div className="connection-icon">{icon}</div>
|
||||||
|
<div className="connection-message">{message}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConnectionStatus;
|
99
client/src/components/HomeScreen.jsx
Normal file
99
client/src/components/HomeScreen.jsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useGame } from '../context/GameContext';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faMusic, faPlus, faDoorOpen } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
|
const HomeScreen = () => {
|
||||||
|
const { createLobby, joinLobby } = useGame();
|
||||||
|
const [playerName, setPlayerName] = useState('');
|
||||||
|
const [lobbyId, setLobbyId] = useState('');
|
||||||
|
const [isCreateMode, setIsCreateMode] = useState(true);
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!playerName.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCreateMode) {
|
||||||
|
createLobby(playerName.trim());
|
||||||
|
} else {
|
||||||
|
if (!lobbyId.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
joinLobby(lobbyId.trim().toUpperCase(), playerName.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="home-screen">
|
||||||
|
<div className="logo">
|
||||||
|
<img src="/logo.png" alt="Song Battle Logo" className="logo-image" />
|
||||||
|
<h1>Liedkampf</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="tabs">
|
||||||
|
<button
|
||||||
|
className={isCreateMode ? 'active' : ''}
|
||||||
|
onClick={() => setIsCreateMode(true)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPlus} /> Erstellen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={!isCreateMode ? 'active' : ''}
|
||||||
|
onClick={() => setIsCreateMode(false)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faDoorOpen} /> Beitreten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="player-name">Dein Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="player-name"
|
||||||
|
value={playerName}
|
||||||
|
onChange={(e) => setPlayerName(e.target.value)}
|
||||||
|
placeholder="Gib deinen Namen ein"
|
||||||
|
maxLength={20}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCreateMode && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="lobby-id">Spielcode</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="lobby-id"
|
||||||
|
value={lobbyId}
|
||||||
|
onChange={(e) => setLobbyId(e.target.value.toUpperCase())}
|
||||||
|
placeholder="6-stelligen Code eingeben"
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" className="btn primary pixelated full-width">
|
||||||
|
{isCreateMode ? 'Neues Spiel erstellen' : 'Spiel beitreten'}
|
||||||
|
<span className="pixel-corner tl"></span>
|
||||||
|
<span className="pixel-corner tr"></span>
|
||||||
|
<span className="pixel-corner bl"></span>
|
||||||
|
<span className="pixel-corner br"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="home-footer">
|
||||||
|
<p>ik wes doch och nüsch</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make sure default export is explicit
|
||||||
|
export default HomeScreen;
|
175
client/src/components/LobbyScreen.jsx
Normal file
175
client/src/components/LobbyScreen.jsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useGame } from '../context/GameContext';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faUsers, faGear, faPlay, faCopy, faSignOutAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { getDisplayName } from '../utils/playerUtils';
|
||||||
|
|
||||||
|
const LobbyScreen = () => {
|
||||||
|
const { lobby, currentPlayer, isHost, updateSettings, startGame, leaveLobby } = useGame();
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
songsPerPlayer: 4,
|
||||||
|
maxPlayers: 10,
|
||||||
|
hidePlayerNames: false
|
||||||
|
});
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lobby && lobby.settings) {
|
||||||
|
setSettings(lobby.settings);
|
||||||
|
}
|
||||||
|
}, [lobby]);
|
||||||
|
|
||||||
|
const handleCopyCode = () => {
|
||||||
|
if (lobby) {
|
||||||
|
navigator.clipboard.writeText(lobby.id);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSettingsChange = (e) => {
|
||||||
|
const { name, value, type, checked } = e.target;
|
||||||
|
const newValue = type === 'checkbox' ? checked : type === 'number' ? parseInt(value) : value;
|
||||||
|
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
[name]: newValue
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSettings = () => {
|
||||||
|
updateSettings(settings);
|
||||||
|
setShowSettings(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartGame = () => {
|
||||||
|
startGame();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!lobby) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lobby-screen">
|
||||||
|
<header className="lobby-header">
|
||||||
|
<h1>Spiellobby</h1>
|
||||||
|
<div className="lobby-code">
|
||||||
|
<p>Spielcode: <span className="code">{lobby.id}</span></p>
|
||||||
|
<button className="btn icon" onClick={handleCopyCode}>
|
||||||
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
|
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="lobby-content">
|
||||||
|
<div className="players-list">
|
||||||
|
<h2><FontAwesomeIcon icon={faUsers} /> Spieler ({lobby.players.length})</h2>
|
||||||
|
<ul>
|
||||||
|
{lobby.players.map((player, index) => (
|
||||||
|
<li key={player.id} className={`player ${!player.isConnected ? 'disconnected' : ''} ${player.id === lobby.hostId ? 'host' : ''}`}>
|
||||||
|
{getDisplayName(player, lobby, currentPlayer, index)}
|
||||||
|
{player.id === lobby.hostId && ' (Gastgeber)'}
|
||||||
|
{player.id === currentPlayer.id && !lobby.settings.hidePlayerNames && ' (Du)'}
|
||||||
|
{!player.isConnected && <span className="status-disconnected">(Getrennt)</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lobby-info">
|
||||||
|
<div className="settings-preview">
|
||||||
|
<h3><FontAwesomeIcon icon={faGear} /> Spieleinstellungen</h3>
|
||||||
|
<p>Lieder pro Spieler: {settings.songsPerPlayer}</p>
|
||||||
|
<p>Maximale Spieler: {settings.maxPlayers}</p>
|
||||||
|
<p>Spielernamen verbergen: {settings.hidePlayerNames ? 'Ja' : 'Nein'}</p>
|
||||||
|
|
||||||
|
{isHost && (
|
||||||
|
<button className="btn secondary" onClick={() => setShowSettings(true)}>
|
||||||
|
Einstellungen ändern
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
{isHost && (
|
||||||
|
<button
|
||||||
|
className="btn primary"
|
||||||
|
onClick={handleStartGame}
|
||||||
|
disabled={lobby.players.length < lobby.settings.minPlayers}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPlay} /> Spiel starten
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn danger" onClick={leaveLobby}>
|
||||||
|
<FontAwesomeIcon icon={faSignOutAlt} /> Lobby verlassen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lobby.players.length < lobby.settings.minPlayers && isHost && (
|
||||||
|
<p className="warning">Mindestens {lobby.settings.minPlayers} Spieler werden benötigt</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSettings && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal">
|
||||||
|
<h2>Spieleinstellungen</h2>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="songsPerPlayer">Lieder pro Spieler</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="songsPerPlayer"
|
||||||
|
name="songsPerPlayer"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={settings.songsPerPlayer}
|
||||||
|
onChange={handleSettingsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="maxPlayers">Maximale Spieleranzahl</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="maxPlayers"
|
||||||
|
name="maxPlayers"
|
||||||
|
min="3"
|
||||||
|
max="20"
|
||||||
|
value={settings.maxPlayers}
|
||||||
|
onChange={handleSettingsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group checkbox-group">
|
||||||
|
<div className="checkbox-container">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="hidePlayerNames"
|
||||||
|
name="hidePlayerNames"
|
||||||
|
checked={settings.hidePlayerNames}
|
||||||
|
onChange={handleSettingsChange}
|
||||||
|
/>
|
||||||
|
<label htmlFor="hidePlayerNames">Spielernamen verbergen</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn secondary" onClick={() => setShowSettings(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button className="btn primary" onClick={handleSaveSettings}>
|
||||||
|
Mach rin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure explicit default export
|
||||||
|
export default LobbyScreen;
|
211
client/src/components/ResultsScreen.jsx
Normal file
211
client/src/components/ResultsScreen.jsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useGame } from '../context/GameContext';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faTrophy, faHome, faRedo, faChartLine } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import YouTubeEmbed from './YouTubeEmbed';
|
||||||
|
import { getDisplayName } from '../utils/playerUtils';
|
||||||
|
|
||||||
|
const ResultsScreen = () => {
|
||||||
|
const { lobby, currentPlayer, leaveLobby } = useGame();
|
||||||
|
const [showBattleHistory, setShowBattleHistory] = useState(false);
|
||||||
|
const [confetti, setConfetti] = useState(true);
|
||||||
|
|
||||||
|
// Gewinner-Informationen
|
||||||
|
const winner = lobby?.finalWinner;
|
||||||
|
const winnerVideoId = getYouTubeId(winner?.youtubeLink);
|
||||||
|
|
||||||
|
// Konfetti-Effekt für die Siegerfeier
|
||||||
|
useEffect(() => {
|
||||||
|
if (confetti) {
|
||||||
|
// Konfetti-Animation erstellen
|
||||||
|
const createConfetti = () => {
|
||||||
|
const confettiContainer = document.createElement('div');
|
||||||
|
confettiContainer.className = 'confetti';
|
||||||
|
|
||||||
|
// Zufällige Position, Größe und Farbe
|
||||||
|
const left = Math.random() * 100;
|
||||||
|
const colors = ['#f94144', '#f3722c', '#f8961e', '#f9c74f', '#90be6d', '#43aa8b', '#577590'];
|
||||||
|
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
const size = Math.random() * 0.7 + 0.3; // Zwischen 0.3 und 1rem
|
||||||
|
const rotation = Math.random() * 360; // Zufällige Rotation
|
||||||
|
const duration = Math.random() * 3 + 2; // Zwischen 2 und 5 Sekunden
|
||||||
|
|
||||||
|
confettiContainer.style.left = `${left}%`;
|
||||||
|
confettiContainer.style.backgroundColor = color;
|
||||||
|
confettiContainer.style.width = `${size}rem`;
|
||||||
|
confettiContainer.style.height = `${size * 0.6}rem`;
|
||||||
|
confettiContainer.style.transform = `rotate(${rotation}deg)`;
|
||||||
|
confettiContainer.style.animation = `confetti-fall ${duration}s linear forwards`;
|
||||||
|
|
||||||
|
document.querySelector('.results-screen').appendChild(confettiContainer);
|
||||||
|
|
||||||
|
// Nach Animation entfernen
|
||||||
|
setTimeout(() => {
|
||||||
|
confettiContainer.remove();
|
||||||
|
}, duration * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mehrere Konfetti-Stücke erstellen
|
||||||
|
const confettiInterval = setInterval(() => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
createConfetti();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
// Konfetti nach einiger Zeit stoppen
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(confettiInterval);
|
||||||
|
setConfetti(false);
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(confettiInterval);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [confetti]);
|
||||||
|
|
||||||
|
// YouTube-Video-ID aus Link extrahieren
|
||||||
|
function getYouTubeId(url) {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||||
|
const match = url.match(regExp);
|
||||||
|
|
||||||
|
return (match && match[2].length === 11) ? match[2] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the submitter from the player list
|
||||||
|
const findSubmitter = (submitterId) => {
|
||||||
|
if (!lobby || !lobby.players) return null;
|
||||||
|
return lobby.players.find(player => player.id === submitterId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!winner) {
|
||||||
|
return (
|
||||||
|
<div className="results-screen">
|
||||||
|
<h2>Ergebnisse werden berechnet...</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the player who submitted the winning song
|
||||||
|
const submitter = findSubmitter(winner.submittedBy);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="results-screen">
|
||||||
|
<header>
|
||||||
|
<h1><FontAwesomeIcon icon={faTrophy} /> Gewinner!</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="winner-card">
|
||||||
|
<div className="winner-info">
|
||||||
|
<h2>{winner.title}</h2>
|
||||||
|
<h3>von {winner.artist}</h3>
|
||||||
|
<p className="submitter">
|
||||||
|
Eingereicht von: {submitter
|
||||||
|
? getDisplayName(submitter, lobby, currentPlayer)
|
||||||
|
: (lobby?.settings?.hidePlayerNames ? 'Anonymer Spieler' : winner.submittedByName)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{winnerVideoId ? (
|
||||||
|
<div className="winner-video">
|
||||||
|
<YouTubeEmbed videoId={winnerVideoId} autoplay={true} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="winner-placeholder">
|
||||||
|
<FontAwesomeIcon icon={faTrophy} size="3x" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="results-actions">
|
||||||
|
<button
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => setShowBattleHistory(!showBattleHistory)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faChartLine} />
|
||||||
|
{showBattleHistory ? 'Kampfverlauf ausblenden' : 'Kampfverlauf anzeigen'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="btn primary" onClick={leaveLobby}>
|
||||||
|
<FontAwesomeIcon icon={faHome} /> Zurück zum Start
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showBattleHistory && (
|
||||||
|
<div className="battle-history">
|
||||||
|
<h3>Kampfverlauf</h3>
|
||||||
|
|
||||||
|
<div className="battles-list">
|
||||||
|
{lobby?.battles?.map((battle, index) => {
|
||||||
|
const isWinner = (battle.song1.id === battle.winner);
|
||||||
|
const song1VideoId = getYouTubeId(battle.song1?.youtubeLink);
|
||||||
|
const song2VideoId = battle.song2 ? getYouTubeId(battle.song2.youtubeLink) : null;
|
||||||
|
|
||||||
|
// Find the players who submitted the songs
|
||||||
|
const song1Submitter = findSubmitter(battle.song1?.submittedBy);
|
||||||
|
const song2Submitter = battle.song2 ? findSubmitter(battle.song2?.submittedBy) : null;
|
||||||
|
|
||||||
|
// Freilos-Runden behandeln
|
||||||
|
const isByeRound = battle.bye === true || !battle.song2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="battle-item">
|
||||||
|
<div className="battle-header">
|
||||||
|
<h4>Runde {battle.round + 1}, Kampf {index + 1} {isByeRound ? "(Automatisches Weiterkommen)" : ""}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="battle-songs">
|
||||||
|
<div className={`battle-song ${isWinner ? 'winner' : ''}`}>
|
||||||
|
<div className="song-info">
|
||||||
|
<h5>{battle.song1.title}</h5>
|
||||||
|
<p>{battle.song1.artist}</p>
|
||||||
|
<span className="votes">{battle.song1Votes} Stimmen</span>
|
||||||
|
{!lobby?.settings?.hidePlayerNames && (
|
||||||
|
<span className="submitter">
|
||||||
|
Eingereicht von: {song1Submitter
|
||||||
|
? getDisplayName(song1Submitter, lobby, currentPlayer)
|
||||||
|
: battle.song1?.submittedByName || 'Unbekannt'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="versus">{isByeRound ? 'FREILOS' : 'VS'}</div>
|
||||||
|
|
||||||
|
{battle.song2 ? (
|
||||||
|
<div className={`battle-song ${!isWinner ? 'winner' : ''}`}>
|
||||||
|
<div className="song-info">
|
||||||
|
<h5>{battle.song2.title}</h5>
|
||||||
|
<p>{battle.song2.artist}</p>
|
||||||
|
<span className="votes">{battle.song2Votes} Stimmen</span>
|
||||||
|
{!lobby?.settings?.hidePlayerNames && (
|
||||||
|
<span className="submitter">
|
||||||
|
Eingereicht von: {song2Submitter
|
||||||
|
? getDisplayName(song2Submitter, lobby, currentPlayer)
|
||||||
|
: battle.song2?.submittedByName || 'Unbekannt'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="battle-song bye">
|
||||||
|
<div className="song-info">
|
||||||
|
<h5>Automatisches Freilos</h5>
|
||||||
|
<p>Kein Gegner</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResultsScreen;
|
524
client/src/components/SongSubmissionScreen.jsx
Normal file
524
client/src/components/SongSubmissionScreen.jsx
Normal file
@ -0,0 +1,524 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useGame } from '../context/GameContext';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPlus, faTrash, faCheck, faMusic, faVideoCamera, faSearch, faSpinner, faExternalLinkAlt, faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { getDisplayName } from '../utils/playerUtils';
|
||||||
|
|
||||||
|
const SongSubmissionScreen = () => {
|
||||||
|
const { lobby, currentPlayer, addSong, removeSong, setPlayerReady, searchYouTube, getYouTubeMetadata } = useGame();
|
||||||
|
const [songs, setSongs] = useState([]);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [songForm, setSongForm] = useState({
|
||||||
|
youtubeLink: ''
|
||||||
|
});
|
||||||
|
const [isFormVisible, setIsFormVisible] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [isLoadingMetadata, setIsLoadingMetadata] = useState(false);
|
||||||
|
const [selectedVideo, setSelectedVideo] = useState(null);
|
||||||
|
const [previewVisible, setPreviewVisible] = useState(false);
|
||||||
|
const searchTimeout = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lobby) {
|
||||||
|
// Finde den aktuellen Spieler und seine Lieder
|
||||||
|
const player = lobby.players.find(p => p.id === currentPlayer.id);
|
||||||
|
if (player) {
|
||||||
|
setIsReady(player.isReady);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [lobby, currentPlayer]);
|
||||||
|
|
||||||
|
// Hole die Lieder des Spielers vom Server
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPlayerSongs = async () => {
|
||||||
|
if (lobby && currentPlayer) {
|
||||||
|
console.log('Lieder für Spieler abrufen:', currentPlayer);
|
||||||
|
console.log('Alle Spieler in der Lobby:', lobby.players.map(p => ({ id: p.id, name: p.name })));
|
||||||
|
|
||||||
|
// Finde den aktuellen Spieler anhand seiner ID, Name oder Socket-ID
|
||||||
|
let player = lobby.players.find(p => p.id === currentPlayer.id);
|
||||||
|
|
||||||
|
// Falls nicht gefunden, versuche es mit dem Namen als Fallback
|
||||||
|
if (!player) {
|
||||||
|
player = lobby.players.find(p => p.name === currentPlayer.name);
|
||||||
|
console.log('Spieler nicht über ID gefunden, versuche es mit Namen. Gefunden:', player);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn Spieler gefunden und Lieder vorhanden, aktualisiere den Zustand
|
||||||
|
if (player) {
|
||||||
|
console.log('Spieler gefunden:', player);
|
||||||
|
if (player.songs && Array.isArray(player.songs)) {
|
||||||
|
console.log('Spieler-Lieder gefunden für', player.name, ':', player.songs.length);
|
||||||
|
console.log('Lieder-Daten:', player.songs);
|
||||||
|
setSongs(player.songs || []);
|
||||||
|
} else {
|
||||||
|
console.log('Kein Lieder-Array für Spieler:', player);
|
||||||
|
setSongs([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Spieler nicht in Lobby gefunden! Aktueller Spieler:', currentPlayer.id);
|
||||||
|
console.log('Verfügbare Spieler:', lobby.players.map(p => p.id));
|
||||||
|
setSongs([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPlayerSongs();
|
||||||
|
// Wichtig: Dieser Effekt sollte ausgeführt werden, wenn sich der Lobby-Zustand ändert
|
||||||
|
}, [lobby, currentPlayer]);
|
||||||
|
|
||||||
|
// Extrahiere Video-ID aus YouTube-URL
|
||||||
|
const extractVideoId = (url) => {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/watch\?v=([^&]+)/,
|
||||||
|
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/embed\/([^/?]+)/,
|
||||||
|
/(?:https?:\/\/)?(?:www\.)?youtu\.be\/([^/?]+)/
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = url.match(pattern);
|
||||||
|
if (match && match[1]) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verzögerte Suchfunktion
|
||||||
|
const handleSearch = async (query) => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const results = await searchYouTube(query);
|
||||||
|
setSearchResults(results || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Suche fehlgeschlagen:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = async (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
|
||||||
|
if (name === 'searchQuery') {
|
||||||
|
setSearchQuery(value);
|
||||||
|
|
||||||
|
// Prüfe, ob die Eingabe ein YouTube-Link sein könnte
|
||||||
|
const videoId = extractVideoId(value);
|
||||||
|
if (videoId) {
|
||||||
|
setSongForm({ youtubeLink: value });
|
||||||
|
|
||||||
|
// Setze grundlegende Informationen sofort für eine reaktionsschnelle Benutzeroberfläche
|
||||||
|
const basicVideoInfo = {
|
||||||
|
id: videoId,
|
||||||
|
url: value,
|
||||||
|
thumbnail: `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`
|
||||||
|
};
|
||||||
|
setSelectedVideo(basicVideoInfo);
|
||||||
|
setSearchResults([]); // Lösche bestehende Suchergebnisse
|
||||||
|
|
||||||
|
// Hole zusätzliche Metadaten vom Server
|
||||||
|
try {
|
||||||
|
setIsLoadingMetadata(true);
|
||||||
|
const metadata = await getYouTubeMetadata(videoId);
|
||||||
|
if (metadata) {
|
||||||
|
// Aktualisiere ausgewähltes Video mit vollständigen Metadaten
|
||||||
|
setSelectedVideo({
|
||||||
|
...basicVideoInfo,
|
||||||
|
title: metadata.title || 'Unbekannter Titel',
|
||||||
|
artist: metadata.artist || 'Unbekannter Künstler',
|
||||||
|
thumbnail: metadata.thumbnail || basicVideoInfo.thumbnail
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Abrufen der Video-Metadaten:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMetadata(false);
|
||||||
|
}
|
||||||
|
} else if (value.trim()) {
|
||||||
|
// Lösche vorherigen Timeout
|
||||||
|
if (searchTimeout.current) {
|
||||||
|
clearTimeout(searchTimeout.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setze einen neuen Timeout, um zu viele API-Aufrufe zu vermeiden
|
||||||
|
searchTimeout.current = setTimeout(() => handleSearch(value), 500);
|
||||||
|
} else {
|
||||||
|
setSearchResults([]);
|
||||||
|
setSelectedVideo(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Funktion zum Umschalten der Videovorschau
|
||||||
|
const togglePreview = (result) => {
|
||||||
|
if (selectedVideo && selectedVideo.id === result.id) {
|
||||||
|
setPreviewVisible(!previewVisible);
|
||||||
|
} else {
|
||||||
|
setSelectedVideo(result);
|
||||||
|
setPreviewVisible(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectSearchResult = (result) => {
|
||||||
|
// Stelle sicher, dass wir ein vollständiges Ergebnis mit allen erforderlichen Feldern haben
|
||||||
|
const completeResult = {
|
||||||
|
id: result.id,
|
||||||
|
url: result.url || `https://www.youtube.com/watch?v=${result.id}`,
|
||||||
|
title: result.title || 'Unbekannter Titel',
|
||||||
|
artist: result.artist || 'Unbekannter Künstler',
|
||||||
|
thumbnail: result.thumbnail || `https://img.youtube.com/vi/${result.id}/mqdefault.jpg`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wenn ein Suchergebnis ausgewählt wird, speichere die YouTube-URL
|
||||||
|
setSongForm({
|
||||||
|
youtubeLink: completeResult.url
|
||||||
|
});
|
||||||
|
|
||||||
|
// Speichere das ausgewählte Video mit allen notwendigen Daten zur Übermittlung
|
||||||
|
setSelectedVideo(completeResult);
|
||||||
|
|
||||||
|
// Behalte die Suchergebnisse sichtbar, aber aktualisiere das Abfragefeld
|
||||||
|
setSearchQuery(completeResult.title);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSong = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Verwende die Daten des ausgewählten Videos, wenn verfügbar, sonst Fallback auf Suchanfrage oder direkte Eingabe
|
||||||
|
let songData;
|
||||||
|
|
||||||
|
// Erzeuge ein konsistentes ID-Format, das später für die Löschung funktioniert
|
||||||
|
const generateSongId = () => `song_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
if (selectedVideo) {
|
||||||
|
// Wir haben ein ausgewähltes Video mit vollständigen Details - verwende alle verfügbaren Metadaten
|
||||||
|
songData = {
|
||||||
|
youtubeLink: selectedVideo.url,
|
||||||
|
title: selectedVideo.title,
|
||||||
|
artist: selectedVideo.artist,
|
||||||
|
thumbnail: selectedVideo.thumbnail,
|
||||||
|
id: generateSongId() // Konsistentes ID-Format
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Füge Lied mit vollständigen Metadaten hinzu:", songData);
|
||||||
|
} else {
|
||||||
|
// Extrahiere YouTube-URL aus Suchanfrage oder direkter Eingabe
|
||||||
|
const youtubeLink = searchQuery.trim() || songForm.youtubeLink.trim();
|
||||||
|
if (!youtubeLink) return;
|
||||||
|
|
||||||
|
// Extrahiere Video-ID, um zu überprüfen, ob es sich um einen gültigen YouTube-Link handelt
|
||||||
|
const videoId = extractVideoId(youtubeLink);
|
||||||
|
if (videoId) {
|
||||||
|
// Es ist ein YouTube-Link, sende ihn zur Metadaten-Auflösung an den Server
|
||||||
|
songData = {
|
||||||
|
youtubeLink: youtubeLink,
|
||||||
|
// Füge die videoId hinzu, um die serverseitige Verarbeitung zu erleichtern
|
||||||
|
videoId: videoId,
|
||||||
|
id: generateSongId() // Konsistentes ID-Format
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Füge Lied mit YouTube-Link hinzu:", songData);
|
||||||
|
} else {
|
||||||
|
// Kein YouTube-Link - behandle als Suchanfrage
|
||||||
|
alert("Bitte gib einen gültigen YouTube-Link ein oder wähle ein Suchergebnis aus");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Füge das Lied hinzu und aktualisiere die UI, wenn der Server antwortet
|
||||||
|
// Wir verwenden den Promise-basierten Ansatz, um auf die vom Server generierte ID für die ordnungsgemäße Löschung zu warten
|
||||||
|
|
||||||
|
// Füge das Lied mit Callback hinzu, um Lieder zu aktualisieren, wenn der Server antwortet
|
||||||
|
addSong(songData)
|
||||||
|
.then((updatedLobby) => {
|
||||||
|
console.log('Lied erfolgreich hinzugefügt, aktualisierte Lobby erhalten:', updatedLobby);
|
||||||
|
if (updatedLobby && currentPlayer) {
|
||||||
|
const player = updatedLobby.players.find(p => p.id === currentPlayer.id);
|
||||||
|
if (player && player.songs) {
|
||||||
|
console.log('Setze Lieder aus addSong-Antwort:', player.songs);
|
||||||
|
setSongs([...player.songs]); // Erstelle ein neues Array, um die Zustandsaktualisierung sicherzustellen
|
||||||
|
} else {
|
||||||
|
console.warn('Spieler oder Lieder nicht in aktualisierter Lobby gefunden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('Fehlende Lobby oder currentPlayer in der Antwort');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Fehler beim Hinzufügen des Liedes:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formular zurücksetzen
|
||||||
|
setSongForm({ youtubeLink: '' });
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
setSelectedVideo(null);
|
||||||
|
setIsFormVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveSong = (songId) => {
|
||||||
|
removeSong(songId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetReady = () => {
|
||||||
|
if (songs.length === lobby.settings.songsPerPlayer) {
|
||||||
|
setPlayerReady();
|
||||||
|
setIsReady(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prüfe, ob wir genügend Lieder eingereicht haben
|
||||||
|
const canSubmitMoreSongs = lobby && songs.length < lobby.settings.songsPerPlayer;
|
||||||
|
|
||||||
|
// Extrahiere YouTube-Video-ID aus verschiedenen YouTube-URL-Formaten
|
||||||
|
const getYoutubeVideoId = (url) => {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
|
||||||
|
if (urlObj.hostname.includes('youtube.com')) {
|
||||||
|
return new URLSearchParams(urlObj.search).get('v');
|
||||||
|
} else if (urlObj.hostname.includes('youtu.be')) {
|
||||||
|
return urlObj.pathname.slice(1);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hole YouTube-Thumbnail-URL aus Video-URL
|
||||||
|
const getYoutubeThumbnail = (url) => {
|
||||||
|
const videoId = getYoutubeVideoId(url);
|
||||||
|
return videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="song-submission-screen">
|
||||||
|
<header className="screen-header">
|
||||||
|
<h1>Füge deine Lieder hinzu</h1>
|
||||||
|
<p className="status">
|
||||||
|
{isReady
|
||||||
|
? 'Warte auf andere Spieler...'
|
||||||
|
: `Füge ${lobby?.settings?.songsPerPlayer || 0} Lieder für den Kampf hinzu`}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="submission-progress">
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{ width: `${(songs.length / (lobby?.settings?.songsPerPlayer || 1)) * 100}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p>{songs.length} / {lobby?.settings?.songsPerPlayer || 0} Lieder</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="songs-list">
|
||||||
|
{songs.map((song, index) => (
|
||||||
|
<div key={song.id || index} className="song-card">
|
||||||
|
<div className="song-thumbnail">
|
||||||
|
{getYoutubeThumbnail(song.youtubeLink) ? (
|
||||||
|
<img src={getYoutubeThumbnail(song.youtubeLink)} alt={song.title} />
|
||||||
|
) : (
|
||||||
|
<div className="thumbnail-placeholder">
|
||||||
|
<FontAwesomeIcon icon={faMusic} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="song-info">
|
||||||
|
<h3>{song.title}</h3>
|
||||||
|
<p>{song.artist}</p>
|
||||||
|
</div>
|
||||||
|
{!isReady && (
|
||||||
|
<button className="btn icon danger" onClick={() => handleRemoveSong(song.id)}>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{canSubmitMoreSongs && !isFormVisible && !isReady && (
|
||||||
|
<button
|
||||||
|
className="btn add-song-btn"
|
||||||
|
onClick={() => setIsFormVisible(true)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPlus} className="icon-margin" />
|
||||||
|
<span>Lied hinzufügen</span>
|
||||||
|
<span className="pixel-corner tl"></span>
|
||||||
|
<span className="pixel-corner tr"></span>
|
||||||
|
<span className="pixel-corner bl"></span>
|
||||||
|
<span className="pixel-corner br"></span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFormVisible && (
|
||||||
|
<div className="song-form-overlay">
|
||||||
|
<form className="song-form" onSubmit={handleAddSong}>
|
||||||
|
<h3>Lied hinzufügen</h3>
|
||||||
|
|
||||||
|
<div className="form-group search-group">
|
||||||
|
<label>Lied suchen</label>
|
||||||
|
<div className="search-container">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="searchQuery"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="YouTube durchsuchen oder Link einfügen..."
|
||||||
|
className="search-input"
|
||||||
|
/>
|
||||||
|
{isSearching && (
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="spinner-icon" spin />
|
||||||
|
)}
|
||||||
|
{searchQuery && !isSearching && (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faTimes}
|
||||||
|
className="clear-icon"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
setSelectedVideo(null);
|
||||||
|
setSongForm({ youtubeLink: '' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zeige ausgewähltes Video ohne eingebetteten Player */}
|
||||||
|
{selectedVideo && (
|
||||||
|
<div className="selected-video">
|
||||||
|
<div className="preview-header">
|
||||||
|
<h4>Ausgewähltes Lied</h4>
|
||||||
|
<a
|
||||||
|
href={selectedVideo.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="external-link"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faExternalLinkAlt} /> Auf YouTube ansehen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="video-details">
|
||||||
|
<div className="thumbnail-container">
|
||||||
|
<img
|
||||||
|
src={selectedVideo.thumbnail}
|
||||||
|
alt={selectedVideo.title}
|
||||||
|
className="selected-thumbnail"
|
||||||
|
/>
|
||||||
|
<div className="play-overlay">
|
||||||
|
<FontAwesomeIcon icon={faVideoCamera} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="selected-info">
|
||||||
|
<h4>{selectedVideo.title || 'Unbekannter Titel'}</h4>
|
||||||
|
<p>{selectedVideo.artist || 'Unbekannter Künstler'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="search-results">
|
||||||
|
<h4>Suchergebnisse</h4>
|
||||||
|
{searchResults.map(result => (
|
||||||
|
<div
|
||||||
|
key={result.id}
|
||||||
|
className={`search-result ${selectedVideo && selectedVideo.id === result.id ? 'selected' : ''}`}
|
||||||
|
onClick={() => handleSelectSearchResult(result)}
|
||||||
|
>
|
||||||
|
<div className="result-thumbnail-container">
|
||||||
|
{result.thumbnail ? (
|
||||||
|
<img src={result.thumbnail} alt={result.title} className="result-thumbnail" />
|
||||||
|
) : (
|
||||||
|
<div className="thumbnail-placeholder">
|
||||||
|
<FontAwesomeIcon icon={faMusic} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="thumbnail-overlay">
|
||||||
|
<FontAwesomeIcon icon={faVideoCamera} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="result-info">
|
||||||
|
<h4>{result.title || 'Unbekannter Titel'}</h4>
|
||||||
|
<p>{result.artist || 'Unbekannter Künstler'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={() => {
|
||||||
|
setIsFormVisible(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
}}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn primary"
|
||||||
|
disabled={!searchQuery.trim() && !songForm.youtubeLink.trim()}
|
||||||
|
>
|
||||||
|
Lied hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="action-buttons">
|
||||||
|
{!isReady && songs.length === lobby?.settings?.songsPerPlayer && (
|
||||||
|
<button
|
||||||
|
className="btn primary"
|
||||||
|
onClick={handleSetReady}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCheck} /> Kampfbereit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<div className="waiting-message">
|
||||||
|
<h3>Kampfbereit!</h3>
|
||||||
|
<p>Warte auf andere Spieler, die ihre Lieder einreichen...</p>
|
||||||
|
|
||||||
|
<div className="player-status">
|
||||||
|
<h4>Spieler bereit</h4>
|
||||||
|
<ul className="players-ready-list">
|
||||||
|
{lobby && lobby.players.map((player, index) => (
|
||||||
|
<li key={player.id} className={player.isReady ? 'ready' : 'not-ready'}>
|
||||||
|
{getDisplayName(player, lobby, currentPlayer, index)} {player.id === currentPlayer.id && lobby.settings.hidePlayerNames && '(Du)'}
|
||||||
|
{player.isReady ? (
|
||||||
|
<FontAwesomeIcon icon={faCheck} className="ready-icon" />
|
||||||
|
) : (
|
||||||
|
<span className="songs-count">{player.songCount}/{lobby?.settings?.songsPerPlayer}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SongSubmissionScreen;
|
448
client/src/components/VotingScreen.jsx
Normal file
448
client/src/components/VotingScreen.jsx
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
// VotingScreen.jsx
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useGame } from '../context/GameContext';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faVoteYea, faTrophy, faMusic, faCheck, faMedal, faCrown, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import YouTubeEmbed from './YouTubeEmbed';
|
||||||
|
import { getDisplayName } from '../utils/playerUtils';
|
||||||
|
|
||||||
|
function VotingScreen() {
|
||||||
|
const { lobby, currentPlayer, submitVote, isHost, isOffline, isReconnecting } = useGame();
|
||||||
|
const [hasVoted, setHasVoted] = useState(false);
|
||||||
|
const [selectedSong, setSelectedSong] = useState(null);
|
||||||
|
const [countdown, setCountdown] = useState(null);
|
||||||
|
const [processingByeAdvance, setProcessingByeAdvance] = useState(false);
|
||||||
|
const [offlineVoteStatus, setOfflineVoteStatus] = useState(null);
|
||||||
|
|
||||||
|
// Hole aktuellen Kampf
|
||||||
|
const battle = lobby?.currentBattle || null;
|
||||||
|
|
||||||
|
// Berechne die Turnierphase basierend auf der Rundennummer und Gesamtliedern
|
||||||
|
const tournamentPhase = useMemo(() => {
|
||||||
|
if (!lobby || !battle) return '';
|
||||||
|
|
||||||
|
// Hole Gesamtanzahl der Lieder im Turnier
|
||||||
|
const totalSongs = lobby.songs?.length || 0;
|
||||||
|
|
||||||
|
if (totalSongs === 0) return 'Vorrunden';
|
||||||
|
|
||||||
|
// Berechne die insgesamt benötigten Runden für das Turnier
|
||||||
|
const totalRounds = Math.ceil(Math.log2(totalSongs));
|
||||||
|
const currentRound = battle.round + 1;
|
||||||
|
const roundsRemaining = totalRounds - currentRound;
|
||||||
|
|
||||||
|
if (roundsRemaining === 0) return 'Finale';
|
||||||
|
if (roundsRemaining === 1) return 'Halbfinale';
|
||||||
|
if (roundsRemaining === 2) return 'Viertelfinale';
|
||||||
|
return 'Vorrunde';
|
||||||
|
}, [lobby, battle]);
|
||||||
|
|
||||||
|
// Berechne das Verhältnis der Stimmen für den Fortschrittsbalken
|
||||||
|
const voteRatio = useMemo(() => {
|
||||||
|
if (!battle || !hasVoted) return { song1Percent: 50, song2Percent: 50 };
|
||||||
|
|
||||||
|
const totalVotes = (battle.song1Votes || 0) + (battle.song2Votes || 0);
|
||||||
|
|
||||||
|
if (totalVotes === 0) return { song1Percent: 50, song2Percent: 50 };
|
||||||
|
|
||||||
|
const song1Percent = Math.round((battle.song1Votes / totalVotes) * 100);
|
||||||
|
const song2Percent = 100 - song1Percent;
|
||||||
|
|
||||||
|
// Determine winner (for styling purposes)
|
||||||
|
const winner = song1Percent > song2Percent ? 'song1' : song1Percent < song2Percent ? 'song2' : 'tie';
|
||||||
|
|
||||||
|
// Calculate margin of victory for animation intensity
|
||||||
|
const margin = Math.abs(song1Percent - song2Percent);
|
||||||
|
const isLandslide = margin >= 30;
|
||||||
|
|
||||||
|
return { song1Percent, song2Percent, winner, totalVotes, isLandslide, margin };
|
||||||
|
}, [battle, hasVoted]);
|
||||||
|
|
||||||
|
// Prüfe, ob der Spieler bereits abgestimmt hat
|
||||||
|
useEffect(() => {
|
||||||
|
if (battle && battle.votes && currentPlayer) {
|
||||||
|
// Prüfe, ob die ID des Spielers im Stimmen-Objekt existiert
|
||||||
|
setHasVoted(
|
||||||
|
Object.prototype.hasOwnProperty.call(battle.votes, currentPlayer.id) ||
|
||||||
|
battle.votes[currentPlayer.id] !== undefined
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setHasVoted(false);
|
||||||
|
}
|
||||||
|
}, [battle, currentPlayer]);
|
||||||
|
|
||||||
|
// Behandle Stimmenwahl
|
||||||
|
const handleVoteSelect = (songId) => {
|
||||||
|
if (hasVoted) return;
|
||||||
|
|
||||||
|
setSelectedSong(songId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sende endgültige Stimme
|
||||||
|
const handleSubmitVote = async () => {
|
||||||
|
if (!selectedSong || hasVoted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If offline, show status but still try to submit (it will be queued)
|
||||||
|
if (isOffline) {
|
||||||
|
setOfflineVoteStatus('pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
await submitVote(selectedSong);
|
||||||
|
|
||||||
|
if (isOffline) {
|
||||||
|
// In offline mode, optimistically update UI
|
||||||
|
setHasVoted(true);
|
||||||
|
setOfflineVoteStatus('queued');
|
||||||
|
|
||||||
|
// Store vote locally for later sync
|
||||||
|
try {
|
||||||
|
const savedVotes = JSON.parse(localStorage.getItem('pendingVotes') || '{}');
|
||||||
|
|
||||||
|
if (battle) {
|
||||||
|
savedVotes[`battle_${battle.round}_${battle.song1.id}`] = {
|
||||||
|
songId: selectedSong,
|
||||||
|
battleRound: battle.round,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
localStorage.setItem('pendingVotes', JSON.stringify(savedVotes));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Fehler beim Speichern der Offline-Stimme:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setHasVoted wird jetzt durch den useEffect behandelt, der die Stimmen prüft
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler bei der Stimmabgabe:", error);
|
||||||
|
setOfflineVoteStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle bye round advancement - für automatisches Weiterkommen
|
||||||
|
const handleByeAdvance = async () => {
|
||||||
|
if (processingByeAdvance || !isHost) return;
|
||||||
|
|
||||||
|
setProcessingByeAdvance(true);
|
||||||
|
try {
|
||||||
|
// If offline, show notification that the action will be queued
|
||||||
|
if (isOffline) {
|
||||||
|
setOfflineVoteStatus('byeQueued');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur der Host kann im Bye-Modus weiterschalten
|
||||||
|
if (battle && battle.song1 && !battle.song2 && battle.song1.id) {
|
||||||
|
await submitVote(battle.song1.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Fortfahren:", error);
|
||||||
|
setOfflineVoteStatus('error');
|
||||||
|
} finally {
|
||||||
|
// Verzögerung, um mehrere Klicks zu verhindern
|
||||||
|
setTimeout(() => setProcessingByeAdvance(false), 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hole YouTube-Video-IDs aus Links
|
||||||
|
const getYouTubeId = (url) => {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||||
|
const match = url.match(regExp);
|
||||||
|
|
||||||
|
return (match && match[2].length === 11) ? match[2] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!battle || !battle.song1) {
|
||||||
|
return (
|
||||||
|
<div className="voting-screen">
|
||||||
|
<h2>Bereite den nächsten Kampf vor...</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Behandle "Freilos"-Runden, in denen ein Lied automatisch weiterkommt
|
||||||
|
if (battle.bye === true && battle.song1 && !battle.song2) {
|
||||||
|
const song1Id = getYouTubeId(battle.song1?.youtubeLink || '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voting-screen">
|
||||||
|
<header className="voting-header">
|
||||||
|
<h1>
|
||||||
|
<FontAwesomeIcon icon={faTrophy} /> Automatisches Weiterkommen
|
||||||
|
</h1>
|
||||||
|
<div className="round-info">
|
||||||
|
<span>Runde {battle.round + 1}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="bye-container">
|
||||||
|
<div className="song-card bye-winner">
|
||||||
|
<div className="song-spotlight"></div>
|
||||||
|
<div className="song-details">
|
||||||
|
<h3>{battle.song1.title}</h3>
|
||||||
|
<p>{battle.song1.artist}</p>
|
||||||
|
<div className="auto-advance-notice">
|
||||||
|
<p>Dieses Lied kommt automatisch in die nächste Runde!</p>
|
||||||
|
<p className="host-info">{isHost ? 'Du kannst als Host zum nächsten Kampf weitergehen.' : 'Warte auf den Host, um fortzufahren.'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{song1Id ? (
|
||||||
|
<div className="video-container">
|
||||||
|
<YouTubeEmbed videoId={song1Id} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="no-video">
|
||||||
|
<FontAwesomeIcon icon={faMusic} className="pulse-icon" />
|
||||||
|
<span>Kein Video verfügbar</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="voting-status">
|
||||||
|
{isHost ? (
|
||||||
|
<button
|
||||||
|
className={`btn primary pixelated full-width ${processingByeAdvance ? 'disabled' : ''}`}
|
||||||
|
onClick={handleByeAdvance}
|
||||||
|
disabled={processingByeAdvance}
|
||||||
|
>
|
||||||
|
{processingByeAdvance ? 'Wird geladen...' : 'Mach weiter'}
|
||||||
|
<span className="pixel-corner tl"></span>
|
||||||
|
<span className="pixel-corner tr"></span>
|
||||||
|
<span className="pixel-corner bl"></span>
|
||||||
|
<span className="pixel-corner br"></span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="auto-advance-notice">Warte auf den Host, um fortzufahren...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const song1Id = getYouTubeId(battle.song1?.youtubeLink || '');
|
||||||
|
const song2Id = getYouTubeId(battle.song2?.youtubeLink || '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voting-screen">
|
||||||
|
<header className="voting-header">
|
||||||
|
<h1>
|
||||||
|
<FontAwesomeIcon icon={tournamentPhase === 'Finale' ? faCrown : tournamentPhase === 'Halbfinale' ? faMedal : faVoteYea} />
|
||||||
|
{tournamentPhase}
|
||||||
|
</h1>
|
||||||
|
<div className="round-info">
|
||||||
|
<span>Runde {battle.round + 1}</span>
|
||||||
|
{hasVoted && <span className="voted-badge">Du hast abgestimmt!</span>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="battle-container">
|
||||||
|
<div
|
||||||
|
className={`song-card ${selectedSong === battle.song1.id ? 'selected' : ''} ${hasVoted ? 'voted' : ''}`}
|
||||||
|
onClick={() => handleVoteSelect(battle.song1.id)}
|
||||||
|
>
|
||||||
|
<div className="song-spotlight"></div>
|
||||||
|
<div className="song-details">
|
||||||
|
<h3>{battle.song1.title}</h3>
|
||||||
|
<p>{battle.song1.artist}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{song1Id ? (
|
||||||
|
<div className="video-container">
|
||||||
|
<YouTubeEmbed videoId={song1Id} />
|
||||||
|
<div className="video-overlay"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="no-video">
|
||||||
|
<FontAwesomeIcon icon={faMusic} className="pulse-icon" />
|
||||||
|
<span>Kein Video verfügbar</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasVoted && (
|
||||||
|
<div className="vote-count">
|
||||||
|
<span className="vote-number">{battle.song1Votes}</span>
|
||||||
|
<span className="vote-text">Stimmen</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedSong === battle.song1.id && !hasVoted && (
|
||||||
|
<div className="selection-indicator">
|
||||||
|
<FontAwesomeIcon icon={faCheck} className="check-icon" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="versus">
|
||||||
|
<span className="versus-text">VS</span>
|
||||||
|
<div className="versus-decoration"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`song-card ${selectedSong === battle.song2.id ? 'selected' : ''} ${hasVoted ? 'voted' : ''}`}
|
||||||
|
onClick={() => handleVoteSelect(battle.song2.id)}
|
||||||
|
>
|
||||||
|
<div className="song-spotlight"></div>
|
||||||
|
<div className="song-details">
|
||||||
|
<h3>{battle.song2.title}</h3>
|
||||||
|
<p>{battle.song2.artist}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{song2Id ? (
|
||||||
|
<div className="video-container">
|
||||||
|
<YouTubeEmbed videoId={song2Id} />
|
||||||
|
<div className="video-overlay"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="no-video">
|
||||||
|
<FontAwesomeIcon icon={faMusic} className="pulse-icon" />
|
||||||
|
<span>Kein Video verfügbar</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasVoted && (
|
||||||
|
<div className="vote-count">
|
||||||
|
<span className="vote-number">{battle.song2Votes}</span>
|
||||||
|
<span className="vote-text">Stimmen</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedSong === battle.song2.id && !hasVoted && (
|
||||||
|
<div className="selection-indicator">
|
||||||
|
<FontAwesomeIcon icon={faCheck} className="check-icon" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vote Progress Bar - Only show after voting */}
|
||||||
|
{hasVoted && (
|
||||||
|
<div className="vote-progress-container">
|
||||||
|
<div className="vote-progress-labels">
|
||||||
|
<span className={`vote-label left ${voteRatio.winner === 'song1' ? 'winning' : ''} ${voteRatio.winner === 'song1' && voteRatio.isLandslide ? 'landslide' : ''}`}>
|
||||||
|
{battle.song1Votes || 0} Stimmen
|
||||||
|
{voteRatio.winner === 'song1' && voteRatio.totalVotes > 1 &&
|
||||||
|
<span className="winner-crown"><FontAwesomeIcon icon={faCrown} /></span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span className={`vote-label right ${voteRatio.winner === 'song2' ? 'winning' : ''} ${voteRatio.winner === 'song2' && voteRatio.isLandslide ? 'landslide' : ''}`}>
|
||||||
|
{battle.song2Votes || 0} Stimmen
|
||||||
|
{voteRatio.winner === 'song2' && voteRatio.totalVotes > 1 &&
|
||||||
|
<span className="winner-crown"><FontAwesomeIcon icon={faCrown} /></span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={`vote-progress-bar ${voteRatio.winner !== 'tie' ? voteRatio.winner : ''} ${voteRatio.isLandslide ? 'landslide' : ''}`}>
|
||||||
|
<div
|
||||||
|
className={`vote-progress-fill song1 ${voteRatio.winner === 'song1' ? 'winning' : ''}`}
|
||||||
|
style={{ width: `${voteRatio.song1Percent}%` }}
|
||||||
|
>
|
||||||
|
{voteRatio.song1Percent > 5 && (
|
||||||
|
<div className="vote-progress-decoration left"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`vote-progress-divider ${voteRatio.margin <= 10 ? 'close-race' : ''}`}
|
||||||
|
style={{ left: `${voteRatio.song1Percent}%` }}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className={`vote-progress-fill song2 ${voteRatio.winner === 'song2' ? 'winning' : ''}`}
|
||||||
|
style={{ width: `${voteRatio.song2Percent}%` }}
|
||||||
|
>
|
||||||
|
{voteRatio.song2Percent > 5 && (
|
||||||
|
<div className="vote-progress-decoration right"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="vote-progress-percentages">
|
||||||
|
<span className={`vote-percent left ${voteRatio.winner === 'song1' ? 'winning' : ''} ${voteRatio.winner === 'song1' && voteRatio.isLandslide ? 'landslide' : ''}`}>
|
||||||
|
{voteRatio.song1Percent}%
|
||||||
|
</span>
|
||||||
|
<span className={`vote-percent right ${voteRatio.winner === 'song2' ? 'winning' : ''} ${voteRatio.winner === 'song2' && voteRatio.isLandslide ? 'landslide' : ''}`}>
|
||||||
|
{voteRatio.song2Percent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasVoted && (
|
||||||
|
<div className="voting-actions">
|
||||||
|
<button
|
||||||
|
className={`btn primary ${isOffline ? 'offline' : ''}`}
|
||||||
|
onClick={handleSubmitVote}
|
||||||
|
disabled={!selectedSong}
|
||||||
|
>
|
||||||
|
{isOffline ? 'Offline Abstimmen' : 'Abstimmen'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOffline && (
|
||||||
|
<div className="offline-notice">
|
||||||
|
<FontAwesomeIcon icon={faExclamationTriangle} />
|
||||||
|
<span>Deine Stimme wird gespeichert und gesendet, sobald die Verbindung wiederhergestellt ist.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{offlineVoteStatus === 'queued' && (
|
||||||
|
<div className="offline-vote-status">
|
||||||
|
<span>Stimme gespeichert! Wird gesendet, wenn online.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{offlineVoteStatus === 'error' && (
|
||||||
|
<div className="offline-vote-status error">
|
||||||
|
<span>Fehler beim Speichern der Stimme.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="voting-status">
|
||||||
|
<p>{hasVoted ? 'Warte auf andere Spieler...' : 'Wähle deinen Favoriten!'}</p>
|
||||||
|
|
||||||
|
{isReconnecting && (
|
||||||
|
<div className="reconnecting-notice">
|
||||||
|
<span>Versuche die Verbindung wiederherzustellen...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="votes-count">
|
||||||
|
<span>{battle.voteCount || 0}</span> von <span>{lobby?.players?.filter(p => p.isConnected).length || 0}</span> Stimmen
|
||||||
|
{isOffline && <span className="offline-badge"> (Offline-Modus)</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste der Spielerstimmen */}
|
||||||
|
<div className="player-votes">
|
||||||
|
<h4>Abstimmende</h4>
|
||||||
|
<ul className="players-voted-list">
|
||||||
|
{lobby?.players?.filter(p => p.isConnected).map(player => {
|
||||||
|
// Prüfe, ob dieser Spieler abgestimmt hat
|
||||||
|
const hasPlayerVoted = battle.votes &&
|
||||||
|
Object.keys(battle.votes).includes(player.id);
|
||||||
|
|
||||||
|
// Highlight current player to show they've voted offline
|
||||||
|
const isCurrentPlayerOfflineVoted = player.id === currentPlayer.id &&
|
||||||
|
isOffline &&
|
||||||
|
hasVoted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={player.id} className={`${hasPlayerVoted ? 'voted' : 'not-voted'} ${isCurrentPlayerOfflineVoted ? 'offline-voted' : ''}`}>
|
||||||
|
{getDisplayName(player, lobby, currentPlayer)} {player.id === currentPlayer.id && lobby.settings.hidePlayerNames && '(Du)'}
|
||||||
|
{hasPlayerVoted &&
|
||||||
|
<FontAwesomeIcon icon={faCheck} className="vote-icon" />
|
||||||
|
}
|
||||||
|
{isCurrentPlayerOfflineVoted &&
|
||||||
|
<FontAwesomeIcon icon={faExclamationTriangle} className="offline-icon" />
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VotingScreen;
|
46
client/src/components/YouTubeEmbed.jsx
Normal file
46
client/src/components/YouTubeEmbed.jsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube video embedding component
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {string} props.videoId - YouTube video ID to embed
|
||||||
|
* @param {boolean} props.autoplay - Whether to autoplay the video (default: false)
|
||||||
|
*/
|
||||||
|
const YouTubeEmbed = ({ videoId, autoplay = false }) => {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!videoId || !containerRef.current) return;
|
||||||
|
|
||||||
|
// Create iframe element
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
|
||||||
|
// Set iframe attributes
|
||||||
|
iframe.width = '100%';
|
||||||
|
iframe.height = '100%';
|
||||||
|
iframe.src = `https://www.youtube.com/embed/${videoId}?rel=0&showinfo=0${autoplay ? '&autoplay=1' : ''}`;
|
||||||
|
iframe.title = 'YouTube video player';
|
||||||
|
iframe.frameBorder = '0';
|
||||||
|
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
|
||||||
|
iframe.allowFullscreen = true;
|
||||||
|
|
||||||
|
// Clear container and append iframe
|
||||||
|
containerRef.current.innerHTML = '';
|
||||||
|
containerRef.current.appendChild(iframe);
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.innerHTML = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [videoId, autoplay]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="youtube-embed" ref={containerRef}>
|
||||||
|
{/* YouTube iframe will be inserted here */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default YouTubeEmbed;
|
773
client/src/context/GameContext.jsx
Normal file
773
client/src/context/GameContext.jsx
Normal file
@ -0,0 +1,773 @@
|
|||||||
|
// Socket.IO client instance for real-time communication
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
// Create a Socket.IO context for use throughout the app
|
||||||
|
const SocketContext = createContext(null);
|
||||||
|
|
||||||
|
// Game context for sharing game state
|
||||||
|
const GameContext = createContext(null);
|
||||||
|
|
||||||
|
export function SocketProvider({ children }) {
|
||||||
|
const [socket, setSocket] = useState(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||||
|
const [reconnectionAttempts, setReconnectionAttempts] = useState(0);
|
||||||
|
const [offlineActions, setOfflineActions] = useState([]);
|
||||||
|
const [lastActivityTime, setLastActivityTime] = useState(Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Create socket connection on component mount with improved reconnection settings
|
||||||
|
const socketInstance = io(import.meta.env.DEV ? 'http://localhost:5237' : window.location.origin, {
|
||||||
|
autoConnect: true,
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionDelayMax: 10000,
|
||||||
|
reconnectionAttempts: Infinity, // Keep trying to reconnect indefinitely
|
||||||
|
timeout: 20000, // Longer timeout for initial connection
|
||||||
|
randomizationFactor: 0.5 // Add some randomization to reconnection attempts to prevent thundering herd
|
||||||
|
});
|
||||||
|
|
||||||
|
// Socket event listeners
|
||||||
|
socketInstance.on('connect', () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
setIsReconnecting(false);
|
||||||
|
setReconnectionAttempts(0);
|
||||||
|
console.log('Connected to server');
|
||||||
|
|
||||||
|
// Process any actions that were queued while offline
|
||||||
|
if (offlineActions.length > 0) {
|
||||||
|
console.log(`Processing ${offlineActions.length} queued actions from offline mode`);
|
||||||
|
// Wait a moment to ensure connection is stable
|
||||||
|
setTimeout(() => {
|
||||||
|
processOfflineActions(socketInstance);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to reconnect to game if we have saved data
|
||||||
|
const savedGameData = localStorage.getItem('songBattleGame');
|
||||||
|
if (savedGameData) {
|
||||||
|
try {
|
||||||
|
const { lobbyId, playerName, lastKnownState } = JSON.parse(savedGameData);
|
||||||
|
if (lobbyId && playerName) {
|
||||||
|
console.log(`Attempting to reconnect to lobby: ${lobbyId}`);
|
||||||
|
socketInstance.emit('reconnect_to_lobby', {
|
||||||
|
lobbyId,
|
||||||
|
playerName,
|
||||||
|
lastKnownState // Send last known game state for server reconciliation
|
||||||
|
}, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
console.error('Reconnection failed:', response.error);
|
||||||
|
// Don't remove data immediately, we might try again
|
||||||
|
if (response.error === 'Lobby not found') {
|
||||||
|
localStorage.removeItem('songBattleGame');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Successfully reconnected to lobby');
|
||||||
|
// Update the saved game data with latest state
|
||||||
|
updateSavedGameData(response.lobby);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing saved game data:', e);
|
||||||
|
localStorage.removeItem('songBattleGame');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socketInstance.on('disconnect', (reason) => {
|
||||||
|
setIsConnected(false);
|
||||||
|
console.log(`Disconnected from server: ${reason}`);
|
||||||
|
|
||||||
|
// If the disconnection is expected, don't try to reconnect
|
||||||
|
if (reason === 'io client disconnect') {
|
||||||
|
console.log('Disconnection was initiated by the client');
|
||||||
|
} else {
|
||||||
|
// Start reconnection process for unexpected disconnections
|
||||||
|
setIsReconnecting(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socketInstance.on('connect_error', (error) => {
|
||||||
|
console.error('Connection error:', error);
|
||||||
|
setIsReconnecting(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
socketInstance.on('reconnect_attempt', (attemptNumber) => {
|
||||||
|
console.log(`Reconnection attempt #${attemptNumber}`);
|
||||||
|
setReconnectionAttempts(attemptNumber);
|
||||||
|
});
|
||||||
|
|
||||||
|
socketInstance.on('reconnect', () => {
|
||||||
|
console.log('Reconnected to server after interruption');
|
||||||
|
setIsReconnecting(false);
|
||||||
|
setReconnectionAttempts(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
socketInstance.on('reconnect_error', (error) => {
|
||||||
|
console.error('Reconnection error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
socketInstance.on('reconnect_failed', () => {
|
||||||
|
console.error('Failed to reconnect to server after maximum attempts');
|
||||||
|
setIsReconnecting(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup a heartbeat to detect silent disconnections
|
||||||
|
const heartbeatInterval = setInterval(() => {
|
||||||
|
if (socketInstance && isConnected) {
|
||||||
|
// Check if we've seen activity recently (within 15 seconds)
|
||||||
|
const timeSinceLastActivity = Date.now() - lastActivityTime;
|
||||||
|
if (timeSinceLastActivity > 15000) {
|
||||||
|
console.log('No recent activity, sending ping to verify connection');
|
||||||
|
socketInstance.emit('ping', () => {
|
||||||
|
// Update activity time when we get a response
|
||||||
|
setLastActivityTime(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a timeout to check if the ping was successful
|
||||||
|
setTimeout(() => {
|
||||||
|
const newTimeSinceLastActivity = Date.now() - lastActivityTime;
|
||||||
|
if (newTimeSinceLastActivity > 15000) {
|
||||||
|
console.warn('No response to ping, connection may be dead');
|
||||||
|
// Force a reconnection attempt
|
||||||
|
socketInstance.disconnect().connect();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30000); // Check every 30 seconds
|
||||||
|
|
||||||
|
setSocket(socketInstance);
|
||||||
|
|
||||||
|
// Function to process queued offline actions
|
||||||
|
const processOfflineActions = (socket) => {
|
||||||
|
if (!socket || !offlineActions.length) return;
|
||||||
|
|
||||||
|
// Process each action in sequence
|
||||||
|
const processAction = (index) => {
|
||||||
|
if (index >= offlineActions.length) {
|
||||||
|
// All actions processed, clear the queue
|
||||||
|
setOfflineActions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action, payload, callback } = offlineActions[index];
|
||||||
|
console.log(`Processing offline action: ${action}`, payload);
|
||||||
|
|
||||||
|
// Emit the action to the server
|
||||||
|
socket.emit(action, payload, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
console.error(`Error processing offline action ${action}:`, response.error);
|
||||||
|
} else {
|
||||||
|
console.log(`Successfully processed offline action: ${action}`);
|
||||||
|
if (callback) callback(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the next action
|
||||||
|
processAction(index + 1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start processing from the first action
|
||||||
|
processAction(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update activity timestamp on any received message
|
||||||
|
const originalOnEvent = socketInstance.onEvent;
|
||||||
|
socketInstance.onEvent = (packet) => {
|
||||||
|
setLastActivityTime(Date.now());
|
||||||
|
originalOnEvent.call(socketInstance, packet);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to update saved game data with latest state
|
||||||
|
const updateSavedGameData = (lobby) => {
|
||||||
|
if (!lobby) return;
|
||||||
|
|
||||||
|
const currentData = localStorage.getItem('songBattleGame');
|
||||||
|
if (currentData) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(currentData);
|
||||||
|
data.lastKnownState = {
|
||||||
|
gameState: lobby.state,
|
||||||
|
currentBattle: lobby.currentBattle,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
localStorage.setItem('songBattleGame', JSON.stringify(data));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating saved game data:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up on unmount
|
||||||
|
return () => {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
socketInstance.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Offline mode detection
|
||||||
|
const [isOffline, setIsOffline] = useState(!navigator.onLine);
|
||||||
|
|
||||||
|
// Monitor browser's online/offline status
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOffline(false);
|
||||||
|
const handleOffline = () => setIsOffline(true);
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add an action to the offline queue
|
||||||
|
const queueOfflineAction = (action, payload, callback) => {
|
||||||
|
console.log(`Queuing offline action: ${action}`, payload);
|
||||||
|
setOfflineActions(prev => [...prev, { action, payload, callback }]);
|
||||||
|
|
||||||
|
// Store in localStorage as fallback
|
||||||
|
try {
|
||||||
|
const offlineQueue = JSON.parse(localStorage.getItem('offlineActionQueue') || '[]');
|
||||||
|
offlineQueue.push({ action, payload, timestamp: Date.now() });
|
||||||
|
localStorage.setItem('offlineActionQueue', JSON.stringify(offlineQueue));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error storing offline action in localStorage:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit an event, queue it if offline
|
||||||
|
const safeEmit = (eventName, data, callback) => {
|
||||||
|
// Update activity timestamp
|
||||||
|
setLastActivityTime(Date.now());
|
||||||
|
|
||||||
|
if (socket && isConnected && !isOffline) {
|
||||||
|
// Online - send immediately
|
||||||
|
socket.emit(eventName, data, callback);
|
||||||
|
} else {
|
||||||
|
// Offline or disconnected - queue for later
|
||||||
|
queueOfflineAction(eventName, data, callback);
|
||||||
|
|
||||||
|
// For specific actions, provide optimistic UI updates
|
||||||
|
if (eventName === 'submit_vote' && data.songId) {
|
||||||
|
// Optimistically show the vote locally
|
||||||
|
// Implementation depends on your UI update mechanism
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SocketContext.Provider value={{
|
||||||
|
socket,
|
||||||
|
isConnected,
|
||||||
|
isReconnecting,
|
||||||
|
reconnectionAttempts,
|
||||||
|
isOffline,
|
||||||
|
safeEmit,
|
||||||
|
queueOfflineAction
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</SocketContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game state provider - manages game state and provides methods for game actions
|
||||||
|
*/
|
||||||
|
export function GameProvider({ children }) {
|
||||||
|
const { socket, isConnected, isOffline, isReconnecting, safeEmit } = useContext(SocketContext);
|
||||||
|
const [lobby, setLobby] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [currentPlayer, setCurrentPlayer] = useState(null);
|
||||||
|
|
||||||
|
// Save game info when lobby is joined
|
||||||
|
useEffect(() => {
|
||||||
|
if (lobby && currentPlayer) {
|
||||||
|
const savedData = {
|
||||||
|
lobbyId: lobby.id,
|
||||||
|
playerName: currentPlayer.name,
|
||||||
|
lastKnownState: {
|
||||||
|
gameState: lobby.state,
|
||||||
|
currentBattle: lobby.currentBattle,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
localStorage.setItem('songBattleGame', JSON.stringify(savedData));
|
||||||
|
}
|
||||||
|
}, [lobby, currentPlayer]);
|
||||||
|
|
||||||
|
// Helper function to update saved game data
|
||||||
|
const updateSavedGameData = (updatedLobby) => {
|
||||||
|
if (!updatedLobby || !currentPlayer) return;
|
||||||
|
|
||||||
|
const savedData = {
|
||||||
|
lobbyId: updatedLobby.id,
|
||||||
|
playerName: currentPlayer.name,
|
||||||
|
lastKnownState: {
|
||||||
|
gameState: updatedLobby.state,
|
||||||
|
currentBattle: updatedLobby.currentBattle,
|
||||||
|
playerState: {
|
||||||
|
isReady: updatedLobby.players?.find(p => p.id === currentPlayer.id)?.isReady,
|
||||||
|
hasVoted: updatedLobby.currentBattle?.votes?.[currentPlayer.id] !== undefined
|
||||||
|
},
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
localStorage.setItem('songBattleGame', JSON.stringify(savedData));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear error after 5 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setError(null);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
// Socket event handlers for game updates
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
// Player joined the lobby
|
||||||
|
const handlePlayerJoined = (data) => {
|
||||||
|
setLobby(prevLobby => {
|
||||||
|
if (!prevLobby) return prevLobby;
|
||||||
|
|
||||||
|
// Update the lobby with the new player
|
||||||
|
const updatedPlayers = [...prevLobby.players, {
|
||||||
|
id: data.playerId,
|
||||||
|
name: data.playerName,
|
||||||
|
isConnected: true,
|
||||||
|
isReady: false,
|
||||||
|
songCount: 0
|
||||||
|
}];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prevLobby,
|
||||||
|
players: updatedPlayers
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Player reconnected to the lobby
|
||||||
|
const handlePlayerReconnected = (data) => {
|
||||||
|
setLobby(prevLobby => {
|
||||||
|
if (!prevLobby) return prevLobby;
|
||||||
|
|
||||||
|
// Update the lobby with the reconnected player
|
||||||
|
const updatedPlayers = prevLobby.players.map(player => {
|
||||||
|
if (player.name === data.playerName && !player.isConnected) {
|
||||||
|
return { ...player, id: data.playerId, isConnected: true };
|
||||||
|
}
|
||||||
|
return player;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prevLobby,
|
||||||
|
players: updatedPlayers
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Player disconnected from the lobby
|
||||||
|
const handlePlayerDisconnected = (data) => {
|
||||||
|
setLobby(data.lobby);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Game settings were updated
|
||||||
|
const handleSettingsUpdated = (data) => {
|
||||||
|
setLobby(prevLobby => {
|
||||||
|
if (!prevLobby) return prevLobby;
|
||||||
|
return {
|
||||||
|
...prevLobby,
|
||||||
|
settings: data.settings
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Game started
|
||||||
|
const handleGameStarted = (data) => {
|
||||||
|
setLobby(prevLobby => {
|
||||||
|
if (!prevLobby) return prevLobby;
|
||||||
|
return {
|
||||||
|
...prevLobby,
|
||||||
|
state: data.state
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Songs were updated
|
||||||
|
const handleSongsUpdated = (data) => {
|
||||||
|
setLobby(data.lobby);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Player status changed (ready/not ready)
|
||||||
|
const handlePlayerStatusChanged = (data) => {
|
||||||
|
console.log('Player status changed, new lobby state:', data.lobby.state);
|
||||||
|
setLobby(data.lobby);
|
||||||
|
|
||||||
|
// If the state is VOTING and we have a current battle, explicitly log it
|
||||||
|
if (data.lobby.state === 'VOTING' && data.lobby.currentBattle) {
|
||||||
|
console.log('Battle ready in player_status_changed:', data.lobby.currentBattle);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vote was submitted
|
||||||
|
const handleVoteSubmitted = (data) => {
|
||||||
|
console.log('Vote submitted, updating lobby');
|
||||||
|
setLobby(data.lobby);
|
||||||
|
};
|
||||||
|
|
||||||
|
// New battle started
|
||||||
|
const handleNewBattle = (data) => {
|
||||||
|
console.log('New battle received:', data.battle);
|
||||||
|
setLobby(prevLobby => {
|
||||||
|
if (!prevLobby) return prevLobby;
|
||||||
|
|
||||||
|
// Ensure we update both the currentBattle and the state
|
||||||
|
return {
|
||||||
|
...prevLobby,
|
||||||
|
currentBattle: data.battle,
|
||||||
|
state: 'VOTING'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle battle ended and show result screen
|
||||||
|
const handleBattleEnded = (data) => {
|
||||||
|
console.log('Battle ended, showing result screen:', data);
|
||||||
|
setLobby(prevLobby => {
|
||||||
|
if (!prevLobby) return prevLobby;
|
||||||
|
return {
|
||||||
|
...prevLobby,
|
||||||
|
state: 'BATTLE',
|
||||||
|
previousBattle: data.previousBattle
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Game finished
|
||||||
|
const handleGameFinished = (data) => {
|
||||||
|
setLobby(prevLobby => {
|
||||||
|
if (!prevLobby) return prevLobby;
|
||||||
|
return {
|
||||||
|
...prevLobby,
|
||||||
|
state: 'FINISHED',
|
||||||
|
finalWinner: data.winner,
|
||||||
|
battles: data.battles
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register socket event listeners
|
||||||
|
socket.on('player_joined', handlePlayerJoined);
|
||||||
|
socket.on('player_reconnected', handlePlayerReconnected);
|
||||||
|
socket.on('player_disconnected', handlePlayerDisconnected);
|
||||||
|
socket.on('settings_updated', handleSettingsUpdated);
|
||||||
|
socket.on('game_started', handleGameStarted);
|
||||||
|
socket.on('songs_updated', handleSongsUpdated);
|
||||||
|
socket.on('player_status_changed', handlePlayerStatusChanged);
|
||||||
|
socket.on('vote_submitted', handleVoteSubmitted);
|
||||||
|
socket.on('battle_ended', handleBattleEnded);
|
||||||
|
socket.on('tournament_started', data => {
|
||||||
|
console.log('Tournament started event received:', data);
|
||||||
|
setLobby(prevLobby => prevLobby ? {...prevLobby, state: data.state} : prevLobby);
|
||||||
|
});
|
||||||
|
socket.on('new_battle', handleNewBattle);
|
||||||
|
socket.on('battle_ended', handleBattleEnded);
|
||||||
|
socket.on('game_finished', handleGameFinished);
|
||||||
|
|
||||||
|
// Clean up listeners on unmount
|
||||||
|
return () => {
|
||||||
|
socket.off('player_joined', handlePlayerJoined);
|
||||||
|
socket.off('player_reconnected', handlePlayerReconnected);
|
||||||
|
socket.off('player_disconnected', handlePlayerDisconnected);
|
||||||
|
socket.off('settings_updated', handleSettingsUpdated);
|
||||||
|
socket.off('game_started', handleGameStarted);
|
||||||
|
socket.off('songs_updated', handleSongsUpdated);
|
||||||
|
socket.off('player_status_changed', handlePlayerStatusChanged);
|
||||||
|
socket.off('vote_submitted', handleVoteSubmitted);
|
||||||
|
socket.off('new_battle', handleNewBattle);
|
||||||
|
socket.off('battle_ended', handleBattleEnded);
|
||||||
|
socket.off('game_finished', handleGameFinished);
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
// Create a lobby
|
||||||
|
const createLobby = (playerName) => {
|
||||||
|
if (!socket || !isConnected) {
|
||||||
|
setError('Not connected to server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeEmit('create_lobby', { playerName }, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
setError(response.error);
|
||||||
|
} else {
|
||||||
|
setLobby(response.lobby);
|
||||||
|
setCurrentPlayer({
|
||||||
|
id: socket.id,
|
||||||
|
name: playerName,
|
||||||
|
isHost: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Join a lobby
|
||||||
|
const joinLobby = (lobbyId, playerName) => {
|
||||||
|
if (!socket || !isConnected) {
|
||||||
|
setError('Not connected to server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeEmit('join_lobby', { lobbyId, playerName }, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
setError(response.error);
|
||||||
|
} else {
|
||||||
|
setLobby(response.lobby);
|
||||||
|
setCurrentPlayer({
|
||||||
|
id: socket.id,
|
||||||
|
name: playerName,
|
||||||
|
isHost: response.lobby.hostId === socket.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update lobby settings
|
||||||
|
const updateSettings = (settings) => {
|
||||||
|
if (!socket || !isConnected || !lobby) {
|
||||||
|
setError('Not connected to server or no active lobby');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeEmit('update_settings', { settings }, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
setError(response.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the game
|
||||||
|
const startGame = () => {
|
||||||
|
if (!socket || !isConnected || !lobby) {
|
||||||
|
setError('Not connected to server or no active lobby');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeEmit('start_game', {}, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
setError(response.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a song
|
||||||
|
const addSong = (song) => {
|
||||||
|
if (!socket || !isConnected || !lobby) {
|
||||||
|
setError('Not connected to server or no active lobby');
|
||||||
|
return Promise.reject('Not connected to server or no active lobby');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Attempting to add song:', song);
|
||||||
|
console.log('Current player state:', currentPlayer);
|
||||||
|
console.log('Current lobby state before adding song:', lobby);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
safeEmit('add_song', { song }, (response) => {
|
||||||
|
console.log('Song addition response:', response);
|
||||||
|
if (response.error) {
|
||||||
|
console.error('Error adding song:', response.error);
|
||||||
|
setError(response.error);
|
||||||
|
reject(response.error);
|
||||||
|
} else if (response.lobby) {
|
||||||
|
// Log detailed lobby state for debugging
|
||||||
|
console.log('Song added successfully, full lobby response:', response.lobby);
|
||||||
|
console.log('Current player songs:', response.lobby.players.find(p => p.id === currentPlayer.id)?.songs);
|
||||||
|
console.log('All players song data:',
|
||||||
|
response.lobby.players.map(p => ({
|
||||||
|
name: p.name,
|
||||||
|
id: p.id,
|
||||||
|
songCount: p.songCount,
|
||||||
|
songs: p.songs ? p.songs.map(s => ({id: s.id, title: s.title})) : 'No songs'
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Force a deep clone of the lobby to ensure React detects the change
|
||||||
|
const updatedLobby = JSON.parse(JSON.stringify(response.lobby));
|
||||||
|
setLobby(updatedLobby);
|
||||||
|
|
||||||
|
// Verify the state was updated correctly
|
||||||
|
setTimeout(() => {
|
||||||
|
// This won't show the updated state immediately due to React's state update mechanism
|
||||||
|
console.log('Lobby state after update (may not reflect immediate changes):', lobby);
|
||||||
|
console.log('Updated lobby that was set:', updatedLobby);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Store the latest state for offline reconciliation
|
||||||
|
updateSavedGameData(updatedLobby);
|
||||||
|
|
||||||
|
// Resolve with the updated lobby
|
||||||
|
resolve(updatedLobby);
|
||||||
|
} else {
|
||||||
|
console.error('Song addition succeeded but no lobby data was returned');
|
||||||
|
setError('Failed to update song list');
|
||||||
|
reject('Failed to update song list');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// Search for songs on YouTube
|
||||||
|
const searchYouTube = (query) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!socket || !isConnected) {
|
||||||
|
setError('Not connected to server');
|
||||||
|
reject('Not connected to server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeEmit('search_youtube', { query }, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
setError(response.error);
|
||||||
|
reject(response.error);
|
||||||
|
} else {
|
||||||
|
resolve(response.results);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get metadata for a YouTube video by ID
|
||||||
|
const getYouTubeMetadata = (videoId) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!socket || !isConnected) {
|
||||||
|
setError('Not connected to server');
|
||||||
|
reject('Not connected to server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeEmit('get_video_metadata', { videoId }, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
setError(response.error);
|
||||||
|
reject(response.error);
|
||||||
|
} else {
|
||||||
|
resolve(response.metadata);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove a song
|
||||||
|
const removeSong = (songId) => {
|
||||||
|
if (!socket || !isConnected || !lobby) {
|
||||||
|
setError('Not connected to server or no active lobby');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeEmit('remove_song', { songId }, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
setError(response.error);
|
||||||
|
} else if (response.lobby) {
|
||||||
|
setLobby(response.lobby);
|
||||||
|
updateSavedGameData(response.lobby);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set player ready
|
||||||
|
const setPlayerReady = () => {
|
||||||
|
if (!socket || !isConnected || !lobby) {
|
||||||
|
setError('Not connected to server or no active lobby');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeEmit('player_ready', {}, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
setError(response.error);
|
||||||
|
} else if (response.lobby) {
|
||||||
|
setLobby(response.lobby);
|
||||||
|
updateSavedGameData(response.lobby);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Submit a vote
|
||||||
|
const submitVote = (songId) => {
|
||||||
|
if (!socket || !isConnected || !lobby) {
|
||||||
|
setError('Not connected to server or no active lobby');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeEmit('submit_vote', { songId }, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
setError(response.error);
|
||||||
|
} else if (response.lobby) {
|
||||||
|
setLobby(response.lobby);
|
||||||
|
updateSavedGameData(response.lobby);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Leave lobby and clear saved game state
|
||||||
|
const leaveLobby = () => {
|
||||||
|
localStorage.removeItem('songBattleGame');
|
||||||
|
setLobby(null);
|
||||||
|
setCurrentPlayer(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Proceed to next battle after the battle result screen
|
||||||
|
const proceedToNextBattle = () => {
|
||||||
|
if (!socket || !isConnected) {
|
||||||
|
setError('Not connected to server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeEmit('proceed_to_next_battle', {}, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
setError(response.error);
|
||||||
|
} else if (response.lobby) {
|
||||||
|
setLobby(response.lobby);
|
||||||
|
updateSavedGameData(response.lobby);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameContext.Provider value={{
|
||||||
|
lobby,
|
||||||
|
error,
|
||||||
|
currentPlayer,
|
||||||
|
isHost: currentPlayer && lobby && currentPlayer.id === lobby.hostId,
|
||||||
|
isOffline: isOffline,
|
||||||
|
isReconnecting,
|
||||||
|
createLobby,
|
||||||
|
joinLobby,
|
||||||
|
updateSettings,
|
||||||
|
startGame,
|
||||||
|
addSong,
|
||||||
|
removeSong,
|
||||||
|
setPlayerReady,
|
||||||
|
submitVote,
|
||||||
|
leaveLobby,
|
||||||
|
searchYouTube,
|
||||||
|
getYouTubeMetadata,
|
||||||
|
proceedToNextBattle
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</GameContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom hooks for using contexts
|
||||||
|
export const useSocket = () => useContext(SocketContext);
|
||||||
|
export const useGame = () => useContext(GameContext);
|
||||||
|
|
||||||
|
// Re-export for better module compatibility
|
||||||
|
export { SocketContext, GameContext };
|
18
client/src/main.jsx
Normal file
18
client/src/main.jsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App.jsx";
|
||||||
|
import "@fontsource/press-start-2p";
|
||||||
|
import "./common/styles/main.sass";
|
||||||
|
import { SocketProvider, GameProvider } from "./context/GameContext.jsx";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<SocketProvider>
|
||||||
|
<GameProvider>
|
||||||
|
<div className="app">
|
||||||
|
<App/>
|
||||||
|
</div>
|
||||||
|
</GameProvider>
|
||||||
|
</SocketProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
45
client/src/utils/playerUtils.js
Normal file
45
client/src/utils/playerUtils.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for player name handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an anonymous name for a player when hidePlayerNames is enabled
|
||||||
|
* @param {string} playerId - The player's ID
|
||||||
|
* @param {number} index - The player's index in a list (optional)
|
||||||
|
* @param {boolean} isCurrentPlayer - Whether this is the current player
|
||||||
|
* @returns {string} - Anonymous name or indicator
|
||||||
|
*/
|
||||||
|
export const getAnonymousName = (playerId, index = null, isCurrentPlayer = false) => {
|
||||||
|
if (isCurrentPlayer) {
|
||||||
|
return 'Du';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use index-based naming if provided
|
||||||
|
if (index !== null) {
|
||||||
|
return `Spieler ${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use last 4 characters of ID as fallback
|
||||||
|
const shortId = playerId.slice(-4);
|
||||||
|
return `Spieler ${shortId}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the appropriate player name based on game settings
|
||||||
|
* @param {Object} player - The player object
|
||||||
|
* @param {Object} lobby - The lobby object
|
||||||
|
* @param {Object} currentPlayer - The current player
|
||||||
|
* @param {number} index - The player's index in a list (optional)
|
||||||
|
* @returns {string} - The player name or anonymous identifier
|
||||||
|
*/
|
||||||
|
export const getDisplayName = (player, lobby, currentPlayer, index = null) => {
|
||||||
|
const isCurrentPlayer = player.id === currentPlayer?.id;
|
||||||
|
|
||||||
|
// If hidePlayerNames is enabled
|
||||||
|
if (lobby?.settings?.hidePlayerNames) {
|
||||||
|
return getAnonymousName(player.id, index, isCurrentPlayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise return the actual name
|
||||||
|
return player.name;
|
||||||
|
};
|
21
client/vite.config.js
Normal file
21
client/vite.config.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import {defineConfig} from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/socket.io": {
|
||||||
|
target: "http://localhost:5237",
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
26
package.json
Normal file
26
package.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "song-battle",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Song Battle game with tournament-style voting for favorite songs",
|
||||||
|
"main": "server/index.js",
|
||||||
|
"author": "Mathias Wagner",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"webui": "cd client && yarn dev",
|
||||||
|
"server": "nodemon server",
|
||||||
|
"dev": "concurrently --kill-others \"yarn server\" \"yarn webui\"",
|
||||||
|
"build": "cd client && yarn build",
|
||||||
|
"start": "node server/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.8.4",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"googleapis": "^148.0.0",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
|
"nodemon": "^3.1.9"
|
||||||
|
}
|
||||||
|
}
|
1440
pnpm-lock.yaml
generated
Normal file
1440
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1173
server/game.js
Normal file
1173
server/game.js
Normal file
File diff suppressed because it is too large
Load Diff
413
server/index.js
Normal file
413
server/index.js
Normal file
@ -0,0 +1,413 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const { Server } = require("socket.io");
|
||||||
|
const http = require("http");
|
||||||
|
const app = express();
|
||||||
|
const path = require("path");
|
||||||
|
const GameManager = require("./game");
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, '../client/dist')));
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
|
||||||
|
app.get('*', (req, res) => res.sendFile(path.join(__dirname, '../client/dist', 'index.html')));
|
||||||
|
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
const io = new Server(server, {
|
||||||
|
cors: {origin: "*"},
|
||||||
|
pingTimeout: 30000,
|
||||||
|
pingInterval: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize game manager
|
||||||
|
const gameManager = new GameManager();
|
||||||
|
|
||||||
|
// Socket.IO event handlers
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
console.log(`User connected: ${socket.id}`);
|
||||||
|
|
||||||
|
// Create a new game lobby
|
||||||
|
socket.on('create_lobby', ({ playerName }, callback) => {
|
||||||
|
try {
|
||||||
|
const result = gameManager.createLobby(socket.id, playerName);
|
||||||
|
|
||||||
|
// Join the socket room for this lobby
|
||||||
|
socket.join(result.lobbyId);
|
||||||
|
|
||||||
|
// Send response to client
|
||||||
|
if (callback) callback(result);
|
||||||
|
|
||||||
|
console.log(`Lobby created: ${result.lobbyId} by ${playerName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating lobby:', error);
|
||||||
|
if (callback) callback({ error: 'Failed to create lobby' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Join an existing lobby
|
||||||
|
socket.on('join_lobby', ({ lobbyId, playerName }, callback) => {
|
||||||
|
try {
|
||||||
|
const result = gameManager.joinLobby(socket.id, playerName, lobbyId);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
if (callback) callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join the socket room for this lobby
|
||||||
|
socket.join(lobbyId);
|
||||||
|
|
||||||
|
// Notify all players in the lobby
|
||||||
|
socket.to(lobbyId).emit('player_joined', {
|
||||||
|
playerId: socket.id,
|
||||||
|
playerName
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send response to client
|
||||||
|
if (callback) callback(result);
|
||||||
|
|
||||||
|
console.log(`Player ${playerName} joined lobby ${lobbyId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error joining lobby:', error);
|
||||||
|
if (callback) callback({ error: 'Failed to join lobby' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attempt to reconnect to a lobby
|
||||||
|
socket.on('reconnect_to_lobby', ({ lobbyId, playerName, lastKnownState }, callback) => {
|
||||||
|
try {
|
||||||
|
const result = gameManager.handleReconnect(socket.id, lobbyId, playerName, lastKnownState);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
if (callback) callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join the socket room for this lobby
|
||||||
|
socket.join(lobbyId);
|
||||||
|
|
||||||
|
// Notify all players in the lobby
|
||||||
|
socket.to(lobbyId).emit('player_reconnected', {
|
||||||
|
playerId: socket.id,
|
||||||
|
playerName
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send response to client
|
||||||
|
if (callback) callback(result);
|
||||||
|
|
||||||
|
console.log(`Player ${playerName} reconnected to lobby ${lobbyId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reconnecting to lobby:', error);
|
||||||
|
if (callback) callback({ error: 'Failed to reconnect to lobby' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update lobby settings
|
||||||
|
socket.on('update_settings', ({ settings }, callback) => {
|
||||||
|
try {
|
||||||
|
const result = gameManager.updateSettings(socket.id, settings);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
if (callback) callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify all players in the lobby
|
||||||
|
io.to(result.lobbyId).emit('settings_updated', {
|
||||||
|
settings: result.lobby.settings
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send response to client
|
||||||
|
if (callback) callback(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating settings:', error);
|
||||||
|
if (callback) callback({ error: 'Failed to update settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the game from lobby
|
||||||
|
socket.on('start_game', (_, callback) => {
|
||||||
|
try {
|
||||||
|
const result = gameManager.startGame(socket.id);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
if (callback) callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify all players in the lobby
|
||||||
|
io.to(result.lobbyId).emit('game_started', {
|
||||||
|
state: result.lobby.state
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send response to client
|
||||||
|
if (callback) callback(result);
|
||||||
|
|
||||||
|
console.log(`Game started in lobby ${result.lobbyId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error starting game:', error);
|
||||||
|
if (callback) callback({ error: 'Failed to start game' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a song
|
||||||
|
socket.on('add_song', async ({ song }, callback) => {
|
||||||
|
try {
|
||||||
|
console.log(`[DEBUG] Attempting to add song: ${JSON.stringify(song)}`);
|
||||||
|
// Fix: Properly await the result of the async addSong function
|
||||||
|
const result = await gameManager.addSong(socket.id, song);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.log(`[DEBUG] Error adding song: ${result.error}`);
|
||||||
|
if (callback) callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add better error handling to prevent crash if lobby is not defined
|
||||||
|
if (!result.lobby || !result.lobbyId) {
|
||||||
|
console.log(`[DEBUG] Warning: Song added but lobby information is missing`);
|
||||||
|
if (callback) callback({ error: 'Failed to associate song with lobby' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[DEBUG] Song added successfully, notifying lobby ${result.lobbyId}`);
|
||||||
|
console.log(`[DEBUG] Lobby songs: ${JSON.stringify(result.lobby.songs.map(s => s.title))}`);
|
||||||
|
|
||||||
|
// Notify all players in the lobby about updated song count
|
||||||
|
io.to(result.lobbyId).emit('songs_updated', {
|
||||||
|
lobby: result.lobby
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send response to client
|
||||||
|
if (callback) callback(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding song:', error);
|
||||||
|
if (callback) callback({ error: 'Failed to add song' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove a song
|
||||||
|
socket.on('remove_song', ({ songId }, callback) => {
|
||||||
|
try {
|
||||||
|
const result = gameManager.removeSong(socket.id, songId);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
if (callback) callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify all players in the lobby
|
||||||
|
io.to(result.lobbyId).emit('songs_updated', {
|
||||||
|
lobby: result.lobby
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send response to client
|
||||||
|
if (callback) callback(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing song:', error);
|
||||||
|
if (callback) callback({ error: 'Failed to remove song' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search YouTube for songs
|
||||||
|
socket.on('search_youtube', async ({ query }, callback) => {
|
||||||
|
try {
|
||||||
|
if (!query || typeof query !== 'string') {
|
||||||
|
if (callback) callback({ error: 'Invalid search query' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await gameManager.searchYouTube(query);
|
||||||
|
|
||||||
|
// Send response to client
|
||||||
|
if (callback) callback({ results });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching YouTube:', error);
|
||||||
|
if (callback) callback({ error: 'Failed to search YouTube' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get metadata for a single YouTube video
|
||||||
|
socket.on('get_video_metadata', async ({ videoId }, callback) => {
|
||||||
|
try {
|
||||||
|
if (!videoId || typeof videoId !== 'string') {
|
||||||
|
if (callback) callback({ error: 'Invalid video ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await gameManager.getYouTubeVideoMetadata(videoId);
|
||||||
|
|
||||||
|
// Send response to client
|
||||||
|
if (callback) callback({ metadata });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting YouTube metadata:', error);
|
||||||
|
if (callback) callback({ error: 'Failed to get YouTube metadata' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark player as ready
|
||||||
|
socket.on('player_ready', (_, callback) => {
|
||||||
|
try {
|
||||||
|
const result = gameManager.setPlayerReady(socket.id);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
if (callback) callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify all players in the lobby
|
||||||
|
io.to(result.lobbyId).emit('player_status_changed', {
|
||||||
|
lobby: result.lobby
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the game state just changed to VOTING, notify about the first battle
|
||||||
|
if (result.lobby.state === 'VOTING' && result.lobby.currentBattle) {
|
||||||
|
console.log('Sending new_battle event to clients');
|
||||||
|
io.to(result.lobbyId).emit('new_battle', {
|
||||||
|
battle: result.lobby.currentBattle
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If tournament just started, explicitly notify about the state change and first battle
|
||||||
|
if (result.tournamentStarted && result.lobby.currentBattle) {
|
||||||
|
console.log('Tournament started, sending battle data');
|
||||||
|
// First send state change
|
||||||
|
io.to(result.lobbyId).emit('tournament_started', {
|
||||||
|
state: 'VOTING'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then send battle data with a slight delay to ensure clients process in the right order
|
||||||
|
setTimeout(() => {
|
||||||
|
io.to(result.lobbyId).emit('new_battle', {
|
||||||
|
battle: result.lobby.currentBattle
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If game finished (edge case where only one song was submitted)
|
||||||
|
if (result.lobby.state === 'FINISHED') {
|
||||||
|
io.to(result.lobbyId).emit('game_finished', {
|
||||||
|
winner: result.lobby.finalWinner,
|
||||||
|
battles: result.lobby.battles
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send response to client
|
||||||
|
if (callback) callback(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting player ready:', error);
|
||||||
|
if (callback) callback({ error: 'Failed to set player status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit a vote in a battle
|
||||||
|
socket.on('submit_vote', ({ songId }, callback) => {
|
||||||
|
try {
|
||||||
|
console.log(`Player ${socket.id} voting for song ${songId}`);
|
||||||
|
const result = gameManager.submitVote(socket.id, songId);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
if (callback) callback({ error: result.error });
|
||||||
|
} else {
|
||||||
|
// Broadcast updated game state to all players in the lobby
|
||||||
|
socket.to(result.lobbyId).emit('vote_submitted', result);
|
||||||
|
|
||||||
|
// Check if we've entered the BATTLE state (all votes are in and battle has ended)
|
||||||
|
const lobby = result.lobby;
|
||||||
|
if (lobby.state === 'BATTLE') {
|
||||||
|
// Broadcast battle result to all players in the lobby
|
||||||
|
io.to(result.lobbyId).emit('battle_ended', {
|
||||||
|
previousBattle: lobby.previousBattle,
|
||||||
|
state: 'BATTLE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Check if the game has finished (edge case - last battle with automatic winner)
|
||||||
|
else if (lobby.state === 'FINISHED') {
|
||||||
|
console.log('Game finished after vote submission');
|
||||||
|
io.to(result.lobbyId).emit('game_finished', {
|
||||||
|
winner: lobby.finalWinner,
|
||||||
|
battles: lobby.battles
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callback) callback(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting vote:', error);
|
||||||
|
if (callback) callback({ error: 'Server error while submitting vote' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Proceed to next battle after the battle result screen
|
||||||
|
socket.on('proceed_to_next_battle', (data, callback) => {
|
||||||
|
try {
|
||||||
|
const result = gameManager.proceedToNextBattle(socket.id);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
if (callback) callback({ error: result.error });
|
||||||
|
} else {
|
||||||
|
// Check if the game has finished
|
||||||
|
if (result.lobby.state === 'FINISHED') {
|
||||||
|
console.log('Game finished, broadcasting final winner');
|
||||||
|
io.to(result.lobbyId).emit('game_finished', {
|
||||||
|
winner: result.lobby.finalWinner,
|
||||||
|
battles: result.lobby.battles
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Broadcast updated game state to all players in the lobby
|
||||||
|
io.to(result.lobbyId).emit('new_battle', {
|
||||||
|
battle: result.lobby.currentBattle,
|
||||||
|
state: result.lobby.state
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callback) callback(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error proceeding to next battle:', error);
|
||||||
|
if (callback) callback({ error: 'Server error while proceeding to next battle' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle player disconnection
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
try {
|
||||||
|
const result = gameManager.handleDisconnect(socket.id);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// Notify remaining players about the disconnection
|
||||||
|
io.to(result.lobbyId).emit('player_disconnected', {
|
||||||
|
playerId: socket.id,
|
||||||
|
lobby: result.lobby
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Player ${socket.id} disconnected from lobby ${result.lobbyId}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling disconnect:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple ping handler to help with connection testing
|
||||||
|
socket.on('ping', (callback) => {
|
||||||
|
if (callback && typeof callback === 'function') {
|
||||||
|
callback({ success: true, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', (error) => {
|
||||||
|
console.error('Server error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 5237;
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('Server shutting down...');
|
||||||
|
server.close(() => {
|
||||||
|
console.log('Server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
129
server/youtube-api.js
Normal file
129
server/youtube-api.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
const { google } = require('googleapis');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
// You would need to obtain a YouTube API key from Google Cloud Console
|
||||||
|
// For now, we'll use a placeholder - you'll need to replace this with your actual API key
|
||||||
|
const API_KEY = process.env.YOUTUBE_API_KEY || 'NO';
|
||||||
|
|
||||||
|
// Initialize the YouTube API client
|
||||||
|
const youtube = google.youtube({
|
||||||
|
version: 'v3',
|
||||||
|
auth: API_KEY
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract YouTube video ID from various formats of YouTube links
|
||||||
|
* @param {string} url - YouTube URL in any format
|
||||||
|
* @returns {string|null} YouTube video ID or null if invalid
|
||||||
|
*/
|
||||||
|
function extractVideoId(url) {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
// Handle different YouTube URL formats
|
||||||
|
const patterns = [
|
||||||
|
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/watch\?v=([^&]+)/,
|
||||||
|
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/embed\/([^/?]+)/,
|
||||||
|
/(?:https?:\/\/)?(?:www\.)?youtu\.be\/([^/?]+)/
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = url.match(pattern);
|
||||||
|
if (match && match[1]) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get video metadata from YouTube API
|
||||||
|
* @param {string} videoId - YouTube video ID
|
||||||
|
* @returns {Promise<Object>} Video metadata including title and channel
|
||||||
|
*/
|
||||||
|
async function getVideoMetadata(videoId) {
|
||||||
|
try {
|
||||||
|
// Try using the googleapis library first
|
||||||
|
try {
|
||||||
|
const response = await youtube.videos.list({
|
||||||
|
part: 'snippet',
|
||||||
|
id: videoId
|
||||||
|
});
|
||||||
|
|
||||||
|
const video = response.data.items[0];
|
||||||
|
|
||||||
|
if (video && video.snippet) {
|
||||||
|
return {
|
||||||
|
title: video.snippet.title,
|
||||||
|
artist: video.snippet.channelTitle,
|
||||||
|
thumbnail: video.snippet.thumbnails.default.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (googleApiError) {
|
||||||
|
console.log('Google API error, falling back to alternative method:', googleApiError.message);
|
||||||
|
// If googleapis fails (e.g., due to API key issues), fall back to a simplified approach
|
||||||
|
const response = await axios.get(`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`);
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
const title = response.data.title || '';
|
||||||
|
// Try to extract artist from title (common format is "Artist - Title")
|
||||||
|
const parts = title.split(' - ');
|
||||||
|
const artist = parts.length > 1 ? parts[0] : response.data.author_name;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: parts.length > 1 ? parts.slice(1).join(' - ') : title,
|
||||||
|
artist: artist,
|
||||||
|
thumbnail: response.data.thumbnail_url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all methods fail, return default values
|
||||||
|
return {
|
||||||
|
title: 'Unknown Title',
|
||||||
|
artist: 'Unknown Artist',
|
||||||
|
thumbnail: null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching video metadata:', error.message);
|
||||||
|
return {
|
||||||
|
title: 'Unknown Title',
|
||||||
|
artist: 'Unknown Artist',
|
||||||
|
thumbnail: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for songs on YouTube
|
||||||
|
* @param {string} query - Search query for YouTube
|
||||||
|
* @returns {Promise<Array>} List of search results
|
||||||
|
*/
|
||||||
|
async function searchYouTube(query) {
|
||||||
|
try {
|
||||||
|
const response = await youtube.search.list({
|
||||||
|
part: 'snippet',
|
||||||
|
q: query,
|
||||||
|
type: 'video',
|
||||||
|
maxResults: 5,
|
||||||
|
videoCategoryId: '10' // Category ID for Music
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.items.map(item => ({
|
||||||
|
id: item.id.videoId,
|
||||||
|
title: item.snippet.title,
|
||||||
|
artist: item.snippet.channelTitle,
|
||||||
|
thumbnail: item.snippet.thumbnails.default.url,
|
||||||
|
youtubeLink: `https://www.youtube.com/watch?v=${item.id.videoId}`
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching YouTube:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
extractVideoId,
|
||||||
|
getVideoMetadata,
|
||||||
|
searchYouTube
|
||||||
|
};
|
Reference in New Issue
Block a user