feat: Introduce a global confirmation dialog and integrate it for various actions across game rooms, tournament, cube, and deck management, while also adding new UI controls and actions to the game room.
Some checks failed
Build and Deploy / build (push) Failing after 15m40s

This commit is contained in:
2025-12-20 17:21:11 +01:00
parent eb453fd906
commit 418e9e4507
8 changed files with 242 additions and 102 deletions

View File

@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.08qtrue2dho" "revision": "0.rc445urejpk"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@@ -7,6 +7,7 @@ import { DeckTester } from './modules/tester/DeckTester';
import { Pack } from './services/PackGeneratorService'; import { Pack } from './services/PackGeneratorService';
import { ToastProvider } from './components/Toast'; import { ToastProvider } from './components/Toast';
import { GlobalContextMenu } from './components/GlobalContextMenu'; import { GlobalContextMenu } from './components/GlobalContextMenu';
import { ConfirmDialogProvider } from './components/ConfirmDialog';
import { PWAInstallPrompt } from './components/PWAInstallPrompt'; import { PWAInstallPrompt } from './components/PWAInstallPrompt';
@@ -71,6 +72,7 @@ export const App: React.FC = () => {
return ( return (
<ToastProvider> <ToastProvider>
<ConfirmDialogProvider>
<GlobalContextMenu /> <GlobalContextMenu />
<PWAInstallPrompt /> <PWAInstallPrompt />
<div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden"> <div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden">
@@ -137,6 +139,7 @@ export const App: React.FC = () => {
</p> </p>
</footer> </footer>
</div> </div>
</ConfirmDialogProvider>
</ToastProvider> </ToastProvider>
); );
}; };

View File

@@ -0,0 +1,77 @@
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
import { Modal } from './Modal';
interface ConfirmOptions {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
type?: 'info' | 'success' | 'warning' | 'error';
}
interface ConfirmDialogContextType {
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
const ConfirmDialogContext = createContext<ConfirmDialogContextType | undefined>(undefined);
export const useConfirm = () => {
const context = useContext(ConfirmDialogContext);
if (!context) {
throw new Error('useConfirm must be used within a ConfirmDialogProvider');
}
return context;
};
export const ConfirmDialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<ConfirmOptions>({
title: '',
message: '',
confirmLabel: 'Confirm',
cancelLabel: 'Cancel',
type: 'warning',
});
const resolveRef = useRef<(value: boolean) => void>(() => { });
const confirm = useCallback((opts: ConfirmOptions) => {
setOptions({
confirmLabel: 'Confirm',
cancelLabel: 'Cancel',
type: 'warning',
...opts,
});
setIsOpen(true);
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve;
});
}, []);
const handleConfirm = useCallback(() => {
setIsOpen(false);
resolveRef.current(true);
}, []);
const handleCancel = useCallback(() => {
setIsOpen(false);
resolveRef.current(false);
}, []);
return (
<ConfirmDialogContext.Provider value={{ confirm }}>
{children}
<Modal
isOpen={isOpen}
onClose={handleCancel}
title={options.title}
message={options.message}
type={options.type}
confirmLabel={options.confirmLabel}
cancelLabel={options.cancelLabel}
onConfirm={handleConfirm}
/>
</ConfirmDialogContext.Provider>
);
};

View File

@@ -5,6 +5,7 @@ import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSett
import { PackCard } from '../../components/PackCard'; import { PackCard } from '../../components/PackCard';
import { socketService } from '../../services/SocketService'; import { socketService } from '../../services/SocketService';
import { useToast } from '../../components/Toast'; import { useToast } from '../../components/Toast';
import { useConfirm } from '../../components/ConfirmDialog';
interface CubeManagerProps { interface CubeManagerProps {
packs: Pack[]; packs: Pack[];
@@ -16,6 +17,7 @@ interface CubeManagerProps {
export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, availableLands, setAvailableLands, onGoToLobby }) => { export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, availableLands, setAvailableLands, onGoToLobby }) => {
const { showToast } = useToast(); const { showToast } = useToast();
const { confirm } = useConfirm();
// --- Services --- // --- Services ---
// Memoize services to persist cache across renders // Memoize services to persist cache across renders
@@ -288,14 +290,14 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
} }
if (newPacks.length === 0) { if (newPacks.length === 0) {
alert(`No packs generated. Check your card pool settings.`); showToast(`No packs generated. Check your card pool settings.`, 'warning');
} else { } else {
setPacks(newPacks); setPacks(newPacks);
setAvailableLands(newLands); setAvailableLands(newLands);
} }
} catch (err: any) { } catch (err: any) {
console.error("Process failed", err); console.error("Process failed", err);
alert(err.message || "Error during process."); showToast(err.message || "Error during process.", 'error');
} finally { } finally {
setLoading(false); setLoading(false);
setProgress(''); setProgress('');
@@ -306,8 +308,13 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
if (packs.length === 0) return; if (packs.length === 0) return;
// Validate Lands - Warn but allow proceed (server will handle it or deck builder will be landless) // Validate Lands - Warn but allow proceed (server will handle it or deck builder will be landless)
if (!availableLands || availableLands.length === 0) { if (availableLands.length === 0) {
if (!confirm("No basic lands detected in the current pool. Decks might be invalid. Continue?")) { if (!await confirm({
title: "No Basic Lands",
message: "No basic lands detected in the current pool. Decks might be invalid. Continue?",
confirmLabel: "Continue",
type: "warning"
})) {
return; return;
} }
} }
@@ -338,12 +345,12 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
onGoToLobby(); onGoToLobby();
}, 100); }, 100);
} else { } else {
alert("Failed to start solo draft: " + response.message); showToast("Failed to start solo draft: " + response.message, 'error');
} }
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
alert("Error: " + e.message); showToast("Error: " + e.message, 'error');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -376,7 +383,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
3,Banishing Light,Normal,Bloomburrow,25a06f82-ebdb-4dd6-bfe8-958018ce557c 3,Banishing Light,Normal,Bloomburrow,25a06f82-ebdb-4dd6-bfe8-958018ce557c
4,Barkform Harvester,Normal,Bloomburrow,f77049a6-0f22-415b-bc89-20bcb32accf6 4,Barkform Harvester,Normal,Bloomburrow,f77049a6-0f22-415b-bc89-20bcb32accf6
1,Bark-Knuckle Boxer,Normal,Bloomburrow,582637a9-6aa0-4824-bed7-d5fc91bda35e 1,Bark-Knuckle Boxer,Normal,Bloomburrow,582637a9-6aa0-4824-bed7-d5fc91bda35e
1,"Baylen, the Haymaker",Normal,Bloomburrow,00e93be2-e06b-4774-8ba5-ccf82a6da1d8 ,"Baylen, the Haymaker",Normal,Bloomburrow,00e93be2-e06b-4774-8ba5-ccf82a6da1d8
3,Bellowing Crier,Normal,Bloomburrow,ca2215dd-6300-49cf-b9b2-3a840b786c31 3,Bellowing Crier,Normal,Bloomburrow,ca2215dd-6300-49cf-b9b2-3a840b786c31
1,Blacksmith's Talent,Normal,Bloomburrow,4bb318fa-481d-40a7-978e-f01b49101ae0 1,Blacksmith's Talent,Normal,Bloomburrow,4bb318fa-481d-40a7-978e-f01b49101ae0
1,Blooming Blast,Normal,Bloomburrow,0cd92a83-cec3-4085-a929-3f204e3e0140 1,Blooming Blast,Normal,Bloomburrow,0cd92a83-cec3-4085-a929-3f204e3e0140
@@ -403,7 +410,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
setTimeout(() => setCopySuccess(false), 2000); setTimeout(() => setCopySuccess(false), 2000);
} catch (err) { } catch (err) {
console.error('Failed to copy: ', err); console.error('Failed to copy: ', err);
alert('Failed to copy CSV to clipboard'); showToast('Failed to copy CSV to clipboard', 'error');
} }
}; };

View File

@@ -9,6 +9,9 @@ import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSenso
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { AutoDeckBuilder } from '../../utils/AutoDeckBuilder'; import { AutoDeckBuilder } from '../../utils/AutoDeckBuilder';
import { Wand2 } from 'lucide-react'; // Import Wand icon import { Wand2 } from 'lucide-react'; // Import Wand icon
import { useToast } from '../../components/Toast';
import { useConfirm } from '../../components/ConfirmDialog';
import { CardComponent } from '../game/CardComponent';
interface DeckBuilderViewProps { interface DeckBuilderViewProps {
roomId: string; roomId: string;
@@ -273,6 +276,10 @@ const CardsDisplay: React.FC<{
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, availableBasicLands = [] }) => { export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, availableBasicLands = [] }) => {
// Unlimited Timer (Static for now) // Unlimited Timer (Static for now)
const [timer] = useState<string>("Unlimited"); const [timer] = useState<string>("Unlimited");
/* --- Hooks --- */
const { showToast } = useToast();
const { confirm } = useConfirm();
const [deckName, setDeckName] = useState('New Deck');
const [layout, setLayout] = useState<'vertical' | 'horizontal'>(() => { const [layout, setLayout] = useState<'vertical' | 'horizontal'>(() => {
const saved = typeof window !== 'undefined' ? localStorage.getItem('deck_layout') : null; const saved = typeof window !== 'undefined' ? localStorage.getItem('deck_layout') : null;
return (saved as 'vertical' | 'horizontal') || 'vertical'; return (saved as 'vertical' | 'horizontal') || 'vertical';
@@ -495,7 +502,12 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
}; };
const handleAutoBuild = async () => { const handleAutoBuild = async () => {
if (confirm("This will replace your current deck with an auto-generated one. Continue?")) { if (await confirm({
title: "Auto-Build Deck",
message: "This will replace your current deck with an auto-generated one. Continue?",
confirmLabel: "Auto-Build",
type: "warning"
})) {
console.log("Auto-Build: Started"); console.log("Auto-Build: Started");
// 1. Merge current deck back into pool (excluding basic lands generated) // 1. Merge current deck back into pool (excluding basic lands generated)
const currentDeckSpells = deck.filter(c => !c.isLandSource && !(c.typeLine || c.type_line || '').includes('Basic')); const currentDeckSpells = deck.filter(c => !c.isLandSource && !(c.typeLine || c.type_line || '').includes('Basic'));

View File

@@ -1,4 +1,5 @@
import { useRef, useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useConfirm } from '../../components/ConfirmDialog';
import { ChevronLeft, Eye, RotateCcw } from 'lucide-react'; import { ChevronLeft, Eye, RotateCcw } from 'lucide-react';
import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core'; import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
@@ -160,7 +161,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
document.body.style.cursor = 'col-resize'; document.body.style.cursor = 'col-resize';
}; };
const onResizeMove = useCallback((e: MouseEvent | TouchEvent) => { const onResizeMove = (e: MouseEvent | TouchEvent) => {
if (!resizingState.current.active || !sidebarRef.current) return; if (!resizingState.current.active || !sidebarRef.current) return;
if (e.cancelable) e.preventDefault(); if (e.cancelable) e.preventDefault();
@@ -168,9 +169,9 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
const delta = clientX - resizingState.current.startX; const delta = clientX - resizingState.current.startX;
const newWidth = Math.max(200, Math.min(600, resizingState.current.startWidth + delta)); const newWidth = Math.max(200, Math.min(600, resizingState.current.startWidth + delta));
sidebarRef.current.style.width = `${newWidth}px`; sidebarRef.current.style.width = `${newWidth}px`;
}, []); };
const onResizeEnd = useCallback(() => { const onResizeEnd = () => {
if (resizingState.current.active && sidebarRef.current) { if (resizingState.current.active && sidebarRef.current) {
setSidebarWidth(parseInt(sidebarRef.current.style.width)); setSidebarWidth(parseInt(sidebarRef.current.style.width));
} }
@@ -180,7 +181,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
document.removeEventListener('mouseup', onResizeEnd); document.removeEventListener('mouseup', onResizeEnd);
document.removeEventListener('touchend', onResizeEnd); document.removeEventListener('touchend', onResizeEnd);
document.body.style.cursor = 'default'; document.body.style.cursor = 'default';
}, []); };
useEffect(() => { useEffect(() => {
// Disable default context menu // Disable default context menu
@@ -299,7 +300,9 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
} }
}; };
// --- DnD Sensors & Logic --- // --- Hooks & Services ---
// const { showToast } = useToast(); // Assuming useToast is defined elsewhere if needed
const { confirm } = useConfirm();
const sensors = useSensors( const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }), useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }) useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } })
@@ -884,8 +887,13 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
<button <button
className="absolute top-0 right-0 p-1 text-slate-600 hover:text-white transition-colors" className="absolute top-0 right-0 p-1 text-slate-600 hover:text-white transition-colors"
title="Restart Game (Dev)" title="Restart Game (Dev)"
onClick={() => { onClick={async () => {
if (window.confirm('Restart game? Deck will remain, state will reset.')) { if (await confirm({
title: 'Restart Game?',
message: 'Are you sure you want to restart the game? The deck will remain, but the game state will reset.',
confirmLabel: 'Restart',
type: 'warning'
})) {
socketService.socket.emit('game_action', { action: { type: 'RESTART_GAME' } }); socketService.socket.emit('game_action', { action: { type: 'RESTART_GAME' } });
} }
}} }}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { socketService } from '../../services/SocketService'; import { socketService } from '../../services/SocketService';
import { Users, MessageSquare, Send, Copy, Check, Layers, LogOut, Bell, BellOff, X, Bot } from 'lucide-react'; import { Share2, Users, Play, LogOut, Copy, Check, Hash, Crown, XCircle, MessageSquare, Send, Bell, BellOff, X, Bot, Layers } from 'lucide-react';
import { useConfirm } from '../../components/ConfirmDialog';
import { Modal } from '../../components/Modal'; import { Modal } from '../../components/Modal';
import { useToast } from '../../components/Toast'; import { useToast } from '../../components/Toast';
import { GameView } from '../game/GameView'; import { GameView } from '../game/GameView';
@@ -45,7 +45,15 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
// State // State
const [room, setRoom] = useState<Room>(initialRoom); const [room, setRoom] = useState<Room>(initialRoom);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [modalConfig, setModalConfig] = useState({ title: '', message: '', type: 'info' as 'info' | 'error' | 'warning' | 'success' }); const [modalConfig, setModalConfig] = useState<{
title: string;
message: string;
type: 'info' | 'error' | 'warning' | 'success';
confirmLabel?: string;
onConfirm?: () => void;
cancelLabel?: string;
onClose?: () => void;
}>({ title: '', message: '', type: 'info' });
// Side Panel State // Side Panel State
const [activePanel, setActivePanel] = useState<'lobby' | 'chat' | null>(null); const [activePanel, setActivePanel] = useState<'lobby' | 'chat' | null>(null);
@@ -55,6 +63,8 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
// Services // Services
const { showToast } = useToast(); const { showToast } = useToast();
const { confirm } = useConfirm();
const [copied, setCopied] = useState(false);
// Restored States // Restored States
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
@@ -132,8 +142,16 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
useEffect(() => { useEffect(() => {
const socket = socketService.socket; const socket = socketService.socket;
const onKicked = () => { const onKicked = () => {
alert("You have been kicked from the room."); // alert("You have been kicked from the room.");
onExit(); // onExit();
setModalConfig({
title: 'Kicked',
message: 'You have been kicked from the room.',
type: 'error',
confirmLabel: 'Back to Lobby',
onConfirm: () => onExit()
});
setModalOpen(true);
}; };
socket.on('kicked', onKicked); socket.on('kicked', onKicked);
return () => { socket.off('kicked', onKicked); }; return () => { socket.off('kicked', onKicked); };
@@ -453,8 +471,13 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
<div className={`flex gap - 1 ${isSolo ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition - opacity`}> <div className={`flex gap - 1 ${isSolo ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition - opacity`}>
{isMeHost && !isMe && ( {isMeHost && !isMe && (
<button <button
onClick={() => { onClick={async () => {
if (confirm(`Kick ${p.name}?`)) { if (await confirm({
title: 'Kick Player?',
message: `Are you sure you want to kick ${p.name}?`,
confirmLabel: 'Kick',
type: 'error'
})) {
socketService.socket.emit('kick_player', { roomId: room.id, targetId: p.id }); socketService.socket.emit('kick_player', { roomId: room.id, targetId: p.id });
} }
}} }}
@@ -548,8 +571,13 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
</div> </div>
<button <button
onClick={() => { onClick={async () => {
if (window.confirm("Are you sure you want to leave the game?")) { if (await confirm({
title: 'Leave Game?',
message: "Are you sure you want to leave the game? You can rejoin later.",
confirmLabel: 'Leave',
type: 'warning'
})) {
onExit(); onExit();
} }
}} }}

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Users } from 'lucide-react'; import { Users } from 'lucide-react';
import { useToast } from '../../components/Toast';
interface Match { interface Match {
id: number; id: number;
@@ -15,6 +16,7 @@ interface Bracket {
export const TournamentManager: React.FC = () => { export const TournamentManager: React.FC = () => {
const [playerInput, setPlayerInput] = useState(''); const [playerInput, setPlayerInput] = useState('');
const [bracket, setBracket] = useState<Bracket | null>(null); const [bracket, setBracket] = useState<Bracket | null>(null);
const { showToast } = useToast();
const shuffleArray = (array: any[]) => { const shuffleArray = (array: any[]) => {
let currentIndex = array.length, randomIndex; let currentIndex = array.length, randomIndex;
@@ -30,7 +32,10 @@ export const TournamentManager: React.FC = () => {
const generateBracket = () => { const generateBracket = () => {
if (!playerInput.trim()) return; if (!playerInput.trim()) return;
const names = playerInput.split('\n').filter(n => n.trim() !== '').map(n => n.trim()); const names = playerInput.split('\n').filter(n => n.trim() !== '').map(n => n.trim());
if (names.length < 2) { alert("Enter at least 2 players."); return; } if (names.length < 2) {
showToast("Enter at least 2 players.", 'error');
return;
}
const shuffled = shuffleArray(names); const shuffled = shuffleArray(names);
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(shuffled.length))); const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(shuffled.length)));