Compare commits

...

4 Commits

8 changed files with 448 additions and 207 deletions

View File

@@ -83,3 +83,5 @@
- [Deck Builder Magnified View](./devlog/2025-12-17-160500_deck_builder_magnified_view.md): Completed. Added magnified card preview sidebar to deck builder. - [Deck Builder Magnified View](./devlog/2025-12-17-160500_deck_builder_magnified_view.md): Completed. Added magnified card preview sidebar to deck builder.
- [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. - [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. - [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,18 @@
# Update Deck Auto-Fill Logic
## Request
The user requested that the "Auto-Fill" (Add Lands) button in the Deck Builder behave exactly like clicking individual lands in the Land Station. Specifically, lands should be added as individual card entries in the deck list so they can be viewed and removed one by one, rather than just updating a counter.
## Implementation Plan
1. Modify `applySuggestion` function in `src/client/src/modules/draft/DeckBuilderView.tsx`.
2. Check if `availableBasicLands` is populated (indicating the graphical Land Station is active).
3. If active, iterate through the suggested land counts.
4. For each count, find the corresponding land card object in `availableBasicLands`.
5. Generate unique card objects (with unique IDs) for each land instance, replicating the logic of `addLandToDeck`.
6. Add these new land objects to the `deck` state.
7. Retain the old counter-based logic as a fallback if `availableBasicLands` is empty.
## Status
- [x] Analyzed `DeckBuilderView.tsx` to understand current `applySuggestion` vs `addLandToDeck` logic.
- [x] Refactored `applySuggestion` to implement the new behavior.
- [x] Verified ID generation and state updates match existing patterns.

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,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,7 +1,9 @@
import React, { useState, useMemo } from 'react';
import React, { useState } from 'react';
import { socketService } from '../../services/SocketService'; import { socketService } from '../../services/SocketService';
import { Save, Layers, Clock } 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;
@@ -10,42 +12,163 @@ 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 [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);
const [displayCard, setDisplayCard] = useState<any>(null);
/* React.useEffect(() => {
// Disable timer countdown if (hoveredCard) {
useEffect(() => { setDisplayCard(hoveredCard);
const interval = setInterval(() => { }
setTimer(t => t > 0 ? t - 1 : 0); }, [hoveredCard]);
}, 1000);
return () => clearInterval(interval);
}, []);
*/
// --- 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;
@@ -55,26 +178,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 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; 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]]++;
@@ -85,15 +201,31 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
}, [deck]); }, [deck]);
const applySuggestion = () => { const applySuggestion = () => {
if (landSuggestion) { if (!landSuggestion) return;
if (availableBasicLands && availableBasicLands.length > 0) {
const newLands: any[] = [];
Object.entries(landSuggestion).forEach(([type, count]) => {
if (count <= 0) return;
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,
id: `land-${landCard.scryfallId}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}-${i}`,
image_uris: landCard.image_uris || { normal: landCard.image }
};
newLands.push(newLand);
}
}
});
if (newLands.length > 0) setDeck(prev => [...prev, ...newLands]);
} else {
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));
@@ -101,7 +233,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)}`,
@@ -112,10 +243,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]);
} }
}; };
@@ -133,7 +261,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,
@@ -146,190 +273,219 @@ 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]);
return ( // --- Render Functions (Inline) ---
<div className="flex-1 w-full flex h-full bg-slate-900 text-white"> const renderLandStation = () => (
{/* Column 1: Zoom Sidebar */} <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="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"> {/* Header & Advisor */}
{hoveredCard ? ( <div className="flex justify-between items-center bg-slate-800/50 p-2 rounded">
<div className="animate-in fade-in slide-in-from-left-4 duration-200 sticky top-4 w-full"> <h4 className="text-xs font-bold text-slate-400 uppercase">Land Station</h4>
<img {landSuggestion ? (
src={hoveredCard.image || hoveredCard.image_uris?.normal || hoveredCard.card_faces?.[0]?.image_uris?.normal} <div className="flex items-center gap-2">
alt={hoveredCard.name} <span className="text-[10px] text-slate-500">Advice:</span>
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10" <div className="flex gap-1">
/> {Object.entries(landSuggestion).map(([type, count]) => {
<div className="mt-4 text-center"> if ((count as number) <= 0) return null;
<h3 className="text-lg font-bold text-slate-200">{hoveredCard.name}</h3> 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';
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{hoveredCard.type_line}</p> return <span key={type} className={`text-[10px] font-bold ${color}`}>{type[0]}:{count as number}</span>
{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>
<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>
) : ( ) : (
<div className="flex flex-col items-center justify-center h-full text-slate-600 text-center opacity-50"> <span className="text-[10px] text-slate-600 italic">Add spells for advice</span>
<div className="w-48 h-64 border-2 border-dashed border-slate-700 rounded-xl mb-4 flex items-center justify-center">
<span className="text-xs uppercase font-bold tracking-widest">Hover Card</span>
</div>
<p className="text-sm">Hover over a card to view clear details.</p>
</div>
)} )}
</div> </div>
{/* Column 2: Pool */} {/* Land Scroll */}
<div className="flex-1 p-4 flex flex-col border-r border-slate-700 min-w-0"> {availableBasicLands && availableBasicLands.length > 0 ? (
<div className="flex justify-between items-center mb-4"> <div className="flex items-center gap-2 overflow-x-auto custom-scrollbar pb-1">
<h2 className="text-xl font-bold flex items-center gap-2"><Layers /> Card Pool ({pool.length})</h2> {sortedLands.map((land) => (
</div> <div
<div className="flex-1 overflow-y-auto p-2 bg-slate-950/50 rounded-lg custom-scrollbar"> key={land.scryfallId}
<div className="flex flex-wrap gap-2 justify-center content-start"> className="relative group cursor-pointer shrink-0"
{pool.map((card) => ( onClick={() => addLandToDeck(land)}
onMouseEnter={() => setHoveredCard(land)}
onMouseLeave={() => setHoveredCard(null)}
>
<img <img
key={card.id} src={land.image || land.image_uris?.normal}
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal} className="w-16 rounded shadow group-hover:scale-105 transition-transform"
className="w-28 hover:scale-110 transition-transform cursor-pointer rounded shadow-md" alt={land.name}
onClick={() => addToDeck(card)}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
title={card.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>
);
return (
<div className="flex-1 w-full flex h-full bg-slate-950 text-white overflow-hidden flex-col">
{/* Global Toolbar - Inlined */}
<div className="h-14 bg-slate-800 border-b border-slate-700 flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-4">
{/* Layout Switcher */}
<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> </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>
<button
onClick={submitDeck}
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
</button>
</div> </div>
</div> </div>
{/* Column 3: Deck & Lands */} <div className="flex-1 flex overflow-hidden">
<div className="flex-1 p-4 flex flex-col min-w-0"> {/* Zoom Sidebar */}
<div className="flex justify-between items-center mb-4"> <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" style={{ perspective: '1000px' }}>
<h2 className="text-xl font-bold">Your Deck ({deck.length + Object.values(lands).reduce((a, b) => a + b, 0)})</h2> <div className="w-full relative sticky top-4">
<div className="flex items-center gap-4"> <div
<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"> className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
<Clock className="w-5 h-5" /> {formatTime(timer)} style={{
</div> transformStyle: 'preserve-3d',
<button transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
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 {/* Front Face (Hovered Card) */}
</button> <div
</div> className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
</div> style={{ backfaceVisibility: 'hidden' }}
>
{(hoveredCard || displayCard) && (
<div className="w-full h-full flex flex-col bg-slate-900 rounded-xl">
<img
src={(hoveredCard || displayCard).image || (hoveredCard || displayCard).image_uris?.normal || (hoveredCard || displayCard).card_faces?.[0]?.image_uris?.normal}
alt={(hoveredCard || displayCard).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 || displayCard).name}</h3>
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{(hoveredCard || displayCard).type_line}</p>
{(hoveredCard || displayCard).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 || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)}
</div>
)}
</div>
</div>
)}
</div>
{/* Deck View */} {/* Back Face (Card Back) */}
<div className="flex-1 overflow-y-auto p-2 bg-slate-950/50 rounded-lg mb-4 custom-scrollbar"> <div
<div className="flex flex-wrap gap-2 justify-center content-start"> className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
{deck.map((card) => ( style={{
<img backfaceVisibility: 'hidden',
key={card.id} transform: 'rotateY(180deg)'
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)} <img
onMouseEnter={() => setHoveredCard(card)} src="/images/back.jpg"
onMouseLeave={() => setHoveredCard(null)} alt="Card Back"
title={card.name} className="w-full h-full object-cover"
/> />
))}
</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> </div>
</div> </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>
)}
</div> </div>
</div> </div>
{/* Content Area */}
{layout === 'vertical' ? (
<div className="flex-1 flex">
{/* Pool Column */}
<div className="flex-1 flex flex-col min-w-0 border-r border-slate-800 bg-slate-900/50">
<div className="p-3 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between">
<span>Card Pool ({pool.length})</span>
</div>
<div className="flex-1 overflow-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>
{/* Deck Column */}
<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">
<span>Deck ({deck.length})</span>
</div>
<div className="flex-1 overflow-auto p-2 custom-scrollbar">
<CardsDisplay cards={deck} viewMode={viewMode} cardWidth={cardWidth} onCardClick={removeFromDeck} onHover={setHoveredCard} emptyMessage="Your Deck is Empty" />
</div>
</div>
</div>
) : (
<div className="flex-1 flex flex-col">
{/* Top: Pool + Land Station */}
<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-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-auto p-2 custom-scrollbar">
<CardsDisplay cards={deck} viewMode={viewMode} cardWidth={cardWidth} onCardClick={removeFromDeck} onHover={setHoveredCard} emptyMessage="Your Deck is Empty" />
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -37,6 +37,7 @@ app.use(express.json({ limit: '50mb' })); // Increase limit for large card lists
// Serve static images (Nested) // Serve static images (Nested)
app.use('/cards', express.static(path.join(__dirname, 'public/cards'))); app.use('/cards', express.static(path.join(__dirname, 'public/cards')));
app.use('/images', express.static(path.join(__dirname, 'public/images')));
// API Routes // API Routes
app.get('/api/health', (_req: Request, res: Response) => { app.get('/api/health', (_req: Request, res: Response) => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

View File

@@ -19,6 +19,7 @@ export default defineConfig({
proxy: { proxy: {
'/api': 'http://localhost:3000', // Proxy API requests to backend '/api': 'http://localhost:3000', // Proxy API requests to backend
'/cards': 'http://localhost:3000', // Proxy cached card images '/cards': 'http://localhost:3000', // Proxy cached card images
'/images': 'http://localhost:3000', // Proxy static images
'/socket.io': { '/socket.io': {
target: 'http://localhost:3000', target: 'http://localhost:3000',
ws: true ws: true