Initial commit from your app

This commit is contained in:
Reynier Matthieu
2025-02-01 22:24:59 +01:00
parent 8eda5e8cfe
commit d4c44ece53
38 changed files with 9031 additions and 0 deletions

3
.bolt/config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"template": "bolt-vite-react-ts"
}

7
.bolt/prompt Normal file
View File

@@ -0,0 +1,7 @@
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
Use icons from lucide-react for logos.
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.

2
.env Normal file
View File

@@ -0,0 +1,2 @@
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InllZGdoanJweXhoeGVzbmJ0YmlwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzgzMjk2OTEsImV4cCI6MjA1MzkwNTY5MX0.pKO-axLvkHtTOMEtEkCPz2yY2khm9RKzkqcFkl_VM_U
VITE_SUPABASE_URL=https://yedghjrpyxhxesnbtbip.supabase.co

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

9
index.html Normal file
View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>My Project</title>
</head>
<body>
<h1>Hello, World!</h1>
</body>
</html>

4069
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "vite-react-typescript-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.48.1",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}

2677
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

85
src/App.tsx Normal file
View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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;

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
console.log('Hello from JS!');

10
src/main.tsx Normal file
View 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
View 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
View 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;
}

View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,143 @@
/*
# Initial Schema Setup
1. New Tables
- `profiles`
- `id` (uuid, references auth.users)
- `username` (text, unique)
- `theme_color` (text, enum)
- `created_at` (timestamptz)
- `updated_at` (timestamptz)
- `collections`
- `id` (uuid)
- `user_id` (uuid, references profiles)
- `card_id` (text)
- `quantity` (integer)
- `created_at` (timestamptz)
- `updated_at` (timestamptz)
- `decks`
- `id` (uuid)
- `user_id` (uuid, references profiles)
- `name` (text)
- `format` (text)
- `created_at` (timestamptz)
- `updated_at` (timestamptz)
- `deck_cards`
- `id` (uuid)
- `deck_id` (uuid, references decks)
- `card_id` (text)
- `quantity` (integer)
- `is_commander` (boolean)
2. Security
- Enable RLS on all tables
- Add policies for authenticated users to manage their own data
*/
-- Create profiles table
CREATE TABLE public.profiles (
id uuid PRIMARY KEY REFERENCES auth.users,
username text UNIQUE,
theme_color text CHECK (theme_color IN ('red', 'green', 'blue', 'yellow', 'grey', 'purple')),
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
-- Create collections table
CREATE TABLE public.collections (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES public.profiles(id) NOT NULL,
card_id text NOT NULL,
quantity integer DEFAULT 1,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
-- Create decks table
CREATE TABLE public.decks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES public.profiles(id) NOT NULL,
name text NOT NULL,
format text NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
-- Create deck_cards table
CREATE TABLE public.deck_cards (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
deck_id uuid REFERENCES public.decks(id) NOT NULL,
card_id text NOT NULL,
quantity integer DEFAULT 1,
is_commander boolean DEFAULT false
);
-- Enable Row Level Security
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.collections ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.decks ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.deck_cards ENABLE ROW LEVEL SECURITY;
-- Profiles policies
CREATE POLICY "Users can view their own profile"
ON public.profiles
FOR SELECT
TO authenticated
USING (auth.uid() = id);
CREATE POLICY "Users can update their own profile"
ON public.profiles
FOR UPDATE
TO authenticated
USING (auth.uid() = id);
-- Collections policies
CREATE POLICY "Users can view their own collection"
ON public.collections
FOR SELECT
TO authenticated
USING (user_id = auth.uid());
CREATE POLICY "Users can manage their own collection"
ON public.collections
FOR ALL
TO authenticated
USING (user_id = auth.uid());
-- Decks policies
CREATE POLICY "Users can view their own decks"
ON public.decks
FOR SELECT
TO authenticated
USING (user_id = auth.uid());
CREATE POLICY "Users can manage their own decks"
ON public.decks
FOR ALL
TO authenticated
USING (user_id = auth.uid());
-- Deck cards policies
CREATE POLICY "Users can view cards in their decks"
ON public.deck_cards
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.decks
WHERE decks.id = deck_cards.deck_id
AND decks.user_id = auth.uid()
)
);
CREATE POLICY "Users can manage cards in their decks"
ON public.deck_cards
FOR ALL
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.decks
WHERE decks.id = deck_cards.deck_id
AND decks.user_id = auth.uid()
)
);

14
tailwind.config.js Normal file
View File

@@ -0,0 +1,14 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
background: '#0f172a',
primary: 'var(--color-primary)',
secondary: 'var(--color-secondary)',
},
},
},
plugins: [],
};

25
tsconfig.app.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

10
vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
});