Initial Commit

This commit is contained in:
2025-12-14 21:00:46 +01:00
commit d687c6b77f
31 changed files with 6478 additions and 0 deletions

12
src/client/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MTG Draft Maker</title>
</head>
<body class="bg-slate-950 text-slate-50">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

44
src/client/src/App.tsx Normal file
View File

@@ -0,0 +1,44 @@
import React, { useState } from 'react';
import { Layers, Box, Trophy } from 'lucide-react';
import { CubeManager } from './modules/cube/CubeManager';
import { TournamentManager } from './modules/tournament/TournamentManager';
export const App: React.FC = () => {
const [activeTab, setActiveTab] = useState<'draft' | 'bracket'>('draft');
return (
<div className="min-h-screen bg-slate-900 text-slate-100 font-sans pb-20">
<header className="bg-slate-800 border-b border-slate-700 p-4 sticky top-0 z-50 shadow-lg">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex items-center gap-3">
<div className="bg-purple-600 p-2 rounded-lg"><Layers className="w-6 h-6 text-white" /></div>
<div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">MTG Peasant Drafter</h1>
<p className="text-slate-400 text-xs uppercase tracking-wider">Pack Generator & Tournament Manager</p>
</div>
</div>
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700">
<button
onClick={() => setActiveTab('draft')}
className={`px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'draft' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Box className="w-4 h-4" /> Draft Management
</button>
<button
onClick={() => setActiveTab('bracket')}
className={`px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'bracket' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Trophy className="w-4 h-4" /> Tournament / Bracket
</button>
</div>
</div>
</header>
<main>
{activeTab === 'draft' && <CubeManager />}
{activeTab === 'bracket' && <TournamentManager />}
</main>
</div>
);
};

View File

@@ -0,0 +1,120 @@
import React from 'react';
import { DraftCard, Pack } from '../services/PackGeneratorService';
import { Copy } from 'lucide-react';
import { StackView } from './StackView';
interface PackCardProps {
pack: Pack;
viewMode: 'list' | 'grid' | 'stack';
}
const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
const getRarityColorClass = (rarity: string) => {
switch (rarity) {
case 'common': return 'bg-black text-white border-slate-600';
case 'uncommon': return 'bg-slate-300 text-slate-900 border-white';
case 'rare': return 'bg-yellow-500 text-yellow-950 border-yellow-200';
case 'mythic': return 'bg-orange-600 text-white border-orange-300';
default: return 'bg-slate-500';
}
};
return (
<li className="relative group">
<div className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700/50 cursor-pointer">
<span className={`font-medium ${card.rarity === 'mythic' ? 'text-orange-400' : card.rarity === 'rare' ? 'text-yellow-400' : card.rarity === 'uncommon' ? 'text-slate-200' : 'text-slate-400'}`}>
{card.name}
</span>
<span className={`w-2 h-2 rounded-full border ${getRarityColorClass(card.rarity)} !p-0 !text-[0px]`}></span>
</div>
{card.image && (
<div className="hidden group-hover:block absolute left-0 top-full z-50 mt-1 pointer-events-none">
<div className="bg-black p-1 rounded-lg border border-slate-500 shadow-2xl w-48">
<img src={card.image} alt={card.name} className="w-full rounded" />
</div>
</div>
)}
</li>
);
};
export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode }) => {
const mythics = pack.cards.filter(c => c.rarity === 'mythic');
const rares = pack.cards.filter(c => c.rarity === 'rare');
const uncommons = pack.cards.filter(c => c.rarity === 'uncommon');
const commons = pack.cards.filter(c => c.rarity === 'common');
const copyPackToClipboard = () => {
const text = pack.cards.map(c => c.name).join('\n');
navigator.clipboard.writeText(text);
// Toast notification could go here
alert(`Pack list ${pack.id} copied!`);
};
return (
<div className={`bg-slate-800 rounded-xl border border-slate-700 shadow-lg flex flex-col ${viewMode === 'stack' ? 'bg-transparent border-none shadow-none' : ''}`}>
{/* Header */}
<div className={`p-3 bg-slate-900 border-b border-slate-700 flex justify-between items-center rounded-t-xl ${viewMode === 'stack' ? 'bg-slate-800 border border-slate-700 mb-4 rounded-xl' : ''}`}>
<div className="flex flex-col">
<h3 className="font-bold text-purple-400 text-sm md:text-base">Pack #{pack.id}</h3>
<span className="text-xs text-slate-500 font-mono">{pack.setName}</span>
</div>
<button onClick={copyPackToClipboard} className="text-slate-400 hover:text-white p-1 rounded hover:bg-slate-700 transition-colors flex items-center gap-2 text-xs">
<Copy className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className={`${viewMode !== 'stack' ? 'p-4' : ''}`}>
{viewMode === 'list' && (
<div className="text-sm space-y-4">
{(mythics.length > 0 || rares.length > 0) && (
<div>
<div className="text-xs font-bold text-yellow-500 uppercase mb-2 border-b border-slate-700 pb-1">Rare / Mythic ({mythics.length + rares.length})</div>
<ul className="space-y-1">
{mythics.map(card => <ListItem key={card.id} card={card} />)}
{rares.map(card => <ListItem key={card.id} card={card} />)}
</ul>
</div>
)}
<div>
<div className="text-xs font-bold text-slate-300 uppercase mb-2 border-b border-slate-700 pb-1">Uncommons ({uncommons.length})</div>
<ul className="space-y-1">
{uncommons.map(card => <ListItem key={card.id} card={card} />)}
</ul>
</div>
<div>
<div className="text-xs font-bold text-slate-500 uppercase mb-2 border-b border-slate-700 pb-1">Commons ({commons.length})</div>
<ul className="space-y-1">
{commons.map(card => <ListItem key={card.id} card={card} />)}
</ul>
</div>
</div>
)}
{viewMode === 'grid' && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{pack.cards.map((card) => (
<div key={card.id} className="relative aspect-[2.5/3.5] bg-slate-900 rounded-lg overflow-hidden group hover:scale-105 transition-transform duration-200 shadow-xl border border-slate-800">
{card.image ? (
<img src={card.image} alt={card.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-center p-1 text-slate-500 font-bold border-2 border-slate-700 m-1 rounded">
{card.name}
</div>
)}
<div className={`absolute bottom-0 left-0 right-0 h-1.5 ${card.rarity === 'mythic' ? 'bg-gradient-to-r from-orange-500 to-red-600' :
card.rarity === 'rare' ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' :
card.rarity === 'uncommon' ? 'bg-gradient-to-r from-gray-300 to-gray-500' :
'bg-black'
}`} />
</div>
))}
</div>
)}
{viewMode === 'stack' && <StackView cards={pack.cards} />}
</div>
</div>
);
};

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { DraftCard } from '../services/PackGeneratorService';
interface StackViewProps {
cards: DraftCard[];
}
export const StackView: React.FC<StackViewProps> = ({ cards }) => {
const getRarityColorClass = (rarity: string) => {
switch (rarity) {
case 'common': return 'bg-black text-white border-slate-600';
case 'uncommon': return 'bg-slate-300 text-slate-900 border-white';
case 'rare': return 'bg-yellow-500 text-yellow-950 border-yellow-200';
case 'mythic': return 'bg-orange-600 text-white border-orange-300';
default: return 'bg-slate-500';
}
};
return (
<div className="relative w-full max-w-sm mx-auto group perspective-1000 py-20">
<div className="relative flex flex-col items-center transition-all duration-500 ease-in-out group-hover:space-y-4 space-y-[-16rem] py-10">
{cards.map((card, index) => {
const colorClass = getRarityColorClass(card.rarity);
// Random slight rotation for "organic" look
const rotation = (index % 2 === 0 ? 1 : -1) * (Math.random() * 2);
return (
<div
key={card.id}
className="relative w-64 aspect-[2.5/3.5] rounded-xl shadow-2xl transition-transform duration-300 hover:scale-110 hover:z-50 hover:rotate-0 origin-center bg-slate-800 border-2 border-slate-900"
style={{
zIndex: index,
transform: `rotate(${rotation}deg)`
}}
>
{card.image ? (
<img src={card.image} alt={card.name} className="w-full h-full object-cover rounded-lg" />
) : (
<div className="w-full h-full p-4 text-center flex items-center justify-center font-bold text-slate-500">
{card.name}
</div>
)}
<div className={`absolute top-2 right-2 w-3 h-3 rounded-full shadow-md z-10 border ${colorClass}`} />
</div>
);
})}
</div>
<div className="text-center text-slate-500 text-xs mt-4 opacity-50 group-hover:opacity-0 transition-opacity">
Hover to expand stack
</div>
</div>
);
};

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { Target, Package, Layers } from 'lucide-react';
import { Pack } from '../services/PackGeneratorService';
interface TournamentPackViewProps {
packs: Pack[];
}
export const TournamentPackView: React.FC<TournamentPackViewProps> = ({ packs }) => {
const packsBySet = packs.reduce((acc, pack) => {
const key = pack.setName || 'Unknown Set';
if (!acc[key]) acc[key] = [];
acc[key].push(pack);
return acc;
}, {} as { [key: string]: Pack[] });
const BOX_SIZE = 30;
return (
<div className="space-y-12 animate-in fade-in duration-700">
{Object.entries(packsBySet).map(([setName, setPacks]) => {
const boxes = [];
for (let i = 0; i < setPacks.length; i += BOX_SIZE) boxes.push(setPacks.slice(i, i + BOX_SIZE));
return (
<div key={setName} className="space-y-6">
<div className="flex items-center gap-4">
<div className="h-px bg-slate-700 flex-1"></div>
<h3 className="text-2xl font-black text-slate-200 uppercase tracking-widest flex items-center gap-2">
<Target className="w-6 h-6 text-purple-500" /> {setName}
</h3>
<div className="h-px bg-slate-700 flex-1"></div>
</div>
<div className="space-y-8">
{boxes.map((boxPacks, boxIndex) => (
<div key={boxIndex} className="bg-slate-800/50 border border-slate-700 rounded-2xl p-6 relative">
<div className="absolute -top-4 left-6 bg-amber-600 text-white px-4 py-1 rounded-full font-bold shadow-lg flex items-center gap-2 border-2 border-slate-900 z-10">
<Package className="w-4 h-4" /> BOX {boxIndex + 1}
<span className="text-amber-200 text-xs font-normal ml-1">({boxPacks.length} packs)</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 mt-4">
{boxPacks.map((pack) => (
<div key={pack.id} className="aspect-[2.5/3.5] bg-gradient-to-br from-slate-700 to-slate-800 rounded-xl border-2 border-slate-600 shadow-xl relative group overflow-hidden cursor-pointer hover:border-amber-500/50 transition-colors">
<div className="absolute inset-2 border-2 border-dashed border-slate-600/30 rounded-lg flex flex-col items-center justify-center">
<Layers className="w-8 h-8 text-slate-600 mb-2 opacity-50" />
<span className="text-2xl font-black text-slate-500 opacity-20">MTG</span>
</div>
<div className="absolute bottom-4 left-0 right-0 text-center">
<div className="bg-slate-900/90 text-white text-xs font-bold py-1 px-2 mx-2 rounded border border-slate-700 truncate">#{pack.id}</div>
<div className="text-[10px] text-slate-400 mt-1 uppercase tracking-widest font-semibold truncate px-2">{pack.setName}</div>
</div>
<div className="absolute inset-0 bg-black/80 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="text-center p-2">
<p className="text-amber-400 font-bold text-xs">Contains {pack.cards.length} cards:</p>
{pack.cards.some(c => c.rarity === 'mythic' || c.rarity === 'rare') && (
<p className="text-yellow-400 text-xs font-bold"> Rare / Mythic</p>
)}
<p className="text-slate-300 text-[10px] italic mt-1">Click for full list</p>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
};

16
src/client/src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles/main.css';
const rootElement = document.getElementById('root');
if (rootElement) {
const root = createRoot(rootElement);
root.render(
<React.StrictMode>
{/* <CubeManager /> is now part of App */}
<App />
</React.StrictMode>
);
}

View File

@@ -0,0 +1,336 @@
import React, { useState, useRef, useEffect } from 'react';
import { Layers, RotateCcw, Box, Check, Loader2, Upload, Eye, EyeOff, LayoutGrid, List, Sliders, Settings } from 'lucide-react';
import { CardParserService } from '../../services/CardParserService';
import { ScryfallService, ScryfallCard } from '../../services/ScryfallService';
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
import { PackCard } from '../../components/PackCard';
import { TournamentPackView } from '../../components/TournamentPackView';
export const CubeManager: React.FC = () => {
// --- Services ---
// --- Services ---
// Memoize services to persist cache across renders
const parserService = React.useMemo(() => new CardParserService(), []);
const scryfallService = React.useMemo(() => new ScryfallService(), []);
const generatorService = React.useMemo(() => new PackGeneratorService(), []);
// --- State ---
const [inputText, setInputText] = useState('');
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState('');
const [rawScryfallData, setRawScryfallData] = useState<ScryfallCard[] | null>(null);
const [processedData, setProcessedData] = useState<{ pools: ProcessedPools, sets: SetsMap } | null>(null);
const [filters, setFilters] = useState({
ignoreBasicLands: true,
ignoreCommander: true,
ignoreTokens: true
});
const [packs, setPacks] = useState<Pack[]>([]);
// UI State
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('list');
const [tournamentMode, setTournamentMode] = useState(false);
// Generation Settings
const [genSettings, setGenSettings] = useState<PackGenerationSettings>({
mode: 'mixed',
rarityMode: 'peasant'
});
const fileInputRef = useRef<HTMLInputElement>(null);
// --- Effects ---
useEffect(() => {
if (rawScryfallData) {
const result = generatorService.processCards(rawScryfallData, filters);
setProcessedData(result);
}
}, [filters, rawScryfallData]);
// --- Handlers ---
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => setInputText(e.target?.result as string || '');
reader.readAsText(file);
event.target.value = '';
};
const loadDemoData = () => {
const demo = `20 Shock
20 Llanowar Elves
20 Giant Growth
20 Counterspell
20 Dark Ritual
20 Lightning Bolt
20 Opt
20 Consider
20 Ponder
20 Preordain
20 Brainstorm
20 Duress
20 Faithless Looting
20 Thrill of Possibility
20 Terror
10 Serra Angel
10 Vampire Nighthawk
10 Eternal Witness
10 Mulldrifter
10 Flametongue Kavu
5 Wrath of God
5 Birds of Paradise
2 Jace, the Mind Sculptor
1 Sheoldred, the Apocalypse
20 Island
1 Sol Ring
1 Command Tower`;
setInputText(demo);
};
const fetchAndParse = async () => {
setLoading(true);
setPacks([]);
setProgress('Parsing text...');
setTournamentMode(false);
try {
const identifiers = parserService.parse(inputText);
// Expand quantity for fetching logic (though service handles it, let's just pass uniques to service)
// The service `fetchCollection` deduplicates automatically.
// Map identifier interface
const fetchList = identifiers.map(id => id.type === 'id' ? { id: id.value } : { name: id.value });
// Fetch
await scryfallService.fetchCollection(fetchList, (current, total) => {
setProgress(`Fetching Scryfall data... (${current}/${total})`);
});
// Expand based on original quantities
const expandedCards: ScryfallCard[] = [];
identifiers.forEach(id => {
const card = scryfallService.getCachedCard(id.type === 'id' ? { id: id.value } : { name: id.value });
if (card) {
for (let i = 0; i < id.quantity; i++) expandedCards.push(card);
}
});
setRawScryfallData(expandedCards);
// Processing happens via useEffect
setLoading(false);
setProgress('');
} catch (err: any) {
console.error(err);
alert(err.message || "Error during process.");
setLoading(false);
}
};
const generatePacks = () => {
if (!processedData) return;
if (processedData.pools.commons.length === 0 && processedData.pools.uncommons.length === 0 && processedData.pools.rares.length === 0) {
alert(`Not enough valid cards.`);
return;
}
setLoading(true);
// Use setTimeout to allow UI to show loading spinner before sync calculation blocks
setTimeout(() => {
try {
const newPacks = generatorService.generatePacks(processedData.pools, processedData.sets, genSettings);
if (newPacks.length === 0) {
alert(`Not enough cards to generate valid packs (minimum required for selected mode).`);
} else {
setPacks(newPacks);
setTournamentMode(false);
}
} catch (e) {
console.error("Generation failed", e);
alert("Error generating packs: " + e);
} finally {
setLoading(false);
}
}, 50);
};
const toggleFilter = (key: keyof typeof filters) => {
setFilters(prev => ({ ...prev, [key]: !prev[key] }));
};
return (
<div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-8 p-4 md:p-6">
{/* --- LEFT COLUMN: CONTROLS --- */}
<div className="lg:col-span-4 flex flex-col gap-4">
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl">
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-semibold text-slate-300 flex items-center gap-2">
<Box className="w-4 h-4" /> Input Bulk
</label>
<div className="flex gap-2">
<button onClick={() => fileInputRef.current?.click()} className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 hover:underline">
<Upload className="w-3 h-3" /> Upload
</button>
<input type="file" ref={fileInputRef} className="hidden" accept=".csv,.txt" onChange={handleFileUpload} />
<button onClick={loadDemoData} className="text-xs text-purple-400 hover:text-purple-300 hover:underline">Demo</button>
</div>
</div>
{/* Filters */}
<div className="mb-4 bg-slate-900 p-3 rounded-lg border border-slate-700">
<h4 className="text-xs font-bold text-slate-400 uppercase mb-2 flex items-center gap-2">
<Sliders className="w-3 h-3" /> Import Options
</h4>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-white text-slate-300">
<input type="checkbox" checked={filters.ignoreBasicLands} onChange={() => toggleFilter('ignoreBasicLands')} className="rounded border-slate-600 bg-slate-800 text-purple-500 focus:ring-purple-500" />
<span>Ignore Basic Lands</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-white text-slate-300">
<input type="checkbox" checked={filters.ignoreCommander} onChange={() => toggleFilter('ignoreCommander')} className="rounded border-slate-600 bg-slate-800 text-purple-500 focus:ring-purple-500" />
<span>Ignore Commander Sets</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-white text-slate-300">
<input type="checkbox" checked={filters.ignoreTokens} onChange={() => toggleFilter('ignoreTokens')} className="rounded border-slate-600 bg-slate-800 text-purple-500 focus:ring-purple-500" />
<span>Ignore Tokens</span>
</label>
</div>
</div>
<textarea
className="w-full h-40 bg-slate-900 border border-slate-700 rounded-lg p-3 text-xs font-mono text-slate-300 focus:ring-2 focus:ring-purple-500 outline-none resize-none mb-4 whitespace-pre"
placeholder="Paste list or upload file..."
value={inputText}
onChange={(e) => setInputText(e.target.value)}
disabled={loading}
/>
<button
onClick={fetchAndParse}
disabled={loading || !inputText}
className={`w-full py-2 mb-4 rounded-lg font-bold flex justify-center items-center gap-2 transition-all ${loading ? 'bg-slate-700 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-500 text-white'}`}
>
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> {progress}</> : <><Check className="w-4 h-4" /> 1. Parse Bulk</>}
</button>
{/* Generation Settings */}
{processedData && Object.keys(processedData.sets).length > 0 && (
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-700 mb-4 animate-in fade-in slide-in-from-top-4 duration-500">
<h3 className="text-sm font-bold text-white mb-2 flex items-center gap-2">
<Settings className="w-4 h-4 text-emerald-400" /> Configuration
</h3>
{/* Mode */}
<div className="mb-4">
<label className="text-xs font-bold text-slate-400 uppercase mb-1 block">Card Source</label>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
<input type="radio" name="genMode" value="mixed" checked={genSettings.mode === 'mixed'} onChange={() => setGenSettings({ ...genSettings, mode: 'mixed' })} className="accent-purple-500" />
<span>Chaos Draft (Mix All)</span>
</label>
<label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
<input type="radio" name="genMode" value="by_set" checked={genSettings.mode === 'by_set'} onChange={() => setGenSettings({ ...genSettings, mode: 'by_set' })} className="accent-purple-500" />
<span>Split by Expansion</span>
</label>
</div>
</div>
{/* Rarity */}
<div className="mb-4">
<label className="text-xs font-bold text-slate-400 uppercase mb-1 block">Format</label>
<div className="flex flex-col gap-2">
<label className={`flex items-center gap-2 text-sm cursor-pointer p-2 rounded border ${genSettings.rarityMode === 'peasant' ? 'bg-slate-700 border-purple-500' : 'border-transparent hover:bg-slate-800'}`}>
<input type="radio" name="rarMode" value="peasant" checked={genSettings.rarityMode === 'peasant'} onChange={() => setGenSettings({ ...genSettings, rarityMode: 'peasant' })} className="accent-purple-500" />
<div>
<span className="block font-bold text-white">Peasant (13 Cards)</span>
<span className="text-xs text-slate-400">10 Commons, 3 Uncommons</span>
</div>
</label>
<label className={`flex items-center gap-2 text-sm cursor-pointer p-2 rounded border ${genSettings.rarityMode === 'standard' ? 'bg-slate-700 border-amber-500' : 'border-transparent hover:bg-slate-800'}`}>
<input type="radio" name="rarMode" value="standard" checked={genSettings.rarityMode === 'standard'} onChange={() => setGenSettings({ ...genSettings, rarityMode: 'standard' })} className="accent-amber-500" />
<div>
<span className="block font-bold text-white">Standard (14 Cards)</span>
<span className="text-xs text-slate-400">10C, 3U, 1 Rare/Mythic</span>
</div>
</label>
</div>
</div>
{/* Sets Info */}
<div className="max-h-40 overflow-y-auto text-xs space-y-1 pr-2 custom-scrollbar border-t border-slate-800 pt-2">
{Object.values(processedData.sets).sort((a, b) => b.commons.length - a.commons.length).map(set => (
<div key={set.code} className="flex justify-between items-center text-slate-400 border-b border-slate-800 pb-1">
<span className="truncate w-32" title={set.name}>{set.name}</span>
<span className="font-mono text-[10px]">{set.commons.length}C / {set.uncommons.length}U / {set.rares.length}R</span>
</div>
))}
</div>
</div>
)}
<button
onClick={generatePacks}
disabled={!processedData || Object.keys(processedData.sets).length === 0 || loading}
className={`w-full py-3 px-4 rounded-lg font-bold flex justify-center items-center gap-2 transition-all ${!processedData || Object.keys(processedData.sets).length === 0 || loading ? 'bg-slate-700 cursor-not-allowed text-slate-500' : 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-900/20'}`}
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <RotateCcw className="w-5 h-5" />}
{loading ? 'Generating...' : '2. Generate Packs'}
</button>
</div>
</div>
{/* --- RIGHT COLUMN: PACKS --- */}
<div className="lg:col-span-8">
<div className="flex flex-col sm:flex-row justify-between items-center mb-6 gap-4 sticky top-4 z-40 bg-slate-900/90 backdrop-blur-sm p-3 rounded-xl border border-white/5 shadow-2xl">
<div className="flex items-center gap-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<span className="bg-slate-700 text-purple-400 px-3 py-1 rounded-lg text-sm border border-slate-600">{packs.length}</span>
Packs
</h2>
{packs.length > 0 && (
<button onClick={() => setTournamentMode(!tournamentMode)} className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold border transition-all ${tournamentMode ? 'bg-amber-500/20 border-amber-500 text-amber-500 animate-pulse' : 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-400'}`}>
{tournamentMode ? <><EyeOff className="w-4 h-4" /> Tournament Mode</> : <><Eye className="w-4 h-4" /> Editor Mode</>}
</button>
)}
</div>
{!tournamentMode && (
<div className="flex bg-slate-800 rounded-lg p-1 border border-slate-700">
<button onClick={() => setViewMode('list')} className={`p-2 rounded ${viewMode === 'list' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><List className="w-4 h-4" /></button>
<button onClick={() => setViewMode('grid')} className={`p-2 rounded ${viewMode === 'grid' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><LayoutGrid className="w-4 h-4" /></button>
<button onClick={() => setViewMode('stack')} className={`p-2 rounded ${viewMode === 'stack' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><Layers className="w-4 h-4" /></button>
</div>
)}
</div>
{packs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed border-slate-700 rounded-2xl bg-slate-800/30 text-slate-500">
<Box className="w-12 h-12 mb-4 opacity-50" />
<p>No packs generated.</p>
</div>
) : (
tournamentMode ? (
<TournamentPackView packs={packs} />
) : (
<div className="grid grid-cols-1 gap-6 pb-20">
{packs.map((pack) => (
<PackCard key={pack.id} pack={pack} viewMode={viewMode} />
))}
</div>
)
)}
</div>
</div >
);
};

View File

@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { Users } from 'lucide-react';
interface Match {
id: number;
p1: string;
p2: string;
}
interface Bracket {
round1: Match[];
totalPlayers: number;
}
export const TournamentManager: React.FC = () => {
const [playerInput, setPlayerInput] = useState('');
const [bracket, setBracket] = useState<Bracket | null>(null);
const shuffleArray = (array: any[]) => {
let currentIndex = array.length, randomIndex;
const newArray = [...array];
while (currentIndex !== 0) {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[newArray[currentIndex], newArray[randomIndex]] = [newArray[randomIndex], newArray[currentIndex]];
}
return newArray;
};
const generateBracket = () => {
if (!playerInput.trim()) return;
const names = playerInput.split('\n').filter(n => n.trim() !== '').map(n => n.trim());
if (names.length < 2) { alert("Enter at least 2 players."); return; }
const shuffled = shuffleArray(names);
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(shuffled.length)));
const byesNeeded = nextPowerOf2 - shuffled.length;
const fullRoster = [...shuffled];
for (let i = 0; i < byesNeeded; i++) fullRoster.push("BYE");
const pairings: Match[] = [];
for (let i = 0; i < fullRoster.length; i += 2) {
pairings.push({ id: i, p1: fullRoster[i], p2: fullRoster[i + 1] });
}
setBracket({ round1: pairings, totalPlayers: names.length });
};
return (
<div className="max-w-4xl mx-auto p-4 md:p-6">
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl mb-8">
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
<Users className="w-5 h-5 text-blue-400" /> Players
</h2>
<p className="text-sm text-slate-400 mb-2">Enter one name per line</p>
<textarea
className="w-full h-32 bg-slate-900 border border-slate-700 rounded-lg p-3 text-sm text-slate-300 focus:ring-2 focus:ring-blue-500 outline-none resize-none mb-4"
placeholder={`Player 1\nPlayer 2...`}
value={playerInput}
onChange={(e) => setPlayerInput(e.target.value)}
/>
<button
onClick={generateBracket}
className="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded-lg font-bold w-full md:w-auto transition-colors"
>
Generate Bracket
</button>
</div>
{bracket && (
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl overflow-x-auto">
<h3 className="text-lg font-bold text-white mb-6 border-b border-slate-700 pb-2">Round 1 (Single Elimination)</h3>
<div className="flex flex-col gap-4 min-w-[300px]">
{bracket.round1.map((match, i) => (
<div key={i} className="bg-slate-900 border border-slate-700 rounded-lg p-4 flex flex-col gap-2 relative">
<div className="absolute -left-3 top-1/2 w-3 h-px bg-slate-600"></div>
<div className="flex justify-between items-center bg-slate-800/50 p-2 rounded border border-slate-700/50">
<span className={match.p1 === 'BYE' ? 'text-slate-500 italic' : 'font-bold text-white'}>{match.p1}</span>
</div>
<div className="text-xs text-center text-slate-500">VS</div>
<div className="flex justify-between items-center bg-slate-800/50 p-2 rounded border border-slate-700/50">
<span className={match.p2 === 'BYE' ? 'text-slate-500 italic' : 'font-bold text-white'}>{match.p2}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,64 @@
export interface CardIdentifier {
type: 'id' | 'name';
value: string;
quantity: number;
}
export class CardParserService {
parse(text: string): CardIdentifier[] {
const lines = text.split('\n').filter(line => line.trim() !== '');
const rawCardList: CardIdentifier[] = [];
const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
lines.forEach(line => {
if (line.toLowerCase().startsWith('quantity') || line.toLowerCase().startsWith('count,name')) return;
const idMatch = line.match(uuidRegex);
const cleanLineForQty = line.replace(/['"]/g, '');
const quantityMatch = cleanLineForQty.match(/^(\d+)[xX\s,;]/);
const quantity = quantityMatch ? parseInt(quantityMatch[1], 10) : 1;
let identifier: { type: 'id' | 'name', value: string } | null = null;
if (idMatch) {
identifier = { type: 'id', value: idMatch[0] };
} else {
const cleanLine = line.replace(/['"]/g, '');
// Remove leading quantity
let name = cleanLine.replace(/^(\d+)[xX\s,;]+/, '').trim();
// Remove set codes in parentheses/brackets e.g. (M20), [STA]
// This regex looks for ( starts, anything inside, ) ends, or same for []
name = name.replace(/\s*[\(\[].*?[\)\]]/g, '');
// Remove trailing collector numbers (digits at the very end)
name = name.replace(/\s+\d+$/, '');
// Remove trailing punctuation
name = name.replace(/^[,;]+|[,;]+$/g, '').trim();
// If CSV like "Name, SetCode", take first part
if (name.includes(',')) name = name.split(',')[0].trim();
if (name && name.length > 1) identifier = { type: 'name', value: name };
}
if (identifier) {
// Return one entry per quantity? Or aggregated?
// The original code pushed multiple entries to an array.
// For a parser service, returning the count is better, but to match logic:
// "for (let i = 0; i < quantity; i++) rawCardList.push(identifier);"
// I will return one object with Quantity property to be efficient.
rawCardList.push({
type: identifier.type,
value: identifier.value,
quantity: quantity
});
}
});
if (rawCardList.length === 0) throw new Error("No valid cards found.");
return rawCardList;
}
}

View File

@@ -0,0 +1,230 @@
import { ScryfallCard } from './ScryfallService';
export interface DraftCard {
id: string; // Internal UUID
scryfallId: string;
name: string;
rarity: string;
colors: string[];
image: string;
set: string;
setCode: string;
setType: string;
}
export interface Pack {
id: number;
setName: string;
cards: DraftCard[];
}
export interface ProcessedPools {
commons: DraftCard[];
uncommons: DraftCard[];
rares: DraftCard[];
mythics: DraftCard[];
}
export interface SetsMap {
[code: string]: {
name: string;
code: string;
commons: DraftCard[];
uncommons: DraftCard[];
rares: DraftCard[];
mythics: DraftCard[];
}
}
export interface PackGenerationSettings {
mode: 'mixed' | 'by_set';
rarityMode: 'peasant' | 'standard'; // Peasant: 10C/3U, Standard: 10C/3U/1R
}
export class PackGeneratorService {
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }): { pools: ProcessedPools, sets: SetsMap } {
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [] };
const setsMap: SetsMap = {};
cards.forEach(cardData => {
const rarity = cardData.rarity;
const typeLine = cardData.type_line || '';
const setType = cardData.set_type;
const layout = cardData.layout;
// Filters
if (filters.ignoreBasicLands && typeLine.includes('Basic')) return;
if (filters.ignoreCommander) {
if (['commander', 'starter', 'duel_deck', 'premium_deck', 'planechase', 'archenemy'].includes(setType)) return;
}
if (filters.ignoreTokens) {
if (layout === 'token' || layout === 'art_series' || layout === 'emblem') return;
}
const cardObj: DraftCard = {
id: crypto.randomUUID(),
scryfallId: cardData.id,
name: cardData.name,
rarity: rarity,
colors: cardData.colors || [],
image: cardData.image_uris?.normal || cardData.card_faces?.[0]?.image_uris?.normal || '',
set: cardData.set_name,
setCode: cardData.set,
setType: setType
};
// Add to pools
if (rarity === 'common') pools.commons.push(cardObj);
else if (rarity === 'uncommon') pools.uncommons.push(cardObj);
else if (rarity === 'rare') pools.rares.push(cardObj);
else if (rarity === 'mythic') pools.mythics.push(cardObj);
// Add to Sets Map
if (!setsMap[cardData.set]) {
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [] };
}
const setEntry = setsMap[cardData.set];
if (rarity === 'common') setEntry.commons.push(cardObj);
else if (rarity === 'uncommon') setEntry.uncommons.push(cardObj);
else if (rarity === 'rare') setEntry.rares.push(cardObj);
else if (rarity === 'mythic') setEntry.mythics.push(cardObj);
});
return { pools, sets: setsMap };
}
generatePacks(pools: ProcessedPools, sets: SetsMap, settings: PackGenerationSettings): Pack[] {
let newPacks: Pack[] = [];
if (settings.mode === 'mixed') {
let currentPools = {
commons: this.shuffle(pools.commons),
uncommons: this.shuffle(pools.uncommons),
rares: this.shuffle(pools.rares),
mythics: this.shuffle(pools.mythics)
};
let packId = 1;
while (true) {
const result = this.buildSinglePack(currentPools, packId, 'Chaos / Mixed', settings.rarityMode);
if (!result) {
break;
}
newPacks.push(result.pack);
currentPools = result.remainingPools;
packId++;
}
} else {
// By Set
let packId = 1;
const sortedSetKeys = Object.keys(sets).sort();
sortedSetKeys.forEach(setCode => {
const setData = sets[setCode];
let currentPools = {
commons: this.shuffle(setData.commons),
uncommons: this.shuffle(setData.uncommons),
rares: this.shuffle(setData.rares),
mythics: this.shuffle(setData.mythics)
};
while (true) {
const result = this.buildSinglePack(currentPools, packId, setData.name, settings.rarityMode);
if (!result) break;
newPacks.push(result.pack);
currentPools = result.remainingPools;
packId++;
}
});
}
return newPacks;
}
private buildSinglePack(pools: ProcessedPools, packId: number, setName: string, rarityMode: 'peasant' | 'standard') {
const packCards: DraftCard[] = [];
let currentPools = { ...pools };
const namesInThisPack = new Set<string>();
const COMMONS_COUNT = 10;
const UNCOMMONS_COUNT = 3;
if (rarityMode === 'standard') {
const isMythicDrop = Math.random() < 0.125;
let rareSuccess = false;
if (isMythicDrop && currentPools.mythics.length > 0) {
const drawM = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack);
if (drawM.success) {
packCards.push(...drawM.selected);
currentPools.mythics = drawM.remainingPool;
drawM.selected.forEach(c => namesInThisPack.add(c.name));
rareSuccess = true;
}
} else if (!rareSuccess && currentPools.rares.length > 0) {
const drawR = this.drawUniqueCards(currentPools.rares, 1, namesInThisPack);
if (drawR.success) {
packCards.push(...drawR.selected);
currentPools.rares = drawR.remainingPool;
drawR.selected.forEach(c => namesInThisPack.add(c.name));
rareSuccess = true;
}
} else if (currentPools.mythics.length > 0) {
// Fallback to mythic if no rare available
const drawM = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack);
if (drawM.success) {
packCards.push(...drawM.selected);
currentPools.mythics = drawM.remainingPool;
drawM.selected.forEach(c => namesInThisPack.add(c.name));
}
}
}
const drawU = this.drawUniqueCards(currentPools.uncommons, UNCOMMONS_COUNT, namesInThisPack);
if (!drawU.success) return null;
packCards.push(...drawU.selected);
currentPools.uncommons = drawU.remainingPool;
drawU.selected.forEach(c => namesInThisPack.add(c.name));
const drawC = this.drawUniqueCards(currentPools.commons, COMMONS_COUNT, namesInThisPack);
if (!drawC.success) return null;
packCards.push(...drawC.selected);
currentPools.commons = drawC.remainingPool;
const rarityWeight: { [key: string]: number } = { 'mythic': 4, 'rare': 3, 'uncommon': 2, 'common': 1 };
packCards.sort((a, b) => rarityWeight[b.rarity] - rarityWeight[a.rarity]);
return { pack: { id: packId, setName, cards: packCards }, remainingPools: currentPools };
}
private drawUniqueCards(pool: DraftCard[], count: number, existingNames: Set<string>) {
const selected: DraftCard[] = [];
const skipped: DraftCard[] = [];
const namesInPack = new Set(existingNames);
const workingPool = [...pool];
while (selected.length < count && workingPool.length > 0) {
const card = workingPool.shift()!;
if (!namesInPack.has(card.name)) {
selected.push(card);
namesInPack.add(card.name);
} else {
skipped.push(card);
}
}
const remainingPool = [...workingPool, ...skipped];
return { selected, remainingPool, success: selected.length === count };
}
private shuffle(array: any[]) {
let currentIndex = array.length, randomIndex;
const newArray = [...array];
while (currentIndex !== 0) {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[newArray[currentIndex], newArray[randomIndex]] = [newArray[randomIndex], newArray[currentIndex]];
}
return newArray;
}
}

View File

@@ -0,0 +1,88 @@
export interface ScryfallCard {
id: string;
name: string;
rarity: string;
set: string;
set_name: string;
set_type: string;
layout: string;
type_line: string;
colors?: string[];
image_uris?: { normal: string };
card_faces?: { image_uris: { normal: string } }[];
}
export class ScryfallService {
private cacheById = new Map<string, ScryfallCard>();
private cacheByName = new Map<string, ScryfallCard>();
async fetchCollection(identifiers: { id?: string; name?: string }[], onProgress?: (current: number, total: number) => void): Promise<ScryfallCard[]> {
// Deduplicate
const uniqueRequests: { id?: string; name?: string }[] = [];
const seen = new Set<string>();
identifiers.forEach(item => {
const key = item.id ? `id:${item.id}` : `name:${item.name?.toLowerCase()}`;
// Check internal cache or seen
if (item.id && this.cacheById.has(item.id)) return;
if (item.name && this.cacheByName.has(item.name.toLowerCase())) return;
if (!seen.has(key)) {
seen.add(key);
uniqueRequests.push(item);
}
});
const fetchedCards: ScryfallCard[] = [];
const chunks = [];
for (let i = 0; i < uniqueRequests.length; i += 75) chunks.push(uniqueRequests.slice(i, i + 75));
let totalFetched = 0;
for (const chunk of chunks) {
if (onProgress) onProgress(totalFetched, uniqueRequests.length);
try {
const response = await fetch('https://api.scryfall.com/cards/collection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifiers: chunk })
});
const data = await response.json();
if (data.data) {
data.data.forEach((card: ScryfallCard) => {
this.cacheById.set(card.id, card);
if (card.name) this.cacheByName.set(card.name.toLowerCase(), card);
fetchedCards.push(card);
});
}
} catch (error) {
console.error("Scryfall fetch error:", error);
}
totalFetched += chunk.length;
await new Promise(r => setTimeout(r, 75)); // Rate limit respect
}
// Return everything requested (from cache included)
const result: ScryfallCard[] = [];
identifiers.forEach(item => {
if (item.id) {
const c = this.cacheById.get(item.id);
if (c) result.push(c);
} else if (item.name) {
const c = this.cacheByName.get(item.name.toLowerCase());
if (c) result.push(c);
}
});
return result;
}
getCachedCard(identifier: { id?: string; name?: string }): ScryfallCard | undefined {
if (identifier.id) return this.cacheById.get(identifier.id);
if (identifier.name) return this.cacheByName.get(identifier.name.toLowerCase());
return undefined;
}
}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

4782
src/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
src/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "mtg-draft-maker",
"version": "1.0.0",
"description": "MTG Draft Maker - Monolith Node.js",
"type": "module",
"scripts": {
"dev": "concurrently \"npm run server\" \"npm run client\"",
"server": "tsx watch server/index.ts",
"client": "vite",
"build": "tsc && vite build",
"start": "node server/dist/index.js"
},
"dependencies": {
"express": "^4.21.2",
"socket.io": "^4.8.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"lucide-react": "^0.475.0"
},
"devDependencies": {
"@types/node": "^22.10.1",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@types/express": "^4.17.21",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"vite": "^6.0.3",
"tsx": "^4.19.2",
"concurrently": "^9.1.0"
}
}

6
src/postcss.config.cjs Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

34
src/server/index.ts Normal file
View File

@@ -0,0 +1,34 @@
import express, { Request, Response } from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: "*", // Adjust for production
methods: ["GET", "POST"]
}
});
const PORT = process.env.PORT || 3000;
app.use(express.json());
// API Routes
app.get('/api/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', message: 'Server is running' });
});
// Socket.IO connection
io.on('connection', (socket) => {
console.log('A user connected', socket.id);
socket.on('disconnect', () => {
console.log('User disconnected', socket.id);
});
});
httpServer.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});

15
src/tailwind.config.cjs Normal file
View File

@@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./client/index.html',
'./client/src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
// Custom colors if needed, mirroring the dark gaming theme
}
},
},
plugins: [],
}

39
src/tsconfig.json Normal file
View File

@@ -0,0 +1,39 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./client/*"
]
}
},
"include": [
"client/**/*.ts",
"client/**/*.tsx",
"server/**/*.ts"
],
"exclude": [
"node_modules"
]
}

26
src/vite.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
root: 'client', // Set root to client folder where index.html resides
build: {
outDir: '../dist', // Build to src/dist (outside client)
emptyOutDir: true
},
resolve: {
alias: {
'@': path.resolve(__dirname, './client/src')
}
},
server: {
proxy: {
'/api': 'http://localhost:3000', // Proxy API requests to backend
'/socket.io': {
target: 'http://localhost:3000',
ws: true
}
}
}
});