refactor(deck-builder): useDeckBuilder + DeckBuilderView (Brief 3) #93
3 changed files with 390 additions and 317 deletions
126
components/DeckBuilderView.js
Normal file
126
components/DeckBuilderView.js
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import Link from 'next/link';
|
||||||
|
import DeckBuilderCardBrowser from './DeckBuilderCardBrowser';
|
||||||
|
import DeckBuilderDeckList from './DeckBuilderDeckList';
|
||||||
|
import DeckBuilderStatsBar from './DeckBuilderStatsBar';
|
||||||
|
import { computeDeckStats } from '../lib/deck-builder-stats';
|
||||||
|
|
||||||
|
export default function DeckBuilderView({
|
||||||
|
addCardToDeck,
|
||||||
|
clearFilters,
|
||||||
|
deck,
|
||||||
|
deckCards,
|
||||||
|
filters,
|
||||||
|
manaSymbolSettings,
|
||||||
|
removeCardFromDeck,
|
||||||
|
searchLoading,
|
||||||
|
searchQuery,
|
||||||
|
searchResults,
|
||||||
|
selectedCard,
|
||||||
|
setFilters,
|
||||||
|
setManaSymbolSettings,
|
||||||
|
setSearchQuery,
|
||||||
|
setSelectedCard,
|
||||||
|
setShowFilters,
|
||||||
|
setShowSettings,
|
||||||
|
setSidebarOpen,
|
||||||
|
setViewMode,
|
||||||
|
showFilters,
|
||||||
|
showSettings,
|
||||||
|
sidebarOpen,
|
||||||
|
toggleColorFilter,
|
||||||
|
viewMode,
|
||||||
|
}) {
|
||||||
|
const stats = computeDeckStats(deckCards);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<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
|
||||||
|
type="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)]">
|
||||||
|
<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
|
||||||
|
type="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>
|
||||||
|
|
||||||
|
<DeckBuilderStatsBar stats={stats} />
|
||||||
|
|
||||||
|
<DeckBuilderDeckList
|
||||||
|
deckCards={deckCards}
|
||||||
|
sidebarOpen={sidebarOpen}
|
||||||
|
onOpenSidebar={() => setSidebarOpen(true)}
|
||||||
|
manaSymbolSettings={manaSymbolSettings}
|
||||||
|
onAddCard={(card, quantity) => addCardToDeck(card, quantity)}
|
||||||
|
onRemoveCard={(cardId, quantity) => removeCardFromDeck(cardId, quantity)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`transition-all duration-300 ${sidebarOpen ? 'w-96' : 'w-0 overflow-hidden'}`}>
|
||||||
|
<DeckBuilderCardBrowser
|
||||||
|
selectedCard={selectedCard}
|
||||||
|
onSelectCard={setSelectedCard}
|
||||||
|
onClearSelectedCard={() => setSelectedCard(null)}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
showSettings={showSettings}
|
||||||
|
onToggleSettings={() => setShowSettings(!showSettings)}
|
||||||
|
onCollapseSidebar={() => setSidebarOpen(false)}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchQueryChange={setSearchQuery}
|
||||||
|
showFilters={showFilters}
|
||||||
|
onToggleFilters={() => setShowFilters(!showFilters)}
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={setFilters}
|
||||||
|
onClearFilters={clearFilters}
|
||||||
|
onToggleColorFilter={toggleColorFilter}
|
||||||
|
searchLoading={searchLoading}
|
||||||
|
searchResults={searchResults}
|
||||||
|
manaSymbolSettings={manaSymbolSettings}
|
||||||
|
onManaSymbolSettingsChange={setManaSymbolSettings}
|
||||||
|
onAddCardToDeck={(card) => {
|
||||||
|
addCardToDeck(card);
|
||||||
|
setSelectedCard(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
lib/use-deck-builder.js
Normal file
254
lib/use-deck-builder.js
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { isBasicLand } from './deck-builder-stats.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deck builder page state and handlers (god-component split).
|
||||||
|
*/
|
||||||
|
export function useDeckBuilder({ user = null, authLoading = true } = {}) {
|
||||||
|
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');
|
||||||
|
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
colors: [],
|
||||||
|
types: [],
|
||||||
|
cmc: '',
|
||||||
|
rarity: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchTimeoutRef = useRef(null);
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && deckId) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- load deck when route id or user changes
|
||||||
|
fetchDeck();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- load deck when route id or user changes
|
||||||
|
}, [user, deckId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial catalog load once user is known
|
||||||
|
searchCards();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- initial catalog load once user is known
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
|
searchCards();
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search; searchCards reads latest filter state
|
||||||
|
}, [searchQuery, filters]);
|
||||||
|
|
||||||
|
const addCardToDeck = async (card, quantity = 1) => {
|
||||||
|
if (deck?.format === 'Commander') {
|
||||||
|
const existingCard = deckCards.find((dc) => dc.card_id === card.id);
|
||||||
|
const currentQuantity = existingCard ? existingCard.quantity : 0;
|
||||||
|
|
||||||
|
if (!isBasicLand(card) && currentQuantity + quantity > 1) {
|
||||||
|
alert('Commander format allows only 1 copy of each non-basic land card.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
fetchDeck();
|
||||||
|
} else {
|
||||||
|
console.error('Failed to remove card from deck');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing card from deck:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showLoggedOut = !authLoading && !user;
|
||||||
|
const showDeckLoading = Boolean(user) && loading;
|
||||||
|
const showDeckNotFound = Boolean(user) && !loading && !deck;
|
||||||
|
|
||||||
|
return {
|
||||||
|
addCardToDeck,
|
||||||
|
authLoading,
|
||||||
|
clearFilters,
|
||||||
|
deck,
|
||||||
|
deckCards,
|
||||||
|
deckId,
|
||||||
|
filters,
|
||||||
|
loading,
|
||||||
|
manaSymbolSettings,
|
||||||
|
removeCardFromDeck,
|
||||||
|
searchLoading,
|
||||||
|
searchQuery,
|
||||||
|
searchResults,
|
||||||
|
selectedCard,
|
||||||
|
setFilters,
|
||||||
|
setManaSymbolSettings,
|
||||||
|
setSearchQuery,
|
||||||
|
setSelectedCard,
|
||||||
|
setShowFilters,
|
||||||
|
setShowSettings,
|
||||||
|
setSidebarOpen,
|
||||||
|
setViewMode,
|
||||||
|
showDeckLoading,
|
||||||
|
showDeckNotFound,
|
||||||
|
showFilters,
|
||||||
|
showLoggedOut,
|
||||||
|
showSettings,
|
||||||
|
sidebarOpen,
|
||||||
|
toggleColorFilter,
|
||||||
|
user,
|
||||||
|
viewMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,232 +1,14 @@
|
||||||
/* eslint-disable @next/next/no-img-element -- External or generated image URLs; next/image migration is out of scope. */
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import DeckBuilderCardBrowser from '../components/DeckBuilderCardBrowser';
|
import DeckBuilderView from '../components/DeckBuilderView';
|
||||||
import DeckBuilderDeckList from '../components/DeckBuilderDeckList';
|
|
||||||
import DeckBuilderStatsBar from '../components/DeckBuilderStatsBar';
|
|
||||||
import { useAuth } from '../lib/use-auth';
|
import { useAuth } from '../lib/use-auth';
|
||||||
import { computeDeckStats, isBasicLand } from '../lib/deck-builder-stats';
|
import { useDeckBuilder } from '../lib/use-deck-builder.js';
|
||||||
|
|
||||||
export default function DeckBuilder() {
|
export default function DeckBuilder() {
|
||||||
const { user } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const builder = useDeckBuilder({ user, authLoading });
|
||||||
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);
|
|
||||||
|
|
||||||
const fetchDeck = async () => {
|
if (builder.showLoggedOut || !user) {
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user && deckId) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- load deck when route id or user changes
|
|
||||||
fetchDeck();
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load deck when route id or user changes
|
|
||||||
}, [user, deckId]);
|
|
||||||
|
|
||||||
// Load initial cards when component mounts
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial catalog load once user is known
|
|
||||||
searchCards();
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- initial catalog load once user is known
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchTimeoutRef.current) {
|
|
||||||
clearTimeout(searchTimeoutRef.current);
|
|
||||||
}
|
|
||||||
searchTimeoutRef.current = setTimeout(() => {
|
|
||||||
searchCards();
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (searchTimeoutRef.current) {
|
|
||||||
clearTimeout(searchTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search; searchCards reads latest filter state
|
|
||||||
}, [searchQuery, filters]);
|
|
||||||
|
|
||||||
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 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 (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
|
@ -241,17 +23,17 @@ export default function DeckBuilder() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (builder.showDeckLoading) {
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<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 className="animate-spin rounded-full h-32 w-32 border-b-2 border-accent-ember" />
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!deck) {
|
if (builder.showDeckNotFound) {
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
|
@ -266,98 +48,9 @@ export default function DeckBuilder() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = computeDeckStats(deckCards);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<DeckBuilderView {...builder} />
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
<DeckBuilderStatsBar stats={stats} />
|
|
||||||
|
|
||||||
<DeckBuilderDeckList
|
|
||||||
deckCards={deckCards}
|
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
onOpenSidebar={() => setSidebarOpen(true)}
|
|
||||||
manaSymbolSettings={manaSymbolSettings}
|
|
||||||
onAddCard={(card, quantity) => addCardToDeck(card, quantity)}
|
|
||||||
onRemoveCard={(cardId, quantity) => removeCardFromDeck(cardId, quantity)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Full Height Right Sidebar - Card Browser */}
|
|
||||||
<div className={`transition-all duration-300 ${sidebarOpen ? 'w-96' : 'w-0 overflow-hidden'}`}>
|
|
||||||
<DeckBuilderCardBrowser
|
|
||||||
selectedCard={selectedCard}
|
|
||||||
onSelectCard={setSelectedCard}
|
|
||||||
onClearSelectedCard={() => setSelectedCard(null)}
|
|
||||||
viewMode={viewMode}
|
|
||||||
onViewModeChange={setViewMode}
|
|
||||||
showSettings={showSettings}
|
|
||||||
onToggleSettings={() => setShowSettings(!showSettings)}
|
|
||||||
onCollapseSidebar={() => setSidebarOpen(false)}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchQueryChange={setSearchQuery}
|
|
||||||
showFilters={showFilters}
|
|
||||||
onToggleFilters={() => setShowFilters(!showFilters)}
|
|
||||||
filters={filters}
|
|
||||||
onFiltersChange={setFilters}
|
|
||||||
onClearFilters={clearFilters}
|
|
||||||
onToggleColorFilter={toggleColorFilter}
|
|
||||||
searchLoading={searchLoading}
|
|
||||||
searchResults={searchResults}
|
|
||||||
manaSymbolSettings={manaSymbolSettings}
|
|
||||||
onManaSymbolSettingsChange={setManaSymbolSettings}
|
|
||||||
onAddCardToDeck={(card) => {
|
|
||||||
addCardToDeck(card);
|
|
||||||
setSelectedCard(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue