feat: implement customizable vertical and horizontal deck builder layouts with a new layout switcher and associated rendering refactors.

This commit is contained in:
2025-12-17 17:03:41 +01:00
parent db785537c9
commit 845f83086f
3 changed files with 269 additions and 159 deletions

View File

@@ -84,3 +84,4 @@
- [Gameplay Magnified View & Timeout](./devlog/2025-12-17-161500_gameplay_magnified_view_and_timeout.md): Completed. Added magnified view with full card details (Oracle text, type, mana) to gameplay and disabled timeout.
- [Test Deck Feature](./devlog/2025-12-17-162500_test_deck_feature.md): Completed. Implemented "Test Solo" button in Cube Manager to instantly start a solo game with a randomized deck from generated packs.
- [Update Deck Auto-Fill](./devlog/2025-12-17-165500_update_deck_autofill.md): Completed. Updated deck builder "Auto-Fill" to add lands as individual cards to the deck list for easier management.
- [Customizable Deck Builder Layout](./devlog/2025-12-17-170000_customizable_deck_builder.md): Completed. Implemented switchable Vertical (Side-by-Side) and Horizontal (Top-Bottom) layouts, with an integrated, improved Land Station.

View File

@@ -0,0 +1,55 @@
# Customizable Deck Builder Layout
## Request
The user wants to customize the Deck Builder interface with the following features:
1. **Layout Modes**:
* **Vertical View (Default)**: The current 3-column layout ([Zoom] | [Pool] | [Deck + Lands]).
* **Horizontal View**: A new layout where Pool is above the Deck. Land Station should be to the left of the Card Pool in this mode.
2. **Land Station Updates**:
* Remove "(Unlimited)" text.
* Increase container height.
* Integrate proper Land Advisor into the Land Station container to save space.
## Design
### New Layout State
- State: `layout: 'vertical' | 'horizontal'`
- Toggle: A button group or switch to change layouts.
### Component Structure
I will extract the core sections into render functions or variables to move them around easily.
- `renderZoomSidebar()`
- `renderPool()`
- `renderDeck()`
- `renderLandStation()` (This will now include the Land Advisor inside it)
### Horizontal Layout Grid
Structure:
```
[Zoom Sidebar (Fixed Left)] | [Main Content (Flex Column)]
|
|-- [Top Row (Flex Row)]
| |-- [Land Station (width fixed or flex)]
| |-- [Pool (Flex 1)]
|
|-- [Bottom Row (Flex 1)]
|-- [Deck]
```
### Vertical Layout Grid (Current)
Structure:
```
[Zoom Sidebar] | [Pool] | [Deck + Land Station]
```
*Note: In current layout, Land Station is stacked vertically with Deck in the 3rd column. User is fine with "exactly how is it now" for vertical.*
### Land Station Refactoring
- Combine `Advice Panel` and `Land Station` div.
- Remove `(Unlimited)`.
- Increase height (e.g., `h-64` or `min-h-[200px]`).
## Implementation Steps
1. Read `DeckBuilderView.tsx` (already read).
2. Refactor to extract render helper functions for clear modularity.
3. Add `layout` state and toggle UI.
4. Implement CSS grids/flex layouts for both modes.
5. Modify Land Station to include Advisor and styling updates.

View File

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