Initial commit from your app

This commit is contained in:
Reynier Matthieu
2025-02-27 16:56:23 +01:00
parent f5e02f6b78
commit 3933415b2d
5 changed files with 1220 additions and 1051 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,276 +1,416 @@
import React, { useState, useEffect } from 'react';
import { Plus, Search, Save, Trash2 } from 'lucide-react';
import { Card, Deck } from '../types';
import { searchCards } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../lib/supabase';
import { validateDeck } from '../utils/deckValidation';
interface DeckManagerProps {
initialDeck?: Deck;
onSave?: () => void;
}
export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Card[]>([]);
const [selectedCards, setSelectedCards] = useState<{ card: Card; quantity: number }[]>(
initialDeck?.cards || []
);
const [deckName, setDeckName] = useState(initialDeck?.name || '');
const [deckFormat, setDeckFormat] = useState(initialDeck?.format || 'standard');
const { user } = useAuth();
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
try {
const cards = await searchCards(searchQuery);
setSearchResults(cards);
} catch (error) {
console.error('Failed to search cards:', error);
}
};
const addCardToDeck = (card: Card) => {
setSelectedCards(prev => {
const existing = prev.find(c => c.card.id === card.id);
if (existing) {
return prev.map(c =>
c.card.id === card.id
? { ...c, quantity: Math.min(c.quantity + 1, 4) }
: c
);
}
return [...prev, { card, quantity: 1 }];
});
};
const removeCardFromDeck = (cardId: string) => {
setSelectedCards(prev => prev.filter(c => c.card.id !== cardId));
};
const updateCardQuantity = (cardId: string, quantity: number) => {
setSelectedCards(prev =>
prev.map(c =>
c.card.id === cardId
? { ...c, quantity: Math.max(1, Math.min(quantity, 4)) }
: c
)
);
};
const saveDeck = async () => {
if (!deckName.trim() || selectedCards.length === 0 || !user) return;
const deckToSave: Deck = {
id: initialDeck?.id || crypto.randomUUID(),
name: deckName,
format: deckFormat,
cards: selectedCards,
userId: user.id,
createdAt: initialDeck?.createdAt || new Date(),
updatedAt: new Date()
};
const validation = validateDeck(deckToSave);
if (!validation.isValid) {
alert(`Deck validation failed: ${validation.errors.join(', ')}`);
return;
}
try {
const deckData = {
id: deckToSave.id,
name: deckToSave.name,
format: deckToSave.format,
user_id: deckToSave.userId,
created_at: deckToSave.createdAt,
updated_at: deckToSave.updatedAt
};
// Save or update the deck
const { error: deckError } = await supabase
.from('decks')
.upsert([deckData])
.select();
if (deckError) throw deckError;
// Delete existing cards if updating
if (initialDeck) {
await supabase
.from('deck_cards')
.delete()
.eq('deck_id', initialDeck.id);
}
// Save the deck cards
const deckCards = selectedCards.map(card => ({
deck_id: deckToSave.id,
card_id: card.card.id,
quantity: card.quantity,
is_commander: card.card.type_line?.toLowerCase().includes('legendary creature') || false
}));
const { error: cardsError } = await supabase
.from('deck_cards')
.insert(deckCards);
if (cardsError) throw cardsError;
if (onSave) onSave();
} catch (error) {
console.error('Error saving deck:', error);
alert('Failed to save deck');
}
};
const currentDeck: Deck = {
id: initialDeck?.id || '',
name: deckName,
format: deckFormat,
cards: selectedCards,
userId: user?.id || '',
createdAt: initialDeck?.createdAt || new Date(),
updatedAt: new Date()
};
const validation = validateDeck(currentDeck);
return (
<div className="min-h-screen bg-gray-900 text-white p-6">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Card Search Section */}
<div className="lg:col-span-2 space-y-6">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
placeholder="Search for cards..."
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2"
>
<Search size={20} />
Search
</button>
</form>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{searchResults.map(card => (
<div
key={card.id}
className="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all"
>
{card.image_uris?.normal && (
<img
src={card.image_uris.normal}
alt={card.name}
className="w-full h-auto"
/>
)}
<div className="p-4">
<h3 className="font-bold mb-2">{card.name}</h3>
<button
onClick={() => addCardToDeck(card)}
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2"
>
<Plus size={20} />
Add to Deck
</button>
</div>
</div>
))}
</div>
</div>
{/* Deck Builder Section */}
<div className="bg-gray-800 rounded-lg p-6">
<div className="space-y-4">
<input
type="text"
value={deckName}
onChange={(e) => setDeckName(e.target.value)}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
placeholder="Deck Name"
/>
<select
value={deckFormat}
onChange={(e) => setDeckFormat(e.target.value)}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
>
<option value="standard">Standard</option>
<option value="modern">Modern</option>
<option value="commander">Commander</option>
<option value="legacy">Legacy</option>
<option value="vintage">Vintage</option>
<option value="pauper">Pauper</option>
</select>
{!validation.isValid && (
<div className="bg-red-500/10 border border-red-500 rounded-lg p-3">
<ul className="list-disc list-inside text-red-400 text-sm">
{validation.errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
<div className="space-y-2">
<h3 className="font-bold text-xl mb-4">
Cards ({selectedCards.reduce((acc, curr) => acc + curr.quantity, 0)})
</h3>
{selectedCards.map(({ card, quantity }) => (
<div key={card.id} className="flex items-center gap-4 bg-gray-700 p-2 rounded-lg">
<img
src={card.image_uris?.art_crop}
alt={card.name}
className="w-12 h-12 rounded"
/>
<div className="flex-1">
<h4 className="font-medium">{card.name}</h4>
</div>
<input
type="number"
value={quantity}
onChange={(e) => updateCardQuantity(card.id, parseInt(e.target.value))}
min="1"
max="4"
className="w-16 px-2 py-1 bg-gray-600 border border-gray-500 rounded text-center"
/>
<button
onClick={() => removeCardFromDeck(card.id)}
className="text-red-500 hover:text-red-400"
>
<Trash2 size={20}/>
</button>
</div>
))}
</div>
<button
onClick={saveDeck}
disabled={!deckName.trim() || selectedCards.length === 0 || !validation.isValid}
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2"
>
<Save size={20} />
{initialDeck ? 'Update Deck' : 'Save Deck'}
</button>
</div>
</div>
</div>
</div>
</div>
);
import React, { useState, useEffect } from 'react';
import { Plus, Search, Save, Trash2 } from 'lucide-react';
import { Card, Deck } from '../types';
import { searchCards } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../lib/supabase';
import { validateDeck } from '../utils/deckValidation';
import MagicCard from './MagicCard';
interface DeckManagerProps {
initialDeck?: Deck;
onSave?: () => void;
}
const calculateManaCurve = (cards: { card: Card; quantity: number }[]) => {
const manaValues = cards.map(({ card }) => {
if (!card.mana_cost) return 0;
// Basic heuristic: count mana symbols
return (card.mana_cost.match(/\{WUBRG0-9]\}/g) || []).length;
});
const averageManaValue = manaValues.reduce((a, b) => a + b, 0) / manaValues.length;
return averageManaValue;
};
const suggestLandCountAndDistribution = (cards: { card: Card; quantity: number }[], format: string) => {
const formatRules = {
standard: { minCards: 60, targetLands: 24.5 },
modern: { minCards: 60, targetLands: 24.5 },
commander: { minCards: 100, targetLands: 36.5 },
legacy: { minCards: 60, targetLands: 24.5 },
vintage: { minCards: 60, targetLands: 24.5 },
pauper: { minCards: 60, targetLands: 24.5 },
};
const { minCards, targetLands } = formatRules[format as keyof typeof formatRules] || formatRules.standard;
const deckSize = cards.reduce((acc, { quantity }) => acc + quantity, 0);
const nonLandCards = cards.reduce((acc, { card, quantity }) => card.type_line?.toLowerCase().includes('land') ? acc : acc + quantity, 0);
const landsToAdd = Math.max(0, minCards - deckSize);
const colorCounts = { W: 0, U: 0, B: 0, R: 0, G: 0 };
let totalColorSymbols = 0;
cards.forEach(({ card, quantity }) => {
if (card.mana_cost) {
const wMatches = (card.mana_cost.match(/\{W\}/g) || []).length;
const uMatches = (card.mana_cost.match(/\{U\}/g) || []).length;
const bMatches = (card.mana_cost.match(/\{B\}/g) || []).length;
const rMatches = (card.mana_cost.match(/\{R\}/g) || []).length;
const gMatches = (card.mana_cost.match(/\{G\}/g) || []).length;
colorCounts.W += wMatches * quantity;
colorCounts.U += uMatches * quantity;
colorCounts.B += bMatches * quantity;
colorCounts.R += rMatches * quantity;
colorCounts.G += gMatches * quantity;
totalColorSymbols += (wMatches + uMatches + bMatches + rMatches + gMatches) * quantity;
}
});
const landDistribution: { [key: string]: number } = {};
for (const color in colorCounts) {
const proportion = totalColorSymbols > 0 ? colorCounts[color as keyof typeof colorCounts] / totalColorSymbols : 0;
landDistribution[color] = Math.round(landsToAdd * proportion);
}
let totalDistributed = Object.values(landDistribution).reduce((acc, count) => acc + count, 0);
if (totalDistributed > landsToAdd) {
// Find the color with the most lands
let maxColor = '';
let maxCount = 0;
for (const color in landDistribution) {
if (landDistribution[color] > maxCount) {
maxColor = color;
maxCount = landDistribution[color];
}
}
// Reduce the land count of that color
landDistribution[maxColor] = maxCount - 1;
}
return { landCount: landsToAdd, landDistribution };
};
export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Card[]>([]);
const [selectedCards, setSelectedCards] = useState<{ card: Card; quantity: number }[]>(
initialDeck?.cards || []
);
const [deckName, setDeckName] = useState(initialDeck?.name || '');
const [deckFormat, setDeckFormat] = useState(initialDeck?.format || 'standard');
const { user } = useAuth();
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
try {
const cards = await searchCards(searchQuery);
setSearchResults(cards);
} catch (error) {
console.error('Failed to search cards:', error);
}
};
const addCardToDeck = (card: Card) => {
setSelectedCards(prev => {
const isBasicLand = card.name === 'Plains' || card.name === 'Island' || card.name === 'Swamp' || card.name === 'Mountain' || card.name === 'Forest';
const existing = prev.find(c => c.card.id === card.id);
if (existing) {
return prev.map(c =>
c.card.id === card.id
? { ...c, quantity: isBasicLand ? c.quantity + 1 : Math.min(c.quantity + 1, 4) }
: c
);
}
return [...prev, { card, quantity: 1 }];
});
};
const removeCardFromDeck = (cardId: string) =>
setSelectedCards(prev => prev.filter(c => c.card.id !== cardId));
const updateCardQuantity = (cardId: string, quantity: number) => {
setSelectedCards(prev => {
return prev.map(c => {
if (c.card.id === cardId) {
const isBasicLand = c.card.name === 'Plains' || c.card.name === 'Island' || c.card.name === 'Swamp' || c.card.name === 'Mountain' || c.card.name === 'Forest';
return { ...c, quantity: quantity };
}
return c;
});
});
};
const saveDeck = async () => {
if (!deckName.trim() || selectedCards.length === 0 || !user) return;
const deckToSave: Deck = {
id: initialDeck?.id || crypto.randomUUID(),
name: deckName,
format: deckFormat,
cards: selectedCards,
userId: user.id,
createdAt: initialDeck?.createdAt || new Date(),
updatedAt: new Date()
};
const validation = validateDeck(deckToSave);
if (!validation.isValid) {
alert(`Deck validation failed: ${validation.errors.join(', ')}`);
return;
}
try {
const deckData = {
id: deckToSave.id,
name: deckToSave.name,
format: deckToSave.format,
user_id: deckToSave.userId,
created_at: deckToSave.createdAt,
updated_at: deckToSave.updatedAt
};
// Save or update the deck
const { error: deckError } = await supabase
.from('decks')
.upsert([deckData])
.select();
if (deckError) throw deckError;
// Delete existing cards if updating
if (initialDeck) {
await supabase
.from('deck_cards')
.delete()
.eq('deck_id', initialDeck.id);
}
// Save the deck cards
const deckCards = selectedCards.map(card => ({
deck_id: deckToSave.id,
card_id: card.card.id,
quantity: card.quantity,
is_commander: card.card.type_line?.toLowerCase().includes('legendary creature') || false
}));
const { error: cardsError } = await supabase
.from('deck_cards')
.insert(deckCards);
if (cardsError) throw cardsError;
if (onSave) onSave();
} catch (error) {
console.error('Error saving deck:', error);
alert('Failed to save deck');
}
};
const currentDeck: Deck = {
id: initialDeck?.id || '',
name: deckName,
format: deckFormat,
cards: selectedCards,
userId: user?.id || '',
createdAt: initialDeck?.createdAt || new Date(),
updatedAt: new Date()
};
const validation = validateDeck(currentDeck);
const deckSize = selectedCards.reduce((acc, curr) => acc + curr.quantity, 0);
const { landCount: suggestedLandCountValue, landDistribution: suggestedLands } = suggestLandCountAndDistribution(selectedCards, deckFormat);
const totalPrice = selectedCards.reduce((acc, { card, quantity }) => {
const isBasicLand = card.name === 'Plains' || card.name === 'Island' || card.name === 'Swamp' || card.name === 'Mountain' || card.name === 'Forest';
const price = isBasicLand ? 0 : (card.prices?.usd ? parseFloat(card.prices.usd) : 0);
return acc + price * quantity;
}, 0);
const addSuggestedLandsToDeck = async () => {
const basicLandCards = {
W: { name: 'Plains', set: 'unh' },
U: { name: 'Island', set: 'unh' },
B: { name: 'Swamp', set: 'unh' },
R: { name: 'Mountain', set: 'unh' },
G: { name: 'Forest', set: 'unh' },
};
for (const color in suggestedLands) {
const landCount = suggestedLands[color];
if (landCount > 0) {
const landName = basicLandCards[color]?.name;
const landSet = basicLandCards[color]?.set;
if (landName && landSet) {
try {
const cards = await searchCards(`${landName} set:${landSet}`);
if (cards && cards.length > 0) {
const landCard = cards[0]; // Take the first matching card
for (let i = 0; i < landCount; i++) {
addCardToDeck(landCard);
}
}
} catch (error) {
console.error(`Failed to add ${landName}:`, error);
}
}
}
}
};
return (
<div className="min-h-screen bg-gray-900 text-white p-6">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Card Search Section */}
<div className="lg:col-span-2 space-y-6">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
placeholder="Search for cards..."
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2"
>
<Search size={20} />
Search
</button>
</form>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{searchResults.map(card => (
<div
key={card.id}
className="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all"
>
<MagicCard card={card} />
<div className="p-4">
<h3 className="font-bold mb-2">{card.name}</h3>
<button
onClick={() => addCardToDeck(card)}
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2"
>
<Plus size={20} />
Add to Deck
</button>
</div>
</div>
))}
</div>
</div>
{/* Deck Builder Section */}
<div className="bg-gray-800 rounded-lg p-6">
<div className="space-y-4">
<input
type="text"
value={deckName}
onChange={(e) => setDeckName(e.target.value)}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
placeholder="Deck Name"
/>
<select
value={deckFormat}
onChange={(e) => setDeckFormat(e.target.value)}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
>
<option value="standard">Standard</option>
<option value="modern">Modern</option>
<option value="commander">Commander</option>
<option value="legacy">Legacy</option>
<option value="vintage">Vintage</option>
<option value="pauper">Pauper</option>
</select>
{!validation.isValid && (
<div className="bg-red-500/10 border border-red-500 rounded-lg p-3">
<ul className="list-disc list-inside text-red-400 text-sm">
{validation.errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
<div className="space-y-2">
<h3 className="font-bold text-xl mb-4">
Cards ({selectedCards.reduce((acc, curr) => acc + curr.quantity, 0)})
</h3>
{selectedCards.map(({ card, quantity }) => (
<div key={card.id} className="flex items-center gap-4 bg-gray-700 p-2 rounded-lg">
<img
src={card.image_uris?.art_crop}
alt={card.name}
className="w-12 h-12 rounded"
/>
<div className="flex-1">
<h4 className="font-medium">{card.name}</h4>
{card.prices?.usd && (
<div className="text-sm text-gray-400">
${card.prices.usd}
</div>
)}
</div>
<input
type="number"
value={quantity}
onChange={(e) => updateCardQuantity(card.id, parseInt(e.target.value))}
min="1"
className="w-16 px-2 py-1 bg-gray-600 border border-gray-500 rounded text-center"
/>
<button
onClick={() => removeCardFromDeck(card.id)}
className="text-red-500 hover:text-red-400"
>
<Trash2 size={20}/>
</button>
</div>
))}
</div>
<div className="font-bold text-xl">
Total Price: ${totalPrice.toFixed(2)}
</div>
{deckSize > 0 && (
<div className="text-gray-400">
Suggested Land Count: {suggestedLandCountValue}
{Object.entries(suggestedLands).map(([landType, count]) => (
<div key={landType}>
{landType}: {count}
</div>
))}
</div>
)}
{deckSize > 0 && (
<button
onClick={addSuggestedLandsToDeck}
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2"
>
<Plus size={20} />
Add Suggested Lands
</button>
)}
<button
onClick={saveDeck}
disabled={!deckName.trim() || selectedCards.length === 0 || !validation.isValid}
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2"
>
<Save size={20} />
{initialDeck ? 'Update Deck' : 'Save Deck'}
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { Card } from '../types';
interface MagicCardProps {
card: Card;
}
const MagicCard = ({ card }: MagicCardProps) => {
return (
<div className="relative">
{card.image_uris?.normal ? (
<img
src={card.image_uris.normal}
alt={card.name}
className="w-full h-auto rounded-lg"
/>
) : (
<div className="w-full h-64 bg-gray-700 rounded-lg flex items-center justify-center text-gray-400">
No Image Available
</div>
)}
{card.prices?.usd && (
<div className="absolute bottom-0 left-0 p-2 bg-gray-900 bg-opacity-50 text-white rounded-bl-lg rounded-tr-lg">
${card.prices.usd}
</div>
)}
</div>
);
};
export default MagicCard;