Initial commit from your app
This commit is contained in:
47
src/components/CardCarousel.tsx
Normal file
47
src/components/CardCarousel.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card } from '../types';
|
||||
import { getRandomCards } from '../services/api';
|
||||
|
||||
export default function CardCarousel() {
|
||||
const [cards, setCards] = useState<Card[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCards = async () => {
|
||||
try {
|
||||
const randomCards = await getRandomCards(6);
|
||||
setCards(randomCards);
|
||||
} catch (error) {
|
||||
console.error('Failed to load cards:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCards();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-pulse h-96 bg-gray-700/50 rounded-lg"></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-screen overflow-hidden">
|
||||
<div className="absolute inset-0 flex">
|
||||
{cards.map((card, index) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="min-w-full h-full transform transition-transform duration-1000"
|
||||
style={{
|
||||
backgroundImage: `url(${card.image_uris?.normal})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
filter: 'blur(8px)',
|
||||
opacity: 0.5
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
src/components/Collection.tsx
Normal file
121
src/components/Collection.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search, Plus } from 'lucide-react';
|
||||
import { Card } from '../types';
|
||||
import { searchCards } from '../services/api';
|
||||
|
||||
export default function Collection() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Card[]>([]);
|
||||
const [collection, setCollection] = useState<{ card: Card; quantity: number }[]>([]);
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
try {
|
||||
const cards = await searchCards(searchQuery);
|
||||
setSearchResults(cards);
|
||||
} catch (error) {
|
||||
console.error('Failed to search cards:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const addToCollection = (card: Card) => {
|
||||
setCollection(prev => {
|
||||
const existing = prev.find(c => c.card.id === card.id);
|
||||
if (existing) {
|
||||
return prev.map(c =>
|
||||
c.card.id === card.id
|
||||
? { ...c, quantity: c.quantity + 1 }
|
||||
: c
|
||||
);
|
||||
}
|
||||
return [...prev, { card, quantity: 1 }];
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">My Collection</h1>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2 mb-8">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Search cards to add..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Search size={20} />
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Search Results */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">Search Results</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{searchResults.map(card => (
|
||||
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
|
||||
{card.image_uris?.normal && (
|
||||
<img
|
||||
src={card.image_uris.normal}
|
||||
alt={card.name}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold mb-2">{card.name}</h3>
|
||||
<button
|
||||
onClick={() => addToCollection(card)}
|
||||
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Add to Collection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collection */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">My Cards</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{collection.map(({ card, quantity }) => (
|
||||
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
|
||||
{card.image_uris?.normal && (
|
||||
<img
|
||||
src={card.image_uris.normal}
|
||||
alt={card.name}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="font-bold">{card.name}</h3>
|
||||
<span className="text-sm bg-blue-600 px-2 py-1 rounded">
|
||||
x{quantity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/components/DeckBuilder.tsx
Normal file
120
src/components/DeckBuilder.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState } from "react";
|
||||
import { motion } from "motion/react"
|
||||
import { Save, Trash2 } from "lucide-react";
|
||||
|
||||
export default function DeckBuilder({
|
||||
deckName,
|
||||
setDeckName,
|
||||
deckFormat,
|
||||
setDeckFormat,
|
||||
selectedCards,
|
||||
updateCardQuantity,
|
||||
removeCardFromDeck,
|
||||
saveDeck,
|
||||
validation,
|
||||
initialDeck,
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Bouton d'ouverture du menu */}
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-4 right-4 bg-blue-600 text-white p-3 rounded-lg md:hidden"
|
||||
>
|
||||
Open Deck Builder
|
||||
</button>
|
||||
|
||||
{/* Menu latéral */}
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: isOpen ? "0%" : "100%" }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="fixed top-0 right-0 w-4/5 h-full bg-gray-800 p-6 shadow-lg md:static md:w-full md:h-auto md:p-6 md:shadow-none z-50"
|
||||
>
|
||||
{/* Bouton de fermeture */}
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-4 right-4 text-white"
|
||||
>
|
||||
✖
|
||||
</button>
|
||||
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={deckName}
|
||||
onChange={(e) => setDeckName(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||
placeholder="Deck Name"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={deckFormat}
|
||||
onChange={(e) => setDeckFormat(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||
>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="modern">Modern</option>
|
||||
<option value="commander">Commander</option>
|
||||
<option value="legacy">Legacy</option>
|
||||
<option value="vintage">Vintage</option>
|
||||
<option value="pauper">Pauper</option>
|
||||
</select>
|
||||
|
||||
{!validation.isValid && (
|
||||
<div className="bg-red-500/10 border border-red-500 rounded-lg p-3">
|
||||
<ul className="list-disc list-inside text-red-400 text-sm">
|
||||
{validation.errors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-xl mb-4">
|
||||
Cards ({selectedCards.reduce((acc, curr) => acc + curr.quantity, 0)})
|
||||
</h3>
|
||||
{selectedCards.map(({ card, quantity }) => (
|
||||
<div key={card.id} className="flex items-center gap-4 bg-gray-700 p-2 rounded-lg">
|
||||
<img
|
||||
src={card.image_uris?.art_crop}
|
||||
alt={card.name}
|
||||
className="w-12 h-12 rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{card.name}</h4>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={quantity}
|
||||
onChange={(e) => updateCardQuantity(card.id, parseInt(e.target.value))}
|
||||
min="1"
|
||||
max="4"
|
||||
className="w-16 px-2 py-1 bg-gray-600 border border-gray-500 rounded text-center"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeCardFromDeck(card.id)}
|
||||
className="text-red-500 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveDeck}
|
||||
disabled={!deckName.trim() || selectedCards.length === 0 || !validation.isValid}
|
||||
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save size={20} />
|
||||
{initialDeck ? "Update Deck" : "Save Deck"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/components/DeckCard.tsx
Normal file
71
src/components/DeckCard.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, Check, Edit } from 'lucide-react';
|
||||
import { Deck } from '../types';
|
||||
import { validateDeck } from '../utils/deckValidation';
|
||||
|
||||
interface DeckCardProps {
|
||||
deck: Deck;
|
||||
onEdit?: (deckId: string) => void;
|
||||
}
|
||||
|
||||
export default function DeckCard({ deck, onEdit }: DeckCardProps) {
|
||||
const validation = validateDeck(deck);
|
||||
const commander = deck.format === 'commander' ? deck.cards.find(card =>
|
||||
card.card.type_line?.toLowerCase().includes('legendary creature')
|
||||
)?.card : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-gray-800 rounded-xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1 cursor-pointer"
|
||||
onClick={() => onEdit?.(deck.id)}
|
||||
>
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<img
|
||||
src={commander?.image_uris?.normal || deck.cards[0]?.card.image_uris?.normal}
|
||||
alt={commander?.name || deck.cards[0]?.card.name}
|
||||
className="w-full object-cover object-top transform translate-y-[-12%]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xl font-bold text-white">{deck.name}</h3>
|
||||
{validation.isValid ? (
|
||||
<div className="flex items-center text-green-400">
|
||||
<Check size={16} className="mr-1" />
|
||||
<span className="text-sm">Legal</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-yellow-400" title={validation.errors.join(', ')}>
|
||||
<AlertTriangle size={16} className="mr-1" />
|
||||
<span className="text-sm">Issues</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-400">
|
||||
<span className="capitalize">{deck.format}</span>
|
||||
<span>{deck.cards.reduce((acc, curr) => acc + curr.quantity, 0)} cards</span>
|
||||
</div>
|
||||
|
||||
{commander && (
|
||||
<div className="mt-2 text-sm text-gray-300">
|
||||
<span className="text-blue-400">Commander:</span> {commander.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(deck.id);
|
||||
}}
|
||||
className="mt-4 w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2 text-white"
|
||||
>
|
||||
<Edit size={20} />
|
||||
Edit Deck
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/components/DeckEditor.tsx
Normal file
85
src/components/DeckEditor.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Deck } from '../types';
|
||||
import DeckManager from './DeckManager';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { getCardsByIds } from '../services/api';
|
||||
|
||||
interface DeckEditorProps {
|
||||
deckId: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function DeckEditor({ deckId, onClose }: DeckEditorProps) {
|
||||
const [deck, setDeck] = useState<Deck | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDeck = async () => {
|
||||
try {
|
||||
// Fetch deck data
|
||||
const { data: deckData, error: deckError } = await supabase
|
||||
.from('decks')
|
||||
.select('*')
|
||||
.eq('id', deckId)
|
||||
.single();
|
||||
|
||||
if (deckError) throw deckError;
|
||||
|
||||
// Fetch deck cards
|
||||
const { data: cardEntities, error: cardsError } = await supabase
|
||||
.from('deck_cards')
|
||||
.select('*')
|
||||
.eq('deck_id', deckId);
|
||||
|
||||
if (cardsError) throw cardsError;
|
||||
|
||||
// Fetch card details from Scryfall
|
||||
const cardIds = cardEntities.map(entity => entity.card_id);
|
||||
const uniqueCardIds = [...new Set(cardIds)];
|
||||
const scryfallCards = await getCardsByIds(uniqueCardIds);
|
||||
|
||||
// Combine deck data with card details
|
||||
const cards = cardEntities.map(entity => ({
|
||||
card: scryfallCards.find(c => c.id === entity.card_id) as Card,
|
||||
quantity: entity.quantity,
|
||||
}));
|
||||
|
||||
setDeck({
|
||||
...deckData,
|
||||
cards,
|
||||
createdAt: new Date(deckData.created_at),
|
||||
updatedAt: new Date(deckData.updated_at),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching deck:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDeck();
|
||||
}, [deckId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!deck) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4">
|
||||
<h2 className="text-xl font-bold text-red-500">Error</h2>
|
||||
<p className="text-red-400">Failed to load deck</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DeckManager initialDeck={deck} onSave={onClose} />;
|
||||
}
|
||||
80
src/components/DeckList.tsx
Normal file
80
src/components/DeckList.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getCardById, getCardsByIds } from '../services/api';
|
||||
import { Deck } from '../types';
|
||||
import { supabase } from "../lib/supabase";
|
||||
import DeckCard from "./DeckCard";
|
||||
|
||||
interface DeckListProps {
|
||||
onDeckEdit?: (deckId: string) => void;
|
||||
}
|
||||
|
||||
const DeckList = ({ onDeckEdit }: DeckListProps) => {
|
||||
const [decks, setDecks] = useState<Deck[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDecks = async () => {
|
||||
const { data: decksData, error: decksError } = await supabase.from('decks').select('*');
|
||||
if (decksError) {
|
||||
console.error('Error fetching decks:', decksError);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const decksWithCards = await Promise.all(decksData.map(async (deck) => {
|
||||
const { data: cardEntities, error: cardsError } = await supabase
|
||||
.from('deck_cards')
|
||||
.select('*')
|
||||
.eq('deck_id', deck.id);
|
||||
|
||||
if (cardsError) {
|
||||
console.error(`Error fetching cards for deck ${deck.id}:`, cardsError);
|
||||
return { ...deck, cards: [] };
|
||||
}
|
||||
|
||||
const cardIds = cardEntities.map((entity) => entity.card_id);
|
||||
const uniqueCardIds = [...new Set(cardIds)];
|
||||
|
||||
const scryfallCards = await getCardsByIds(uniqueCardIds);
|
||||
|
||||
const cards = cardEntities.map((entity) => {
|
||||
const card = scryfallCards.find((c) => c.id === entity.card_id);
|
||||
return {
|
||||
card,
|
||||
quantity: entity.quantity,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...deck,
|
||||
cards,
|
||||
createdAt: new Date(deck.created_at),
|
||||
updatedAt: new Date(deck.updated_at),
|
||||
};
|
||||
}));
|
||||
|
||||
setDecks(decksWithCards);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchDecks();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{decks.map((deck) => (
|
||||
<DeckCard key={deck.id} deck={deck} onEdit={onDeckEdit} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeckList;
|
||||
276
src/components/DeckManager.tsx
Normal file
276
src/components/DeckManager.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Search, Save, Trash2 } from 'lucide-react';
|
||||
import { Card, Deck } from '../types';
|
||||
import { searchCards } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { validateDeck } from '../utils/deckValidation';
|
||||
|
||||
interface DeckManagerProps {
|
||||
initialDeck?: Deck;
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Card[]>([]);
|
||||
const [selectedCards, setSelectedCards] = useState<{ card: Card; quantity: number }[]>(
|
||||
initialDeck?.cards || []
|
||||
);
|
||||
const [deckName, setDeckName] = useState(initialDeck?.name || '');
|
||||
const [deckFormat, setDeckFormat] = useState(initialDeck?.format || 'standard');
|
||||
const { user } = useAuth();
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
try {
|
||||
const cards = await searchCards(searchQuery);
|
||||
setSearchResults(cards);
|
||||
} catch (error) {
|
||||
console.error('Failed to search cards:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const addCardToDeck = (card: Card) => {
|
||||
setSelectedCards(prev => {
|
||||
const existing = prev.find(c => c.card.id === card.id);
|
||||
if (existing) {
|
||||
return prev.map(c =>
|
||||
c.card.id === card.id
|
||||
? { ...c, quantity: Math.min(c.quantity + 1, 4) }
|
||||
: c
|
||||
);
|
||||
}
|
||||
return [...prev, { card, quantity: 1 }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeCardFromDeck = (cardId: string) => {
|
||||
setSelectedCards(prev => prev.filter(c => c.card.id !== cardId));
|
||||
};
|
||||
|
||||
const updateCardQuantity = (cardId: string, quantity: number) => {
|
||||
setSelectedCards(prev =>
|
||||
prev.map(c =>
|
||||
c.card.id === cardId
|
||||
? { ...c, quantity: Math.max(1, Math.min(quantity, 4)) }
|
||||
: c
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const saveDeck = async () => {
|
||||
if (!deckName.trim() || selectedCards.length === 0 || !user) return;
|
||||
|
||||
const deckToSave: Deck = {
|
||||
id: initialDeck?.id || crypto.randomUUID(),
|
||||
name: deckName,
|
||||
format: deckFormat,
|
||||
cards: selectedCards,
|
||||
userId: user.id,
|
||||
createdAt: initialDeck?.createdAt || new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
const validation = validateDeck(deckToSave);
|
||||
if (!validation.isValid) {
|
||||
alert(`Deck validation failed: ${validation.errors.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deckData = {
|
||||
id: deckToSave.id,
|
||||
name: deckToSave.name,
|
||||
format: deckToSave.format,
|
||||
user_id: deckToSave.userId,
|
||||
created_at: deckToSave.createdAt,
|
||||
updated_at: deckToSave.updatedAt
|
||||
};
|
||||
|
||||
// Save or update the deck
|
||||
const { error: deckError } = await supabase
|
||||
.from('decks')
|
||||
.upsert([deckData])
|
||||
.select();
|
||||
|
||||
if (deckError) throw deckError;
|
||||
|
||||
// Delete existing cards if updating
|
||||
if (initialDeck) {
|
||||
await supabase
|
||||
.from('deck_cards')
|
||||
.delete()
|
||||
.eq('deck_id', initialDeck.id);
|
||||
}
|
||||
|
||||
// Save the deck cards
|
||||
const deckCards = selectedCards.map(card => ({
|
||||
deck_id: deckToSave.id,
|
||||
card_id: card.card.id,
|
||||
quantity: card.quantity,
|
||||
is_commander: card.card.type_line?.toLowerCase().includes('legendary creature') || false
|
||||
}));
|
||||
|
||||
const { error: cardsError } = await supabase
|
||||
.from('deck_cards')
|
||||
.insert(deckCards);
|
||||
|
||||
if (cardsError) throw cardsError;
|
||||
|
||||
if (onSave) onSave();
|
||||
} catch (error) {
|
||||
console.error('Error saving deck:', error);
|
||||
alert('Failed to save deck');
|
||||
}
|
||||
};
|
||||
|
||||
const currentDeck: Deck = {
|
||||
id: initialDeck?.id || '',
|
||||
name: deckName,
|
||||
format: deckFormat,
|
||||
cards: selectedCards,
|
||||
userId: user?.id || '',
|
||||
createdAt: initialDeck?.createdAt || new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
const validation = validateDeck(currentDeck);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Card Search Section */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||
placeholder="Search for cards..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Search size={20} />
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{searchResults.map(card => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all"
|
||||
>
|
||||
{card.image_uris?.normal && (
|
||||
<img
|
||||
src={card.image_uris.normal}
|
||||
alt={card.name}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold mb-2">{card.name}</h3>
|
||||
<button
|
||||
onClick={() => addCardToDeck(card)}
|
||||
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Add to Deck
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deck Builder Section */}
|
||||
<div className="bg-gray-800 rounded-lg p-6">
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={deckName}
|
||||
onChange={(e) => setDeckName(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||
placeholder="Deck Name"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={deckFormat}
|
||||
onChange={(e) => setDeckFormat(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||
>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="modern">Modern</option>
|
||||
<option value="commander">Commander</option>
|
||||
<option value="legacy">Legacy</option>
|
||||
<option value="vintage">Vintage</option>
|
||||
<option value="pauper">Pauper</option>
|
||||
</select>
|
||||
|
||||
{!validation.isValid && (
|
||||
<div className="bg-red-500/10 border border-red-500 rounded-lg p-3">
|
||||
<ul className="list-disc list-inside text-red-400 text-sm">
|
||||
{validation.errors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-xl mb-4">
|
||||
Cards ({selectedCards.reduce((acc, curr) => acc + curr.quantity, 0)})
|
||||
</h3>
|
||||
{selectedCards.map(({ card, quantity }) => (
|
||||
<div key={card.id} className="flex items-center gap-4 bg-gray-700 p-2 rounded-lg">
|
||||
<img
|
||||
src={card.image_uris?.art_crop}
|
||||
alt={card.name}
|
||||
className="w-12 h-12 rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{card.name}</h4>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={quantity}
|
||||
onChange={(e) => updateCardQuantity(card.id, parseInt(e.target.value))}
|
||||
min="1"
|
||||
max="4"
|
||||
className="w-16 px-2 py-1 bg-gray-600 border border-gray-500 rounded text-center"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeCardFromDeck(card.id)}
|
||||
className="text-red-500 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={20}/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveDeck}
|
||||
disabled={!deckName.trim() || selectedCards.length === 0 || !validation.isValid}
|
||||
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save size={20} />
|
||||
{initialDeck ? 'Update Deck' : 'Save Deck'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
src/components/LoginForm.tsx
Normal file
147
src/components/LoginForm.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Mail, Lock, LogIn } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Card } from '../types';
|
||||
import { getRandomCards } from '../services/api';
|
||||
|
||||
export default function LoginForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSignUp, setIsSignUp] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { signIn, signUp } = useAuth();
|
||||
const [cards, setCards] = useState<Card[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCards = async () => {
|
||||
try {
|
||||
const randomCards = await getRandomCards(6);
|
||||
setCards(randomCards);
|
||||
} catch (error) {
|
||||
console.error('Failed to load cards:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCards();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (isSignUp) {
|
||||
await signUp(email, password);
|
||||
} else {
|
||||
await signIn(email, password);
|
||||
}
|
||||
window.location.href = '/'; // Redirect to home after successful login
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-pulse h-96 bg-gray-700/50 rounded-lg"></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex items-center justify-center p-6 overflow-hidden">
|
||||
{/* Animated Background */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="flex animate-slide"
|
||||
style={{
|
||||
width: `${cards.length * 100}%`,
|
||||
animation: 'slide 60s linear infinite'
|
||||
}}
|
||||
>
|
||||
{[...cards, ...cards].map((card, index) => (
|
||||
<div
|
||||
key={`${card.id}-${index}`}
|
||||
className="relative w-full h-screen"
|
||||
style={{
|
||||
width: `${100 / (cards.length * 2)}%`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transform transition-transform duration-1000"
|
||||
style={{
|
||||
backgroundImage: `url(${card.image_uris?.normal})`,
|
||||
filter: 'blur(8px) brightness(0.4)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="relative z-10 bg-gray-900/80 p-8 rounded-lg shadow-xl backdrop-blur-sm w-full max-w-md">
|
||||
<h2 className="text-3xl font-bold text-orange-500 mb-6 text-center">
|
||||
Deckerr
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/10 border border-red-500 rounded text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-800/50 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-800/50 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-lg transition duration-200"
|
||||
>
|
||||
<LogIn size={20} />
|
||||
{isSignUp ? 'Sign Up' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => setIsSignUp(!isSignUp)}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{isSignUp ? 'Already have an account? Sign In' : 'Need an account? Sign Up'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
src/components/Navigation.tsx
Normal file
175
src/components/Navigation.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Home, PlusSquare, Library, LogOut, Settings, ChevronDown } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
type Page = 'home' | 'deck' | 'login' | 'collection' | 'profile';
|
||||
|
||||
interface NavigationProps {
|
||||
currentPage: Page;
|
||||
setCurrentPage: (page: Page) => void;
|
||||
}
|
||||
|
||||
export default function Navigation({ currentPage, setCurrentPage }: NavigationProps) {
|
||||
const { user, signOut } = useAuth();
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
if (user) {
|
||||
const { data } = await supabase
|
||||
.from('profiles')
|
||||
.select('username')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setUsername(data.username);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchProfile();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const navItems = [
|
||||
{ id: 'home' as const, label: 'Home', icon: Home },
|
||||
{ id: 'deck' as const, label: 'New Deck', icon: PlusSquare },
|
||||
{ id: 'collection' as const, label: 'Collection', icon: Library },
|
||||
];
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
setCurrentPage('login');
|
||||
} catch (error) {
|
||||
console.error('Error signing out:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getAvatarUrl = (userId: string) => {
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}&backgroundColor=b6e3f4,c0aede,d1d4f9`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Navigation - Top */}
|
||||
<nav className="hidden md:block fixed top-0 left-0 right-0 bg-gray-800 border-b border-gray-700 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center space-x-8">
|
||||
<span className="text-2xl font-bold text-orange-500">Deckerr</span>
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setCurrentPage(item.id)}
|
||||
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors
|
||||
${currentPage === item.id
|
||||
? 'text-white bg-gray-900'
|
||||
: 'text-gray-300 hover:text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center space-x-3 px-3 py-2 rounded-md hover:bg-gray-700"
|
||||
>
|
||||
<img
|
||||
src={getAvatarUrl(user.id)}
|
||||
alt="User avatar"
|
||||
className="w-8 h-8 rounded-full bg-gray-700"
|
||||
/>
|
||||
<span className="text-gray-300 text-sm">{username || user.email}</span>
|
||||
<ChevronDown size={16} className="text-gray-400" />
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-gray-800 rounded-md shadow-lg py-1 border border-gray-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentPage('profile');
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<span>Profile Settings</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Navigation - Bottom */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-800 border-t border-gray-700 z-50">
|
||||
<div className="grid grid-cols-4 h-16">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setCurrentPage(item.id)}
|
||||
className={`flex flex-col items-center justify-center space-y-1
|
||||
${currentPage === item.id
|
||||
? 'text-white bg-gray-900'
|
||||
: 'text-gray-300 hover:text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span className="text-xs">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex flex-col items-center justify-center space-y-1 text-gray-300 hover:text-white hover:bg-gray-700"
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={getAvatarUrl(user.id)}
|
||||
alt="User avatar"
|
||||
className="w-8 h-8 rounded-full bg-gray-700"
|
||||
/>
|
||||
<Settings size={14} className="absolute -bottom-1 -right-1 bg-gray-800 rounded-full p-0.5" />
|
||||
</div>
|
||||
<span className="text-xs">Profile</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content Padding */}
|
||||
<div className="md:pt-16 pb-16 md:pb-0" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
src/components/Profile.tsx
Normal file
128
src/components/Profile.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Save } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const THEME_COLORS = ['red', 'green', 'blue', 'yellow', 'grey', 'purple'];
|
||||
|
||||
export default function Profile() {
|
||||
const { user } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [themeColor, setThemeColor] = useState('blue');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadProfile = async () => {
|
||||
if (user) {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('username, theme_color')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setUsername(data.username || '');
|
||||
setThemeColor(data.theme_color || 'blue');
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProfile();
|
||||
}, [user]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('profiles')
|
||||
.upsert({
|
||||
id: user.id,
|
||||
username,
|
||||
theme_color: themeColor,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
alert('Profile updated successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
alert('Failed to update profile');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">Profile Settings</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Theme Color
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{THEME_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setThemeColor(color)}
|
||||
className={`h-12 rounded-lg border-2 transition-all capitalize
|
||||
${themeColor === color
|
||||
? 'border-white scale-105'
|
||||
: 'border-transparent hover:border-gray-600'
|
||||
}`}
|
||||
style={{ backgroundColor: `var(--color-${color}-primary)` }}
|
||||
>
|
||||
{color}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg transition duration-200"
|
||||
>
|
||||
{saving ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
||||
) : (
|
||||
<>
|
||||
<Save size={20} />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user