Implement main game mechanic
This commit is contained in:
117
client/src/common/contexts/SocketContext.jsx
Normal file
117
client/src/common/contexts/SocketContext.jsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { createContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
export const SocketContext = createContext();
|
||||
|
||||
export const SocketProvider = ({ children }) => {
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const connectAttempts = useRef(0);
|
||||
const maxAttempts = 3;
|
||||
const reconnectDelay = 2000; // ms
|
||||
|
||||
// Connect to socket server
|
||||
const connect = useCallback(() => {
|
||||
if (socket) return;
|
||||
|
||||
// Determine server URL based on environment
|
||||
const serverUrl = process.env.NODE_ENV === 'production'
|
||||
? window.location.origin
|
||||
: 'http://localhost:5287';
|
||||
|
||||
try {
|
||||
const newSocket = io(serverUrl, {
|
||||
reconnectionAttempts: 3,
|
||||
timeout: 10000,
|
||||
reconnectionDelay: 1000
|
||||
});
|
||||
|
||||
newSocket.on('connect', () => {
|
||||
console.log('Socket connected with ID:', newSocket.id);
|
||||
setConnected(true);
|
||||
connectAttempts.current = 0;
|
||||
|
||||
// Store player ID in localStorage for persistence
|
||||
localStorage.setItem('playerId', newSocket.id);
|
||||
});
|
||||
|
||||
newSocket.on('disconnect', (reason) => {
|
||||
console.log('Socket disconnected:', reason);
|
||||
setConnected(false);
|
||||
});
|
||||
|
||||
newSocket.on('connect_error', (error) => {
|
||||
console.error('Connection error:', error);
|
||||
connectAttempts.current += 1;
|
||||
|
||||
if (connectAttempts.current >= maxAttempts) {
|
||||
console.error('Max connection attempts reached, giving up');
|
||||
newSocket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
setSocket(newSocket);
|
||||
} catch (err) {
|
||||
console.error('Error creating socket connection:', err);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
// Disconnect socket
|
||||
const disconnect = useCallback(() => {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
setSocket(null);
|
||||
setConnected(false);
|
||||
console.log('Socket manually disconnected');
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
// Send event through socket
|
||||
const send = useCallback((event, data) => {
|
||||
if (!socket || !connected) {
|
||||
console.warn(`Cannot send event "${event}": socket not connected`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
socket.emit(event, data);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Error sending event "${event}":`, err);
|
||||
return false;
|
||||
}
|
||||
}, [socket, connected]);
|
||||
|
||||
// Register event listener
|
||||
const on = useCallback((event, callback) => {
|
||||
if (!socket) {
|
||||
console.warn(`Cannot listen for event "${event}": socket not initialized`);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
socket.on(event, callback);
|
||||
return () => socket.off(event, callback);
|
||||
}, [socket]);
|
||||
|
||||
// Clean up socket on component unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
}
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={{
|
||||
socket,
|
||||
connected,
|
||||
connect,
|
||||
disconnect,
|
||||
send,
|
||||
on
|
||||
}}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
};
|
@ -1,19 +1,144 @@
|
||||
import "./styles.sass";
|
||||
import {StateContext} from "@/common/contexts/StateContext";
|
||||
import {SocketContext} from "@/common/contexts/SocketContext";
|
||||
import {useContext, useState, useEffect, useRef} from "react";
|
||||
import MusicSlider from "@/pages/Game/components/MusicSlider";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faMessage} from "@fortawesome/free-solid-svg-icons";
|
||||
import {faMessage, faMusic, faHeadphones, faClock, faCrown} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export const Game = () => {
|
||||
const {setCurrentState} = useContext(StateContext);
|
||||
const {send, on, socket} = useContext(SocketContext);
|
||||
|
||||
const [role, setRole] = useState(null); // 'composer' or 'guesser'
|
||||
const [round, setRound] = useState(1);
|
||||
const [phase, setPhase] = useState("waiting"); // waiting, composing, guessing, results
|
||||
const [timeLeft, setTimeLeft] = useState(30);
|
||||
const [frequency, setFrequency] = useState(440);
|
||||
const [currentSong, setCurrentSong] = useState(null);
|
||||
const [songOptions, setSongOptions] = useState([]);
|
||||
const [scores, setScores] = useState({});
|
||||
const [selectedSong, setSelectedSong] = useState(null);
|
||||
const [guessResult, setGuessResult] = useState(null);
|
||||
const [isHost, setIsHost] = useState(false);
|
||||
|
||||
// Chat state
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [connectedUsers, setConnectedUsers] = useState([]);
|
||||
const [username, setUsername] = useState("");
|
||||
const messageEndRef = useRef(null);
|
||||
const timerIntervalRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPhase("waiting");
|
||||
|
||||
const handleRolesAssigned = (roles) => {
|
||||
console.log("Roles assigned:", roles);
|
||||
const myRole = roles[socket?.id];
|
||||
|
||||
if (myRole) {
|
||||
setRole(myRole);
|
||||
|
||||
setMessages(prev => [...prev, {
|
||||
system: true,
|
||||
text: myRole === "composer"
|
||||
? "Du bist der Komponist! Spiele den Song mit dem Tonregler."
|
||||
: "Du bist ein Rater! Höre die Frequenzen und versuche, den Song zu erkennen."
|
||||
}]);
|
||||
} else {
|
||||
console.error("No role assigned to this player!");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSongSelected = (song) => {
|
||||
console.log("Song selected:", song);
|
||||
setCurrentSong(song);
|
||||
};
|
||||
|
||||
const handleRoundStarted = (data) => {
|
||||
console.log("Round started:", data);
|
||||
setRound(data.round);
|
||||
setPhase("composing");
|
||||
setTimeLeft(data.timeRemaining);
|
||||
};
|
||||
|
||||
const handleGuessingPhaseStarted = (data) => {
|
||||
console.log("Guessing phase started:", data);
|
||||
setPhase("guessing");
|
||||
setTimeLeft(data.timeRemaining);
|
||||
setSongOptions(data.songOptions);
|
||||
};
|
||||
|
||||
const handleGuessResult = (result) => {
|
||||
console.log("Guess result:", result);
|
||||
setGuessResult(result.isCorrect);
|
||||
setCurrentSong(result.correctSong);
|
||||
};
|
||||
|
||||
const handleRoundResults = (results) => {
|
||||
console.log("Round results:", results);
|
||||
setPhase("results");
|
||||
setScores(results.scores);
|
||||
if (!currentSong) {
|
||||
setCurrentSong(results.selectedSong);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoomUsers = (users) => {
|
||||
console.log("Room users:", users);
|
||||
setConnectedUsers(users);
|
||||
|
||||
const currentUser = users.find(u => u.id === socket?.id);
|
||||
if (currentUser) {
|
||||
setUsername(currentUser.name);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHostStatus = (status) => {
|
||||
setIsHost(status.isHost);
|
||||
};
|
||||
|
||||
const cleanupRolesAssigned = on("roles-assigned", handleRolesAssigned);
|
||||
const cleanupSongSelected = on("song-selected", handleSongSelected);
|
||||
const cleanupRoundStarted = on("round-started", handleRoundStarted);
|
||||
const cleanupGuessingPhaseStarted = on("guessing-phase-started", handleGuessingPhaseStarted);
|
||||
const cleanupGuessResult = on("guess-result", handleGuessResult);
|
||||
const cleanupRoundResults = on("round-results", handleRoundResults);
|
||||
const cleanupRoomUsers = on("room-users-update", handleRoomUsers);
|
||||
const cleanupHostStatus = on("host-status", handleHostStatus);
|
||||
|
||||
send("get-room-users");
|
||||
|
||||
send("check-host-status");
|
||||
|
||||
return () => {
|
||||
cleanupRolesAssigned();
|
||||
cleanupSongSelected();
|
||||
cleanupRoundStarted();
|
||||
cleanupGuessingPhaseStarted();
|
||||
cleanupGuessResult();
|
||||
cleanupRoundResults();
|
||||
cleanupRoomUsers();
|
||||
cleanupHostStatus();
|
||||
};
|
||||
}, [socket, on, send]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timerIntervalRef.current) {
|
||||
clearInterval(timerIntervalRef.current);
|
||||
}
|
||||
|
||||
if (phase !== "waiting" && phase !== "results") {
|
||||
timerIntervalRef.current = setInterval(() => {
|
||||
setTimeLeft(prev => Math.max(0, prev - 1));
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerIntervalRef.current) {
|
||||
clearInterval(timerIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [phase]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleChatMessage = (messageData) => {
|
||||
@ -41,31 +166,36 @@ export const Game = () => {
|
||||
});
|
||||
};
|
||||
|
||||
if (socket && socket.id) {
|
||||
send("get-user-info");
|
||||
}
|
||||
|
||||
const handleUserInfo = (userInfo) => {
|
||||
setUsername(userInfo.name);
|
||||
const handleFrequencyUpdate = (data) => {
|
||||
if (role === "guesser") {
|
||||
setFrequency(data.frequency);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const cleanupChatMessage = on("chat-message", handleChatMessage);
|
||||
const cleanupUserConnected = on("user-connected", handleUserConnected);
|
||||
const cleanupUserDisconnected = on("user-disconnected", handleUserDisconnected);
|
||||
const cleanupUserInfo = on("user-info", handleUserInfo);
|
||||
const cleanupFrequencyUpdate = on("frequency-update", handleFrequencyUpdate);
|
||||
|
||||
return () => {
|
||||
cleanupChatMessage();
|
||||
cleanupUserConnected();
|
||||
cleanupUserDisconnected();
|
||||
cleanupUserInfo();
|
||||
cleanupFrequencyUpdate();
|
||||
};
|
||||
}, [on, send, socket]);
|
||||
}, [on, role]);
|
||||
|
||||
useEffect(() => {
|
||||
messageEndRef.current?.scrollIntoView({behavior: "smooth"});
|
||||
}, [messages]);
|
||||
|
||||
const handleFrequencyChange = (newFrequency) => {
|
||||
setFrequency(newFrequency);
|
||||
if (role === "composer") {
|
||||
send("submit-frequency", { frequency: newFrequency });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = () => {
|
||||
if (inputValue.trim()) {
|
||||
const messageData = {
|
||||
@ -77,6 +207,173 @@ export const Game = () => {
|
||||
setInputValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSongSelect = (song) => {
|
||||
setSelectedSong(song);
|
||||
};
|
||||
|
||||
const handleNextRound = () => {
|
||||
send("next-round");
|
||||
|
||||
setSelectedSong(null);
|
||||
setGuessResult(null);
|
||||
setTimeLeft(0);
|
||||
};
|
||||
|
||||
const renderPhaseContent = () => {
|
||||
switch (phase) {
|
||||
case "waiting":
|
||||
return (
|
||||
<div className="waiting-phase">
|
||||
<h3>Warten auf Spielstart...</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "composing":
|
||||
return (
|
||||
<div className="composing-phase">
|
||||
<div className="phase-header">
|
||||
<h3>Runde {round}: {role === "composer" ? "Spielen" : "Zuhören"}</h3>
|
||||
<div className="timer">
|
||||
<FontAwesomeIcon icon={faClock} /> {timeLeft}s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{role === "composer" && currentSong && (
|
||||
<div className="song-display">
|
||||
<div className="song-card">
|
||||
<img src={currentSong.coverUrl} alt={currentSong.title} />
|
||||
<div className="song-info">
|
||||
<div className="song-names">{currentSong.title}</div>
|
||||
<div className="song-description">von {currentSong.artist}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="instruction">Spiele diesen Song mit dem Tonregler!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{role === "guesser" && (
|
||||
<div className="listening-display">
|
||||
<div className="listening-icon">
|
||||
<FontAwesomeIcon icon={faHeadphones} size="4x" />
|
||||
</div>
|
||||
<p className="instruction">Höre genau zu und versuche, den Song zu erkennen!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "guessing":
|
||||
return (
|
||||
<div className="guessing-phase">
|
||||
<div className="phase-header">
|
||||
<h3>Runde {round}: Song erraten</h3>
|
||||
<div className="timer">
|
||||
<FontAwesomeIcon icon={faClock} /> {timeLeft}s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{role === "composer" && (
|
||||
<div className="waiting-for-guessers">
|
||||
<p>Die Rater versuchen nun, deinen Song zu erraten...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{role === "guesser" && (
|
||||
<div className="song-options">
|
||||
<p className="instruction">Welchen Song hat der Komponist gespielt?</p>
|
||||
<div className="song-grid">
|
||||
{songOptions.map(song => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`song-option ${selectedSong?.id === song.id ? 'selected' : ''}`}
|
||||
onClick={() => handleSongSelect(song)}
|
||||
>
|
||||
<img src={song.coverUrl} alt={song.title} />
|
||||
<div className="song-details">
|
||||
<div className="song-title">{song.title}</div>
|
||||
<div className="song-artist">{song.artist}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "results":
|
||||
return (
|
||||
<div className="results-phase">
|
||||
<h3>Runde {round}: Ergebnisse</h3>
|
||||
|
||||
<div className="round-results">
|
||||
{role === "composer" && (
|
||||
<div className="composer-results">
|
||||
<p>Die Rater haben versucht, deinen Song zu erraten.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{role === "guesser" && (
|
||||
<div className="guesser-results">
|
||||
{currentSong && (
|
||||
<div className="correct-song">
|
||||
<p>Der richtige Song war:</p>
|
||||
<div className="song-card highlight">
|
||||
<img src={currentSong.coverUrl} alt={currentSong.title} />
|
||||
<div className="song-info">
|
||||
<div className="song-names">{currentSong.title}</div>
|
||||
<div className="song-description">von {currentSong.artist}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{guessResult !== null && (
|
||||
<div className={`guess-result ${guessResult ? 'correct' : 'incorrect'}`}>
|
||||
{guessResult
|
||||
? 'Richtig! Du erhälst 10 Punkte.'
|
||||
: 'Leider falsch. Kein Punkt.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="scoreboard">
|
||||
<h4>Punktestand</h4>
|
||||
<div className="scores">
|
||||
{Object.entries(scores).map(([userId, score]) => {
|
||||
const user = connectedUsers.find(u => u.id === userId) || { name: 'Unbekannt' };
|
||||
return (
|
||||
<div key={userId} className="score-entry">
|
||||
<span className="player-name">
|
||||
{user.id === (socket?.id || "you") && "👉 "}
|
||||
{user.name}
|
||||
{userId === connectedUsers.find(u => u.id === "user1")?.id && (
|
||||
<FontAwesomeIcon icon={faCrown} className="host-icon" />
|
||||
)}
|
||||
</span>
|
||||
<span className="player-score">{score}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isHost && (
|
||||
<button className="next-round-button" onClick={handleNextRound}>
|
||||
Nächste Runde
|
||||
</button>
|
||||
)}
|
||||
{!isHost && <p className="waiting-message">Warten auf Rundenwechsel durch Host...</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div>Unknown phase</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="game-page">
|
||||
@ -84,17 +381,18 @@ export const Game = () => {
|
||||
<div className="rotating-gradient"></div>
|
||||
</div>
|
||||
|
||||
<div className="main-content">
|
||||
<div className="song-display">
|
||||
<h2>ToneGuessr</h2>
|
||||
<div className="song-card">
|
||||
<img src="https://mir-s3-cdn-cf.behance.net/project_modules/1400/fe529a64193929.5aca8500ba9ab.jpg" alt="Song"/>
|
||||
<div className="song-info">
|
||||
<div className="song-names">Black Steam</div>
|
||||
<div className="song-description">von Carrot Quest GmbH</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="game-header">
|
||||
<div className="game-role">
|
||||
<FontAwesomeIcon icon={role === "composer" ? faMusic : faHeadphones} />
|
||||
<span>{role === "composer" ? "Komponist" : "Rater"}</span>
|
||||
</div>
|
||||
<h2>ToneGuessr</h2>
|
||||
<div className="game-round">Runde {round}</div>
|
||||
</div>
|
||||
|
||||
<div className="main-content">
|
||||
{renderPhaseContent()}
|
||||
|
||||
<div className="chat-window">
|
||||
<div className="chat-header">
|
||||
<FontAwesomeIcon icon={faMessage} />
|
||||
@ -125,7 +423,12 @@ export const Game = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MusicSlider/>
|
||||
|
||||
<MusicSlider
|
||||
isReadOnly={role !== "composer" || phase !== "composing"}
|
||||
onFrequencyChange={handleFrequencyChange}
|
||||
frequency={frequency}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import "./styles.sass";
|
||||
|
||||
export const MusicSlider = () => {
|
||||
const [frequency, setFrequency] = useState(440);
|
||||
export const MusicSlider = ({ isReadOnly = false, onFrequencyChange, frequency: externalFrequency }) => {
|
||||
const [frequency, setFrequency] = useState(externalFrequency || 440);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const audioContextRef = useRef(null);
|
||||
@ -10,7 +10,19 @@ export const MusicSlider = () => {
|
||||
const gainNodeRef = useRef(null);
|
||||
const sliderRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalFrequency !== undefined && !dragging) {
|
||||
setFrequency(externalFrequency);
|
||||
|
||||
if (!isPlaying && !isReadOnly) {
|
||||
startAudio();
|
||||
}
|
||||
}
|
||||
}, [externalFrequency, dragging, isPlaying, isReadOnly]);
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
if (isReadOnly) return;
|
||||
|
||||
setDragging(true);
|
||||
startAudio();
|
||||
handleFrequencyChange(e);
|
||||
@ -18,15 +30,23 @@ export const MusicSlider = () => {
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDragging(false);
|
||||
stopAudio();
|
||||
if (!isReadOnly) {
|
||||
stopAudio();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFrequencyChange = (e) => {
|
||||
if (isReadOnly) return;
|
||||
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const clampedX = Math.max(0, Math.min(x, rect.width));
|
||||
const newFrequency = 20 + (clampedX / rect.width) * 1980;
|
||||
setFrequency(newFrequency);
|
||||
|
||||
if (onFrequencyChange) {
|
||||
onFrequencyChange(newFrequency);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -80,9 +100,25 @@ export const MusicSlider = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (oscillatorRef.current) {
|
||||
oscillatorRef.current.stop();
|
||||
oscillatorRef.current.disconnect();
|
||||
}
|
||||
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
||||
audioContextRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="otamatone-container">
|
||||
<div className="otamatone" onMouseDown={handleMouseDown}>
|
||||
<div className={`otamatone-container ${isReadOnly ? 'read-only' : ''}`}>
|
||||
<div
|
||||
className="otamatone"
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isReadOnly ? 'default' : 'pointer' }}
|
||||
>
|
||||
<div className="otamatone-face">
|
||||
<div className="otamatone-mouth" style={{
|
||||
height: `${10 + (frequency / 2000) * 40}px`,
|
||||
|
@ -281,6 +281,86 @@
|
||||
opacity: 1
|
||||
animation: rotate-background 5s linear infinite
|
||||
|
||||
.game-header
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
width: 100%
|
||||
padding: 10px 20px
|
||||
margin-bottom: 20px
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2)
|
||||
|
||||
.game-role
|
||||
display: flex
|
||||
align-items: center
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
padding: 8px 15px
|
||||
border-radius: 20px
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
|
||||
svg
|
||||
margin-right: 8px
|
||||
color: $yellow
|
||||
|
||||
.game-round
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
padding: 8px 15px
|
||||
border-radius: 20px
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
|
||||
.phase-header
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
width: 100%
|
||||
margin-bottom: 20px
|
||||
|
||||
.timer
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
padding: 10px 15px
|
||||
border-radius: 20px
|
||||
font-size: 18px
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
svg
|
||||
margin-right: 8px
|
||||
color: $yellow
|
||||
|
||||
.song-grid
|
||||
display: grid
|
||||
grid-template-columns: repeat(3, 1fr)
|
||||
gap: 20px
|
||||
margin-top: 20px
|
||||
|
||||
.song-option
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
border-radius: 15px
|
||||
overflow: hidden
|
||||
cursor: pointer
|
||||
transition: all 0.3s ease
|
||||
border: 2px solid transparent
|
||||
|
||||
&:hover
|
||||
transform: translateY(-5px)
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3)
|
||||
|
||||
&.selected
|
||||
border: 2px solid $yellow
|
||||
box-shadow: 0 0 20px rgba(255, 255, 0, 0.5)
|
||||
|
||||
img
|
||||
width: 100%
|
||||
height: 120px
|
||||
object-fit: cover
|
||||
|
||||
.song-details
|
||||
padding: 10px
|
||||
|
||||
.song-title
|
||||
font-weight: bold
|
||||
font-size: 16px
|
||||
|
||||
@keyframes subtle-text-glow
|
||||
0%, 100%
|
||||
text-shadow: 0 0 10px rgba(255, 255, 255, 0.4)
|
||||
|
Reference in New Issue
Block a user