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

2
.env
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

46
.gitignore vendored
View File

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

40
LICENSE
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,19 +23,32 @@ export const getCardById = async (cardId: string): Promise<Card> => {
return await response.json();
};
export const getCardsByIds = async (cardIds: string[]): Promise<Card[]> => {
//75 cards per request max
const response = await fetch(`${SCRYFALL_API}/cards/collection`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
identifiers: cardIds.map((id) => ({ id })),
}),
});
const data = await response.json();
return data.data;
const chunkArray = (array: string[], size: number): string[][] => {
const chunkedArray: string[][] = [];
for (let i = 0; i < array.length; i += size) {
chunkedArray.push(array.slice(i, i + size));
}
return chunkedArray;
};
export const getCardsByIds = async (cardIds: string[]): Promise<Card[]> => {
const chunkedCardIds = chunkArray(cardIds, 75);
let allCards: Card[] = [];
for (const chunk of chunkedCardIds) {
const response = await fetch(`${SCRYFALL_API}/cards/collection`, {
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 {
id: string;
email: string;
username: string;
themeColor: 'red' | 'green' | 'blue' | 'yellow' | 'grey' | 'purple';
}
export interface Card {
id: string;
name: string;
image_uris?: {
normal: string;
art_crop: string;
};
mana_cost?: string;
type_line?: string;
oracle_text?: string;
colors?: string[];
}
export interface Deck {
id: string;
name: string;
format: string;
cards: { card: Card; quantity: number, is_commander: boolean }[];
userId: string;
createdAt: Date;
updatedAt: Date;
}
export interface CardEntity {
id: string;
deck_id: string;
card_id: string;
quantity: number;
is_commander: boolean;
export interface User {
id: string;
email: string;
username: string;
themeColor: 'red' | 'green' | 'blue' | 'yellow' | 'grey' | 'purple';
}
export interface Card {
id: string;
name: string;
image_uris?: {
normal: string;
art_crop: string;
};
mana_cost?: string;
type_line?: string;
oracle_text?: string;
colors?: string[];
}
export interface Deck {
id: string;
name: string;
format: string;
cards: { card: Card; quantity: number, is_commander: boolean }[];
userId: string;
createdAt: Date;
updatedAt: Date;
}
export interface CardEntity {
id: string;
deck_id: string;
card_id: string;
quantity: number;
is_commander: boolean;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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