diff --git a/components/DeckBuilderView.js b/components/DeckBuilderView.js
new file mode 100644
index 0000000..17604b6
--- /dev/null
+++ b/components/DeckBuilderView.js
@@ -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 (
+
+
+
+
+
+ ← Back to Decks
+
+
+
{deck.name}
+
+ {deck.format} • {stats.totalCards}/100 cards
+
+
+
+
+
+ View Deck
+
+
+
+
+
+
+
+
+
+ Deck Cards ({stats.totalCards})
+
+
+
+
+
+
+
setSidebarOpen(true)}
+ manaSymbolSettings={manaSymbolSettings}
+ onAddCard={(card, quantity) => addCardToDeck(card, quantity)}
+ onRemoveCard={(cardId, quantity) => removeCardFromDeck(cardId, quantity)}
+ />
+
+
+
+
+ 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);
+ }}
+ />
+
+
+
+ );
+}
diff --git a/lib/use-deck-builder.js b/lib/use-deck-builder.js
new file mode 100644
index 0000000..d4b08ef
--- /dev/null
+++ b/lib/use-deck-builder.js
@@ -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,
+ };
+}
diff --git a/pages/deck-builder.js b/pages/deck-builder.js
index 21b30b6..75056a4 100644
--- a/pages/deck-builder.js
+++ b/pages/deck-builder.js
@@ -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 Layout from '../components/Layout';
-import DeckBuilderCardBrowser from '../components/DeckBuilderCardBrowser';
-import DeckBuilderDeckList from '../components/DeckBuilderDeckList';
-import DeckBuilderStatsBar from '../components/DeckBuilderStatsBar';
+import DeckBuilderView from '../components/DeckBuilderView';
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() {
- 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);
+ const { user, loading: authLoading } = useAuth();
+ const builder = useDeckBuilder({ user, authLoading });
- 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);
- }
- };
-
- 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) {
+ if (builder.showLoggedOut || !user) {
return (
@@ -241,17 +23,17 @@ export default function DeckBuilder() {
);
}
- if (loading) {
+ if (builder.showDeckLoading) {
return (
);
}
- if (!deck) {
+ if (builder.showDeckNotFound) {
return (
@@ -266,98 +48,9 @@ export default function DeckBuilder() {
);
}
- const stats = computeDeckStats(deckCards);
-
return (
-
- {/* Header */}
-
-
-
-
- ← Back to Decks
-
-
-
{deck.name}
-
- {deck.format} • {stats.totalCards}/100 cards
-
-
-
-
-
- View Deck
-
-
-
-
-
- {/* Main Deck View - Left Side */}
-
-
-
-
- Deck Cards ({stats.totalCards})
-
-
-
-
-
-
-
setSidebarOpen(true)}
- manaSymbolSettings={manaSymbolSettings}
- onAddCard={(card, quantity) => addCardToDeck(card, quantity)}
- onRemoveCard={(cardId, quantity) => removeCardFromDeck(cardId, quantity)}
- />
-
-
-
- {/* Full Height Right Sidebar - Card Browser */}
-
- 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);
- }}
- />
-
-
-
+
);
-}
\ No newline at end of file
+}