feat: implement customizable vertical and horizontal deck builder layouts with a new layout switcher and associated rendering refactors.
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
import { Save, Layers, Clock } from 'lucide-react';
|
||||
import { Save, Layers, Clock, Columns, LayoutTemplate } from 'lucide-react';
|
||||
|
||||
interface DeckBuilderViewProps {
|
||||
roomId: string;
|
||||
@@ -13,21 +12,12 @@ interface DeckBuilderViewProps {
|
||||
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, availableBasicLands = [] }) => {
|
||||
// Unlimited Timer (Static for now)
|
||||
const [timer] = useState<string>("Unlimited");
|
||||
const [layout, setLayout] = useState<'vertical' | 'horizontal'>('vertical');
|
||||
const [pool, setPool] = useState<any[]>(initialPool);
|
||||
const [deck, setDeck] = useState<any[]>([]);
|
||||
const [lands, setLands] = useState({ Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 });
|
||||
const [hoveredCard, setHoveredCard] = useState<any>(null);
|
||||
|
||||
/*
|
||||
// Disable timer countdown
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTimer(t => t > 0 ? t - 1 : 0);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
*/
|
||||
|
||||
// --- Land Advice Logic ---
|
||||
const landSuggestion = React.useMemo(() => {
|
||||
const targetLands = 17;
|
||||
@@ -56,8 +46,6 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
|
||||
totalPips = Object.values(pips).reduce((a, b) => a + b, 0);
|
||||
|
||||
// If no colored pips (artifacts only?), suggest even split or just return 0s?
|
||||
// Let's assume proportional to 1 if 0 to avoid div by zero.
|
||||
if (totalPips === 0) return null;
|
||||
|
||||
// Distribute
|
||||
@@ -181,9 +169,190 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
return [...(availableBasicLands || [])].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [availableBasicLands]);
|
||||
|
||||
// --- Sub Actions ---
|
||||
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 = () => (
|
||||
<div className={`bg-slate-800 rounded-lg border border-slate-700 flex flex-col ${layout === 'horizontal' ? 'h-full' : 'h-72'} transition-all`}>
|
||||
<div className="p-3 border-b border-slate-700 flex flex-col gap-2 shrink-0 bg-slate-900/30">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-bold text-slate-400 uppercase">Land Station</h3>
|
||||
</div>
|
||||
|
||||
{/* Integrated Advisor */}
|
||||
<div className="bg-slate-950/50 rounded border border-white/5 p-2 flex flex-col gap-1">
|
||||
<span className="text-[10px] text-emerald-400 font-bold uppercase flex items-center gap-1">
|
||||
<Layers className="w-3 h-3" /> Land Advisor (Target: 17)
|
||||
</span>
|
||||
{renderAdvisorContent()}
|
||||
</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>
|
||||
) : (
|
||||
// Fallback counter UI
|
||||
<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>
|
||||
);
|
||||
|
||||
const renderPool = () => (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-4 shrink-0">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2"><Layers /> Card Pool ({pool.length})</h2>
|
||||
</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-2 text-amber-400 font-mono text-xl font-bold bg-slate-800 px-3 py-1 rounded border border-amber-500/30">
|
||||
<Clock className="w-5 h-5" /> {formatTime(timer)}
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Save className="w-4 h-4" /> Submit Deck
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
{deck.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={() => removeFromDeck(card)}
|
||||
onMouseEnter={() => setHoveredCard(card)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
title={card.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 w-full flex h-full bg-slate-900 text-white">
|
||||
{/* Column 1: Zoom Sidebar */}
|
||||
<div className="flex-1 w-full flex h-full bg-slate-900 text-white overflow-hidden relative">
|
||||
{/* View Switcher - Absolute Positioned */}
|
||||
<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' }}>
|
||||
<button
|
||||
onClick={() => setLayout('vertical')}
|
||||
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'}`}
|
||||
title="Cards Side-by-Side"
|
||||
>
|
||||
<Columns className="w-4 h-4" /> Vertical
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLayout('horizontal')}
|
||||
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'}`}
|
||||
title="Pool Above Deck"
|
||||
>
|
||||
<LayoutTemplate className="w-4 h-4" /> Horizontal
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Column 1: Zoom Sidebar (Always visible) */}
|
||||
<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">
|
||||
@@ -212,156 +381,41 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column 2: Pool */}
|
||||
<div className="flex-1 p-4 flex flex-col border-r border-slate-700 min-w-0">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2"><Layers /> Card Pool ({pool.length})</h2>
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column 3: Deck & Lands */}
|
||||
<div className="flex-1 p-4 flex flex-col min-w-0">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<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-2 text-amber-400 font-mono text-xl font-bold bg-slate-800 px-3 py-1 rounded border border-amber-500/30">
|
||||
<Clock className="w-5 h-5" /> {formatTime(timer)}
|
||||
{/* Vertical: Column 3 (Deck & Lands) */}
|
||||
<div className="flex-1 p-4 flex flex-col min-w-0">
|
||||
{renderDeck()}
|
||||
<div className="mt-4">
|
||||
{renderLandStation()}
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Save className="w-4 h-4" /> Submit Deck
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deck View */}
|
||||
<div className="flex-1 overflow-y-auto p-2 bg-slate-950/50 rounded-lg mb-4 custom-scrollbar">
|
||||
<div className="flex flex-wrap gap-2 justify-center content-start">
|
||||
{deck.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={() => removeFromDeck(card)}
|
||||
onMouseEnter={() => setHoveredCard(card)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
title={card.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
{/* Advice Panel */}
|
||||
<div className="bg-slate-800 rounded-lg p-3 border border-slate-700 flex justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-slate-400 font-bold uppercase flex items-center gap-2">
|
||||
<Layers className="w-3 h-3 text-emerald-400" /> Land Advisor (Target: 17)
|
||||
</span>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
Based on your deck's mana symbols.
|
||||
</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>
|
||||
{landSuggestion ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<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-sm 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-xs px-3 py-1 rounded shadow transition-colors font-bold"
|
||||
>
|
||||
Auto-Fill
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 italic">Add colored spells to get advice.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Land Station */}
|
||||
<div className="h-48 bg-slate-800 rounded-lg p-4 border border-slate-700 flex flex-col">
|
||||
<h3 className="text-sm font-bold text-slate-400 uppercase mb-2">Land Station (Unlimited)</h3>
|
||||
|
||||
{availableBasicLands && availableBasicLands.length > 0 ? (
|
||||
<div className="flex-1 overflow-x-auto flex items-center gap-3 custom-scrollbar p-2 bg-slate-900/50 rounded-lg">
|
||||
{sortedLands.map((land) => (
|
||||
<div
|
||||
key={land.scryfallId}
|
||||
className="flex-shrink-0 relative group cursor-pointer"
|
||||
onClick={() => addLandToDeck(land)}
|
||||
onMouseEnter={() => setHoveredCard(land)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
>
|
||||
<img
|
||||
src={land.image || land.image_uris?.normal}
|
||||
className="w-24 rounded shadow-lg group-hover:scale-110 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-xs bg-black/50 px-2 py-1 rounded">+ Add</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-around items-center h-full">
|
||||
{Object.keys(lands).map(type => (
|
||||
<div key={type} className="flex flex-col items-center gap-1">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-xs border-2
|
||||
${type === 'Plains' ? 'bg-amber-100 border-amber-300 text-amber-900' : ''}
|
||||
${type === 'Island' ? 'bg-blue-100 border-blue-300 text-blue-900' : ''}
|
||||
${type === 'Swamp' ? 'bg-purple-100 border-purple-300 text-purple-900' : ''}
|
||||
${type === 'Mountain' ? 'bg-red-100 border-red-300 text-red-900' : ''}
|
||||
${type === 'Forest' ? 'bg-green-100 border-green-300 text-green-900' : ''}
|
||||
`}>
|
||||
{type[0]}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => handleLandChange(type, -1)} className="w-8 h-8 bg-slate-700 rounded hover:bg-slate-600 flex items-center justify-center text-lg font-bold text-slate-300">-</button>
|
||||
<span className="w-8 text-center font-bold text-lg">{lands[type as keyof typeof lands]}</span>
|
||||
<button onClick={() => handleLandChange(type, 1)} className="w-8 h-8 bg-slate-700 rounded hover:bg-slate-600 flex items-center justify-center text-lg font-bold text-slate-300">+</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Bottom Row: Deck */}
|
||||
<div className="h-[40%] p-4 flex flex-col bg-slate-900/50">
|
||||
{renderDeck()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user