feat: Add PWA support and implement drag-and-drop functionality for deck building.
All checks were successful
Build and Deploy / build (push) Successful in 1m28s

This commit is contained in:
2025-12-17 19:16:55 +01:00
parent bf40784667
commit 2bbedfd17f
7 changed files with 4624 additions and 551 deletions

View File

@@ -0,0 +1,12 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="120" fill="#0F172A"/>
<path d="M128 128H384V384H128V128Z" fill="#1E293B" stroke="#10B981" stroke-width="24" stroke-linejoin="round"/>
<path d="M168 168H424V424H168V168Z" fill="#0F172A" stroke="#A855F7" stroke-width="24" stroke-linejoin="round"/>
<path d="M256 128V384M128 256H384" stroke="url(#paint0_radial)" stroke-width="4"/>
<defs>
<radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(256 256) rotate(90) scale(128)">
<stop stop-color="#10B981"/>
<stop offset="1" stop-color="#A855F7" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 738 B

View File

@@ -5,6 +5,8 @@ import { StackView } from '../../components/StackView';
import { FoilOverlay } from '../../components/CardPreview';
import { DraftCard } from '../../services/PackGeneratorService';
import { useCardTouch } from '../../utils/interaction';
import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
interface DeckBuilderViewProps {
roomId: string;
@@ -17,10 +19,64 @@ interface DeckBuilderViewProps {
const normalizeCard = (c: any): DraftCard => ({
...c,
finish: c.finish || 'nonfoil',
typeLine: c.typeLine || c.type_line,
// Ensure image is top-level for components that expect it
image: c.image || c.image_uris?.normal || c.card_faces?.[0]?.image_uris?.normal
});
// Draggable Wrapper for Cards
const DraggableCardWrapper = ({ children, card, source, disabled }: any) => {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: card.id,
data: { card, source },
disabled
});
const style = transform ? {
transform: CSS.Translate.toString(transform),
opacity: isDragging ? 0 : 1,
zIndex: isDragging ? 999 : undefined
} : undefined;
return (
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="touch-none">
{children}
</div>
);
};
// Draggable Wrapper for Lands (Special case: ID is generic until dropped)
const DraggableLandWrapper = ({ children, land }: any) => {
const id = `land-source-${land.name}`;
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: id,
data: { card: land, type: 'land' }
});
// For lands, we want to copy, so don't hide original
const style = transform ? {
transform: CSS.Translate.toString(transform),
zIndex: isDragging ? 999 : undefined,
opacity: isDragging ? 0.5 : 1 // Show ghost
} : undefined;
return (
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="touch-none">
{children}
</div>
);
};
// Droppable Zone
const DroppableZone = ({ id, children, className }: any) => {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div ref={setNodeRef} className={`${className} ${isOver ? 'ring-2 ring-emerald-500 bg-emerald-900/10' : ''}`}>
{children}
</div>
);
};
// 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';
@@ -56,7 +112,7 @@ const ListItem: React.FC<{ card: DraftCard; onClick?: () => void; onHover?: (c:
)}
</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="text-[10px] text-slate-600 font-mono uppercase opacity-0 group-hover:opacity-100 transition-opacity">{card.typeLine?.split('—')[0]?.trim()}</span>
<span className={`w-2 h-2 rounded-full border ${getRarityColorClass(card.rarity)} !p-0 !text-[0px]`}></span>
</div>
</div>
@@ -71,7 +127,8 @@ const CardsDisplay: React.FC<{
onCardClick: (c: any) => void;
onHover: (c: any) => void;
emptyMessage: string;
}> = ({ cards, viewMode, cardWidth, onCardClick, onHover, emptyMessage }) => {
source: 'pool' | 'deck';
}> = ({ cards, viewMode, cardWidth, onCardClick, onHover, emptyMessage, source }) => {
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">
@@ -85,7 +142,11 @@ const CardsDisplay: React.FC<{
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} />)}
{sorted.map(c => (
<DraggableCardWrapper key={c.id} card={c} source={source}>
<ListItem card={normalizeCard(c)} onClick={() => onCardClick(c)} onHover={onHover} />
</DraggableCardWrapper>
))}
</div>
);
}
@@ -93,6 +154,8 @@ const CardsDisplay: React.FC<{
if (viewMode === 'stack') {
return (
<div className="w-full h-full"> {/* Allow native scrolling from parent */}
{/* StackView doesn't support DnD yet, so we disable it or handle it differently.
For now, drag from StackView is not implemented, falling back to Click. */}
<StackView
cards={cards.map(normalizeCard)}
cardWidth={cardWidth}
@@ -119,14 +182,15 @@ const CardsDisplay: React.FC<{
const isFoil = card.finish === 'foil';
return (
<DeckCardItem
key={card.id}
card={card}
useArtCrop={useArtCrop}
isFoil={isFoil}
onCardClick={onCardClick}
onHover={onHover}
/>
<DraggableCardWrapper key={card.id} card={card} source={source}>
<DeckCardItem
card={card}
useArtCrop={useArtCrop}
isFoil={isFoil}
onCardClick={onCardClick}
onHover={onHover}
/>
</DraggableCardWrapper>
);
})}
</div>
@@ -154,8 +218,10 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
// --- Land Advice Logic ---
const landSuggestion = useMemo(() => {
// ... (logic remains same, simplified for brevity in thought but copied fully in implementation)
const targetLands = 17;
const existingLands = deck.filter(c => c.type_line && c.type_line.includes('Land')).length;
// @ts-ignore
const existingLands = deck.filter(c => (c.typeLine || c.type_line || '').includes('Land')).length;
const landsNeeded = Math.max(0, targetLands - existingLands);
if (landsNeeded === 0) return null;
@@ -164,7 +230,9 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
let totalPips = 0;
deck.forEach(card => {
if (card.type_line && card.type_line.includes('Land')) return;
// @ts-ignore
const tLine = card.typeLine || card.type_line;
if (tLine && tLine.includes('Land')) return;
if (!card.mana_cost) return;
const cost = card.mana_cost;
pips.Plains += (cost.match(/{W}/g) || []).length;
@@ -262,7 +330,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
id: `basic-${type}-${i}`,
name: type,
image_uris: { normal: landUrlMap[type] },
type_line: "Basic Land"
typeLine: "Basic Land"
}));
});
@@ -274,7 +342,40 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
return [...(availableBasicLands || [])].sort((a, b) => a.name.localeCompare(b.name));
}, [availableBasicLands]);
// --- Render Functions (Inline) ---
// --- DnD Handlers ---
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
useSensor(TouchSensor, { activationConstraint: { distance: 10 } })
);
const [draggedCard, setDraggedCard] = useState<any>(null);
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
setDraggedCard(active.data.current?.card);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) {
setDraggedCard(null);
return;
}
const data = active.data.current;
if (!data) return;
if (data.type === 'land' && over.id === 'deck-zone') {
addLandToDeck(data.card);
} else if (data.source === 'pool' && over.id === 'deck-zone') {
addToDeck(data.card);
} else if (data.source === 'deck' && over.id === 'pool-zone') {
removeFromDeck(data.card);
}
setDraggedCard(null);
};
// --- 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">
{/* Header & Advisor */}
@@ -301,22 +402,24 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
{availableBasicLands && availableBasicLands.length > 0 ? (
<div className="flex items-center gap-2 overflow-x-auto custom-scrollbar pb-1">
{sortedLands.map((land) => (
<div
key={land.scryfallId}
className="relative group cursor-pointer shrink-0"
onClick={() => addLandToDeck(land)}
onMouseEnter={() => setHoveredCard(land)}
onMouseLeave={() => setHoveredCard(null)}
>
<img
src={land.image || land.image_uris?.normal}
className="w-16 rounded shadow group-hover:scale-105 transition-transform"
alt={land.name}
/>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 bg-black/40 rounded transition-opacity">
<span className="text-white font-bold text-[10px] bg-black/50 px-1 rounded">+</span>
<DraggableLandWrapper key={land.scryfallId} land={land}>
<div
className="relative group cursor-pointer shrink-0"
onClick={() => addLandToDeck(land)}
onMouseEnter={() => setHoveredCard(land)}
onMouseLeave={() => setHoveredCard(null)}
>
<img
src={land.image || land.image_uris?.normal}
className="w-16 rounded shadow group-hover:scale-105 transition-transform"
alt={land.name}
draggable={false}
/>
<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>
</DraggableLandWrapper>
))}
</div>
) : (
@@ -338,152 +441,164 @@ 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()}>
{/* 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>
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
{/* Global Toolbar */}
<div className="h-14 bg-slate-800 border-b border-slate-700 flex items-center justify-between px-4 shrink-0 overflow-x-auto text-xs sm:text-sm">
<div className="flex items-center gap-4">
{/* Layout Switcher */}
<div className="hidden sm:flex bg-slate-900 rounded-lg p-1 border border-slate-700">
<button onClick={() => setLayout('vertical')} className={`p-1.5 rounded ${layout === 'vertical' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="Vertical Split"><Columns className="w-4 h-4" /></button>
<button onClick={() => setLayout('horizontal')} className={`p-1.5 rounded ${layout === 'horizontal' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="Horizontal Split"><LayoutTemplate className="w-4 h-4" /></button>
</div>
{/* View Mode Switcher */}
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700">
<button onClick={() => setViewMode('list')} className={`p-1.5 rounded ${viewMode === 'list' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="List View"><List className="w-4 h-4" /></button>
<button onClick={() => setViewMode('grid')} className={`p-1.5 rounded ${viewMode === 'grid' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="Grid View"><LayoutGrid className="w-4 h-4" /></button>
<button onClick={() => setViewMode('stack')} className={`p-1.5 rounded ${viewMode === 'stack' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="Stack View"><Layers className="w-4 h-4" /></button>
</div>
{/* Slider */}
<div className="hidden sm: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>
{/* 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 className="flex-1 flex overflow-hidden">
{/* 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 className="w-full relative sticky top-4">
<div
className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
style={{
transformStyle: 'preserve-3d',
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
}}
<div className="flex items-center gap-4">
<div className="hidden sm: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"
>
{/* Front Face (Hovered Card) */}
<div
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
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>
<Save className="w-4 h-4" /> <span className="hidden sm:inline">Submit Deck</span><span className="sm:hidden">Save</span>
</button>
</div>
</div>
{/* Back Face (Card Back) */}
<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 className="w-full relative sticky top-4">
<div
className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
style={{
backfaceVisibility: 'hidden',
transform: 'rotateY(180deg)'
transformStyle: 'preserve-3d',
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
}}
>
<img
src="/images/back.jpg"
alt="Card Back"
className="w-full h-full object-cover"
/>
{/* Front Face (Hovered Card) */}
<div
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
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"
draggable={false}
/>
<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).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">
{(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)}
</div>
)}
</div>
</div>
)}
</div>
{/* Back Face (Card Back) */}
<div
className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
style={{
backfaceVisibility: 'hidden',
transform: 'rotateY(180deg)'
}}
>
<img
src="/images/back.jpg"
alt="Card Back"
className="w-full h-full object-cover"
draggable={false}
/>
</div>
</div>
</div>
</div>
{/* Content Area */}
{layout === 'vertical' ? (
<div className="flex-1 flex flex-col lg:flex-row">
{/* 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">
<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" />
</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">
<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" source="deck" />
</div>
</DroppableZone>
</div>
) : (
<div className="flex-1 flex flex-col">
{/* 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>
</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" />
</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>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" source="deck" />
</div>
</DroppableZone>
</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>
<DragOverlay dropAnimation={{ duration: 200, easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)' }}>
{draggedCard ? (
<div className={`w-36 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} />
</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>
) : null}
</DragOverlay>
</DndContext>
</div>
);
};
@@ -500,13 +615,13 @@ const DeckCardItem = ({ card, useArtCrop, isFoil, onCardClick, onHover }: any) =
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchMove={onTouchMove}
className="relative group bg-slate-900 rounded-lg shrink-0 cursor-pointer hover:scale-105 transition-transform"
className="relative group bg-slate-900 rounded-lg shrink-0 cursor-pointer hover:scale-105 transition-transform touch-none"
>
<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.5 rounded backdrop-blur-sm">FOIL</div>}
{displayImage ? (
<img src={displayImage} alt={card.name} className="w-full h-full object-cover" />
<img src={displayImage} alt={card.name} className="w-full h-full object-cover" draggable={false} />
) : (
<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>
)}

View File

@@ -5,6 +5,8 @@ import { LogOut, Columns, LayoutTemplate } from 'lucide-react';
import { Modal } from '../../components/Modal';
import { FoilOverlay } from '../../components/CardPreview';
import { useCardTouch } from '../../utils/interaction';
import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
// Helper to normalize card data for visuals
const normalizeCard = (c: any) => ({
@@ -13,6 +15,19 @@ const normalizeCard = (c: any) => ({
image: c.image || c.image_uris?.normal || c.card_faces?.[0]?.image_uris?.normal
});
// Droppable Wrapper for Pool
const PoolDroppable = ({ children, className, style }: any) => {
const { setNodeRef, isOver } = useDroppable({
id: 'pool-zone',
});
return (
<div ref={setNodeRef} className={`${className} ${isOver ? 'ring-4 ring-emerald-500/50 bg-emerald-900/20' : ''}`} style={style}>
{children}
</div>
);
};
interface DraftViewProps {
draftState: any;
roomId: string; // Passed from parent
@@ -108,267 +123,297 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
const pickedCards = draftState.players[currentPlayerId]?.pool || [];
const handlePick = (cardId: string) => {
// roomId and playerId are now inferred by the server from socket session
socketService.socket.emit('pick_card', { cardId });
};
// ... inside DraftView return ...
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
useSensor(TouchSensor, { activationConstraint: { distance: 10 } })
);
const [draggedCard, setDraggedCard] = useState<any>(null);
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
setDraggedCard(active.data.current?.card);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && over.id === 'pool-zone') {
handlePick(active.id as string);
}
setDraggedCard(null);
};
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 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>
<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>
{/* Top Header: Timer & Pack Info */}
<div className="shrink-0 p-4 z-10">
<div className="flex justify-between items-center bg-slate-900/80 backdrop-blur border border-slate-800 p-4 rounded-lg shadow-lg">
<div className="flex items-center gap-8">
<div>
<h2 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-orange-500 shadow-amber-500/20 drop-shadow-sm">
Pack {draftState.packNumber}
</h2>
<span className="text-sm text-slate-400 font-medium">Pick {pickedCards.length % 15 + 1}</span>
</div>
{/* Layout Switcher */}
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700 h-10 items-center">
<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>
{/* Card Scalar */}
<div className="flex flex-col gap-1 w-24 md:w-32">
<label className="text-[10px] text-slate-500 uppercase font-bold tracking-wider">Card Size</label>
<input
type="range"
min="0.5"
max="1.5"
step="0.01"
value={cardScale}
onChange={(e) => setCardScale(parseFloat(e.target.value))}
className="w-full h-1 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-emerald-500"
/>
</div>
</div>
<div className="flex items-center gap-6">
{!activePack ? (
<div className="text-sm font-bold text-amber-500 animate-pulse uppercase tracking-wider">Waiting...</div>
) : (
<div className="text-4xl font-mono text-emerald-400 font-bold drop-shadow-[0_0_10px_rgba(52,211,153,0.5)]">
00:{timer < 10 ? `0${timer}` : timer}
{/* Top Header: Timer & Pack Info */}
<div className="shrink-0 p-4 z-10">
<div className="flex flex-col lg:flex-row justify-between items-center bg-slate-900/80 backdrop-blur border border-slate-800 p-4 rounded-lg shadow-lg gap-4 lg:gap-0">
<div className="flex flex-wrap justify-center items-center gap-4 lg:gap-8">
<div className="text-center lg:text-left">
<h2 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-orange-500 shadow-amber-500/20 drop-shadow-sm">
Pack {draftState.packNumber}
</h2>
<span className="text-sm text-slate-400 font-medium">Pick {pickedCards.length % 15 + 1}</span>
</div>
)}
{onExit && (
<button
onClick={() => setConfirmExitOpen(true)}
className="p-3 bg-slate-800 hover:bg-red-500/20 text-slate-400 hover:text-red-500 border border-slate-700 hover:border-red-500/50 rounded-xl transition-all shadow-lg group"
title="Exit to Lobby"
>
<LogOut className="w-5 h-5 group-hover:scale-110 transition-transform" />
</button>
)}
{/* Layout Switcher */}
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700 h-10 items-center">
<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>
{/* Card Scalar */}
<div className="flex flex-col gap-1 w-24 md:w-32">
<label className="text-[10px] text-slate-500 uppercase font-bold tracking-wider">Card Size</label>
<input
type="range"
min="0.5"
max="1.5"
step="0.01"
value={cardScale}
onChange={(e) => setCardScale(parseFloat(e.target.value))}
className="w-full h-1 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-emerald-500"
/>
</div>
</div>
<div className="flex items-center gap-6">
{!activePack ? (
<div className="text-sm font-bold text-amber-500 animate-pulse uppercase tracking-wider">Waiting...</div>
) : (
<div className="text-4xl font-mono text-emerald-400 font-bold drop-shadow-[0_0_10px_rgba(52,211,153,0.5)]">
00:{timer < 10 ? `0${timer}` : timer}
</div>
)}
{onExit && (
<button
onClick={() => setConfirmExitOpen(true)}
className="p-3 bg-slate-800 hover:bg-red-500/20 text-slate-400 hover:text-red-500 border border-slate-700 hover:border-red-500/50 rounded-xl transition-all shadow-lg group"
title="Exit to Lobby"
>
<LogOut className="w-5 h-5 group-hover:scale-110 transition-transform" />
</button>
)}
</div>
</div>
</div>
</div>
{/* Middle Content: Zoom Sidebar + Pack Grid */}
<div className="flex-1 flex overflow-hidden">
{/* Middle Content: Zoom Sidebar + Pack Grid */}
<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 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"
style={{
transformStyle: 'preserve-3d',
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
}}
>
{/* Front Face (Hovered Card) */}
{/* 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 className="w-full relative sticky top-8 px-6">
<div
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
style={{ backfaceVisibility: 'hidden' }}
className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
style={{
transformStyle: 'preserve-3d',
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
}}
>
{(hoveredCard || displayCard) && (
<div className="w-full h-full flex flex-col bg-slate-900 rounded-xl relative overflow-hidden">
<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 h-full object-cover rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
/>
{/* Foil Overlay for Preview */}
{((hoveredCard || displayCard).finish === 'foil') && <FoilOverlay />}
{/* Front Face (Hovered Card) */}
<div
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
style={{ backfaceVisibility: 'hidden' }}
>
{(hoveredCard || displayCard) && (
<div className="w-full h-full flex flex-col bg-slate-900 rounded-xl relative overflow-hidden">
<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 h-full object-cover rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
draggable={false}
/>
{/* Foil Overlay for Preview */}
{((hoveredCard || displayCard).finish === 'foil') && <FoilOverlay />}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent p-4 text-center z-20">
<h3 className="text-lg font-bold text-slate-200">{(hoveredCard || displayCard).name}</h3>
<p className="text-xs text-slate-300 uppercase tracking-wider mt-1">{(hoveredCard || displayCard).type_line}</p>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent p-4 text-center z-20">
<h3 className="text-lg font-bold text-slate-200">{(hoveredCard || displayCard).name}</h3>
<p className="text-xs text-slate-300 uppercase tracking-wider mt-1">{(hoveredCard || displayCard).type_line}</p>
</div>
</div>
)}
</div>
{/* Back Face (Card Back) */}
<div
className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
style={{
backfaceVisibility: 'hidden',
transform: 'rotateY(180deg)'
}}
>
<img
src="/images/back.jpg"
alt="Card Back"
className="w-full h-full object-cover"
draggable={false}
/>
</div>
</div>
{/* Oracle Text Box Below Card */}
{(hoveredCard || displayCard)?.oracle_text && (
<div className={`mt-6 text-xs text-slate-300 text-left bg-slate-900/80 backdrop-blur p-4 rounded-lg border border-slate-700 leading-relaxed transition-opacity duration-300 ${hoveredCard ? 'opacity-100' : 'opacity-0'}`}>
{(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-2 last:mb-0">{line}</p>)}
</div>
)}
</div>
</div>
{/* Main Content Area: Handles both Pack and Pool based on layout */}
{layout === 'vertical' ? (
<div className="flex-1 flex min-w-0">
{/* Left: Pack */}
<div className="flex-1 overflow-y-auto p-4 z-0 custom-scrollbar border-r border-slate-800">
{!activePack ? (
<div className="flex flex-col items-center justify-center min-h-full pb-10 fade-in animate-in duration-500">
<div className="w-24 h-24 mb-6 relative">
<div className="absolute inset-0 rounded-full border-4 border-slate-800"></div>
<div className="absolute inset-0 rounded-full border-t-4 border-emerald-500 animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<LogOut className="w-8 h-8 text-emerald-500 rotate-180" />
</div>
</div>
<h2 className="text-3xl font-bold text-white mb-2">Waiting...</h2>
<p className="text-slate-400">Your neighbor is picking.</p>
</div>
) : (
<div className="flex flex-col items-center justify-center min-h-full pb-10">
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold mb-8">Select a Card</h3>
<div className="flex flex-wrap justify-center gap-6">
{activePack.cards.map((rawCard: any) => (
<DraftCardItem
key={rawCard.id}
rawCard={rawCard}
cardScale={cardScale}
handlePick={handlePick}
setHoveredCard={setHoveredCard}
/>
))}
</div>
</div>
)}
</div>
{/* Back Face (Card Back) */}
<div
className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
style={{
backfaceVisibility: 'hidden',
transform: 'rotateY(180deg)'
}}
>
<img
src="/images/back.jpg"
alt="Card Back"
className="w-full h-full object-cover"
/>
</div>
</div>
{/* Oracle Text Box Below Card */}
{(hoveredCard || displayCard)?.oracle_text && (
<div className={`mt-6 text-xs text-slate-300 text-left bg-slate-900/80 backdrop-blur p-4 rounded-lg border border-slate-700 leading-relaxed transition-opacity duration-300 ${hoveredCard ? 'opacity-100' : 'opacity-0'}`}>
{(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-2 last:mb-0">{line}</p>)}
</div>
)}
</div>
</div>
{/* Main Content Area: Handles both Pack and Pool based on layout */}
{layout === 'vertical' ? (
<div className="flex-1 flex min-w-0">
{/* Left: Pack */}
<div className="flex-1 overflow-y-auto p-4 z-0 custom-scrollbar border-r border-slate-800">
{!activePack ? (
<div className="flex flex-col items-center justify-center min-h-full pb-10 fade-in animate-in duration-500">
<div className="w-24 h-24 mb-6 relative">
<div className="absolute inset-0 rounded-full border-4 border-slate-800"></div>
<div className="absolute inset-0 rounded-full border-t-4 border-emerald-500 animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<LogOut className="w-8 h-8 text-emerald-500 rotate-180" />
</div>
</div>
<h2 className="text-3xl font-bold text-white mb-2">Waiting...</h2>
<p className="text-slate-400">Your neighbor is picking.</p>
{/* Right: Pool (Vertical Column) */}
<PoolDroppable className="flex-1 bg-slate-900/50 flex flex-col min-w-0 border-l border-slate-800 transition-colors duration-200">
<div className="px-4 py-3 border-b border-slate-800 flex items-center justify-between shrink-0 bg-slate-900/80">
<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 flex-col items-center justify-center min-h-full pb-10">
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold mb-8">Select a Card</h3>
<div className="flex flex-wrap justify-center gap-6">
{activePack.cards.map((rawCard: any) => (
<DraftCardItem
key={rawCard.id}
rawCard={rawCard}
cardScale={cardScale}
handlePick={handlePick}
setHoveredCard={setHoveredCard}
/>
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar">
<div className="flex flex-wrap gap-4 content-start">
{pickedCards.map((card: any, idx: number) => (
<PoolCardItem key={`${card.id}-${idx}`} card={card} setHoveredCard={setHoveredCard} vertical={true} />
))}
</div>
</div>
)}
</PoolDroppable>
</div>
{/* Right: Pool (Vertical Column) */}
<div className="flex-1 bg-slate-900/50 flex flex-col min-w-0 border-l border-slate-800">
<div className="px-4 py-3 border-b border-slate-800 flex items-center justify-between shrink-0 bg-slate-900/80">
<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 className="flex-1 flex flex-col min-w-0">
{/* Top: Pack */}
<div className="flex-1 overflow-y-auto p-4 z-0 custom-scrollbar">
{!activePack ? (
<div className="flex flex-col items-center justify-center min-h-full pb-10 fade-in animate-in duration-500">
<div className="w-24 h-24 mb-6 relative">
<div className="absolute inset-0 rounded-full border-4 border-slate-800"></div>
<div className="absolute inset-0 rounded-full border-t-4 border-emerald-500 animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<LogOut className="w-8 h-8 text-emerald-500 rotate-180" />
</div>
</div>
<h2 className="text-3xl font-bold text-white mb-2">Waiting...</h2>
<p className="text-slate-400">Your neighbor is picking.</p>
</div>
) : (
<div className="flex flex-col items-center justify-center min-h-full pb-10">
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold mb-8">Select a Card</h3>
<div className="flex flex-wrap justify-center gap-6">
{activePack.cards.map((rawCard: any) => (
<DraftCardItem
key={rawCard.id}
rawCard={rawCard}
cardScale={cardScale}
handlePick={handlePick}
setHoveredCard={setHoveredCard}
/>
))}
</div>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar">
<div className="flex flex-wrap gap-4 content-start">
{/* 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}
>
<div className="w-16 h-1 bg-slate-600 rounded-full"></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} vertical={true} />
<PoolCardItem key={`${card.id}-${idx}`} card={card} setHoveredCard={setHoveredCard} />
))}
</div>
</div>
</div>
</div>
) : (
<div className="flex-1 flex flex-col min-w-0">
{/* Top: Pack */}
<div className="flex-1 overflow-y-auto p-4 z-0 custom-scrollbar">
{!activePack ? (
<div className="flex flex-col items-center justify-center min-h-full pb-10 fade-in animate-in duration-500">
<div className="w-24 h-24 mb-6 relative">
<div className="absolute inset-0 rounded-full border-4 border-slate-800"></div>
<div className="absolute inset-0 rounded-full border-t-4 border-emerald-500 animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<LogOut className="w-8 h-8 text-emerald-500 rotate-180" />
</div>
</div>
<h2 className="text-3xl font-bold text-white mb-2">Waiting...</h2>
<p className="text-slate-400">Your neighbor is picking.</p>
</div>
) : (
<div className="flex flex-col items-center justify-center min-h-full pb-10">
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold mb-8">Select a Card</h3>
<div className="flex flex-wrap justify-center gap-6">
{activePack.cards.map((rawCard: any) => (
<DraftCardItem
key={rawCard.id}
rawCard={rawCard}
cardScale={cardScale}
handlePick={handlePick}
setHoveredCard={setHoveredCard}
/>
))}
</div>
</div>
)}
</PoolDroppable>
</div>
)}
{/* 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}
>
<div className="w-16 h-1 bg-slate-600 rounded-full"></div>
</div>
</div>
<Modal
isOpen={confirmExitOpen}
onClose={() => setConfirmExitOpen(false)}
title="Exit Draft?"
message="Are you sure you want to exit the draft? You can rejoin later."
type="warning"
confirmLabel="Exit Draft"
cancelLabel="Stay"
onConfirm={onExit}
/>
{/* Bottom: Pool (Horizontal Strip) */}
<div
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>
{/* Drag Overlay */}
<DragOverlay dropAnimation={null}>
{draggedCard ? (
<div className="w-32 h-44 opacity-90 rotate-3 cursor-grabbing shadow-2xl">
<img src={draggedCard.image} alt={draggedCard.name} className="w-full h-full object-cover rounded-xl" draggable={false} />
</div>
</div>
)}
</div>
<Modal
isOpen={confirmExitOpen}
onClose={() => setConfirmExitOpen(false)}
title="Exit Draft?"
message="Are you sure you want to exit the draft? You can rejoin later."
type="warning"
confirmLabel="Exit Draft"
cancelLabel="Stay"
onConfirm={onExit}
/>
) : null}
</DragOverlay>
</DndContext>
</div>
);
};
@@ -378,10 +423,23 @@ const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any)
const isFoil = card.finish === 'foil';
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => handlePick(card.id), card);
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: card.id,
data: { card }
});
const style = transform ? {
transform: CSS.Translate.toString(transform),
opacity: isDragging ? 0 : 1, // Hide original when dragging
} : undefined;
return (
<div
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer"
style={{ width: `${14 * cardScale}rem` }}
ref={setNodeRef}
style={{ ...style, width: `${14 * cardScale}rem` }}
{...listeners}
{...attributes}
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer touch-none"
onClick={onClick}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
@@ -397,6 +455,7 @@ const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any)
src={card.image}
alt={card.name}
className="w-full h-full object-cover relative z-10"
draggable={false}
/>
{isFoil && <FoilOverlay />}
{isFoil && <div className="absolute top-2 right-2 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1.5 rounded backdrop-blur-sm border border-white/20">FOIL</div>}
@@ -422,6 +481,7 @@ const PoolCardItem = ({ card, setHoveredCard, vertical = false }: any) => {
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`}
draggable={false}
/>
</div>
)

View File

@@ -51,6 +51,7 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
const messagesEndRef = useRef<HTMLDivElement>(null);
const [gameState, setGameState] = useState<any>(initialGameState || null);
const [draftState, setDraftState] = useState<any>(initialDraftState || null);
const [mobileTab, setMobileTab] = useState<'game' | 'chat'>('game');
// Derived State
const host = room.players.find(p => p.isHost);
@@ -234,10 +235,33 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
};
return (
<div className="flex h-full gap-4">
{renderContent()}
<div className="flex h-full flex-col lg:flex-row gap-4 overflow-hidden">
{/* Mobile Tab Bar */}
<div className="lg:hidden shrink-0 flex items-center bg-slate-800 border-b border-slate-700">
<button
onClick={() => setMobileTab('game')}
className={`flex-1 p-3 flex items-center justify-center gap-2 text-sm font-bold transition-colors ${mobileTab === 'game' ? 'text-emerald-400 bg-slate-700/50 border-b-2 border-emerald-500' : 'text-slate-400 hover:text-slate-200'}`}
>
<Layers className="w-4 h-4" /> Game
</button>
<button
onClick={() => setMobileTab('chat')}
className={`flex-1 p-3 flex items-center justify-center gap-2 text-sm font-bold transition-colors ${mobileTab === 'chat' ? 'text-purple-400 bg-slate-700/50 border-b-2 border-purple-500' : 'text-slate-400 hover:text-slate-200'}`}
>
<div className="flex items-center gap-1">
<Users className="w-4 h-4" />
<span className="text-slate-600">/</span>
<MessageSquare className="w-4 h-4" />
</div>
Lobby & Chat
</button>
</div>
<div className="w-80 flex flex-col gap-4">
<div className={`flex-1 min-h-0 flex flex-col ${mobileTab === 'game' ? 'flex' : 'hidden lg:flex'}`}>
{renderContent()}
</div>
<div className={`w-full lg:w-80 shrink-0 flex flex-col gap-4 min-h-0 ${mobileTab === 'chat' ? 'flex' : 'hidden lg:flex'}`}>
<div className="flex-1 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl overflow-hidden flex flex-col">
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
<Users className="w-4 h-4" /> Lobby

4135
src/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,13 +11,17 @@
"start": "NODE_ENV=production tsx server/index.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"express": "^4.21.2",
"lucide-react": "^0.475.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"tsx": "^4.19.2"
"tsx": "^4.19.2",
"vite-plugin-pwa": "^1.2.0"
},
"devDependencies": {
"@types/express": "^4.17.21",

View File

@@ -1,9 +1,34 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import * as path from 'path';
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['icon.svg'],
manifest: {
name: 'MTG Draft Maker',
short_name: 'MTG Draft',
description: 'Multiplayer Magic: The Gathering Draft Simulator',
theme_color: '#0f172a',
background_color: '#0f172a',
display: 'standalone',
orientation: 'any',
start_url: '/',
icons: [
{
src: 'icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any maskable'
}
]
}
})
],
root: 'client', // Set root to client folder where index.html resides
build: {
outDir: '../dist', // Build to src/dist (outside client)