add chunk to get cards collection from scryfall

This commit is contained in:
Reynier Matthieu
2025-03-06 15:49:44 +01:00
parent 2ffa49b8f0
commit a077c40c5a
36 changed files with 3068 additions and 3055 deletions

View File

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

View File

@@ -1,7 +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. 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. 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 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. 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
View File

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

46
.gitignore vendored
View File

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

40
LICENSE
View File

@@ -1,21 +1,21 @@
MIT License MIT License
Copyright (c) 2025 Reynier Matthieu Copyright (c) 2025 Reynier Matthieu
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions: furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software. copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.

View File

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

View File

@@ -1,13 +1,13 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>Vite + React + TS</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

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

View File

@@ -1,91 +1,91 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import DeckManager from './components/DeckManager'; import DeckManager from './components/DeckManager';
import DeckList from './components/DeckList'; import DeckList from './components/DeckList';
import LoginForm from './components/LoginForm'; import LoginForm from './components/LoginForm';
import Navigation from './components/Navigation'; import Navigation from './components/Navigation';
import Collection from './components/Collection'; import Collection from './components/Collection';
import DeckEditor from './components/DeckEditor'; import DeckEditor from './components/DeckEditor';
import Profile from './components/Profile'; import Profile from './components/Profile';
import CardSearch from './components/CardSearch'; import CardSearch from './components/CardSearch';
import LifeCounter from './components/LifeCounter'; import LifeCounter from './components/LifeCounter';
import { AuthProvider, useAuth } from './contexts/AuthContext'; import { AuthProvider, useAuth } from './contexts/AuthContext';
type Page = 'home' | 'deck' | 'login' | 'collection' | 'edit-deck' | 'profile' | 'search' | 'life-counter'; type Page = 'home' | 'deck' | 'login' | 'collection' | 'edit-deck' | 'profile' | 'search' | 'life-counter';
function AppContent() { function AppContent() {
const [currentPage, setCurrentPage] = useState<Page>('home'); const [currentPage, setCurrentPage] = useState<Page>('home');
const [selectedDeckId, setSelectedDeckId] = useState<string | null>(null); const [selectedDeckId, setSelectedDeckId] = useState<string | null>(null);
const { user, loading } = useAuth(); const { user, loading } = useAuth();
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center"> <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 className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
</div> </div>
); );
} }
if (!user && currentPage !== 'login') { if (!user && currentPage !== 'login') {
return <LoginForm />; return <LoginForm />;
} }
const handleDeckEdit = (deckId: string) => { const handleDeckEdit = (deckId: string) => {
setSelectedDeckId(deckId); setSelectedDeckId(deckId);
setCurrentPage('edit-deck'); setCurrentPage('edit-deck');
}; };
const renderPage = () => { const renderPage = () => {
switch (currentPage) { switch (currentPage) {
case 'home': case 'home':
return ( return (
<div className="min-h-screen bg-gray-900 text-white p-6"> <div className="min-h-screen bg-gray-900 text-white p-6">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-6">My Decks</h1> <h1 className="text-3xl font-bold mb-6">My Decks</h1>
<DeckList onDeckEdit={handleDeckEdit} /> <DeckList onDeckEdit={handleDeckEdit} />
</div> </div>
</div> </div>
); );
case 'deck': case 'deck':
return <DeckManager />; return <DeckManager />;
case 'edit-deck': case 'edit-deck':
return selectedDeckId ? ( return selectedDeckId ? (
<DeckEditor <DeckEditor
deckId={selectedDeckId} deckId={selectedDeckId}
onClose={() => { onClose={() => {
setSelectedDeckId(null); setSelectedDeckId(null);
setCurrentPage('home'); setCurrentPage('home');
}} }}
/> />
) : null; ) : null;
case 'collection': case 'collection':
return <Collection />; return <Collection />;
case 'profile': case 'profile':
return <Profile />; return <Profile />;
case 'search': case 'search':
return <CardSearch />; return <CardSearch />;
case 'life-counter': case 'life-counter':
return <LifeCounter />; return <LifeCounter />;
case 'login': case 'login':
return <LoginForm />; return <LoginForm />;
default: default:
return null; return null;
} }
}; };
return ( return (
<div className="min-h-screen bg-gray-900"> <div className="min-h-screen bg-gray-900">
<Navigation currentPage={currentPage} setCurrentPage={setCurrentPage} /> <Navigation currentPage={currentPage} setCurrentPage={setCurrentPage} />
{renderPage()} {renderPage()}
</div> </div>
); );
} }
function App() { function App() {
return ( return (
<AuthProvider> <AuthProvider>
<AppContent /> <AppContent />
</AuthProvider> </AuthProvider>
); );
} }
export default App; export default App;

View File

@@ -1,47 +1,47 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Card } from '../types'; import { Card } from '../types';
import { getRandomCards } from '../services/api'; import { getRandomCards } from '../services/api';
export default function CardCarousel() { export default function CardCarousel() {
const [cards, setCards] = useState<Card[]>([]); const [cards, setCards] = useState<Card[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const loadCards = async () => { const loadCards = async () => {
try { try {
const randomCards = await getRandomCards(6); const randomCards = await getRandomCards(6);
setCards(randomCards); setCards(randomCards);
} catch (error) { } catch (error) {
console.error('Failed to load cards:', error); console.error('Failed to load cards:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
loadCards(); loadCards();
}, []); }, []);
if (loading) { if (loading) {
return <div className="animate-pulse h-96 bg-gray-700/50 rounded-lg"></div>; return <div className="animate-pulse h-96 bg-gray-700/50 rounded-lg"></div>;
} }
return ( return (
<div className="relative h-screen overflow-hidden"> <div className="relative h-screen overflow-hidden">
<div className="absolute inset-0 flex"> <div className="absolute inset-0 flex">
{cards.map((card, index) => ( {cards.map((card, index) => (
<div <div
key={card.id} key={card.id}
className="min-w-full h-full transform transition-transform duration-1000" className="min-w-full h-full transform transition-transform duration-1000"
style={{ style={{
backgroundImage: `url(${card.image_uris?.normal})`, backgroundImage: `url(${card.image_uris?.normal})`,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
filter: 'blur(8px)', filter: 'blur(8px)',
opacity: 0.5 opacity: 0.5
}} }}
/> />
))} ))}
</div> </div>
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,121 +1,121 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Search, Plus } from 'lucide-react'; import { Search, Plus } from 'lucide-react';
import { Card } from '../types'; import { Card } from '../types';
import { searchCards } from '../services/api'; import { searchCards } from '../services/api';
export default function Collection() { export default function Collection() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Card[]>([]); const [searchResults, setSearchResults] = useState<Card[]>([]);
const [collection, setCollection] = useState<{ card: Card; quantity: number }[]>([]); const [collection, setCollection] = useState<{ card: Card; quantity: number }[]>([]);
const handleSearch = async (e: React.FormEvent) => { const handleSearch = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!searchQuery.trim()) return; if (!searchQuery.trim()) return;
try { try {
const cards = await searchCards(searchQuery); const cards = await searchCards(searchQuery);
setSearchResults(cards); setSearchResults(cards);
} catch (error) { } catch (error) {
console.error('Failed to search cards:', error); console.error('Failed to search cards:', error);
} }
}; };
const addToCollection = (card: Card) => { const addToCollection = (card: Card) => {
setCollection(prev => { setCollection(prev => {
const existing = prev.find(c => c.card.id === card.id); const existing = prev.find(c => c.card.id === card.id);
if (existing) { if (existing) {
return prev.map(c => return prev.map(c =>
c.card.id === card.id c.card.id === card.id
? { ...c, quantity: c.quantity + 1 } ? { ...c, quantity: c.quantity + 1 }
: c : c
); );
} }
return [...prev, { card, quantity: 1 }]; return [...prev, { card, quantity: 1 }];
}); });
}; };
return ( return (
<div className="min-h-screen bg-gray-900 text-white p-6"> <div className="min-h-screen bg-gray-900 text-white p-6">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-6">My Collection</h1> <h1 className="text-3xl font-bold mb-6">My Collection</h1>
{/* Search */} {/* Search */}
<form onSubmit={handleSearch} className="flex gap-2 mb-8"> <form onSubmit={handleSearch} className="flex gap-2 mb-8">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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" 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..." placeholder="Search cards to add..."
/> />
</div> </div>
<button <button
type="submit" type="submit"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2" className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2"
> >
<Search size={20} /> <Search size={20} />
Search Search
</button> </button>
</form> </form>
{/* Search Results */} {/* Search Results */}
{searchResults.length > 0 && ( {searchResults.length > 0 && (
<div className="mb-8"> <div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Search Results</h2> <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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{searchResults.map(card => ( {searchResults.map(card => (
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden"> <div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
{card.image_uris?.normal && ( {card.image_uris?.normal && (
<img <img
src={card.image_uris.normal} src={card.image_uris.normal}
alt={card.name} alt={card.name}
className="w-full h-auto" className="w-full h-auto"
/> />
)} )}
<div className="p-4"> <div className="p-4">
<h3 className="font-bold mb-2">{card.name}</h3> <h3 className="font-bold mb-2">{card.name}</h3>
<button <button
onClick={() => addToCollection(card)} 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" 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} /> <Plus size={20} />
Add to Collection Add to Collection
</button> </button>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
)} )}
{/* Collection */} {/* Collection */}
<div> <div>
<h2 className="text-xl font-semibold mb-4">My Cards</h2> <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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{collection.map(({ card, quantity }) => ( {collection.map(({ card, quantity }) => (
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden"> <div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
{card.image_uris?.normal && ( {card.image_uris?.normal && (
<img <img
src={card.image_uris.normal} src={card.image_uris.normal}
alt={card.name} alt={card.name}
className="w-full h-auto" className="w-full h-auto"
/> />
)} )}
<div className="p-4"> <div className="p-4">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<h3 className="font-bold">{card.name}</h3> <h3 className="font-bold">{card.name}</h3>
<span className="text-sm bg-blue-600 px-2 py-1 rounded"> <span className="text-sm bg-blue-600 px-2 py-1 rounded">
x{quantity} x{quantity}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,77 +1,77 @@
import React from 'react'; import React from 'react';
import { AlertTriangle, Check, Edit } from 'lucide-react'; import { AlertTriangle, Check, Edit } from 'lucide-react';
import { Deck } from '../types'; import { Deck } from '../types';
import { validateDeck } from '../utils/deckValidation'; import { validateDeck } from '../utils/deckValidation';
interface DeckCardProps { interface DeckCardProps {
deck: Deck; deck: Deck;
onEdit?: (deckId: string) => void; onEdit?: (deckId: string) => void;
} }
export default function DeckCard({ deck, onEdit }: DeckCardProps) { export default function DeckCard({ deck, onEdit }: DeckCardProps) {
if(deck.id === "410ed539-a8f4-4bc4-91f1-6c113b9b7e25"){ if(deck.id === "410ed539-a8f4-4bc4-91f1-6c113b9b7e25"){
console.log("deck", deck.name); console.log("deck", deck.name);
console.log("cardEntities", deck.cards); console.log("cardEntities", deck.cards);
} }
const validation = validateDeck(deck); const validation = validateDeck(deck);
const commander = deck.format === 'commander' ? deck.cards.find(card => const commander = deck.format === 'commander' ? deck.cards.find(card =>
card.is_commander card.is_commander
)?.card : null; )?.card : null;
return ( return (
<div <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" 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)} onClick={() => onEdit?.(deck.id)}
> >
<div className="relative h-48 overflow-hidden"> <div className="relative h-48 overflow-hidden">
<img <img
src={commander?.image_uris?.normal || deck.cards[0]?.card.image_uris?.normal} src={commander?.image_uris?.normal || deck.cards[0]?.card.image_uris?.normal}
alt={commander?.name || deck.cards[0]?.card.name} alt={commander?.name || deck.cards[0]?.card.name}
className="w-full object-cover object-top transform translate-y-[-12%]" 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 className="absolute inset-0 bg-gradient-to-t from-gray-900 to-transparent" />
</div> </div>
<div className="p-4"> <div className="p-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="text-xl font-bold text-white">{deck.name}</h3> <h3 className="text-xl font-bold text-white">{deck.name}</h3>
{validation.isValid ? ( {validation.isValid ? (
<div className="flex items-center text-green-400"> <div className="flex items-center text-green-400">
<Check size={16} className="mr-1" /> <Check size={16} className="mr-1" />
<span className="text-sm">Legal</span> <span className="text-sm">Legal</span>
</div> </div>
) : ( ) : (
<div className="flex items-center text-yellow-400" title={validation.errors.join(', ')}> <div className="flex items-center text-yellow-400" title={validation.errors.join(', ')}>
<AlertTriangle size={16} className="mr-1" /> <AlertTriangle size={16} className="mr-1" />
<span className="text-sm">Issues</span> <span className="text-sm">Issues</span>
</div> </div>
)} )}
</div> </div>
<div className="flex items-center justify-between text-sm text-gray-400"> <div className="flex items-center justify-between text-sm text-gray-400">
<span className="capitalize">{deck.format}</span> <span className="capitalize">{deck.format}</span>
<span>{deck.cards.reduce((acc, curr) => acc + curr.quantity, 0)} cards</span> <span>{deck.cards.reduce((acc, curr) => acc + curr.quantity, 0)} cards</span>
</div> </div>
{commander && ( {commander && (
<div className="mt-2 text-sm text-gray-300"> <div className="mt-2 text-sm text-gray-300">
<span className="text-blue-400">Commander:</span> {commander.name} <span className="text-blue-400">Commander:</span> {commander.name}
</div> </div>
)} )}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEdit?.(deck.id); 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" 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 size={20} />
Edit Deck Edit Deck
</button> </button>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,85 +1,85 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Card, Deck } from '../types'; import { Card, Deck } from '../types';
import DeckManager from './DeckManager'; import DeckManager from './DeckManager';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { getCardsByIds } from '../services/api'; import { getCardsByIds } from '../services/api';
interface DeckEditorProps { interface DeckEditorProps {
deckId: string; deckId: string;
onClose?: () => void; onClose?: () => void;
} }
export default function DeckEditor({ deckId, onClose }: DeckEditorProps) { export default function DeckEditor({ deckId, onClose }: DeckEditorProps) {
const [deck, setDeck] = useState<Deck | null>(null); const [deck, setDeck] = useState<Deck | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const fetchDeck = async () => { const fetchDeck = async () => {
try { try {
// Fetch deck data // Fetch deck data
const { data: deckData, error: deckError } = await supabase const { data: deckData, error: deckError } = await supabase
.from('decks') .from('decks')
.select('*') .select('*')
.eq('id', deckId) .eq('id', deckId)
.single(); .single();
if (deckError) throw deckError; if (deckError) throw deckError;
// Fetch deck cards // Fetch deck cards
const { data: cardEntities, error: cardsError } = await supabase const { data: cardEntities, error: cardsError } = await supabase
.from('deck_cards') .from('deck_cards')
.select('*') .select('*')
.eq('deck_id', deckId); .eq('deck_id', deckId);
if (cardsError) throw cardsError; if (cardsError) throw cardsError;
// Fetch card details from Scryfall // Fetch card details from Scryfall
const cardIds = cardEntities.map(entity => entity.card_id); const cardIds = cardEntities.map(entity => entity.card_id);
const uniqueCardIds = [...new Set(cardIds)]; const uniqueCardIds = [...new Set(cardIds)];
const scryfallCards = await getCardsByIds(uniqueCardIds); const scryfallCards = await getCardsByIds(uniqueCardIds);
// Combine deck data with card details // Combine deck data with card details
const cards = cardEntities.map(entity => ({ const cards = cardEntities.map(entity => ({
card: scryfallCards.find(c => c.id === entity.card_id) as Card, card: scryfallCards.find(c => c.id === entity.card_id) as Card,
quantity: entity.quantity, quantity: entity.quantity,
})); }));
setDeck({ setDeck({
...deckData, ...deckData,
cards, cards,
createdAt: new Date(deckData.created_at), createdAt: new Date(deckData.created_at),
updatedAt: new Date(deckData.updated_at), updatedAt: new Date(deckData.updated_at),
}); });
} catch (error) { } catch (error) {
console.error('Error fetching deck:', error); console.error('Error fetching deck:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchDeck(); fetchDeck();
}, [deckId]); }, [deckId]);
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-gray-900 text-white p-6 flex items-center justify-center"> <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 className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
</div> </div>
); );
} }
if (!deck) { if (!deck) {
return ( return (
<div className="min-h-screen bg-gray-900 text-white p-6"> <div className="min-h-screen bg-gray-900 text-white p-6">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4"> <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> <h2 className="text-xl font-bold text-red-500">Error</h2>
<p className="text-red-400">Failed to load deck</p> <p className="text-red-400">Failed to load deck</p>
</div> </div>
</div> </div>
</div> </div>
); );
} }
return <DeckManager initialDeck={deck} onSave={onClose} />; return <DeckManager initialDeck={deck} onSave={onClose} />;
} }

View File

@@ -1,99 +1,99 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { getCardById, getCardsByIds } from '../services/api'; import { getCardById, getCardsByIds } from '../services/api';
import { Deck } from '../types'; import { Deck } from '../types';
import { supabase } from "../lib/supabase"; import { supabase } from "../lib/supabase";
import DeckCard from "./DeckCard"; import DeckCard from "./DeckCard";
interface DeckListProps { interface DeckListProps {
onDeckEdit?: (deckId: string) => void; onDeckEdit?: (deckId: string) => void;
} }
const DeckList = ({ onDeckEdit }: DeckListProps) => { const DeckList = ({ onDeckEdit }: DeckListProps) => {
const [decks, setDecks] = useState<Deck[]>([]); const [decks, setDecks] = useState<Deck[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const fetchDecks = async () => { const fetchDecks = async () => {
const { data: decksData, error: decksError } = await supabase.from('decks').select('*'); const { data: decksData, error: decksError } = await supabase.from('decks').select('*');
if (decksError) { if (decksError) {
console.error('Error fetching decks:', decksError); console.error('Error fetching decks:', decksError);
setLoading(false); setLoading(false);
return; return;
} }
const decksWithCards = await Promise.all(decksData.map(async (deck) => { const decksWithCards = await Promise.all(decksData.map(async (deck) => {
const { data: cardEntities, error: cardsError } = await supabase const { data: cardEntities, error: cardsError } = await supabase
.from('deck_cards') .from('deck_cards')
.select('*') .select('*')
.eq('deck_id', deck.id); .eq('deck_id', deck.id);
if (cardsError) { if (cardsError) {
console.error(`Error fetching cards for deck ${deck.id}:`, cardsError); console.error(`Error fetching cards for deck ${deck.id}:`, cardsError);
return { ...deck, cards: [] }; return { ...deck, cards: [] };
} }
const cardIds = cardEntities.map((entity) => entity.card_id); const cardIds = cardEntities.map((entity) => entity.card_id);
const uniqueCardIds = [...new Set(cardIds)]; const uniqueCardIds = [...new Set(cardIds)];
if(deck.id === "410ed539-a8f4-4bc4-91f1-6c113b9b7e25"){ if(deck.id === "410ed539-a8f4-4bc4-91f1-6c113b9b7e25"){
console.log("uniqueCardIds", uniqueCardIds); console.log("uniqueCardIds", uniqueCardIds);
} }
try { try {
const scryfallCards = await getCardsByIds(uniqueCardIds); const scryfallCards = await getCardsByIds(uniqueCardIds);
if (!scryfallCards) { if (!scryfallCards) {
console.error("scryfallCards is undefined after getCardsByIds"); console.error("scryfallCards is undefined after getCardsByIds");
return { ...deck, cards: [] }; return { ...deck, cards: [] };
} }
const cards = cardEntities.map((entity) => { const cards = cardEntities.map((entity) => {
const card = scryfallCards.find((c) => c.id === entity.card_id); const card = scryfallCards.find((c) => c.id === entity.card_id);
return { return {
card, card,
quantity: entity.quantity, quantity: entity.quantity,
is_commander: entity.is_commander, is_commander: entity.is_commander,
}; };
}); });
return { return {
...deck, ...deck,
cards, cards,
createdAt: new Date(deck.created_at), createdAt: new Date(deck.created_at),
updatedAt: new Date(deck.updated_at), updatedAt: new Date(deck.updated_at),
}; };
} catch (error) { } catch (error) {
console.error("Error fetching cards from Scryfall:", error); console.error("Error fetching cards from Scryfall:", error);
return { ...deck, cards: [] }; return { ...deck, cards: [] };
} }
})); }));
setDecks(decksWithCards); setDecks(decksWithCards);
setLoading(false); setLoading(false);
}; };
fetchDecks(); fetchDecks();
}, []); }, []);
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <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 className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
</div> </div>
); );
} }
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{decks.map((deck) => ( {decks.map((deck) => (
<DeckCard key={deck.id} deck={deck} onEdit={onDeckEdit} /> <DeckCard key={deck.id} deck={deck} onEdit={onDeckEdit} />
))} ))}
</div> </div>
); );
}; };
export default DeckList; export default DeckList;

File diff suppressed because it is too large Load Diff

View File

@@ -1,167 +1,167 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Plus, Minus } from 'lucide-react'; import { Plus, Minus } from 'lucide-react';
interface Player { interface Player {
id: number; id: number;
name: string; name: string;
life: number; life: number;
color: string; color: string;
} }
const COLORS = ['white', 'blue', 'black', 'red', 'green']; const COLORS = ['white', 'blue', 'black', 'red', 'green'];
export default function LifeCounter() { export default function LifeCounter() {
const [numPlayers, setNumPlayers] = useState<number | null>(null); const [numPlayers, setNumPlayers] = useState<number | null>(null);
const [playerNames, setPlayerNames] = useState<string[]>([]); const [playerNames, setPlayerNames] = useState<string[]>([]);
const [players, setPlayers] = useState<Player[]>([]); const [players, setPlayers] = useState<Player[]>([]);
const [setupComplete, setSetupComplete] = useState(false); const [setupComplete, setSetupComplete] = useState(false);
useEffect(() => { useEffect(() => {
if (numPlayers !== null) { if (numPlayers !== null) {
setPlayers( setPlayers(
Array.from({ length: numPlayers }, (_, i) => ({ Array.from({ length: numPlayers }, (_, i) => ({
id: i + 1, id: i + 1,
name: playerNames[i] || `Player ${i + 1}`, name: playerNames[i] || `Player ${i + 1}`,
life: 20, life: 20,
color: COLORS[i % COLORS.length], color: COLORS[i % COLORS.length],
})) }))
); );
} }
}, [numPlayers, playerNames]); }, [numPlayers, playerNames]);
const handleNumPlayersChange = (e: React.ChangeEvent<HTMLSelectElement>) => { const handleNumPlayersChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newNumPlayers = parseInt(e.target.value, 10); const newNumPlayers = parseInt(e.target.value, 10);
setNumPlayers(newNumPlayers); setNumPlayers(newNumPlayers);
setPlayerNames(Array(newNumPlayers).fill('')); setPlayerNames(Array(newNumPlayers).fill(''));
}; };
const handleNameChange = (index: number, newName: string) => { const handleNameChange = (index: number, newName: string) => {
const updatedNames = [...playerNames]; const updatedNames = [...playerNames];
updatedNames[index] = newName; updatedNames[index] = newName;
setPlayerNames(updatedNames); setPlayerNames(updatedNames);
}; };
const updateLife = (playerId: number, change: number) => { const updateLife = (playerId: number, change: number) => {
setPlayers((prevPlayers) => setPlayers((prevPlayers) =>
prevPlayers.map((player) => prevPlayers.map((player) =>
player.id === playerId ? { ...player, life: player.life + change } : player player.id === playerId ? { ...player, life: player.life + change } : player
) )
); );
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSetupComplete(true); setSetupComplete(true);
}; };
const renderSetupForm = () => ( const renderSetupForm = () => (
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<h2 className="text-2xl font-bold mb-6">Setup Players</h2> <h2 className="text-2xl font-bold mb-6">Setup Players</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">
Number of Players Number of Players
</label> </label>
<select <select
value={numPlayers || ''} value={numPlayers || ''}
onChange={handleNumPlayersChange} onChange={handleNumPlayersChange}
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 text-white" 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 text-white"
required required
> >
<option value="" disabled>Select Number of Players</option> <option value="" disabled>Select Number of Players</option>
{[2, 3, 4, 5, 6].map((num) => ( {[2, 3, 4, 5, 6].map((num) => (
<option key={num} value={num}> <option key={num} value={num}>
{num} {num}
</option> </option>
))} ))}
</select> </select>
</div> </div>
{numPlayers !== null && {numPlayers !== null &&
Array.from({ length: numPlayers }, (_, i) => ( Array.from({ length: numPlayers }, (_, i) => (
<div key={i}> <div key={i}>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">
Player {i + 1} Name Player {i + 1} Name
</label> </label>
<input <input
type="text" type="text"
value={playerNames[i] || ''} value={playerNames[i] || ''}
onChange={(e) => handleNameChange(i, e.target.value)} onChange={(e) => handleNameChange(i, 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 text-white" 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 text-white"
placeholder={`Player ${i + 1} Name`} placeholder={`Player ${i + 1} Name`}
/> />
</div> </div>
))} ))}
{numPlayers !== null && ( {numPlayers !== null && (
<button <button
type="submit" type="submit"
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg" className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg"
> >
Start Game Start Game
</button> </button>
)} )}
</form> </form>
</div> </div>
); );
const renderLifeCounters = () => ( const renderLifeCounters = () => (
<div className="flex flex-col items-center justify-center min-h-screen"> <div className="flex flex-col items-center justify-center min-h-screen">
<div className="relative w-full h-full"> <div className="relative w-full h-full">
{players.map((player, index) => { {players.map((player, index) => {
const angle = (index / players.length) * 360; const angle = (index / players.length) * 360;
const rotation = 360 - angle; const rotation = 360 - angle;
const x = 50 + 40 * Math.cos((angle - 90) * Math.PI / 180); const x = 50 + 40 * Math.cos((angle - 90) * Math.PI / 180);
const y = 50 + 40 * Math.sin((angle - 90) * Math.PI / 180); const y = 50 + 40 * Math.sin((angle - 90) * Math.PI / 180);
return ( return (
<div <div
key={player.id} key={player.id}
className="absolute transform -translate-x-1/2 -translate-y-1/2" className="absolute transform -translate-x-1/2 -translate-y-1/2"
style={{ style={{
top: `${y}%`, top: `${y}%`,
left: `${x}%`, left: `${x}%`,
transform: `translate(-50%, -50%) rotate(${rotation}deg)`, transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
}} }}
> >
<div <div
className="rounded-lg p-4 flex flex-col items-center" className="rounded-lg p-4 flex flex-col items-center"
style={{ style={{
backgroundColor: `var(--color-${player.color}-primary)`, backgroundColor: `var(--color-${player.color}-primary)`,
color: 'white', color: 'white',
transform: `rotate(${-rotation}deg)`, transform: `rotate(${-rotation}deg)`,
}} }}
> >
<h2 className="text-xl font-bold mb-4">{player.name}</h2> <h2 className="text-xl font-bold mb-4">{player.name}</h2>
<div className="text-4xl font-bold mb-4">{player.life}</div> <div className="text-4xl font-bold mb-4">{player.life}</div>
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
onClick={() => updateLife(player.id, 1)} onClick={() => updateLife(player.id, 1)}
className="bg-green-600 hover:bg-green-700 rounded-full p-2" className="bg-green-600 hover:bg-green-700 rounded-full p-2"
> >
<Plus size={24} /> <Plus size={24} />
</button> </button>
<button <button
onClick={() => updateLife(player.id, -1)} onClick={() => updateLife(player.id, -1)}
className="bg-red-600 hover:bg-red-700 rounded-full p-2" className="bg-red-600 hover:bg-red-700 rounded-full p-2"
> >
<Minus size={24} /> <Minus size={24} />
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
); );
return ( return (
<div className="min-h-screen bg-gray-900 text-white p-6"> <div className="min-h-screen bg-gray-900 text-white p-6">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-6">Life Counter</h1> <h1 className="text-3xl font-bold mb-6">Life Counter</h1>
{!setupComplete ? renderSetupForm() : renderLifeCounters()} {!setupComplete ? renderSetupForm() : renderLifeCounters()}
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,147 +1,147 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Mail, Lock, LogIn } from 'lucide-react'; import { Mail, Lock, LogIn } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { Card } from '../types'; import { Card } from '../types';
import { getRandomCards } from '../services/api'; import { getRandomCards } from '../services/api';
export default function LoginForm() { export default function LoginForm() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isSignUp, setIsSignUp] = useState(false); const [isSignUp, setIsSignUp] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { signIn, signUp } = useAuth(); const { signIn, signUp } = useAuth();
const [cards, setCards] = useState<Card[]>([]); const [cards, setCards] = useState<Card[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const loadCards = async () => { const loadCards = async () => {
try { try {
const randomCards = await getRandomCards(6); const randomCards = await getRandomCards(6);
setCards(randomCards); setCards(randomCards);
} catch (error) { } catch (error) {
console.error('Failed to load cards:', error); console.error('Failed to load cards:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
loadCards(); loadCards();
}, []); }, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
try { try {
if (isSignUp) { if (isSignUp) {
await signUp(email, password); await signUp(email, password);
} else { } else {
await signIn(email, password); await signIn(email, password);
} }
window.location.href = '/'; // Redirect to home after successful login window.location.href = '/'; // Redirect to home after successful login
} catch (error) { } catch (error) {
setError(error instanceof Error ? error.message : 'An error occurred'); setError(error instanceof Error ? error.message : 'An error occurred');
} }
}; };
if (loading) { if (loading) {
return <div className="animate-pulse h-96 bg-gray-700/50 rounded-lg"></div>; return <div className="animate-pulse h-96 bg-gray-700/50 rounded-lg"></div>;
} }
return ( return (
<div className="relative min-h-screen flex items-center justify-center p-6 overflow-hidden"> <div className="relative min-h-screen flex items-center justify-center p-6 overflow-hidden">
{/* Animated Background */} {/* Animated Background */}
<div className="absolute inset-0 overflow-hidden"> <div className="absolute inset-0 overflow-hidden">
<div <div
className="flex animate-slide" className="flex animate-slide"
style={{ style={{
width: `${cards.length * 100}%`, width: `${cards.length * 100}%`,
animation: 'slide 60s linear infinite' animation: 'slide 60s linear infinite'
}} }}
> >
{[...cards, ...cards].map((card, index) => ( {[...cards, ...cards].map((card, index) => (
<div <div
key={`${card.id}-${index}`} key={`${card.id}-${index}`}
className="relative w-full h-screen" className="relative w-full h-screen"
style={{ style={{
width: `${100 / (cards.length * 2)}%` width: `${100 / (cards.length * 2)}%`
}} }}
> >
<div <div
className="absolute inset-0 bg-cover bg-center transform transition-transform duration-1000" className="absolute inset-0 bg-cover bg-center transform transition-transform duration-1000"
style={{ style={{
backgroundImage: `url(${card.image_uris?.normal})`, backgroundImage: `url(${card.image_uris?.normal})`,
filter: 'blur(8px) brightness(0.4)', filter: 'blur(8px) brightness(0.4)',
}} }}
/> />
</div> </div>
))} ))}
</div> </div>
</div> </div>
{/* Login Form */} {/* 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"> <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"> <h2 className="text-3xl font-bold text-orange-500 mb-6 text-center">
Deckerr Deckerr
</h2> </h2>
{error && ( {error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500 rounded text-red-500"> <div className="mb-4 p-3 bg-red-500/10 border border-red-500 rounded text-red-500">
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">
Email Email
</label> </label>
<div className="relative"> <div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} /> <Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} 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" 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" placeholder="Enter your email"
required required
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">
Password Password
</label> </label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} /> <Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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" 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" placeholder="Enter your password"
required required
/> />
</div> </div>
</div> </div>
<button <button
type="submit" 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" 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} /> <LogIn size={20} />
{isSignUp ? 'Sign Up' : 'Sign In'} {isSignUp ? 'Sign Up' : 'Sign In'}
</button> </button>
</form> </form>
<div className="mt-4 text-center"> <div className="mt-4 text-center">
<button <button
onClick={() => setIsSignUp(!isSignUp)} onClick={() => setIsSignUp(!isSignUp)}
className="text-blue-400 hover:text-blue-300" className="text-blue-400 hover:text-blue-300"
> >
{isSignUp ? 'Already have an account? Sign In' : 'Need an account? Sign Up'} {isSignUp ? 'Already have an account? Sign In' : 'Need an account? Sign Up'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

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

View File

@@ -1,107 +1,107 @@
import React from 'react'; import React from 'react';
export const ManaWhite = ({ size = 20, ...props }: { size?: number }) => ( export const ManaWhite = ({ size = 20, ...props }: { size?: number }) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width={size} width={size}
height={size} height={size}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
{...props} {...props}
> >
<path d="M12 2v20M2 12h20" /> <path d="M12 2v20M2 12h20" />
</svg> </svg>
); );
export const ManaBlue = ({ size = 20, ...props }: { size?: number }) => ( export const ManaBlue = ({ size = 20, ...props }: { size?: number }) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width={size} width={size}
height={size} height={size}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
{...props} {...props}
> >
<path d="M12 2v20M2 12h20" /> <path d="M12 2v20M2 12h20" />
<path d="M12 2v20M2 12h20" transform="rotate(45 12 12)" /> <path d="M12 2v20M2 12h20" transform="rotate(45 12 12)" />
</svg> </svg>
); );
export const ManaBlack = ({ size = 20, ...props }: { size?: number }) => ( export const ManaBlack = ({ size = 20, ...props }: { size?: number }) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width={size} width={size}
height={size} height={size}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
{...props} {...props}
> >
<path d="M12 2v20M2 12h20" transform="rotate(90 12 12)" /> <path d="M12 2v20M2 12h20" transform="rotate(90 12 12)" />
<path d="M12 2v20M2 12h20" transform="rotate(135 12 12)" /> <path d="M12 2v20M2 12h20" transform="rotate(135 12 12)" />
</svg> </svg>
); );
export const ManaRed = ({ size = 20, ...props }: { size?: number }) => ( export const ManaRed = ({ size = 20, ...props }: { size?: number }) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width={size} width={size}
height={size} height={size}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
{...props} {...props}
> >
<path d="M12 2v20M2 12h20" transform="rotate(135 12 12)" /> <path d="M12 2v20M2 12h20" transform="rotate(135 12 12)" />
<path d="M12 2v20M2 12h20" transform="rotate(180 12 12)" /> <path d="M12 2v20M2 12h20" transform="rotate(180 12 12)" />
</svg> </svg>
); );
export const ManaGreen = ({ size = 20, ...props }: { size?: number }) => ( export const ManaGreen = ({ size = 20, ...props }: { size?: number }) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width={size} width={size}
height={size} height={size}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
{...props} {...props}
> >
<path d="M12 2v20M2 12h20" transform="rotate(180 12 12)" /> <path d="M12 2v20M2 12h20" transform="rotate(180 12 12)" />
<path d="M12 2v20M2 12h20" transform="rotate(225 12 12)" /> <path d="M12 2v20M2 12h20" transform="rotate(225 12 12)" />
</svg> </svg>
); );
export const ManaColorless = ({ size = 20, ...props }: { size?: number }) => ( export const ManaColorless = ({ size = 20, ...props }: { size?: number }) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width={size} width={size}
height={size} height={size}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
{...props} {...props}
> >
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
</svg> </svg>
); );

View File

@@ -1,199 +1,199 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Home, PlusSquare, Library, LogOut, Settings, ChevronDown, Search, Heart, Menu } from 'lucide-react'; import { Home, PlusSquare, Library, LogOut, Settings, ChevronDown, Search, Heart, Menu } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
type Page = 'home' | 'deck' | 'login' | 'collection' | 'profile' | 'search' | 'life-counter'; type Page = 'home' | 'deck' | 'login' | 'collection' | 'profile' | 'search' | 'life-counter';
interface NavigationProps { interface NavigationProps {
currentPage: Page; currentPage: Page;
setCurrentPage: (page: Page) => void; setCurrentPage: (page: Page) => void;
} }
export default function Navigation({ currentPage, setCurrentPage }: NavigationProps) { export default function Navigation({ currentPage, setCurrentPage }: NavigationProps) {
const { user, signOut } = useAuth(); const { user, signOut } = useAuth();
const [showDropdown, setShowDropdown] = useState(false); const [showDropdown, setShowDropdown] = useState(false);
const [showMobileMenu, setShowMobileMenu] = useState(false); const [showMobileMenu, setShowMobileMenu] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const mobileMenuRef = useRef<HTMLDivElement>(null); const mobileMenuRef = useRef<HTMLDivElement>(null);
const [username, setUsername] = useState<string | null>(null); const [username, setUsername] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const fetchProfile = async () => { const fetchProfile = async () => {
if (user) { if (user) {
const { data } = await supabase const { data } = await supabase
.from('profiles') .from('profiles')
.select('username') .select('username')
.eq('id', user.id) .eq('id', user.id)
.single(); .single();
if (data) { if (data) {
setUsername(data.username); setUsername(data.username);
} }
} }
}; };
fetchProfile(); fetchProfile();
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowDropdown(false); setShowDropdown(false);
} }
if (mobileMenuRef.current && !mobileMenuRef.current.contains(event.target as Node)) { if (mobileMenuRef.current && !mobileMenuRef.current.contains(event.target as Node)) {
setShowMobileMenu(false); setShowMobileMenu(false);
} }
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
const navItems = [ const navItems = [
{ id: 'home' as const, label: 'Home', icon: Home }, { id: 'home' as const, label: 'Home', icon: Home },
{ id: 'deck' as const, label: 'New Deck', icon: PlusSquare }, { id: 'deck' as const, label: 'New Deck', icon: PlusSquare },
{ id: 'collection' as const, label: 'Collection', icon: Library }, { id: 'collection' as const, label: 'Collection', icon: Library },
{ id: 'search' as const, label: 'Search', icon: Search }, { id: 'search' as const, label: 'Search', icon: Search },
{ id: 'life-counter' as const, label: 'Life Counter', icon: Heart }, { id: 'life-counter' as const, label: 'Life Counter', icon: Heart },
]; ];
const handleSignOut = async () => { const handleSignOut = async () => {
try { try {
await signOut(); await signOut();
setCurrentPage('login'); setCurrentPage('login');
} catch (error) { } catch (error) {
console.error('Error signing out:', error); console.error('Error signing out:', error);
} }
}; };
const getAvatarUrl = (userId: string) => { const getAvatarUrl = (userId: string) => {
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}&backgroundColor=b6e3f4,c0aede,d1d4f9`; return `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}&backgroundColor=b6e3f4,c0aede,d1d4f9`;
}; };
return ( return (
<> <>
{/* Desktop Navigation - Top */} {/* 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"> <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="max-w-7xl mx-auto px-4">
<div className="flex items-center justify-between h-16"> <div className="flex items-center justify-between h-16">
<div className="flex items-center space-x-8"> <div className="flex items-center space-x-8">
<span className="text-2xl font-bold text-orange-500">Deckerr</span> <span className="text-2xl font-bold text-orange-500">Deckerr</span>
{navItems.map((item) => ( {navItems.map((item) => (
<button <button
key={item.id} key={item.id}
onClick={() => setCurrentPage(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 className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors
${currentPage === item.id ${currentPage === item.id
? 'text-white bg-gray-900' ? 'text-white bg-gray-900'
: 'text-gray-300 hover:text-white hover:bg-gray-700' : 'text-gray-300 hover:text-white hover:bg-gray-700'
}`} }`}
> >
<item.icon size={20} /> <item.icon size={20} />
<span>{item.label}</span> <span>{item.label}</span>
</button> </button>
))} ))}
</div> </div>
{user && ( {user && (
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button
onClick={() => setShowDropdown(!showDropdown)} onClick={() => setShowDropdown(!showDropdown)}
className="flex items-center space-x-3 px-3 py-2 rounded-md hover:bg-gray-700" className="flex items-center space-x-3 px-3 py-2 rounded-md hover:bg-gray-700"
> >
<img <img
src={getAvatarUrl(user.id)} src={getAvatarUrl(user.id)}
alt="User avatar" alt="User avatar"
className="w-8 h-8 rounded-full bg-gray-700" className="w-8 h-8 rounded-full bg-gray-700"
/> />
<span className="text-gray-300 text-sm">{username || user.email}</span> <span className="text-gray-300 text-sm">{username || user.email}</span>
<ChevronDown size={16} className="text-gray-400" /> <ChevronDown size={16} className="text-gray-400" />
</button> </button>
{showDropdown && ( {showDropdown && (
<div className="absolute right-0 mt-2 w-48 bg-gray-800 rounded-md shadow-lg py-1 border border-gray-700"> <div className="absolute right-0 mt-2 w-48 bg-gray-800 rounded-md shadow-lg py-1 border border-gray-700">
<button <button
onClick={() => { onClick={() => {
setCurrentPage('profile'); setCurrentPage('profile');
setShowDropdown(false); setShowDropdown(false);
}} }}
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700" 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} /> <Settings size={16} />
<span>Profile Settings</span> <span>Profile Settings</span>
</button> </button>
<button <button
onClick={handleSignOut} onClick={handleSignOut}
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700" 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} /> <LogOut size={16} />
<span>Sign Out</span> <span>Sign Out</span>
</button> </button>
</div> </div>
)} )}
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
</nav> </nav>
{/* Mobile Navigation - Bottom */} {/* Mobile Navigation - Bottom */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-800 border-t border-gray-700 z-50"> <nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-800 border-t border-gray-700 z-50">
<div className="flex justify-between items-center h-16 px-4"> <div className="flex justify-between items-center h-16 px-4">
<span className="text-2xl font-bold text-orange-500">Deckerr</span> <span className="text-2xl font-bold text-orange-500">Deckerr</span>
<div className="relative" ref={mobileMenuRef}> <div className="relative" ref={mobileMenuRef}>
<button <button
onClick={() => setShowMobileMenu(!showMobileMenu)} onClick={() => setShowMobileMenu(!showMobileMenu)}
className="text-gray-300 hover:text-white" className="text-gray-300 hover:text-white"
> >
<Menu size={24} /> <Menu size={24} />
</button> </button>
{showMobileMenu && ( {showMobileMenu && (
<div className="absolute right-0 bottom-16 w-48 bg-gray-800 rounded-md shadow-lg py-1 border border-gray-700"> <div className="absolute right-0 bottom-16 w-48 bg-gray-800 rounded-md shadow-lg py-1 border border-gray-700">
{navItems.map((item) => ( {navItems.map((item) => (
<button <button
key={item.id} key={item.id}
onClick={() => { onClick={() => {
setCurrentPage(item.id); setCurrentPage(item.id);
setShowMobileMenu(false); setShowMobileMenu(false);
}} }}
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700" className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
> >
<item.icon size={16} /> <item.icon size={16} />
<span>{item.label}</span> <span>{item.label}</span>
</button> </button>
))} ))}
{user && ( {user && (
<> <>
<button <button
onClick={() => { onClick={() => {
setCurrentPage('profile'); setCurrentPage('profile');
setShowMobileMenu(false); setShowMobileMenu(false);
}} }}
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700" 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} /> <Settings size={16} />
<span>Profile Settings</span> <span>Profile Settings</span>
</button> </button>
<button <button
onClick={handleSignOut} onClick={handleSignOut}
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700" 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} /> <LogOut size={16} />
<span>Sign Out</span> <span>Sign Out</span>
</button> </button>
</> </>
)} )}
</div> </div>
)} )}
</div> </div>
</div> </div>
</nav> </nav>
{/* Content Padding */} {/* Content Padding */}
<div className="md:pt-16 pb-16 md:pb-0" /> <div className="md:pt-16 pb-16 md:pb-0" />
</> </>
); );
} }

View File

@@ -1,128 +1,128 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Save } from 'lucide-react'; import { Save } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
const THEME_COLORS = ['red', 'green', 'blue', 'yellow', 'grey', 'purple']; const THEME_COLORS = ['red', 'green', 'blue', 'yellow', 'grey', 'purple'];
export default function Profile() { export default function Profile() {
const { user } = useAuth(); const { user } = useAuth();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [themeColor, setThemeColor] = useState('blue'); const [themeColor, setThemeColor] = useState('blue');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
const loadProfile = async () => { const loadProfile = async () => {
if (user) { if (user) {
const { data, error } = await supabase const { data, error } = await supabase
.from('profiles') .from('profiles')
.select('username, theme_color') .select('username, theme_color')
.eq('id', user.id) .eq('id', user.id)
.single(); .single();
if (data) { if (data) {
setUsername(data.username || ''); setUsername(data.username || '');
setThemeColor(data.theme_color || 'blue'); setThemeColor(data.theme_color || 'blue');
} }
setLoading(false); setLoading(false);
} }
}; };
loadProfile(); loadProfile();
}, [user]); }, [user]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!user) return; if (!user) return;
setSaving(true); setSaving(true);
try { try {
const { error } = await supabase const { error } = await supabase
.from('profiles') .from('profiles')
.upsert({ .upsert({
id: user.id, id: user.id,
username, username,
theme_color: themeColor, theme_color: themeColor,
updated_at: new Date() updated_at: new Date()
}); });
if (error) throw error; if (error) throw error;
alert('Profile updated successfully!'); alert('Profile updated successfully!');
} catch (error) { } catch (error) {
console.error('Error updating profile:', error); console.error('Error updating profile:', error);
alert('Failed to update profile'); alert('Failed to update profile');
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center"> <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 className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-gray-900 text-white p-6"> <div className="min-h-screen bg-gray-900 text-white p-6">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Profile Settings</h1> <h1 className="text-3xl font-bold mb-8">Profile Settings</h1>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">
Username Username
</label> </label>
<input <input
type="text" type="text"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} 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" 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" placeholder="Enter your username"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">
Theme Color Theme Color
</label> </label>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{THEME_COLORS.map((color) => ( {THEME_COLORS.map((color) => (
<button <button
key={color} key={color}
type="button" type="button"
onClick={() => setThemeColor(color)} onClick={() => setThemeColor(color)}
className={`h-12 rounded-lg border-2 transition-all capitalize className={`h-12 rounded-lg border-2 transition-all capitalize
${themeColor === color ${themeColor === color
? 'border-white scale-105' ? 'border-white scale-105'
: 'border-transparent hover:border-gray-600' : 'border-transparent hover:border-gray-600'
}`} }`}
style={{ backgroundColor: `var(--color-${color}-primary)` }} style={{ backgroundColor: `var(--color-${color}-primary)` }}
> >
{color} {color}
</button> </button>
))} ))}
</div> </div>
</div> </div>
<button <button
type="submit" type="submit"
disabled={saving} 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" 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 ? ( {saving ? (
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div> <div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
) : ( ) : (
<> <>
<Save size={20} /> <Save size={20} />
Save Changes Save Changes
</> </>
)} )}
</button> </button>
</form> </form>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,107 +1,107 @@
import React, { createContext, useContext, useEffect, useState } from 'react'; import React, { createContext, useContext, useEffect, useState } from 'react';
import { User } from '@supabase/supabase-js'; import { User } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
loading: boolean; loading: boolean;
signIn: (email: string, password: string) => Promise<void>; signIn: (email: string, password: string) => Promise<void>;
signUp: (email: string, password: string) => Promise<void>; signUp: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
// Check active sessions and sets the user // Check active sessions and sets the user
supabase.auth.getSession().then(({ data: { session } }) => { supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null); setUser(session?.user ?? null);
setLoading(false); setLoading(false);
}); });
// Listen for changes on auth state // Listen for changes on auth state
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null); setUser(session?.user ?? null);
setLoading(false); setLoading(false);
// If this is a new sign up, create a profile using setTimeout to avoid deadlock // If this is a new sign up, create a profile using setTimeout to avoid deadlock
if (_event === 'SIGNED_IN' && session) { if (_event === 'SIGNED_IN' && session) {
setTimeout(async () => { setTimeout(async () => {
const { error } = await supabase const { error } = await supabase
.from('profiles') .from('profiles')
.upsert( .upsert(
{ {
id: session.user.id, id: session.user.id,
theme_color: 'blue' // Default theme color theme_color: 'blue' // Default theme color
}, },
{ onConflict: 'id' } { onConflict: 'id' }
); );
if (error) { if (error) {
console.error('Error creating profile:', error); console.error('Error creating profile:', error);
} }
}, 0); }, 0);
} }
}); });
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, []); }, []);
const signUp = async (email: string, password: string) => { const signUp = async (email: string, password: string) => {
const { error, data } = await supabase.auth.signUp({ const { error, data } = await supabase.auth.signUp({
email, email,
password, password,
}); });
if (error) throw error; if (error) throw error;
// Create a profile for the new user using setTimeout to avoid deadlock // Create a profile for the new user using setTimeout to avoid deadlock
if (data.user) { if (data.user) {
setTimeout(async () => { setTimeout(async () => {
const { error: profileError } = await supabase const { error: profileError } = await supabase
.from('profiles') .from('profiles')
.insert({ .insert({
id: data.user!.id, id: data.user!.id,
theme_color: 'blue' // Default theme color theme_color: 'blue' // Default theme color
}); });
if (profileError) { if (profileError) {
console.error('Error creating profile:', profileError); console.error('Error creating profile:', profileError);
// Optionally handle the error (e.g., delete the auth user) // Optionally handle the error (e.g., delete the auth user)
throw profileError; throw profileError;
} }
}, 0); }, 0);
} }
}; };
const signIn = async (email: string, password: string) => { const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ const { error } = await supabase.auth.signInWithPassword({
email, email,
password, password,
}); });
if (error) throw error; if (error) throw error;
}; };
const signOut = async () => { const signOut = async () => {
const { error } = await supabase.auth.signOut(); const { error } = await supabase.auth.signOut();
if (error) throw error; if (error) throw error;
}; };
return ( return (
<AuthContext.Provider value={{ user, loading, signIn, signUp, signOut }}> <AuthContext.Provider value={{ user, loading, signIn, signUp, signOut }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
} }
export function useAuth() { export function useAuth() {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (context === undefined) { if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider'); throw new Error('useAuth must be used within an AuthProvider');
} }
return context; return context;
} }

View File

@@ -1,16 +1,16 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@keyframes slide { @keyframes slide {
0% { 0% {
transform: translateX(0); transform: translateX(0);
} }
100% { 100% {
transform: translateX(-50%); transform: translateX(-50%);
} }
} }
.animate-slide { .animate-slide {
animation: slide 60s linear infinite; animation: slide 60s linear infinite;
} }

View File

@@ -1,10 +1,10 @@
import {createClient} from "@supabase/supabase-js"; import {createClient} from "@supabase/supabase-js";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) { if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Missing Supabase environment variables'); throw new Error('Missing Supabase environment variables');
} }
export const supabase = createClient(supabaseUrl, supabaseAnonKey); export const supabase = createClient(supabaseUrl, supabaseAnonKey);

View File

@@ -1,11 +1,11 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App.tsx'; import App from './App.tsx';
import './index.css'; import './index.css';
import './utils/theme.ts'; import './utils/theme.ts';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode> </StrictMode>
); );

View File

@@ -23,19 +23,32 @@ export const getCardById = async (cardId: string): Promise<Card> => {
return await response.json(); return await response.json();
}; };
export const getCardsByIds = async (cardIds: string[]): Promise<Card[]> => { const chunkArray = (array: string[], size: number): string[][] => {
const chunkedArray: string[][] = [];
//75 cards per request max for (let i = 0; i < array.length; i += size) {
const response = await fetch(`${SCRYFALL_API}/cards/collection`, { chunkedArray.push(array.slice(i, i + size));
method: 'POST', }
headers: { return chunkedArray;
'Content-Type': 'application/json', };
},
body: JSON.stringify({ export const getCardsByIds = async (cardIds: string[]): Promise<Card[]> => {
identifiers: cardIds.map((id) => ({ id })), const chunkedCardIds = chunkArray(cardIds, 75);
}), let allCards: Card[] = [];
});
for (const chunk of chunkedCardIds) {
const data = await response.json(); const response = await fetch(`${SCRYFALL_API}/cards/collection`, {
return data.data; method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
identifiers: chunk.map((id) => ({ id })),
}),
});
const data = await response.json();
allCards = allCards.concat(data.data);
}
return allCards;
}; };

View File

@@ -1,37 +1,37 @@
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
username: string; username: string;
themeColor: 'red' | 'green' | 'blue' | 'yellow' | 'grey' | 'purple'; themeColor: 'red' | 'green' | 'blue' | 'yellow' | 'grey' | 'purple';
} }
export interface Card { export interface Card {
id: string; id: string;
name: string; name: string;
image_uris?: { image_uris?: {
normal: string; normal: string;
art_crop: string; art_crop: string;
}; };
mana_cost?: string; mana_cost?: string;
type_line?: string; type_line?: string;
oracle_text?: string; oracle_text?: string;
colors?: string[]; colors?: string[];
} }
export interface Deck { export interface Deck {
id: string; id: string;
name: string; name: string;
format: string; format: string;
cards: { card: Card; quantity: number, is_commander: boolean }[]; cards: { card: Card; quantity: number, is_commander: boolean }[];
userId: string; userId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
export interface CardEntity { export interface CardEntity {
id: string; id: string;
deck_id: string; deck_id: string;
card_id: string; card_id: string;
quantity: number; quantity: number;
is_commander: boolean; is_commander: boolean;
} }

View File

@@ -1,81 +1,81 @@
import { Deck } from '../types'; import { Deck } from '../types';
interface DeckValidation { interface DeckValidation {
isValid: boolean; isValid: boolean;
errors: string[]; errors: string[];
} }
const FORMAT_RULES = { const FORMAT_RULES = {
standard: { standard: {
minCards: 60, minCards: 60,
maxCards: undefined, maxCards: undefined,
maxCopies: 4, maxCopies: 4,
}, },
modern: { modern: {
minCards: 60, minCards: 60,
maxCards: undefined, maxCards: undefined,
maxCopies: 4, maxCopies: 4,
}, },
commander: { commander: {
minCards: 100, minCards: 100,
maxCards: 100, maxCards: 100,
maxCopies: 1, maxCopies: 1,
requiresCommander: true, requiresCommander: true,
}, },
legacy: { legacy: {
minCards: 60, minCards: 60,
maxCards: undefined, maxCards: undefined,
maxCopies: 4, maxCopies: 4,
}, },
vintage: { vintage: {
minCards: 60, minCards: 60,
maxCards: undefined, maxCards: undefined,
maxCopies: 4, maxCopies: 4,
}, },
pauper: { pauper: {
minCards: 60, minCards: 60,
maxCards: undefined, maxCards: undefined,
maxCopies: 4, maxCopies: 4,
}, },
}; };
export function validateDeck(deck: Deck): DeckValidation { export function validateDeck(deck: Deck): DeckValidation {
const rules = FORMAT_RULES[deck.format as keyof typeof FORMAT_RULES]; const rules = FORMAT_RULES[deck.format as keyof typeof FORMAT_RULES];
const errors: string[] = []; const errors: string[] = [];
// Count total cards // Count total cards
const totalCards = deck.cards.reduce((acc, curr) => acc + curr.quantity, 0); const totalCards = deck.cards.reduce((acc, curr) => acc + curr.quantity, 0);
// Check minimum cards // Check minimum cards
if (totalCards < rules.minCards) { if (totalCards < rules.minCards) {
errors.push(`Deck must contain at least ${rules.minCards} cards`); errors.push(`Deck must contain at least ${rules.minCards} cards`);
} }
// Check maximum cards // Check maximum cards
if (rules.maxCards && totalCards > rules.maxCards) { if (rules.maxCards && totalCards > rules.maxCards) {
errors.push(`Deck must not contain more than ${rules.maxCards} cards`); errors.push(`Deck must not contain more than ${rules.maxCards} cards`);
} }
// Check card copies // Check card copies
const cardCounts = new Map<string, number>(); const cardCounts = new Map<string, number>();
for (const element of deck.cards) { for (const element of deck.cards) {
const {card, quantity} = element; const {card, quantity} = element;
//console.log("card", card); //console.log("card", card);
const currentCount = cardCounts.get(card.id) || 0; const currentCount = cardCounts.get(card.id) || 0;
cardCounts.set(card.id, currentCount + quantity); cardCounts.set(card.id, currentCount + quantity);
} }
cardCounts.forEach((count, cardName) => { cardCounts.forEach((count, cardName) => {
const card = deck.cards.find(c => c.card.id === cardName)?.card; const card = deck.cards.find(c => c.card.id === cardName)?.card;
const isBasicLand = card?.name === 'Plains' || card?.name === 'Island' || card?.name === 'Swamp' || card?.name === 'Mountain' || card?.name === 'Forest'; const isBasicLand = card?.name === 'Plains' || card?.name === 'Island' || card?.name === 'Swamp' || card?.name === 'Mountain' || card?.name === 'Forest';
if (!isBasicLand && count > rules.maxCopies) { if (!isBasicLand && count > rules.maxCopies) {
errors.push(`${cardName} has too many copies (max ${rules.maxCopies})`); errors.push(`${cardName} has too many copies (max ${rules.maxCopies})`);
} }
}); });
return { return {
isValid: errors.length === 0, isValid: errors.length === 0,
errors, errors,
}; };
} }

View File

@@ -1,42 +1,42 @@
export const themeColors = { export const themeColors = {
red: { red: {
primary: '#ef4444', primary: '#ef4444',
secondary: '#b91c1c', secondary: '#b91c1c',
hover: '#dc2626' hover: '#dc2626'
}, },
green: { green: {
primary: '#22c55e', primary: '#22c55e',
secondary: '#15803d', secondary: '#15803d',
hover: '#16a34a' hover: '#16a34a'
}, },
blue: { blue: {
primary: '#3b82f6', primary: '#3b82f6',
secondary: '#1d4ed8', secondary: '#1d4ed8',
hover: '#2563eb' hover: '#2563eb'
}, },
yellow: { yellow: {
primary: '#eab308', primary: '#eab308',
secondary: '#a16207', secondary: '#a16207',
hover: '#ca8a04' hover: '#ca8a04'
}, },
grey: { grey: {
primary: '#6b7280', primary: '#6b7280',
secondary: '#374151', secondary: '#374151',
hover: '#4b5563' hover: '#4b5563'
}, },
purple: { purple: {
primary: '#a855f7', primary: '#a855f7',
secondary: '#7e22ce', secondary: '#7e22ce',
hover: '#9333ea' hover: '#9333ea'
}, },
white: { white: {
primary: '#f8fafc', primary: '#f8fafc',
secondary: '#e2e8f0', secondary: '#e2e8f0',
hover: '#f1f5f9', hover: '#f1f5f9',
}, },
black: { black: {
primary: '#0f172a', primary: '#0f172a',
secondary: '#1e293b', secondary: '#1e293b',
hover: '#1e293b', hover: '#1e293b',
} }
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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