From da3f7fa137aa872ba01aa3729d2160b26d810f96 Mon Sep 17 00:00:00 2001 From: dnviti Date: Wed, 17 Dec 2025 17:31:06 +0100 Subject: [PATCH] feat: Implement multiple card display modes (list, grid, stack) in the deck builder and refactor card rendering components for improved interactivity and display options. --- src/client/src/components/StackView.tsx | 26 +- .../src/modules/draft/DeckBuilderView.tsx | 551 ++++++++++-------- 2 files changed, 311 insertions(+), 266 deletions(-) diff --git a/src/client/src/components/StackView.tsx b/src/client/src/components/StackView.tsx index 988a812..8a2160d 100644 --- a/src/client/src/components/StackView.tsx +++ b/src/client/src/components/StackView.tsx @@ -1,10 +1,12 @@ import React, { useMemo } from 'react'; import { DraftCard } from '../services/PackGeneratorService'; -import { CardHoverWrapper, FoilOverlay } from './CardPreview'; +import { FoilOverlay } from './CardPreview'; interface StackViewProps { cards: DraftCard[]; cardWidth?: number; + onCardClick?: (card: DraftCard) => void; + onHover?: (card: DraftCard | null) => void; } const CATEGORY_ORDER = [ @@ -19,7 +21,7 @@ const CATEGORY_ORDER = [ 'Other' ]; -export const StackView: React.FC = ({ cards, cardWidth = 150 }) => { +export const StackView: React.FC = ({ cards, cardWidth = 150, onCardClick, onHover }) => { const categorizedCards = useMemo(() => { const categories: Record = {}; @@ -54,21 +56,21 @@ export const StackView: React.FC = ({ cards, cardWidth = 150 }) }, [cards]); return ( -
+
{CATEGORY_ORDER.map(category => { const catCards = categorizedCards[category]; if (catCards.length === 0) return null; return ( -
+
{/* Header */} -
+
{category} {catCards.length}
{/* Stack */} -
+
{catCards.map((card, index) => { // Margin calculation: Negative margin to pull up next cards. // To show a "strip" of say 35px at the top of each card. @@ -77,9 +79,15 @@ export const StackView: React.FC = ({ cards, cardWidth = 150 }) const displayImage = useArtCrop ? card.imageArtCrop : card.image; return ( - = 200}> +
onHover && onHover(card)} + onMouseLeave={() => onHover && onHover(null)} + onClick={() => onCardClick && onCardClick(card)} + >
= ({ cards, cardWidth = 150 }) {/* Optional: Shine effect for foils if visible? */} {card.finish === 'foil' && }
- +
) })}
diff --git a/src/client/src/modules/draft/DeckBuilderView.tsx b/src/client/src/modules/draft/DeckBuilderView.tsx index e61e02d..0335d89 100644 --- a/src/client/src/modules/draft/DeckBuilderView.tsx +++ b/src/client/src/modules/draft/DeckBuilderView.tsx @@ -1,6 +1,9 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { socketService } from '../../services/SocketService'; -import { Save, Layers, Clock, Columns, LayoutTemplate } from 'lucide-react'; +import { Save, Layers, Clock, Columns, LayoutTemplate, List, LayoutGrid } from 'lucide-react'; +import { StackView } from '../../components/StackView'; +import { FoilOverlay } from '../../components/CardPreview'; +import { DraftCard } from '../../services/PackGeneratorService'; interface DeckBuilderViewProps { roomId: string; @@ -9,33 +12,156 @@ interface DeckBuilderViewProps { availableBasicLands?: any[]; } +// Internal Helper to normalize card data for visuals +const normalizeCard = (c: any): DraftCard => ({ + ...c, + finish: c.finish || 'nonfoil', + // Ensure image is top-level for components that expect it + image: c.image || c.image_uris?.normal || c.card_faces?.[0]?.image_uris?.normal +}); + +// Reusable List Item Component +const ListItem: React.FC<{ card: DraftCard; onClick?: () => void; onHover?: (c: any) => void }> = ({ card, onClick, onHover }) => { + const isFoil = (card: DraftCard) => card.finish === 'foil'; + + const getRarityColorClass = (rarity: string) => { + switch (rarity) { + case 'common': return 'bg-black text-white border-slate-600'; + case 'uncommon': return 'bg-slate-300 text-slate-900 border-white'; + case 'rare': return 'bg-yellow-500 text-yellow-950 border-yellow-200'; + case 'mythic': return 'bg-orange-600 text-white border-orange-300'; + default: return 'bg-slate-500'; + } + }; + + return ( +
onHover && onHover(card)} + onMouseLeave={() => onHover && onHover(null)} + className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700/50 cursor-pointer transition-colors w-full group" + > + + {card.name} + {isFoil(card) && ( + + FOIL + + )} + +
+ {card.type_line?.split('—')[0]?.trim()} + +
+
+ ); +}; + +// Extracted Component to avoid re-mounting issues +const CardsDisplay: React.FC<{ + cards: any[]; + viewMode: 'list' | 'grid' | 'stack'; + cardWidth: number; + onCardClick: (c: any) => void; + onHover: (c: any) => void; + emptyMessage: string; +}> = ({ cards, viewMode, cardWidth, onCardClick, onHover, emptyMessage }) => { + if (cards.length === 0) { + return ( +
+ +

{emptyMessage}

+
+ ) + } + + if (viewMode === 'list') { + const sorted = [...cards].sort((a, b) => (a.cmc || 0) - (b.cmc || 0)); + return ( +
+ {sorted.map(c => onCardClick(c)} onHover={onHover} />)} +
+ ); + } + + if (viewMode === 'stack') { + return ( +
{/* Allow native scrolling from parent */} + onCardClick(c)} + onHover={(c) => onHover(c)} + /> +
+ ) + } + + // Grid View + return ( +
+ {cards.map(c => { + const card = normalizeCard(c); + const useArtCrop = cardWidth < 200 && !!card.imageArtCrop; + const displayImage = useArtCrop ? card.imageArtCrop : card.image; + const isFoil = card.finish === 'foil'; + + return ( +
onCardClick(c)} + onMouseEnter={() => onHover(card)} + onMouseLeave={() => onHover(null)} + className="relative group bg-slate-900 rounded-lg shrink-0 cursor-pointer hover:scale-105 transition-transform" + > +
+ {isFoil && } + {isFoil &&
FOIL
} + {displayImage ? ( + {card.name} + ) : ( +
{card.name}
+ )} +
+
+
+ ); + })} +
+ ) +}; + export const DeckBuilderView: React.FC = ({ initialPool, availableBasicLands = [] }) => { // Unlimited Timer (Static for now) const [timer] = useState("Unlimited"); const [layout, setLayout] = useState<'vertical' | 'horizontal'>('vertical'); + const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('grid'); + const [cardWidth, setCardWidth] = useState(150); + const [pool, setPool] = useState(initialPool); const [deck, setDeck] = useState([]); const [lands, setLands] = useState({ Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 }); const [hoveredCard, setHoveredCard] = useState(null); // --- Land Advice Logic --- - const landSuggestion = React.useMemo(() => { + const landSuggestion = useMemo(() => { const targetLands = 17; - // Count existing non-basic lands in deck const existingLands = deck.filter(c => c.type_line && c.type_line.includes('Land')).length; - // We want to suggest basics to reach target const landsNeeded = Math.max(0, targetLands - existingLands); if (landsNeeded === 0) return null; - // Count pips in spell costs const pips = { Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 }; let totalPips = 0; deck.forEach(card => { if (card.type_line && card.type_line.includes('Land')) return; if (!card.mana_cost) return; - const cost = card.mana_cost; pips.Plains += (cost.match(/{W}/g) || []).length; pips.Island += (cost.match(/{U}/g) || []).length; @@ -45,24 +171,19 @@ export const DeckBuilderView: React.FC = ({ initialPool, a }); totalPips = Object.values(pips).reduce((a, b) => a + b, 0); - if (totalPips === 0) return null; - // Distribute const suggestion = { Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 }; let allocated = 0; - // First pass: floor (Object.keys(pips) as Array).forEach(type => { const count = Math.floor((pips[type] / totalPips) * landsNeeded); suggestion[type] = count; allocated += count; }); - // Remainder let remainder = landsNeeded - allocated; if (remainder > 0) { - // Add to color with most pips const sortedTypes = (Object.keys(pips) as Array).sort((a, b) => pips[b] - pips[a]); for (let i = 0; i < remainder; i++) { suggestion[sortedTypes[i % sortedTypes.length]]++; @@ -74,24 +195,15 @@ export const DeckBuilderView: React.FC = ({ initialPool, a const applySuggestion = () => { if (!landSuggestion) return; - - // Check if we have available basic lands to add as real cards if (availableBasicLands && availableBasicLands.length > 0) { const newLands: any[] = []; - Object.entries(landSuggestion).forEach(([type, count]) => { if (count <= 0) return; - - // Find matching land in availableBasicLands - // We look for strict name match first, then potential fallback (e.g. snow lands) - const landCard = availableBasicLands.find(l => l.name === type) || - availableBasicLands.find(l => l.name.includes(type)); - + const landCard = availableBasicLands.find(l => l.name === type) || availableBasicLands.find(l => l.name.includes(type)); if (landCard) { for (let i = 0; i < count; i++) { const newLand = { ...landCard, - // Ensure unique ID with index id: `land-${landCard.scryfallId}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}-${i}`, image_uris: landCard.image_uris || { normal: landCard.image } }; @@ -99,20 +211,14 @@ export const DeckBuilderView: React.FC = ({ initialPool, a } } }); - - if (newLands.length > 0) { - setDeck(prev => [...prev, ...newLands]); - } + if (newLands.length > 0) setDeck(prev => [...prev, ...newLands]); } else { - // Fallback: If no basic lands loaded (counter mode), use the old counter logic setLands(landSuggestion); } }; - // --- Helper Methods --- - const formatTime = (seconds: number | string) => { - return seconds; // Just return "Unlimited" - }; + // --- Actions --- + const formatTime = (seconds: number | string) => seconds; const addToDeck = (card: any) => { setPool(prev => prev.filter(c => c.id !== card.id)); @@ -120,7 +226,6 @@ export const DeckBuilderView: React.FC = ({ initialPool, a }; const addLandToDeck = (land: any) => { - // Create a unique instance const newLand = { ...land, id: `land-${land.scryfallId}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`, @@ -131,10 +236,7 @@ export const DeckBuilderView: React.FC = ({ initialPool, a const removeFromDeck = (card: any) => { setDeck(prev => prev.filter(c => c.id !== card.id)); - - if (card.id.startsWith('land-')) { - // Just delete - } else { + if (!card.id.startsWith('land-')) { setPool(prev => [...prev, card]); } }; @@ -152,7 +254,6 @@ export const DeckBuilderView: React.FC = ({ initialPool, a Mountain: "https://cards.scryfall.io/normal/front/f/5/f5383569-42b7-4c07-b67f-2736bc88bd37.jpg", Forest: "https://cards.scryfall.io/normal/front/1/f/1fa688da-901d-4876-be11-884d6b677271.jpg" }; - return Array(count).fill(null).map((_, i) => ({ id: `basic-${type}-${i}`, name: type, @@ -165,257 +266,193 @@ export const DeckBuilderView: React.FC = ({ initialPool, a socketService.socket.emit('player_ready', { deck: fullDeck }); }; - const sortedLands = React.useMemo(() => { + const sortedLands = useMemo(() => { return [...(availableBasicLands || [])].sort((a, b) => a.name.localeCompare(b.name)); }, [availableBasicLands]); - // --- Sub Actions --- - const renderAdvisorContent = () => { - if (!landSuggestion) return Add colored spells to get advice.; - - return ( -
-
- {(Object.entries(landSuggestion) as [string, number][]).map(([type, count]) => { - if (count === 0) return null; - let colorClass = "text-slate-300"; - if (type === 'Plains') colorClass = "text-amber-200"; - if (type === 'Island') colorClass = "text-blue-200"; - if (type === 'Swamp') colorClass = "text-purple-200"; - if (type === 'Mountain') colorClass = "text-red-200"; - if (type === 'Forest') colorClass = "text-emerald-200"; - - return ( -
- {type.substring(0, 1)}: - {count} -
- ) - })} -
- -
- ); - } - - // --- Render Sections --- + // --- Render Functions (Inline) --- const renderLandStation = () => ( -
-
-
-

Land Station

-
- - {/* Integrated Advisor */} -
- - Land Advisor (Target: 17) - - {renderAdvisorContent()} -
-
- -
- {availableBasicLands && availableBasicLands.length > 0 ? ( -
- {/* Note: horizontal layout gets grid-cols-2 for vertical scrolling list feeling, vertical layout gets side-scrolling or wrapped */} -
- {sortedLands.map((land) => ( -
addLandToDeck(land)} - onMouseEnter={() => setHoveredCard(land)} - onMouseLeave={() => setHoveredCard(null)} - > - {land.name} -
- + -
-
- ))} +
+ {/* Header & Advisor */} +
+

Land Station

+ {landSuggestion ? ( +
+ Advice: +
+ {Object.entries(landSuggestion).map(([type, count]) => { + if ((count as number) <= 0) return null; + const color = type === 'Plains' ? 'text-amber-200' : type === 'Island' ? 'text-blue-200' : type === 'Swamp' ? 'text-purple-200' : type === 'Mountain' ? 'text-red-200' : 'text-emerald-200'; + return {type[0]}:{count as number} + })}
+
) : ( - // Fallback counter UI -
- {Object.keys(lands).map(type => ( -
-
-
- {type[0]} -
-
-
- - {lands[type as keyof typeof lands]} - -
-
- ))} -
+ Add spells for advice )}
+ + {/* Land Scroll */} + {availableBasicLands && availableBasicLands.length > 0 ? ( +
+ {sortedLands.map((land) => ( +
addLandToDeck(land)} + onMouseEnter={() => setHoveredCard(land)} + onMouseLeave={() => setHoveredCard(null)} + > + {land.name} +
+ + +
+
+ ))} +
+ ) : ( +
+ {Object.keys(lands).map(type => ( +
+
{type[0]}
+
+ + {lands[type as keyof typeof lands]} + +
+
+ ))} +
+ )}
); - const renderPool = () => ( - <> -
-

Card Pool ({pool.length})

-
-
-
- {pool.map((card) => ( - addToDeck(card)} - onMouseEnter={() => setHoveredCard(card)} - onMouseLeave={() => setHoveredCard(null)} - title={card.name} - /> - ))} -
-
- - ); - - const renderDeck = () => ( - <> -
-

Your Deck ({deck.length + Object.values(lands).reduce((a, b) => a + b, 0)})

+ return ( +
+ {/* Global Toolbar - Inlined */} +
-
- {formatTime(timer)} + {/* Layout Switcher */} +
+ + +
+ + {/* View Mode Switcher */} +
+ + + +
+ + {/* Slider */} +
+
+ setCardWidth(parseInt(e.target.value))} + className="w-24 accent-purple-500 cursor-pointer h-1.5 bg-slate-800 rounded-lg appearance-none" + /> +
+
+
+ +
+
+ {formatTime(timer)}
-
-
- {deck.map((card) => ( - removeFromDeck(card)} - onMouseEnter={() => setHoveredCard(card)} - onMouseLeave={() => setHoveredCard(null)} - title={card.name} - /> - ))} +
+ {/* Zoom Sidebar */} +
+ {hoveredCard ? ( +
+ {hoveredCard.name} +
+

{hoveredCard.name}

+

{hoveredCard.type_line}

+ {hoveredCard.oracle_text && ( +
+ {hoveredCard.oracle_text.split('\n').map((line: string, i: number) =>

{line}

)} +
+ )} +
+
+ ) : ( +
+ Hover Card +
+ )}
-
- - ); - return ( -
- {/* View Switcher - Absolute Positioned */} -
- - -
- - {/* Column 1: Zoom Sidebar (Always visible) */} -
- {hoveredCard ? ( -
- {hoveredCard.name} -
-

{hoveredCard.name}

-

{hoveredCard.type_line}

- {hoveredCard.oracle_text && ( -
- {hoveredCard.oracle_text.split('\n').map((line: string, i: number) =>

{line}

)} -
- )} + {/* Content Area */} + {layout === 'vertical' ? ( +
+ {/* Pool Column */} +
+
+ Card Pool ({pool.length}) +
+
+ {renderLandStation()} + +
+
+ {/* Deck Column */} +
+
+ Deck ({deck.length}) +
+
+ +
) : ( -
-
- Hover Card +
+ {/* Top: Pool + Land Station */} +
+
+ Card Pool ({pool.length}) +
+
+ {renderLandStation()} + +
+
+ {/* Bottom: Deck */} +
+
+ Deck ({deck.length}) +
+
+ +
-

Hover over a card to view clear details.

)}
- - {/* Main Content Area */} - {layout === 'vertical' ? ( - <> - {/* Vertical: Column 2 (Pool) */} -
- {renderPool()} -
- {/* Vertical: Column 3 (Deck & Lands) */} -
- {renderDeck()} -
- {renderLandStation()} -
-
- - ) : ( - /* Horizontal Layout */ -
- {/* Top Row: Lands + Pool */} -
- {/* Land Station (Left of Pool) */} -
- {renderLandStation()} -
- {/* Pool */} -
- {renderPool()} -
-
- {/* Bottom Row: Deck */} -
- {renderDeck()} -
-
- )}
); };