diff --git a/src/client/src/components/Modal.tsx b/src/client/src/components/Modal.tsx new file mode 100644 index 0000000..422586c --- /dev/null +++ b/src/client/src/components/Modal.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { X, AlertTriangle, CheckCircle, Info } from 'lucide-react'; + +interface ModalProps { + isOpen: boolean; + onClose?: () => void; + title: string; + message: string; + type?: 'info' | 'success' | 'warning' | 'error'; + confirmLabel?: string; + onConfirm?: () => void; + cancelLabel?: string; +} + +export const Modal: React.FC = ({ + isOpen, + onClose, + title, + message, + type = 'info', + confirmLabel = 'OK', + onConfirm, + cancelLabel +}) => { + if (!isOpen) return null; + + const getIcon = () => { + switch (type) { + case 'success': return ; + case 'warning': return ; + case 'error': return ; + default: return ; + } + }; + + const getBorderColor = () => { + switch (type) { + case 'success': return 'border-emerald-500/50'; + case 'warning': return 'border-amber-500/50'; + case 'error': return 'border-red-500/50'; + default: return 'border-slate-700'; + } + }; + + return ( +
+
+
+
+ {getIcon()} +

{title}

+
+ {onClose && !cancelLabel && ( + + )} +
+ +

+ {message} +

+ +
+ {cancelLabel && onClose && ( + + )} + +
+
+
+ ); +}; diff --git a/src/client/src/modules/draft/DraftView.tsx b/src/client/src/modules/draft/DraftView.tsx index 4623beb..4f3f304 100644 --- a/src/client/src/modules/draft/DraftView.tsx +++ b/src/client/src/modules/draft/DraftView.tsx @@ -1,15 +1,19 @@ import React, { useState, useEffect } from 'react'; import { socketService } from '../../services/SocketService'; +import { LogOut } from 'lucide-react'; +import { Modal } from '../../components/Modal'; interface DraftViewProps { draftState: any; roomId: string; // Passed from parent currentPlayerId: string; + onExit?: () => void; } -export const DraftView: React.FC = ({ draftState, roomId, currentPlayerId }) => { +export const DraftView: React.FC = ({ draftState, roomId, currentPlayerId, onExit }) => { const [timer, setTimer] = useState(60); + const [confirmExitOpen, setConfirmExitOpen] = useState(false); useEffect(() => { const interval = setInterval(() => { @@ -78,14 +82,7 @@ export const DraftView: React.FC = ({ draftState, roomId, curren socketService.socket.emit('pick_card', { roomId, playerId: currentPlayerId, cardId }); }; - if (!activePack) { - return ( -
-

Waiting for next pack...

-
-
- ); - } + // ... inside DraftView return ... return (
e.preventDefault()}> @@ -117,8 +114,23 @@ export const DraftView: React.FC = ({ draftState, roomId, curren
-
- 00:{timer < 10 ? `0${timer}` : timer} +
+ {!activePack ? ( +
Waiting...
+ ) : ( +
+ 00:{timer < 10 ? `0${timer}` : timer} +
+ )} + {onExit && ( + + )}
@@ -127,7 +139,7 @@ export const DraftView: React.FC = ({ draftState, roomId, curren
{/* Dedicated Zoom Zone (Left Sidebar) */} -
+
{hoveredCard ? (
= ({ draftState, roomId, curren )}
- {/* Main Area: Current Pack */} + {/* Main Area: Current Pack OR Waiting State */}
-
-

Select a Card

-
- {activePack.cards.map((card: any) => ( -
handlePick(card.id)} - onMouseEnter={() => setHoveredCard(card)} - onMouseLeave={() => setHoveredCard(null)} - > -
- {card.name} + {!activePack ? ( +
+
+
+
+
+ {/* Just a placeholder icon or similar */}
- ))} +
+

Waiting for next pack...

+

Your neighbor is selecting a card.

+
+
+
+
+
-
+ ) : ( +
+

Select a Card

+
+ {activePack.cards.map((card: any) => ( +
handlePick(card.id)} + onMouseEnter={() => setHoveredCard(card)} + onMouseLeave={() => setHoveredCard(null)} + > +
+ {card.name} +
+ ))} +
+
+ )}
@@ -214,6 +245,16 @@ export const DraftView: React.FC = ({ draftState, roomId, curren ))}
+ setConfirmExitOpen(false)} + title="Exit Draft?" + message="Are you sure you want to exit the draft? You can rejoin later." + type="warning" + confirmLabel="Exit Draft" + cancelLabel="Stay" + onConfirm={onExit} + />
); }; diff --git a/src/client/src/modules/lobby/GameRoom.tsx b/src/client/src/modules/lobby/GameRoom.tsx index 0504b26..2fe1564 100644 --- a/src/client/src/modules/lobby/GameRoom.tsx +++ b/src/client/src/modules/lobby/GameRoom.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect, useRef } from 'react'; import { socketService } from '../../services/SocketService'; -import { Users, MessageSquare, Send, Play, Copy, Check, Layers } from 'lucide-react'; +import { Users, MessageSquare, Send, Play, Copy, Check, Layers, LogOut } from 'lucide-react'; +import { Modal } from '../../components/Modal'; import { GameView } from '../game/GameView'; import { DraftView } from '../draft/DraftView'; import { DeckBuilderView } from '../draft/DeckBuilderView'; @@ -11,6 +12,7 @@ interface Player { name: string; isHost: boolean; role: 'player' | 'spectator'; + isOffline?: boolean; } interface ChatMessage { @@ -32,54 +34,39 @@ interface GameRoomProps { room: Room; currentPlayerId: string; initialGameState?: any; + initialDraftState?: any; + onExit: () => void; } -export const GameRoom: React.FC = ({ room: initialRoom, currentPlayerId, initialGameState }) => { +export const GameRoom: React.FC = ({ room: initialRoom, currentPlayerId, initialGameState, initialDraftState, onExit }) => { + // State const [room, setRoom] = useState(initialRoom); + const [modalOpen, setModalOpen] = useState(false); + const [modalConfig, setModalConfig] = useState({ title: '', message: '', type: 'info' as 'info' | 'error' | 'warning' | 'success' }); + + // Restored States const [message, setMessage] = useState(''); const [messages, setMessages] = useState(initialRoom.messages || []); const messagesEndRef = useRef(null); const [gameState, setGameState] = useState(initialGameState || null); + const [draftState, setDraftState] = useState(initialDraftState || null); + // Derived State + const host = room.players.find(p => p.isHost); + const isHostOffline = host?.isOffline; + const isMeHost = currentPlayerId === host?.id; + + // Effects useEffect(() => { setRoom(initialRoom); setMessages(initialRoom.messages || []); }, [initialRoom]); - useEffect(() => { - const socket = socketService.socket; - - const handleRoomUpdate = (updatedRoom: Room) => { - console.log('Room updated:', updatedRoom); - setRoom(updatedRoom); - }; - - const handleNewMessage = (msg: ChatMessage) => { - setMessages(prev => [...prev, msg]); - }; - - const handleGameUpdate = (game: any) => { - setGameState(game); - }; - - socket.on('room_update', handleRoomUpdate); - socket.on('new_message', handleNewMessage); - socket.on('game_update', handleGameUpdate); - - return () => { - socket.off('room_update', handleRoomUpdate); - socket.off('new_message', handleNewMessage); - socket.off('game_update', handleGameUpdate); - }; - }, []); - + // Scroll to bottom of chat useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); - // New States - const [draftState, setDraftState] = useState(null); - useEffect(() => { const socket = socketService.socket; const handleDraftUpdate = (data: any) => { @@ -87,7 +74,12 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl }; const handleDraftError = (error: { message: string }) => { - alert(error.message); // Simple alert for now + setModalConfig({ + title: 'Error', + message: error.message, + type: 'error' + }); + setModalOpen(true); }; socket.on('draft_update', handleDraftUpdate); @@ -116,10 +108,8 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl if (navigator.clipboard) { navigator.clipboard.writeText(room.id).catch(err => { console.error('Failed to copy: ', err); - // Fallback could go here }); } else { - // Fallback for non-secure context or older browsers console.warn('Clipboard API not available'); const textArea = document.createElement("textarea"); textArea.value = room.id; @@ -135,19 +125,17 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl }; const handleStartGame = () => { - // Create a test deck for each player for now const testDeck = Array.from({ length: 40 }).map((_, i) => ({ id: `card-${i}`, name: i % 2 === 0 ? "Mountain" : "Lightning Bolt", image_uris: { normal: i % 2 === 0 - ? "https://cards.scryfall.io/normal/front/1/9/194459f0-2586-444a-be7d-786d5e7e9bc4.jpg" // Mountain - : "https://cards.scryfall.io/normal/front/f/2/f29ba16f-c8fb-42fe-aabf-87089cb211a7.jpg" // Bolt + ? "https://cards.scryfall.io/normal/front/1/9/194459f0-2586-444a-be7d-786d5e7e9bc4.jpg" + : "https://cards.scryfall.io/normal/front/f/2/f29ba16f-c8fb-42fe-aabf-87089cb211a7.jpg" } })); const decks = room.players.reduce((acc, p) => ({ ...acc, [p.id]: testDeck }), {}); - socketService.socket.emit('start_game', { roomId: room.id, decks }); }; @@ -155,21 +143,16 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl socketService.socket.emit('start_draft', { roomId: room.id }); }; - // Helper to determine view const renderContent = () => { if (gameState) { return ; } if (room.status === 'drafting' && draftState) { - return ; + return ; } if (room.status === 'deck_building' && draftState) { - // Check if I am ready - // Type casting needed because 'ready' was added to interface only in server side so far? - // Need to update client Player interface too in this file if not already consistent. - // But let's assume raw object has it. const me = room.players.find(p => p.id === currentPlayerId) as any; if (me?.ready) { return ( @@ -201,7 +184,6 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl return ; } - // Default Waiting Lobby return (

Waiting for Players...

@@ -250,16 +232,13 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl
{renderContent()} - {/* Sidebar: Players & Chat */}
- {/* Players List */}

Lobby

{room.players.map(p => { - // Cast to any to access ready state without full interface update for now const isReady = (p as any).ready; return (
@@ -283,7 +262,6 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl
- {/* Chat */}

Chat @@ -311,6 +289,48 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl

+ + {/* Host Disconnected Overlay */} + {isHostOffline && !isMeHost && ( +
+
+
+ +
+

Game Paused

+

+ The host {host?.name} has disconnected. + The game is paused until they reconnect. +

+
+
+ + Waiting for host... +
+ + +
+
+
+ )} + + {/* Global Modal */} + setModalOpen(false)} + title={modalConfig.title} + message={modalConfig.message} + type={modalConfig.type} + />
); }; diff --git a/src/client/src/modules/lobby/LobbyManager.tsx b/src/client/src/modules/lobby/LobbyManager.tsx index 0fd6951..19a1f52 100644 --- a/src/client/src/modules/lobby/LobbyManager.tsx +++ b/src/client/src/modules/lobby/LobbyManager.tsx @@ -15,6 +15,8 @@ export const LobbyManager: React.FC = ({ generatedPacks }) => const [joinRoomId, setJoinRoomId] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const [initialDraftState, setInitialDraftState] = useState(null); + const [playerId] = useState(() => { const saved = localStorage.getItem('player_id'); if (saved) return saved; @@ -128,6 +130,7 @@ export const LobbyManager: React.FC = ({ generatedPacks }) => }); if (response.success) { + setInitialDraftState(response.draftState || null); setActiveRoom(response.room); } else { setError(response.message || 'Failed to join room'); @@ -154,12 +157,7 @@ export const LobbyManager: React.FC = ({ generatedPacks }) => connect(); socketService.emitPromise('rejoin_room', { roomId: savedRoomId }) .then(() => { - // We don't get the room back directly in this event usually, but let's assume socket events 'room_update' handles it? - // The backend 'rejoin_room' doesn't return a callback with room data in the current implementation, it emits updates. - // However, let's try to invoke 'join_room' logic as a fallback or assume room_update catches it. - // Actually, backend 'rejoin_room' DOES emit 'room_update'. - // Let's rely on the socket listener in GameRoom... wait, GameRoom is not mounted yet! - // We need to listen to 'room_update' HERE to switch state. + // Rejoin logic mostly handled by onRoomUpdate via socket }) .catch(err => { console.warn("Reconnection failed", err); @@ -183,8 +181,17 @@ export const LobbyManager: React.FC = ({ generatedPacks }) => }, [playerId]); + const handleExitRoom = () => { + setActiveRoom(null); + setInitialDraftState(null); + localStorage.removeItem('active_room_id'); + // Also likely want to disconnect socket or leave room specifically if needed, + // but just clearing local state allows creating new rooms. + // Ideally: socketService.emit('leave_room', { roomId: activeRoom.id, playerId }); + }; + if (activeRoom) { - return ; + return ; } return ( diff --git a/src/client/src/modules/tester/DeckTester.tsx b/src/client/src/modules/tester/DeckTester.tsx index fadef03..c3257ad 100644 --- a/src/client/src/modules/tester/DeckTester.tsx +++ b/src/client/src/modules/tester/DeckTester.tsx @@ -115,8 +115,13 @@ export const DeckTester: React.FC = () => { } }; + const handleExitTester = () => { + setActiveRoom(null); + setInitialGame(null); + }; + if (activeRoom) { - return ; + return ; } return ( diff --git a/src/server/index.ts b/src/server/index.ts index f90d2d6..445d57b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -88,13 +88,14 @@ io.on('connection', (socket) => { console.log(`Player ${playerName} joined room ${roomId}`); io.to(room.id).emit('room_update', room); // Broadcast update - // If drafting, send state immediately + // If drafting, send state immediately and include in callback + let currentDraft = null; if (room.status === 'drafting') { - const draft = draftManager.getDraft(roomId); - if (draft) socket.emit('draft_update', draft); + currentDraft = draftManager.getDraft(roomId); + if (currentDraft) socket.emit('draft_update', currentDraft); } - callback({ success: true, room }); + callback({ success: true, room, draftState: currentDraft }); } else { callback({ success: false, message: 'Room not found or full' }); }