feat: Implement multiple card display modes (list, grid, stack) in the deck builder and refactor card rendering components for improved interactivity and display options.

This commit is contained in:
2025-12-17 17:31:06 +01:00
parent 845f83086f
commit da3f7fa137
2 changed files with 311 additions and 266 deletions

View File

@@ -1,10 +1,12 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { DraftCard } from '../services/PackGeneratorService'; import { DraftCard } from '../services/PackGeneratorService';
import { CardHoverWrapper, FoilOverlay } from './CardPreview'; import { FoilOverlay } from './CardPreview';
interface StackViewProps { interface StackViewProps {
cards: DraftCard[]; cards: DraftCard[];
cardWidth?: number; cardWidth?: number;
onCardClick?: (card: DraftCard) => void;
onHover?: (card: DraftCard | null) => void;
} }
const CATEGORY_ORDER = [ const CATEGORY_ORDER = [
@@ -19,7 +21,7 @@ const CATEGORY_ORDER = [
'Other' 'Other'
]; ];
export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150 }) => { export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, onCardClick, onHover }) => {
const categorizedCards = useMemo(() => { const categorizedCards = useMemo(() => {
const categories: Record<string, DraftCard[]> = {}; const categories: Record<string, DraftCard[]> = {};
@@ -54,21 +56,21 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150 })
}, [cards]); }, [cards]);
return ( return (
<div className="flex flex-row gap-4 overflow-x-auto pb-8 snap-x"> <div className="flex flex-row gap-4 overflow-x-auto pb-8 snap-x items-start">
{CATEGORY_ORDER.map(category => { {CATEGORY_ORDER.map(category => {
const catCards = categorizedCards[category]; const catCards = categorizedCards[category];
if (catCards.length === 0) return null; if (catCards.length === 0) return null;
return ( return (
<div key={category} className="flex-shrink-0 snap-start" style={{ width: cardWidth }}> <div key={category} className="flex-shrink-0 snap-start flex flex-col" style={{ width: cardWidth }}>
{/* Header */} {/* Header */}
<div className="flex justify-between items-center mb-2 px-1 border-b border-slate-700 pb-1"> <div className="flex justify-between items-center mb-2 px-1 border-b border-slate-700 pb-1 shrink-0 bg-slate-900/80 backdrop-blur z-10 sticky top-0">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">{category}</span> <span className="text-xs font-bold text-slate-400 uppercase tracking-wider">{category}</span>
<span className="text-xs font-mono text-slate-500">{catCards.length}</span> <span className="text-xs font-mono text-slate-500">{catCards.length}</span>
</div> </div>
{/* Stack */} {/* Stack */}
<div className="flex flex-col relative px-2"> <div className="flex flex-col relative px-2 pb-32">
{catCards.map((card, index) => { {catCards.map((card, index) => {
// Margin calculation: Negative margin to pull up next cards. // Margin calculation: Negative margin to pull up next cards.
// To show a "strip" of say 35px at the top of each card. // To show a "strip" of say 35px at the top of each card.
@@ -77,9 +79,15 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150 })
const displayImage = useArtCrop ? card.imageArtCrop : card.image; const displayImage = useArtCrop ? card.imageArtCrop : card.image;
return ( return (
<CardHoverWrapper key={card.id} card={card} className="relative w-full z-0 hover:z-50 transition-all duration-200" preventPreview={cardWidth >= 200}> <div
key={card.id}
className="relative w-full z-0 hover:z-50 transition-all duration-200 group"
onMouseEnter={() => onHover && onHover(card)}
onMouseLeave={() => onHover && onHover(null)}
onClick={() => onCardClick && onCardClick(card)}
>
<div <div
className={`relative w-full rounded-lg bg-slate-800 shadow-md border border-slate-950 overflow-hidden cursor-pointer group`} className={`relative w-full rounded-lg bg-slate-800 shadow-md border border-slate-950 overflow-hidden cursor-pointer group-hover:ring-2 group-hover:ring-purple-400`}
style={{ style={{
// Aspect ratio is maintained by image or div dimensions // Aspect ratio is maintained by image or div dimensions
// With overlap, we just render them one after another with negative margin // With overlap, we just render them one after another with negative margin
@@ -91,7 +99,7 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150 })
{/* Optional: Shine effect for foils if visible? */} {/* Optional: Shine effect for foils if visible? */}
{card.finish === 'foil' && <FoilOverlay />} {card.finish === 'foil' && <FoilOverlay />}
</div> </div>
</CardHoverWrapper> </div>
) )
})} })}
</div> </div>

View File

@@ -1,6 +1,9 @@
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { socketService } from '../../services/SocketService'; 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 { interface DeckBuilderViewProps {
roomId: string; roomId: string;
@@ -9,33 +12,156 @@ interface DeckBuilderViewProps {
availableBasicLands?: any[]; 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 (
<div
onClick={onClick}
onMouseEnter={() => 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"
>
<span className={`font-medium flex items-center gap-2 truncate ${card.rarity === 'mythic' ? 'text-orange-400' : card.rarity === 'rare' ? 'text-yellow-400' : card.rarity === 'uncommon' ? 'text-slate-200' : 'text-slate-400'}`}>
{card.name}
{isFoil(card) && (
<span className="text-transparent bg-clip-text bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 animate-pulse text-xs font-bold border border-purple-500/50 rounded px-1">
FOIL
</span>
)}
</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-slate-600 font-mono uppercase opacity-0 group-hover:opacity-100 transition-opacity">{card.type_line?.split('—')[0]?.trim()}</span>
<span className={`w-2 h-2 rounded-full border ${getRarityColorClass(card.rarity)} !p-0 !text-[0px]`}></span>
</div>
</div>
);
};
// 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 (
<div className="flex flex-col items-center justify-center h-full text-slate-500 opacity-50 p-8 border-2 border-dashed border-slate-700/50 rounded-lg">
<Layers className="w-12 h-12 mb-2" />
<p>{emptyMessage}</p>
</div>
)
}
if (viewMode === 'list') {
const sorted = [...cards].sort((a, b) => (a.cmc || 0) - (b.cmc || 0));
return (
<div className="flex flex-col gap-1 w-full">
{sorted.map(c => <ListItem key={c.id} card={normalizeCard(c)} onClick={() => onCardClick(c)} onHover={onHover} />)}
</div>
);
}
if (viewMode === 'stack') {
return (
<div className="w-full h-full"> {/* Allow native scrolling from parent */}
<StackView
cards={cards.map(normalizeCard)}
cardWidth={cardWidth}
onCardClick={(c) => onCardClick(c)}
onHover={(c) => onHover(c)}
/>
</div>
)
}
// Grid View
return (
<div
className="grid gap-4 pb-20 content-start"
style={{
gridTemplateColumns: `repeat(auto-fill, minmax(${cardWidth}px, 1fr))`
}}
>
{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 (
<div
key={card.id}
onClick={() => 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"
>
<div className={`relative ${useArtCrop ? 'aspect-square' : 'aspect-[2.5/3.5]'} overflow-hidden rounded-lg shadow-xl border transition-all duration-200 group-hover:ring-2 group-hover:ring-purple-400 group-hover:shadow-purple-500/30 ${isFoil ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}>
{isFoil && <FoilOverlay />}
{isFoil && <div className="absolute top-1 right-1 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1 rounded backdrop-blur-sm">FOIL</div>}
{displayImage ? (
<img src={displayImage} alt={card.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-center p-1 text-slate-500 font-bold border-2 border-slate-700 m-1 rounded">{card.name}</div>
)}
<div className={`absolute bottom-0 left-0 right-0 h-1.5 ${card.rarity === 'mythic' ? 'bg-gradient-to-r from-orange-500 to-red-600' : card.rarity === 'rare' ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' : card.rarity === 'uncommon' ? 'bg-gradient-to-r from-gray-300 to-gray-500' : 'bg-black'}`} />
</div>
</div>
);
})}
</div>
)
};
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, availableBasicLands = [] }) => { export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, availableBasicLands = [] }) => {
// Unlimited Timer (Static for now) // Unlimited Timer (Static for now)
const [timer] = useState<string>("Unlimited"); const [timer] = useState<string>("Unlimited");
const [layout, setLayout] = useState<'vertical' | 'horizontal'>('vertical'); const [layout, setLayout] = useState<'vertical' | 'horizontal'>('vertical');
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('grid');
const [cardWidth, setCardWidth] = useState(150);
const [pool, setPool] = useState<any[]>(initialPool); const [pool, setPool] = useState<any[]>(initialPool);
const [deck, setDeck] = useState<any[]>([]); const [deck, setDeck] = useState<any[]>([]);
const [lands, setLands] = useState({ Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 }); const [lands, setLands] = useState({ Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 });
const [hoveredCard, setHoveredCard] = useState<any>(null); const [hoveredCard, setHoveredCard] = useState<any>(null);
// --- Land Advice Logic --- // --- Land Advice Logic ---
const landSuggestion = React.useMemo(() => { const landSuggestion = useMemo(() => {
const targetLands = 17; const targetLands = 17;
// Count existing non-basic lands in deck
const existingLands = deck.filter(c => c.type_line && c.type_line.includes('Land')).length; 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); const landsNeeded = Math.max(0, targetLands - existingLands);
if (landsNeeded === 0) return null; if (landsNeeded === 0) return null;
// Count pips in spell costs
const pips = { Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 }; const pips = { Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 };
let totalPips = 0; let totalPips = 0;
deck.forEach(card => { deck.forEach(card => {
if (card.type_line && card.type_line.includes('Land')) return; if (card.type_line && card.type_line.includes('Land')) return;
if (!card.mana_cost) return; if (!card.mana_cost) return;
const cost = card.mana_cost; const cost = card.mana_cost;
pips.Plains += (cost.match(/{W}/g) || []).length; pips.Plains += (cost.match(/{W}/g) || []).length;
pips.Island += (cost.match(/{U}/g) || []).length; pips.Island += (cost.match(/{U}/g) || []).length;
@@ -45,24 +171,19 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
}); });
totalPips = Object.values(pips).reduce((a, b) => a + b, 0); totalPips = Object.values(pips).reduce((a, b) => a + b, 0);
if (totalPips === 0) return null; if (totalPips === 0) return null;
// Distribute
const suggestion = { Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 }; const suggestion = { Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 };
let allocated = 0; let allocated = 0;
// First pass: floor
(Object.keys(pips) as Array<keyof typeof pips>).forEach(type => { (Object.keys(pips) as Array<keyof typeof pips>).forEach(type => {
const count = Math.floor((pips[type] / totalPips) * landsNeeded); const count = Math.floor((pips[type] / totalPips) * landsNeeded);
suggestion[type] = count; suggestion[type] = count;
allocated += count; allocated += count;
}); });
// Remainder
let remainder = landsNeeded - allocated; let remainder = landsNeeded - allocated;
if (remainder > 0) { if (remainder > 0) {
// Add to color with most pips
const sortedTypes = (Object.keys(pips) as Array<keyof typeof pips>).sort((a, b) => pips[b] - pips[a]); const sortedTypes = (Object.keys(pips) as Array<keyof typeof pips>).sort((a, b) => pips[b] - pips[a]);
for (let i = 0; i < remainder; i++) { for (let i = 0; i < remainder; i++) {
suggestion[sortedTypes[i % sortedTypes.length]]++; suggestion[sortedTypes[i % sortedTypes.length]]++;
@@ -74,24 +195,15 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
const applySuggestion = () => { const applySuggestion = () => {
if (!landSuggestion) return; if (!landSuggestion) return;
// Check if we have available basic lands to add as real cards
if (availableBasicLands && availableBasicLands.length > 0) { if (availableBasicLands && availableBasicLands.length > 0) {
const newLands: any[] = []; const newLands: any[] = [];
Object.entries(landSuggestion).forEach(([type, count]) => { Object.entries(landSuggestion).forEach(([type, count]) => {
if (count <= 0) return; if (count <= 0) return;
const landCard = availableBasicLands.find(l => l.name === type) || availableBasicLands.find(l => l.name.includes(type));
// 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));
if (landCard) { if (landCard) {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const newLand = { const newLand = {
...landCard, ...landCard,
// Ensure unique ID with index
id: `land-${landCard.scryfallId}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}-${i}`, id: `land-${landCard.scryfallId}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}-${i}`,
image_uris: landCard.image_uris || { normal: landCard.image } image_uris: landCard.image_uris || { normal: landCard.image }
}; };
@@ -99,20 +211,14 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
} }
} }
}); });
if (newLands.length > 0) setDeck(prev => [...prev, ...newLands]);
if (newLands.length > 0) {
setDeck(prev => [...prev, ...newLands]);
}
} else { } else {
// Fallback: If no basic lands loaded (counter mode), use the old counter logic
setLands(landSuggestion); setLands(landSuggestion);
} }
}; };
// --- Helper Methods --- // --- Actions ---
const formatTime = (seconds: number | string) => { const formatTime = (seconds: number | string) => seconds;
return seconds; // Just return "Unlimited"
};
const addToDeck = (card: any) => { const addToDeck = (card: any) => {
setPool(prev => prev.filter(c => c.id !== card.id)); setPool(prev => prev.filter(c => c.id !== card.id));
@@ -120,7 +226,6 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
}; };
const addLandToDeck = (land: any) => { const addLandToDeck = (land: any) => {
// Create a unique instance
const newLand = { const newLand = {
...land, ...land,
id: `land-${land.scryfallId}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`, id: `land-${land.scryfallId}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
@@ -131,10 +236,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
const removeFromDeck = (card: any) => { const removeFromDeck = (card: any) => {
setDeck(prev => prev.filter(c => c.id !== card.id)); setDeck(prev => prev.filter(c => c.id !== card.id));
if (!card.id.startsWith('land-')) {
if (card.id.startsWith('land-')) {
// Just delete
} else {
setPool(prev => [...prev, card]); setPool(prev => [...prev, card]);
} }
}; };
@@ -152,7 +254,6 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
Mountain: "https://cards.scryfall.io/normal/front/f/5/f5383569-42b7-4c07-b67f-2736bc88bd37.jpg", 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" Forest: "https://cards.scryfall.io/normal/front/1/f/1fa688da-901d-4876-be11-884d6b677271.jpg"
}; };
return Array(count).fill(null).map((_, i) => ({ return Array(count).fill(null).map((_, i) => ({
id: `basic-${type}-${i}`, id: `basic-${type}-${i}`,
name: type, name: type,
@@ -165,257 +266,193 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
socketService.socket.emit('player_ready', { deck: fullDeck }); socketService.socket.emit('player_ready', { deck: fullDeck });
}; };
const sortedLands = React.useMemo(() => { const sortedLands = useMemo(() => {
return [...(availableBasicLands || [])].sort((a, b) => a.name.localeCompare(b.name)); return [...(availableBasicLands || [])].sort((a, b) => a.name.localeCompare(b.name));
}, [availableBasicLands]); }, [availableBasicLands]);
// --- Sub Actions --- // --- Render Functions (Inline) ---
const renderAdvisorContent = () => {
if (!landSuggestion) return <span className="text-xs text-slate-500 italic">Add colored spells to get advice.</span>;
return (
<div className="flex items-center gap-4 flex-wrap">
<div className="flex gap-2">
{(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 (
<div key={type} className={`font-bold ${colorClass} text-xs flex items-center gap-1`}>
<span>{type.substring(0, 1)}:</span>
<span>{count}</span>
</div>
)
})}
</div>
<button
onClick={applySuggestion}
className="bg-emerald-700 hover:bg-emerald-600 text-white text-[10px] px-2 py-1 rounded shadow transition-colors font-bold uppercase tracking-wide"
>
Auto-Fill
</button>
</div>
);
}
// --- Render Sections ---
const renderLandStation = () => ( const renderLandStation = () => (
<div className={`bg-slate-800 rounded-lg border border-slate-700 flex flex-col ${layout === 'horizontal' ? 'h-full' : 'h-72'} transition-all`}> <div className="bg-slate-900/40 rounded border border-slate-700/50 p-2 mb-2 shrink-0 flex flex-col gap-2">
<div className="p-3 border-b border-slate-700 flex flex-col gap-2 shrink-0 bg-slate-900/30"> {/* Header & Advisor */}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center bg-slate-800/50 p-2 rounded">
<h3 className="text-sm font-bold text-slate-400 uppercase">Land Station</h3> <h4 className="text-xs font-bold text-slate-400 uppercase">Land Station</h4>
</div> {landSuggestion ? (
<div className="flex items-center gap-2">
{/* Integrated Advisor */} <span className="text-[10px] text-slate-500">Advice:</span>
<div className="bg-slate-950/50 rounded border border-white/5 p-2 flex flex-col gap-1"> <div className="flex gap-1">
<span className="text-[10px] text-emerald-400 font-bold uppercase flex items-center gap-1"> {Object.entries(landSuggestion).map(([type, count]) => {
<Layers className="w-3 h-3" /> Land Advisor (Target: 17) if ((count as number) <= 0) return null;
</span> 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';
{renderAdvisorContent()} return <span key={type} className={`text-[10px] font-bold ${color}`}>{type[0]}:{count as number}</span>
</div> })}
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 bg-slate-900/50 rounded-b-lg">
{availableBasicLands && availableBasicLands.length > 0 ? (
<div className={`grid ${layout === 'horizontal' ? 'grid-cols-2' : 'grid-flow-col auto-cols-max'} gap-2 content-start`}>
{/* Note: horizontal layout gets grid-cols-2 for vertical scrolling list feeling, vertical layout gets side-scrolling or wrapped */}
<div className="flex flex-wrap gap-2 justify-center">
{sortedLands.map((land) => (
<div
key={land.scryfallId}
className="relative group cursor-pointer"
onClick={() => addLandToDeck(land)}
onMouseEnter={() => setHoveredCard(land)}
onMouseLeave={() => setHoveredCard(null)}
>
<img
src={land.image || land.image_uris?.normal}
className="w-20 hover:scale-105 transition-transform rounded shadow-lg"
alt={land.name}
/>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 bg-black/40 rounded transition-opacity">
<span className="text-white font-bold text-xs bg-black/50 px-1 rounded">+</span>
</div>
</div>
))}
</div> </div>
<button onClick={applySuggestion} className="bg-emerald-700 hover:bg-emerald-600 text-white text-[10px] px-2 py-0.5 rounded shadow font-bold uppercase">Auto-Fill</button>
</div> </div>
) : ( ) : (
// Fallback counter UI <span className="text-[10px] text-slate-600 italic">Add spells for advice</span>
<div className="flex flex-col gap-2 p-2">
{Object.keys(lands).map(type => (
<div key={type} className="flex items-center justify-between bg-slate-800 p-2 rounded">
<div className="flex items-center gap-2">
<div className={`w-6 h-6 rounded-full flex items-center justify-center font-bold text-xs border
${type === 'Plains' ? 'bg-amber-900/50 border-amber-500 text-amber-200' : ''}
${type === 'Island' ? 'bg-blue-900/50 border-blue-500 text-blue-200' : ''}
${type === 'Swamp' ? 'bg-purple-900/50 border-purple-500 text-purple-200' : ''}
${type === 'Mountain' ? 'bg-red-900/50 border-red-500 text-red-200' : ''}
${type === 'Forest' ? 'bg-green-900/50 border-green-500 text-green-200' : ''}
`}>
{type[0]}
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={() => handleLandChange(type, -1)} className="w-6 h-6 bg-slate-700 hover:bg-slate-600 rounded text-slate-300 font-bold">-</button>
<span className="w-6 text-center text-sm font-bold">{lands[type as keyof typeof lands]}</span>
<button onClick={() => handleLandChange(type, 1)} className="w-6 h-6 bg-slate-700 hover:bg-slate-600 rounded text-slate-300 font-bold">+</button>
</div>
</div>
))}
</div>
)} )}
</div> </div>
{/* Land Scroll */}
{availableBasicLands && availableBasicLands.length > 0 ? (
<div className="flex items-center gap-2 overflow-x-auto custom-scrollbar pb-1">
{sortedLands.map((land) => (
<div
key={land.scryfallId}
className="relative group cursor-pointer shrink-0"
onClick={() => addLandToDeck(land)}
onMouseEnter={() => setHoveredCard(land)}
onMouseLeave={() => setHoveredCard(null)}
>
<img
src={land.image || land.image_uris?.normal}
className="w-16 rounded shadow group-hover:scale-105 transition-transform"
alt={land.name}
/>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 bg-black/40 rounded transition-opacity">
<span className="text-white font-bold text-[10px] bg-black/50 px-1 rounded">+</span>
</div>
</div>
))}
</div>
) : (
<div className="flex justify-between px-2">
{Object.keys(lands).map(type => (
<div key={type} className="flex flex-col items-center">
<div className="text-[10px] font-bold text-slate-500">{type[0]}</div>
<div className="flex items-center gap-1">
<button onClick={() => handleLandChange(type, -1)} className="w-5 h-5 bg-slate-700 rounded text-slate-300 flex items-center justify-center font-bold text-xs">-</button>
<span className="w-4 text-center text-xs font-bold">{lands[type as keyof typeof lands]}</span>
<button onClick={() => handleLandChange(type, 1)} className="w-5 h-5 bg-slate-700 rounded text-slate-300 flex items-center justify-center font-bold text-xs">+</button>
</div>
</div>
))}
</div>
)}
</div> </div>
); );
const renderPool = () => ( return (
<> <div className="flex-1 w-full flex h-full bg-slate-950 text-white overflow-hidden flex-col">
<div className="flex justify-between items-center mb-4 shrink-0"> {/* Global Toolbar - Inlined */}
<h2 className="text-xl font-bold flex items-center gap-2"><Layers /> Card Pool ({pool.length})</h2> <div className="h-14 bg-slate-800 border-b border-slate-700 flex items-center justify-between px-4 shrink-0">
</div>
<div className="flex-1 overflow-y-auto p-2 bg-slate-950/50 rounded-lg custom-scrollbar">
<div className="flex flex-wrap gap-2 justify-center content-start">
{pool.map((card) => (
<img
key={card.id}
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
className="w-28 hover:scale-110 transition-transform cursor-pointer rounded shadow-md"
onClick={() => addToDeck(card)}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
title={card.name}
/>
))}
</div>
</div>
</>
);
const renderDeck = () => (
<>
<div className="flex justify-between items-center mb-4 shrink-0">
<h2 className="text-xl font-bold">Your Deck ({deck.length + Object.values(lands).reduce((a, b) => a + b, 0)})</h2>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-amber-400 font-mono text-xl font-bold bg-slate-800 px-3 py-1 rounded border border-amber-500/30"> {/* Layout Switcher */}
<Clock className="w-5 h-5" /> {formatTime(timer)} <div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700">
<button onClick={() => setLayout('vertical')} className={`p-1.5 rounded ${layout === 'vertical' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="Vertical Split"><Columns className="w-4 h-4" /></button>
<button onClick={() => setLayout('horizontal')} className={`p-1.5 rounded ${layout === 'horizontal' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="Horizontal Split"><LayoutTemplate className="w-4 h-4" /></button>
</div>
{/* View Mode Switcher */}
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700">
<button onClick={() => setViewMode('list')} className={`p-1.5 rounded ${viewMode === 'list' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="List View"><List className="w-4 h-4" /></button>
<button onClick={() => setViewMode('grid')} className={`p-1.5 rounded ${viewMode === 'grid' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="Grid View"><LayoutGrid className="w-4 h-4" /></button>
<button onClick={() => setViewMode('stack')} className={`p-1.5 rounded ${viewMode === 'stack' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="Stack View"><Layers className="w-4 h-4" /></button>
</div>
{/* Slider */}
<div className="flex items-center gap-2 bg-slate-900 rounded-lg px-2 py-1 border border-slate-700 h-9">
<div className="w-2 h-3 rounded border border-slate-500 bg-slate-700" />
<input
type="range"
min="100"
max="300"
step="1"
value={cardWidth}
onChange={(e) => setCardWidth(parseInt(e.target.value))}
className="w-24 accent-purple-500 cursor-pointer h-1.5 bg-slate-800 rounded-lg appearance-none"
/>
<div className="w-3 h-5 rounded border border-slate-500 bg-slate-700" />
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-amber-400 font-mono text-sm font-bold bg-slate-900 px-3 py-1.5 rounded border border-amber-500/30">
<Clock className="w-4 h-4" /> {formatTime(timer)}
</div> </div>
<button <button
onClick={submitDeck} onClick={submitDeck}
className="bg-emerald-600 hover:bg-emerald-500 text-white px-6 py-2 rounded-lg font-bold shadow-lg flex items-center gap-2 transition-transform hover:scale-105" className="bg-emerald-600 hover:bg-emerald-500 text-white px-4 py-2 rounded-lg font-bold shadow-lg flex items-center gap-2 transition-transform hover:scale-105 text-sm"
> >
<Save className="w-4 h-4" /> Submit Deck <Save className="w-4 h-4" /> Submit Deck
</button> </button>
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto p-2 bg-slate-950/50 rounded-lg custom-scrollbar"> <div className="flex-1 flex overflow-hidden">
<div className="flex flex-wrap gap-2 justify-center content-start"> {/* Zoom Sidebar */}
{deck.map((card) => ( <div className="hidden xl:flex w-72 shrink-0 flex-col items-center justify-start pt-4 border-r border-slate-800 bg-slate-900 z-10 p-4">
<img {hoveredCard ? (
key={card.id} <div key={hoveredCard.id} className="animate-in fade-in duration-300 sticky top-4 w-full">
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal} <img
className="w-28 hover:scale-110 transition-transform cursor-pointer rounded shadow-md" src={hoveredCard.image || hoveredCard.image_uris?.normal || hoveredCard.card_faces?.[0]?.image_uris?.normal}
onClick={() => removeFromDeck(card)} alt={hoveredCard.name}
onMouseEnter={() => setHoveredCard(card)} className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
onMouseLeave={() => setHoveredCard(null)} />
title={card.name} <div className="mt-4 text-center">
/> <h3 className="text-lg font-bold text-slate-200">{hoveredCard.name}</h3>
))} <p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{hoveredCard.type_line}</p>
{hoveredCard.oracle_text && (
<div className="mt-4 text-xs text-slate-400 text-left bg-slate-950 p-3 rounded-lg border border-slate-800 leading-relaxed">
{hoveredCard.oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)}
</div>
)}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-64 text-slate-600 text-center opacity-50 border-2 border-dashed border-slate-800 rounded-xl mt-10">
<span className="text-xs uppercase font-bold tracking-widest">Hover Card</span>
</div>
)}
</div> </div>
</div>
</>
);
return ( {/* Content Area */}
<div className="flex-1 w-full flex h-full bg-slate-900 text-white overflow-hidden relative"> {layout === 'vertical' ? (
{/* View Switcher - Absolute Positioned */} <div className="flex-1 flex">
<div className="absolute bottom-4 left-84 z-20 flex bg-slate-800/80 backdrop-blur rounded-lg p-1 border border-slate-700 shadow-xl gap-1" style={{ left: '330px' }}> {/* Pool Column */}
<button <div className="flex-1 flex flex-col min-w-0 border-r border-slate-800 bg-slate-900/50">
onClick={() => setLayout('vertical')} <div className="p-3 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between">
className={`p-2 rounded flex items-center gap-2 text-xs font-bold transition-colors ${layout === 'vertical' ? 'bg-slate-600 text-white shadow' : 'text-slate-400 hover:text-white'}`} <span>Card Pool ({pool.length})</span>
title="Cards Side-by-Side" </div>
> <div className="flex-1 overflow-y-auto p-2 custom-scrollbar flex flex-col">
<Columns className="w-4 h-4" /> Vertical {renderLandStation()}
</button> <CardsDisplay cards={pool} viewMode={viewMode} cardWidth={cardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" />
<button </div>
onClick={() => setLayout('horizontal')} </div>
className={`p-2 rounded flex items-center gap-2 text-xs font-bold transition-colors ${layout === 'horizontal' ? 'bg-slate-600 text-white shadow' : 'text-slate-400 hover:text-white'}`} {/* Deck Column */}
title="Pool Above Deck" <div className="flex-1 flex flex-col min-w-0 bg-slate-900/50">
> <div className="p-3 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between">
<LayoutTemplate className="w-4 h-4" /> Horizontal <span>Deck ({deck.length})</span>
</button> </div>
</div> <div className="flex-1 overflow-y-auto p-2 custom-scrollbar">
<CardsDisplay cards={deck} viewMode={viewMode} cardWidth={cardWidth} onCardClick={removeFromDeck} onHover={setHoveredCard} emptyMessage="Your Deck is Empty" />
{/* Column 1: Zoom Sidebar (Always visible) */} </div>
<div className="hidden xl:flex w-80 shrink-0 flex-col items-center justify-start pt-8 border-r border-slate-800 bg-slate-950/50 z-10 p-4">
{hoveredCard ? (
<div className="animate-in fade-in slide-in-from-left-4 duration-200 sticky top-4 w-full">
<img
src={hoveredCard.image || hoveredCard.image_uris?.normal || hoveredCard.card_faces?.[0]?.image_uris?.normal}
alt={hoveredCard.name}
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
/>
<div className="mt-4 text-center">
<h3 className="text-lg font-bold text-slate-200">{hoveredCard.name}</h3>
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{hoveredCard.type_line}</p>
{hoveredCard.oracle_text && (
<div className="mt-4 text-sm text-slate-400 text-left bg-slate-900/50 p-3 rounded-lg border border-slate-800">
{hoveredCard.oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)}
</div>
)}
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center h-full text-slate-600 text-center opacity-50"> <div className="flex-1 flex flex-col">
<div className="w-48 h-64 border-2 border-dashed border-slate-700 rounded-xl mb-4 flex items-center justify-center"> {/* Top: Pool + Land Station */}
<span className="text-xs uppercase font-bold tracking-widest">Hover Card</span> <div className="flex-1 flex flex-col min-h-0 border-b border-slate-800 bg-slate-900/50">
<div className="p-2 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between shrink-0">
<span>Card Pool ({pool.length})</span>
</div>
<div className="flex-1 overflow-y-auto p-2 custom-scrollbar flex flex-col">
{renderLandStation()}
<CardsDisplay cards={pool} viewMode={viewMode} cardWidth={cardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" />
</div>
</div>
{/* Bottom: Deck */}
<div className="h-[40%] flex flex-col min-h-0 bg-slate-900/50">
<div className="p-2 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between shrink-0">
<span>Deck ({deck.length})</span>
</div>
<div className="flex-1 overflow-y-auto p-2 custom-scrollbar">
<CardsDisplay cards={deck} viewMode={viewMode} cardWidth={cardWidth} onCardClick={removeFromDeck} onHover={setHoveredCard} emptyMessage="Your Deck is Empty" />
</div>
</div> </div>
<p className="text-sm">Hover over a card to view clear details.</p>
</div> </div>
)} )}
</div> </div>
{/* Main Content Area */}
{layout === 'vertical' ? (
<>
{/* Vertical: Column 2 (Pool) */}
<div className="flex-1 p-4 flex flex-col border-r border-slate-700 min-w-0">
{renderPool()}
</div>
{/* Vertical: Column 3 (Deck & Lands) */}
<div className="flex-1 p-4 flex flex-col min-w-0">
{renderDeck()}
<div className="mt-4">
{renderLandStation()}
</div>
</div>
</>
) : (
/* Horizontal Layout */
<div className="flex-1 flex flex-col min-w-0">
{/* Top Row: Lands + Pool */}
<div className="flex-1 flex min-h-0 border-b border-slate-700">
{/* Land Station (Left of Pool) */}
<div className="w-[300px] p-4 border-r border-slate-700 flex flex-col">
{renderLandStation()}
</div>
{/* Pool */}
<div className="flex-1 p-4 flex flex-col min-w-0">
{renderPool()}
</div>
</div>
{/* Bottom Row: Deck */}
<div className="h-[40%] p-4 flex flex-col bg-slate-900/50">
{renderDeck()}
</div>
</div>
)}
</div> </div>
); );
}; };