feat: Add exit functionality and confirmation modal to DraftView, and include draft state in join room callback.

This commit is contained in:
2025-12-16 21:30:51 +01:00
parent ca76405986
commit b9c5905474
6 changed files with 262 additions and 95 deletions

View File

@@ -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<ModalProps> = ({
isOpen,
onClose,
title,
message,
type = 'info',
confirmLabel = 'OK',
onConfirm,
cancelLabel
}) => {
if (!isOpen) return null;
const getIcon = () => {
switch (type) {
case 'success': return <CheckCircle className="w-6 h-6 text-emerald-500" />;
case 'warning': return <AlertTriangle className="w-6 h-6 text-amber-500" />;
case 'error': return <AlertTriangle className="w-6 h-6 text-red-500" />;
default: return <Info className="w-6 h-6 text-blue-500" />;
}
};
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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div
className={`bg-slate-900 border ${getBorderColor()} rounded-xl shadow-2xl max-w-md w-full p-6 animate-in zoom-in-95 duration-200`}
role="dialog"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
{getIcon()}
<h3 className="text-xl font-bold text-white">{title}</h3>
</div>
{onClose && !cancelLabel && (
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
)}
</div>
<p className="text-slate-300 mb-8 leading-relaxed">
{message}
</p>
<div className="flex justify-end gap-3">
{cancelLabel && onClose && (
<button
onClick={onClose}
className="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium transition-colors border border-slate-700"
>
{cancelLabel}
</button>
)}
<button
onClick={() => {
if (onConfirm) onConfirm();
if (onClose) onClose();
}}
className={`px-6 py-2 rounded-lg font-bold text-white shadow-lg transition-transform hover:scale-105 ${type === 'error' ? 'bg-red-600 hover:bg-red-500' :
type === 'warning' ? 'bg-amber-600 hover:bg-amber-500' :
type === 'success' ? 'bg-emerald-600 hover:bg-emerald-500' :
'bg-blue-600 hover:bg-blue-500'
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
};

View File

@@ -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<DraftViewProps> = ({ draftState, roomId, currentPlayerId }) => {
export const DraftView: React.FC<DraftViewProps> = ({ 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<DraftViewProps> = ({ draftState, roomId, curren
socketService.socket.emit('pick_card', { roomId, playerId: currentPlayerId, cardId });
};
if (!activePack) {
return (
<div className="flex flex-col items-center justify-center h-full bg-slate-900 text-white">
<h2 className="text-2xl font-bold mb-4">Waiting for next pack...</h2>
<div className="animate-pulse bg-slate-700 w-64 h-8 rounded"></div>
</div>
);
}
// ... inside DraftView return ...
return (
<div className="flex flex-col h-full bg-slate-950 text-white overflow-hidden relative select-none" onContextMenu={(e) => e.preventDefault()}>
@@ -117,8 +114,23 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, roomId, curren
</div>
</div>
<div className="text-4xl font-mono text-emerald-400 font-bold drop-shadow-[0_0_10px_rgba(52,211,153,0.5)]">
00:{timer < 10 ? `0${timer}` : timer}
<div className="flex items-center gap-6">
{!activePack ? (
<div className="text-sm font-bold text-amber-500 animate-pulse uppercase tracking-wider">Waiting...</div>
) : (
<div className="text-4xl font-mono text-emerald-400 font-bold drop-shadow-[0_0_10px_rgba(52,211,153,0.5)]">
00:{timer < 10 ? `0${timer}` : timer}
</div>
)}
{onExit && (
<button
onClick={() => setConfirmExitOpen(true)}
className="p-3 bg-slate-800 hover:bg-red-500/20 text-slate-400 hover:text-red-500 border border-slate-700 hover:border-red-500/50 rounded-xl transition-all shadow-lg group"
title="Exit to Lobby"
>
<LogOut className="w-5 h-5 group-hover:scale-110 transition-transform" />
</button>
)}
</div>
</div>
</div>
@@ -127,7 +139,7 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, roomId, curren
<div className="flex-1 flex overflow-hidden">
{/* Dedicated Zoom Zone (Left Sidebar) */}
<div className="hidden lg:flex w-80 shrink-0 flex-col items-center justify-start pt-8 border-r border-slate-800/50 bg-slate-900/20 backdrop-blur-sm z-10">
<div className="hidden lg:flex w-80 shrink-0 flex-col items-center justify-start pt-8 border-r border-slate-800/50 bg-slate-900/20 backdrop-blur-sm z-10 transition-all">
{hoveredCard ? (
<div className="animate-in fade-in slide-in-from-left-4 duration-300 p-4 sticky top-4">
<img
@@ -150,30 +162,49 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, roomId, curren
)}
</div>
{/* Main Area: Current Pack */}
{/* Main Area: Current Pack OR Waiting State */}
<div className="flex-1 overflow-y-auto p-4 z-0 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
<div className="flex flex-col items-center justify-center min-h-full pb-10">
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold mb-8">Select a Card</h3>
<div className="flex flex-wrap justify-center gap-6 [perspective:1000px]">
{activePack.cards.map((card: any) => (
<div
key={card.id}
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer"
style={{ width: `${14 * cardScale}rem` }}
onClick={() => handlePick(card.id)}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
>
<div className="absolute inset-0 rounded-xl bg-emerald-500 blur-xl opacity-0 group-hover:opacity-40 transition-opacity duration-300"></div>
<img
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
alt={card.name}
className="w-full rounded-xl shadow-2xl shadow-black group-hover:ring-2 ring-emerald-400/50 relative z-10"
/>
{!activePack ? (
<div className="flex flex-col items-center justify-center min-h-full pb-10 fade-in animate-in duration-500">
<div className="w-24 h-24 mb-6 relative">
<div className="absolute inset-0 rounded-full border-4 border-slate-800"></div>
<div className="absolute inset-0 rounded-full border-4 border-t-emerald-500 animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<LogOut className="w-8 h-8 text-emerald-500 rotate-180" /> {/* Just a placeholder icon or similar */}
</div>
))}
</div>
<h2 className="text-3xl font-bold text-white mb-2">Waiting for next pack...</h2>
<p className="text-slate-400">Your neighbor is selecting a card.</p>
<div className="mt-8 flex gap-2">
<div className="w-3 h-3 bg-emerald-500 rounded-full animate-bounce [animation-delay:-0.3s]"></div>
<div className="w-3 h-3 bg-emerald-500 rounded-full animate-bounce [animation-delay:-0.15s]"></div>
<div className="w-3 h-3 bg-emerald-500 rounded-full animate-bounce"></div>
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center min-h-full pb-10">
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold mb-8">Select a Card</h3>
<div className="flex flex-wrap justify-center gap-6 [perspective:1000px]">
{activePack.cards.map((card: any) => (
<div
key={card.id}
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer"
style={{ width: `${14 * cardScale}rem` }}
onClick={() => handlePick(card.id)}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
>
<div className="absolute inset-0 rounded-xl bg-emerald-500 blur-xl opacity-0 group-hover:opacity-40 transition-opacity duration-300"></div>
<img
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
alt={card.name}
className="w-full rounded-xl shadow-2xl shadow-black group-hover:ring-2 ring-emerald-400/50 relative z-10"
/>
</div>
))}
</div>
</div>
)}
</div>
</div>
@@ -214,6 +245,16 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, roomId, curren
))}
</div>
</div>
<Modal
isOpen={confirmExitOpen}
onClose={() => 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}
/>
</div>
);
};

View File

@@ -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<GameRoomProps> = ({ room: initialRoom, currentPlayerId, initialGameState }) => {
export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPlayerId, initialGameState, initialDraftState, onExit }) => {
// State
const [room, setRoom] = useState<Room>(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<ChatMessage[]>(initialRoom.messages || []);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [gameState, setGameState] = useState<any>(initialGameState || null);
const [draftState, setDraftState] = useState<any>(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<any>(null);
useEffect(() => {
const socket = socketService.socket;
const handleDraftUpdate = (data: any) => {
@@ -87,7 +74,12 @@ export const GameRoom: React.FC<GameRoomProps> = ({ 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<GameRoomProps> = ({ 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<GameRoomProps> = ({ 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<GameRoomProps> = ({ room: initialRoom, currentPl
socketService.socket.emit('start_draft', { roomId: room.id });
};
// Helper to determine view
const renderContent = () => {
if (gameState) {
return <GameView gameState={gameState} currentPlayerId={currentPlayerId} />;
}
if (room.status === 'drafting' && draftState) {
return <DraftView draftState={draftState} roomId={room.id} currentPlayerId={currentPlayerId} />;
return <DraftView draftState={draftState} roomId={room.id} currentPlayerId={currentPlayerId} onExit={onExit} />;
}
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<GameRoomProps> = ({ room: initialRoom, currentPl
return <DeckBuilderView roomId={room.id} currentPlayerId={currentPlayerId} initialPool={myPool} />;
}
// Default Waiting Lobby
return (
<div className="flex-1 bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl flex flex-col items-center justify-center">
<h2 className="text-3xl font-bold text-white mb-4">Waiting for Players...</h2>
@@ -250,16 +232,13 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
<div className="flex h-full gap-4">
{renderContent()}
{/* Sidebar: Players & Chat */}
<div className="w-80 flex flex-col gap-4">
{/* Players List */}
<div className="flex-1 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl overflow-hidden flex flex-col">
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
<Users className="w-4 h-4" /> Lobby
</h3>
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
{room.players.map(p => {
// Cast to any to access ready state without full interface update for now
const isReady = (p as any).ready;
return (
<div key={p.id} className="flex items-center justify-between bg-slate-900/50 p-2 rounded-lg border border-slate-700/50">
@@ -283,7 +262,6 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
</div>
</div>
{/* Chat */}
<div className="h-1/2 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl flex flex-col">
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
<MessageSquare className="w-4 h-4" /> Chat
@@ -311,6 +289,48 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
</form>
</div>
</div>
{/* Host Disconnected Overlay */}
{isHostOffline && !isMeHost && (
<div className="absolute inset-0 z-50 bg-black/80 backdrop-blur-md flex flex-col items-center justify-center p-8 animate-in fade-in duration-500">
<div className="bg-slate-900 border border-red-500/50 p-8 rounded-2xl shadow-2xl max-w-lg text-center">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-6">
<Users className="w-8 h-8 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">Game Paused</h2>
<p className="text-slate-300 mb-6">
The host <span className="text-white font-bold">{host?.name}</span> has disconnected.
The game is paused until they reconnect.
</p>
<div className="flex flex-col gap-6 items-center">
<div className="flex items-center justify-center gap-2 text-xs text-slate-500 uppercase tracking-wider font-bold animate-pulse">
<span className="w-2 h-2 bg-red-500 rounded-full"></span>
Waiting for host...
</div>
<button
onClick={() => {
if (window.confirm("Are you sure you want to leave the game?")) {
onExit();
}
}}
className="px-6 py-2 bg-slate-800 hover:bg-red-900/30 text-slate-400 hover:text-red-400 border border-slate-700 hover:border-red-500/50 rounded-lg flex items-center gap-2 transition-all"
>
<LogOut className="w-4 h-4" /> Leave Game
</button>
</div>
</div>
</div>
)}
{/* Global Modal */}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={modalConfig.title}
message={modalConfig.message}
type={modalConfig.type}
/>
</div>
);
};

View File

@@ -15,6 +15,8 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
const [joinRoomId, setJoinRoomId] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [initialDraftState, setInitialDraftState] = useState<any>(null);
const [playerId] = useState(() => {
const saved = localStorage.getItem('player_id');
if (saved) return saved;
@@ -128,6 +130,7 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ 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<LobbyManagerProps> = ({ 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<LobbyManagerProps> = ({ 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 <GameRoom room={activeRoom} currentPlayerId={playerId} />;
return <GameRoom room={activeRoom} currentPlayerId={playerId} onExit={handleExitRoom} initialDraftState={initialDraftState} />;
}
return (

View File

@@ -115,8 +115,13 @@ export const DeckTester: React.FC = () => {
}
};
const handleExitTester = () => {
setActiveRoom(null);
setInitialGame(null);
};
if (activeRoom) {
return <GameRoom room={activeRoom} currentPlayerId={playerId} initialGameState={initialGame} />;
return <GameRoom room={activeRoom} currentPlayerId={playerId} initialGameState={initialGame} onExit={handleExitTester} />;
}
return (

View File

@@ -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' });
}