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

This commit is contained in:
2025-04-24 16:21:43 +02:00
parent 44a75ba715
commit 22eca7d4e0
28 changed files with 5046 additions and 402 deletions

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -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

View File

@ -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>
</>
)
}

View 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)

View File

@ -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

View 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

View 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)

View 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

View 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

View 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

View 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

View File

@ -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

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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 };

View File

@ -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>,
);