feat: enhance Modal component with children and dynamic sizing, and add box selection for multiple draft boxes in LobbyManager.
This commit is contained in:
@@ -5,11 +5,13 @@ interface ModalProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
type?: 'info' | 'success' | 'warning' | 'error';
|
type?: 'info' | 'success' | 'warning' | 'error';
|
||||||
confirmLabel?: string;
|
confirmLabel?: string;
|
||||||
onConfirm?: () => void;
|
onConfirm?: () => void;
|
||||||
cancelLabel?: string;
|
cancelLabel?: string;
|
||||||
|
maxWidth?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Modal: React.FC<ModalProps> = ({
|
export const Modal: React.FC<ModalProps> = ({
|
||||||
@@ -17,10 +19,12 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
|
children,
|
||||||
type = 'info',
|
type = 'info',
|
||||||
confirmLabel = 'OK',
|
confirmLabel = 'OK',
|
||||||
onConfirm,
|
onConfirm,
|
||||||
cancelLabel
|
cancelLabel,
|
||||||
|
maxWidth = 'max-w-md'
|
||||||
}) => {
|
}) => {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@@ -45,10 +49,10 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
return (
|
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="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
|
<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`}
|
className={`bg-slate-900 border ${getBorderColor()} rounded-xl shadow-2xl ${maxWidth} w-full p-6 animate-in zoom-in-95 duration-200 flex flex-col max-h-[90vh]`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4 shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{getIcon()}
|
{getIcon()}
|
||||||
<h3 className="text-xl font-bold text-white">{title}</h3>
|
<h3 className="text-xl font-bold text-white">{title}</h3>
|
||||||
@@ -60,11 +64,17 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-slate-300 mb-8 leading-relaxed">
|
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||||
|
{message && (
|
||||||
|
<p className="text-slate-300 mb-4 leading-relaxed">
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
{(onConfirm || cancelLabel) && (
|
||||||
|
<div className="flex justify-end gap-3 mt-6 shrink-0">
|
||||||
{cancelLabel && onClose && (
|
{cancelLabel && onClose && (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -73,9 +83,10 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onConfirm && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onConfirm) onConfirm();
|
onConfirm();
|
||||||
if (onClose) onClose();
|
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' :
|
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' :
|
||||||
@@ -86,7 +97,9 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import React, { useState } from 'react';
|
|||||||
import { socketService } from '../../services/SocketService';
|
import { socketService } from '../../services/SocketService';
|
||||||
import { GameRoom } from './GameRoom';
|
import { GameRoom } from './GameRoom';
|
||||||
import { Pack } from '../../services/PackGeneratorService';
|
import { Pack } from '../../services/PackGeneratorService';
|
||||||
import { Users, PlusCircle, LogIn, AlertCircle, Loader2 } from 'lucide-react';
|
import { Users, PlusCircle, LogIn, AlertCircle, Loader2, Package, Check } from 'lucide-react';
|
||||||
|
import { Modal } from '../../components/Modal';
|
||||||
|
|
||||||
interface LobbyManagerProps {
|
interface LobbyManagerProps {
|
||||||
generatedPacks: Pack[];
|
generatedPacks: Pack[];
|
||||||
@@ -31,29 +32,23 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, avai
|
|||||||
localStorage.setItem('player_name', playerName);
|
localStorage.setItem('player_name', playerName);
|
||||||
}, [playerName]);
|
}, [playerName]);
|
||||||
|
|
||||||
|
const [showBoxSelection, setShowBoxSelection] = useState(false);
|
||||||
|
const [availableBoxes, setAvailableBoxes] = useState<{ id: string, title: string, packs: Pack[], setCode: string, packCount: number }[]>([]);
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (!socketService.socket.connected) {
|
if (!socketService.socket.connected) {
|
||||||
socketService.connect();
|
socketService.connect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateRoom = async () => {
|
const executeCreateRoom = async (packsToUse: Pack[]) => {
|
||||||
if (!playerName) {
|
|
||||||
setError('Please enter your name');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (generatedPacks.length === 0) {
|
|
||||||
setError('No packs generated! Please go to Draft Management and generate packs first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Collect all cards for caching (packs + basic lands)
|
// Collect all cards for caching (packs + basic lands)
|
||||||
const allCards = generatedPacks.flatMap(p => p.cards);
|
const allCards = packsToUse.flatMap(p => p.cards);
|
||||||
const allCardsAndLands = [...allCards, ...availableLands];
|
const allCardsAndLands = [...allCards, ...availableLands];
|
||||||
|
|
||||||
// Deduplicate by Scryfall ID
|
// Deduplicate by Scryfall ID
|
||||||
@@ -84,7 +79,7 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, avai
|
|||||||
// Note: For multiplayer, clients need to access this URL.
|
// Note: For multiplayer, clients need to access this URL.
|
||||||
const baseUrl = `${window.location.protocol}//${window.location.host}/cards/images`;
|
const baseUrl = `${window.location.protocol}//${window.location.host}/cards/images`;
|
||||||
|
|
||||||
const updatedPacks = generatedPacks.map(pack => ({
|
const updatedPacks = packsToUse.map(pack => ({
|
||||||
...pack,
|
...pack,
|
||||||
cards: pack.cards.map(c => ({
|
cards: pack.cards.map(c => ({
|
||||||
...c,
|
...c,
|
||||||
@@ -115,9 +110,68 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, avai
|
|||||||
setError(err.message || 'Connection error');
|
setError(err.message || 'Connection error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setShowBoxSelection(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateRoom = async () => {
|
||||||
|
if (!playerName) {
|
||||||
|
setError('Please enter your name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (generatedPacks.length === 0) {
|
||||||
|
setError('No packs generated! Please go to Draft Management and generate packs first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logic to detect Multiple Boxes
|
||||||
|
// 1. Group by Set Name
|
||||||
|
const packsBySet: Record<string, Pack[]> = {};
|
||||||
|
generatedPacks.forEach(p => {
|
||||||
|
const key = p.setName;
|
||||||
|
if (!packsBySet[key]) packsBySet[key] = [];
|
||||||
|
packsBySet[key].push(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
const boxes: { id: string, title: string, packs: Pack[], setCode: string, packCount: number }[] = [];
|
||||||
|
|
||||||
|
// Sort sets alphabetically
|
||||||
|
Object.keys(packsBySet).sort().forEach(setName => {
|
||||||
|
const setPacks = packsBySet[setName];
|
||||||
|
const BOX_SIZE = 36;
|
||||||
|
|
||||||
|
// Split into chunks of 36
|
||||||
|
for (let i = 0; i < setPacks.length; i += BOX_SIZE) {
|
||||||
|
const chunk = setPacks.slice(i, i + BOX_SIZE);
|
||||||
|
const boxNum = Math.floor(i / BOX_SIZE) + 1;
|
||||||
|
const setCode = (chunk[0].cards[0]?.setCode || 'unk').toLowerCase();
|
||||||
|
|
||||||
|
boxes.push({
|
||||||
|
id: `${setCode}-${boxNum}-${Date.now()}`, // Unique ID
|
||||||
|
title: `${setName} - Box ${boxNum}`,
|
||||||
|
packs: chunk,
|
||||||
|
setCode: setCode,
|
||||||
|
packCount: chunk.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Strategy: If we have multiple boxes, or if we have > 36 packs but maybe not multiple "boxes" (e.g. 50 packs of mixed),
|
||||||
|
// we should interpret them.
|
||||||
|
// The prompt says: "more than 1 box has been generated".
|
||||||
|
// If I generate 2 boxes (72 packs), `boxes` array will have length 2.
|
||||||
|
// If I generate 1 box (36 packs), `boxes` array will have length 1.
|
||||||
|
|
||||||
|
if (boxes.length > 1) {
|
||||||
|
setAvailableBoxes(boxes);
|
||||||
|
setShowBoxSelection(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only 1 box (or partial), just use all packs
|
||||||
|
executeCreateRoom(generatedPacks);
|
||||||
|
};
|
||||||
|
|
||||||
const handleJoinRoom = async () => {
|
const handleJoinRoom = async () => {
|
||||||
if (!playerName) {
|
if (!playerName) {
|
||||||
setError('Please enter your name');
|
setError('Please enter your name');
|
||||||
@@ -316,6 +370,62 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, avai
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Box Selection Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showBoxSelection}
|
||||||
|
onClose={() => setShowBoxSelection(false)}
|
||||||
|
title="Select Sealed Box"
|
||||||
|
message="Multiple boxes available. Please select a sealed box to open for this draft."
|
||||||
|
type="info"
|
||||||
|
maxWidth="max-w-3xl"
|
||||||
|
>
|
||||||
|
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto custom-scrollbar p-1">
|
||||||
|
{availableBoxes.map(box => (
|
||||||
|
<button
|
||||||
|
key={box.id}
|
||||||
|
onClick={() => executeCreateRoom(box.packs)}
|
||||||
|
className="group relative flex flex-col items-center p-6 bg-slate-900 border border-slate-700 rounded-xl hover:border-purple-500 hover:bg-slate-800 transition-all shadow-xl hover:shadow-purple-900/20"
|
||||||
|
>
|
||||||
|
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<div className="bg-purple-600 rounded-full p-1 shadow-lg shadow-purple-500/50">
|
||||||
|
<Check className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Box Graphic simulation */}
|
||||||
|
<div className="w-24 h-32 mb-4 relative perspective-1000 group-hover:scale-105 transition-transform duration-300">
|
||||||
|
<div className="absolute inset-0 bg-slate-800 rounded border border-slate-600 transform rotate-y-12 translate-z-4 shadow-2xl flex items-center justify-center overflow-hidden">
|
||||||
|
{/* Set Icon as Box art */}
|
||||||
|
<img
|
||||||
|
src={`https://svgs.scryfall.io/sets/${box.setCode}.svg?1734307200`}
|
||||||
|
alt={box.setCode}
|
||||||
|
className="w-16 h-16 opacity-20 group-hover:opacity-50 transition-opacity invert"
|
||||||
|
/>
|
||||||
|
<Package className="absolute bottom-2 right-2 w-6 h-6 text-slate-500" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-transparent to-black/50 pointer-events-none rounded"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-bold text-white text-center text-lg leading-tight mb-1 group-hover:text-purple-400 transition-colors">
|
||||||
|
{box.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500 font-mono uppercase tracking-wider">
|
||||||
|
<span className="bg-slate-800 px-2 py-0.5 rounded border border-slate-700">{box.setCode.toUpperCase()}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{box.packCount} Packs</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBoxSelection(false)}
|
||||||
|
className="px-4 py-2 text-slate-400 hover:text-white transition-colors text-sm font-bold"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user