Initial commit from your app
This commit is contained in:
85
src/App.tsx
Normal file
85
src/App.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useState } from 'react';
|
||||
import DeckManager from './components/DeckManager';
|
||||
import DeckList from './components/DeckList';
|
||||
import LoginForm from './components/LoginForm';
|
||||
import Navigation from './components/Navigation';
|
||||
import Collection from './components/Collection';
|
||||
import DeckEditor from './components/DeckEditor';
|
||||
import Profile from './components/Profile';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
|
||||
type Page = 'home' | 'deck' | 'login' | 'collection' | 'edit-deck' | 'profile';
|
||||
|
||||
function AppContent() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>('home');
|
||||
const [selectedDeckId, setSelectedDeckId] = useState<string | null>(null);
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user && currentPage !== 'login') {
|
||||
return <LoginForm />;
|
||||
}
|
||||
|
||||
const handleDeckEdit = (deckId: string) => {
|
||||
setSelectedDeckId(deckId);
|
||||
setCurrentPage('edit-deck');
|
||||
};
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'home':
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">My Decks</h1>
|
||||
<DeckList onDeckEdit={handleDeckEdit} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'deck':
|
||||
return <DeckManager />;
|
||||
case 'edit-deck':
|
||||
return selectedDeckId ? (
|
||||
<DeckEditor
|
||||
deckId={selectedDeckId}
|
||||
onClose={() => {
|
||||
setSelectedDeckId(null);
|
||||
setCurrentPage('home');
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
case 'collection':
|
||||
return <Collection />;
|
||||
case 'profile':
|
||||
return <Profile />;
|
||||
case 'login':
|
||||
return <LoginForm />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900">
|
||||
<Navigation currentPage={currentPage} setCurrentPage={setCurrentPage} />
|
||||
{renderPage()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
47
src/components/CardCarousel.tsx
Normal file
47
src/components/CardCarousel.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card } from '../types';
|
||||
import { getRandomCards } from '../services/api';
|
||||
|
||||
export default function CardCarousel() {
|
||||
const [cards, setCards] = useState<Card[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCards = async () => {
|
||||
try {
|
||||
const randomCards = await getRandomCards(6);
|
||||
setCards(randomCards);
|
||||
} catch (error) {
|
||||
console.error('Failed to load cards:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCards();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-pulse h-96 bg-gray-700/50 rounded-lg"></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-screen overflow-hidden">
|
||||
<div className="absolute inset-0 flex">
|
||||
{cards.map((card, index) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="min-w-full h-full transform transition-transform duration-1000"
|
||||
style={{
|
||||
backgroundImage: `url(${card.image_uris?.normal})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
filter: 'blur(8px)',
|
||||
opacity: 0.5
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
src/components/Collection.tsx
Normal file
121
src/components/Collection.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search, Plus } from 'lucide-react';
|
||||
import { Card } from '../types';
|
||||
import { searchCards } from '../services/api';
|
||||
|
||||
export default function Collection() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Card[]>([]);
|
||||
const [collection, setCollection] = useState<{ card: Card; quantity: number }[]>([]);
|
||||
|
||||
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 addToCollection = (card: Card) => {
|
||||
setCollection(prev => {
|
||||
const existing = prev.find(c => c.card.id === card.id);
|
||||
if (existing) {
|
||||
return prev.map(c =>
|
||||
c.card.id === card.id
|
||||
? { ...c, quantity: c.quantity + 1 }
|
||||
: c
|
||||
);
|
||||
}
|
||||
return [...prev, { card, quantity: 1 }];
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">My Collection</h1>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2 mb-8">
|
||||
<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"
|
||||
placeholder="Search cards to add..."
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Search Results */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">Search Results</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{searchResults.map(card => (
|
||||
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
|
||||
{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={() => addToCollection(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 Collection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collection */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">My Cards</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{collection.map(({ card, quantity }) => (
|
||||
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
|
||||
{card.image_uris?.normal && (
|
||||
<img
|
||||
src={card.image_uris.normal}
|
||||
alt={card.name}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="font-bold">{card.name}</h3>
|
||||
<span className="text-sm bg-blue-600 px-2 py-1 rounded">
|
||||
x{quantity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/components/DeckBuilder.tsx
Normal file
120
src/components/DeckBuilder.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState } from "react";
|
||||
import { motion } from "motion/react"
|
||||
import { Save, Trash2 } from "lucide-react";
|
||||
|
||||
export default function DeckBuilder({
|
||||
deckName,
|
||||
setDeckName,
|
||||
deckFormat,
|
||||
setDeckFormat,
|
||||
selectedCards,
|
||||
updateCardQuantity,
|
||||
removeCardFromDeck,
|
||||
saveDeck,
|
||||
validation,
|
||||
initialDeck,
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Bouton d'ouverture du menu */}
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-4 right-4 bg-blue-600 text-white p-3 rounded-lg md:hidden"
|
||||
>
|
||||
Open Deck Builder
|
||||
</button>
|
||||
|
||||
{/* Menu latéral */}
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: isOpen ? "0%" : "100%" }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="fixed top-0 right-0 w-4/5 h-full bg-gray-800 p-6 shadow-lg md:static md:w-full md:h-auto md:p-6 md:shadow-none z-50"
|
||||
>
|
||||
{/* Bouton de fermeture */}
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-4 right-4 text-white"
|
||||
>
|
||||
✖
|
||||
</button>
|
||||
|
||||
<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>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/components/DeckCard.tsx
Normal file
71
src/components/DeckCard.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, Check, Edit } from 'lucide-react';
|
||||
import { Deck } from '../types';
|
||||
import { validateDeck } from '../utils/deckValidation';
|
||||
|
||||
interface DeckCardProps {
|
||||
deck: Deck;
|
||||
onEdit?: (deckId: string) => void;
|
||||
}
|
||||
|
||||
export default function DeckCard({ deck, onEdit }: DeckCardProps) {
|
||||
const validation = validateDeck(deck);
|
||||
const commander = deck.format === 'commander' ? deck.cards.find(card =>
|
||||
card.card.type_line?.toLowerCase().includes('legendary creature')
|
||||
)?.card : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-gray-800 rounded-xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1 cursor-pointer"
|
||||
onClick={() => onEdit?.(deck.id)}
|
||||
>
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<img
|
||||
src={commander?.image_uris?.normal || deck.cards[0]?.card.image_uris?.normal}
|
||||
alt={commander?.name || deck.cards[0]?.card.name}
|
||||
className="w-full object-cover object-top transform translate-y-[-12%]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xl font-bold text-white">{deck.name}</h3>
|
||||
{validation.isValid ? (
|
||||
<div className="flex items-center text-green-400">
|
||||
<Check size={16} className="mr-1" />
|
||||
<span className="text-sm">Legal</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-yellow-400" title={validation.errors.join(', ')}>
|
||||
<AlertTriangle size={16} className="mr-1" />
|
||||
<span className="text-sm">Issues</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-400">
|
||||
<span className="capitalize">{deck.format}</span>
|
||||
<span>{deck.cards.reduce((acc, curr) => acc + curr.quantity, 0)} cards</span>
|
||||
</div>
|
||||
|
||||
{commander && (
|
||||
<div className="mt-2 text-sm text-gray-300">
|
||||
<span className="text-blue-400">Commander:</span> {commander.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(deck.id);
|
||||
}}
|
||||
className="mt-4 w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2 text-white"
|
||||
>
|
||||
<Edit size={20} />
|
||||
Edit Deck
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/components/DeckEditor.tsx
Normal file
85
src/components/DeckEditor.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Deck } from '../types';
|
||||
import DeckManager from './DeckManager';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { getCardsByIds } from '../services/api';
|
||||
|
||||
interface DeckEditorProps {
|
||||
deckId: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function DeckEditor({ deckId, onClose }: DeckEditorProps) {
|
||||
const [deck, setDeck] = useState<Deck | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDeck = async () => {
|
||||
try {
|
||||
// Fetch deck data
|
||||
const { data: deckData, error: deckError } = await supabase
|
||||
.from('decks')
|
||||
.select('*')
|
||||
.eq('id', deckId)
|
||||
.single();
|
||||
|
||||
if (deckError) throw deckError;
|
||||
|
||||
// Fetch deck cards
|
||||
const { data: cardEntities, error: cardsError } = await supabase
|
||||
.from('deck_cards')
|
||||
.select('*')
|
||||
.eq('deck_id', deckId);
|
||||
|
||||
if (cardsError) throw cardsError;
|
||||
|
||||
// Fetch card details from Scryfall
|
||||
const cardIds = cardEntities.map(entity => entity.card_id);
|
||||
const uniqueCardIds = [...new Set(cardIds)];
|
||||
const scryfallCards = await getCardsByIds(uniqueCardIds);
|
||||
|
||||
// Combine deck data with card details
|
||||
const cards = cardEntities.map(entity => ({
|
||||
card: scryfallCards.find(c => c.id === entity.card_id) as Card,
|
||||
quantity: entity.quantity,
|
||||
}));
|
||||
|
||||
setDeck({
|
||||
...deckData,
|
||||
cards,
|
||||
createdAt: new Date(deckData.created_at),
|
||||
updatedAt: new Date(deckData.updated_at),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching deck:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDeck();
|
||||
}, [deckId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!deck) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4">
|
||||
<h2 className="text-xl font-bold text-red-500">Error</h2>
|
||||
<p className="text-red-400">Failed to load deck</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DeckManager initialDeck={deck} onSave={onClose} />;
|
||||
}
|
||||
80
src/components/DeckList.tsx
Normal file
80
src/components/DeckList.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getCardById, getCardsByIds } from '../services/api';
|
||||
import { Deck } from '../types';
|
||||
import { supabase } from "../lib/supabase";
|
||||
import DeckCard from "./DeckCard";
|
||||
|
||||
interface DeckListProps {
|
||||
onDeckEdit?: (deckId: string) => void;
|
||||
}
|
||||
|
||||
const DeckList = ({ onDeckEdit }: DeckListProps) => {
|
||||
const [decks, setDecks] = useState<Deck[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDecks = async () => {
|
||||
const { data: decksData, error: decksError } = await supabase.from('decks').select('*');
|
||||
if (decksError) {
|
||||
console.error('Error fetching decks:', decksError);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const decksWithCards = await Promise.all(decksData.map(async (deck) => {
|
||||
const { data: cardEntities, error: cardsError } = await supabase
|
||||
.from('deck_cards')
|
||||
.select('*')
|
||||
.eq('deck_id', deck.id);
|
||||
|
||||
if (cardsError) {
|
||||
console.error(`Error fetching cards for deck ${deck.id}:`, cardsError);
|
||||
return { ...deck, cards: [] };
|
||||
}
|
||||
|
||||
const cardIds = cardEntities.map((entity) => entity.card_id);
|
||||
const uniqueCardIds = [...new Set(cardIds)];
|
||||
|
||||
const scryfallCards = await getCardsByIds(uniqueCardIds);
|
||||
|
||||
const cards = cardEntities.map((entity) => {
|
||||
const card = scryfallCards.find((c) => c.id === entity.card_id);
|
||||
return {
|
||||
card,
|
||||
quantity: entity.quantity,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...deck,
|
||||
cards,
|
||||
createdAt: new Date(deck.created_at),
|
||||
updatedAt: new Date(deck.updated_at),
|
||||
};
|
||||
}));
|
||||
|
||||
setDecks(decksWithCards);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchDecks();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{decks.map((deck) => (
|
||||
<DeckCard key={deck.id} deck={deck} onEdit={onDeckEdit} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeckList;
|
||||
276
src/components/DeckManager.tsx
Normal file
276
src/components/DeckManager.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
147
src/components/LoginForm.tsx
Normal file
147
src/components/LoginForm.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Mail, Lock, LogIn } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Card } from '../types';
|
||||
import { getRandomCards } from '../services/api';
|
||||
|
||||
export default function LoginForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSignUp, setIsSignUp] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { signIn, signUp } = useAuth();
|
||||
const [cards, setCards] = useState<Card[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCards = async () => {
|
||||
try {
|
||||
const randomCards = await getRandomCards(6);
|
||||
setCards(randomCards);
|
||||
} catch (error) {
|
||||
console.error('Failed to load cards:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCards();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (isSignUp) {
|
||||
await signUp(email, password);
|
||||
} else {
|
||||
await signIn(email, password);
|
||||
}
|
||||
window.location.href = '/'; // Redirect to home after successful login
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-pulse h-96 bg-gray-700/50 rounded-lg"></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex items-center justify-center p-6 overflow-hidden">
|
||||
{/* Animated Background */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="flex animate-slide"
|
||||
style={{
|
||||
width: `${cards.length * 100}%`,
|
||||
animation: 'slide 60s linear infinite'
|
||||
}}
|
||||
>
|
||||
{[...cards, ...cards].map((card, index) => (
|
||||
<div
|
||||
key={`${card.id}-${index}`}
|
||||
className="relative w-full h-screen"
|
||||
style={{
|
||||
width: `${100 / (cards.length * 2)}%`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transform transition-transform duration-1000"
|
||||
style={{
|
||||
backgroundImage: `url(${card.image_uris?.normal})`,
|
||||
filter: 'blur(8px) brightness(0.4)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="relative z-10 bg-gray-900/80 p-8 rounded-lg shadow-xl backdrop-blur-sm w-full max-w-md">
|
||||
<h2 className="text-3xl font-bold text-orange-500 mb-6 text-center">
|
||||
Deckerr
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/10 border border-red-500 rounded text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-800/50 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-800/50 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-lg transition duration-200"
|
||||
>
|
||||
<LogIn size={20} />
|
||||
{isSignUp ? 'Sign Up' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => setIsSignUp(!isSignUp)}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{isSignUp ? 'Already have an account? Sign In' : 'Need an account? Sign Up'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
src/components/Navigation.tsx
Normal file
175
src/components/Navigation.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Home, PlusSquare, Library, LogOut, Settings, ChevronDown } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
type Page = 'home' | 'deck' | 'login' | 'collection' | 'profile';
|
||||
|
||||
interface NavigationProps {
|
||||
currentPage: Page;
|
||||
setCurrentPage: (page: Page) => void;
|
||||
}
|
||||
|
||||
export default function Navigation({ currentPage, setCurrentPage }: NavigationProps) {
|
||||
const { user, signOut } = useAuth();
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
if (user) {
|
||||
const { data } = await supabase
|
||||
.from('profiles')
|
||||
.select('username')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setUsername(data.username);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchProfile();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const navItems = [
|
||||
{ id: 'home' as const, label: 'Home', icon: Home },
|
||||
{ id: 'deck' as const, label: 'New Deck', icon: PlusSquare },
|
||||
{ id: 'collection' as const, label: 'Collection', icon: Library },
|
||||
];
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
setCurrentPage('login');
|
||||
} catch (error) {
|
||||
console.error('Error signing out:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getAvatarUrl = (userId: string) => {
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}&backgroundColor=b6e3f4,c0aede,d1d4f9`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Navigation - Top */}
|
||||
<nav className="hidden md:block fixed top-0 left-0 right-0 bg-gray-800 border-b border-gray-700 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center space-x-8">
|
||||
<span className="text-2xl font-bold text-orange-500">Deckerr</span>
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setCurrentPage(item.id)}
|
||||
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors
|
||||
${currentPage === item.id
|
||||
? 'text-white bg-gray-900'
|
||||
: 'text-gray-300 hover:text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center space-x-3 px-3 py-2 rounded-md hover:bg-gray-700"
|
||||
>
|
||||
<img
|
||||
src={getAvatarUrl(user.id)}
|
||||
alt="User avatar"
|
||||
className="w-8 h-8 rounded-full bg-gray-700"
|
||||
/>
|
||||
<span className="text-gray-300 text-sm">{username || user.email}</span>
|
||||
<ChevronDown size={16} className="text-gray-400" />
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-gray-800 rounded-md shadow-lg py-1 border border-gray-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentPage('profile');
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<span>Profile Settings</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Navigation - Bottom */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-800 border-t border-gray-700 z-50">
|
||||
<div className="grid grid-cols-4 h-16">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setCurrentPage(item.id)}
|
||||
className={`flex flex-col items-center justify-center space-y-1
|
||||
${currentPage === item.id
|
||||
? 'text-white bg-gray-900'
|
||||
: 'text-gray-300 hover:text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span className="text-xs">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex flex-col items-center justify-center space-y-1 text-gray-300 hover:text-white hover:bg-gray-700"
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={getAvatarUrl(user.id)}
|
||||
alt="User avatar"
|
||||
className="w-8 h-8 rounded-full bg-gray-700"
|
||||
/>
|
||||
<Settings size={14} className="absolute -bottom-1 -right-1 bg-gray-800 rounded-full p-0.5" />
|
||||
</div>
|
||||
<span className="text-xs">Profile</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content Padding */}
|
||||
<div className="md:pt-16 pb-16 md:pb-0" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
src/components/Profile.tsx
Normal file
128
src/components/Profile.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Save } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const THEME_COLORS = ['red', 'green', 'blue', 'yellow', 'grey', 'purple'];
|
||||
|
||||
export default function Profile() {
|
||||
const { user } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [themeColor, setThemeColor] = useState('blue');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadProfile = async () => {
|
||||
if (user) {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('username, theme_color')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setUsername(data.username || '');
|
||||
setThemeColor(data.theme_color || 'blue');
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProfile();
|
||||
}, [user]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('profiles')
|
||||
.upsert({
|
||||
id: user.id,
|
||||
username,
|
||||
theme_color: themeColor,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
alert('Profile updated successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
alert('Failed to update profile');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">Profile Settings</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Theme Color
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{THEME_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setThemeColor(color)}
|
||||
className={`h-12 rounded-lg border-2 transition-all capitalize
|
||||
${themeColor === color
|
||||
? 'border-white scale-105'
|
||||
: 'border-transparent hover:border-gray-600'
|
||||
}`}
|
||||
style={{ backgroundColor: `var(--color-${color}-primary)` }}
|
||||
>
|
||||
{color}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg transition duration-200"
|
||||
>
|
||||
{saving ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
||||
) : (
|
||||
<>
|
||||
<Save size={20} />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/contexts/AuthContext.tsx
Normal file
107
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
signIn: (email: string, password: string) => Promise<void>;
|
||||
signUp: (email: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check active sessions and sets the user
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setUser(session?.user ?? null);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// Listen for changes on auth state
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setUser(session?.user ?? null);
|
||||
setLoading(false);
|
||||
|
||||
// If this is a new sign up, create a profile using setTimeout to avoid deadlock
|
||||
if (_event === 'SIGNED_IN' && session) {
|
||||
setTimeout(async () => {
|
||||
const { error } = await supabase
|
||||
.from('profiles')
|
||||
.upsert(
|
||||
{
|
||||
id: session.user.id,
|
||||
theme_color: 'blue' // Default theme color
|
||||
},
|
||||
{ onConflict: 'id' }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating profile:', error);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
const signUp = async (email: string, password: string) => {
|
||||
const { error, data } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Create a profile for the new user using setTimeout to avoid deadlock
|
||||
if (data.user) {
|
||||
setTimeout(async () => {
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.insert({
|
||||
id: data.user!.id,
|
||||
theme_color: 'blue' // Default theme color
|
||||
});
|
||||
|
||||
if (profileError) {
|
||||
console.error('Error creating profile:', profileError);
|
||||
// Optionally handle the error (e.g., delete the auth user)
|
||||
throw profileError;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (error) throw error;
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
if (error) throw error;
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, signIn, signUp, signOut }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
16
src/index.css
Normal file
16
src/index.css
Normal file
@@ -0,0 +1,16 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@keyframes slide {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide {
|
||||
animation: slide 60s linear infinite;
|
||||
}
|
||||
274
src/lib/Entities.ts
Normal file
274
src/lib/Entities.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[]
|
||||
|
||||
export type Database = {
|
||||
graphql_public: {
|
||||
Tables: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Functions: {
|
||||
graphql: {
|
||||
Args: {
|
||||
operationName?: string
|
||||
query?: string
|
||||
variables?: Json
|
||||
extensions?: Json
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
}
|
||||
Enums: {
|
||||
[_ in never]: never
|
||||
}
|
||||
CompositeTypes: {
|
||||
[_ in never]: never
|
||||
}
|
||||
}
|
||||
public: {
|
||||
Tables: {
|
||||
collections: {
|
||||
Row: {
|
||||
card_id: string
|
||||
created_at: string | null
|
||||
id: string
|
||||
quantity: number | null
|
||||
updated_at: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
card_id: string
|
||||
created_at?: string | null
|
||||
id?: string
|
||||
quantity?: number | null
|
||||
updated_at?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
card_id?: string
|
||||
created_at?: string | null
|
||||
id?: string
|
||||
quantity?: number | null
|
||||
updated_at?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "collections_user_id_fkey"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
deck_cards: {
|
||||
Row: {
|
||||
card_id: string
|
||||
deck_id: string
|
||||
id: string
|
||||
is_commander: boolean | null
|
||||
quantity: number | null
|
||||
}
|
||||
Insert: {
|
||||
card_id: string
|
||||
deck_id: string
|
||||
id?: string
|
||||
is_commander?: boolean | null
|
||||
quantity?: number | null
|
||||
}
|
||||
Update: {
|
||||
card_id?: string
|
||||
deck_id?: string
|
||||
id?: string
|
||||
is_commander?: boolean | null
|
||||
quantity?: number | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "deck_cards_deck_id_fkey"
|
||||
columns: ["deck_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "decks"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
decks: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
format: string
|
||||
id: string
|
||||
name: string
|
||||
updated_at: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
format: string
|
||||
id?: string
|
||||
name: string
|
||||
updated_at?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
format?: string
|
||||
id?: string
|
||||
name?: string
|
||||
updated_at?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "decks_user_id_fkey"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
profiles: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
id: string
|
||||
theme_color: string | null
|
||||
updated_at: string | null
|
||||
username: string | null
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
id: string
|
||||
theme_color?: string | null
|
||||
updated_at?: string | null
|
||||
username?: string | null
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
id?: string
|
||||
theme_color?: string | null
|
||||
updated_at?: string | null
|
||||
username?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Functions: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Enums: {
|
||||
[_ in never]: never
|
||||
}
|
||||
CompositeTypes: {
|
||||
[_ in never]: never
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PublicSchema = Database[Extract<keyof Database, "public">]
|
||||
|
||||
export type Tables<
|
||||
PublicTableNameOrOptions extends
|
||||
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
|
||||
| { schema: keyof Database },
|
||||
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
|
||||
Database[PublicTableNameOrOptions["schema"]]["Views"])
|
||||
: never = never,
|
||||
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
|
||||
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
||||
Row: infer R
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
|
||||
PublicSchema["Views"])
|
||||
? (PublicSchema["Tables"] &
|
||||
PublicSchema["Views"])[PublicTableNameOrOptions] extends {
|
||||
Row: infer R
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: never
|
||||
|
||||
export type TablesInsert<
|
||||
PublicTableNameOrOptions extends
|
||||
| keyof PublicSchema["Tables"]
|
||||
| { schema: keyof Database },
|
||||
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
|
||||
: never = never,
|
||||
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||
Insert: infer I
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
|
||||
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
|
||||
Insert: infer I
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: never
|
||||
|
||||
export type TablesUpdate<
|
||||
PublicTableNameOrOptions extends
|
||||
| keyof PublicSchema["Tables"]
|
||||
| { schema: keyof Database },
|
||||
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
|
||||
: never = never,
|
||||
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||
Update: infer U
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
|
||||
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
|
||||
Update: infer U
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: never
|
||||
|
||||
export type Enums<
|
||||
PublicEnumNameOrOptions extends
|
||||
| keyof PublicSchema["Enums"]
|
||||
| { schema: keyof Database },
|
||||
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
|
||||
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
|
||||
: never = never,
|
||||
> = PublicEnumNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
||||
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
|
||||
? PublicSchema["Enums"][PublicEnumNameOrOptions]
|
||||
: never
|
||||
|
||||
export type CompositeTypes<
|
||||
PublicCompositeTypeNameOrOptions extends
|
||||
| keyof PublicSchema["CompositeTypes"]
|
||||
| { schema: keyof Database },
|
||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
||||
: never = never,
|
||||
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
||||
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"]
|
||||
? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||
: never
|
||||
10
src/lib/supabase.ts
Normal file
10
src/lib/supabase.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import {createClient} from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error('Missing Supabase environment variables');
|
||||
}
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||
1
src/main.js
Normal file
1
src/main.js
Normal file
@@ -0,0 +1 @@
|
||||
console.log('Hello from JS!');
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
39
src/services/api.ts
Normal file
39
src/services/api.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Card } from '../types';
|
||||
|
||||
const SCRYFALL_API = 'https://api.scryfall.com';
|
||||
|
||||
export const searchCards = async (query: string): Promise<Card[]> => {
|
||||
const response = await fetch(`${SCRYFALL_API}/cards/search?q=${query}`);
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
};
|
||||
|
||||
export const getRandomCards = async (count: number = 10): Promise<Card[]> => {
|
||||
const cards: Card[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const response = await fetch(`${SCRYFALL_API}/cards/random`);
|
||||
const card = await response.json();
|
||||
cards.push(card);
|
||||
}
|
||||
return cards;
|
||||
};
|
||||
|
||||
export const getCardById = async (cardId: string): Promise<Card> => {
|
||||
const response = await fetch(`${SCRYFALL_API}/cards/${cardId}`);
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const getCardsByIds = async (cardIds: string[]): Promise<Card[]> => {
|
||||
const response = await fetch(`${SCRYFALL_API}/cards/collection`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifiers: cardIds.map((id) => ({ id })),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
};
|
||||
37
src/types/index.ts
Normal file
37
src/types/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
themeColor: 'red' | 'green' | 'blue' | 'yellow' | 'grey' | 'purple';
|
||||
}
|
||||
|
||||
export interface Card {
|
||||
id: string;
|
||||
name: string;
|
||||
image_uris?: {
|
||||
normal: string;
|
||||
art_crop: string;
|
||||
};
|
||||
mana_cost?: string;
|
||||
type_line?: string;
|
||||
oracle_text?: string;
|
||||
colors?: string[];
|
||||
}
|
||||
|
||||
export interface Deck {
|
||||
id: string;
|
||||
name: string;
|
||||
format: string;
|
||||
cards: { card: Card; quantity: number }[];
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CardEntity {
|
||||
id: string;
|
||||
deck_id: string;
|
||||
card_id: string;
|
||||
quantity: number;
|
||||
is_commander: boolean;
|
||||
}
|
||||
89
src/utils/deckValidation.ts
Normal file
89
src/utils/deckValidation.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Deck } from '../types';
|
||||
|
||||
interface DeckValidation {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const FORMAT_RULES = {
|
||||
standard: {
|
||||
minCards: 60,
|
||||
maxCards: undefined,
|
||||
maxCopies: 4,
|
||||
},
|
||||
modern: {
|
||||
minCards: 60,
|
||||
maxCards: undefined,
|
||||
maxCopies: 4,
|
||||
},
|
||||
commander: {
|
||||
minCards: 100,
|
||||
maxCards: 100,
|
||||
maxCopies: 1,
|
||||
requiresCommander: true,
|
||||
},
|
||||
legacy: {
|
||||
minCards: 60,
|
||||
maxCards: undefined,
|
||||
maxCopies: 4,
|
||||
},
|
||||
vintage: {
|
||||
minCards: 60,
|
||||
maxCards: undefined,
|
||||
maxCopies: 4,
|
||||
},
|
||||
pauper: {
|
||||
minCards: 60,
|
||||
maxCards: undefined,
|
||||
maxCopies: 4,
|
||||
},
|
||||
};
|
||||
|
||||
export function validateDeck(deck: Deck): DeckValidation {
|
||||
console.log("deck in validator", deck);
|
||||
const rules = FORMAT_RULES[deck.format as keyof typeof FORMAT_RULES];
|
||||
const errors: string[] = [];
|
||||
|
||||
// Count total cards
|
||||
const totalCards = deck.cards.reduce((acc, curr) => acc + curr.quantity, 0);
|
||||
|
||||
// Check minimum cards
|
||||
if (totalCards < rules.minCards) {
|
||||
errors.push(`Deck must contain at least ${rules.minCards} cards`);
|
||||
}
|
||||
|
||||
// Check maximum cards
|
||||
if (rules.maxCards && totalCards > rules.maxCards) {
|
||||
errors.push(`Deck must not contain more than ${rules.maxCards} cards`);
|
||||
}
|
||||
|
||||
// Check card copies
|
||||
const cardCounts = new Map<string, number>();
|
||||
for (const element of deck.cards) {
|
||||
const {card, quantity} = element;
|
||||
console.log("card", card);
|
||||
const currentCount = cardCounts.get(card.id) || 0;
|
||||
cardCounts.set(card.id, currentCount + quantity);
|
||||
}
|
||||
|
||||
cardCounts.forEach((count, cardName) => {
|
||||
if (count > rules.maxCopies) {
|
||||
errors.push(`${cardName} has too many copies (max ${rules.maxCopies})`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check commander requirement
|
||||
if (rules.requiresCommander) {
|
||||
const hasCommander = deck.cards.some(({ card }) =>
|
||||
card.type_line?.toLowerCase().includes('legendary creature')
|
||||
);
|
||||
if (!hasCommander) {
|
||||
errors.push('Deck must have a legendary creature as commander');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
32
src/utils/theme.ts
Normal file
32
src/utils/theme.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export const themeColors = {
|
||||
red: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#b91c1c',
|
||||
hover: '#dc2626'
|
||||
},
|
||||
green: {
|
||||
primary: '#22c55e',
|
||||
secondary: '#15803d',
|
||||
hover: '#16a34a'
|
||||
},
|
||||
blue: {
|
||||
primary: '#3b82f6',
|
||||
secondary: '#1d4ed8',
|
||||
hover: '#2563eb'
|
||||
},
|
||||
yellow: {
|
||||
primary: '#eab308',
|
||||
secondary: '#a16207',
|
||||
hover: '#ca8a04'
|
||||
},
|
||||
grey: {
|
||||
primary: '#6b7280',
|
||||
secondary: '#374151',
|
||||
hover: '#4b5563'
|
||||
},
|
||||
purple: {
|
||||
primary: '#a855f7',
|
||||
secondary: '#7e22ce',
|
||||
hover: '#9333ea'
|
||||
}
|
||||
};
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user