Add screens and components for Song Battle game, including Home, Lobby, Voting, Results, and Song Submission screens; implement YouTube video embedding and styles
Some checks failed
Publish Docker image / Push Docker image to Docker Hub (push) Failing after 7m12s
Some checks failed
Publish Docker image / Push Docker image to Docker Hub (push) Failing after 7m12s
This commit is contained in:
@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg" href="/logo.svg" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Liedkampf</title>
|
||||
</head>
|
||||
|
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 |
@ -1,24 +0,0 @@
|
||||
<svg width="150" height="200" viewBox="0 0 150 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="noteGradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#ff007f"/>
|
||||
<stop offset="100%" stop-color="#7f00ff"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.5"/>
|
||||
<stop offset="100%" stop-color="#000000" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Note Stem -->
|
||||
<path d="M110 20C110 12 104 6 96 6C88 6 82 12 82 20V106C76 102 66 100 56 104C40 110 32 126 38 142C44 158 60 168 76 162C90 157 96 144 94 130V62L110 58V20Z" fill="url(#noteGradient)" stroke="#000" stroke-width="3" />
|
||||
|
||||
<!-- Outer Glow Circle -->
|
||||
<circle cx="66" cy="134" r="32" fill="url(#noteGradient)" stroke="black" stroke-width="5" />
|
||||
|
||||
<!-- Aggressive Tail -->
|
||||
<path d="M110 58L132 50L132 26C132 16 122 8 112 8L110 20" fill="#7f00ff" stroke="#000" stroke-width="2"/>
|
||||
|
||||
<!-- Shine Highlight -->
|
||||
<ellipse cx="92" cy="30" rx="8" ry="14" fill="white" opacity="0.2"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.1 KiB |
@ -1,9 +1,17 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faDrum, faGuitar, faHeadphones, faMusic} from "@fortawesome/free-solid-svg-icons";
|
||||
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";
|
||||
|
||||
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},
|
||||
@ -28,6 +36,26 @@ const App = () => {
|
||||
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 />;
|
||||
default:
|
||||
return <HomeScreen />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="background-elements">
|
||||
@ -54,6 +82,22 @@ const App = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="content-container">
|
||||
{!isConnected && (
|
||||
<div className="connection-status">
|
||||
<p>Connecting to server...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderGameScreen()}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
116
client/src/common/styles/buttons.sass
Normal file
116
client/src/common/styles/buttons.sass
Normal file
@ -0,0 +1,116 @@
|
||||
// Button styles for the Song Battle application
|
||||
|
||||
// Base button style
|
||||
.btn
|
||||
display: inline-block
|
||||
padding: 0.75rem 1.5rem
|
||||
border: none
|
||||
border-radius: 0.5rem
|
||||
font-weight: 600
|
||||
cursor: pointer
|
||||
transition: all 0.3s ease
|
||||
text-transform: uppercase
|
||||
letter-spacing: 1px
|
||||
position: relative
|
||||
overflow: hidden
|
||||
text-align: center
|
||||
min-width: 120px
|
||||
|
||||
// Sheen animation on hover
|
||||
&:before
|
||||
content: ''
|
||||
position: absolute
|
||||
top: 0
|
||||
left: -100%
|
||||
width: 100%
|
||||
height: 100%
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.3),
|
||||
transparent
|
||||
)
|
||||
transition: all 0.6s
|
||||
|
||||
&:hover:before
|
||||
left: 100%
|
||||
|
||||
&:hover
|
||||
transform: translateY(-3px)
|
||||
box-shadow: 0 7px 15px rgba(0, 0, 0, 0.3)
|
||||
|
||||
&:active
|
||||
transform: translateY(0)
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2)
|
||||
|
||||
&:disabled
|
||||
opacity: 0.5
|
||||
cursor: not-allowed
|
||||
transform: none
|
||||
box-shadow: none
|
||||
|
||||
// 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)
|
@ -1,16 +1,17 @@
|
||||
$background: rgba(255, 255, 255, 0.14)
|
||||
$border: rgba(255, 255, 255, 0.35)
|
||||
$white: #ECECEC
|
||||
$green: #26EE5E
|
||||
$black: #000
|
||||
$dark-gray: #1e1e1e
|
||||
$light-gray: #aaa
|
||||
$red: #ff0000
|
||||
// Colors for Song Battle application
|
||||
|
||||
$pink: #ff6bb3
|
||||
$blue: #4d9dff
|
||||
$purple: #9c6bff
|
||||
$cyan: #6bffea
|
||||
$orange: #ff9b6b
|
||||
$yellow: #ffde6b
|
||||
$mint-green: #85ffbd
|
||||
// Main colors
|
||||
$background: #0f0f1a // Kept for compatibility
|
||||
$card-bg: rgba(20, 20, 35, 0.85) // Semi-transparent dark card background
|
||||
$text: #ffffff
|
||||
$text-muted: rgba(255, 255, 255, 0.7) // Brighter for better contrast with gradient
|
||||
|
||||
// Brand colors
|
||||
$primary: #f0c3ff // Light purple - complementary to gradient
|
||||
$secondary: #00e5ff // Bright cyan - stands out against pink/purple
|
||||
$accent: #ffcc00 // Bright gold - contrasts with pink/purple
|
||||
|
||||
// Status colors
|
||||
$success: #00c853
|
||||
$warning: #ffc107
|
||||
$danger: #ff5252
|
||||
|
89
client/src/common/styles/components/home-screen.sass
Normal file
89
client/src/common/styles/components/home-screen.sass
Normal file
@ -0,0 +1,89 @@
|
||||
// Home Screen styles
|
||||
|
||||
.home-screen
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
justify-content: center
|
||||
min-height: 100%
|
||||
padding: 2rem
|
||||
position: relative
|
||||
|
||||
.logo
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
margin-bottom: 2rem
|
||||
|
||||
.logo-image
|
||||
image-rendering: pixelated
|
||||
width: 120px
|
||||
height: auto
|
||||
margin-bottom: 1rem
|
||||
animation: pulse 2s infinite ease-in-out
|
||||
filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.7))
|
||||
|
||||
h1
|
||||
font-size: 3.5rem
|
||||
margin: 0
|
||||
color: white
|
||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5), 2px 2px 4px rgba(0, 0, 0, 0.3)
|
||||
|
||||
.card
|
||||
background-color: $card-bg
|
||||
border-radius: 1rem
|
||||
padding: 2rem
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 15px rgba(255, 255, 255, 0.1)
|
||||
border: 1px solid rgba(255, 255, 255, 0.1)
|
||||
backdrop-filter: blur(10px)
|
||||
width: 100%
|
||||
max-width: 500px
|
||||
|
||||
.tabs
|
||||
display: flex
|
||||
margin-bottom: 1.5rem
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.1)
|
||||
|
||||
button
|
||||
flex: 1
|
||||
background: none
|
||||
border: none
|
||||
color: $text-muted
|
||||
padding: 1rem
|
||||
font-size: 1rem
|
||||
cursor: pointer
|
||||
transition: color 0.2s, border-bottom 0.2s
|
||||
position: relative
|
||||
|
||||
&:after
|
||||
content: ''
|
||||
position: absolute
|
||||
bottom: -2px
|
||||
left: 0
|
||||
width: 100%
|
||||
height: 2px
|
||||
background-color: transparent
|
||||
transition: background-color 0.2s
|
||||
|
||||
&.active
|
||||
color: $primary
|
||||
|
||||
&:after
|
||||
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
|
193
client/src/common/styles/components/results-screen.sass
Normal file
193
client/src/common/styles/components/results-screen.sass
Normal file
@ -0,0 +1,193 @@
|
||||
// Results Screen styles
|
||||
|
||||
.results-screen
|
||||
display: flex
|
||||
flex-direction: column
|
||||
min-height: 100%
|
||||
padding: 1.5rem
|
||||
position: relative
|
||||
overflow: hidden
|
||||
|
||||
header
|
||||
display: flex
|
||||
justify-content: center
|
||||
margin-bottom: 2rem
|
||||
|
||||
h1
|
||||
margin: 0
|
||||
color: $primary
|
||||
text-shadow: 0 0 8px rgba($primary, 0.5)
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.5rem
|
||||
font-size: 2.5rem
|
||||
|
||||
svg
|
||||
color: $accent
|
||||
|
||||
.winner-card
|
||||
background-color: $card-bg
|
||||
border-radius: 1rem
|
||||
padding: 1.5rem
|
||||
margin-bottom: 2rem
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 1.5rem
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3)
|
||||
position: relative
|
||||
overflow: hidden
|
||||
|
||||
&::after
|
||||
content: ''
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
right: 0
|
||||
height: 5px
|
||||
background: linear-gradient(to right, $primary, $accent)
|
||||
|
||||
@media (min-width: 768px)
|
||||
flex-direction: row
|
||||
align-items: stretch
|
||||
|
||||
.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)
|
||||
|
||||
.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)
|
257
client/src/common/styles/components/song-submission-screen.sass
Normal file
257
client/src/common/styles/components/song-submission-screen.sass
Normal file
@ -0,0 +1,257 @@
|
||||
// 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
|
||||
text-shadow: 0 0 8px rgba($primary, 0.5)
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.5rem
|
||||
|
||||
.songs-counter
|
||||
background-color: rgba($card-bg, 0.7)
|
||||
border-radius: 0.5rem
|
||||
padding: 0.75rem 1rem
|
||||
font-size: 1.1rem
|
||||
|
||||
.counter
|
||||
font-weight: bold
|
||||
color: $primary
|
||||
|
||||
.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-radius: 1rem
|
||||
padding: 1.5rem
|
||||
|
||||
h2
|
||||
margin-top: 0
|
||||
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
|
||||
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: 1rem
|
||||
|
||||
&: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
|
199
client/src/common/styles/components/voting-screen.sass
Normal file
199
client/src/common/styles/components/voting-screen.sass
Normal file
@ -0,0 +1,199 @@
|
||||
// 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
|
||||
text-shadow: 0 0 8px rgba($primary, 0.5)
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.5rem
|
||||
|
||||
.round-info
|
||||
background-color: rgba($card-bg, 0.7)
|
||||
border-radius: 0.5rem
|
||||
padding: 0.75rem 1rem
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.75rem
|
||||
|
||||
span
|
||||
font-weight: bold
|
||||
|
||||
.voted-badge
|
||||
background-color: $success
|
||||
color: #fff
|
||||
border-radius: 1rem
|
||||
padding: 0.25rem 0.75rem
|
||||
font-size: 0.85rem
|
||||
animation: pulse 2s infinite
|
||||
|
||||
.battle-container
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
gap: 1.5rem
|
||||
margin-bottom: 2rem
|
||||
perspective: 1000px
|
||||
|
||||
@media (min-width: 768px)
|
||||
flex-direction: row
|
||||
align-items: stretch
|
||||
|
||||
.song-card
|
||||
flex: 1
|
||||
background-color: $card-bg
|
||||
border-radius: 1rem
|
||||
padding: 1.5rem
|
||||
display: flex
|
||||
flex-direction: column
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease, border 0.3s ease
|
||||
border: 2px solid transparent
|
||||
cursor: pointer
|
||||
position: relative
|
||||
overflow: hidden
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4)
|
||||
|
||||
&:hover:not(.voted)
|
||||
transform: translateY(-5px)
|
||||
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.6)
|
||||
|
||||
&.selected:not(.voted)
|
||||
border-color: $secondary
|
||||
box-shadow: 0 0 25px rgba($secondary, 0.4)
|
||||
transform: translateY(-8px) scale(1.02)
|
||||
|
||||
.song-spotlight
|
||||
opacity: 1
|
||||
|
||||
&.voted
|
||||
cursor: default
|
||||
|
||||
.song-spotlight
|
||||
position: absolute
|
||||
top: -50%
|
||||
left: -50%
|
||||
width: 200%
|
||||
height: 200%
|
||||
background: radial-gradient(ellipse at center, rgba($secondary, 0.3) 0%, rgba($secondary, 0) 70%)
|
||||
pointer-events: none
|
||||
opacity: 0
|
||||
transition: opacity 0.5s
|
||||
z-index: 1
|
||||
|
||||
.song-details
|
||||
margin-bottom: 1rem
|
||||
|
||||
h3
|
||||
margin: 0 0 0.25rem 0
|
||||
font-size: 1.5rem
|
||||
|
||||
p
|
||||
margin: 0
|
||||
color: $text-muted
|
||||
|
||||
.video-container
|
||||
width: 100%
|
||||
flex-grow: 1
|
||||
min-height: 200px
|
||||
position: relative
|
||||
margin-bottom: 0.5rem
|
||||
|
||||
.youtube-embed
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
border-radius: 0.5rem
|
||||
overflow: hidden
|
||||
|
||||
.no-video
|
||||
flex-grow: 1
|
||||
min-height: 200px
|
||||
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
|
||||
font-size: 3rem
|
||||
gap: 1rem
|
||||
|
||||
span
|
||||
font-size: 1rem
|
||||
|
||||
.vote-count
|
||||
position: absolute
|
||||
bottom: 1rem
|
||||
right: 1rem
|
||||
background-color: rgba($background, 0.8)
|
||||
padding: 0.5rem 1rem
|
||||
border-radius: 2rem
|
||||
font-size: 0.9rem
|
||||
|
||||
&::after
|
||||
content: ''
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
background: linear-gradient(to right, $primary, $secondary)
|
||||
opacity: 0.2
|
||||
border-radius: 2rem
|
||||
z-index: -1
|
||||
|
||||
.versus
|
||||
font-family: 'Bangers', cursive
|
||||
font-size: 2rem
|
||||
color: $accent
|
||||
text-shadow: 0 0 10px rgba($accent, 0.5)
|
||||
display: flex
|
||||
align-items: center
|
||||
padding: 0 1rem
|
||||
|
||||
@media (min-width: 768px)
|
||||
padding: 1rem
|
||||
|
||||
.voting-actions
|
||||
display: flex
|
||||
justify-content: center
|
||||
margin-bottom: 2rem
|
||||
|
||||
.btn
|
||||
padding: 0.75rem 2rem
|
||||
font-size: 1.1rem
|
||||
|
||||
.voting-status
|
||||
margin-top: auto
|
||||
text-align: center
|
||||
|
||||
p
|
||||
color: $text-muted
|
||||
font-style: italic
|
||||
margin-bottom: 0.5rem
|
||||
|
||||
.votes-count
|
||||
display: inline-block
|
||||
padding: 0.5rem 1rem
|
||||
background-color: rgba($card-bg, 0.7)
|
||||
border-radius: 0.5rem
|
||||
|
||||
span:first-child
|
||||
font-weight: bold
|
||||
color: $secondary
|
11
client/src/common/styles/components/youtube-embed.sass
Normal file
11
client/src/common/styles/components/youtube-embed.sass
Normal file
@ -0,0 +1,11 @@
|
||||
// YouTube Embed component styles
|
||||
|
||||
.youtube-embed
|
||||
width: 100%
|
||||
height: 100%
|
||||
background-color: #000
|
||||
border-radius: 0.5rem
|
||||
overflow: hidden
|
||||
|
||||
iframe
|
||||
border: none
|
181
client/src/common/styles/components/youtube-search.sass
Normal file
181
client/src/common/styles/components/youtube-search.sass
Normal file
@ -0,0 +1,181 @@
|
||||
// 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
|
||||
border-radius: 0.5rem
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
color: $text
|
||||
font-size: 1rem
|
||||
|
||||
&:focus
|
||||
outline: none
|
||||
border-color: $primary
|
||||
box-shadow: 0 0 0 3px rgba($primary, 0.3)
|
||||
|
||||
.spinner-icon, .clear-icon
|
||||
position: absolute
|
||||
top: 50%
|
||||
right: 1rem
|
||||
transform: translateY(-50%)
|
||||
color: $text-muted
|
||||
|
||||
.clear-icon
|
||||
cursor: pointer
|
||||
&:hover
|
||||
color: $text
|
||||
|
||||
.selected-video
|
||||
background: rgba($card-bg, 0.6)
|
||||
border-radius: 0.75rem
|
||||
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
|
||||
|
||||
.preview-toggle
|
||||
background: transparent
|
||||
border: none
|
||||
color: $secondary
|
||||
cursor: pointer
|
||||
padding: 0.25rem 0.5rem
|
||||
border-radius: 0.25rem
|
||||
font-size: 0.85rem
|
||||
|
||||
&:hover
|
||||
background: rgba($secondary, 0.15)
|
||||
|
||||
.video-details
|
||||
display: flex
|
||||
gap: 1rem
|
||||
margin-bottom: 1rem
|
||||
|
||||
.selected-thumbnail
|
||||
width: 120px
|
||||
height: 90px
|
||||
object-fit: cover
|
||||
border-radius: 0.25rem
|
||||
|
||||
.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.6)
|
||||
border-radius: 0.75rem
|
||||
padding: 1rem
|
||||
max-height: 400px
|
||||
overflow-y: auto
|
||||
|
||||
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
|
@ -1,157 +1,124 @@
|
||||
@import "@/common/styles/colors"
|
||||
@import "./colors"
|
||||
|
||||
.form-base
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
backdrop-filter: blur(15px)
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
border-radius: 20px
|
||||
padding: 2.5rem
|
||||
width: 100%
|
||||
position: relative
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15), 0 0 20px rgba(255, 255, 255, 0.1)
|
||||
.form-group
|
||||
margin-bottom: 1.5rem
|
||||
|
||||
h2
|
||||
margin: 0 0 2rem
|
||||
text-align: center
|
||||
color: $white
|
||||
font-size: 2.5rem
|
||||
text-shadow: 0 0 10px rgba(255, 255, 255, 0.4)
|
||||
|
||||
form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 1.5rem
|
||||
|
||||
.input-group
|
||||
position: relative
|
||||
|
||||
input
|
||||
width: 100%
|
||||
padding: 1.2rem 1.5rem
|
||||
background: rgba(255, 255, 255, 0.07)
|
||||
border: 1px solid rgba(255, 255, 255, 0.1)
|
||||
border-radius: 12px
|
||||
color: $white
|
||||
font-family: 'Bangers', sans-serif
|
||||
font-size: 1.2rem
|
||||
letter-spacing: 0.15rem
|
||||
transition: all 0.3s ease
|
||||
outline: none
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1)
|
||||
label
|
||||
display: block
|
||||
margin-bottom: 0.5rem
|
||||
font-weight: 500
|
||||
|
||||
.required
|
||||
color: $danger
|
||||
margin-left: 0.25rem
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="email"],
|
||||
input[type="url"],
|
||||
input[type="password"],
|
||||
textarea,
|
||||
select
|
||||
width: 100%
|
||||
padding: 0.75rem
|
||||
background-color: rgba(0, 0, 0, 0.4)
|
||||
border: 2px solid rgba(255, 255, 255, 0.3)
|
||||
border-radius: 0.5rem
|
||||
color: $text
|
||||
font-size: 1rem
|
||||
transition: all 0.3s ease
|
||||
box-sizing: border-box
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2)
|
||||
|
||||
&:focus
|
||||
border-color: $secondary
|
||||
box-shadow: 0 0 15px rgba($secondary, 0.5)
|
||||
outline: none
|
||||
transform: scale(1.02)
|
||||
|
||||
&::placeholder
|
||||
color: rgba(255, 255, 255, 0.5)
|
||||
|
||||
&:focus
|
||||
border-color: rgba(255, 255, 255, 0.5)
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15), 0 0 10px rgba(255, 255, 255, 0.2)
|
||||
border-color: $primary
|
||||
box-shadow: 0 0 0 2px rgba($primary, 0.25)
|
||||
outline: none
|
||||
|
||||
&.error
|
||||
border-color: rgba(255, 0, 0, 0.5)
|
||||
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both
|
||||
&::placeholder
|
||||
color: rgba(255, 255, 255, 0.4)
|
||||
|
||||
.error-message
|
||||
position: absolute
|
||||
color: rgba(255, 100, 100, 0.9)
|
||||
font-size: 0.85rem
|
||||
bottom: -1.2rem
|
||||
left: 0.2rem
|
||||
animation: fade-in 0.3s ease
|
||||
|
||||
.button
|
||||
border: none
|
||||
border-radius: 12px
|
||||
padding: 1.2rem
|
||||
color: $white
|
||||
font-family: 'Bangers', sans-serif
|
||||
font-size: 1.3rem
|
||||
letter-spacing: 0.15rem
|
||||
cursor: pointer
|
||||
transition: all 0.3s ease
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
gap: 0.8rem
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15)
|
||||
|
||||
&:hover
|
||||
transform: translateY(-3px)
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2)
|
||||
|
||||
&:active
|
||||
transform: translateY(0)
|
||||
|
||||
.submit-button
|
||||
@extend .button
|
||||
margin-top: 1rem
|
||||
|
||||
&.join-button
|
||||
background: linear-gradient(45deg, $blue, $purple)
|
||||
&.checkbox
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
&:hover
|
||||
background: linear-gradient(45deg, lighten($blue, 10%), lighten($purple, 10%))
|
||||
label
|
||||
display: flex
|
||||
align-items: center
|
||||
margin: 0
|
||||
cursor: pointer
|
||||
|
||||
&.create-button
|
||||
background: linear-gradient(45deg, $pink, $purple)
|
||||
|
||||
&:hover
|
||||
background: linear-gradient(45deg, lighten($pink, 10%), lighten($purple, 10%))
|
||||
input[type="checkbox"]
|
||||
margin-right: 0.5rem
|
||||
cursor: pointer
|
||||
|
||||
.back-button
|
||||
position: absolute
|
||||
top: -4rem
|
||||
left: 0
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
border-radius: 10px
|
||||
// Form actions
|
||||
.form-actions
|
||||
display: flex
|
||||
justify-content: flex-end
|
||||
gap: 1rem
|
||||
margin-top: 1.5rem
|
||||
|
||||
// Buttons
|
||||
.btn
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
gap: 0.7rem
|
||||
color: rgba(255, 255, 255, 0.9)
|
||||
padding: 0.75rem 1.5rem
|
||||
border: none
|
||||
border-radius: 0.5rem
|
||||
font-size: 1rem
|
||||
font-weight: 500
|
||||
cursor: pointer
|
||||
font-family: 'Bangers', sans-serif
|
||||
font-size: 1.1rem
|
||||
padding: 0.7rem 1.3rem
|
||||
transition: all 0.3s ease
|
||||
letter-spacing: 0.1rem
|
||||
backdrop-filter: blur(10px)
|
||||
transition: all 0.2s
|
||||
|
||||
svg
|
||||
font-size: 1rem
|
||||
transition: transform 0.3s ease
|
||||
margin-right: 0.5rem
|
||||
|
||||
&:hover
|
||||
color: $white
|
||||
background: rgba(255, 255, 255, 0.2)
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1)
|
||||
&.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
|
||||
transform: translateX(-5px)
|
||||
|
||||
.glassy-card
|
||||
background: rgba(255, 255, 255, 0.07)
|
||||
backdrop-filter: blur(10px)
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
border-radius: 20px
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1), 0 0 20px rgba(255, 255, 255, 0.1)
|
||||
overflow: hidden
|
||||
transition: all 0.3s ease
|
||||
|
||||
@keyframes shake
|
||||
10%, 90%
|
||||
transform: translate3d(-1px, 0, 0)
|
||||
20%, 80%
|
||||
transform: translate3d(2px, 0, 0)
|
||||
30%, 50%, 70%
|
||||
transform: translate3d(-4px, 0, 0)
|
||||
40%, 60%
|
||||
transform: translate3d(4px, 0, 0)
|
||||
|
||||
@keyframes fade-in
|
||||
from
|
||||
opacity: 0
|
||||
transform: translateY(-5px)
|
||||
to
|
||||
opacity: 1
|
||||
transform: translateY(0)
|
||||
margin-right: 0.5rem
|
||||
|
File diff suppressed because it is too large
Load Diff
95
client/src/components/HomeScreen.jsx
Normal file
95
client/src/components/HomeScreen.jsx
Normal file
@ -0,0 +1,95 @@
|
||||
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>Song Battle</h1>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="tabs">
|
||||
<button
|
||||
className={isCreateMode ? 'active' : ''}
|
||||
onClick={() => setIsCreateMode(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} /> Create Game
|
||||
</button>
|
||||
<button
|
||||
className={!isCreateMode ? 'active' : ''}
|
||||
onClick={() => setIsCreateMode(false)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDoorOpen} /> Join Game
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="player-name">Your Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="player-name"
|
||||
value={playerName}
|
||||
onChange={(e) => setPlayerName(e.target.value)}
|
||||
placeholder="Enter your name"
|
||||
maxLength={20}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isCreateMode && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="lobby-id">Game Code</label>
|
||||
<input
|
||||
type="text"
|
||||
id="lobby-id"
|
||||
value={lobbyId}
|
||||
onChange={(e) => setLobbyId(e.target.value.toUpperCase())}
|
||||
placeholder="Enter 6-letter code"
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary">
|
||||
{isCreateMode ? 'Create New Game' : 'Join Game'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<footer className="home-footer">
|
||||
<p>Let your favorite songs battle it out!</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Make sure default export is explicit
|
||||
export default HomeScreen;
|
172
client/src/components/LobbyScreen.jsx
Normal file
172
client/src/components/LobbyScreen.jsx
Normal file
@ -0,0 +1,172 @@
|
||||
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';
|
||||
|
||||
const LobbyScreen = () => {
|
||||
const { lobby, currentPlayer, isHost, updateSettings, startGame, leaveLobby } = useGame();
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [settings, setSettings] = useState({
|
||||
songsPerPlayer: 4,
|
||||
maxPlayers: 10,
|
||||
requireYoutubeLinks: true
|
||||
});
|
||||
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>Game Lobby</h1>
|
||||
<div className="lobby-code">
|
||||
<p>Game Code: <span className="code">{lobby.id}</span></p>
|
||||
<button className="btn icon" onClick={handleCopyCode}>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="lobby-content">
|
||||
<div className="players-list">
|
||||
<h2><FontAwesomeIcon icon={faUsers} /> Players ({lobby.players.length})</h2>
|
||||
<ul>
|
||||
{lobby.players.map(player => (
|
||||
<li key={player.id} className={`player ${!player.isConnected ? 'disconnected' : ''} ${player.id === lobby.hostId ? 'host' : ''}`}>
|
||||
{player.name} {player.id === lobby.hostId && '(Host)'} {player.id === currentPlayer.id && '(You)'}
|
||||
{!player.isConnected && <span className="status-disconnected">(Disconnected)</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="lobby-info">
|
||||
<div className="settings-preview">
|
||||
<h3><FontAwesomeIcon icon={faGear} /> Game Settings</h3>
|
||||
<p>Songs per player: {settings.songsPerPlayer}</p>
|
||||
<p>Max players: {settings.maxPlayers}</p>
|
||||
<p>YouTube links required: {settings.requireYoutubeLinks ? 'Yes' : 'No'}</p>
|
||||
|
||||
{isHost && (
|
||||
<button className="btn secondary" onClick={() => setShowSettings(true)}>
|
||||
Edit Settings
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="actions">
|
||||
{isHost && (
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={handleStartGame}
|
||||
disabled={lobby.players.length < lobby.settings.minPlayers}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlay} /> Start Game
|
||||
</button>
|
||||
)}
|
||||
<button className="btn danger" onClick={leaveLobby}>
|
||||
<FontAwesomeIcon icon={faSignOutAlt} /> Leave Lobby
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lobby.players.length < lobby.settings.minPlayers && isHost && (
|
||||
<p className="warning">Need at least {lobby.settings.minPlayers} players to start</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSettings && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<h2>Game Settings</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="songsPerPlayer">Songs per player</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">Maximum players</label>
|
||||
<input
|
||||
type="number"
|
||||
id="maxPlayers"
|
||||
name="maxPlayers"
|
||||
min="3"
|
||||
max="20"
|
||||
value={settings.maxPlayers}
|
||||
onChange={handleSettingsChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox">
|
||||
<label htmlFor="requireYoutubeLinks">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="requireYoutubeLinks"
|
||||
name="requireYoutubeLinks"
|
||||
checked={settings.requireYoutubeLinks}
|
||||
onChange={handleSettingsChange}
|
||||
/>
|
||||
Require YouTube links
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn secondary" onClick={() => setShowSettings(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn primary" onClick={handleSaveSettings}>
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Ensure explicit default export
|
||||
export default LobbyScreen;
|
160
client/src/components/ResultsScreen.jsx
Normal file
160
client/src/components/ResultsScreen.jsx
Normal file
@ -0,0 +1,160 @@
|
||||
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';
|
||||
|
||||
const ResultsScreen = () => {
|
||||
const { lobby, currentPlayer, leaveLobby } = useGame();
|
||||
const [showBattleHistory, setShowBattleHistory] = useState(false);
|
||||
const [confetti, setConfetti] = useState(true);
|
||||
|
||||
// Winner information
|
||||
const winner = lobby?.finalWinner;
|
||||
const winnerVideoId = getYouTubeId(winner?.youtubeLink);
|
||||
|
||||
// Confetti effect for winner celebration
|
||||
useEffect(() => {
|
||||
if (confetti) {
|
||||
// Create confetti animation
|
||||
const createConfetti = () => {
|
||||
const confettiContainer = document.createElement('div');
|
||||
confettiContainer.className = 'confetti';
|
||||
|
||||
// Random position 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)];
|
||||
|
||||
confettiContainer.style.left = `${left}%`;
|
||||
confettiContainer.style.backgroundColor = color;
|
||||
|
||||
document.querySelector('.results-screen').appendChild(confettiContainer);
|
||||
|
||||
// Remove after animation
|
||||
setTimeout(() => {
|
||||
confettiContainer.remove();
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
// Create multiple confetti pieces
|
||||
const confettiInterval = setInterval(() => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
createConfetti();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// Stop confetti after some time
|
||||
setTimeout(() => {
|
||||
clearInterval(confettiInterval);
|
||||
setConfetti(false);
|
||||
}, 10000);
|
||||
|
||||
return () => {
|
||||
clearInterval(confettiInterval);
|
||||
};
|
||||
}
|
||||
}, [confetti]);
|
||||
|
||||
// Get YouTube video ID from link
|
||||
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;
|
||||
}
|
||||
|
||||
if (!winner) {
|
||||
return (
|
||||
<div className="results-screen">
|
||||
<h2>Calculating results...</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="results-screen">
|
||||
<header>
|
||||
<h1><FontAwesomeIcon icon={faTrophy} /> Winner!</h1>
|
||||
</header>
|
||||
|
||||
<div className="winner-card">
|
||||
<div className="winner-info">
|
||||
<h2>{winner.title}</h2>
|
||||
<h3>by {winner.artist}</h3>
|
||||
<p className="submitter">Submitted by: {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 ? 'Hide Battle History' : 'Show Battle History'}
|
||||
</button>
|
||||
|
||||
<button className="btn primary" onClick={leaveLobby}>
|
||||
<FontAwesomeIcon icon={faHome} /> Return to Home
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showBattleHistory && (
|
||||
<div className="battle-history">
|
||||
<h3>Battle History</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 = getYouTubeId(battle.song2.youtubeLink);
|
||||
|
||||
return (
|
||||
<div key={index} className="battle-item">
|
||||
<div className="battle-header">
|
||||
<h4>Round {battle.round + 1}, Battle {index + 1}</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} votes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="versus">VS</div>
|
||||
|
||||
<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} votes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResultsScreen;
|
476
client/src/components/SongSubmissionScreen.jsx
Normal file
476
client/src/components/SongSubmissionScreen.jsx
Normal file
@ -0,0 +1,476 @@
|
||||
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';
|
||||
|
||||
const SongSubmissionScreen = () => {
|
||||
const { lobby, currentPlayer, addSong, removeSong, setPlayerReady, searchYouTube } = 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) {
|
||||
// Find current player's songs
|
||||
const player = lobby.players.find(p => p.id === currentPlayer.id);
|
||||
if (player) {
|
||||
setIsReady(player.isReady);
|
||||
}
|
||||
}
|
||||
}, [lobby, currentPlayer]);
|
||||
|
||||
// Get player's songs from the server
|
||||
useEffect(() => {
|
||||
const fetchPlayerSongs = async () => {
|
||||
if (lobby && currentPlayer) {
|
||||
console.log('Fetching songs for player:', currentPlayer);
|
||||
console.log('All players in lobby:', lobby.players.map(p => ({ id: p.id, name: p.name })));
|
||||
|
||||
// Find the current player by their ID, name, or socket ID
|
||||
let player = lobby.players.find(p => p.id === currentPlayer.id);
|
||||
|
||||
// If not found by ID, try by name as fallback
|
||||
if (!player) {
|
||||
player = lobby.players.find(p => p.name === currentPlayer.name);
|
||||
console.log('Player not found by ID, trying by name. Found:', player);
|
||||
}
|
||||
|
||||
// If player found and has songs, update the state
|
||||
if (player) {
|
||||
console.log('Found player:', player);
|
||||
if (player.songs && Array.isArray(player.songs)) {
|
||||
console.log('Found player songs for', player.name, ':', player.songs.length);
|
||||
console.log('Songs data:', player.songs);
|
||||
setSongs(player.songs);
|
||||
} else {
|
||||
console.log('No songs array for player:', player);
|
||||
setSongs([]);
|
||||
}
|
||||
} else {
|
||||
console.error('Player not found in lobby! Current player:', currentPlayer.id);
|
||||
console.log('Available players:', lobby.players.map(p => p.id));
|
||||
setSongs([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchPlayerSongs();
|
||||
// Include lobby in the dependency array to ensure this runs when the lobby state changes
|
||||
}, [lobby, currentPlayer]);
|
||||
|
||||
// Extract video ID from 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;
|
||||
};
|
||||
|
||||
// Debounced search function
|
||||
const handleSearch = async (query) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const results = await searchYouTube(query);
|
||||
setSearchResults(results || []);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = async (e) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === 'searchQuery') {
|
||||
setSearchQuery(value);
|
||||
|
||||
// Check if the input might be a YouTube link
|
||||
const videoId = extractVideoId(value);
|
||||
if (videoId) {
|
||||
setSongForm({ youtubeLink: value });
|
||||
setSelectedVideo({
|
||||
id: videoId,
|
||||
url: value,
|
||||
thumbnail: `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`
|
||||
});
|
||||
setSearchResults([]); // Clear any existing search results
|
||||
} else if (value.trim()) {
|
||||
// Clear any previous timeout
|
||||
if (searchTimeout.current) {
|
||||
clearTimeout(searchTimeout.current);
|
||||
}
|
||||
|
||||
// Set a new timeout to prevent too many API calls
|
||||
searchTimeout.current = setTimeout(() => handleSearch(value), 500);
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
setSelectedVideo(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Function to toggle video preview
|
||||
const togglePreview = (result) => {
|
||||
if (selectedVideo && selectedVideo.id === result.id) {
|
||||
setPreviewVisible(!previewVisible);
|
||||
} else {
|
||||
setSelectedVideo(result);
|
||||
setPreviewVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSearchResult = (result) => {
|
||||
// Make sure we have a complete result with all required fields
|
||||
const completeResult = {
|
||||
id: result.id,
|
||||
url: result.url || `https://www.youtube.com/watch?v=${result.id}`,
|
||||
title: result.title || 'Unknown Title',
|
||||
artist: result.artist || 'Unknown Artist',
|
||||
thumbnail: result.thumbnail || `https://img.youtube.com/vi/${result.id}/mqdefault.jpg`
|
||||
};
|
||||
|
||||
// When a search result is selected, store the YouTube URL
|
||||
setSongForm({
|
||||
youtubeLink: completeResult.url
|
||||
});
|
||||
|
||||
// Store the selected video with all necessary data for submission
|
||||
setSelectedVideo(completeResult);
|
||||
|
||||
// Keep the search results visible but update the query field
|
||||
setSearchQuery(completeResult.title);
|
||||
};
|
||||
|
||||
const handleAddSong = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Use selected video data if available, otherwise fallback to search query or direct input
|
||||
let songData;
|
||||
|
||||
if (selectedVideo) {
|
||||
// We have a selected video with full details - use all available metadata
|
||||
songData = {
|
||||
youtubeLink: selectedVideo.url,
|
||||
title: selectedVideo.title,
|
||||
artist: selectedVideo.artist,
|
||||
thumbnail: selectedVideo.thumbnail,
|
||||
id: `song_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` // Generate a unique ID
|
||||
};
|
||||
|
||||
console.log("Adding song with full metadata:", songData);
|
||||
} else {
|
||||
// Extract YouTube URL from search query or direct input
|
||||
const youtubeLink = searchQuery.trim() || songForm.youtubeLink.trim();
|
||||
if (!youtubeLink) return;
|
||||
|
||||
// Extract video ID to check if it's a valid YouTube link
|
||||
const videoId = extractVideoId(youtubeLink);
|
||||
if (videoId) {
|
||||
// It's a YouTube link, send it to the server for metadata resolution
|
||||
songData = {
|
||||
youtubeLink: youtubeLink,
|
||||
// Include the videoId to make server-side processing easier
|
||||
videoId: videoId,
|
||||
id: `song_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` // Generate a unique ID
|
||||
};
|
||||
|
||||
console.log("Adding song with YouTube link:", songData);
|
||||
} else {
|
||||
// Not a YouTube link - treat as a search query
|
||||
alert("Please enter a valid YouTube link or select a search result");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the song and manually update the local songs array to ensure UI reflects the change
|
||||
addSong(songData);
|
||||
|
||||
// Optimistically add the song to the local state to immediately reflect changes in UI
|
||||
// Note: The server response will ultimately override this if needed
|
||||
setSongs(prevSongs => [...prevSongs, songData]);
|
||||
|
||||
// Reset form
|
||||
setSongForm({ youtubeLink: '' });
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
setSelectedVideo(null);
|
||||
setIsFormVisible(false);
|
||||
};
|
||||
|
||||
const handleRemoveSong = (songId) => {
|
||||
removeSong(songId);
|
||||
};
|
||||
|
||||
const handleSetReady = () => {
|
||||
if (songs.length === lobby.settings.songsPerPlayer) {
|
||||
setPlayerReady();
|
||||
setIsReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if we've submitted enough songs
|
||||
const canSubmitMoreSongs = lobby && songs.length < lobby.settings.songsPerPlayer;
|
||||
|
||||
// Extract YouTube video ID from various YouTube URL formats
|
||||
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;
|
||||
};
|
||||
|
||||
// Get YouTube thumbnail URL from 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>Submit Your Songs</h1>
|
||||
<p className="status">
|
||||
{isReady
|
||||
? 'Waiting for other players...'
|
||||
: `Submit ${lobby?.settings?.songsPerPlayer || 0} songs to battle`}
|
||||
</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} songs</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="add-song-btn"
|
||||
onClick={() => setIsFormVisible(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
<span>Add Song</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isFormVisible && (
|
||||
<form className="song-form" onSubmit={handleAddSong}>
|
||||
<h3>Add a Song</h3>
|
||||
|
||||
<div className="form-group search-group">
|
||||
<label>Find a song</label>
|
||||
<div className="search-container">
|
||||
<input
|
||||
type="text"
|
||||
name="searchQuery"
|
||||
value={searchQuery}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Search YouTube or paste a link..."
|
||||
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>
|
||||
|
||||
{/* Show selected video without embedded player */}
|
||||
{selectedVideo && (
|
||||
<div className="selected-video">
|
||||
<div className="preview-header">
|
||||
<h4>Selected Song</h4>
|
||||
<a
|
||||
href={selectedVideo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="external-link"
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} /> View on YouTube
|
||||
</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 || 'Unknown Title'}</h4>
|
||||
<p>{selectedVideo.artist || 'Unknown Artist'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div className="search-results">
|
||||
<h4>Search Results</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 || 'Unknown Title'}</h4>
|
||||
<p>{result.artist || 'Unknown Artist'}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => {
|
||||
setIsFormVisible(false);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
disabled={!searchQuery.trim() && !songForm.youtubeLink.trim()}
|
||||
>
|
||||
Add Song
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="action-buttons">
|
||||
{!isReady && songs.length === lobby?.settings?.songsPerPlayer && (
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={handleSetReady}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} /> Ready to Battle
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isReady && (
|
||||
<div className="waiting-message">
|
||||
<h3>Ready to Battle!</h3>
|
||||
<p>Waiting for other players to submit their songs...</p>
|
||||
|
||||
<div className="player-status">
|
||||
<h4>Players Ready</h4>
|
||||
<ul className="players-ready-list">
|
||||
{lobby && lobby.players.map(player => (
|
||||
<li key={player.id} className={player.isReady ? 'ready' : 'not-ready'}>
|
||||
{player.name} {player.id === currentPlayer.id && '(You)'}
|
||||
{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;
|
182
client/src/components/VotingScreen.jsx
Normal file
182
client/src/components/VotingScreen.jsx
Normal file
@ -0,0 +1,182 @@
|
||||
// VotingScreen.jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useGame } from '../context/GameContext';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faVoteYea, faTrophy, faMusic } from '@fortawesome/free-solid-svg-icons';
|
||||
import YouTubeEmbed from './YouTubeEmbed';
|
||||
|
||||
function VotingScreen() {
|
||||
const { lobby, currentPlayer, submitVote } = useGame();
|
||||
const [hasVoted, setHasVoted] = useState(false);
|
||||
const [selectedSong, setSelectedSong] = useState(null);
|
||||
const [countdown, setCountdown] = useState(null);
|
||||
|
||||
// Get current battle
|
||||
const battle = lobby?.currentBattle || null;
|
||||
|
||||
// Check if player has already voted
|
||||
useEffect(() => {
|
||||
if (battle && battle.votes && currentPlayer) {
|
||||
// Check if player's ID exists in votes map
|
||||
setHasVoted(battle.votes.has(currentPlayer.id));
|
||||
} else {
|
||||
setHasVoted(false);
|
||||
}
|
||||
}, [battle, currentPlayer]);
|
||||
|
||||
// Handle vote selection
|
||||
const handleVoteSelect = (songId) => {
|
||||
if (hasVoted) return;
|
||||
|
||||
setSelectedSong(songId);
|
||||
};
|
||||
|
||||
// Submit final vote
|
||||
const handleSubmitVote = () => {
|
||||
if (!selectedSong || hasVoted) return;
|
||||
|
||||
submitVote(selectedSong);
|
||||
setHasVoted(true);
|
||||
};
|
||||
|
||||
// Get YouTube video IDs from 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 || !battle.song2) {
|
||||
return (
|
||||
<div className="voting-screen">
|
||||
<h2>Preparing the next battle...</h2>
|
||||
</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={faVoteYea} /> Song Battle!
|
||||
</h1>
|
||||
<div className="round-info">
|
||||
<span>Round {battle.round + 1}</span>
|
||||
{hasVoted && <span className="voted-badge">You voted!</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 className="song-submitter">
|
||||
<small>Submitted by: {battle.song1.submittedByName}</small>
|
||||
</div>
|
||||
</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>No video available</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasVoted && (
|
||||
<div className="vote-count">
|
||||
<span className="vote-number">{battle.song1Votes}</span>
|
||||
<span className="vote-text">votes</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 className="song-submitter">
|
||||
<small>Submitted by: {battle.song2.submittedByName}</small>
|
||||
</div>
|
||||
</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>No video available</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasVoted && (
|
||||
<div className="vote-count">
|
||||
<span className="vote-number">{battle.song2Votes}</span>
|
||||
<span className="vote-text">votes</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSong === battle.song2.id && !hasVoted && (
|
||||
<div className="selection-indicator">
|
||||
<FontAwesomeIcon icon={faCheck} className="check-icon" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasVoted && (
|
||||
<div className="voting-actions">
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={handleSubmitVote}
|
||||
disabled={!selectedSong}
|
||||
>
|
||||
Cast Vote
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="voting-status">
|
||||
<p>{hasVoted ? 'Waiting for other players to vote...' : 'Choose your favorite!'}</p>
|
||||
<div className="votes-count">
|
||||
<span>{battle.voteCount || 0}</span> of <span>{lobby?.players?.filter(p => p.isConnected).length || 0}</span> votes
|
||||
</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;
|
471
client/src/context/GameContext.jsx
Normal file
471
client/src/context/GameContext.jsx
Normal file
@ -0,0 +1,471 @@
|
||||
// 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);
|
||||
|
||||
useEffect(() => {
|
||||
// Create socket connection on component mount
|
||||
const socketInstance = io(import.meta.env.DEV ? 'http://localhost:5237' : window.location.origin, {
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
reconnectionAttempts: 5
|
||||
});
|
||||
|
||||
// Socket event listeners
|
||||
socketInstance.on('connect', () => {
|
||||
setIsConnected(true);
|
||||
console.log('Connected to server');
|
||||
|
||||
// Try to reconnect to game if we have saved data
|
||||
const savedGameData = localStorage.getItem('songBattleGame');
|
||||
if (savedGameData) {
|
||||
try {
|
||||
const { lobbyId, playerName } = JSON.parse(savedGameData);
|
||||
if (lobbyId && playerName) {
|
||||
console.log(`Attempting to reconnect to lobby: ${lobbyId}`);
|
||||
socketInstance.emit('reconnect_to_lobby', { lobbyId, playerName }, (response) => {
|
||||
if (response.error) {
|
||||
console.error('Reconnection failed:', response.error);
|
||||
localStorage.removeItem('songBattleGame');
|
||||
} else {
|
||||
console.log('Successfully reconnected to lobby');
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing saved game data:', e);
|
||||
localStorage.removeItem('songBattleGame');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socketInstance.on('disconnect', () => {
|
||||
setIsConnected(false);
|
||||
console.log('Disconnected from server');
|
||||
});
|
||||
|
||||
socketInstance.on('connect_error', (error) => {
|
||||
console.error('Connection error:', error);
|
||||
});
|
||||
|
||||
setSocket(socketInstance);
|
||||
|
||||
// Clean up socket connection on unmount
|
||||
return () => {
|
||||
socketInstance.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={{ socket, isConnected }}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Game state provider - manages game state and provides methods for game actions
|
||||
*/
|
||||
export function GameProvider({ children }) {
|
||||
const { socket, isConnected } = 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) {
|
||||
localStorage.setItem('songBattleGame', JSON.stringify({
|
||||
lobbyId: lobby.id,
|
||||
playerName: currentPlayer.name
|
||||
}));
|
||||
}
|
||||
}, [lobby, currentPlayer]);
|
||||
|
||||
// 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'
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 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('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('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('game_finished', handleGameFinished);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
// Create a lobby
|
||||
const createLobby = (playerName) => {
|
||||
if (!socket || !isConnected) {
|
||||
setError('Not connected to server');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('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;
|
||||
}
|
||||
|
||||
socket.emit('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;
|
||||
}
|
||||
|
||||
socket.emit('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;
|
||||
}
|
||||
|
||||
socket.emit('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;
|
||||
}
|
||||
|
||||
console.log('Attempting to add song:', song);
|
||||
console.log('Current player state:', currentPlayer);
|
||||
console.log('Current lobby state before adding song:', lobby);
|
||||
|
||||
socket.emit('add_song', { song }, (response) => {
|
||||
console.log('Song addition response:', response);
|
||||
if (response.error) {
|
||||
console.error('Error adding song:', response.error);
|
||||
setError(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);
|
||||
} else {
|
||||
console.error('Song addition succeeded but no lobby data was returned');
|
||||
setError('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;
|
||||
}
|
||||
|
||||
socket.emit('search_youtube', { query }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
reject(response.error);
|
||||
} else {
|
||||
resolve(response.results);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Remove a song
|
||||
const removeSong = (songId) => {
|
||||
if (!socket || !isConnected || !lobby) {
|
||||
setError('Not connected to server or no active lobby');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('remove_song', { songId }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Set player ready
|
||||
const setPlayerReady = () => {
|
||||
if (!socket || !isConnected || !lobby) {
|
||||
setError('Not connected to server or no active lobby');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('player_ready', {}, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Submit a vote
|
||||
const submitVote = (songId) => {
|
||||
if (!socket || !isConnected || !lobby) {
|
||||
setError('Not connected to server or no active lobby');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('submit_vote', { songId }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Leave lobby and clear saved game state
|
||||
const leaveLobby = () => {
|
||||
localStorage.removeItem('songBattleGame');
|
||||
setLobby(null);
|
||||
setCurrentPlayer(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<GameContext.Provider value={{
|
||||
lobby,
|
||||
error,
|
||||
currentPlayer,
|
||||
isHost: currentPlayer && lobby && currentPlayer.id === lobby.hostId,
|
||||
createLobby,
|
||||
joinLobby,
|
||||
updateSettings,
|
||||
startGame,
|
||||
addSong,
|
||||
removeSong,
|
||||
setPlayerReady,
|
||||
submitVote,
|
||||
leaveLobby,
|
||||
searchYouTube
|
||||
}}>
|
||||
{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 };
|
@ -2,14 +2,17 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import "@fontsource/bangers";
|
||||
import "@/common/styles/main.sass";
|
||||
import "./common/styles/main.sass";
|
||||
import { SocketProvider, GameProvider } from "./context/GameContext.jsx";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
|
||||
<div className="app">
|
||||
<App/>
|
||||
</div>
|
||||
|
||||
<SocketProvider>
|
||||
<GameProvider>
|
||||
<div className="app">
|
||||
<App/>
|
||||
</div>
|
||||
</GameProvider>
|
||||
</SocketProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
Reference in New Issue
Block a user