From d10bf1614b9338ee84cdac6a6072576b60f7f898 Mon Sep 17 00:00:00 2001 From: varutasu <104105839+varutasu@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:18:28 -0500 Subject: [PATCH] refactor(deck-builder): useDeckBuilder + DeckBuilderView (Brief 3) (#93) Extract page state into useDeckBuilder and presentational layout into DeckBuilderView; pages/deck-builder.js is a thin auth-gated composer. Co-authored-by: Cursor --- components/DeckBuilderView.js | 126 +++++++++++++ lib/use-deck-builder.js | 254 ++++++++++++++++++++++++++ pages/deck-builder.js | 327 ++-------------------------------- 3 files changed, 390 insertions(+), 317 deletions(-) create mode 100644 components/DeckBuilderView.js create mode 100644 lib/use-deck-builder.js 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 +}