diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index fdb6990..7d9dc0e 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -32,6 +32,7 @@ export default [ ...react.configs['jsx-runtime'].rules, ...reactHooks.configs.recommended.rules, 'react/jsx-no-target-blank': 'off', + 'react/prop-types': 'off', 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 'prettier/prettier': 'error', }, diff --git a/frontend/index.html b/frontend/index.html index 7d12e88..5f5a664 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,14 +5,14 @@ diff --git a/frontend/package.json b/frontend/package.json index bab7a58..a40bd7e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "lint:fix": "eslint . --fix && prettier --write .", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b8f9c08..794e777 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,7 @@ -import Deck from "./Components/Deck"; -import Home from "./pages/Home"; -import Error from "./pages/Error"; -import { BrowserRouter, Routes, Route } from "react-router-dom"; +import Deck from './Components/Deck'; +import Home from './pages/Home'; +import Error from './pages/Error'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; function App() { return ( @@ -14,4 +14,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/Components/ClueInput.jsx b/frontend/src/Components/ClueInput.jsx index 35d0efe..a9f6018 100644 --- a/frontend/src/Components/ClueInput.jsx +++ b/frontend/src/Components/ClueInput.jsx @@ -1,5 +1,5 @@ // ClueInput.jsx -import React, { useState,useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import socket from '../socket'; @@ -20,7 +20,10 @@ const ClueInput = ({ onClueSubmit }) => { // read currentTurn from the Redux store so we can restrict Concealer input to the active team const currentTurn = useSelector((state) => state.game?.currentTurn ?? 'red'); - const numbers = Array.from({ length: 10 }, (_, i) => ({ value: `${i + 1}`, label: `${i + 1}` })).concat({ value: 'infinity', label: 'โˆž' }); + const numbers = Array.from({ length: 10 }, (_, i) => ({ value: `${i + 1}`, label: `${i + 1}` })).concat({ + value: 'infinity', + label: 'โˆž', + }); useEffect(() => { // If this client is a Revealer and there's a persisted clue, show it after reload/join @@ -42,7 +45,7 @@ const ClueInput = ({ onClueSubmit }) => { console.log('๐Ÿ“ฌ clueReceived:', clueData); setClueWord(clueData.word); setClueNumber(clueData.number); - setIsSubmitted(true); + setIsSubmitted(true); setCardsRevealed(0); // Reset counter for new turn }; socket.on('clueReceived', onClueReceived); @@ -55,7 +58,7 @@ const ClueInput = ({ onClueSubmit }) => { const normalizedTurn = String(currentTurn || '').toLowerCase(); const isRoleConcealer = normalizedRole.startsWith('conceal') || normalizedRole === 'spymaster'; if (isRoleConcealer && normalizedTeam && normalizedTeam === normalizedTurn) { - setIsSubmitted(false); + setIsSubmitted(false); setClueWord(''); setClueNumber('1'); setCardsRevealed(0); @@ -70,7 +73,7 @@ const ClueInput = ({ onClueSubmit }) => { const normalizedTurn = String(currentTurn || '').toLowerCase(); const isRoleConcealer = normalizedRole.startsWith('conceal') || normalizedRole === 'spymaster'; if (isRoleConcealer && normalizedTeam && normalizedTeam === normalizedTurn) { - setIsSubmitted(false); + setIsSubmitted(false); setClueWord(''); setClueNumber('1'); setCardsRevealed(0); @@ -89,15 +92,15 @@ const ClueInput = ({ onClueSubmit }) => { const onCardRevealed = ({ cardsRevealedThisTurn }) => { console.log('๐ŸŽฏ Card revealed, count:', cardsRevealedThisTurn); setCardsRevealed(cardsRevealedThisTurn || 0); - + // Check if we've reached the clue number limit if (cardsRevealedThisTurn && clueNumber !== 'infinity' && cardsRevealedThisTurn >= parseInt(clueNumber)) { console.log(`โœ… Clue limit reached! ${cardsRevealedThisTurn} cards revealed, switching turn...`); - setIsSubmitted(false); + setIsSubmitted(false); setClueWord(''); setClueNumber('1'); setCardsRevealed(0); - + // Emit switchTurn event socket.emit('switchTurn', { gameId }); } @@ -128,60 +131,65 @@ const ClueInput = ({ onClueSubmit }) => { const isRoleConcealer = normalizedRole.startsWith('conceal') || normalizedRole === 'spymaster'; const isConcealers = isRoleConcealer && normalizedTeam && normalizedTeam === normalizedTurn; - useEffect(()=>{ - console.log("ClueWord:",clueWord); - },[]) + useEffect(() => { + console.log('ClueWord:', clueWord); + }, []); return (
- {!isSubmitted && isConcealers ? ( -
-
- setClueWord(e.target.value)} - placeholder="Enter one-word clue" - className="flex-grow p-3 rounded-xl border dark:border-gray-700 bg-input dark:bg-gray-800 text-foreground dark:text-white focus:ring-2 focus:ring-primary max-w-[400px]" - /> - - -
+ {!isSubmitted && isConcealers ? ( +
+
+ setClueWord(e.target.value)} + placeholder="Enter one-word clue" + className="flex-grow p-3 rounded-xl border dark:border-gray-700 bg-input dark:bg-gray-800 text-foreground dark:text-white focus:ring-2 focus:ring-primary max-w-[400px]" + /> + + +
- ) : ( - clueWord ? ( + ) : clueWord ? (
- {clueWord}{" "} + {clueWord}{' '} ({clueNumber === 'infinity' ? 'โˆž' : clueNumber})
{cardsRevealed} / {clueNumber === 'infinity' ? 'โˆž' : clueNumber} cards revealed
- - ) : ( - <> - ) + ) : ( + <> )} -
+
); }; diff --git a/frontend/src/Components/Deck.jsx b/frontend/src/Components/Deck.jsx index afa87b3..f2f19c9 100644 --- a/frontend/src/Components/Deck.jsx +++ b/frontend/src/Components/Deck.jsx @@ -1,21 +1,28 @@ -import { useEffect, useState,useRef } from "react"; -import { useSelector, useDispatch } from "react-redux"; -import { clickCard, setPendingReveal, revealLocal, resetAll, updateCardClickedBy } from "../store/slices/cardsSlice"; -import { showOverlay, hideOverlay, showClueDisplay, hideClueDisplay, toggleConfirmTarget, clearConfirmTargets } from "../store/slices/uiSlice"; -import { updatePlayers } from "../store/slices/playersSlice"; -import { setCurrentTurn } from "../store/slices/gameSlice"; -import socket from "../socket"; -import { DeckCard } from "./DeckCard"; -import ThemeToggle from "./ThemeToggle"; -import Teams from "./Teams"; -import ClueInput from "./ClueInput"; -import TurnOverlay from "./TurnOverlay"; -import TurnBadge from "./TurnBadge"; -import { useParams } from "react-router-dom"; -import axios from "axios"; +import { useEffect, useState, useRef } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { clickCard, setPendingReveal, revealLocal, resetAll, updateCardClickedBy } from '../store/slices/cardsSlice'; +import { + showOverlay, + hideOverlay, + showClueDisplay, + hideClueDisplay, + toggleConfirmTarget, + clearConfirmTargets, +} from '../store/slices/uiSlice'; +import { updatePlayers } from '../store/slices/playersSlice'; +import { setCurrentTurn } from '../store/slices/gameSlice'; +import socket from '../socket'; +import { DeckCard } from './DeckCard'; +import ThemeToggle from './ThemeToggle'; +import Teams from './Teams'; +import ClueInput from './ClueInput'; +import TurnOverlay from './TurnOverlay'; +import TurnBadge from './TurnBadge'; +import { useParams } from 'react-router-dom'; +import axios from 'axios'; import API_URL from '../apiConfig'; -import { setCards } from "../store/slices/cardsSlice"; -import { updateScores } from "../store/slices/scoreSlice"; +import { setCards } from '../store/slices/cardsSlice'; +import { updateScores } from '../store/slices/scoreSlice'; const ANIMATION_DURATION = 600; // ms - match CSS animation length const Deck = () => { @@ -25,10 +32,10 @@ const Deck = () => { const overlayActive = useSelector((state) => state.ui?.overlayActive ?? false); const lastClue = useSelector((state) => state.ui?.lastClue ?? null); const confirmTargetIds = useSelector((state) => state.ui?.confirmTargetIds ?? []); - const currentTurn = useSelector((state) => state.game?.currentTurn ?? "red"); + const currentTurn = useSelector((state) => state.game?.currentTurn ?? 'red'); const scores = useSelector((state) => state.scores ?? { red: 9, blue: 8 }); - const [joinedTeam, setJoinedTeam] = useState(""); - const [joinedTitle, setJoinedTitle] = useState(""); + const [joinedTeam, setJoinedTeam] = useState(''); + const [joinedTitle, setJoinedTitle] = useState(''); const [finalWinner, setFinalWinner] = useState(null); const needsSpectatorUpdate = useRef(false); const hasJoined = useRef(false); @@ -39,7 +46,7 @@ const Deck = () => { const designHeight = 750; const wrapperRef = useRef(null); const [scale, setScale] = useState(1); - const handleTeamData=(team,title)=>{ + const handleTeamData = (team, title) => { setJoinedTeam(team); setJoinedTitle(title); // Store in localStorage so ClueInput can access it @@ -49,35 +56,34 @@ const Deck = () => { const nickname = localStorage.getItem('nickname') || 'Anonymous'; console.log('โžก๏ธ Emitting joinTeam', { gameId, nickname, team, role: title }); socket.emit('joinTeam', { gameId, nickname, team, role: title }); - } - -useEffect(() => { - // If the page is refreshed and the stored role was "Concealers", - // clear the stored role and mark that we need to tell the server - // this player should now be a spectator (so the players list updates). - const prevTitle = localStorage.getItem('joinedTitle'); - if (prevTitle === 'Concealers') { - console.log("๐Ÿงน Detected stale 'Concealers' on refresh โ€” will update server to spectator after join"); - needsSpectatorUpdate.current = true; - } - - // Clear persisted join info (prevents concealer UI bleed-through) - localStorage.removeItem('joinedTitle'); - localStorage.removeItem('joinedTeam'); - setJoinedTitle(''); - setJoinedTeam(''); - - socket.on("connect", () => { - console.log("๐ŸŸข Connected with socket ID:", socket.id); - localStorage.setItem("socketId", socket.id); - }); - - return () => { - // cleanup - socket.off("connect"); }; -}, []); + useEffect(() => { + // If the page is refreshed and the stored role was "Concealers", + // clear the stored role and mark that we need to tell the server + // this player should now be a spectator (so the players list updates). + const prevTitle = localStorage.getItem('joinedTitle'); + if (prevTitle === 'Concealers') { + console.log("๐Ÿงน Detected stale 'Concealers' on refresh โ€” will update server to spectator after join"); + needsSpectatorUpdate.current = true; + } + + // Clear persisted join info (prevents concealer UI bleed-through) + localStorage.removeItem('joinedTitle'); + localStorage.removeItem('joinedTeam'); + setJoinedTitle(''); + setJoinedTeam(''); + + socket.on('connect', () => { + console.log('๐ŸŸข Connected with socket ID:', socket.id); + localStorage.setItem('socketId', socket.id); + }); + + return () => { + // cleanup + socket.off('connect'); + }; + }, []); useEffect(() => { // detect score hitting zero -> trigger win overlay, reveal all cards and show winner @@ -110,14 +116,13 @@ useEffect(() => { setFinalWinner(winner); }, WIN_OVERLAY_MS); } - prevScoresRef.current = now; }, [scores, cards, dispatch]); useEffect(() => { - socket.on("receiveMessage", (data) => console.log("Received live message:", data)); - socket.on("clueReceived", (clueData) => { + socket.on('receiveMessage', (data) => console.log('Received live message:', data)); + socket.on('clueReceived', (clueData) => { console.log('๐ŸŽค Deck received clueReceived:', clueData); // For Revealers, show persistent display // For others, show brief overlay @@ -129,39 +134,41 @@ useEffect(() => { } }); return () => { - socket.off("receiveMessage"); - socket.off("clueReceived"); + socket.off('receiveMessage'); + socket.off('clueReceived'); }; }, [dispatch]); -useEffect(() => { - async function fetchScores() { - try { - const res = await axios.get(`${API_URL}/api/score_and_turn/${gameId}`); - dispatch(updateScores({ - red: res.data.redScore, - blue: res.data.blueScore, - })); - } catch (err) { - console.error("Failed to fetch scores", err); + useEffect(() => { + async function fetchScores() { + try { + const res = await axios.get(`${API_URL}/api/score_and_turn/${gameId}`); + dispatch( + updateScores({ + red: res.data.redScore, + blue: res.data.blueScore, + }) + ); + } catch (err) { + console.error('Failed to fetch scores', err); + } } - } - fetchScores(); -}, []); + fetchScores(); + }, []); const handleClueSubmit = (clueData) => { dispatch(showOverlay(clueData)); setTimeout(() => dispatch(hideOverlay()), 3000); }; - const onConfirmCardClick=(e,card)=>{ + const onConfirmCardClick = (e, card) => { e.stopPropagation(); if (!card || card.revealed || card.pendingReveal) return; // Only Revealers can click cards - if (joinedTitle !== "Revealers") { - console.warn("โŒ Only Revealers can click cards"); + if (joinedTitle !== 'Revealers') { + console.warn('โŒ Only Revealers can click cards'); return; } @@ -176,8 +183,8 @@ useEffect(() => { setTimeout(() => { dispatch({ type: 'cards/revealLocal', payload: { id: card.id, revealed: true } }); dispatch(clickCard({ id: card.id, word: card.word, team: card.team, gameId })); - // Server will use socket.id to identify the revealer; no need to send socketId from client - socket.emit("revealCard", { gameId, cardId: card.id }); + // Server will use socket.id to identify the revealer; no need to send socketId from client + socket.emit('revealCard', { gameId, cardId: card.id }); // clear all selected confirm buttons after a confirmation dispatch(clearConfirmTargets()); }, ANIMATION_DURATION); @@ -185,11 +192,11 @@ useEffect(() => { const onCardClick = (cardId) => { // Only Revealers can select cards - if (joinedTitle !== "Revealers") { - console.warn("โŒ Only Revealers can select cards"); + if (joinedTitle !== 'Revealers') { + console.warn('โŒ Only Revealers can select cards'); return; } - + // Check if it's this player's team turn if (joinedTeam !== currentTurn) { console.warn(`โŒ It's ${currentTurn} team's turn, your team is ${joinedTeam}`); @@ -201,7 +208,7 @@ useEffect(() => { console.warn('โŒ Cannot select cards: no clue submitted yet'); return; } - + dispatch(toggleConfirmTarget(cardId)); // Log which local player clicked and emit a UI-only selection event const myName = localStorage.getItem('nickname') || 'Anonymous'; @@ -215,41 +222,40 @@ useEffect(() => { } }; - useEffect(() => { async function fetchBoard() { // Clear any persisted reveal state immediately to avoid a flash // of colored/revealed cards from a previous session while we // fetch the actual board for this game. dispatch(resetAll()); - try { - const res = await axios.get(`${API_URL}/api/cards/${gameId}`); - const normalized = (res.data.board || []).map((c, i) => ({ - // keep server _id if present, otherwise fallback to index - id: c._id ?? i, - word: c.word, - team: c.type ?? c.team ?? 'neutral', // map server "type" -> "team" - revealed: c.revealed ?? false, - clickedBy: c.clickedBy ?? [], - // keep raw fields if you need them - _raw: c, - })); - - dispatch(setCards(normalized)); - - // Initialize players from game document - if (res.data.players) { - dispatch(updatePlayers({ players: res.data.players })); - } - - // Initialize currentTurn from game document - if (res.data.currentTurn) { - dispatch(setCurrentTurn(res.data.currentTurn)); + try { + const res = await axios.get(`${API_URL}/api/cards/${gameId}`); + const normalized = (res.data.board || []).map((c, i) => ({ + // keep server _id if present, otherwise fallback to index + id: c._id ?? i, + word: c.word, + team: c.type ?? c.team ?? 'neutral', // map server "type" -> "team" + revealed: c.revealed ?? false, + clickedBy: c.clickedBy ?? [], + // keep raw fields if you need them + _raw: c, + })); + + dispatch(setCards(normalized)); + + // Initialize players from game document + if (res.data.players) { + dispatch(updatePlayers({ players: res.data.players })); + } + + // Initialize currentTurn from game document + if (res.data.currentTurn) { + dispatch(setCurrentTurn(res.data.currentTurn)); + } + } catch (err) { + console.error('Failed to fetch game:', err); + } } - } catch (err) { - console.error("Failed to fetch game:", err); - } -} fetchBoard(); }, [gameId, dispatch]); @@ -259,77 +265,78 @@ useEffect(() => { if (hasJoined.current) return; hasJoined.current = true; + console.log('โžก๏ธ Emitting joinGame for', gameId); + socket.emit('joinGame', { gameId, nickname: localStorage.getItem('nickname') }); - console.log("โžก๏ธ Emitting joinGame for", gameId); - socket.emit("joinGame", { gameId, nickname: localStorage.getItem("nickname") }); - - const onJoined = (data) => console.log("โœ… joinedGame ack:", data); - const onPlayerJoined = (data) => console.log("๐Ÿ‘ฅ another player:", data); + const onJoined = (data) => console.log('โœ… joinedGame ack:', data); + const onPlayerJoined = (data) => console.log('๐Ÿ‘ฅ another player:', data); // If we detected that the client had been a Concealer before the refresh, // tell the server to mark this socket/player as a spectator now that we've // re-joined the game (server requires the player to exist first). const onJoinedWithSpectatorFix = (data) => { - console.log("โœ… joinedGame ack:", data); + console.log('โœ… joinedGame ack:', data); if (needsSpectatorUpdate.current && gameId) { const nickname = localStorage.getItem('nickname') || 'Anonymous'; - console.log("โžก๏ธ Emitting joinTeam -> spectator to update players list after refresh", { gameId, nickname }); + console.log('โžก๏ธ Emitting joinTeam -> spectator to update players list after refresh', { gameId, nickname }); socket.emit('joinTeam', { gameId, nickname, team: 'spectator', role: 'spectator' }); needsSpectatorUpdate.current = false; } }; - socket.on("joinedGame", onJoinedWithSpectatorFix); - socket.on("playerJoined", onPlayerJoined); + socket.on('joinedGame', onJoinedWithSpectatorFix); + socket.on('playerJoined', onPlayerJoined); const onPlayersUpdated = ({ players }) => { - console.log("๐Ÿ” players updated:", players); + console.log('๐Ÿ” players updated:', players); dispatch(updatePlayers({ players })); }; const onJoinedTeamAck = ({ players }) => { - console.log("๐ŸŽฏ joined team ack:", players); + console.log('๐ŸŽฏ joined team ack:', players); dispatch(updatePlayers({ players })); }; - socket.on("playersUpdated", onPlayersUpdated); - socket.on("joinedTeamAck", onJoinedTeamAck); + socket.on('playersUpdated', onPlayersUpdated); + socket.on('joinedTeamAck', onJoinedTeamAck); return () => { - socket.off("joinedGame", onJoined); - socket.off("playerJoined", onPlayerJoined); - socket.off("playersUpdated", onPlayersUpdated); - socket.off("joinedTeamAck", onJoinedTeamAck); + socket.off('joinedGame', onJoined); + socket.off('playerJoined', onPlayerJoined); + socket.off('playersUpdated', onPlayersUpdated); + socket.off('joinedTeamAck', onJoinedTeamAck); }; }, [gameId, dispatch]); useEffect(() => { - socket.on("cardRevealed", ({ cardId,updated_score }) => { + socket.on('cardRevealed', ({ cardId, updated_score }) => { // Trigger animation on other participants' screens dispatch(setPendingReveal({ id: cardId, pending: true })); console.log(updated_score); - dispatch(updateScores({ - red: updated_score.redScore, - blue: updated_score.blueScore - })); + dispatch( + updateScores({ + red: updated_score.redScore, + blue: updated_score.blueScore, + }) + ); // After animation, reveal the card setTimeout(() => { dispatch(revealLocal({ id: cardId, revealed: true })); }, ANIMATION_DURATION); }); - socket.on("turnSwitched", ({ currentTurn }) => { + socket.on('turnSwitched', ({ currentTurn }) => { // If we've already declared a winner, ignore turn switch overlays if (finalWinner) return; console.log(`๐Ÿ”„ Turn switched to ${currentTurn}`); dispatch(setCurrentTurn(currentTurn)); // Reset clickedBy for all cards locally so selection chips clear on turn change try { - cards.forEach(c => dispatch(updateCardClickedBy({ id: c.id, clickedBy: [] }))); + cards.forEach((c) => dispatch(updateCardClickedBy({ id: c.id, clickedBy: [] }))); } catch (err) { console.warn('Failed to clear local clickedBy on turn switch', err); } - // Reset local per-player clicked flag (client-side guard removed) + // Reset local per-player clicked flag (client-side guard removed) // Hide persistent clue display when turn switches dispatch(hideClueDisplay()); // Show a brief turn overlay (reuses overlayActive/lastClue state) @@ -342,7 +349,7 @@ useEffect(() => { socket.on('cardClicked', ({ cardId, clickedBy }) => { dispatch(updateCardClickedBy({ id: cardId, clickedBy })); // Log the incoming info for debugging: who clicked which card - console.log(`โฌ…๏ธ [socket] cardClicked received cardId=${cardId} clickedBy=[${(clickedBy||[]).join(', ')}]`); + console.log(`โฌ…๏ธ [socket] cardClicked received cardId=${cardId} clickedBy=[${(clickedBy || []).join(', ')}]`); // Example: detect if a specific player clicked (change 'Alice' to the name you want to watch) const watchName = localStorage.getItem('watchPlayer') || null; // optional: set watchPlayer in localStorage @@ -358,11 +365,11 @@ useEffect(() => { // When server tells us all clickedBy lists were cleared on turn switch socket.on('clearAllClickedBy', () => { try { - cards.forEach(c => dispatch(updateCardClickedBy({ id: c.id, clickedBy: [] }))); + cards.forEach((c) => dispatch(updateCardClickedBy({ id: c.id, clickedBy: [] }))); } catch (err) { console.warn('Failed to handle clearAllClickedBy', err); } - // client-side single-click guard removed; nothing to reset here + // client-side single-click guard removed; nothing to reset here }); // Server requests that persistent clue displays be cleared (e.g., on turn change) @@ -375,8 +382,8 @@ useEffect(() => { }); return () => { - socket.off("cardRevealed"); - socket.off("turnSwitched"); + socket.off('cardRevealed'); + socket.off('turnSwitched'); socket.off('cardClicked'); socket.off('clearAllClickedBy'); socket.off('clearClueDisplay'); @@ -413,41 +420,51 @@ useEffect(() => { }; }, []); - return ( <> -
- {/* Persistent turn badge (shows from first render and updates on turn change) */} - -
-
- {/* Card Deck - Top (keeps exact design proportions by scaling inner content) */} -
- {cards.map((card) => ( - onCardClick(card.id)} - clickConfirm={(e) => onConfirmCardClick(e, card)} - confirmButton={confirmTargetIds.includes(card.id)} - revealed={joinedTitle === "Concealers" ? true : card.revealed} - pending={card.pendingReveal} - serverRevealed={card.revealed} - concealerView={joinedTitle === "Concealers"} - revealWordsOnGameOver={finalWinner != null} - /> - ))} +
+ {/* Persistent turn badge (shows from first render and updates on turn change) */} + +
+
+ {/* Card Deck - Top (keeps exact design proportions by scaling inner content) */} +
+ {cards.map((card) => ( + onCardClick(card.id)} + clickConfirm={(e) => onConfirmCardClick(e, card)} + confirmButton={confirmTargetIds.includes(card.id)} + revealed={joinedTitle === 'Concealers' ? true : card.revealed} + pending={card.pendingReveal} + serverRevealed={card.revealed} + concealerView={joinedTitle === 'Concealers'} + revealWordsOnGameOver={finalWinner != null} + /> + ))}
- + {/* ClueInput or Revealer Display - Bottom */} @@ -473,33 +490,39 @@ useEffect(() => { {overlayActive && lastClue && (lastClue.isTurn || lastClue.isWin) ? ( // Turn or Win overlay animates itself to the top; keep it separate component dispatch(hideOverlay())} /> - ) : overlayActive && ( -
- {lastClue ? ( -
-

- {lastClue.word} -

-

- {lastClue.number === 'infinity' ? 'โˆž' : lastClue.number} -

-
- ) : ( -

Clue Submitted!

- )} -
+ ) : ( + overlayActive && ( +
+ {lastClue ? ( +
+

+ {lastClue.word} +

+

+ {lastClue.number === 'infinity' ? 'โˆž' : lastClue.number} +

+
+ ) : ( +

Clue Submitted!

+ )} +
+ ) )} {/* Final winner badge shown after reveal */} {finalWinner && (
-
+
-
{finalWinner === 'red' ? 'Red Team Wins' : 'Blue Team Wins'}
+
+ {finalWinner === 'red' ? 'Red Team Wins' : 'Blue Team Wins'} +
)} - ); - }; + ); +}; - export default Deck; +export default Deck; diff --git a/frontend/src/Components/DeckCard.jsx b/frontend/src/Components/DeckCard.jsx index 50df92c..d6598c1 100644 --- a/frontend/src/Components/DeckCard.jsx +++ b/frontend/src/Components/DeckCard.jsx @@ -1,9 +1,21 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { createPortal } from 'react-dom'; -import { IoInformationCircle } from "react-icons/io5"; -import { GiConfirmed } from "react-icons/gi"; +import { IoInformationCircle } from 'react-icons/io5'; +import { GiConfirmed } from 'react-icons/gi'; import axios from 'axios'; -export function DeckCard({ word, team, click, clickConfirm, confirmButton = false, revealed = false, pending = false, serverRevealed = false, concealerView = false, revealWordsOnGameOver = false, clickedBy = [] }) { +export function DeckCard({ + word, + team, + click, + clickConfirm, + confirmButton = false, + revealed = false, + pending = false, + serverRevealed = false, + concealerView = false, + revealWordsOnGameOver = false, + clickedBy = [], +}) { const teamStyles = { red: { bg: 'bg-gradient-to-br from-red-700 via-red-800 to-red-900', @@ -11,7 +23,7 @@ export function DeckCard({ word, team, click, clickConfirm, confirmButton = fals shadow: 'shadow-xl shadow-red-500/40', glow: 'group-hover:shadow-red-500/50', text: 'text-white', - shine: 'from-red-300/0 via-red-100/20 to-red-300/0' + shine: 'from-red-300/0 via-red-100/20 to-red-300/0', }, blue: { bg: 'bg-gradient-to-br from-blue-500 via-blue-600 to-blue-700', @@ -19,7 +31,7 @@ export function DeckCard({ word, team, click, clickConfirm, confirmButton = fals shadow: 'shadow-xl shadow-blue-500/40', glow: 'group-hover:shadow-blue-500/50', text: 'text-white', - shine: 'from-blue-300/0 via-blue-100/20 to-blue-300/0' + shine: 'from-blue-300/0 via-blue-100/20 to-blue-300/0', }, neutral: { bg: 'bg-gradient-to-br from-white via-gray-50 to-gray-100', @@ -27,7 +39,7 @@ export function DeckCard({ word, team, click, clickConfirm, confirmButton = fals shadow: 'shadow-xl shadow-gray-400/40', glow: 'group-hover:shadow-gray-400/50', text: 'text-gray-900', - shine: 'from-white/0 via-white/40 to-white/0' + shine: 'from-white/0 via-white/40 to-white/0', }, assassin: { bg: 'bg-gradient-to-br from-gray-800 via-gray-900 to-black', @@ -35,8 +47,8 @@ export function DeckCard({ word, team, click, clickConfirm, confirmButton = fals shadow: 'shadow-xl shadow-black/60', glow: 'group-hover:shadow-gray-600/70', text: 'text-white', - shine: 'from-gray-400/0 via-gray-300/15 to-gray-400/0' - } + shine: 'from-gray-400/0 via-gray-300/15 to-gray-400/0', + }, }; const defaultStyle = { @@ -45,7 +57,8 @@ export function DeckCard({ word, team, click, clickConfirm, confirmButton = fals shadow: 'shadow-xl shadow-amber-200/40 dark:shadow-amber-900/40', glow: 'group-hover:shadow-amber-300/50 dark:group-hover:shadow-amber-800/50', text: 'text-amber-900 dark:text-amber-100', - shine: 'from-amber-200/0 via-amber-100/40 to-amber-200/0 dark:from-amber-700/0 dark:via-amber-600/20 dark:to-amber-700/0' + shine: + 'from-amber-200/0 via-amber-100/40 to-amber-200/0 dark:from-amber-700/0 dark:via-amber-600/20 dark:to-amber-700/0', }; let style; @@ -63,12 +76,12 @@ export function DeckCard({ word, team, click, clickConfirm, confirmButton = fals const animClass = pending ? 'animate-card-flip' : ''; const revealedClass = revealed ? 'revealed-card' : ''; - const [response, setResponse] = useState(""); + const [response, setResponse] = useState(''); const handleInfoClick = async (e) => { // prevent bubbling to card click e.stopPropagation(); - const url = 'https://en.wikipedia.org/api/rest_v1/page/summary/'+word; + const url = 'https://en.wikipedia.org/api/rest_v1/page/summary/' + word; try { const res = await axios.get(url); setResponse(res.data.extract); @@ -79,54 +92,59 @@ export function DeckCard({ word, team, click, clickConfirm, confirmButton = fals return (
- handleInfoClick(e)} className='absolute top-[5px] left-[5px] text-[30px] z-30 text-gray-800 dark:text-white opacity-90 hover:cursor-pointer' /> + handleInfoClick(e)} + className="absolute top-[5px] left-[5px] text-[30px] z-30 text-gray-800 dark:text-white opacity-90 hover:cursor-pointer" + /> { // If someone has clicked this card, show up to two inline chips. // If more than two players clicked, show an overflow chip with "..." // and reveal a hover panel listing all names. clickedBy && clickedBy.length > 0 ? ( -
- { - (() => { - const maxVisible = 1; - const visible = clickedBy.slice(0, maxVisible); - const extra = clickedBy.length - visible.length; - return ( -
-
- {visible.map((name, idx) => ( -
- {name.length > 18 ? name.slice(0, 15) + 'โ€ฆ' : name} +
+ {(() => { + const maxVisible = 1; + const visible = clickedBy.slice(0, maxVisible); + const extra = clickedBy.length - visible.length; + return ( +
+
+ {visible.map((name, idx) => ( +
+ {name.length > 18 ? name.slice(0, 15) + 'โ€ฆ' : name} +
+ ))} + {extra > 0 ? ( +
+ โ€ฆ +
+ ) : null} +
+ + {/* Hover panel showing full list of names (shows when hovering the chips) */} +
+
+ {clickedBy.map((n, i) => ( +
+ {n}
))} - {extra > 0 ? ( -
- โ€ฆ -
- ) : null} -
- - {/* Hover panel showing full list of names (shows when hovering the chips) */} -
-
- {clickedBy.map((n, i) => ( -
{n}
- ))} -
- ); - })() - } +
+ ); + })()}
) : null } {/* background glow (kept) */} -
+
{/* Geometric pattern overlay */}
-
@@ -162,11 +180,11 @@ export function DeckCard({ word, team, click, clickConfirm, confirmButton = fals
{concealerView && serverRevealed && !revealWordsOnGameOver ? ( @@ -176,34 +194,42 @@ export function DeckCard({ word, team, click, clickConfirm, confirmButton = fals word )} - {/* Meaning modal (renders into document.body so it's not clipped by card) */} - { - response ? createPortal( -
setResponse('')}> -
-
e.stopPropagation()}> -
- Meaning - -
-

{response}

-
-
, - document.body - ) : null - } - {/* Word content */} +
+ Meaning + +
+

{response}

+
+
, + document.body + ) + : null} + {/* Word content */}
{/* Shine effect on hover */}
-
+
{/* Bottom highlight */} @@ -215,7 +241,10 @@ export function DeckCard({ word, team, click, clickConfirm, confirmButton = fals make the button smaller so it doesn't visually dominate the card. */} {confirmButton && !serverRevealed ? ( )}
diff --git a/frontend/src/Components/TeamPanel.jsx b/frontend/src/Components/TeamPanel.jsx index 258456d..e298acb 100644 --- a/frontend/src/Components/TeamPanel.jsx +++ b/frontend/src/Components/TeamPanel.jsx @@ -1,23 +1,24 @@ -import PlayerList from "./PlayerList"; +import PlayerList from './PlayerList'; const TeamPanel = ({ team, score, concealers, revealers }) => { - const colorClasses = team === 'red' - ? { - bg: 'bg-red-500 dark:bg-red-700', - border: 'border-red-600 dark:border-red-400', - text: 'text-red-500 dark:text-red-400', - headerText: 'text-white', - ring: 'ring-red-500 dark:ring-red-400', - shadow: 'shadow-red-500/50 dark:shadow-red-700/50' - } - : { - bg: 'bg-blue-500 dark:bg-blue-700', - border: 'border-blue-600 dark:border-blue-400', - text: 'text-blue-500 dark:text-blue-400', - headerText: 'text-white', - ring: 'ring-blue-500 dark:ring-blue-400', - shadow: 'shadow-blue-500/50 dark:shadow-blue-700/50' - }; + const colorClasses = + team === 'red' + ? { + bg: 'bg-red-500 dark:bg-red-700', + border: 'border-red-600 dark:border-red-400', + text: 'text-red-500 dark:text-red-400', + headerText: 'text-white', + ring: 'ring-red-500 dark:ring-red-400', + shadow: 'shadow-red-500/50 dark:shadow-red-700/50', + } + : { + bg: 'bg-blue-500 dark:bg-blue-700', + border: 'border-blue-600 dark:border-blue-400', + text: 'text-blue-500 dark:text-blue-400', + headerText: 'text-white', + ring: 'ring-blue-500 dark:ring-blue-400', + shadow: 'shadow-blue-500/50 dark:shadow-blue-700/50', + }; return (
{ `} > {/* Team Header */} -
-

- {team} Team -

+
+

{team} Team

{/* Score Section */}

Cards Left

-
+
{score}
{/* Players List Grouped */}
- - + +
); }; -export default TeamPanel; \ No newline at end of file +export default TeamPanel; diff --git a/frontend/src/Components/Teams.jsx b/frontend/src/Components/Teams.jsx index f314608..eae74d5 100644 --- a/frontend/src/Components/Teams.jsx +++ b/frontend/src/Components/Teams.jsx @@ -1,21 +1,20 @@ -import { useState, useEffect } from "react"; -import { useParams } from "react-router-dom"; -import { useSelector } from "react-redux"; -import TeamPanel from "./TeamPanel"; -import axios from "axios"; +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import TeamPanel from './TeamPanel'; +import axios from 'axios'; import API_URL from '../apiConfig'; - -import { JoinContext } from "../context/JoinContext"; +import { JoinContext } from '../context/JoinContext'; const Teams = ({ onDataReceived }) => { - const { gameId } = useParams(); // <-- GET GAME ID FROM URL + const { gameId } = useParams(); // <-- GET GAME ID FROM URL - const [joinedTeam, setJoinedTeam] = useState(""); - const [joinedTitle, setJoinedTitle] = useState(""); + const [joinedTeam, setJoinedTeam] = useState(''); + const [joinedTitle, setJoinedTitle] = useState(''); - const [allPlayers, setAllPlayers] = useState([]); // From API + const [allPlayers, setAllPlayers] = useState([]); // From API const handleJoin = (teamJoin, titleJoin) => { setJoinedTeam(teamJoin); @@ -27,15 +26,14 @@ const Teams = ({ onDataReceived }) => { useEffect(() => { if (!gameId) return; - -// ... existing code ... + // ... existing code ... const fetchPlayers = async () => { try { const res = await axios.get(`${API_URL}/api/players/${gameId}`); setAllPlayers(res.data.players || []); } catch (err) { - console.error("Failed to load players", err); + console.error('Failed to load players', err); } }; @@ -44,27 +42,28 @@ const Teams = ({ onDataReceived }) => { // Auto refresh players every 1.5 sec const interval = setInterval(fetchPlayers, 5500); return () => clearInterval(interval); - }, [gameId]); // Filter players - const redConcealers = allPlayers.filter(p => p.team === "red" && p.role === "Concealers").map(p => p.name); - const redRevealers = allPlayers.filter(p => p.team === "red" && p.role === "Revealers").map(p => p.name); + const redConcealers = allPlayers.filter((p) => p.team === 'red' && p.role === 'Concealers').map((p) => p.name); + const redRevealers = allPlayers.filter((p) => p.team === 'red' && p.role === 'Revealers').map((p) => p.name); - const blueConcealers = allPlayers.filter(p => p.team === "blue" && p.role === "Concealers").map(p => p.name); - const blueRevealers = allPlayers.filter(p => p.team === "blue" && p.role === "Revealers").map(p => p.name); + const blueConcealers = allPlayers.filter((p) => p.team === 'blue' && p.role === 'Concealers').map((p) => p.name); + const blueRevealers = allPlayers.filter((p) => p.team === 'blue' && p.role === 'Revealers').map((p) => p.name); // Read persisted/Redux scores and fall back to 0 if shape varies - const scoresState = useSelector(state => state.scores || {}); + const scoresState = useSelector((state) => state.scores || {}); const redScore = scoresState?.red ?? scoresState?.redScore ?? scoresState?.red_team ?? scoresState?.redTeam ?? 0; const blueScore = scoresState?.blue ?? scoresState?.blueScore ?? scoresState?.blue_team ?? scoresState?.blueTeam ?? 0; return ( - + {/* Desktop: side panels positioned vertically centered */}
@@ -83,4 +82,4 @@ const Teams = ({ onDataReceived }) => { ); }; -export default Teams; \ No newline at end of file +export default Teams; diff --git a/frontend/src/Components/ThemeToggle.jsx b/frontend/src/Components/ThemeToggle.jsx index 486a94c..6920965 100644 --- a/frontend/src/Components/ThemeToggle.jsx +++ b/frontend/src/Components/ThemeToggle.jsx @@ -1,38 +1,38 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect } from 'react'; export default function ThemeToggle() { const [dark, setDark] = useState(() => { try { - const t = localStorage.getItem('theme') - if (t) return t === 'dark' - return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches + const t = localStorage.getItem('theme'); + if (t) return t === 'dark'; + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; } catch { - return false + return false; } - }) + }); useEffect(() => { try { if (dark) { - document.documentElement.classList.add('dark') - localStorage.setItem('theme', 'dark') + document.documentElement.classList.add('dark'); + localStorage.setItem('theme', 'dark'); } else { - document.documentElement.classList.remove('dark') - localStorage.setItem('theme', 'light') + document.documentElement.classList.remove('dark'); + localStorage.setItem('theme', 'light'); } - } catch (e){ + } catch (e) { console.error(e); } - }, [dark]) + }, [dark]); return ( - - ) -} \ No newline at end of file + + ); +} diff --git a/frontend/src/Components/TurnBadge.jsx b/frontend/src/Components/TurnBadge.jsx index 4fa884b..81eaff9 100644 --- a/frontend/src/Components/TurnBadge.jsx +++ b/frontend/src/Components/TurnBadge.jsx @@ -1,4 +1,3 @@ -import React from 'react'; import { useSelector } from 'react-redux'; const TurnBadge = () => { @@ -7,7 +6,9 @@ const TurnBadge = () => { return (
-
+
{isRed ? 'Red Team Turn' : 'Blue Team Turn'}
diff --git a/frontend/src/Components/TurnOverlay.jsx b/frontend/src/Components/TurnOverlay.jsx index 50d8ba1..1355f7c 100644 --- a/frontend/src/Components/TurnOverlay.jsx +++ b/frontend/src/Components/TurnOverlay.jsx @@ -1,15 +1,15 @@ -import React from 'react'; - const TurnOverlay = ({ team = 'red', isWin = false }) => { const isRed = String(team).toLowerCase() === 'red'; return (
-
+
{isRed ? 'RED' : 'BLUE'}
-
{isWin ? "Team Wins" : "Team's Turn"}
+
{isWin ? 'Team Wins' : "Team's Turn"}
diff --git a/frontend/src/constants/floating_words.js b/frontend/src/constants/floating_words.js index 92898e9..89385ea 100644 --- a/frontend/src/constants/floating_words.js +++ b/frontend/src/constants/floating_words.js @@ -1,12 +1,61 @@ export const FLOATING_WORDS = [ - 'AGENT', 'CLUE', 'CONTACT', 'DECRYPT', 'ENCODE', 'MISSION', - 'REVEALER', 'CONCEALER', 'INTEL', 'ASSASSIN', 'RED', 'BLUE', - 'FIELD', 'CODE', 'TARGET', 'WILD', 'DANGER', 'SECRET', 'PUZZLE', - 'SPY', 'CARD', 'GUESS', 'TURN', 'SCORE', 'TEAM', 'COVER', 'TRAITOR', // New words - 'HIDDEN', 'KEY', 'WORD', 'CYPHER', 'LOCATE', 'VECTOR', 'CONFIRM', 'PASS', // More new words - + 'AGENT', + 'CLUE', + 'CONTACT', + 'DECRYPT', + 'ENCODE', + 'MISSION', + 'REVEALER', + 'CONCEALER', + 'INTEL', + 'ASSASSIN', + 'RED', + 'BLUE', + 'FIELD', + 'CODE', + 'TARGET', + 'WILD', + 'DANGER', + 'SECRET', + 'PUZZLE', + 'SPY', + 'CARD', + 'GUESS', + 'TURN', + 'SCORE', + 'TEAM', + 'COVER', + 'TRAITOR', // New words + 'HIDDEN', + 'KEY', + 'WORD', + 'CYPHER', + 'LOCATE', + 'VECTOR', + 'CONFIRM', + 'PASS', // More new words + // Doubling the list for higher density - 'CLUE', 'AGENT', 'ENCODE', 'MISSION', 'REVEALER', 'INTEL', - 'RED', 'BLUE', 'FIELD', 'CODE', 'TARGET', 'DANGER', 'SECRET', 'PUZZLE', - 'SPY', 'CARD', 'GUESS', 'SCORE', 'TEAM', 'HIDDEN', 'KEY', 'WORD' // Doubled new words -]; \ No newline at end of file + 'CLUE', + 'AGENT', + 'ENCODE', + 'MISSION', + 'REVEALER', + 'INTEL', + 'RED', + 'BLUE', + 'FIELD', + 'CODE', + 'TARGET', + 'DANGER', + 'SECRET', + 'PUZZLE', + 'SPY', + 'CARD', + 'GUESS', + 'SCORE', + 'TEAM', + 'HIDDEN', + 'KEY', + 'WORD', // Doubled new words +]; diff --git a/frontend/src/context/JoinContext.js b/frontend/src/context/JoinContext.js index 4d3cdd6..0c76adb 100644 --- a/frontend/src/context/JoinContext.js +++ b/frontend/src/context/JoinContext.js @@ -1,3 +1,3 @@ -import { createContext } from "react"; +import { createContext } from 'react'; -export const JoinContext = createContext(); \ No newline at end of file +export const JoinContext = createContext(); diff --git a/frontend/src/index.css b/frontend/src/index.css index 586f0d0..f1061e9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -36,7 +36,7 @@ --chart-4: #f59e0b; --chart-5: #6366f1; --radius: 0.625rem; - + /* --- Sidebar Colors Reverted to Original Neutral Values --- */ --sidebar: oklch(0.985 0 0); /* Original light sidebar background */ --sidebar-foreground: oklch(0.145 0 0); /* Original dark text */ @@ -76,7 +76,7 @@ --chart-3: #10b981; --chart-4: #f59e0b; --chart-5: #3b82f6; - + /* --- Sidebar Colors Reverted to Original Neutral Values --- */ --sidebar: oklch(0.205 0 0); /* Original dark sidebar background */ --sidebar-foreground: oklch(0.985 0 0); /* Original light text */ @@ -143,7 +143,7 @@ * Base typography. This is not applied to elements which have an ancestor with a Tailwind text class. */ @layer base { - :where(:not(:has([class*=" text-"]), :not(:has([class^="text-"])))) { + :where(:not(:has([class*=' text-']), :not(:has([class^='text-'])))) { h1 { font-size: var(--text-2xl); font-weight: var(--font-weight-medium); @@ -213,17 +213,33 @@ html { /* card press/flip animation */ @keyframes card-press { - 0% { transform: scale(1) rotateX(0deg); } - 30% { transform: scale(0.96) rotateX(12deg); } - 60% { transform: scale(0.98) rotateX(-6deg); } - 100% { transform: scale(1) rotateX(0deg); } + 0% { + transform: scale(1) rotateX(0deg); + } + 30% { + transform: scale(0.96) rotateX(12deg); + } + 60% { + transform: scale(0.98) rotateX(-6deg); + } + 100% { + transform: scale(1) rotateX(0deg); + } } /* optional subtle flip for dramatic reveal */ @keyframes card-flip { - 0% { transform: perspective(800px) rotateY(0deg); } - 50% { transform: perspective(800px) rotateY(90deg); opacity: 0.6; } - 100% { transform: perspective(800px) rotateY(0deg); opacity: 1; } + 0% { + transform: perspective(800px) rotateY(0deg); + } + 50% { + transform: perspective(800px) rotateY(90deg); + opacity: 0.6; + } + 100% { + transform: perspective(800px) rotateY(0deg); + opacity: 1; + } } /* class to trigger the animation */ @@ -243,23 +259,18 @@ html { transform-origin: center; } */ -@media(max-width:1584px) -{ - .turns - { - top:5px; +@media (max-width: 1584px) { + .turns { + top: 5px; } } -@media(max-width:1023px) -{ - .deck - { +@media (max-width: 1023px) { + .deck { position: relative; - top:100px; + top: 100px; } - .turns - { - top:-50px; + .turns { + top: -50px; } -} \ No newline at end of file +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 39ad1c7..09f2829 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,10 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import {Provider} from "react-redux"; -import store,{persistor} from './store/index.js'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Provider } from 'react-redux'; +import store, { persistor } from './store/index.js'; import { PersistGate } from 'redux-persist/integration/react'; -import './index.css' -import App from './App.jsx' +import './index.css'; +import App from './App.jsx'; createRoot(document.getElementById('root')).render( @@ -13,5 +13,5 @@ createRoot(document.getElementById('root')).render( - , -) + +); diff --git a/frontend/src/pages/Error.jsx b/frontend/src/pages/Error.jsx index b5c03bc..659d75b 100644 --- a/frontend/src/pages/Error.jsx +++ b/frontend/src/pages/Error.jsx @@ -1,80 +1,79 @@ -import React, { useEffect, useMemo } from "react"; -import { useNavigate, Link } from "react-router-dom"; -import ThemeToggle from "../Components/ThemeToggle"; +import { useEffect, useMemo } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import ThemeToggle from '../Components/ThemeToggle'; // Floating words used on the Home page โ€” keep consistent here -import { FLOATING_WORDS } from "../constants/floating_words"; +import { FLOATING_WORDS } from '../constants/floating_words'; // const FLOATING_WORDS = [ // 'AGENT', 'CLUE', 'MISSION', 'INTEL', 'SECRET', 'SPY', 'CARD', 'GUESS', 'TEAM', 'CODE', // 'RED', 'BLUE', 'HIDDEN', 'KEY', 'WORD' // ]; const FloatingWord = ({ word, x, y, size, delay, duration }) => ( - - {word} - + + {word} + ); export default function Error() { - const navigate = useNavigate(); + const navigate = useNavigate(); - useEffect(() => { - const t = setTimeout(() => navigate("/", { replace: true }), 3000); - return () => clearTimeout(t); - }, [navigate]); + useEffect(() => { + const t = setTimeout(() => navigate('/', { replace: true }), 3000); + return () => clearTimeout(t); + }, [navigate]); - const wordElements = useMemo(() => { - return FLOATING_WORDS.map((word, i) => ( - - )); - }, []); + const wordElements = useMemo(() => { + return FLOATING_WORDS.map((word, i) => ( + + )); + }, []); - return ( -
+ return ( +
+ - +
{wordElements}
-
{wordElements}
+
+

404

+

Page not found.

+

Redirecting to home in 3 seconds...

-
-

404

-

Page not found.

-

Redirecting to home in 3 seconds...

+
+ + + Cancel + +
+
-
- - - Cancel - -
-
- -
- -
-
- ); +
+ +
+
+ ); } diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 999e301..3fa7624 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -1,6 +1,6 @@ -import React, { useState, useMemo } from 'react'; +import { useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import axios from "axios"; +import axios from 'axios'; import ThemeToggle from '../Components/ThemeToggle'; import API_URL from '../apiConfig'; @@ -8,7 +8,7 @@ import { FLOATING_WORDS } from '../constants/floating_words'; // Reusable component for a single floating word (for clarity) const FloatingWord = ({ word, x, y, size, delay, duration }) => ( - ( fontSize: `${size}rem`, animation: `float ${duration}s linear infinite`, animationDelay: `-${delay}s`, - opacity: 0.8 + opacity: 0.8, }} > {word} @@ -31,44 +31,43 @@ const Home = () => { const navigate = useNavigate(); async function handleCreateGame() { - let temp_color=localStorage.getItem("theme"); + let temp_color = localStorage.getItem('theme'); localStorage.clear(); - localStorage.setItem("theme",temp_color); + localStorage.setItem('theme', temp_color); if (!nickname.trim()) { - alert("Please enter a nickname."); + alert('Please enter a nickname.'); return; } + // ... existing code ... -// ... existing code ... - - localStorage.setItem("nickname",nickname); + localStorage.setItem('nickname', nickname); setIsLoading(true); try { const res = await axios.post(`${API_URL}/api/generate`, { nickname }); - const newGameId = res.data.gameId; + const newGameId = res.data.gameId; navigate(`/game/${newGameId}`); } catch (error) { - console.error("Failed to create game:", error); - alert("Could not create game. Please try again."); + console.error('Failed to create game:', error); + alert('Could not create game. Please try again.'); } finally { setIsLoading(false); } - }; + } const handleJoinGame = () => { - let temp_color=localStorage.getItem("theme"); + let temp_color = localStorage.getItem('theme'); localStorage.clear(); - localStorage.setItem("theme",temp_color); + localStorage.setItem('theme', temp_color); if (!nickname.trim()) { - alert("Please enter a nickname."); + alert('Please enter a nickname.'); return; } if (!gameId.trim()) { - alert("Please enter a valid Game ID."); + alert('Please enter a valid Game ID.'); return; } - localStorage.setItem("nickname",nickname); + localStorage.setItem('nickname', nickname); navigate(`/game/${gameId.trim()}`); }; @@ -89,7 +88,6 @@ const Home = () => { return (
- {/* Custom Keyframes for floating words - Increased travel distance */} - + {/* 1. Floating Words Background Layer (Now covers the whole screen) */} {/* Opacity is set to 100% here, letting the individual word component handle transparency */} -
- {wordElements} -
+
{wordElements}
{/* Main Content Card */}
@@ -152,8 +148,7 @@ const Home = () => { onClick={handleCreateGame} disabled={isLoading || !nickname.trim() || (gameId && nickname)} className={`w-full py-3 px-4 rounded-lg bg-primary text-primary-foreground font-semibold shadow-lg transition-opacity duration-200 - ${(isLoading || !nickname.trim() || (gameId && nickname)) ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-90'}` - } + ${isLoading || !nickname.trim() || (gameId && nickname) ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-90'}`} > {isLoading ? 'Creating...' : 'Create New Game'} @@ -163,11 +158,11 @@ const Home = () => { onClick={handleJoinGame} disabled={!gameId.trim() || !nickname.trim()} className={`w-full py-3 px-4 rounded-lg font-semibold shadow-md transition-all duration-200 - ${(!gameId.trim() || !nickname.trim()) - ? 'bg-muted text-muted-foreground cursor-not-allowed opacity-70' - : 'bg-secondary text-secondary-foreground hover:bg-secondary/80' - }` - } + ${ + !gameId.trim() || !nickname.trim() + ? 'bg-muted text-muted-foreground cursor-not-allowed opacity-70' + : 'bg-secondary text-secondary-foreground hover:bg-secondary/80' + }`} > Join Existing Game @@ -183,4 +178,4 @@ const Home = () => { ); }; -export default Home; \ No newline at end of file +export default Home; diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 6c76b84..9cba45c 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -1,28 +1,19 @@ import { configureStore } from '@reduxjs/toolkit'; import { combineReducers } from 'redux'; -import { - persistStore, - persistReducer, - FLUSH, - REHYDRATE, - PAUSE, - PERSIST, - PURGE, - REGISTER, -} from 'redux-persist'; +import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist'; import storage from 'redux-persist/lib/storage'; // localStorage import cardsReducer from './slices/cardsSlice'; import uiReducer from './slices/uiSlice'; -import scoreReducer from "./slices/scoreSlice"; -import playersReducer from "./slices/playersSlice"; -import gameReducer from "./slices/gameSlice"; +import scoreReducer from './slices/scoreSlice'; +import playersReducer from './slices/playersSlice'; +import gameReducer from './slices/gameSlice'; const rootReducer = combineReducers({ cards: cardsReducer, ui: uiReducer, scores: scoreReducer, players: playersReducer, - game: gameReducer + game: gameReducer, }); const persistConfig = { diff --git a/frontend/src/store/slices/cardsSlice.js b/frontend/src/store/slices/cardsSlice.js index 7257eac..4cdab75 100644 --- a/frontend/src/store/slices/cardsSlice.js +++ b/frontend/src/store/slices/cardsSlice.js @@ -4,38 +4,38 @@ import socket from '../../socket'; // initial cards (same as your deck) const initialCardsList = [ - { word: "Phoenix", team: "blue" }, - { word: "Dragon", team: "red" }, - { word: "Ocean", team: "blue" }, - { word: "Flame", team: "red" }, - { word: "Crystal", team: "blue" }, - { word: "Sunset", team: "red" }, - { word: "Arctic", team: "blue" }, - { word: "Mars", team: "red" }, - { word: "Neptune", team: "blue" }, - { word: "Volcano", team: "red" }, - { word: "Glacier", team: "blue" }, - { word: "Torch", team: "red" }, - { word: "Sapphire", team: "blue" }, - { word: "Ruby", team: "red" }, - { word: "River", team: "blue" }, - { word: "Ember", team: "red" }, - { word: "Frost", team: "blue" }, - { word: "Castle", team: "neutral" }, - { word: "Tower", team: "neutral" }, - { word: "Bridge", team: "neutral" }, - { word: "Garden", team: "neutral" }, - { word: "Pyramid", team: "neutral" }, - { word: "Temple", team: "neutral" }, - { word: "Mountain", team: "neutral" }, - { word: "Assassin", team: "assassin" }, + { word: 'Phoenix', team: 'blue' }, + { word: 'Dragon', team: 'red' }, + { word: 'Ocean', team: 'blue' }, + { word: 'Flame', team: 'red' }, + { word: 'Crystal', team: 'blue' }, + { word: 'Sunset', team: 'red' }, + { word: 'Arctic', team: 'blue' }, + { word: 'Mars', team: 'red' }, + { word: 'Neptune', team: 'blue' }, + { word: 'Volcano', team: 'red' }, + { word: 'Glacier', team: 'blue' }, + { word: 'Torch', team: 'red' }, + { word: 'Sapphire', team: 'blue' }, + { word: 'Ruby', team: 'red' }, + { word: 'River', team: 'blue' }, + { word: 'Ember', team: 'red' }, + { word: 'Frost', team: 'blue' }, + { word: 'Castle', team: 'neutral' }, + { word: 'Tower', team: 'neutral' }, + { word: 'Bridge', team: 'neutral' }, + { word: 'Garden', team: 'neutral' }, + { word: 'Pyramid', team: 'neutral' }, + { word: 'Temple', team: 'neutral' }, + { word: 'Mountain', team: 'neutral' }, + { word: 'Assassin', team: 'assassin' }, ]; const initialState = { cards: initialCardsList.map((c, i) => ({ ...c, id: i, - revealed: false, // fully revealed (shows team color) + revealed: false, // fully revealed (shows team color) pendingReveal: false, // animation in progress })), status: 'idle', @@ -91,7 +91,7 @@ const cardsSlice = createSlice({ id: i, revealed: c.revealed ?? false, pendingReveal: false, - clickedBy: c.clickedBy ?? [] + clickedBy: c.clickedBy ?? [], })); }, updateCardClickedBy(state, action) { diff --git a/frontend/src/store/slices/gameSlice.js b/frontend/src/store/slices/gameSlice.js index af93222..78fc7fe 100644 --- a/frontend/src/store/slices/gameSlice.js +++ b/frontend/src/store/slices/gameSlice.js @@ -1,15 +1,15 @@ -import { createSlice } from "@reduxjs/toolkit"; +import { createSlice } from '@reduxjs/toolkit'; const gameSlice = createSlice({ - name: "game", + name: 'game', initialState: { - currentTurn: "red" // "red" or "blue" + currentTurn: 'red', // "red" or "blue" }, reducers: { setCurrentTurn(state, action) { state.currentTurn = action.payload; - } - } + }, + }, }); export const { setCurrentTurn } = gameSlice.actions; diff --git a/frontend/src/store/slices/playersSlice.js b/frontend/src/store/slices/playersSlice.js index e3e1264..48ee17c 100644 --- a/frontend/src/store/slices/playersSlice.js +++ b/frontend/src/store/slices/playersSlice.js @@ -1,16 +1,16 @@ -import { createSlice } from "@reduxjs/toolkit"; +import { createSlice } from '@reduxjs/toolkit'; const playersSlice = createSlice({ - name: "players", + name: 'players', initialState: { - list: [] // Array of { socketId, name, team, role, _id } + list: [], // Array of { socketId, name, team, role, _id } }, reducers: { updatePlayers(state, action) { // action.payload should be { players: [...] } from server state.list = action.payload.players || []; - } - } + }, + }, }); export const { updatePlayers } = playersSlice.actions; diff --git a/frontend/src/store/slices/scoreSlice.js b/frontend/src/store/slices/scoreSlice.js index bbbf732..fd936c5 100644 --- a/frontend/src/store/slices/scoreSlice.js +++ b/frontend/src/store/slices/scoreSlice.js @@ -1,7 +1,7 @@ -import { createSlice } from "@reduxjs/toolkit"; +import { createSlice } from '@reduxjs/toolkit'; const scoreSlice = createSlice({ - name: "scores", + name: 'scores', initialState: { red: 9, blue: 8 }, reducers: { updateScores(state, action) { @@ -13,8 +13,8 @@ const scoreSlice = createSlice({ state.red = action.payload.redScore; state.blue = action.payload.blueScore; } - } - } + }, + }, }); export const { updateScores } = scoreSlice.actions; diff --git a/frontend/src/store/slices/uiSlice.js b/frontend/src/store/slices/uiSlice.js index 52be344..6bddab6 100644 --- a/frontend/src/store/slices/uiSlice.js +++ b/frontend/src/store/slices/uiSlice.js @@ -46,9 +46,16 @@ const uiSlice = createSlice({ // Clear all selections clearConfirmTargets: (state) => { state.confirmTargetIds = []; - } + }, }, }); -export const { showOverlay, hideOverlay, showClueDisplay, hideClueDisplay, toggleConfirmTarget, removeConfirmTarget, clearConfirmTargets } = uiSlice.actions; +export const { + showOverlay, + hideOverlay, + showClueDisplay, + hideClueDisplay, + toggleConfirmTarget, + removeConfirmTarget, + clearConfirmTargets, +} = uiSlice.actions; export default uiSlice.reducer; - diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index faf612d..2701895 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -3,10 +3,7 @@ /** @type {import('tailwindcss').Config} */ export default { // 1. Tell Tailwind where to find your files to scan for classes - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], theme: { extend: { backgroundSize: { @@ -14,25 +11,25 @@ export default { }, }, }, - + // 2. Safelist the dynamic classes to fix the issue safelist: [ // Shadow classes 'shadow-blue-500/40', 'shadow-red-500/40', - + // Gradient from classes 'from-blue-500', 'from-red-500', - + // Gradient via classes 'via-blue-600', 'via-red-600', - + // Gradient to classes 'to-blue-700', 'to-red-700', - + // Border classes 'border-blue-400/30', 'border-red-400/30', @@ -44,6 +41,6 @@ export default { 'card-back', 'flipped', ], - + plugins: [], -} \ No newline at end of file +}; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 3d15f68..6e7f528 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,11 +1,8 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; // https://vite.dev/config/ export default defineConfig({ - plugins: [ - react(), - tailwindcss(), - ], -}) + plugins: [react(), tailwindcss()], +});