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