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 <cursoragent@cursor.com>
This commit is contained in:
varutasu 2026-06-03 18:18:28 -05:00 committed by GitHub
parent 4c5271ffbf
commit d10bf1614b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 390 additions and 317 deletions

View 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
View 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,
};
}

View file

@ -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>
); );
} }