add chunk to get cards collection from scryfall
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"template": "bolt-vite-react-ts"
|
"template": "bolt-vite-react-ts"
|
||||||
}
|
}
|
||||||
|
|||||||
12
.bolt/prompt
12
.bolt/prompt
@@ -1,7 +1,7 @@
|
|||||||
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
|
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
|
||||||
|
|
||||||
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
|
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
|
||||||
|
|
||||||
Use icons from lucide-react for logos.
|
Use icons from lucide-react for logos.
|
||||||
|
|
||||||
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.
|
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.
|
||||||
|
|||||||
2
.env
2
.env
@@ -1,2 +1,2 @@
|
|||||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InllZGdoanJweXhoeGVzbmJ0YmlwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzgzMjk2OTEsImV4cCI6MjA1MzkwNTY5MX0.pKO-axLvkHtTOMEtEkCPz2yY2khm9RKzkqcFkl_VM_U
|
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InllZGdoanJweXhoeGVzbmJ0YmlwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzgzMjk2OTEsImV4cCI6MjA1MzkwNTY5MX0.pKO-axLvkHtTOMEtEkCPz2yY2khm9RKzkqcFkl_VM_U
|
||||||
VITE_SUPABASE_URL=https://yedghjrpyxhxesnbtbip.supabase.co
|
VITE_SUPABASE_URL=https://yedghjrpyxhxesnbtbip.supabase.co
|
||||||
|
|||||||
46
.gitignore
vendored
46
.gitignore
vendored
@@ -1,24 +1,24 @@
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
*.suo
|
||||||
*.ntvs*
|
*.ntvs*
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|||||||
40
LICENSE
40
LICENSE
@@ -1,21 +1,21 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Reynier Matthieu
|
Copyright (c) 2025 Reynier Matthieu
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
in the Software without restriction, including without limitation the rights
|
in the Software without restriction, including without limitation the rights
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
furnished to do so, subject to the following conditions:
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
The above copyright notice and this permission notice shall be included in all
|
||||||
copies or substantial portions of the Software.
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import js from '@eslint/js';
|
import js from '@eslint/js';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist'] },
|
{ ignores: ['dist'] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
'react-hooks': reactHooks,
|
'react-hooks': reactHooks,
|
||||||
'react-refresh': reactRefresh,
|
'react-refresh': reactRefresh,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
'react-refresh/only-export-components': [
|
'react-refresh/only-export-components': [
|
||||||
'warn',
|
'warn',
|
||||||
{ allowConstantExport: true },
|
{ allowConstantExport: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
24
index.html
24
index.html
@@ -1,13 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>Vite + React + TS</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
180
src/App.tsx
180
src/App.tsx
@@ -1,91 +1,91 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import DeckManager from './components/DeckManager';
|
import DeckManager from './components/DeckManager';
|
||||||
import DeckList from './components/DeckList';
|
import DeckList from './components/DeckList';
|
||||||
import LoginForm from './components/LoginForm';
|
import LoginForm from './components/LoginForm';
|
||||||
import Navigation from './components/Navigation';
|
import Navigation from './components/Navigation';
|
||||||
import Collection from './components/Collection';
|
import Collection from './components/Collection';
|
||||||
import DeckEditor from './components/DeckEditor';
|
import DeckEditor from './components/DeckEditor';
|
||||||
import Profile from './components/Profile';
|
import Profile from './components/Profile';
|
||||||
import CardSearch from './components/CardSearch';
|
import CardSearch from './components/CardSearch';
|
||||||
import LifeCounter from './components/LifeCounter';
|
import LifeCounter from './components/LifeCounter';
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
|
|
||||||
type Page = 'home' | 'deck' | 'login' | 'collection' | 'edit-deck' | 'profile' | 'search' | 'life-counter';
|
type Page = 'home' | 'deck' | 'login' | 'collection' | 'edit-deck' | 'profile' | 'search' | 'life-counter';
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const [currentPage, setCurrentPage] = useState<Page>('home');
|
const [currentPage, setCurrentPage] = useState<Page>('home');
|
||||||
const [selectedDeckId, setSelectedDeckId] = useState<string | null>(null);
|
const [selectedDeckId, setSelectedDeckId] = useState<string | null>(null);
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user && currentPage !== 'login') {
|
if (!user && currentPage !== 'login') {
|
||||||
return <LoginForm />;
|
return <LoginForm />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeckEdit = (deckId: string) => {
|
const handleDeckEdit = (deckId: string) => {
|
||||||
setSelectedDeckId(deckId);
|
setSelectedDeckId(deckId);
|
||||||
setCurrentPage('edit-deck');
|
setCurrentPage('edit-deck');
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPage = () => {
|
const renderPage = () => {
|
||||||
switch (currentPage) {
|
switch (currentPage) {
|
||||||
case 'home':
|
case 'home':
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-6">My Decks</h1>
|
<h1 className="text-3xl font-bold mb-6">My Decks</h1>
|
||||||
<DeckList onDeckEdit={handleDeckEdit} />
|
<DeckList onDeckEdit={handleDeckEdit} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'deck':
|
case 'deck':
|
||||||
return <DeckManager />;
|
return <DeckManager />;
|
||||||
case 'edit-deck':
|
case 'edit-deck':
|
||||||
return selectedDeckId ? (
|
return selectedDeckId ? (
|
||||||
<DeckEditor
|
<DeckEditor
|
||||||
deckId={selectedDeckId}
|
deckId={selectedDeckId}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSelectedDeckId(null);
|
setSelectedDeckId(null);
|
||||||
setCurrentPage('home');
|
setCurrentPage('home');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
case 'collection':
|
case 'collection':
|
||||||
return <Collection />;
|
return <Collection />;
|
||||||
case 'profile':
|
case 'profile':
|
||||||
return <Profile />;
|
return <Profile />;
|
||||||
case 'search':
|
case 'search':
|
||||||
return <CardSearch />;
|
return <CardSearch />;
|
||||||
case 'life-counter':
|
case 'life-counter':
|
||||||
return <LifeCounter />;
|
return <LifeCounter />;
|
||||||
case 'login':
|
case 'login':
|
||||||
return <LoginForm />;
|
return <LoginForm />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900">
|
<div className="min-h-screen bg-gray-900">
|
||||||
<Navigation currentPage={currentPage} setCurrentPage={setCurrentPage} />
|
<Navigation currentPage={currentPage} setCurrentPage={setCurrentPage} />
|
||||||
{renderPage()}
|
{renderPage()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppContent />
|
<AppContent />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -1,47 +1,47 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Card } from '../types';
|
import { Card } from '../types';
|
||||||
import { getRandomCards } from '../services/api';
|
import { getRandomCards } from '../services/api';
|
||||||
|
|
||||||
export default function CardCarousel() {
|
export default function CardCarousel() {
|
||||||
const [cards, setCards] = useState<Card[]>([]);
|
const [cards, setCards] = useState<Card[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCards = async () => {
|
const loadCards = async () => {
|
||||||
try {
|
try {
|
||||||
const randomCards = await getRandomCards(6);
|
const randomCards = await getRandomCards(6);
|
||||||
setCards(randomCards);
|
setCards(randomCards);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load cards:', error);
|
console.error('Failed to load cards:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadCards();
|
loadCards();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="animate-pulse h-96 bg-gray-700/50 rounded-lg"></div>;
|
return <div className="animate-pulse h-96 bg-gray-700/50 rounded-lg"></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-screen overflow-hidden">
|
<div className="relative h-screen overflow-hidden">
|
||||||
<div className="absolute inset-0 flex">
|
<div className="absolute inset-0 flex">
|
||||||
{cards.map((card, index) => (
|
{cards.map((card, index) => (
|
||||||
<div
|
<div
|
||||||
key={card.id}
|
key={card.id}
|
||||||
className="min-w-full h-full transform transition-transform duration-1000"
|
className="min-w-full h-full transform transition-transform duration-1000"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${card.image_uris?.normal})`,
|
backgroundImage: `url(${card.image_uris?.normal})`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
filter: 'blur(8px)',
|
filter: 'blur(8px)',
|
||||||
opacity: 0.5
|
opacity: 0.5
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,121 +1,121 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Search, Plus } from 'lucide-react';
|
import { Search, Plus } from 'lucide-react';
|
||||||
import { Card } from '../types';
|
import { Card } from '../types';
|
||||||
import { searchCards } from '../services/api';
|
import { searchCards } from '../services/api';
|
||||||
|
|
||||||
export default function Collection() {
|
export default function Collection() {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<Card[]>([]);
|
const [searchResults, setSearchResults] = useState<Card[]>([]);
|
||||||
const [collection, setCollection] = useState<{ card: Card; quantity: number }[]>([]);
|
const [collection, setCollection] = useState<{ card: Card; quantity: number }[]>([]);
|
||||||
|
|
||||||
const handleSearch = async (e: React.FormEvent) => {
|
const handleSearch = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!searchQuery.trim()) return;
|
if (!searchQuery.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cards = await searchCards(searchQuery);
|
const cards = await searchCards(searchQuery);
|
||||||
setSearchResults(cards);
|
setSearchResults(cards);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to search cards:', error);
|
console.error('Failed to search cards:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addToCollection = (card: Card) => {
|
const addToCollection = (card: Card) => {
|
||||||
setCollection(prev => {
|
setCollection(prev => {
|
||||||
const existing = prev.find(c => c.card.id === card.id);
|
const existing = prev.find(c => c.card.id === card.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return prev.map(c =>
|
return prev.map(c =>
|
||||||
c.card.id === card.id
|
c.card.id === card.id
|
||||||
? { ...c, quantity: c.quantity + 1 }
|
? { ...c, quantity: c.quantity + 1 }
|
||||||
: c
|
: c
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return [...prev, { card, quantity: 1 }];
|
return [...prev, { card, quantity: 1 }];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-6">My Collection</h1>
|
<h1 className="text-3xl font-bold mb-6">My Collection</h1>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<form onSubmit={handleSearch} className="flex gap-2 mb-8">
|
<form onSubmit={handleSearch} className="flex gap-2 mb-8">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
placeholder="Search cards to add..."
|
placeholder="Search cards to add..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2"
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Search size={20} />
|
<Search size={20} />
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Search Results */}
|
{/* Search Results */}
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h2 className="text-xl font-semibold mb-4">Search Results</h2>
|
<h2 className="text-xl font-semibold mb-4">Search Results</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{searchResults.map(card => (
|
{searchResults.map(card => (
|
||||||
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
|
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
|
||||||
{card.image_uris?.normal && (
|
{card.image_uris?.normal && (
|
||||||
<img
|
<img
|
||||||
src={card.image_uris.normal}
|
src={card.image_uris.normal}
|
||||||
alt={card.name}
|
alt={card.name}
|
||||||
className="w-full h-auto"
|
className="w-full h-auto"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="font-bold mb-2">{card.name}</h3>
|
<h3 className="font-bold mb-2">{card.name}</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => addToCollection(card)}
|
onClick={() => addToCollection(card)}
|
||||||
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2"
|
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<Plus size={20} />
|
<Plus size={20} />
|
||||||
Add to Collection
|
Add to Collection
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Collection */}
|
{/* Collection */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold mb-4">My Cards</h2>
|
<h2 className="text-xl font-semibold mb-4">My Cards</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{collection.map(({ card, quantity }) => (
|
{collection.map(({ card, quantity }) => (
|
||||||
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
|
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
|
||||||
{card.image_uris?.normal && (
|
{card.image_uris?.normal && (
|
||||||
<img
|
<img
|
||||||
src={card.image_uris.normal}
|
src={card.image_uris.normal}
|
||||||
alt={card.name}
|
alt={card.name}
|
||||||
className="w-full h-auto"
|
className="w-full h-auto"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<h3 className="font-bold">{card.name}</h3>
|
<h3 className="font-bold">{card.name}</h3>
|
||||||
<span className="text-sm bg-blue-600 px-2 py-1 rounded">
|
<span className="text-sm bg-blue-600 px-2 py-1 rounded">
|
||||||
x{quantity}
|
x{quantity}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,77 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { AlertTriangle, Check, Edit } from 'lucide-react';
|
import { AlertTriangle, Check, Edit } from 'lucide-react';
|
||||||
import { Deck } from '../types';
|
import { Deck } from '../types';
|
||||||
import { validateDeck } from '../utils/deckValidation';
|
import { validateDeck } from '../utils/deckValidation';
|
||||||
|
|
||||||
interface DeckCardProps {
|
interface DeckCardProps {
|
||||||
deck: Deck;
|
deck: Deck;
|
||||||
onEdit?: (deckId: string) => void;
|
onEdit?: (deckId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeckCard({ deck, onEdit }: DeckCardProps) {
|
export default function DeckCard({ deck, onEdit }: DeckCardProps) {
|
||||||
|
|
||||||
if(deck.id === "410ed539-a8f4-4bc4-91f1-6c113b9b7e25"){
|
if(deck.id === "410ed539-a8f4-4bc4-91f1-6c113b9b7e25"){
|
||||||
console.log("deck", deck.name);
|
console.log("deck", deck.name);
|
||||||
console.log("cardEntities", deck.cards);
|
console.log("cardEntities", deck.cards);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = validateDeck(deck);
|
const validation = validateDeck(deck);
|
||||||
const commander = deck.format === 'commander' ? deck.cards.find(card =>
|
const commander = deck.format === 'commander' ? deck.cards.find(card =>
|
||||||
card.is_commander
|
card.is_commander
|
||||||
)?.card : null;
|
)?.card : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-gray-800 rounded-xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1 cursor-pointer"
|
className="bg-gray-800 rounded-xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1 cursor-pointer"
|
||||||
onClick={() => onEdit?.(deck.id)}
|
onClick={() => onEdit?.(deck.id)}
|
||||||
>
|
>
|
||||||
<div className="relative h-48 overflow-hidden">
|
<div className="relative h-48 overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={commander?.image_uris?.normal || deck.cards[0]?.card.image_uris?.normal}
|
src={commander?.image_uris?.normal || deck.cards[0]?.card.image_uris?.normal}
|
||||||
alt={commander?.name || deck.cards[0]?.card.name}
|
alt={commander?.name || deck.cards[0]?.card.name}
|
||||||
className="w-full object-cover object-top transform translate-y-[-12%]"
|
className="w-full object-cover object-top transform translate-y-[-12%]"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="text-xl font-bold text-white">{deck.name}</h3>
|
<h3 className="text-xl font-bold text-white">{deck.name}</h3>
|
||||||
{validation.isValid ? (
|
{validation.isValid ? (
|
||||||
<div className="flex items-center text-green-400">
|
<div className="flex items-center text-green-400">
|
||||||
<Check size={16} className="mr-1" />
|
<Check size={16} className="mr-1" />
|
||||||
<span className="text-sm">Legal</span>
|
<span className="text-sm">Legal</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center text-yellow-400" title={validation.errors.join(', ')}>
|
<div className="flex items-center text-yellow-400" title={validation.errors.join(', ')}>
|
||||||
<AlertTriangle size={16} className="mr-1" />
|
<AlertTriangle size={16} className="mr-1" />
|
||||||
<span className="text-sm">Issues</span>
|
<span className="text-sm">Issues</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm text-gray-400">
|
<div className="flex items-center justify-between text-sm text-gray-400">
|
||||||
<span className="capitalize">{deck.format}</span>
|
<span className="capitalize">{deck.format}</span>
|
||||||
<span>{deck.cards.reduce((acc, curr) => acc + curr.quantity, 0)} cards</span>
|
<span>{deck.cards.reduce((acc, curr) => acc + curr.quantity, 0)} cards</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{commander && (
|
{commander && (
|
||||||
<div className="mt-2 text-sm text-gray-300">
|
<div className="mt-2 text-sm text-gray-300">
|
||||||
<span className="text-blue-400">Commander:</span> {commander.name}
|
<span className="text-blue-400">Commander:</span> {commander.name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEdit?.(deck.id);
|
onEdit?.(deck.id);
|
||||||
}}
|
}}
|
||||||
className="mt-4 w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2 text-white"
|
className="mt-4 w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2 text-white"
|
||||||
>
|
>
|
||||||
<Edit size={20} />
|
<Edit size={20} />
|
||||||
Edit Deck
|
Edit Deck
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +1,85 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Card, Deck } from '../types';
|
import { Card, Deck } from '../types';
|
||||||
import DeckManager from './DeckManager';
|
import DeckManager from './DeckManager';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { getCardsByIds } from '../services/api';
|
import { getCardsByIds } from '../services/api';
|
||||||
|
|
||||||
interface DeckEditorProps {
|
interface DeckEditorProps {
|
||||||
deckId: string;
|
deckId: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeckEditor({ deckId, onClose }: DeckEditorProps) {
|
export default function DeckEditor({ deckId, onClose }: DeckEditorProps) {
|
||||||
const [deck, setDeck] = useState<Deck | null>(null);
|
const [deck, setDeck] = useState<Deck | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDeck = async () => {
|
const fetchDeck = async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch deck data
|
// Fetch deck data
|
||||||
const { data: deckData, error: deckError } = await supabase
|
const { data: deckData, error: deckError } = await supabase
|
||||||
.from('decks')
|
.from('decks')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', deckId)
|
.eq('id', deckId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (deckError) throw deckError;
|
if (deckError) throw deckError;
|
||||||
|
|
||||||
// Fetch deck cards
|
// Fetch deck cards
|
||||||
const { data: cardEntities, error: cardsError } = await supabase
|
const { data: cardEntities, error: cardsError } = await supabase
|
||||||
.from('deck_cards')
|
.from('deck_cards')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('deck_id', deckId);
|
.eq('deck_id', deckId);
|
||||||
|
|
||||||
if (cardsError) throw cardsError;
|
if (cardsError) throw cardsError;
|
||||||
|
|
||||||
// Fetch card details from Scryfall
|
// Fetch card details from Scryfall
|
||||||
const cardIds = cardEntities.map(entity => entity.card_id);
|
const cardIds = cardEntities.map(entity => entity.card_id);
|
||||||
const uniqueCardIds = [...new Set(cardIds)];
|
const uniqueCardIds = [...new Set(cardIds)];
|
||||||
const scryfallCards = await getCardsByIds(uniqueCardIds);
|
const scryfallCards = await getCardsByIds(uniqueCardIds);
|
||||||
|
|
||||||
// Combine deck data with card details
|
// Combine deck data with card details
|
||||||
const cards = cardEntities.map(entity => ({
|
const cards = cardEntities.map(entity => ({
|
||||||
card: scryfallCards.find(c => c.id === entity.card_id) as Card,
|
card: scryfallCards.find(c => c.id === entity.card_id) as Card,
|
||||||
quantity: entity.quantity,
|
quantity: entity.quantity,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setDeck({
|
setDeck({
|
||||||
...deckData,
|
...deckData,
|
||||||
cards,
|
cards,
|
||||||
createdAt: new Date(deckData.created_at),
|
createdAt: new Date(deckData.created_at),
|
||||||
updatedAt: new Date(deckData.updated_at),
|
updatedAt: new Date(deckData.updated_at),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching deck:', error);
|
console.error('Error fetching deck:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchDeck();
|
fetchDeck();
|
||||||
}, [deckId]);
|
}, [deckId]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white p-6 flex items-center justify-center">
|
<div className="min-h-screen bg-gray-900 text-white p-6 flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!deck) {
|
if (!deck) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4">
|
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4">
|
||||||
<h2 className="text-xl font-bold text-red-500">Error</h2>
|
<h2 className="text-xl font-bold text-red-500">Error</h2>
|
||||||
<p className="text-red-400">Failed to load deck</p>
|
<p className="text-red-400">Failed to load deck</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <DeckManager initialDeck={deck} onSave={onClose} />;
|
return <DeckManager initialDeck={deck} onSave={onClose} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +1,99 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { getCardById, getCardsByIds } from '../services/api';
|
import { getCardById, getCardsByIds } from '../services/api';
|
||||||
import { Deck } from '../types';
|
import { Deck } from '../types';
|
||||||
import { supabase } from "../lib/supabase";
|
import { supabase } from "../lib/supabase";
|
||||||
import DeckCard from "./DeckCard";
|
import DeckCard from "./DeckCard";
|
||||||
|
|
||||||
interface DeckListProps {
|
interface DeckListProps {
|
||||||
onDeckEdit?: (deckId: string) => void;
|
onDeckEdit?: (deckId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeckList = ({ onDeckEdit }: DeckListProps) => {
|
const DeckList = ({ onDeckEdit }: DeckListProps) => {
|
||||||
const [decks, setDecks] = useState<Deck[]>([]);
|
const [decks, setDecks] = useState<Deck[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDecks = async () => {
|
const fetchDecks = async () => {
|
||||||
const { data: decksData, error: decksError } = await supabase.from('decks').select('*');
|
const { data: decksData, error: decksError } = await supabase.from('decks').select('*');
|
||||||
if (decksError) {
|
if (decksError) {
|
||||||
console.error('Error fetching decks:', decksError);
|
console.error('Error fetching decks:', decksError);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const decksWithCards = await Promise.all(decksData.map(async (deck) => {
|
const decksWithCards = await Promise.all(decksData.map(async (deck) => {
|
||||||
const { data: cardEntities, error: cardsError } = await supabase
|
const { data: cardEntities, error: cardsError } = await supabase
|
||||||
.from('deck_cards')
|
.from('deck_cards')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('deck_id', deck.id);
|
.eq('deck_id', deck.id);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (cardsError) {
|
if (cardsError) {
|
||||||
console.error(`Error fetching cards for deck ${deck.id}:`, cardsError);
|
console.error(`Error fetching cards for deck ${deck.id}:`, cardsError);
|
||||||
return { ...deck, cards: [] };
|
return { ...deck, cards: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardIds = cardEntities.map((entity) => entity.card_id);
|
const cardIds = cardEntities.map((entity) => entity.card_id);
|
||||||
const uniqueCardIds = [...new Set(cardIds)];
|
const uniqueCardIds = [...new Set(cardIds)];
|
||||||
|
|
||||||
if(deck.id === "410ed539-a8f4-4bc4-91f1-6c113b9b7e25"){
|
if(deck.id === "410ed539-a8f4-4bc4-91f1-6c113b9b7e25"){
|
||||||
console.log("uniqueCardIds", uniqueCardIds);
|
console.log("uniqueCardIds", uniqueCardIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const scryfallCards = await getCardsByIds(uniqueCardIds);
|
const scryfallCards = await getCardsByIds(uniqueCardIds);
|
||||||
|
|
||||||
if (!scryfallCards) {
|
if (!scryfallCards) {
|
||||||
console.error("scryfallCards is undefined after getCardsByIds");
|
console.error("scryfallCards is undefined after getCardsByIds");
|
||||||
return { ...deck, cards: [] };
|
return { ...deck, cards: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards = cardEntities.map((entity) => {
|
const cards = cardEntities.map((entity) => {
|
||||||
const card = scryfallCards.find((c) => c.id === entity.card_id);
|
const card = scryfallCards.find((c) => c.id === entity.card_id);
|
||||||
return {
|
return {
|
||||||
card,
|
card,
|
||||||
quantity: entity.quantity,
|
quantity: entity.quantity,
|
||||||
is_commander: entity.is_commander,
|
is_commander: entity.is_commander,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...deck,
|
...deck,
|
||||||
cards,
|
cards,
|
||||||
createdAt: new Date(deck.created_at),
|
createdAt: new Date(deck.created_at),
|
||||||
updatedAt: new Date(deck.updated_at),
|
updatedAt: new Date(deck.updated_at),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching cards from Scryfall:", error);
|
console.error("Error fetching cards from Scryfall:", error);
|
||||||
return { ...deck, cards: [] };
|
return { ...deck, cards: [] };
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setDecks(decksWithCards);
|
setDecks(decksWithCards);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchDecks();
|
fetchDecks();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{decks.map((deck) => (
|
{decks.map((deck) => (
|
||||||
<DeckCard key={deck.id} deck={deck} onEdit={onDeckEdit} />
|
<DeckCard key={deck.id} deck={deck} onEdit={onDeckEdit} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DeckList;
|
export default DeckList;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,167 +1,167 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Plus, Minus } from 'lucide-react';
|
import { Plus, Minus } from 'lucide-react';
|
||||||
|
|
||||||
interface Player {
|
interface Player {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
life: number;
|
life: number;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = ['white', 'blue', 'black', 'red', 'green'];
|
const COLORS = ['white', 'blue', 'black', 'red', 'green'];
|
||||||
|
|
||||||
export default function LifeCounter() {
|
export default function LifeCounter() {
|
||||||
const [numPlayers, setNumPlayers] = useState<number | null>(null);
|
const [numPlayers, setNumPlayers] = useState<number | null>(null);
|
||||||
const [playerNames, setPlayerNames] = useState<string[]>([]);
|
const [playerNames, setPlayerNames] = useState<string[]>([]);
|
||||||
const [players, setPlayers] = useState<Player[]>([]);
|
const [players, setPlayers] = useState<Player[]>([]);
|
||||||
const [setupComplete, setSetupComplete] = useState(false);
|
const [setupComplete, setSetupComplete] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (numPlayers !== null) {
|
if (numPlayers !== null) {
|
||||||
setPlayers(
|
setPlayers(
|
||||||
Array.from({ length: numPlayers }, (_, i) => ({
|
Array.from({ length: numPlayers }, (_, i) => ({
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
name: playerNames[i] || `Player ${i + 1}`,
|
name: playerNames[i] || `Player ${i + 1}`,
|
||||||
life: 20,
|
life: 20,
|
||||||
color: COLORS[i % COLORS.length],
|
color: COLORS[i % COLORS.length],
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [numPlayers, playerNames]);
|
}, [numPlayers, playerNames]);
|
||||||
|
|
||||||
const handleNumPlayersChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleNumPlayersChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const newNumPlayers = parseInt(e.target.value, 10);
|
const newNumPlayers = parseInt(e.target.value, 10);
|
||||||
setNumPlayers(newNumPlayers);
|
setNumPlayers(newNumPlayers);
|
||||||
setPlayerNames(Array(newNumPlayers).fill(''));
|
setPlayerNames(Array(newNumPlayers).fill(''));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNameChange = (index: number, newName: string) => {
|
const handleNameChange = (index: number, newName: string) => {
|
||||||
const updatedNames = [...playerNames];
|
const updatedNames = [...playerNames];
|
||||||
updatedNames[index] = newName;
|
updatedNames[index] = newName;
|
||||||
setPlayerNames(updatedNames);
|
setPlayerNames(updatedNames);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateLife = (playerId: number, change: number) => {
|
const updateLife = (playerId: number, change: number) => {
|
||||||
setPlayers((prevPlayers) =>
|
setPlayers((prevPlayers) =>
|
||||||
prevPlayers.map((player) =>
|
prevPlayers.map((player) =>
|
||||||
player.id === playerId ? { ...player, life: player.life + change } : player
|
player.id === playerId ? { ...player, life: player.life + change } : player
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSetupComplete(true);
|
setSetupComplete(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderSetupForm = () => (
|
const renderSetupForm = () => (
|
||||||
<div className="max-w-md mx-auto">
|
<div className="max-w-md mx-auto">
|
||||||
<h2 className="text-2xl font-bold mb-6">Setup Players</h2>
|
<h2 className="text-2xl font-bold mb-6">Setup Players</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Number of Players
|
Number of Players
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={numPlayers || ''}
|
value={numPlayers || ''}
|
||||||
onChange={handleNumPlayersChange}
|
onChange={handleNumPlayersChange}
|
||||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="" disabled>Select Number of Players</option>
|
<option value="" disabled>Select Number of Players</option>
|
||||||
{[2, 3, 4, 5, 6].map((num) => (
|
{[2, 3, 4, 5, 6].map((num) => (
|
||||||
<option key={num} value={num}>
|
<option key={num} value={num}>
|
||||||
{num}
|
{num}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{numPlayers !== null &&
|
{numPlayers !== null &&
|
||||||
Array.from({ length: numPlayers }, (_, i) => (
|
Array.from({ length: numPlayers }, (_, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Player {i + 1} Name
|
Player {i + 1} Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={playerNames[i] || ''}
|
value={playerNames[i] || ''}
|
||||||
onChange={(e) => handleNameChange(i, e.target.value)}
|
onChange={(e) => handleNameChange(i, e.target.value)}
|
||||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||||
placeholder={`Player ${i + 1} Name`}
|
placeholder={`Player ${i + 1} Name`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{numPlayers !== null && (
|
{numPlayers !== null && (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg"
|
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg"
|
||||||
>
|
>
|
||||||
Start Game
|
Start Game
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderLifeCounters = () => (
|
const renderLifeCounters = () => (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
{players.map((player, index) => {
|
{players.map((player, index) => {
|
||||||
const angle = (index / players.length) * 360;
|
const angle = (index / players.length) * 360;
|
||||||
const rotation = 360 - angle;
|
const rotation = 360 - angle;
|
||||||
const x = 50 + 40 * Math.cos((angle - 90) * Math.PI / 180);
|
const x = 50 + 40 * Math.cos((angle - 90) * Math.PI / 180);
|
||||||
const y = 50 + 40 * Math.sin((angle - 90) * Math.PI / 180);
|
const y = 50 + 40 * Math.sin((angle - 90) * Math.PI / 180);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={player.id}
|
key={player.id}
|
||||||
className="absolute transform -translate-x-1/2 -translate-y-1/2"
|
className="absolute transform -translate-x-1/2 -translate-y-1/2"
|
||||||
style={{
|
style={{
|
||||||
top: `${y}%`,
|
top: `${y}%`,
|
||||||
left: `${x}%`,
|
left: `${x}%`,
|
||||||
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="rounded-lg p-4 flex flex-col items-center"
|
className="rounded-lg p-4 flex flex-col items-center"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `var(--color-${player.color}-primary)`,
|
backgroundColor: `var(--color-${player.color}-primary)`,
|
||||||
color: 'white',
|
color: 'white',
|
||||||
transform: `rotate(${-rotation}deg)`,
|
transform: `rotate(${-rotation}deg)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-bold mb-4">{player.name}</h2>
|
<h2 className="text-xl font-bold mb-4">{player.name}</h2>
|
||||||
<div className="text-4xl font-bold mb-4">{player.life}</div>
|
<div className="text-4xl font-bold mb-4">{player.life}</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => updateLife(player.id, 1)}
|
onClick={() => updateLife(player.id, 1)}
|
||||||
className="bg-green-600 hover:bg-green-700 rounded-full p-2"
|
className="bg-green-600 hover:bg-green-700 rounded-full p-2"
|
||||||
>
|
>
|
||||||
<Plus size={24} />
|
<Plus size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateLife(player.id, -1)}
|
onClick={() => updateLife(player.id, -1)}
|
||||||
className="bg-red-600 hover:bg-red-700 rounded-full p-2"
|
className="bg-red-600 hover:bg-red-700 rounded-full p-2"
|
||||||
>
|
>
|
||||||
<Minus size={24} />
|
<Minus size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-6">Life Counter</h1>
|
<h1 className="text-3xl font-bold mb-6">Life Counter</h1>
|
||||||
{!setupComplete ? renderSetupForm() : renderLifeCounters()}
|
{!setupComplete ? renderSetupForm() : renderLifeCounters()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,147 +1,147 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Mail, Lock, LogIn } from 'lucide-react';
|
import { Mail, Lock, LogIn } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { Card } from '../types';
|
import { Card } from '../types';
|
||||||
import { getRandomCards } from '../services/api';
|
import { getRandomCards } from '../services/api';
|
||||||
|
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [isSignUp, setIsSignUp] = useState(false);
|
const [isSignUp, setIsSignUp] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { signIn, signUp } = useAuth();
|
const { signIn, signUp } = useAuth();
|
||||||
const [cards, setCards] = useState<Card[]>([]);
|
const [cards, setCards] = useState<Card[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCards = async () => {
|
const loadCards = async () => {
|
||||||
try {
|
try {
|
||||||
const randomCards = await getRandomCards(6);
|
const randomCards = await getRandomCards(6);
|
||||||
setCards(randomCards);
|
setCards(randomCards);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load cards:', error);
|
console.error('Failed to load cards:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadCards();
|
loadCards();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isSignUp) {
|
if (isSignUp) {
|
||||||
await signUp(email, password);
|
await signUp(email, password);
|
||||||
} else {
|
} else {
|
||||||
await signIn(email, password);
|
await signIn(email, password);
|
||||||
}
|
}
|
||||||
window.location.href = '/'; // Redirect to home after successful login
|
window.location.href = '/'; // Redirect to home after successful login
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error instanceof Error ? error.message : 'An error occurred');
|
setError(error instanceof Error ? error.message : 'An error occurred');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="animate-pulse h-96 bg-gray-700/50 rounded-lg"></div>;
|
return <div className="animate-pulse h-96 bg-gray-700/50 rounded-lg"></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen flex items-center justify-center p-6 overflow-hidden">
|
<div className="relative min-h-screen flex items-center justify-center p-6 overflow-hidden">
|
||||||
{/* Animated Background */}
|
{/* Animated Background */}
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="flex animate-slide"
|
className="flex animate-slide"
|
||||||
style={{
|
style={{
|
||||||
width: `${cards.length * 100}%`,
|
width: `${cards.length * 100}%`,
|
||||||
animation: 'slide 60s linear infinite'
|
animation: 'slide 60s linear infinite'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[...cards, ...cards].map((card, index) => (
|
{[...cards, ...cards].map((card, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${card.id}-${index}`}
|
key={`${card.id}-${index}`}
|
||||||
className="relative w-full h-screen"
|
className="relative w-full h-screen"
|
||||||
style={{
|
style={{
|
||||||
width: `${100 / (cards.length * 2)}%`
|
width: `${100 / (cards.length * 2)}%`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-cover bg-center transform transition-transform duration-1000"
|
className="absolute inset-0 bg-cover bg-center transform transition-transform duration-1000"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${card.image_uris?.normal})`,
|
backgroundImage: `url(${card.image_uris?.normal})`,
|
||||||
filter: 'blur(8px) brightness(0.4)',
|
filter: 'blur(8px) brightness(0.4)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Form */}
|
{/* Login Form */}
|
||||||
<div className="relative z-10 bg-gray-900/80 p-8 rounded-lg shadow-xl backdrop-blur-sm w-full max-w-md">
|
<div className="relative z-10 bg-gray-900/80 p-8 rounded-lg shadow-xl backdrop-blur-sm w-full max-w-md">
|
||||||
<h2 className="text-3xl font-bold text-orange-500 mb-6 text-center">
|
<h2 className="text-3xl font-bold text-orange-500 mb-6 text-center">
|
||||||
Deckerr
|
Deckerr
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 bg-red-500/10 border border-red-500 rounded text-red-500">
|
<div className="mb-4 p-3 bg-red-500/10 border border-red-500 rounded text-red-500">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 bg-gray-800/50 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
className="w-full pl-10 pr-4 py-2 bg-gray-800/50 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||||
placeholder="Enter your email"
|
placeholder="Enter your email"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 bg-gray-800/50 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
className="w-full pl-10 pr-4 py-2 bg-gray-800/50 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-lg transition duration-200"
|
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-lg transition duration-200"
|
||||||
>
|
>
|
||||||
<LogIn size={20} />
|
<LogIn size={20} />
|
||||||
{isSignUp ? 'Sign Up' : 'Sign In'}
|
{isSignUp ? 'Sign Up' : 'Sign In'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSignUp(!isSignUp)}
|
onClick={() => setIsSignUp(!isSignUp)}
|
||||||
className="text-blue-400 hover:text-blue-300"
|
className="text-blue-400 hover:text-blue-300"
|
||||||
>
|
>
|
||||||
{isSignUp ? 'Already have an account? Sign In' : 'Need an account? Sign Up'}
|
{isSignUp ? 'Already have an account? Sign In' : 'Need an account? Sign Up'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card } from '../types';
|
import { Card } from '../types';
|
||||||
|
|
||||||
interface MagicCardProps {
|
interface MagicCardProps {
|
||||||
card: Card;
|
card: Card;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MagicCard = ({ card }: MagicCardProps) => {
|
const MagicCard = ({ card }: MagicCardProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{card.image_uris?.normal ? (
|
{card.image_uris?.normal ? (
|
||||||
<img
|
<img
|
||||||
src={card.image_uris.normal}
|
src={card.image_uris.normal}
|
||||||
alt={card.name}
|
alt={card.name}
|
||||||
className="w-full h-auto rounded-lg"
|
className="w-full h-auto rounded-lg"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-64 bg-gray-700 rounded-lg flex items-center justify-center text-gray-400">
|
<div className="w-full h-64 bg-gray-700 rounded-lg flex items-center justify-center text-gray-400">
|
||||||
No Image Available
|
No Image Available
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{card.prices?.usd && (
|
{card.prices?.usd && (
|
||||||
<div className="absolute bottom-0 left-0 p-2 bg-gray-900 bg-opacity-50 text-white rounded-bl-lg rounded-tr-lg">
|
<div className="absolute bottom-0 left-0 p-2 bg-gray-900 bg-opacity-50 text-white rounded-bl-lg rounded-tr-lg">
|
||||||
${card.prices.usd}
|
${card.prices.usd}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MagicCard;
|
export default MagicCard;
|
||||||
|
|||||||
@@ -1,107 +1,107 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const ManaWhite = ({ size = 20, ...props }: { size?: number }) => (
|
export const ManaWhite = ({ size = 20, ...props }: { size?: number }) => (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<path d="M12 2v20M2 12h20" />
|
<path d="M12 2v20M2 12h20" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ManaBlue = ({ size = 20, ...props }: { size?: number }) => (
|
export const ManaBlue = ({ size = 20, ...props }: { size?: number }) => (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<path d="M12 2v20M2 12h20" />
|
<path d="M12 2v20M2 12h20" />
|
||||||
<path d="M12 2v20M2 12h20" transform="rotate(45 12 12)" />
|
<path d="M12 2v20M2 12h20" transform="rotate(45 12 12)" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ManaBlack = ({ size = 20, ...props }: { size?: number }) => (
|
export const ManaBlack = ({ size = 20, ...props }: { size?: number }) => (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<path d="M12 2v20M2 12h20" transform="rotate(90 12 12)" />
|
<path d="M12 2v20M2 12h20" transform="rotate(90 12 12)" />
|
||||||
<path d="M12 2v20M2 12h20" transform="rotate(135 12 12)" />
|
<path d="M12 2v20M2 12h20" transform="rotate(135 12 12)" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ManaRed = ({ size = 20, ...props }: { size?: number }) => (
|
export const ManaRed = ({ size = 20, ...props }: { size?: number }) => (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<path d="M12 2v20M2 12h20" transform="rotate(135 12 12)" />
|
<path d="M12 2v20M2 12h20" transform="rotate(135 12 12)" />
|
||||||
<path d="M12 2v20M2 12h20" transform="rotate(180 12 12)" />
|
<path d="M12 2v20M2 12h20" transform="rotate(180 12 12)" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ManaGreen = ({ size = 20, ...props }: { size?: number }) => (
|
export const ManaGreen = ({ size = 20, ...props }: { size?: number }) => (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<path d="M12 2v20M2 12h20" transform="rotate(180 12 12)" />
|
<path d="M12 2v20M2 12h20" transform="rotate(180 12 12)" />
|
||||||
<path d="M12 2v20M2 12h20" transform="rotate(225 12 12)" />
|
<path d="M12 2v20M2 12h20" transform="rotate(225 12 12)" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ManaColorless = ({ size = 20, ...props }: { size?: number }) => (
|
export const ManaColorless = ({ size = 20, ...props }: { size?: number }) => (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,199 +1,199 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Home, PlusSquare, Library, LogOut, Settings, ChevronDown, Search, Heart, Menu } from 'lucide-react';
|
import { Home, PlusSquare, Library, LogOut, Settings, ChevronDown, Search, Heart, Menu } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
type Page = 'home' | 'deck' | 'login' | 'collection' | 'profile' | 'search' | 'life-counter';
|
type Page = 'home' | 'deck' | 'login' | 'collection' | 'profile' | 'search' | 'life-counter';
|
||||||
|
|
||||||
interface NavigationProps {
|
interface NavigationProps {
|
||||||
currentPage: Page;
|
currentPage: Page;
|
||||||
setCurrentPage: (page: Page) => void;
|
setCurrentPage: (page: Page) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Navigation({ currentPage, setCurrentPage }: NavigationProps) {
|
export default function Navigation({ currentPage, setCurrentPage }: NavigationProps) {
|
||||||
const { user, signOut } = useAuth();
|
const { user, signOut } = useAuth();
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const [username, setUsername] = useState<string | null>(null);
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProfile = async () => {
|
const fetchProfile = async () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('username')
|
.select('username')
|
||||||
.eq('id', user.id)
|
.eq('id', user.id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
setUsername(data.username);
|
setUsername(data.username);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
setShowDropdown(false);
|
setShowDropdown(false);
|
||||||
}
|
}
|
||||||
if (mobileMenuRef.current && !mobileMenuRef.current.contains(event.target as Node)) {
|
if (mobileMenuRef.current && !mobileMenuRef.current.contains(event.target as Node)) {
|
||||||
setShowMobileMenu(false);
|
setShowMobileMenu(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'home' as const, label: 'Home', icon: Home },
|
{ id: 'home' as const, label: 'Home', icon: Home },
|
||||||
{ id: 'deck' as const, label: 'New Deck', icon: PlusSquare },
|
{ id: 'deck' as const, label: 'New Deck', icon: PlusSquare },
|
||||||
{ id: 'collection' as const, label: 'Collection', icon: Library },
|
{ id: 'collection' as const, label: 'Collection', icon: Library },
|
||||||
{ id: 'search' as const, label: 'Search', icon: Search },
|
{ id: 'search' as const, label: 'Search', icon: Search },
|
||||||
{ id: 'life-counter' as const, label: 'Life Counter', icon: Heart },
|
{ id: 'life-counter' as const, label: 'Life Counter', icon: Heart },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
try {
|
try {
|
||||||
await signOut();
|
await signOut();
|
||||||
setCurrentPage('login');
|
setCurrentPage('login');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error signing out:', error);
|
console.error('Error signing out:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAvatarUrl = (userId: string) => {
|
const getAvatarUrl = (userId: string) => {
|
||||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}&backgroundColor=b6e3f4,c0aede,d1d4f9`;
|
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}&backgroundColor=b6e3f4,c0aede,d1d4f9`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop Navigation - Top */}
|
{/* Desktop Navigation - Top */}
|
||||||
<nav className="hidden md:block fixed top-0 left-0 right-0 bg-gray-800 border-b border-gray-700 z-50">
|
<nav className="hidden md:block fixed top-0 left-0 right-0 bg-gray-800 border-b border-gray-700 z-50">
|
||||||
<div className="max-w-7xl mx-auto px-4">
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
<div className="flex items-center space-x-8">
|
<div className="flex items-center space-x-8">
|
||||||
<span className="text-2xl font-bold text-orange-500">Deckerr</span>
|
<span className="text-2xl font-bold text-orange-500">Deckerr</span>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setCurrentPage(item.id)}
|
onClick={() => setCurrentPage(item.id)}
|
||||||
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors
|
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors
|
||||||
${currentPage === item.id
|
${currentPage === item.id
|
||||||
? 'text-white bg-gray-900'
|
? 'text-white bg-gray-900'
|
||||||
: 'text-gray-300 hover:text-white hover:bg-gray-700'
|
: 'text-gray-300 hover:text-white hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon size={20} />
|
<item.icon size={20} />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDropdown(!showDropdown)}
|
onClick={() => setShowDropdown(!showDropdown)}
|
||||||
className="flex items-center space-x-3 px-3 py-2 rounded-md hover:bg-gray-700"
|
className="flex items-center space-x-3 px-3 py-2 rounded-md hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={getAvatarUrl(user.id)}
|
src={getAvatarUrl(user.id)}
|
||||||
alt="User avatar"
|
alt="User avatar"
|
||||||
className="w-8 h-8 rounded-full bg-gray-700"
|
className="w-8 h-8 rounded-full bg-gray-700"
|
||||||
/>
|
/>
|
||||||
<span className="text-gray-300 text-sm">{username || user.email}</span>
|
<span className="text-gray-300 text-sm">{username || user.email}</span>
|
||||||
<ChevronDown size={16} className="text-gray-400" />
|
<ChevronDown size={16} className="text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showDropdown && (
|
{showDropdown && (
|
||||||
<div className="absolute right-0 mt-2 w-48 bg-gray-800 rounded-md shadow-lg py-1 border border-gray-700">
|
<div className="absolute right-0 mt-2 w-48 bg-gray-800 rounded-md shadow-lg py-1 border border-gray-700">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentPage('profile');
|
setCurrentPage('profile');
|
||||||
setShowDropdown(false);
|
setShowDropdown(false);
|
||||||
}}
|
}}
|
||||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<Settings size={16} />
|
<Settings size={16} />
|
||||||
<span>Profile Settings</span>
|
<span>Profile Settings</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<LogOut size={16} />
|
<LogOut size={16} />
|
||||||
<span>Sign Out</span>
|
<span>Sign Out</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile Navigation - Bottom */}
|
{/* Mobile Navigation - Bottom */}
|
||||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-800 border-t border-gray-700 z-50">
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-800 border-t border-gray-700 z-50">
|
||||||
<div className="flex justify-between items-center h-16 px-4">
|
<div className="flex justify-between items-center h-16 px-4">
|
||||||
<span className="text-2xl font-bold text-orange-500">Deckerr</span>
|
<span className="text-2xl font-bold text-orange-500">Deckerr</span>
|
||||||
<div className="relative" ref={mobileMenuRef}>
|
<div className="relative" ref={mobileMenuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMobileMenu(!showMobileMenu)}
|
onClick={() => setShowMobileMenu(!showMobileMenu)}
|
||||||
className="text-gray-300 hover:text-white"
|
className="text-gray-300 hover:text-white"
|
||||||
>
|
>
|
||||||
<Menu size={24} />
|
<Menu size={24} />
|
||||||
</button>
|
</button>
|
||||||
{showMobileMenu && (
|
{showMobileMenu && (
|
||||||
<div className="absolute right-0 bottom-16 w-48 bg-gray-800 rounded-md shadow-lg py-1 border border-gray-700">
|
<div className="absolute right-0 bottom-16 w-48 bg-gray-800 rounded-md shadow-lg py-1 border border-gray-700">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentPage(item.id);
|
setCurrentPage(item.id);
|
||||||
setShowMobileMenu(false);
|
setShowMobileMenu(false);
|
||||||
}}
|
}}
|
||||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<item.icon size={16} />
|
<item.icon size={16} />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentPage('profile');
|
setCurrentPage('profile');
|
||||||
setShowMobileMenu(false);
|
setShowMobileMenu(false);
|
||||||
}}
|
}}
|
||||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<Settings size={16} />
|
<Settings size={16} />
|
||||||
<span>Profile Settings</span>
|
<span>Profile Settings</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<LogOut size={16} />
|
<LogOut size={16} />
|
||||||
<span>Sign Out</span>
|
<span>Sign Out</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Content Padding */}
|
{/* Content Padding */}
|
||||||
<div className="md:pt-16 pb-16 md:pb-0" />
|
<div className="md:pt-16 pb-16 md:pb-0" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,128 +1,128 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Save } from 'lucide-react';
|
import { Save } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
const THEME_COLORS = ['red', 'green', 'blue', 'yellow', 'grey', 'purple'];
|
const THEME_COLORS = ['red', 'green', 'blue', 'yellow', 'grey', 'purple'];
|
||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [themeColor, setThemeColor] = useState('blue');
|
const [themeColor, setThemeColor] = useState('blue');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadProfile = async () => {
|
const loadProfile = async () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('username, theme_color')
|
.select('username, theme_color')
|
||||||
.eq('id', user.id)
|
.eq('id', user.id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
setUsername(data.username || '');
|
setUsername(data.username || '');
|
||||||
setThemeColor(data.theme_color || 'blue');
|
setThemeColor(data.theme_color || 'blue');
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadProfile();
|
loadProfile();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.upsert({
|
.upsert({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username,
|
username,
|
||||||
theme_color: themeColor,
|
theme_color: themeColor,
|
||||||
updated_at: new Date()
|
updated_at: new Date()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
alert('Profile updated successfully!');
|
alert('Profile updated successfully!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating profile:', error);
|
console.error('Error updating profile:', error);
|
||||||
alert('Failed to update profile');
|
alert('Failed to update profile');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-8">Profile Settings</h1>
|
<h1 className="text-3xl font-bold mb-8">Profile Settings</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Username
|
Username
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
placeholder="Enter your username"
|
placeholder="Enter your username"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Theme Color
|
Theme Color
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{THEME_COLORS.map((color) => (
|
{THEME_COLORS.map((color) => (
|
||||||
<button
|
<button
|
||||||
key={color}
|
key={color}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setThemeColor(color)}
|
onClick={() => setThemeColor(color)}
|
||||||
className={`h-12 rounded-lg border-2 transition-all capitalize
|
className={`h-12 rounded-lg border-2 transition-all capitalize
|
||||||
${themeColor === color
|
${themeColor === color
|
||||||
? 'border-white scale-105'
|
? 'border-white scale-105'
|
||||||
: 'border-transparent hover:border-gray-600'
|
: 'border-transparent hover:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
style={{ backgroundColor: `var(--color-${color}-primary)` }}
|
style={{ backgroundColor: `var(--color-${color}-primary)` }}
|
||||||
>
|
>
|
||||||
{color}
|
{color}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg transition duration-200"
|
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg transition duration-200"
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save size={20} />
|
<Save size={20} />
|
||||||
Save Changes
|
Save Changes
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,107 +1,107 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
import { User } from '@supabase/supabase-js';
|
import { User } from '@supabase/supabase-js';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
signIn: (email: string, password: string) => Promise<void>;
|
signIn: (email: string, password: string) => Promise<void>;
|
||||||
signUp: (email: string, password: string) => Promise<void>;
|
signUp: (email: string, password: string) => Promise<void>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check active sessions and sets the user
|
// Check active sessions and sets the user
|
||||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||||
setUser(session?.user ?? null);
|
setUser(session?.user ?? null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for changes on auth state
|
// Listen for changes on auth state
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
setUser(session?.user ?? null);
|
setUser(session?.user ?? null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
// If this is a new sign up, create a profile using setTimeout to avoid deadlock
|
// If this is a new sign up, create a profile using setTimeout to avoid deadlock
|
||||||
if (_event === 'SIGNED_IN' && session) {
|
if (_event === 'SIGNED_IN' && session) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.upsert(
|
.upsert(
|
||||||
{
|
{
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
theme_color: 'blue' // Default theme color
|
theme_color: 'blue' // Default theme color
|
||||||
},
|
},
|
||||||
{ onConflict: 'id' }
|
{ onConflict: 'id' }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error creating profile:', error);
|
console.error('Error creating profile:', error);
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signUp = async (email: string, password: string) => {
|
const signUp = async (email: string, password: string) => {
|
||||||
const { error, data } = await supabase.auth.signUp({
|
const { error, data } = await supabase.auth.signUp({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
// Create a profile for the new user using setTimeout to avoid deadlock
|
// Create a profile for the new user using setTimeout to avoid deadlock
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
const { error: profileError } = await supabase
|
const { error: profileError } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.insert({
|
.insert({
|
||||||
id: data.user!.id,
|
id: data.user!.id,
|
||||||
theme_color: 'blue' // Default theme color
|
theme_color: 'blue' // Default theme color
|
||||||
});
|
});
|
||||||
|
|
||||||
if (profileError) {
|
if (profileError) {
|
||||||
console.error('Error creating profile:', profileError);
|
console.error('Error creating profile:', profileError);
|
||||||
// Optionally handle the error (e.g., delete the auth user)
|
// Optionally handle the error (e.g., delete the auth user)
|
||||||
throw profileError;
|
throw profileError;
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const signIn = async (email: string, password: string) => {
|
const signIn = async (email: string, password: string) => {
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
};
|
};
|
||||||
|
|
||||||
const signOut = async () => {
|
const signOut = async () => {
|
||||||
const { error } = await supabase.auth.signOut();
|
const { error } = await supabase.auth.signOut();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, signIn, signUp, signOut }}>
|
<AuthContext.Provider value={{ user, loading, signIn, signUp, signOut }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const context = useContext(AuthContext);
|
const context = useContext(AuthContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@keyframes slide {
|
@keyframes slide {
|
||||||
0% {
|
0% {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-slide {
|
.animate-slide {
|
||||||
animation: slide 60s linear infinite;
|
animation: slide 60s linear infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {createClient} from "@supabase/supabase-js";
|
import {createClient} from "@supabase/supabase-js";
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
throw new Error('Missing Supabase environment variables');
|
throw new Error('Missing Supabase environment variables');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||||
|
|||||||
20
src/main.tsx
20
src/main.tsx
@@ -1,11 +1,11 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import './utils/theme.ts';
|
import './utils/theme.ts';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,19 +23,32 @@ export const getCardById = async (cardId: string): Promise<Card> => {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCardsByIds = async (cardIds: string[]): Promise<Card[]> => {
|
const chunkArray = (array: string[], size: number): string[][] => {
|
||||||
|
const chunkedArray: string[][] = [];
|
||||||
//75 cards per request max
|
for (let i = 0; i < array.length; i += size) {
|
||||||
const response = await fetch(`${SCRYFALL_API}/cards/collection`, {
|
chunkedArray.push(array.slice(i, i + size));
|
||||||
method: 'POST',
|
}
|
||||||
headers: {
|
return chunkedArray;
|
||||||
'Content-Type': 'application/json',
|
};
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
export const getCardsByIds = async (cardIds: string[]): Promise<Card[]> => {
|
||||||
identifiers: cardIds.map((id) => ({ id })),
|
const chunkedCardIds = chunkArray(cardIds, 75);
|
||||||
}),
|
let allCards: Card[] = [];
|
||||||
});
|
|
||||||
|
for (const chunk of chunkedCardIds) {
|
||||||
const data = await response.json();
|
const response = await fetch(`${SCRYFALL_API}/cards/collection`, {
|
||||||
return data.data;
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
identifiers: chunk.map((id) => ({ id })),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
allCards = allCards.concat(data.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allCards;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
themeColor: 'red' | 'green' | 'blue' | 'yellow' | 'grey' | 'purple';
|
themeColor: 'red' | 'green' | 'blue' | 'yellow' | 'grey' | 'purple';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Card {
|
export interface Card {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
image_uris?: {
|
image_uris?: {
|
||||||
normal: string;
|
normal: string;
|
||||||
art_crop: string;
|
art_crop: string;
|
||||||
};
|
};
|
||||||
mana_cost?: string;
|
mana_cost?: string;
|
||||||
type_line?: string;
|
type_line?: string;
|
||||||
oracle_text?: string;
|
oracle_text?: string;
|
||||||
colors?: string[];
|
colors?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Deck {
|
export interface Deck {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
format: string;
|
format: string;
|
||||||
cards: { card: Card; quantity: number, is_commander: boolean }[];
|
cards: { card: Card; quantity: number, is_commander: boolean }[];
|
||||||
userId: string;
|
userId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardEntity {
|
export interface CardEntity {
|
||||||
id: string;
|
id: string;
|
||||||
deck_id: string;
|
deck_id: string;
|
||||||
card_id: string;
|
card_id: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
is_commander: boolean;
|
is_commander: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,81 @@
|
|||||||
import { Deck } from '../types';
|
import { Deck } from '../types';
|
||||||
|
|
||||||
interface DeckValidation {
|
interface DeckValidation {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const FORMAT_RULES = {
|
const FORMAT_RULES = {
|
||||||
standard: {
|
standard: {
|
||||||
minCards: 60,
|
minCards: 60,
|
||||||
maxCards: undefined,
|
maxCards: undefined,
|
||||||
maxCopies: 4,
|
maxCopies: 4,
|
||||||
},
|
},
|
||||||
modern: {
|
modern: {
|
||||||
minCards: 60,
|
minCards: 60,
|
||||||
maxCards: undefined,
|
maxCards: undefined,
|
||||||
maxCopies: 4,
|
maxCopies: 4,
|
||||||
},
|
},
|
||||||
commander: {
|
commander: {
|
||||||
minCards: 100,
|
minCards: 100,
|
||||||
maxCards: 100,
|
maxCards: 100,
|
||||||
maxCopies: 1,
|
maxCopies: 1,
|
||||||
requiresCommander: true,
|
requiresCommander: true,
|
||||||
},
|
},
|
||||||
legacy: {
|
legacy: {
|
||||||
minCards: 60,
|
minCards: 60,
|
||||||
maxCards: undefined,
|
maxCards: undefined,
|
||||||
maxCopies: 4,
|
maxCopies: 4,
|
||||||
},
|
},
|
||||||
vintage: {
|
vintage: {
|
||||||
minCards: 60,
|
minCards: 60,
|
||||||
maxCards: undefined,
|
maxCards: undefined,
|
||||||
maxCopies: 4,
|
maxCopies: 4,
|
||||||
},
|
},
|
||||||
pauper: {
|
pauper: {
|
||||||
minCards: 60,
|
minCards: 60,
|
||||||
maxCards: undefined,
|
maxCards: undefined,
|
||||||
maxCopies: 4,
|
maxCopies: 4,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function validateDeck(deck: Deck): DeckValidation {
|
export function validateDeck(deck: Deck): DeckValidation {
|
||||||
const rules = FORMAT_RULES[deck.format as keyof typeof FORMAT_RULES];
|
const rules = FORMAT_RULES[deck.format as keyof typeof FORMAT_RULES];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
// Count total cards
|
// Count total cards
|
||||||
const totalCards = deck.cards.reduce((acc, curr) => acc + curr.quantity, 0);
|
const totalCards = deck.cards.reduce((acc, curr) => acc + curr.quantity, 0);
|
||||||
|
|
||||||
// Check minimum cards
|
// Check minimum cards
|
||||||
if (totalCards < rules.minCards) {
|
if (totalCards < rules.minCards) {
|
||||||
errors.push(`Deck must contain at least ${rules.minCards} cards`);
|
errors.push(`Deck must contain at least ${rules.minCards} cards`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check maximum cards
|
// Check maximum cards
|
||||||
if (rules.maxCards && totalCards > rules.maxCards) {
|
if (rules.maxCards && totalCards > rules.maxCards) {
|
||||||
errors.push(`Deck must not contain more than ${rules.maxCards} cards`);
|
errors.push(`Deck must not contain more than ${rules.maxCards} cards`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check card copies
|
// Check card copies
|
||||||
const cardCounts = new Map<string, number>();
|
const cardCounts = new Map<string, number>();
|
||||||
for (const element of deck.cards) {
|
for (const element of deck.cards) {
|
||||||
const {card, quantity} = element;
|
const {card, quantity} = element;
|
||||||
//console.log("card", card);
|
//console.log("card", card);
|
||||||
const currentCount = cardCounts.get(card.id) || 0;
|
const currentCount = cardCounts.get(card.id) || 0;
|
||||||
cardCounts.set(card.id, currentCount + quantity);
|
cardCounts.set(card.id, currentCount + quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
cardCounts.forEach((count, cardName) => {
|
cardCounts.forEach((count, cardName) => {
|
||||||
const card = deck.cards.find(c => c.card.id === cardName)?.card;
|
const card = deck.cards.find(c => c.card.id === cardName)?.card;
|
||||||
const isBasicLand = card?.name === 'Plains' || card?.name === 'Island' || card?.name === 'Swamp' || card?.name === 'Mountain' || card?.name === 'Forest';
|
const isBasicLand = card?.name === 'Plains' || card?.name === 'Island' || card?.name === 'Swamp' || card?.name === 'Mountain' || card?.name === 'Forest';
|
||||||
|
|
||||||
if (!isBasicLand && count > rules.maxCopies) {
|
if (!isBasicLand && count > rules.maxCopies) {
|
||||||
errors.push(`${cardName} has too many copies (max ${rules.maxCopies})`);
|
errors.push(`${cardName} has too many copies (max ${rules.maxCopies})`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isValid: errors.length === 0,
|
isValid: errors.length === 0,
|
||||||
errors,
|
errors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
export const themeColors = {
|
export const themeColors = {
|
||||||
red: {
|
red: {
|
||||||
primary: '#ef4444',
|
primary: '#ef4444',
|
||||||
secondary: '#b91c1c',
|
secondary: '#b91c1c',
|
||||||
hover: '#dc2626'
|
hover: '#dc2626'
|
||||||
},
|
},
|
||||||
green: {
|
green: {
|
||||||
primary: '#22c55e',
|
primary: '#22c55e',
|
||||||
secondary: '#15803d',
|
secondary: '#15803d',
|
||||||
hover: '#16a34a'
|
hover: '#16a34a'
|
||||||
},
|
},
|
||||||
blue: {
|
blue: {
|
||||||
primary: '#3b82f6',
|
primary: '#3b82f6',
|
||||||
secondary: '#1d4ed8',
|
secondary: '#1d4ed8',
|
||||||
hover: '#2563eb'
|
hover: '#2563eb'
|
||||||
},
|
},
|
||||||
yellow: {
|
yellow: {
|
||||||
primary: '#eab308',
|
primary: '#eab308',
|
||||||
secondary: '#a16207',
|
secondary: '#a16207',
|
||||||
hover: '#ca8a04'
|
hover: '#ca8a04'
|
||||||
},
|
},
|
||||||
grey: {
|
grey: {
|
||||||
primary: '#6b7280',
|
primary: '#6b7280',
|
||||||
secondary: '#374151',
|
secondary: '#374151',
|
||||||
hover: '#4b5563'
|
hover: '#4b5563'
|
||||||
},
|
},
|
||||||
purple: {
|
purple: {
|
||||||
primary: '#a855f7',
|
primary: '#a855f7',
|
||||||
secondary: '#7e22ce',
|
secondary: '#7e22ce',
|
||||||
hover: '#9333ea'
|
hover: '#9333ea'
|
||||||
},
|
},
|
||||||
white: {
|
white: {
|
||||||
primary: '#f8fafc',
|
primary: '#f8fafc',
|
||||||
secondary: '#e2e8f0',
|
secondary: '#e2e8f0',
|
||||||
hover: '#f1f5f9',
|
hover: '#f1f5f9',
|
||||||
},
|
},
|
||||||
black: {
|
black: {
|
||||||
primary: '#0f172a',
|
primary: '#0f172a',
|
||||||
secondary: '#1e293b',
|
secondary: '#1e293b',
|
||||||
hover: '#1e293b',
|
hover: '#1e293b',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,143 +1,143 @@
|
|||||||
/*
|
/*
|
||||||
# Initial Schema Setup
|
# Initial Schema Setup
|
||||||
|
|
||||||
1. New Tables
|
1. New Tables
|
||||||
- `profiles`
|
- `profiles`
|
||||||
- `id` (uuid, references auth.users)
|
- `id` (uuid, references auth.users)
|
||||||
- `username` (text, unique)
|
- `username` (text, unique)
|
||||||
- `theme_color` (text, enum)
|
- `theme_color` (text, enum)
|
||||||
- `created_at` (timestamptz)
|
- `created_at` (timestamptz)
|
||||||
- `updated_at` (timestamptz)
|
- `updated_at` (timestamptz)
|
||||||
- `collections`
|
- `collections`
|
||||||
- `id` (uuid)
|
- `id` (uuid)
|
||||||
- `user_id` (uuid, references profiles)
|
- `user_id` (uuid, references profiles)
|
||||||
- `card_id` (text)
|
- `card_id` (text)
|
||||||
- `quantity` (integer)
|
- `quantity` (integer)
|
||||||
- `created_at` (timestamptz)
|
- `created_at` (timestamptz)
|
||||||
- `updated_at` (timestamptz)
|
- `updated_at` (timestamptz)
|
||||||
- `decks`
|
- `decks`
|
||||||
- `id` (uuid)
|
- `id` (uuid)
|
||||||
- `user_id` (uuid, references profiles)
|
- `user_id` (uuid, references profiles)
|
||||||
- `name` (text)
|
- `name` (text)
|
||||||
- `format` (text)
|
- `format` (text)
|
||||||
- `created_at` (timestamptz)
|
- `created_at` (timestamptz)
|
||||||
- `updated_at` (timestamptz)
|
- `updated_at` (timestamptz)
|
||||||
- `deck_cards`
|
- `deck_cards`
|
||||||
- `id` (uuid)
|
- `id` (uuid)
|
||||||
- `deck_id` (uuid, references decks)
|
- `deck_id` (uuid, references decks)
|
||||||
- `card_id` (text)
|
- `card_id` (text)
|
||||||
- `quantity` (integer)
|
- `quantity` (integer)
|
||||||
- `is_commander` (boolean)
|
- `is_commander` (boolean)
|
||||||
|
|
||||||
2. Security
|
2. Security
|
||||||
- Enable RLS on all tables
|
- Enable RLS on all tables
|
||||||
- Add policies for authenticated users to manage their own data
|
- Add policies for authenticated users to manage their own data
|
||||||
*/
|
*/
|
||||||
|
|
||||||
-- Create profiles table
|
-- Create profiles table
|
||||||
CREATE TABLE public.profiles (
|
CREATE TABLE public.profiles (
|
||||||
id uuid PRIMARY KEY REFERENCES auth.users,
|
id uuid PRIMARY KEY REFERENCES auth.users,
|
||||||
username text UNIQUE,
|
username text UNIQUE,
|
||||||
theme_color text CHECK (theme_color IN ('red', 'green', 'blue', 'yellow', 'grey', 'purple')),
|
theme_color text CHECK (theme_color IN ('red', 'green', 'blue', 'yellow', 'grey', 'purple')),
|
||||||
created_at timestamptz DEFAULT now(),
|
created_at timestamptz DEFAULT now(),
|
||||||
updated_at timestamptz DEFAULT now()
|
updated_at timestamptz DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Create collections table
|
-- Create collections table
|
||||||
CREATE TABLE public.collections (
|
CREATE TABLE public.collections (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id uuid REFERENCES public.profiles(id) NOT NULL,
|
user_id uuid REFERENCES public.profiles(id) NOT NULL,
|
||||||
card_id text NOT NULL,
|
card_id text NOT NULL,
|
||||||
quantity integer DEFAULT 1,
|
quantity integer DEFAULT 1,
|
||||||
created_at timestamptz DEFAULT now(),
|
created_at timestamptz DEFAULT now(),
|
||||||
updated_at timestamptz DEFAULT now()
|
updated_at timestamptz DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Create decks table
|
-- Create decks table
|
||||||
CREATE TABLE public.decks (
|
CREATE TABLE public.decks (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id uuid REFERENCES public.profiles(id) NOT NULL,
|
user_id uuid REFERENCES public.profiles(id) NOT NULL,
|
||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
format text NOT NULL,
|
format text NOT NULL,
|
||||||
created_at timestamptz DEFAULT now(),
|
created_at timestamptz DEFAULT now(),
|
||||||
updated_at timestamptz DEFAULT now()
|
updated_at timestamptz DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Create deck_cards table
|
-- Create deck_cards table
|
||||||
CREATE TABLE public.deck_cards (
|
CREATE TABLE public.deck_cards (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
deck_id uuid REFERENCES public.decks(id) NOT NULL,
|
deck_id uuid REFERENCES public.decks(id) NOT NULL,
|
||||||
card_id text NOT NULL,
|
card_id text NOT NULL,
|
||||||
quantity integer DEFAULT 1,
|
quantity integer DEFAULT 1,
|
||||||
is_commander boolean DEFAULT false
|
is_commander boolean DEFAULT false
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Enable Row Level Security
|
-- Enable Row Level Security
|
||||||
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE public.collections ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE public.collections ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE public.decks ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE public.decks ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE public.deck_cards ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE public.deck_cards ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
-- Profiles policies
|
-- Profiles policies
|
||||||
CREATE POLICY "Users can view their own profile"
|
CREATE POLICY "Users can view their own profile"
|
||||||
ON public.profiles
|
ON public.profiles
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (auth.uid() = id);
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
CREATE POLICY "Users can update their own profile"
|
CREATE POLICY "Users can update their own profile"
|
||||||
ON public.profiles
|
ON public.profiles
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (auth.uid() = id);
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
-- Collections policies
|
-- Collections policies
|
||||||
CREATE POLICY "Users can view their own collection"
|
CREATE POLICY "Users can view their own collection"
|
||||||
ON public.collections
|
ON public.collections
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (user_id = auth.uid());
|
USING (user_id = auth.uid());
|
||||||
|
|
||||||
CREATE POLICY "Users can manage their own collection"
|
CREATE POLICY "Users can manage their own collection"
|
||||||
ON public.collections
|
ON public.collections
|
||||||
FOR ALL
|
FOR ALL
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (user_id = auth.uid());
|
USING (user_id = auth.uid());
|
||||||
|
|
||||||
-- Decks policies
|
-- Decks policies
|
||||||
CREATE POLICY "Users can view their own decks"
|
CREATE POLICY "Users can view their own decks"
|
||||||
ON public.decks
|
ON public.decks
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (user_id = auth.uid());
|
USING (user_id = auth.uid());
|
||||||
|
|
||||||
CREATE POLICY "Users can manage their own decks"
|
CREATE POLICY "Users can manage their own decks"
|
||||||
ON public.decks
|
ON public.decks
|
||||||
FOR ALL
|
FOR ALL
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (user_id = auth.uid());
|
USING (user_id = auth.uid());
|
||||||
|
|
||||||
-- Deck cards policies
|
-- Deck cards policies
|
||||||
CREATE POLICY "Users can view cards in their decks"
|
CREATE POLICY "Users can view cards in their decks"
|
||||||
ON public.deck_cards
|
ON public.deck_cards
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (
|
USING (
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM public.decks
|
SELECT 1 FROM public.decks
|
||||||
WHERE decks.id = deck_cards.deck_id
|
WHERE decks.id = deck_cards.deck_id
|
||||||
AND decks.user_id = auth.uid()
|
AND decks.user_id = auth.uid()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE POLICY "Users can manage cards in their decks"
|
CREATE POLICY "Users can manage cards in their decks"
|
||||||
ON public.deck_cards
|
ON public.deck_cards
|
||||||
FOR ALL
|
FOR ALL
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (
|
USING (
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM public.decks
|
SELECT 1 FROM public.decks
|
||||||
WHERE decks.id = deck_cards.deck_id
|
WHERE decks.id = deck_cards.deck_id
|
||||||
AND decks.user_id = auth.uid()
|
AND decks.user_id = auth.uid()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
background: '#0f172a',
|
background: '#0f172a',
|
||||||
primary: 'var(--color-primary)',
|
primary: 'var(--color-primary)',
|
||||||
secondary: 'var(--color-secondary)',
|
secondary: 'var(--color-secondary)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['lucide-react'],
|
exclude: ['lucide-react'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user