feat: enhance UI with custom sort dropdown, resizable layouts, StackView DnD, and optimize slider/resize performance with layout fixes.
This commit is contained in:
@@ -13,6 +13,7 @@ interface StackViewProps {
|
||||
onHover?: (card: DraftCard | null) => void;
|
||||
disableHoverPreview?: boolean;
|
||||
groupBy?: GroupMode;
|
||||
renderWrapper?: (card: DraftCard, children: React.ReactNode) => React.ReactNode;
|
||||
}
|
||||
|
||||
const GROUPS: Record<GroupMode, string[]> = {
|
||||
@@ -71,7 +72,7 @@ const getCardGroup = (card: DraftCard, mode: GroupMode): string => {
|
||||
};
|
||||
|
||||
|
||||
export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, onCardClick, onHover, disableHoverPreview = false, groupBy = 'color' }) => {
|
||||
export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, onCardClick, onHover, disableHoverPreview = false, groupBy = 'color', renderWrapper }) => {
|
||||
|
||||
const categorizedCards = useMemo(() => {
|
||||
const categories: Record<string, DraftCard[]> = {};
|
||||
@@ -139,6 +140,7 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, on
|
||||
onHover={onHover}
|
||||
onCardClick={onCardClick}
|
||||
disableHoverPreview={disableHoverPreview}
|
||||
renderWrapper={renderWrapper}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -150,10 +152,10 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, on
|
||||
);
|
||||
};
|
||||
|
||||
const StackCardItem = ({ card, cardWidth, isLast, useArtCrop, displayImage, onHover, onCardClick, disableHoverPreview }: any) => {
|
||||
const StackCardItem = ({ card, cardWidth, isLast, useArtCrop, displayImage, onHover, onCardClick, disableHoverPreview, renderWrapper }: any) => {
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(onHover || (() => { }), () => onCardClick && onCardClick(card), card);
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<div
|
||||
className="relative w-full z-0 hover:z-50 transition-all duration-200 group"
|
||||
onMouseEnter={() => onHover && onHover(card)}
|
||||
@@ -177,4 +179,10 @@ const StackCardItem = ({ card, cardWidth, isLast, useArtCrop, displayImage, onHo
|
||||
</CardHoverWrapper>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (renderWrapper) {
|
||||
return renderWrapper(card, content);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
@@ -115,6 +115,14 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
||||
const saved = localStorage.getItem('cube_cardWidth');
|
||||
return saved ? parseInt(saved) : 60;
|
||||
});
|
||||
// Local state for smooth slider
|
||||
const [localCardWidth, setLocalCardWidth] = useState(cardWidth);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalCardWidth(cardWidth);
|
||||
if (containerRef.current) containerRef.current.style.setProperty('--card-width', `${cardWidth}px`);
|
||||
}, [cardWidth]);
|
||||
|
||||
// --- Persistence Effects ---
|
||||
useEffect(() => localStorage.setItem('cube_inputText', inputText), [inputText]);
|
||||
@@ -453,7 +461,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto w-full flex flex-col lg:flex-row gap-8 p-4 md:p-6">
|
||||
<div ref={containerRef} className="h-full overflow-y-auto w-full flex flex-col lg:flex-row gap-8 p-4 md:p-6" style={{ '--card-width': `${localCardWidth}px` } as React.CSSProperties}>
|
||||
|
||||
{/* --- LEFT COLUMN: CONTROLS --- */}
|
||||
<div className="w-full lg:w-1/3 lg:max-w-[400px] shrink-0 flex flex-col gap-4 lg:sticky lg:top-4 lg:self-start lg:max-h-[calc(100vh-10rem)] lg:overflow-y-auto custom-scrollbar p-1">
|
||||
@@ -841,10 +849,16 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
||||
min="60"
|
||||
max="200"
|
||||
step="1"
|
||||
value={cardWidth}
|
||||
onChange={(e) => setCardWidth(parseInt(e.target.value))}
|
||||
value={localCardWidth}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setLocalCardWidth(val);
|
||||
if (containerRef.current) containerRef.current.style.setProperty('--card-width', `${val}px`);
|
||||
}}
|
||||
onMouseUp={() => setCardWidth(localCardWidth)}
|
||||
onTouchEnd={() => setCardWidth(localCardWidth)}
|
||||
className="w-24 accent-purple-500 cursor-pointer h-1.5 bg-slate-600 rounded-lg appearance-none"
|
||||
title={`Card Size: ${cardWidth}px`}
|
||||
title={`Card Size: ${localCardWidth}px`}
|
||||
/>
|
||||
<div className="w-4 h-6 rounded border border-slate-500 bg-slate-700" title="Large Cards" />
|
||||
</div>
|
||||
@@ -870,12 +884,12 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
||||
className="grid gap-6 pb-20"
|
||||
style={{
|
||||
gridTemplateColumns: cardWidth <= 150
|
||||
? `repeat(auto-fill, minmax(${viewMode === 'list' ? '320px' : '550px'}, 1fr))`
|
||||
? `repeat(auto-fill, minmax(var(--card-width, ${viewMode === 'list' ? '320px' : '550px'}), 1fr))`
|
||||
: '1fr'
|
||||
}}
|
||||
>
|
||||
{packs.map((pack) => (
|
||||
<PackCard key={pack.id} pack={pack} viewMode={viewMode} cardWidth={cardWidth} />
|
||||
<PackCard key={pack.id} pack={pack} viewMode={viewMode} cardWidth={localCardWidth} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
import { Save, Layers, Clock, Columns, LayoutTemplate, List, LayoutGrid } from 'lucide-react';
|
||||
import { Save, Layers, Clock, Columns, LayoutTemplate, List, LayoutGrid, ChevronDown, Check, GripVertical } from 'lucide-react';
|
||||
import { StackView } from '../../components/StackView';
|
||||
import { FoilOverlay } from '../../components/CardPreview';
|
||||
import { DraftCard } from '../../services/PackGeneratorService';
|
||||
@@ -145,6 +145,7 @@ const CardsDisplay: React.FC<{
|
||||
)
|
||||
}
|
||||
|
||||
// Use CSS var for grid
|
||||
if (viewMode === 'list') {
|
||||
const sorted = [...cards].sort((a, b) => (a.cmc || 0) - (b.cmc || 0));
|
||||
return (
|
||||
@@ -176,6 +177,11 @@ const CardsDisplay: React.FC<{
|
||||
onHover={(c) => onHover(c)}
|
||||
disableHoverPreview={true}
|
||||
groupBy={groupBy}
|
||||
renderWrapper={(card, children) => (
|
||||
<DraggableCardWrapper key={card.id} card={card} source={source}>
|
||||
{children}
|
||||
</DraggableCardWrapper>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -186,7 +192,7 @@ const CardsDisplay: React.FC<{
|
||||
<div
|
||||
className="grid gap-4 pb-20 content-start"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${cardWidth}px, 1fr))`
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(var(--card-width, ${cardWidth}px), 1fr))`
|
||||
}}
|
||||
>
|
||||
{cards.map(c => {
|
||||
@@ -218,6 +224,39 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('stack'); // Default to stack as requested? Or keep grid. User didn't say default view, just default Order.
|
||||
const [groupBy, setGroupBy] = useState<'type' | 'color' | 'cmc' | 'rarity'>('color');
|
||||
const [cardWidth, setCardWidth] = useState(60);
|
||||
// Local state for smooth slider
|
||||
const [localCardWidth, setLocalCardWidth] = useState(cardWidth);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sync
|
||||
React.useEffect(() => {
|
||||
setLocalCardWidth(cardWidth);
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.setProperty('--card-width', `${cardWidth}px`);
|
||||
}
|
||||
}, [cardWidth]);
|
||||
|
||||
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
|
||||
|
||||
// --- Resize State ---
|
||||
const [sidebarWidth, setSidebarWidth] = useState(320); // Initial 320px
|
||||
const [poolHeightPercent, setPoolHeightPercent] = useState(60); // Initial 60% for pool (horizontal layout)
|
||||
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||
const poolRef = React.useRef<HTMLDivElement>(null);
|
||||
const resizingState = React.useRef<{
|
||||
startX: number,
|
||||
startY: number,
|
||||
startWidth: number,
|
||||
startHeightPercent: number,
|
||||
active: 'sidebar' | 'pool' | null
|
||||
}>({ startX: 0, startY: 0, startWidth: 0, startHeightPercent: 0, active: null });
|
||||
|
||||
// Initial visual set
|
||||
React.useEffect(() => {
|
||||
if (sidebarRef.current) sidebarRef.current.style.width = `${sidebarWidth}px`;
|
||||
if (poolRef.current) poolRef.current.style.height = `${poolHeightPercent}%`;
|
||||
}, []);
|
||||
|
||||
const [pool, setPool] = useState<any[]>(initialPool);
|
||||
const [deck, setDeck] = useState<any[]>([]);
|
||||
@@ -395,6 +434,79 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
setDraggedCard(null);
|
||||
};
|
||||
|
||||
// --- Resize Handlers ---
|
||||
// --- Resize Handlers ---
|
||||
const handleResizeStart = (type: 'sidebar' | 'pool', e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid scrolling/selection
|
||||
if (e.cancelable) e.preventDefault();
|
||||
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
const containerTop = 56;
|
||||
const containerHeight = window.innerHeight - containerTop;
|
||||
const currentPoolHeight = poolRef.current?.getBoundingClientRect().height || (containerHeight * 0.6);
|
||||
|
||||
resizingState.current = {
|
||||
startX: clientX,
|
||||
startY: clientY,
|
||||
startWidth: sidebarRef.current?.getBoundingClientRect().width || 320,
|
||||
startHeightPercent: poolHeightPercent,
|
||||
active: type
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onResizeMove);
|
||||
document.addEventListener('touchmove', onResizeMove, { passive: false });
|
||||
document.addEventListener('mouseup', onResizeEnd);
|
||||
document.addEventListener('touchend', onResizeEnd);
|
||||
document.body.style.cursor = type === 'sidebar' ? 'col-resize' : 'row-resize';
|
||||
};
|
||||
|
||||
const onResizeMove = React.useCallback((e: MouseEvent | TouchEvent) => {
|
||||
if (!resizingState.current.active) return;
|
||||
|
||||
if (e.cancelable) e.preventDefault();
|
||||
|
||||
const clientX = (e as TouchEvent).touches ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX;
|
||||
const clientY = (e as TouchEvent).touches ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (resizingState.current.active === 'sidebar' && sidebarRef.current) {
|
||||
const delta = clientX - resizingState.current.startX;
|
||||
const newWidth = Math.max(200, Math.min(600, resizingState.current.startWidth + delta));
|
||||
sidebarRef.current.style.width = `${newWidth}px`;
|
||||
}
|
||||
|
||||
if (resizingState.current.active === 'pool' && poolRef.current) {
|
||||
const containerTop = 56;
|
||||
const containerHeight = window.innerHeight - containerTop;
|
||||
const relativeY = clientY - containerTop;
|
||||
const percentage = (relativeY / containerHeight) * 100;
|
||||
const clamped = Math.max(20, Math.min(80, percentage));
|
||||
poolRef.current.style.height = `${clamped}%`;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onResizeEnd = React.useCallback(() => {
|
||||
if (resizingState.current.active === 'sidebar' && sidebarRef.current) {
|
||||
setSidebarWidth(parseInt(sidebarRef.current.style.width));
|
||||
}
|
||||
if (resizingState.current.active === 'pool' && poolRef.current) {
|
||||
const hStyle = poolRef.current.style.height;
|
||||
if (hStyle.includes('%')) {
|
||||
setPoolHeightPercent(parseFloat(hStyle));
|
||||
}
|
||||
}
|
||||
|
||||
resizingState.current.active = null;
|
||||
document.removeEventListener('mousemove', onResizeMove);
|
||||
document.removeEventListener('touchmove', onResizeMove);
|
||||
document.removeEventListener('mouseup', onResizeEnd);
|
||||
document.removeEventListener('touchend', onResizeEnd);
|
||||
document.body.style.cursor = 'default';
|
||||
}, []);
|
||||
|
||||
// --- Render Functions ---
|
||||
const renderLandStation = () => (
|
||||
<div className="bg-slate-900/40 rounded border border-slate-700/50 p-2 mb-2 shrink-0 flex flex-col gap-2">
|
||||
@@ -460,7 +572,12 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 w-full flex h-full bg-slate-950 text-white overflow-hidden flex-col select-none" onContextMenu={(e) => e.preventDefault()}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 w-full flex h-full bg-slate-950 text-white overflow-hidden flex-col select-none"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
style={{ '--card-width': `${localCardWidth}px` } as React.CSSProperties}
|
||||
>
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
{/* Global Toolbar */}
|
||||
{/* Global Toolbar */}
|
||||
@@ -473,20 +590,43 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
<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>
|
||||
|
||||
{/* Group By Dropdown (Only relevant for Stack View usually, but nice to have) */}
|
||||
{/* Group By Dropdown (Custom UI) */}
|
||||
{viewMode === 'stack' && (
|
||||
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700 h-9 items-center px-2 gap-2">
|
||||
<span className="text-[10px] text-slate-500 uppercase font-bold">Sort:</span>
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.value as any)}
|
||||
className="bg-transparent text-xs font-bold text-white outline-none cursor-pointer"
|
||||
<div className="relative z-50">
|
||||
<button
|
||||
onClick={() => setSortDropdownOpen(!sortDropdownOpen)}
|
||||
className="flex items-center gap-2 bg-slate-900 rounded-lg p-1.5 border border-slate-700 h-9 px-3 text-xs font-bold text-white hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<option value="color">Color</option>
|
||||
<option value="type">Type</option>
|
||||
<option value="cmc">Mana Value</option>
|
||||
<option value="rarity">Rarity</option>
|
||||
</select>
|
||||
<span className="text-slate-500 uppercase">Sort:</span>
|
||||
<span className="capitalize">{groupBy === 'cmc' ? 'Mana Value' : groupBy}</span>
|
||||
<ChevronDown className={`w-3 h-3 text-slate-400 transition-transform ${sortDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{sortDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setSortDropdownOpen(false)} />
|
||||
<div className="absolute top-full left-0 mt-2 w-40 bg-slate-800 border border-slate-700 rounded-xl shadow-xl overflow-hidden z-50 animate-in fade-in zoom-in-95 duration-200">
|
||||
{[
|
||||
{ value: 'color', label: 'Color' },
|
||||
{ value: 'type', label: 'Type' },
|
||||
{ value: 'cmc', label: 'Mana Value' },
|
||||
{ value: 'rarity', label: 'Rarity' }
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setGroupBy(opt.value as any);
|
||||
setSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-xs font-bold flex items-center justify-between ${groupBy === opt.value ? 'bg-purple-900/30 text-purple-200' : 'text-slate-300 hover:bg-slate-700 hover:text-white'}`}
|
||||
>
|
||||
{opt.label}
|
||||
{groupBy === opt.value && <Check className="w-3 h-3 text-purple-400" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -504,8 +644,14 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
min="60"
|
||||
max="200"
|
||||
step="1"
|
||||
value={cardWidth}
|
||||
onChange={(e) => setCardWidth(parseInt(e.target.value))}
|
||||
value={localCardWidth}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setLocalCardWidth(val);
|
||||
if (containerRef.current) containerRef.current.style.setProperty('--card-width', `${val}px`);
|
||||
}}
|
||||
onMouseUp={() => setCardWidth(localCardWidth)}
|
||||
onTouchEnd={() => setCardWidth(localCardWidth)}
|
||||
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" />
|
||||
@@ -527,7 +673,12 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
|
||||
<div className="flex-1 flex overflow-hidden lg:flex-row flex-col">
|
||||
{/* Zoom Sidebar */}
|
||||
<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' }}>
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className="hidden xl:flex shrink-0 flex-col items-center justify-start pt-4 border-r border-slate-800 bg-slate-900 z-10 p-4 relative"
|
||||
style={{ perspective: '1000px' }}
|
||||
>
|
||||
{/* Front content ... */}
|
||||
<div className="w-full relative sticky top-4">
|
||||
<div
|
||||
className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
|
||||
@@ -553,7 +704,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
<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).typeLine || (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">
|
||||
<div className="mt-4 text-xs text-slate-400 text-left bg-slate-950 p-3 rounded-lg border border-slate-800 leading-relaxed max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)}
|
||||
</div>
|
||||
)}
|
||||
@@ -579,11 +730,31 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 bg-transparent hover:bg-purple-500/50 cursor-col-resize z-50 flex flex-col justify-center items-center group transition-colors touch-none"
|
||||
onMouseDown={(e) => handleResizeStart('sidebar', e)}
|
||||
onTouchStart={(e) => handleResizeStart('sidebar', e)}
|
||||
>
|
||||
<div className="h-8 w-1 bg-slate-700/50 rounded-full group-hover:bg-purple-400 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
{layout === 'vertical' ? (
|
||||
<div className="flex-1 flex flex-col lg:flex-row">
|
||||
<div className="flex-1 flex flex-col lg:flex-row min-w-0">
|
||||
{/* Vertical layout typically means Pool Left / Deck Right or vice versa.
|
||||
The previous code had them side-by-side with equal flex.
|
||||
The request asks for Library to be resizable. In vertical mode they share width.
|
||||
We can add a splitter here if needed, but horizontal split (top/bottom) is more common for resizing.
|
||||
Let's stick to equal flex for vertical column mode for now, as it's cleaner,
|
||||
or implement width resizing if specifically requested.
|
||||
Given the constraints of "library section ... needs to be resizable", a Top/Bottom split is the only one
|
||||
where resizing makes distinct sense vs side-by-side.
|
||||
Wait, "library section" usually implies the Deck list.
|
||||
In side-by-side, we can resize the split.
|
||||
*/}
|
||||
{/* Pool Column */}
|
||||
<DroppableZone id="pool-zone" 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">
|
||||
@@ -594,6 +765,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
<CardsDisplay cards={pool} viewMode={viewMode} cardWidth={cardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" source="pool" groupBy={groupBy} />
|
||||
</div>
|
||||
</DroppableZone>
|
||||
|
||||
{/* Deck Column */}
|
||||
<DroppableZone id="deck-zone" 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">
|
||||
@@ -605,26 +777,45 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
</DroppableZone>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||
{/* Top: Pool + Land Station */}
|
||||
<DroppableZone id="pool-zone" 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>
|
||||
{/* Top: Pool + Land Station */}
|
||||
<div ref={poolRef} style={{ height: `${poolHeightPercent}%` }} className="flex flex-col border-b border-slate-800 bg-slate-900/50 overflow-hidden">
|
||||
<DroppableZone
|
||||
id="pool-zone"
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<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" source="pool" groupBy={groupBy} />
|
||||
</div>
|
||||
</DroppableZone>
|
||||
|
||||
{/* Resizer Handle */}
|
||||
<div
|
||||
className="h-2 bg-slate-800 hover:bg-purple-500/50 cursor-row-resize flex items-center justify-center shrink-0 z-20 group transition-colors touch-none"
|
||||
onMouseDown={(e) => handleResizeStart('pool', e)}
|
||||
onTouchStart={(e) => handleResizeStart('pool', e)}
|
||||
>
|
||||
<div className="w-16 h-1 bg-slate-600 rounded-full group-hover:bg-purple-300" />
|
||||
</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" source="pool" groupBy={groupBy} />
|
||||
</div>
|
||||
</DroppableZone>
|
||||
{/* Bottom: Deck */}
|
||||
<DroppableZone id="deck-zone" 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>Library ({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 Library is Empty" source="deck" groupBy={groupBy} />
|
||||
</div>
|
||||
</DroppableZone>
|
||||
|
||||
{/* Bottom: Deck */}
|
||||
<DroppableZone
|
||||
id="deck-zone"
|
||||
className="flex-1 flex flex-col min-h-0 bg-slate-900/50 overflow-hidden"
|
||||
>
|
||||
<div className="p-2 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between shrink-0">
|
||||
<span>Library ({deck.length})</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-2 custom-scrollbar">
|
||||
<CardsDisplay cards={deck} viewMode={viewMode} cardWidth={localCardWidth} onCardClick={removeFromDeck} onHover={setHoveredCard} emptyMessage="Your Library is Empty" source="deck" groupBy={groupBy} />
|
||||
</div>
|
||||
</DroppableZone>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -632,7 +823,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggedCard ? (
|
||||
<div
|
||||
style={{ width: `${cardWidth}px` }}
|
||||
style={{ width: `${localCardWidth}px` }}
|
||||
className={`rounded-xl shadow-2xl opacity-90 rotate-3 cursor-grabbing overflow-hidden ring-2 ring-emerald-500 bg-slate-900 aspect-[2.5/3.5]`}
|
||||
>
|
||||
<img src={draggedCard.image || draggedCard.image_uris?.normal} alt={draggedCard.name} className="w-full h-full object-cover" draggable={false} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
import { LogOut, Columns, LayoutTemplate } from 'lucide-react';
|
||||
import { Modal } from '../../components/Modal';
|
||||
@@ -58,19 +58,49 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
return () => clearInterval(interval);
|
||||
}, [pickExpiresAt]);
|
||||
|
||||
|
||||
|
||||
// --- UI State & Persistence ---
|
||||
const [sidebarWidth, setSidebarWidth] = useState(320);
|
||||
const [poolHeight, setPoolHeight] = useState<number>(() => {
|
||||
const saved = localStorage.getItem('draft_poolHeight');
|
||||
return saved ? parseInt(saved, 10) : 220;
|
||||
});
|
||||
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||
const poolRef = React.useRef<HTMLDivElement>(null);
|
||||
const resizingState = React.useRef<{
|
||||
startX: number,
|
||||
startY: number,
|
||||
startWidth: number,
|
||||
startHeight: number,
|
||||
active: 'sidebar' | 'pool' | null
|
||||
}>({ startX: 0, startY: 0, startWidth: 0, startHeight: 0, active: null });
|
||||
|
||||
// Apply initial sizes visually without causing re-renders
|
||||
useEffect(() => {
|
||||
if (sidebarRef.current) sidebarRef.current.style.width = `${sidebarWidth}px`;
|
||||
if (poolRef.current) poolRef.current.style.height = `${poolHeight}px`;
|
||||
}, []); // Only on mount to set initial visual state, subsequent updates handled by resize logic
|
||||
|
||||
|
||||
const [cardScale, setCardScale] = useState<number>(() => {
|
||||
const saved = localStorage.getItem('draft_cardScale');
|
||||
return saved ? parseFloat(saved) : 0.35;
|
||||
});
|
||||
// Local state for smooth slider
|
||||
const [localCardScale, setLocalCardScale] = useState(cardScale);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sync local state if external update happens
|
||||
useEffect(() => {
|
||||
setLocalCardScale(cardScale);
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.setProperty('--card-scale', cardScale.toString());
|
||||
}
|
||||
}, [cardScale]);
|
||||
|
||||
const [layout, setLayout] = useState<'vertical' | 'horizontal'>('horizontal');
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
// Persist settings
|
||||
useEffect(() => {
|
||||
@@ -81,34 +111,68 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
localStorage.setItem('draft_cardScale', cardScale.toString());
|
||||
}, [cardScale]);
|
||||
|
||||
// Resize Handlers
|
||||
const startResizing = (e: React.MouseEvent) => {
|
||||
setIsResizing(true);
|
||||
e.preventDefault();
|
||||
const handleResizeStart = (type: 'sidebar' | 'pool', e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid scrolling/selection
|
||||
if (e.cancelable) e.preventDefault();
|
||||
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
resizingState.current = {
|
||||
startX: clientX,
|
||||
startY: clientY,
|
||||
startWidth: sidebarRef.current?.getBoundingClientRect().width || 320,
|
||||
startHeight: poolRef.current?.getBoundingClientRect().height || 220,
|
||||
active: type
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onResizeMove);
|
||||
document.addEventListener('touchmove', onResizeMove, { passive: false });
|
||||
document.addEventListener('mouseup', onResizeEnd);
|
||||
document.addEventListener('touchend', onResizeEnd);
|
||||
document.body.style.cursor = type === 'sidebar' ? 'col-resize' : 'row-resize';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const stopResizing = () => setIsResizing(false);
|
||||
const resize = (e: MouseEvent) => {
|
||||
if (isResizing) {
|
||||
const newHeight = window.innerHeight - e.clientY;
|
||||
// Limits: Min 100px, Max 60% of screen
|
||||
const maxHeight = window.innerHeight * 0.6;
|
||||
if (newHeight >= 100 && newHeight <= maxHeight) {
|
||||
setPoolHeight(newHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
const onResizeMove = React.useCallback((e: MouseEvent | TouchEvent) => {
|
||||
if (!resizingState.current.active) return;
|
||||
|
||||
if (isResizing) {
|
||||
document.addEventListener('mousemove', resize);
|
||||
document.addEventListener('mouseup', stopResizing);
|
||||
if (e.cancelable) e.preventDefault();
|
||||
|
||||
const clientX = (e as TouchEvent).touches ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX;
|
||||
const clientY = (e as TouchEvent).touches ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY;
|
||||
|
||||
// Direct DOM manipulation for performance
|
||||
requestAnimationFrame(() => {
|
||||
if (resizingState.current.active === 'sidebar' && sidebarRef.current) {
|
||||
const delta = clientX - resizingState.current.startX;
|
||||
const newWidth = Math.max(200, Math.min(600, resizingState.current.startWidth + delta));
|
||||
sidebarRef.current.style.width = `${newWidth}px`;
|
||||
}
|
||||
|
||||
if (resizingState.current.active === 'pool' && poolRef.current) {
|
||||
const delta = resizingState.current.startY - clientY; // Dragging up increases height
|
||||
const newHeight = Math.max(100, Math.min(window.innerHeight * 0.6, resizingState.current.startHeight + delta));
|
||||
poolRef.current.style.height = `${newHeight}px`;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onResizeEnd = React.useCallback(() => {
|
||||
// Commit final state
|
||||
if (resizingState.current.active === 'sidebar' && sidebarRef.current) {
|
||||
setSidebarWidth(parseInt(sidebarRef.current.style.width));
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', resize);
|
||||
document.removeEventListener('mouseup', stopResizing);
|
||||
};
|
||||
}, [isResizing]);
|
||||
if (resizingState.current.active === 'pool' && poolRef.current) {
|
||||
setPoolHeight(parseInt(poolRef.current.style.height));
|
||||
}
|
||||
|
||||
resizingState.current.active = null;
|
||||
document.removeEventListener('mousemove', onResizeMove);
|
||||
document.removeEventListener('touchmove', onResizeMove);
|
||||
document.removeEventListener('mouseup', onResizeEnd);
|
||||
document.removeEventListener('touchend', onResizeEnd);
|
||||
document.body.style.cursor = 'default';
|
||||
}, []);
|
||||
|
||||
const [hoveredCard, setHoveredCard] = useState<any>(null);
|
||||
const [displayCard, setDisplayCard] = useState<any>(null);
|
||||
@@ -152,7 +216,12 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 w-full flex flex-col h-full bg-slate-950 text-white overflow-hidden relative select-none" onContextMenu={(e) => e.preventDefault()}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 w-full flex flex-col h-full bg-slate-950 text-white overflow-hidden relative select-none"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
style={{ '--card-scale': localCardScale } as React.CSSProperties}
|
||||
>
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black opacity-50 pointer-events-none"></div>
|
||||
|
||||
@@ -193,8 +262,17 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
min="0.35"
|
||||
max="1.0"
|
||||
step="0.01"
|
||||
value={cardScale}
|
||||
onChange={(e) => setCardScale(parseFloat(e.target.value))}
|
||||
value={localCardScale}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setLocalCardScale(val);
|
||||
// Direct DOM update for performance
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.setProperty('--card-scale', val.toString());
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => setCardScale(localCardScale)}
|
||||
onTouchEnd={() => setCardScale(localCardScale)}
|
||||
className="w-full h-1 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
@@ -225,7 +303,11 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
|
||||
{/* Dedicated Zoom Zone (Left Sidebar) */}
|
||||
<div className="hidden lg:flex w-80 shrink-0 flex-col items-center justify-start pt-8 border-r border-slate-800/50 bg-slate-900/20 backdrop-blur-sm z-10 transition-all" style={{ perspective: '1000px' }}>
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className="hidden lg:flex shrink-0 flex-col items-center justify-start pt-8 border-r border-slate-800/50 bg-slate-900/20 backdrop-blur-sm z-10 relative"
|
||||
style={{ perspective: '1000px' }}
|
||||
>
|
||||
<div className="w-full relative sticky top-8 px-6">
|
||||
<div
|
||||
className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
|
||||
@@ -282,6 +364,14 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Resize Handle for Sidebar */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 bg-transparent hover:bg-emerald-500/50 cursor-col-resize z-50 flex flex-col justify-center items-center group transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart('sidebar', e)}
|
||||
onTouchStart={(e) => handleResizeStart('sidebar', e)}
|
||||
>
|
||||
<div className="h-8 w-1 bg-slate-700/50 rounded-full group-hover:bg-emerald-400 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area: Handles both Pack and Pool based on layout */}
|
||||
@@ -372,29 +462,31 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className="h-1 bg-slate-800 hover:bg-emerald-500 cursor-row-resize z-30 transition-colors w-full flex items-center justify-center shrink-0"
|
||||
onMouseDown={startResizing}
|
||||
className="h-2 bg-slate-800 hover:bg-emerald-500/50 cursor-row-resize z-30 transition-colors w-full flex items-center justify-center shrink-0 group touch-none"
|
||||
onMouseDown={(e) => handleResizeStart('pool', e)}
|
||||
onTouchStart={(e) => handleResizeStart('pool', e)}
|
||||
>
|
||||
<div className="w-16 h-1 bg-slate-600 rounded-full"></div>
|
||||
<div className="w-16 h-1 bg-slate-600 rounded-full group-hover:bg-emerald-300"></div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Pool (Horizontal Strip) */}
|
||||
<PoolDroppable
|
||||
className="shrink-0 bg-slate-900/90 backdrop-blur-md flex flex-col z-20 shadow-[-10px_-10px_30px_rgba(0,0,0,0.3)] transition-all ease-out duration-75 border-t border-slate-800"
|
||||
style={{ height: `${poolHeight}px` }}
|
||||
>
|
||||
<div className="px-6 py-2 flex items-center justify-between shrink-0">
|
||||
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
Your Pool ({pickedCards.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-x-auto flex items-center gap-2 px-6 pb-4 custom-scrollbar">
|
||||
{pickedCards.map((card: any, idx: number) => (
|
||||
<PoolCardItem key={`${card.id}-${idx}`} card={card} setHoveredCard={setHoveredCard} />
|
||||
))}
|
||||
</div>
|
||||
</PoolDroppable>
|
||||
<div ref={poolRef} style={{ height: `${poolHeight}px` }} className="shrink-0 flex flex-col overflow-hidden">
|
||||
<PoolDroppable
|
||||
className="flex-1 bg-slate-900/90 backdrop-blur-md flex flex-col z-20 shadow-[-10px_-10px_30px_rgba(0,0,0,0.3)] border-t border-slate-800 min-h-0"
|
||||
>
|
||||
<div className="px-6 py-2 flex items-center justify-between shrink-0">
|
||||
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
Your Pool ({pickedCards.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-x-auto flex gap-2 px-6 pb-2 pt-2 custom-scrollbar min-h-0">
|
||||
{pickedCards.map((card: any, idx: number) => (
|
||||
<PoolCardItem key={`${card.id}-${idx}`} card={card} setHoveredCard={setHoveredCard} />
|
||||
))}
|
||||
</div>
|
||||
</PoolDroppable>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -415,7 +507,7 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
{draggedCard ? (
|
||||
<div
|
||||
className="opacity-90 rotate-3 cursor-grabbing shadow-2xl rounded-xl"
|
||||
style={{ width: `${14 * cardScale}rem`, aspectRatio: '2.5/3.5' }}
|
||||
style={{ width: `calc(14rem * var(--card-scale, ${localCardScale}))`, aspectRatio: '2.5/3.5' }}
|
||||
>
|
||||
<img src={draggedCard.image} alt={draggedCard.name} className="w-full h-full object-cover rounded-xl" draggable={false} />
|
||||
</div>
|
||||
@@ -474,7 +566,7 @@ const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any)
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ ...style, width: `${14 * cardScale}rem` }}
|
||||
style={{ ...style, width: `calc(14rem * var(--card-scale))` }}
|
||||
{...attributes}
|
||||
{...mergedListeners}
|
||||
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer"
|
||||
@@ -506,7 +598,7 @@ const PoolCardItem = ({ card, setHoveredCard, vertical = false }: any) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative group shrink-0 transition-all flex items-center cursor-pointer ${vertical ? 'w-24 h-32' : 'h-full'}`}
|
||||
className={`relative group shrink-0 flex items-center justify-center cursor-pointer ${vertical ? 'w-24 h-32' : 'h-full aspect-[2.5/3.5] p-2'}`}
|
||||
onMouseEnter={() => setHoveredCard(card)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
onTouchStart={onTouchStart}
|
||||
@@ -517,7 +609,7 @@ const PoolCardItem = ({ card, setHoveredCard, vertical = false }: any) => {
|
||||
<img
|
||||
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
|
||||
alt={card.name}
|
||||
className={`${vertical ? 'w-full h-full object-cover' : 'h-[90%] w-auto object-contain'} rounded-lg shadow-lg border border-slate-700/50 group-hover:border-emerald-500/50 group-hover:shadow-emerald-500/20 transition-all`}
|
||||
className={`${vertical ? 'w-full h-full object-cover' : 'h-full w-auto object-contain'} rounded-lg shadow-lg border border-slate-700/50 group-hover:border-emerald-500/50 group-hover:shadow-emerald-500/20 transition-all`}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user