diff --git a/.env.example b/.env.example index 5ea5f59..7f11e18 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ DB_PORT=3306 DB_PASSWORD_TEST=app_pw_change_me DB_NAME_TEST=red_tetris_test -CLIENT_URL=http://localhost:3000,http://127.0.0.1:3000 +CLIENT_URL=http://localhost:3001,http://127.0.0.1:3001 CLIENT_PORT=3001 # Client Configuration diff --git a/client/README.md b/client/README.md index 5c4780a..eb5b276 100644 --- a/client/README.md +++ b/client/README.md @@ -52,7 +52,7 @@ To build and run using Docker: docker build -t my-app . # Run the container -docker run -p 3000:3000 my-app +docker run -p 3001:3001 my-app ``` The containerized application can be deployed to any platform that supports Docker, including: diff --git a/client/app/app.css b/client/app/app.css index d6d62ca..111d7f4 100644 --- a/client/app/app.css +++ b/client/app/app.css @@ -3,14 +3,119 @@ @theme { --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + + /* ── Semantic surface colors ── */ + --color-page: var(--page); + --color-surface: var(--surface); + --color-surface-alt: var(--surface-alt); + --color-surface-hover: var(--surface-hover); + + /* ── Semantic foreground (text) colors ── */ + --color-on-surface: var(--on-surface); + --color-on-surface-variant: var(--on-surface-variant); + --color-on-surface-muted: var(--on-surface-muted); + --color-on-surface-faint: var(--on-surface-faint); + + /* ── Borders ── */ + --color-outline: var(--outline); + --color-outline-variant: var(--outline-variant); + + /* ── Overlay ── */ + --color-scrim: var(--scrim); + + /* ── Status colors ── */ + --color-status-success-bg: var(--status-success-bg); + --color-status-success-border: var(--status-success-border); + --color-status-success-text: var(--status-success-text); + --color-status-error-bg: var(--status-error-bg); + --color-status-error-border: var(--status-error-border); + --color-status-error-text: var(--status-error-text); +} + +/* ── Light theme (default) ── */ +:root { + --page: #f1f5f9; + --surface: #ffffff; + --surface-alt: #f1f5f9; + --surface-hover: #e2e8f0; + --on-surface: #0f172a; + --on-surface-variant: #475569; + --on-surface-muted: #64748b; + --on-surface-faint: #94a3b8; + --outline: #cbd5e1; + --outline-variant: #e2e8f0; + --scrim: rgba(0, 0, 0, 0.15); + + --status-success-bg: #f0fdf4; + --status-success-border: #bbf7d0; + --status-success-text: #15803d; + --status-error-bg: #fef2f2; + --status-error-border: #fecaca; + --status-error-text: #dc2626; + + /* Game-board cell tokens */ + --cell-grid: rgba(0, 0, 0, 0.08); + --cell-ghost-bg: rgba(0, 0, 0, 0.05); + --cell-ghost-border-light: rgba(0, 0, 0, 0.06); + --cell-ghost-border-dark: rgba(0, 0, 0, 0.1); + --cell-penalty-bg: #cbd5e1; + --cell-penalty-border-light: rgba(255, 255, 255, 0.4); + --cell-penalty-border-dark: rgba(0, 0, 0, 0.15); + --piece-border-light: rgba(255, 255, 255, 0.25); + --piece-border-dark: rgba(0, 0, 0, 0.2); + --mini-board-bg: #e2e8f0; + --opponent-ghost-alive: rgba(0, 0, 0, 0.04); + --opponent-ghost-dead: rgba(0, 0, 0, 0.02); + --opponent-penalty-alive: #cbd5e1; + --opponent-penalty-dead: #94a3b8; + --opponent-dead-piece: #9ca3af; + + color-scheme: light; +} + +/* ── Dark theme ── */ +.dark { + --page: #0f172a; + --surface: #111827; + --surface-alt: #1f2937; + --surface-hover: #374151; + --on-surface: #ffffff; + --on-surface-variant: #d1d5db; + --on-surface-muted: #9ca3af; + --on-surface-faint: #6b7280; + --outline: #4b5563; + --outline-variant: rgba(255, 255, 255, 0.1); + --scrim: rgba(0, 0, 0, 0.4); + + --status-success-bg: rgba(34, 197, 94, 0.15); + --status-success-border: #166534; + --status-success-text: #4ade80; + --status-error-bg: rgba(239, 68, 68, 0.15); + --status-error-border: #991b1b; + --status-error-text: #fca5a5; + + --cell-grid: rgba(255, 255, 255, 0.12); + --cell-ghost-bg: rgba(255, 255, 255, 0.12); + --cell-ghost-border-light: rgba(255, 255, 255, 0.15); + --cell-ghost-border-dark: rgba(0, 0, 0, 0.3); + --cell-penalty-bg: #374151; + --cell-penalty-border-light: rgba(255, 255, 255, 0.08); + --cell-penalty-border-dark: rgba(0, 0, 0, 0.3); + --piece-border-light: rgba(255, 255, 255, 0.15); + --piece-border-dark: rgba(0, 0, 0, 0.3); + --mini-board-bg: #000000; + --opponent-ghost-alive: rgba(255, 255, 255, 0.06); + --opponent-ghost-dead: rgba(255, 255, 255, 0.03); + --opponent-penalty-alive: #111111; + --opponent-penalty-dead: #444444; + --opponent-dead-piece: #777777; + + color-scheme: dark; } html, body { - background: #0f172a; + background: var(--page); + color: var(--on-surface); min-height: 100vh; - - @media (prefers-color-scheme: dark) { - color-scheme: dark; - } } diff --git a/client/app/components/GameModeSelector.tsx b/client/app/components/GameModeSelector.tsx index f494c74..dff4732 100644 --- a/client/app/components/GameModeSelector.tsx +++ b/client/app/components/GameModeSelector.tsx @@ -25,16 +25,16 @@ export function GameModeSelector({ selected, onChange, disabled, compact }: Game className={`relative rounded-lg p-2.5 text-left transition-all border ${ isActive ? `${info.accentBg} ${info.accentBorder} ring-1 ring-offset-0 ring-current ${info.accentColor}` - : 'bg-gray-800 border-gray-600 hover:border-gray-500' + : 'bg-surface-alt border-outline hover:border-on-surface-faint' } ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`} >
{info.icon} - + {info.label}
-
{info.boardLabel}
+
{info.boardLabel}
); })} @@ -59,22 +59,22 @@ export function GameModeSelector({ selected, onChange, disabled, compact }: Game className={`relative rounded-lg p-3 text-left transition-all border ${ isActive ? `${info.accentBg} ${info.accentBorder} ring-1 ring-offset-0 ring-current ${info.accentColor}` - : 'bg-gray-800 border-gray-600 hover:border-gray-500 hover:bg-gray-800/80' + : 'bg-surface-alt border-outline hover:border-on-surface-faint hover:bg-surface-alt/80' } ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`} >
{info.icon}
- + {info.label} -
- {info.boardLabel} - {pieceCount} pcs +
+ {info.boardLabel} + {pieceCount} pcs
-

{info.description}

+

{info.description}

diff --git a/client/app/components/LoadingOverlay.tsx b/client/app/components/LoadingOverlay.tsx index 776469b..2075db4 100644 --- a/client/app/components/LoadingOverlay.tsx +++ b/client/app/components/LoadingOverlay.tsx @@ -1,10 +1,10 @@ export function LoadingOverlay() { return ( -
-
+
+
-

Loading...

+

Loading...

diff --git a/client/app/components/ThemeToggle.tsx b/client/app/components/ThemeToggle.tsx new file mode 100644 index 0000000..6148f86 --- /dev/null +++ b/client/app/components/ThemeToggle.tsx @@ -0,0 +1,31 @@ +interface ThemeToggleProps { + theme: 'light' | 'dark'; + toggle: () => void; +} + +export function ThemeToggle({ theme, toggle }: ThemeToggleProps) { + return ( + + ); +} diff --git a/client/app/components/auth/LoginForm.tsx b/client/app/components/auth/LoginForm.tsx index b24b9de..8b19923 100644 --- a/client/app/components/auth/LoginForm.tsx +++ b/client/app/components/auth/LoginForm.tsx @@ -28,17 +28,17 @@ export function LoginForm() { }; return ( -
+
{/* Header */}

RED TETRIS

-

Welcome Back!

-

Sign in to continue your game

+

Welcome Back!

+

Sign in to continue your game

{/* Login Form */} -
+
{ @@ -46,8 +46,8 @@ export function LoginForm() { }} > {error && ( -
-
{error}
+
+
{error}
)} @@ -55,7 +55,7 @@ export function LoginForm() {
@@ -64,7 +64,7 @@ export function LoginForm() { name='username' type='text' required - className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' + className='w-full bg-surface-alt border border-outline rounded-lg px-4 py-3 text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Enter your username' disabled={isLoading} /> @@ -72,7 +72,7 @@ export function LoginForm() {
@@ -81,7 +81,7 @@ export function LoginForm() { name='password' type='password' required - className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' + className='w-full bg-surface-alt border border-outline rounded-lg px-4 py-3 text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Enter your password' disabled={isLoading} /> @@ -91,30 +91,11 @@ export function LoginForm() { -
-

+

+

Don't have an account?{' '} +

{/* Header */}

RED TETRIS

-

Join the Game!

-

Create your account to start playing

+

Join the Game!

+

Create your account to start playing

{/* Register Form */} -
+
{ @@ -58,8 +58,8 @@ export function RegisterForm() { }} > {error && ( -
-
{error}
+
+
{error}
)} @@ -67,7 +67,7 @@ export function RegisterForm() {
@@ -79,7 +79,7 @@ export function RegisterForm() { minLength={3} maxLength={50} pattern='[a-zA-Z0-9_-]+' - className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' + className='w-full bg-surface-alt border border-outline rounded-lg px-4 py-3 text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Choose a unique username (3-50 chars)' disabled={isLoading} /> @@ -87,7 +87,7 @@ export function RegisterForm() {
@@ -96,7 +96,7 @@ export function RegisterForm() { name='email' type='email' required - className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' + className='w-full bg-surface-alt border border-outline rounded-lg px-4 py-3 text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Enter your email address' disabled={isLoading} /> @@ -104,7 +104,7 @@ export function RegisterForm() {
@@ -114,7 +114,7 @@ export function RegisterForm() { type='password' required minLength={6} - className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' + className='w-full bg-surface-alt border border-outline rounded-lg px-4 py-3 text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Create a strong password (6+ chars)' disabled={isLoading} /> @@ -122,7 +122,7 @@ export function RegisterForm() {
@@ -132,7 +132,7 @@ export function RegisterForm() { type='password' required minLength={6} - className='w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' + className='w-full bg-surface-alt border border-outline rounded-lg px-4 py-3 text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 transition-colors' placeholder='Confirm your password' disabled={isLoading} /> @@ -142,30 +142,11 @@ export function RegisterForm() { -
-

+

+

Already have an account?{' '}

-
-
- {score} -
Score
+
+
+ {score} +
Score
@@ -66,12 +66,7 @@ function renderCell( return (
); } @@ -85,11 +80,11 @@ function renderCell( height: cellSize, boxSizing: 'border-box', borderRadius: 2, - background: 'rgba(255,255,255,0.12)', - borderTop: '1px solid rgba(255,255,255,0.15)', - borderLeft: '1px solid rgba(255,255,255,0.1)', - borderBottom: '1px solid rgba(0,0,0,0.3)', - borderRight: '1px solid rgba(0,0,0,0.2)', + background: 'var(--cell-ghost-bg)', + borderTop: '1px solid var(--cell-ghost-border-light)', + borderLeft: '1px solid var(--cell-ghost-border-light)', + borderBottom: '1px solid var(--cell-ghost-border-dark)', + borderRight: '1px solid var(--cell-ghost-border-dark)', }} /> ); @@ -104,11 +99,11 @@ function renderCell( height: cellSize, boxSizing: 'border-box', borderRadius: 2, - background: '#374151', - borderTop: '1px solid rgba(255,255,255,0.08)', - borderLeft: '1px solid rgba(255,255,255,0.05)', - borderBottom: '1px solid rgba(0,0,0,0.3)', - borderRight: '1px solid rgba(0,0,0,0.2)', + background: 'var(--cell-penalty-bg)', + borderTop: '1px solid var(--cell-penalty-border-light)', + borderLeft: '1px solid var(--cell-penalty-border-light)', + borderBottom: '1px solid var(--cell-penalty-border-dark)', + borderRight: '1px solid var(--cell-penalty-border-dark)', }} /> ); @@ -124,10 +119,10 @@ function renderCell( boxSizing: 'border-box', borderRadius: 2, background: color, - borderTop: '1px solid rgba(255,255,255,0.15)', - borderLeft: '1px solid rgba(255,255,255,0.1)', - borderBottom: '1px solid rgba(0,0,0,0.3)', - borderRight: '1px solid rgba(0,0,0,0.2)', + borderTop: '1px solid var(--piece-border-light)', + borderLeft: '1px solid var(--piece-border-light)', + borderBottom: '1px solid var(--piece-border-dark)', + borderRight: '1px solid var(--piece-border-dark)', }} /> ); diff --git a/client/app/components/game/GamePage.tsx b/client/app/components/game/GamePage.tsx index 9b5407a..a6aff53 100644 --- a/client/app/components/game/GamePage.tsx +++ b/client/app/components/game/GamePage.tsx @@ -16,11 +16,15 @@ const CELL = 28; const MAX_WIDTH = Math.max(...Object.values(GAME_SETUP).map((s) => s.width)); const MAX_HEIGHT = Math.max(...Object.values(GAME_SETUP).map((s) => s.height)); +interface GamePageProps { + initialRoom?: string; +} + /** * Main game page component — composes all game sub-components and wires up hooks. */ -export function GamePage() { - const game = useGameSocket(); +export function GamePage({ initialRoom }: GamePageProps) { + const game = useGameSocket(initialRoom); useKeyboardControls(game.socketRef, game.room, game.playerId); const setup = GAME_SETUP[game.selectedMode]; @@ -42,6 +46,7 @@ export function GamePage() { error: game.error, message: game.message, hostName: game.hostName, + playerName: game.playerName, create: game.create, join: game.join, start: game.start, @@ -51,20 +56,20 @@ export function GamePage() { return (
Back

RED TETRIS

-

Play Mode

+

Play Mode

@@ -82,10 +87,10 @@ export function GamePage() { {/* Center: Game Area */}
{/* Hold Piece */} -
-

Hold

+
+

Hold

{/* Main Game Board */} -
-

Your Board

+
+

Your Board

{modeInfo.icon} {modeInfo.label} - ({modeInfo.boardLabel}) + ({modeInfo.boardLabel})
{/* Next Piece */} -
-

Next

+
+

Next

0 && (
-
-

Opponents

+
+

Opponents

{game.opponents.map((op) => ( 0; cells.push( @@ -53,18 +53,18 @@ export function OpponentCard({ name, board, isAlive, score, isHost, idColors, wi }; return ( -
+
{/* Left: Info */}
- {name} + {name}
- {isHost && Host} -
- Score: {score} + {isHost && Host} +
+ Score: {score}
{/* Right: Board */} -
+
{renderOpponentBoard()}
diff --git a/client/app/components/game/PiecePreview.tsx b/client/app/components/game/PiecePreview.tsx index 4c1c287..95830c1 100644 --- a/client/app/components/game/PiecePreview.tsx +++ b/client/app/components/game/PiecePreview.tsx @@ -37,10 +37,10 @@ export function PiecePreview({ type, shapes, colors, cellSize }: PiecePreviewPro boxSizing: 'border-box', borderRadius: 2, background: color, - borderTop: '1px solid rgba(255,255,255,0.15)', - borderLeft: '1px solid rgba(255,255,255,0.1)', - borderBottom: '1px solid rgba(0,0,0,0.3)', - borderRight: '1px solid rgba(0,0,0,0.2)', + borderTop: '1px solid var(--piece-border-light)', + borderLeft: '1px solid var(--piece-border-light)', + borderBottom: '1px solid var(--piece-border-dark)', + borderRight: '1px solid var(--piece-border-dark)', }} /> ) : ( diff --git a/client/app/components/game/RoomControls.tsx b/client/app/components/game/RoomControls.tsx index b6e6847..bb5479b 100644 --- a/client/app/components/game/RoomControls.tsx +++ b/client/app/components/game/RoomControls.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { GameModeSelector } from '../GameModeSelector'; import type { GameMode } from '../../constants'; import type { GameState } from '../../types/socket'; @@ -13,6 +14,7 @@ interface RoomControlsProps { error: string | null; message: string | null; hostName: string | null; + playerName: string | null; create: () => void; join: () => void; start: () => void; @@ -35,6 +37,7 @@ export function RoomControls({ error, message, hostName, + playerName, create, join, start, @@ -44,26 +47,62 @@ export function RoomControls({ }: RoomControlsProps) { const isHost = hostId === playerId || state?.hostId === playerId; const canRestart = state?.status === 'finished' && playerId && hostId && playerId === hostId; + const [linkCopied, setLinkCopied] = useState(false); + + const copyRoomLink = () => { + const name = playerName ?? 'player'; + const url = `${window.location.origin}/${encodeURIComponent(room)}/${encodeURIComponent(name)}`; + + const onSuccess = () => { + setLinkCopied(true); + setTimeout(() => setLinkCopied(false), 2000); + }; + + if (navigator.clipboard?.writeText) { + navigator.clipboard + .writeText(url) + .then(onSuccess) + .catch(() => fallbackCopy(url, onSuccess)); + } else { + fallbackCopy(url, onSuccess); + } + }; + + const fallbackCopy = (text: string, onSuccess: () => void) => { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand('copy'); + onSuccess(); + } catch { + /* silent fail */ + } + document.body.removeChild(textarea); + }; if (compact) { return ( -
-

Room Settings

+
+

Room Settings

- + setRoom(e.target.value)} disabled={!!playerId} - className='w-full px-3 py-2 text-sm bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 disabled:opacity-50 disabled:cursor-not-allowed' + className='w-full px-3 py-2 text-sm bg-surface-alt border border-outline rounded-lg text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 disabled:opacity-50 disabled:cursor-not-allowed' placeholder='Room code' />
- + Create @@ -105,11 +144,19 @@ export function RoomControls({ {playerId && ( )} + {playerId && room && ( + + )}
{/* Status Messages */} @@ -130,23 +177,23 @@ export function RoomControls({ /* Desktop variant */ return (
-
-

Room Settings

+
+

Room Settings

- + setRoom(e.target.value)} disabled={!!playerId} - className='w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 disabled:opacity-50 disabled:cursor-not-allowed' + className='w-full px-3 py-2 bg-surface-alt border border-outline rounded-lg text-on-surface placeholder-on-surface-faint focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 disabled:opacity-50 disabled:cursor-not-allowed' placeholder='Enter room code' />
- + Join Room @@ -175,7 +222,7 @@ export function RoomControls({ @@ -190,11 +237,19 @@ export function RoomControls({ {playerId && ( )} + {playerId && room && ( + + )}
{/* Status Messages */} @@ -233,26 +288,26 @@ function StatusMessages({ return (
{hostId && playerId === hostId && ( -
- Room: - {room} +
+ Room: + {room}
)} {hostName && ( -
- Host: - {hostName} - {isHost ? (you) : null} +
+ Host: + {hostName} + {isHost ? (you) : null}
)} {error && ( -
- {error} +
+ {error}
)} {message && ( -
- {message} +
+ {message}
)}
diff --git a/client/app/hooks/useGameSocket.ts b/client/app/hooks/useGameSocket.ts index 6eea76d..24a9a63 100644 --- a/client/app/hooks/useGameSocket.ts +++ b/client/app/hooks/useGameSocket.ts @@ -18,6 +18,7 @@ export interface UseGameSocketReturn { actionLoading: boolean; myView: PlayerState | null; hostName: string | null; + playerName: string | null; opponents: OpponentInfo[]; join: () => void; create: () => void; @@ -37,8 +38,9 @@ export interface OpponentInfo { isHost: boolean; } -export function useGameSocket(): UseGameSocketReturn { - const [room, setRoom] = useState(''); +export function useGameSocket(initialRoom?: string): UseGameSocketReturn { + const [room, setRoom] = useState(initialRoom ?? ''); + const initialRoomRef = useRef(initialRoom); const [playerId, setPlayerId] = useState(null); const [selectedMode, setSelectedMode] = useState('classic'); const [state, setState] = useState(null); @@ -142,6 +144,16 @@ export function useGameSocket(): UseGameSocketReturn { else setError(err?.message ?? String(err)); }); + /* auto-join if initialRoom was provided (from /:room/:playerName URL) */ + if (initialRoomRef.current) { + const joinRoom = initialRoomRef.current; + setActionLoading(true); + s.once('connect', () => { + s.emit('join', { roomId: joinRoom }); + }); + s.connect(); + } + return () => { s.off(); s.disconnect(); @@ -218,6 +230,11 @@ export function useGameSocket(): UseGameSocketReturn { return hid ? (state.players?.[hid]?.name ?? hid) : null; }, [state, hostId]); + const playerName = useMemo(() => { + if (!state || !playerId) return null; + return state.players?.[playerId]?.name ?? null; + }, [state, playerId]); + const computeSpectrumFromBoard = (board: number[][]): number[] => { const cols = Array.from({ length: WIDTH }, () => 0); for (let x = 0; x < WIDTH; x++) { @@ -271,6 +288,7 @@ export function useGameSocket(): UseGameSocketReturn { actionLoading, myView, hostName, + playerName, opponents, join, create, diff --git a/client/app/hooks/useTheme.ts b/client/app/hooks/useTheme.ts new file mode 100644 index 0000000..3552723 --- /dev/null +++ b/client/app/hooks/useTheme.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; + +type Theme = 'light' | 'dark'; + +const STORAGE_KEY = 'red-tetris-theme'; + +function resolveClientTheme(): Theme { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'light' || stored === 'dark') return stored; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +export function useTheme() { + const [theme, setTheme] = useState('dark'); + const [isHydrated, setIsHydrated] = useState(false); + + useEffect(() => { + try { + setTheme(resolveClientTheme()); + } finally { + setIsHydrated(true); + } + }, []); + + useEffect(() => { + if (!isHydrated) return; + + const root = document.documentElement; + if (theme === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + localStorage.setItem(STORAGE_KEY, theme); + }, [theme, isHydrated]); + + const toggle = () => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')); + + return { theme, toggle, isHydrated } as const; +} diff --git a/client/app/root.tsx b/client/app/root.tsx index 1985cdf..2f91d60 100644 --- a/client/app/root.tsx +++ b/client/app/root.tsx @@ -5,6 +5,8 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration, import type { Route } from './+types/routes'; import './app.css'; import { LoadingOverlay } from './components/LoadingOverlay'; +import { ThemeToggle } from './components/ThemeToggle'; +import { useTheme } from './hooks/useTheme'; import { store } from './store/store'; export const links: Route.LinksFunction = () => [ @@ -18,7 +20,10 @@ export const links: Route.LinksFunction = () => [ export function Layout({ children }: { children: React.ReactNode }) { return ( - + - + {children} @@ -40,9 +45,16 @@ export function Layout({ children }: { children: React.ReactNode }) { export default function App() { const navigation = useNavigation(); const isNavigating = navigation.state === 'loading'; + const { theme, toggle, isHydrated } = useTheme(); return ( + {isHydrated && ( + + )} {isNavigating && } @@ -68,12 +80,12 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { } return ( -
+

{message}

-

{details}

+

{details}

{stack && ( -
+          
             {stack}
           
)} diff --git a/client/app/routes.ts b/client/app/routes.ts index 0318af5..1c361b1 100644 --- a/client/app/routes.ts +++ b/client/app/routes.ts @@ -1,8 +1,10 @@ import { type RouteConfig, index, route } from '@react-router/dev/routes'; export default [ + route('.well-known/appspecific/com.chrome.devtools.json', 'routes/chrome-devtools.tsx'), index('routes/home.tsx'), route('login', 'routes/login.tsx'), route('register', 'routes/register.tsx'), route('game', 'routes/game.tsx'), + route(':room/:playerName', 'routes/join.tsx'), ] satisfies RouteConfig; diff --git a/client/app/routes/+types/home.d.ts b/client/app/routes/+types/home.d.ts deleted file mode 100644 index 1f0e4fb..0000000 --- a/client/app/routes/+types/home.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -export namespace Route { - export type MetaArgs = Record; -} - -export {}; diff --git a/client/app/routes/chrome-devtools.tsx b/client/app/routes/chrome-devtools.tsx new file mode 100644 index 0000000..8444757 --- /dev/null +++ b/client/app/routes/chrome-devtools.tsx @@ -0,0 +1,7 @@ +export function loader() { + return new Response(null, { status: 204 }); +} + +export default function ChromeDevtoolsProbe() { + return null; +} diff --git a/client/app/routes/join.tsx b/client/app/routes/join.tsx new file mode 100644 index 0000000..0ec6382 --- /dev/null +++ b/client/app/routes/join.tsx @@ -0,0 +1,12 @@ +import { useParams } from 'react-router'; +import { ProtectedRoute } from '../components/auth/ProtectedRoute'; +import { GamePage } from '../components/game/GamePage'; + +export default function JoinRoute() { + const { room } = useParams<{ room: string; playerName: string }>(); + return ( + + + + ); +} diff --git a/client/app/services/games.ts b/client/app/services/games.ts index 3917940..68d801d 100644 --- a/client/app/services/games.ts +++ b/client/app/services/games.ts @@ -69,6 +69,7 @@ const createGamesService = () => { let highScore = 0; let totalScore = 0; let bestPlace = Infinity; + let multiplayerGames = 0; for (const game of games) { const myRecord = game.players.find((p) => p.player.id === userId); @@ -76,14 +77,20 @@ const createGamesService = () => { totalScore += myRecord.score; if (myRecord.score > highScore) highScore = myRecord.score; - if (myRecord.place < bestPlace) bestPlace = myRecord.place; - if (myRecord.place === 1) wins++; + + // Only count wins, win rate and best place for multiplayer games + const isMultiplayer = game.players.length > 1; + if (isMultiplayer) { + multiplayerGames++; + if (myRecord.place < bestPlace) bestPlace = myRecord.place; + if (myRecord.place === 1) wins++; + } } return { gamesPlayed: games.length, wins, - winRate: games.length > 0 ? Math.round((wins / games.length) * 100) : 0, + winRate: multiplayerGames > 0 ? Math.round((wins / multiplayerGames) * 100) : 0, highScore, averageScore: games.length > 0 ? Math.round(totalScore / games.length) : 0, bestPlace: bestPlace === Infinity ? 0 : bestPlace, diff --git a/client/app/welcome/welcome.tsx b/client/app/welcome/welcome.tsx index a1545bf..a17fdd7 100644 --- a/client/app/welcome/welcome.tsx +++ b/client/app/welcome/welcome.tsx @@ -43,33 +43,33 @@ export function Welcome() { }; return ( -
+
{/* Header */}

RED TETRIS

-

Multiplayer Tetris

+

Multiplayer Tetris

{user && (
{/* Profile Section */}
-
+
{user.username.charAt(0).toUpperCase()}
-

{user.username}

-

{user.email}

-

+

{user.username}

+

{user.email}

+

Member since {new Date(user.created_at).toLocaleDateString()}

@@ -89,79 +89,79 @@ export function Welcome() { > PLAY NOW -

Ready to drop some blocks?

+

Ready to drop some blocks?

{/* Stats Grid */} -
-

Your Stats

+
+

Your Stats

{statsLoading ? (
-

Loading stats...

+

Loading stats...

) : stats && stats.gamesPlayed > 0 ? (
-
-
{stats.gamesPlayed}
-
Games Played
+
+
{stats.gamesPlayed}
+
Games Played
-
-
{stats.highScore.toLocaleString()}
-
High Score
+
+
{stats.highScore.toLocaleString()}
+
High Score
-
-
{stats.wins}
-
Wins
+
+
{stats.wins}
+
Wins
-
-
{stats.averageScore.toLocaleString()}
-
Avg Score
+
+
{stats.averageScore.toLocaleString()}
+
Avg Score
-
-
{stats.winRate}%
-
Win Rate
+
+
{stats.winRate}%
+
Win Rate
-
-
#{stats.bestPlace}
-
Best Place
+
+
#{stats.bestPlace}
+
Best Place
) : (
-

No games played yet

-

Play your first game to see your stats!

+

No games played yet

+

Play your first game to see your stats!

)}
{/* Recent Games */} {recentGames.length > 0 && ( -
-

Recent Games

+
+

Recent Games

{recentGames.map((game) => { const myRecord = game.players.find((p) => p.player.id === user.id); return (
#{myRecord?.place ?? '?'}
-
+
{game.players.length} player{game.players.length !== 1 ? 's' : ''}
-
+
{new Date(game.finished_at).toLocaleDateString()} at{' '} {new Date(game.finished_at).toLocaleTimeString([], { hour: '2-digit', @@ -171,8 +171,8 @@ export function Welcome() {
-
{myRecord?.score.toLocaleString() ?? 0}
-
points
+
{myRecord?.score.toLocaleString() ?? 0}
+
points
); diff --git a/client/package.json b/client/package.json index 073af9d..7beb510 100644 --- a/client/package.json +++ b/client/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "sh -c 'react-router dev --port ${CLIENT_PORT:-3001} --host 0.0.0.0'", "build": "react-router build", - "start": "react-router-serve build/server/index.js", + "start": "sh -c 'PORT=${CLIENT_PORT:-3001} HOST=0.0.0.0 react-router-serve build/server/index.js'", "lint": "eslint . --ext js,jsx,ts,tsx --ignore-pattern .react-router --ignore-pattern build --ignore-pattern node_modules --ignore-pattern coverage --fix", "lint:check": "eslint . --ext js,jsx,ts,tsx --ignore-pattern .react-router --ignore-pattern build --ignore-pattern node_modules --ignore-pattern coverage", "prettier": "prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --ignore-path ../.prettierignore --write", diff --git a/client/tests/ErrorButton.test.tsx b/client/tests/ErrorButton.test.tsx new file mode 100644 index 0000000..c14eff8 --- /dev/null +++ b/client/tests/ErrorButton.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import ErrorButton from '~/components/ErrorButton'; + +describe('ErrorButton', () => { + it('renders button text', () => { + render(); + expect(screen.getByRole('button', { name: 'Break the world' })).toBeInTheDocument(); + }); + + it('includes the red button styling classes', () => { + render(); + const button = screen.getByRole('button', { name: 'Break the world' }); + + expect(button.className).toContain('bg-red-600'); + expect(button.className).toContain('hover:bg-red-500'); + }); + + it('throws the expected error when clicked', () => { + const element = ErrorButton(); + const { onClick } = element.props as { onClick?: () => void }; + + expect(onClick).toBeTypeOf('function'); + + if (!onClick) { + throw new Error('Expected onClick handler to be defined'); + } + + expect(() => onClick()).toThrowError('This is a test error from the ErrorButton component'); + }); +}); diff --git a/client/tests/GameModeSelector.test.tsx b/client/tests/GameModeSelector.test.tsx index e6cef52..3046364 100644 --- a/client/tests/GameModeSelector.test.tsx +++ b/client/tests/GameModeSelector.test.tsx @@ -149,7 +149,7 @@ describe('GameModeSelector', () => { it('does not highlight non-selected modes', () => { render(); const narrowBtn = screen.getByText('Narrow').closest('button'); - expect(narrowBtn?.className).toContain('bg-gray-800'); + expect(narrowBtn?.className).toContain('bg-surface-alt'); expect(narrowBtn?.className).not.toContain('ring-1'); }); }); diff --git a/client/tests/OpponentCard.test.tsx b/client/tests/OpponentCard.test.tsx index 5766bf1..7136fd5 100644 --- a/client/tests/OpponentCard.test.tsx +++ b/client/tests/OpponentCard.test.tsx @@ -150,7 +150,7 @@ describe('OpponentCard', () => { /> ); const cell = container.querySelector('[style*="box-sizing"]'); - expect(cell?.style.background).toBe('rgba(255, 255, 255, 0.06)'); + expect(cell?.style.background).toBe('var(--opponent-ghost-alive)'); }); it('renders penalty cells (val=-2) with dead color', () => { @@ -165,7 +165,7 @@ describe('OpponentCard', () => { /> ); const cell = container.querySelector('[style*="box-sizing"]'); - expect(cell?.style.background).toBe('rgba(255, 255, 255, 0.03)'); + expect(cell?.style.background).toBe('var(--opponent-ghost-dead)'); }); it('renders locked cells (val=-1) with alive color', () => { @@ -180,7 +180,7 @@ describe('OpponentCard', () => { /> ); const cell = container.querySelector('[style*="box-sizing"]'); - expect(cell?.style.background).toBe('rgb(17, 17, 17)'); + expect(cell?.style.background).toBe('var(--opponent-penalty-alive)'); }); it('renders locked cells (val=-1) with dead color', () => { @@ -195,7 +195,7 @@ describe('OpponentCard', () => { /> ); const cell = container.querySelector('[style*="box-sizing"]'); - expect(cell?.style.background).toBe('rgb(68, 68, 68)'); + expect(cell?.style.background).toBe('var(--opponent-penalty-dead)'); }); it('renders piece cells (val>0) with idColors when alive', () => { @@ -213,7 +213,7 @@ describe('OpponentCard', () => { expect(cell?.style.background).toBe('rgb(255, 0, 0)'); // idColors[1] }); - it('renders piece cells (val>0) as #777 when dead', () => { + it('renders piece cells (val>0) as dead-piece color when dead', () => { const board = [[1]]; const { container } = render( { /> ); const cell = container.querySelector('[style*="box-sizing"]'); - expect(cell?.style.background).toBe('rgb(119, 119, 119)'); + expect(cell?.style.background).toBe('var(--opponent-dead-piece)'); }); it('falls back to #999 when idColors does not have the piece index', () => { diff --git a/client/tests/RoomControls.test.tsx b/client/tests/RoomControls.test.tsx new file mode 100644 index 0000000..1adbd89 --- /dev/null +++ b/client/tests/RoomControls.test.tsx @@ -0,0 +1,586 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { RoomControls } from '~/components/game/RoomControls'; + +// Mock GameModeSelector to keep tests focused on RoomControls logic +vi.mock('~/components/GameModeSelector', () => ({ + GameModeSelector: ({ selected, disabled }: { selected: string; disabled?: boolean }) => ( +
+ mode={selected} disabled={String(!!disabled)} +
+ ), +})); + +const defaultProps = { + room: 'test-room', + setRoom: vi.fn(), + selectedMode: 'classic' as const, + setSelectedMode: vi.fn(), + playerId: null as string | null, + hostId: null as string | null, + state: null, + error: null as string | null, + message: null as string | null, + hostName: null as string | null, + playerName: null as string | null, + create: vi.fn(), + join: vi.fn(), + start: vi.fn(), + leave: vi.fn(), + restart: vi.fn(), +}; + +describe('RoomControls', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('desktop variant (default)', () => { + it('renders Room Settings heading', () => { + render(); + expect(screen.getByText('Room Settings')).toBeInTheDocument(); + }); + + it('renders room code input with current value', () => { + render(); + const input = screen.getByPlaceholderText('Enter room code'); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue('test-room'); + }); + + it('calls setRoom when input changes', () => { + const setRoom = vi.fn(); + render( + + ); + fireEvent.change(screen.getByPlaceholderText('Enter room code'), { target: { value: 'new-room' } }); + expect(setRoom).toHaveBeenCalledWith('new-room'); + }); + + it('disables room input when player is in a room', () => { + render( + + ); + expect(screen.getByPlaceholderText('Enter room code')).toBeDisabled(); + }); + + it('renders Create Room button when not in a room', () => { + render(); + expect(screen.getByText('Create Room')).toBeInTheDocument(); + }); + + it('renders Join Room button when not in a room', () => { + render(); + expect(screen.getByText('Join Room')).toBeInTheDocument(); + }); + + it('hides Create/Join when in a room', () => { + render( + + ); + expect(screen.queryByText('Create Room')).not.toBeInTheDocument(); + expect(screen.queryByText('Join Room')).not.toBeInTheDocument(); + }); + + it('calls create when Create Room is clicked', () => { + const create = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Create Room')); + expect(create).toHaveBeenCalledOnce(); + }); + + it('calls join when Join Room is clicked', () => { + const join = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Join Room')); + expect(join).toHaveBeenCalledOnce(); + }); + + it('disables Join Room when room is empty', () => { + render( + + ); + expect(screen.getByText('Join Room')).toBeDisabled(); + }); + + it('disables Start Game when no playerId', () => { + render(); + expect(screen.getByText('Start Game')).toBeDisabled(); + }); + + it('disables Start Game when player is not host', () => { + render( + + ); + expect(screen.getByText('Start Game')).toBeDisabled(); + }); + + it('enables Start Game when player is the host', () => { + render( + + ); + expect(screen.getByText('Start Game')).not.toBeDisabled(); + }); + + it('calls start when Start Game is clicked', () => { + const start = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Start Game')); + expect(start).toHaveBeenCalledOnce(); + }); + + it('shows Leave Room button only when in a room', () => { + const { rerender } = render(); + expect(screen.queryByText('Leave Room')).not.toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByText('Leave Room')).toBeInTheDocument(); + }); + + it('calls leave when Leave Room is clicked', () => { + const leave = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Leave Room')); + expect(leave).toHaveBeenCalledOnce(); + }); + + it('shows Restart button only when game is finished and player is host', () => { + render( + + ); + expect(screen.getByText('Restart')).toBeInTheDocument(); + }); + + it('hides Restart when game is not finished', () => { + render( + + ); + expect(screen.queryByText('Restart')).not.toBeInTheDocument(); + }); + + it('hides Restart when player is not host', () => { + render( + + ); + expect(screen.queryByText('Restart')).not.toBeInTheDocument(); + }); + + it('calls restart when Restart is clicked', () => { + const restart = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Restart')); + expect(restart).toHaveBeenCalledOnce(); + }); + + it('renders GameModeSelector', () => { + render(); + expect(screen.getByTestId('game-mode-selector')).toBeInTheDocument(); + }); + }); + + describe('compact variant', () => { + it('renders compact heading', () => { + render( + + ); + expect(screen.getByText('Room Settings')).toBeInTheDocument(); + }); + + it('renders room code input in compact mode', () => { + render( + + ); + expect(screen.getByPlaceholderText('Room code')).toBeInTheDocument(); + }); + + it('renders Create/Join/Start buttons in compact mode', () => { + render( + + ); + expect(screen.getByText('Create')).toBeInTheDocument(); + expect(screen.getByText('Join')).toBeInTheDocument(); + expect(screen.getByText('Start')).toBeInTheDocument(); + }); + + it('disables Create in compact when already in a room', () => { + render( + + ); + expect(screen.getByText('Create')).toBeDisabled(); + }); + + it('disables Join in compact when no room or already in room', () => { + render( + + ); + expect(screen.getByText('Join')).toBeDisabled(); + }); + + it('shows Leave button in compact when in a room', () => { + render( + + ); + expect(screen.getByText('Leave')).toBeInTheDocument(); + }); + + it('shows Restart in compact when game finished and host', () => { + render( + + ); + expect(screen.getByText('Restart')).toBeInTheDocument(); + }); + }); + + describe('Copy Link button', () => { + it('shows Copy Link when in a room with a room code', () => { + render( + + ); + expect(screen.getByText('Copy Link')).toBeInTheDocument(); + }); + + it('hides Copy Link when not in a room', () => { + render( + + ); + expect(screen.queryByText('Copy Link')).not.toBeInTheDocument(); + }); + + it('hides Copy Link when room is empty', () => { + render( + + ); + expect(screen.queryByText('Copy Link')).not.toBeInTheDocument(); + }); + + it('copies link via navigator.clipboard and shows Copied!', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + render( + + ); + fireEvent.click(screen.getByText('Copy Link')); + + await waitFor(() => { + expect(screen.getByText('Copied!')).toBeInTheDocument(); + }); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/my-room/alice')); + }); + + it('uses fallback copy when clipboard API is unavailable', () => { + // Remove clipboard API + Object.assign(navigator, { clipboard: undefined }); + + const execCommand = vi.fn().mockReturnValue(true); + document.execCommand = execCommand; + + render( + + ); + fireEvent.click(screen.getByText('Copy Link')); + + expect(execCommand).toHaveBeenCalledWith('copy'); + }); + + it('uses fallback when clipboard.writeText rejects', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('denied')); + Object.assign(navigator, { clipboard: { writeText } }); + + const execCommand = vi.fn().mockReturnValue(true); + document.execCommand = execCommand; + + render( + + ); + fireEvent.click(screen.getByText('Copy Link')); + + await waitFor(() => { + expect(execCommand).toHaveBeenCalledWith('copy'); + }); + }); + + it('uses "player" as default name when playerName is null', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + render( + + ); + fireEvent.click(screen.getByText('Copy Link')); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/my-room/player')); + }); + }); + + it('shows Copy Link in compact mode', () => { + render( + + ); + expect(screen.getByText('Copy Link')).toBeInTheDocument(); + }); + }); + + describe('StatusMessages', () => { + it('shows room code when player is host', () => { + render( + + ); + expect(screen.getByText('Room:')).toBeInTheDocument(); + expect(screen.getByText('ABC123')).toBeInTheDocument(); + }); + + it('does not show room code when player is not host', () => { + render( + + ); + // The "Room:" label should not be present for non-host + expect(screen.queryByText('Room:')).not.toBeInTheDocument(); + }); + + it('shows host name', () => { + render( + + ); + expect(screen.getByText('Host:')).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + it('shows (you) indicator when current player is host', () => { + render( + + ); + expect(screen.getByText('(you)')).toBeInTheDocument(); + }); + + it('does not show (you) when not the host', () => { + render( + + ); + expect(screen.queryByText('(you)')).not.toBeInTheDocument(); + }); + + it('shows error message', () => { + render( + + ); + expect(screen.getByText('Room not found')).toBeInTheDocument(); + }); + + it('shows success message', () => { + render( + + ); + expect(screen.getByText('Player joined!')).toBeInTheDocument(); + }); + + it('does not show error when null', () => { + render( + + ); + // No error container + const errorEls = document.querySelectorAll('.text-status-error-text'); + expect(errorEls.length).toBe(0); + }); + + it('shows status messages in compact mode', () => { + render( + + ); + expect(screen.getByText('Some error')).toBeInTheDocument(); + expect(screen.getByText('Some message')).toBeInTheDocument(); + expect(screen.getByText('Host1')).toBeInTheDocument(); + }); + }); + + describe('isHost via state.hostId fallback', () => { + it('uses state.hostId when hostId prop is null', () => { + render( + + ); + // canRestart should be false because hostId prop is null (used for canRestart check) + // but isHost uses state.hostId fallback + // hostName not set so no "(you)" check; but at least shouldn't crash + expect(screen.getByText('Start Game')).toBeInTheDocument(); + }); + }); +}); diff --git a/client/tests/ThemeToggle.test.tsx b/client/tests/ThemeToggle.test.tsx new file mode 100644 index 0000000..2f96fd7 --- /dev/null +++ b/client/tests/ThemeToggle.test.tsx @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeToggle } from '~/components/ThemeToggle'; + +describe('ThemeToggle', () => { + it('renders sun icon in dark mode', () => { + const { container } = render( + + ); + const icon = container.querySelector('span[aria-hidden="true"]'); + expect(icon).toBeInTheDocument(); + expect(icon?.classList.contains('text-yellow-400')).toBe(true); + expect(icon?.textContent).toBe('☀'); + }); + + it('renders moon icon in light mode', () => { + const { container } = render( + + ); + const icon = container.querySelector('span[aria-hidden="true"]'); + expect(icon).toBeInTheDocument(); + expect(icon?.classList.contains('text-slate-600')).toBe(true); + expect(icon?.textContent).toBe('☾'); + }); + + it('has correct aria-label for dark mode (switch to light)', () => { + render( + + ); + expect(screen.getByLabelText('Switch to light mode')).toBeInTheDocument(); + }); + + it('has correct aria-label for light mode (switch to dark)', () => { + render( + + ); + expect(screen.getByLabelText('Switch to dark mode')).toBeInTheDocument(); + }); + + it('has correct title for dark mode', () => { + render( + + ); + expect(screen.getByTitle('Switch to light mode')).toBeInTheDocument(); + }); + + it('has correct title for light mode', () => { + render( + + ); + expect(screen.getByTitle('Switch to dark mode')).toBeInTheDocument(); + }); + + it('calls toggle when clicked', () => { + const toggle = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole('button')); + expect(toggle).toHaveBeenCalledOnce(); + }); + + it('renders as a button element', () => { + render( + + ); + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button.tagName).toBe('BUTTON'); + }); +}); diff --git a/client/tests/chromeDevtoolsRoute.test.tsx b/client/tests/chromeDevtoolsRoute.test.tsx new file mode 100644 index 0000000..112204c --- /dev/null +++ b/client/tests/chromeDevtoolsRoute.test.tsx @@ -0,0 +1,19 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import ChromeDevtoolsProbe, { loader } from '~/routes/chrome-devtools'; + +describe('chrome-devtools route', () => { + it('returns 204 from loader', () => { + const response = loader(); + + expect(response).toBeInstanceOf(Response); + expect(response.status).toBe(204); + }); + + it('renders null component', () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/client/tests/gamesService.test.ts b/client/tests/gamesService.test.ts index 9d76436..f6f0251 100644 --- a/client/tests/gamesService.test.ts +++ b/client/tests/gamesService.test.ts @@ -181,5 +181,57 @@ describe('gamesService', () => { expect(stats.highScore).toBe(0); expect(stats.bestPlace).toBe(0); // Infinity becomes 0 }); + + it('excludes solo games from wins, winRate and bestPlace', () => { + const games = [ + { + game_id: 1, + finished_at: '2025-01-01', + players: [{ player: { id: 1, username: 'me' }, score: 800, place: 1 }], + }, + ]; + + const stats = gamesService.computeStats(games, 1); + expect(stats.gamesPlayed).toBe(1); + expect(stats.wins).toBe(0); // solo game, not counted + expect(stats.winRate).toBe(0); // no multiplayer games + expect(stats.highScore).toBe(800); // still counted for score + expect(stats.averageScore).toBe(800); + expect(stats.bestPlace).toBe(0); // solo excluded + }); + + it('counts multiplayer wins but not solo wins', () => { + const games = [ + { + game_id: 1, + finished_at: '2025-01-01', + players: [{ player: { id: 1, username: 'me' }, score: 500, place: 1 }], + }, + { + game_id: 2, + finished_at: '2025-01-02', + players: [ + { player: { id: 1, username: 'me' }, score: 300, place: 1 }, + { player: { id: 2, username: 'other' }, score: 100, place: 2 }, + ], + }, + { + game_id: 3, + finished_at: '2025-01-03', + players: [ + { player: { id: 1, username: 'me' }, score: 200, place: 2 }, + { player: { id: 3, username: 'third' }, score: 400, place: 1 }, + ], + }, + ]; + + const stats = gamesService.computeStats(games, 1); + expect(stats.gamesPlayed).toBe(3); + expect(stats.wins).toBe(1); // only game 2 (multiplayer win) + expect(stats.winRate).toBe(50); // 1 win / 2 multiplayer games + expect(stats.highScore).toBe(500); // solo game score still counts + expect(stats.averageScore).toBe(333); // Math.round((500+300+200)/3) + expect(stats.bestPlace).toBe(1); // from multiplayer game 2 + }); }); }); diff --git a/client/tests/routes.test.tsx b/client/tests/routes.test.tsx index 81780a0..4be6e05 100644 --- a/client/tests/routes.test.tsx +++ b/client/tests/routes.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; -import { MemoryRouter } from 'react-router'; +import { MemoryRouter, Route, Routes } from 'react-router'; import authReducer from '~/store/authSlice'; // Mock services @@ -27,6 +27,12 @@ vi.mock('~/services/games', () => ({ }, })); +vi.mock('~/components/game/GamePage', () => ({ + GamePage: ({ initialRoom }: { initialRoom?: string }) => ( +
GamePage: {initialRoom ?? 'no-room'}
+ ), +})); + const createStore = (authState = {}) => configureStore({ reducer: { auth: authReducer }, @@ -118,3 +124,55 @@ describe('Route meta functions', () => { expect(result).toEqual(expect.arrayContaining([expect.objectContaining({ title: 'Red Tetris - Home' })])); }); }); + +describe('Route: Join', () => { + it('renders GamePage with initialRoom from URL params when authenticated', async () => { + const { default: JoinRoute } = await import('~/routes/join'); + const store = createStore({ + hydrated: true, + isAuthenticated: true, + user: { id: 1, username: 'testuser', email: 'test@test.com', created_at: '', updated_at: '' }, + }); + + render( + + + + } + /> + + + + ); + + expect(screen.getByTestId('game-page')).toBeInTheDocument(); + expect(screen.getByText('GamePage: my-room')).toBeInTheDocument(); + }); + + it('redirects to login when not authenticated', async () => { + const { default: JoinRoute } = await import('~/routes/join'); + const store = createStore({ hydrated: true, isAuthenticated: false }); + + render( + + + + } + /> + Login Page
} + /> + + + + ); + + expect(screen.queryByTestId('game-page')).not.toBeInTheDocument(); + expect(screen.getByText('Login Page')).toBeInTheDocument(); + }); +}); diff --git a/client/tests/useTheme.test.ts b/client/tests/useTheme.test.ts new file mode 100644 index 0000000..c193ac9 --- /dev/null +++ b/client/tests/useTheme.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTheme } from '~/hooks/useTheme'; + +describe('useTheme', () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove('dark'); + vi.mocked(localStorage.getItem).mockClear(); + vi.mocked(localStorage.setItem).mockClear(); + }); + + it('defaults to dark theme when localStorage is empty and prefers-color-scheme is dark', () => { + vi.mocked(localStorage.getItem).mockReturnValue(null); + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi + .fn() + .mockImplementation((query: string) => ({ matches: query === '(prefers-color-scheme: dark)', media: query })), + }); + + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe('dark'); + }); + + it('defaults to light theme when prefers-color-scheme is light', () => { + vi.mocked(localStorage.getItem).mockReturnValue(null); + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ matches: false, media: query })), + }); + + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe('light'); + }); + + it('reads theme from localStorage when stored', () => { + vi.mocked(localStorage.getItem).mockReturnValue('light'); + + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe('light'); + }); + + it('reads dark theme from localStorage', () => { + vi.mocked(localStorage.getItem).mockReturnValue('dark'); + + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe('dark'); + }); + + it('adds dark class to documentElement when theme is dark', () => { + vi.mocked(localStorage.getItem).mockReturnValue('dark'); + + renderHook(() => useTheme()); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); + + it('removes dark class from documentElement when theme is light', () => { + document.documentElement.classList.add('dark'); + vi.mocked(localStorage.getItem).mockReturnValue('light'); + + renderHook(() => useTheme()); + expect(document.documentElement.classList.contains('dark')).toBe(false); + }); + + it('persists theme to localStorage', () => { + vi.mocked(localStorage.getItem).mockReturnValue('dark'); + + renderHook(() => useTheme()); + expect(localStorage.setItem).toHaveBeenCalledWith('red-tetris-theme', 'dark'); + }); + + it('toggles from dark to light', () => { + vi.mocked(localStorage.getItem).mockReturnValue('dark'); + + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe('dark'); + + act(() => { + result.current.toggle(); + }); + + expect(result.current.theme).toBe('light'); + expect(document.documentElement.classList.contains('dark')).toBe(false); + expect(localStorage.setItem).toHaveBeenCalledWith('red-tetris-theme', 'light'); + }); + + it('toggles from light to dark', () => { + vi.mocked(localStorage.getItem).mockReturnValue('light'); + + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe('light'); + + act(() => { + result.current.toggle(); + }); + + expect(result.current.theme).toBe('dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + expect(localStorage.setItem).toHaveBeenCalledWith('red-tetris-theme', 'dark'); + }); + + it('ignores invalid localStorage values and falls back to matchMedia', () => { + vi.mocked(localStorage.getItem).mockReturnValue('invalid-value'); + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi + .fn() + .mockImplementation((query: string) => ({ matches: query === '(prefers-color-scheme: dark)', media: query })), + }); + + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe('dark'); + }); +}); diff --git a/client/vite.config.ts b/client/vite.config.ts index 9c7fa94..5fee987 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,30 +1,15 @@ import { reactRouter } from '@react-router/dev/vite'; import { sentryVitePlugin } from '@sentry/vite-plugin'; import tailwindcss from '@tailwindcss/vite'; -import os from 'os'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; -// Helper function to get the first network IP -function getNetworkIP() { - const interfaces = os.networkInterfaces(); - - for (const name of Object.keys(interfaces)) { - for (const networkInterface of interfaces[name]) { - if (networkInterface.family === 'IPv4' && !networkInterface.internal) { - return networkInterface.address; - } - } - } - return 'localhost'; -} - // Dynamic server URL based on environment const serverPort = process.env.SERVER_PORT ?? '3002'; const clientPort = parseInt(process.env.CLIENT_PORT ?? '3001', 10); const serverUrl = process.env.VITE_SERVER_URL === 'auto' - ? `http://${getNetworkIP()}:${serverPort}` + ? `http://localhost:${serverPort}` : (process.env.VITE_SERVER_URL ?? `http://localhost:${serverPort}`); export default defineConfig({ @@ -37,10 +22,7 @@ export default defineConfig({ envDir: '..', - server: { - host: '0.0.0.0', // Allow external connections - port: clientPort, - }, + server: { host: '0.0.0.0', port: clientPort }, define: { 'import.meta.env.VITE_SERVER_URL': JSON.stringify(serverUrl) }, diff --git a/server/app.ts b/server/app.ts index cf947de..e7b91ea 100644 --- a/server/app.ts +++ b/server/app.ts @@ -19,39 +19,17 @@ import { logDbError, logger } from './utils/logger'; dotenv.config({ path: path.resolve(process.cwd(), '..', '.env') }); -import os from 'os'; - const app = express(); const server = http.createServer(app); -// Helper function to get network IPs -function getNetworkIPs(): string[] { - const interfaces = os.networkInterfaces(); - const ips: string[] = []; - - for (const name of Object.keys(interfaces)) { - for (const networkInterface of interfaces[name] ?? []) { - if (networkInterface.family === 'IPv4' && !networkInterface.internal) { - ips.push(networkInterface.address); - } - } - } - return ips; -} - -// Dynamic CORS origins - include current network IPs +// Localhost-only CORS origins const clientPort = process.env.CLIENT_PORT ?? '3001'; -const clientUrlsRaw = process.env.CLIENT_URL ?? `http://localhost:${clientPort}`; -const baseOrigins = clientUrlsRaw +const clientUrlsRaw = process.env.CLIENT_URL ?? `http://localhost:${clientPort},http://127.0.0.1:${clientPort}`; +const allowedOrigins = clientUrlsRaw .split(',') .map((s) => s.trim()) .filter(Boolean); -// Add current network IPs dynamically -const networkIPs = getNetworkIPs(); -const dynamicOrigins = networkIPs.map((ip) => `http://${ip}:${clientPort}`); -const allowedOrigins = [...baseOrigins, ...dynamicOrigins]; - const io = new IOServer(server, { cors: { origin: allowedOrigins, credentials: true }, });