2025-07-29 15:19:48 -04:00
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
|
|
|
|
import { useRouter } from 'next/router';
|
|
|
|
|
|
import Link from 'next/link';
|
|
|
|
|
|
import Layout from '../components/Layout';
|
|
|
|
|
|
import { ManaCost, ColorIdentity, ColorFilterSymbol } from '../components/ManaSymbols';
|
|
|
|
|
|
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
|
|
|
|
|
import { useAuth } from '../lib/auth-context';
|
|
|
|
|
|
import { getColorIdentity, getColorSymbol } from '../lib/mana-symbols';
|
|
|
|
|
|
|
|
|
|
|
|
export default function DeckBuilder() {
|
|
|
|
|
|
const { user } = useAuth();
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const { deck: deckId } = router.query;
|
|
|
|
|
|
|
|
|
|
|
|
const [deck, setDeck] = useState(null);
|
|
|
|
|
|
const [deckCards, setDeckCards] = useState([]);
|
|
|
|
|
|
const [searchResults, setSearchResults] = useState([]);
|
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [searchLoading, setSearchLoading] = useState(false);
|
|
|
|
|
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
|
|
|
|
const [selectedCard, setSelectedCard] = useState(null);
|
|
|
|
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
|
|
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
|
|
|
|
const [viewMode, setViewMode] = useState('list'); // 'list' or 'thumbnail'
|
|
|
|
|
|
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
|
|
|
|
|
const [filters, setFilters] = useState({
|
|
|
|
|
|
colors: [],
|
|
|
|
|
|
types: [],
|
|
|
|
|
|
cmc: '',
|
|
|
|
|
|
rarity: ''
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const searchTimeoutRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (user && deckId) {
|
|
|
|
|
|
fetchDeck();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [user, deckId]);
|
|
|
|
|
|
|
|
|
|
|
|
// Load initial cards when component mounts
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (user) {
|
|
|
|
|
|
searchCards();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [user]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (searchTimeoutRef.current) {
|
|
|
|
|
|
clearTimeout(searchTimeoutRef.current);
|
|
|
|
|
|
}
|
|
|
|
|
|
searchTimeoutRef.current = setTimeout(() => {
|
|
|
|
|
|
searchCards();
|
|
|
|
|
|
}, 300);
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (searchTimeoutRef.current) {
|
|
|
|
|
|
clearTimeout(searchTimeoutRef.current);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [searchQuery, filters]);
|
|
|
|
|
|
|
|
|
|
|
|
const fetchDeck = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const token = localStorage.getItem('auth_token');
|
|
|
|
|
|
const response = await fetch(`/api/decks/${deckId}`, {
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Authorization': `Bearer ${token}`
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
setDeck(data);
|
|
|
|
|
|
setDeckCards(data.cards || []);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('Failed to fetch deck');
|
|
|
|
|
|
router.push('/decks');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error fetching deck:', error);
|
|
|
|
|
|
router.push('/decks');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const searchCards = async () => {
|
|
|
|
|
|
setSearchLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const token = localStorage.getItem('auth_token');
|
|
|
|
|
|
const params = new URLSearchParams({
|
|
|
|
|
|
game: 'MTG',
|
|
|
|
|
|
limit: '50'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Only add search if there's a query
|
|
|
|
|
|
if (searchQuery.trim()) {
|
|
|
|
|
|
params.append('search', searchQuery);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (filters.colors.length > 0) {
|
|
|
|
|
|
params.append('colors', filters.colors.join(','));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (filters.types.length > 0) {
|
|
|
|
|
|
params.append('types', filters.types.join(','));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (filters.cmc) {
|
|
|
|
|
|
params.append('cmc', filters.cmc);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (filters.rarity) {
|
|
|
|
|
|
params.append('rarity', filters.rarity);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const response = await fetch(`/api/cards/search?${params}`, {
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Authorization': `Bearer ${token}`
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
setSearchResults(data.cards || []);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error searching cards:', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSearchLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addCardToDeck = async (card, quantity = 1) => {
|
|
|
|
|
|
// Commander format validation
|
|
|
|
|
|
if (deck.format === 'Commander') {
|
|
|
|
|
|
const existingCard = deckCards.find(dc => dc.card_id === card.id);
|
|
|
|
|
|
const currentQuantity = existingCard ? existingCard.quantity : 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Check singleton rule (except basic lands)
|
|
|
|
|
|
if (!isBasicLand(card) && currentQuantity + quantity > 1) {
|
|
|
|
|
|
alert('Commander format allows only 1 copy of each non-basic land card.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check total deck size
|
|
|
|
|
|
const totalCards = deckCards.reduce((sum, dc) => sum + dc.quantity, 0);
|
|
|
|
|
|
if (totalCards + quantity > 100) {
|
|
|
|
|
|
alert('Commander decks can have a maximum of 100 cards.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const token = localStorage.getItem('auth_token');
|
|
|
|
|
|
const response = await fetch(`/api/decks/${deckId}/cards`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'Authorization': `Bearer ${token}`
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
cardId: card.id,
|
|
|
|
|
|
quantity
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
// Refresh deck cards
|
|
|
|
|
|
fetchDeck();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('Failed to add card to deck');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error adding card to deck:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const removeCardFromDeck = async (cardId, quantity = 1) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const token = localStorage.getItem('auth_token');
|
|
|
|
|
|
const response = await fetch(`/api/decks/${deckId}/cards`, {
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'Authorization': `Bearer ${token}`
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
cardId,
|
|
|
|
|
|
quantity
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
// Refresh deck cards
|
|
|
|
|
|
fetchDeck();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('Failed to remove card from deck');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error removing card from deck:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const isBasicLand = (card) => {
|
|
|
|
|
|
const basicLands = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest'];
|
|
|
|
|
|
return basicLands.includes(card.name);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getDeckStats = () => {
|
|
|
|
|
|
const totalCards = deckCards.reduce((sum, card) => sum + card.quantity, 0);
|
|
|
|
|
|
const avgCmc = deckCards.length > 0
|
|
|
|
|
|
? (deckCards.reduce((sum, card) => sum + (card.cmc || 0) * card.quantity, 0) / totalCards).toFixed(1)
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
|
|
|
|
|
const colorCounts = deckCards.reduce((counts, card) => {
|
|
|
|
|
|
if (card.colors) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const colors = JSON.parse(card.colors);
|
|
|
|
|
|
colors.forEach(color => {
|
|
|
|
|
|
counts[color] = (counts[color] || 0) + card.quantity;
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// Handle non-JSON color format
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return counts;
|
|
|
|
|
|
}, {});
|
|
|
|
|
|
|
|
|
|
|
|
const typeCounts = deckCards.reduce((counts, card) => {
|
|
|
|
|
|
if (card.card_type) {
|
|
|
|
|
|
const types = card.card_type.split(' — ')[0].split(' ');
|
|
|
|
|
|
types.forEach(type => {
|
|
|
|
|
|
counts[type] = (counts[type] || 0) + card.quantity;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return counts;
|
|
|
|
|
|
}, {});
|
|
|
|
|
|
|
|
|
|
|
|
return { totalCards, avgCmc, colorCounts, typeCounts };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const toggleColorFilter = (color) => {
|
|
|
|
|
|
setFilters(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
colors: prev.colors.includes(color)
|
|
|
|
|
|
? prev.colors.filter(c => c !== color)
|
|
|
|
|
|
: [...prev.colors, color]
|
|
|
|
|
|
}));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const clearFilters = () => {
|
|
|
|
|
|
setFilters({
|
|
|
|
|
|
colors: [],
|
|
|
|
|
|
types: [],
|
|
|
|
|
|
cmc: '',
|
|
|
|
|
|
rarity: ''
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
|
return (
|
2026-05-24 11:05:17 -04:00
|
|
|
|
<Layout user={user}>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<h1 className="text-2xl font-bold mb-4">Please log in to use the deck builder</h1>
|
|
|
|
|
|
<Link href="/login" className="text-accent-ember hover:underline">
|
|
|
|
|
|
Go to Login
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
2026-05-24 11:05:17 -04:00
|
|
|
|
<Layout user={user}>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
|
|
|
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-accent-ember"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!deck) {
|
|
|
|
|
|
return (
|
2026-05-24 11:05:17 -04:00
|
|
|
|
<Layout user={user}>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<h1 className="text-2xl font-bold mb-4">Deck not found</h1>
|
|
|
|
|
|
<Link href="/decks" className="text-accent-ember hover:underline">
|
|
|
|
|
|
Back to My Decks
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const stats = getDeckStats();
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-24 11:05:17 -04:00
|
|
|
|
<Layout user={user}>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
|
|
|
|
{/* Header */}
|
|
|
|
|
|
<div className="flex justify-between items-center mb-6">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="flex items-center space-x-3">
|
|
|
|
|
|
<Link href="/decks" className="text-accent-ember hover:underline">
|
|
|
|
|
|
← Back to Decks
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h1 className="text-3xl font-bold text-text-primary mt-2">{deck.name}</h1>
|
|
|
|
|
|
<p className="text-text-secondary">
|
|
|
|
|
|
{deck.format} • {stats.totalCards}/100 cards
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex space-x-3">
|
|
|
|
|
|
<button className="bg-bg-secondary text-text-primary px-4 py-2 rounded-lg hover:bg-bg-tertiary transition-colors">
|
|
|
|
|
|
Save Deck
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<Link
|
|
|
|
|
|
href={`/deck/${deck.id}`}
|
|
|
|
|
|
className="bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
View Deck
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-6 h-[calc(100vh-12rem)]">
|
|
|
|
|
|
{/* Main Deck View - Left Side */}
|
|
|
|
|
|
<div className={`flex-1 transition-all duration-300`}>
|
|
|
|
|
|
<div className="bg-bg-secondary rounded-lg p-6 h-full flex flex-col">
|
|
|
|
|
|
<div className="flex justify-between items-center mb-6">
|
|
|
|
|
|
<h2 className="text-xl font-semibold text-text-primary">
|
|
|
|
|
|
Deck Cards ({stats.totalCards})
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
|
|
|
|
|
className="bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors flex items-center space-x-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>{sidebarOpen ? 'Hide' : 'Show'} Browser</span>
|
|
|
|
|
|
<span>{sidebarOpen ? '→' : '←'}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Deck Stats Bar */}
|
|
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6 p-4 bg-bg-primary rounded-lg">
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="text-lg font-bold text-text-primary">{stats.totalCards}/100</div>
|
|
|
|
|
|
<div className="text-text-secondary text-sm">Cards</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="text-lg font-bold text-text-primary">{stats.avgCmc}</div>
|
|
|
|
|
|
<div className="text-text-secondary text-sm">Avg CMC</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="text-lg font-bold text-text-primary">
|
|
|
|
|
|
{Object.keys(stats.colorCounts).length}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-text-secondary text-sm">Colors</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="text-lg font-bold text-text-primary">
|
|
|
|
|
|
{Object.keys(stats.typeCounts).length}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-text-secondary text-sm">Types</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Deck Cards List */}
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto space-y-2">
|
|
|
|
|
|
{deckCards.length === 0 ? (
|
|
|
|
|
|
<div className="text-center py-12">
|
|
|
|
|
|
<div className="text-6xl mb-4">🃏</div>
|
|
|
|
|
|
<h3 className="text-xl font-semibold text-text-primary mb-2">Empty Deck</h3>
|
|
|
|
|
|
<p className="text-text-secondary mb-4">Start building your deck by searching for cards</p>
|
|
|
|
|
|
{!sidebarOpen && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setSidebarOpen(true)}
|
|
|
|
|
|
className="bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
Open Card Browser
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
deckCards
|
|
|
|
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
|
|
|
|
.map((card) => (
|
|
|
|
|
|
<div key={`${card.card_id}-${card.id}`} className="flex items-center justify-between p-4 bg-bg-primary rounded-lg hover:bg-bg-tertiary transition-colors">
|
|
|
|
|
|
<div className="flex items-center space-x-4">
|
|
|
|
|
|
{card.image_url && (
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={card.image_url}
|
|
|
|
|
|
alt={card.name}
|
|
|
|
|
|
className="w-14 h-20 object-cover rounded shadow-md"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h4 className="font-semibold text-text-primary text-lg">{card.name}</h4>
|
|
|
|
|
|
<p className="text-text-secondary text-sm">{card.set_name}</p>
|
|
|
|
|
|
<div className="flex items-center space-x-3 mt-1">
|
|
|
|
|
|
{card.mana_cost && (
|
|
|
|
|
|
<div className="bg-bg-secondary px-2 py-1 rounded">
|
|
|
|
|
|
<ManaCost cost={card.mana_cost} size="sm" useSVG={manaSymbolSettings.useSVG} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{card.rarity && (
|
|
|
|
|
|
<span className={`text-xs px-2 py-1 rounded capitalize ${
|
|
|
|
|
|
card.rarity === 'mythic' ? 'bg-orange-100 text-orange-800' :
|
|
|
|
|
|
card.rarity === 'rare' ? 'bg-yellow-100 text-yellow-800' :
|
|
|
|
|
|
card.rarity === 'uncommon' ? 'bg-gray-100 text-gray-800' :
|
|
|
|
|
|
'bg-green-100 text-green-800'
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
{card.rarity}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center space-x-3">
|
|
|
|
|
|
<span className="text-text-primary font-bold text-lg">{card.quantity}x</span>
|
|
|
|
|
|
<div className="flex items-center space-x-1">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => removeCardFromDeck(card.card_id, 1)}
|
|
|
|
|
|
className="w-8 h-8 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors flex items-center justify-center font-bold"
|
|
|
|
|
|
>
|
|
|
|
|
|
−
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => addCardToDeck(card, 1)}
|
|
|
|
|
|
className="w-8 h-8 bg-accent-ember text-white rounded-full hover:bg-accent-ember-dark transition-colors flex items-center justify-center font-bold"
|
|
|
|
|
|
>
|
|
|
|
|
|
+
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Full Height Right Sidebar - Card Browser */}
|
|
|
|
|
|
<div className={`transition-all duration-300 ${sidebarOpen ? 'w-96' : 'w-0 overflow-hidden'}`}>
|
|
|
|
|
|
<div className="bg-bg-tertiary rounded-lg h-full flex flex-col relative border border-border">
|
|
|
|
|
|
{!selectedCard ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{/* Header */}
|
|
|
|
|
|
<div className="flex justify-between items-center p-4 border-b border-border bg-bg-secondary rounded-t-lg">
|
|
|
|
|
|
<h3 className="text-lg font-semibold text-text-primary">Card Browser</h3>
|
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
|
{/* View Mode Toggle */}
|
|
|
|
|
|
<div className="flex bg-bg-primary rounded-lg p-1">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setViewMode('list')}
|
|
|
|
|
|
className={`px-2 py-1 rounded text-xs transition-colors ${
|
|
|
|
|
|
viewMode === 'list'
|
|
|
|
|
|
? 'bg-accent-ember text-white'
|
|
|
|
|
|
: 'text-text-secondary hover:text-text-primary'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
title="List View"
|
|
|
|
|
|
>
|
|
|
|
|
|
☰
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setViewMode('thumbnail')}
|
|
|
|
|
|
className={`px-2 py-1 rounded text-xs transition-colors ${
|
|
|
|
|
|
viewMode === 'thumbnail'
|
|
|
|
|
|
? 'bg-accent-ember text-white'
|
|
|
|
|
|
: 'text-text-secondary hover:text-text-primary'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
title="Thumbnail View"
|
|
|
|
|
|
>
|
|
|
|
|
|
⊞
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setShowSettings(!showSettings)}
|
|
|
|
|
|
className={`text-text-secondary hover:text-text-primary transition-colors p-1 ${
|
|
|
|
|
|
showSettings ? 'text-accent-ember' : ''
|
|
|
|
|
|
}`}
|
|
|
|
|
|
title="Settings"
|
|
|
|
|
|
>
|
|
|
|
|
|
⚙️
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setSidebarOpen(false)}
|
|
|
|
|
|
className="text-text-secondary hover:text-text-primary transition-colors p-1"
|
|
|
|
|
|
title="Collapse"
|
|
|
|
|
|
>
|
|
|
|
|
|
→
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Search */}
|
|
|
|
|
|
<div className="p-4 border-b border-border">
|
|
|
|
|
|
<div className="flex space-x-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
|
|
|
|
placeholder="Search for cards..."
|
|
|
|
|
|
className="flex-1 px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-primary text-text-primary text-sm"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
|
|
|
|
className={`p-2 rounded-lg transition-colors ${
|
|
|
|
|
|
showFilters || filters.colors.length > 0 || filters.cmc || filters.rarity
|
|
|
|
|
|
? 'bg-accent-ember text-white'
|
|
|
|
|
|
: 'bg-bg-primary text-text-secondary hover:bg-bg-tertiary'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
title="Filters"
|
|
|
|
|
|
>
|
|
|
|
|
|
🔍
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Settings Panel */}
|
|
|
|
|
|
{showSettings && (
|
|
|
|
|
|
<div className="p-4 border-b border-border bg-bg-primary">
|
|
|
|
|
|
<ManaSymbolSettings onSettingsChange={setManaSymbolSettings} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Quick Filters */}
|
|
|
|
|
|
{showFilters && (
|
|
|
|
|
|
<div className="p-4 border-b border-border bg-bg-primary space-y-3">
|
|
|
|
|
|
{/* Color Filters */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
|
|
|
|
|
<label className="text-text-secondary text-xs font-medium">Colors</label>
|
|
|
|
|
|
{(filters.colors.length > 0 || filters.cmc || filters.rarity) && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={clearFilters}
|
|
|
|
|
|
className="text-xs text-accent-ember hover:underline"
|
|
|
|
|
|
>
|
|
|
|
|
|
Clear All
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex space-x-1">
|
|
|
|
|
|
{['W', 'U', 'B', 'R', 'G'].map(color => (
|
|
|
|
|
|
<ColorFilterSymbol
|
|
|
|
|
|
key={color}
|
|
|
|
|
|
color={color}
|
|
|
|
|
|
isActive={filters.colors.includes(color)}
|
|
|
|
|
|
onClick={toggleColorFilter}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
useSVG={manaSymbolSettings.useSVG}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* CMC and Rarity */}
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-text-secondary text-xs font-medium mb-1">CMC</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={filters.cmc}
|
|
|
|
|
|
onChange={(e) => setFilters({...filters, cmc: e.target.value})}
|
|
|
|
|
|
className="w-full px-2 py-1 border border-border rounded text-xs focus:outline-none focus:ring-1 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">Any</option>
|
|
|
|
|
|
<option value="0">0</option>
|
|
|
|
|
|
<option value="1">1</option>
|
|
|
|
|
|
<option value="2">2</option>
|
|
|
|
|
|
<option value="3">3</option>
|
|
|
|
|
|
<option value="4">4</option>
|
|
|
|
|
|
<option value="5">5</option>
|
|
|
|
|
|
<option value="6+">6+</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-text-secondary text-xs font-medium mb-1">Rarity</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={filters.rarity}
|
|
|
|
|
|
onChange={(e) => setFilters({...filters, rarity: e.target.value})}
|
|
|
|
|
|
className="w-full px-2 py-1 border border-border rounded text-xs focus:outline-none focus:ring-1 focus:ring-accent-ember bg-bg-secondary text-text-primary"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">Any</option>
|
|
|
|
|
|
<option value="common">Common</option>
|
|
|
|
|
|
<option value="uncommon">Uncommon</option>
|
|
|
|
|
|
<option value="rare">Rare</option>
|
|
|
|
|
|
<option value="mythic">Mythic</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Card List */}
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
|
|
|
|
{viewMode === 'list' ? (
|
|
|
|
|
|
/* List View */
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
{searchLoading ? (
|
|
|
|
|
|
<div className="text-center py-8">
|
|
|
|
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-accent-ember mx-auto"></div>
|
|
|
|
|
|
<p className="text-text-secondary mt-2 text-xs">Searching...</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : searchResults.length === 0 ? (
|
|
|
|
|
|
<div className="text-center py-8">
|
|
|
|
|
|
<div className="text-2xl mb-2">🔍</div>
|
|
|
|
|
|
<p className="text-text-secondary text-xs">
|
|
|
|
|
|
{searchQuery ? 'No cards found' : 'No cards available'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
searchResults.map((card) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={card.id}
|
|
|
|
|
|
className="flex items-center p-2 bg-bg-secondary rounded hover:bg-bg-primary transition-colors cursor-pointer"
|
|
|
|
|
|
onClick={() => setSelectedCard(card)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
|
|
|
|
|
{card.image_url && (
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={card.image_url}
|
|
|
|
|
|
alt={card.name}
|
|
|
|
|
|
className="w-8 h-11 object-cover rounded flex-shrink-0"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<h4 className="font-medium text-text-primary text-xs truncate">{card.name}</h4>
|
|
|
|
|
|
<p className="text-text-secondary text-xs truncate">{card.set_name}</p>
|
|
|
|
|
|
<div className="flex items-center space-x-1">
|
|
|
|
|
|
{card.mana_cost && (
|
|
|
|
|
|
<ManaCost cost={card.mana_cost} size="xs" useSVG={manaSymbolSettings.useSVG} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
{card.rarity && (
|
|
|
|
|
|
<span className={`text-xs px-1 rounded capitalize ${
|
|
|
|
|
|
card.rarity === 'mythic' ? 'bg-orange-100 text-orange-800' :
|
|
|
|
|
|
card.rarity === 'rare' ? 'bg-yellow-100 text-yellow-800' :
|
|
|
|
|
|
card.rarity === 'uncommon' ? 'bg-gray-100 text-gray-800' :
|
|
|
|
|
|
'bg-green-100 text-green-800'
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
{card.rarity[0].toUpperCase()}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
/* Thumbnail View */
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{searchLoading ? (
|
|
|
|
|
|
<div className="text-center py-8">
|
|
|
|
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-accent-ember mx-auto"></div>
|
|
|
|
|
|
<p className="text-text-secondary mt-2 text-xs">Searching...</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : searchResults.length === 0 ? (
|
|
|
|
|
|
<div className="text-center py-8">
|
|
|
|
|
|
<div className="text-2xl mb-2">🔍</div>
|
|
|
|
|
|
<p className="text-text-secondary text-xs">
|
|
|
|
|
|
{searchQuery ? 'No cards found' : 'No cards available'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="grid grid-cols-3 gap-2">
|
|
|
|
|
|
{searchResults.map((card) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={card.id}
|
|
|
|
|
|
className="relative group cursor-pointer"
|
|
|
|
|
|
onClick={() => setSelectedCard(card)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{card.image_url ? (
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={card.image_url}
|
|
|
|
|
|
alt={card.name}
|
|
|
|
|
|
className="w-full aspect-[2.5/3.5] object-cover rounded-lg shadow-sm group-hover:shadow-md transition-shadow"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{/* Hover overlay with card name */}
|
|
|
|
|
|
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-60 transition-all duration-200 rounded-lg flex items-end">
|
|
|
|
|
|
<div className="p-2 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
|
|
|
|
|
<p className="text-xs font-medium truncate">{card.name}</p>
|
|
|
|
|
|
<p className="text-xs opacity-75 truncate">{card.set_name}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/* Rarity indicator */}
|
|
|
|
|
|
{card.rarity && (
|
|
|
|
|
|
<div className={`absolute top-1 right-1 w-2 h-2 rounded-full ${
|
|
|
|
|
|
card.rarity === 'mythic' ? 'bg-orange-500' :
|
|
|
|
|
|
card.rarity === 'rare' ? 'bg-yellow-500' :
|
|
|
|
|
|
card.rarity === 'uncommon' ? 'bg-gray-400' :
|
|
|
|
|
|
'bg-green-500'
|
|
|
|
|
|
}`}></div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="w-full aspect-[2.5/3.5] bg-bg-secondary rounded-lg flex items-center justify-center group-hover:bg-bg-primary transition-colors">
|
|
|
|
|
|
<div className="text-center p-2">
|
|
|
|
|
|
<p className="text-xs font-medium text-text-primary truncate">{card.name}</p>
|
|
|
|
|
|
<p className="text-xs text-text-secondary truncate">{card.set_name}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
/* Card Detail View */
|
|
|
|
|
|
<>
|
|
|
|
|
|
{/* Detail Header */}
|
|
|
|
|
|
<div className="flex items-center p-4 border-b border-border bg-bg-secondary rounded-t-lg">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setSelectedCard(null)}
|
|
|
|
|
|
className="text-text-secondary hover:text-text-primary transition-colors mr-3"
|
|
|
|
|
|
>
|
|
|
|
|
|
← Back
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<h3 className="text-lg font-semibold text-text-primary truncate">{selectedCard.name}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Card Detail Content */}
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
{/* Card Image */}
|
|
|
|
|
|
{selectedCard.image_url && (
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={selectedCard.image_url}
|
|
|
|
|
|
alt={selectedCard.name}
|
|
|
|
|
|
className="w-full max-w-64 mx-auto rounded-lg shadow-lg"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Card Info */}
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h4 className="font-semibold text-text-primary text-lg">{selectedCard.name}</h4>
|
|
|
|
|
|
<p className="text-text-secondary text-sm">{selectedCard.set_name}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{selectedCard.mana_cost && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-text-secondary text-xs font-medium mb-1">Mana Cost</label>
|
|
|
|
|
|
<div className="bg-bg-primary px-3 py-2 rounded">
|
|
|
|
|
|
<ManaCost cost={selectedCard.mana_cost} size="md" useSVG={manaSymbolSettings.useSVG} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{selectedCard.card_type && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-text-secondary text-xs font-medium mb-1">Type</label>
|
|
|
|
|
|
<div className="bg-bg-primary px-3 py-2 rounded text-sm">{selectedCard.card_type}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{selectedCard.oracle_text && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-text-secondary text-xs font-medium mb-1">Oracle Text</label>
|
|
|
|
|
|
<div className="bg-bg-primary px-3 py-2 rounded text-sm whitespace-pre-wrap">{selectedCard.oracle_text}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
|
{selectedCard.rarity && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-text-secondary text-xs font-medium mb-1">Rarity</label>
|
|
|
|
|
|
<div className={`px-3 py-2 rounded text-sm capitalize ${
|
|
|
|
|
|
selectedCard.rarity === 'mythic' ? 'bg-orange-100 text-orange-800' :
|
|
|
|
|
|
selectedCard.rarity === 'rare' ? 'bg-yellow-100 text-yellow-800' :
|
|
|
|
|
|
selectedCard.rarity === 'uncommon' ? 'bg-gray-100 text-gray-800' :
|
|
|
|
|
|
'bg-green-100 text-green-800'
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
{selectedCard.rarity}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{selectedCard.cmc !== undefined && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-text-secondary text-xs font-medium mb-1">CMC</label>
|
|
|
|
|
|
<div className="bg-bg-primary px-3 py-2 rounded text-sm">{selectedCard.cmc}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Floating Action Button */}
|
|
|
|
|
|
{selectedCard && (
|
|
|
|
|
|
<div className="absolute bottom-4 left-4 right-4">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
addCardToDeck(selectedCard);
|
|
|
|
|
|
setSelectedCard(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="w-full bg-accent-ember text-white py-3 rounded-lg hover:bg-accent-ember-dark transition-colors font-semibold shadow-lg"
|
|
|
|
|
|
>
|
|
|
|
|
|
Add to Deck
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|