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 { FoilOverlay } from '../../components/CardPreview';
import { DraftCard } from '../../services/PackGeneratorService'; import { DraftCard } from '../../services/PackGeneratorService';
import { useCardTouch } from '../../utils/interaction'; 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 { interface DeckBuilderViewProps {
roomId: string; roomId: string;
@@ -17,10 +19,64 @@ interface DeckBuilderViewProps {
const normalizeCard = (c: any): DraftCard => ({ const normalizeCard = (c: any): DraftCard => ({
...c, ...c,
finish: c.finish || 'nonfoil', finish: c.finish || 'nonfoil',
typeLine: c.typeLine || c.type_line,
// Ensure image is top-level for components that expect it // Ensure image is top-level for components that expect it
image: c.image || c.image_uris?.normal || c.card_faces?.[0]?.image_uris?.normal 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 // Reusable List Item Component
const ListItem: React.FC<{ card: DraftCard; onClick?: () => void; onHover?: (c: any) => void }> = ({ card, onClick, onHover }) => { const ListItem: React.FC<{ card: DraftCard; onClick?: () => void; onHover?: (c: any) => void }> = ({ card, onClick, onHover }) => {
const isFoil = (card: DraftCard) => card.finish === 'foil'; const isFoil = (card: DraftCard) => card.finish === 'foil';
@@ -56,7 +112,7 @@ const ListItem: React.FC<{ card: DraftCard; onClick?: () => void; onHover?: (c:
)} )}
</span> </span>
<div className="flex items-center gap-2 shrink-0"> <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> <span className={`w-2 h-2 rounded-full border ${getRarityColorClass(card.rarity)} !p-0 !text-[0px]`}></span>
</div> </div>
</div> </div>
@@ -71,7 +127,8 @@ const CardsDisplay: React.FC<{
onCardClick: (c: any) => void; onCardClick: (c: any) => void;
onHover: (c: any) => void; onHover: (c: any) => void;
emptyMessage: string; emptyMessage: string;
}> = ({ cards, viewMode, cardWidth, onCardClick, onHover, emptyMessage }) => { source: 'pool' | 'deck';
}> = ({ cards, viewMode, cardWidth, onCardClick, onHover, emptyMessage, source }) => {
if (cards.length === 0) { if (cards.length === 0) {
return ( 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"> <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)); const sorted = [...cards].sort((a, b) => (a.cmc || 0) - (b.cmc || 0));
return ( return (
<div className="flex flex-col gap-1 w-full"> <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> </div>
); );
} }
@@ -93,6 +154,8 @@ const CardsDisplay: React.FC<{
if (viewMode === 'stack') { if (viewMode === 'stack') {
return ( return (
<div className="w-full h-full"> {/* Allow native scrolling from parent */} <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 <StackView
cards={cards.map(normalizeCard)} cards={cards.map(normalizeCard)}
cardWidth={cardWidth} cardWidth={cardWidth}
@@ -119,14 +182,15 @@ const CardsDisplay: React.FC<{
const isFoil = card.finish === 'foil'; const isFoil = card.finish === 'foil';
return ( return (
<DraggableCardWrapper key={card.id} card={card} source={source}>
<DeckCardItem <DeckCardItem
key={card.id}
card={card} card={card}
useArtCrop={useArtCrop} useArtCrop={useArtCrop}
isFoil={isFoil} isFoil={isFoil}
onCardClick={onCardClick} onCardClick={onCardClick}
onHover={onHover} onHover={onHover}
/> />
</DraggableCardWrapper>
); );
})} })}
</div> </div>
@@ -154,8 +218,10 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
// --- Land Advice Logic --- // --- Land Advice Logic ---
const landSuggestion = useMemo(() => { const landSuggestion = useMemo(() => {
// ... (logic remains same, simplified for brevity in thought but copied fully in implementation)
const targetLands = 17; 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); const landsNeeded = Math.max(0, targetLands - existingLands);
if (landsNeeded === 0) return null; if (landsNeeded === 0) return null;
@@ -164,7 +230,9 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
let totalPips = 0; let totalPips = 0;
deck.forEach(card => { 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; if (!card.mana_cost) return;
const cost = card.mana_cost; const cost = card.mana_cost;
pips.Plains += (cost.match(/{W}/g) || []).length; pips.Plains += (cost.match(/{W}/g) || []).length;
@@ -262,7 +330,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
id: `basic-${type}-${i}`, id: `basic-${type}-${i}`,
name: type, name: type,
image_uris: { normal: landUrlMap[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)); return [...(availableBasicLands || [])].sort((a, b) => a.name.localeCompare(b.name));
}, [availableBasicLands]); }, [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 = () => ( 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"> <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 */} {/* Header & Advisor */}
@@ -301,8 +402,8 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
{availableBasicLands && availableBasicLands.length > 0 ? ( {availableBasicLands && availableBasicLands.length > 0 ? (
<div className="flex items-center gap-2 overflow-x-auto custom-scrollbar pb-1"> <div className="flex items-center gap-2 overflow-x-auto custom-scrollbar pb-1">
{sortedLands.map((land) => ( {sortedLands.map((land) => (
<DraggableLandWrapper key={land.scryfallId} land={land}>
<div <div
key={land.scryfallId}
className="relative group cursor-pointer shrink-0" className="relative group cursor-pointer shrink-0"
onClick={() => addLandToDeck(land)} onClick={() => addLandToDeck(land)}
onMouseEnter={() => setHoveredCard(land)} onMouseEnter={() => setHoveredCard(land)}
@@ -312,11 +413,13 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
src={land.image || land.image_uris?.normal} src={land.image || land.image_uris?.normal}
className="w-16 rounded shadow group-hover:scale-105 transition-transform" className="w-16 rounded shadow group-hover:scale-105 transition-transform"
alt={land.name} 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"> <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> <span className="text-white font-bold text-[10px] bg-black/50 px-1 rounded">+</span>
</div> </div>
</div> </div>
</DraggableLandWrapper>
))} ))}
</div> </div>
) : ( ) : (
@@ -338,11 +441,12 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
return ( 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 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 */} <DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="h-14 bg-slate-800 border-b border-slate-700 flex items-center justify-between px-4 shrink-0"> {/* 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"> <div className="flex items-center gap-4">
{/* Layout Switcher */} {/* Layout Switcher */}
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700"> <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('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> <button onClick={() => setLayout('horizontal')} className={`p-1.5 rounded ${layout === 'horizontal' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="Horizontal Split"><LayoutTemplate className="w-4 h-4" /></button>
</div> </div>
@@ -355,7 +459,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
</div> </div>
{/* Slider */} {/* 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="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" /> <div className="w-2 h-3 rounded border border-slate-500 bg-slate-700" />
<input <input
type="range" type="range"
@@ -371,19 +475,19 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
</div> </div>
<div className="flex items-center gap-4"> <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"> <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)} <Clock className="w-4 h-4" /> {formatTime(timer)}
</div> </div>
<button <button
onClick={submitDeck} 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" 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 <Save className="w-4 h-4" /> <span className="hidden sm:inline">Submit Deck</span><span className="sm:hidden">Save</span>
</button> </button>
</div> </div>
</div> </div>
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden lg:flex-row flex-col">
{/* Zoom Sidebar */} {/* 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="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="w-full relative sticky top-4">
@@ -405,10 +509,11 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
src={(hoveredCard || displayCard).image || (hoveredCard || displayCard).image_uris?.normal || (hoveredCard || displayCard).card_faces?.[0]?.image_uris?.normal} src={(hoveredCard || displayCard).image || (hoveredCard || displayCard).image_uris?.normal || (hoveredCard || displayCard).card_faces?.[0]?.image_uris?.normal}
alt={(hoveredCard || displayCard).name} alt={(hoveredCard || displayCard).name}
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10" className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
draggable={false}
/> />
<div className="mt-4 text-center"> <div className="mt-4 text-center">
<h3 className="text-lg font-bold text-slate-200">{(hoveredCard || displayCard).name}</h3> <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> <p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{(hoveredCard || displayCard).typeLine || (hoveredCard || displayCard).type_line}</p>
{(hoveredCard || displayCard).oracle_text && ( {(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">
{(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)} {(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)}
@@ -431,6 +536,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
src="/images/back.jpg" src="/images/back.jpg"
alt="Card Back" alt="Card Back"
className="w-full h-full object-cover" className="w-full h-full object-cover"
draggable={false}
/> />
</div> </div>
</div> </div>
@@ -439,51 +545,60 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
{/* Content Area */} {/* Content Area */}
{layout === 'vertical' ? ( {layout === 'vertical' ? (
<div className="flex-1 flex"> <div className="flex-1 flex flex-col lg:flex-row">
{/* Pool Column */} {/* Pool Column */}
<div className="flex-1 flex flex-col min-w-0 border-r border-slate-800 bg-slate-900/50"> <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"> <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> <span>Card Pool ({pool.length})</span>
</div> </div>
<div className="flex-1 overflow-auto p-2 custom-scrollbar flex flex-col"> <div className="flex-1 overflow-auto p-2 custom-scrollbar flex flex-col">
{renderLandStation()} {renderLandStation()}
<CardsDisplay cards={pool} viewMode={viewMode} cardWidth={cardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" /> <CardsDisplay cards={pool} viewMode={viewMode} cardWidth={cardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" source="pool" />
</div>
</div> </div>
</DroppableZone>
{/* Deck Column */} {/* Deck Column */}
<div className="flex-1 flex flex-col min-w-0 bg-slate-900/50"> <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"> <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> <span>Deck ({deck.length})</span>
</div> </div>
<div className="flex-1 overflow-auto p-2 custom-scrollbar"> <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" /> <CardsDisplay cards={deck} viewMode={viewMode} cardWidth={cardWidth} onCardClick={removeFromDeck} onHover={setHoveredCard} emptyMessage="Your Deck is Empty" source="deck" />
</div>
</div> </div>
</DroppableZone>
</div> </div>
) : ( ) : (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
{/* Top: Pool + Land Station */} {/* Top: Pool + Land Station */}
<div className="flex-1 flex flex-col min-h-0 border-b border-slate-800 bg-slate-900/50"> <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"> <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> <span>Card Pool ({pool.length})</span>
</div> </div>
<div className="flex-1 overflow-auto p-2 custom-scrollbar flex flex-col"> <div className="flex-1 overflow-auto p-2 custom-scrollbar flex flex-col">
{renderLandStation()} {renderLandStation()}
<CardsDisplay cards={pool} viewMode={viewMode} cardWidth={cardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" /> <CardsDisplay cards={pool} viewMode={viewMode} cardWidth={cardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" source="pool" />
</div>
</div> </div>
</DroppableZone>
{/* Bottom: Deck */} {/* Bottom: Deck */}
<div className="h-[40%] flex flex-col min-h-0 bg-slate-900/50"> <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"> <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> <span>Deck ({deck.length})</span>
</div> </div>
<div className="flex-1 overflow-auto p-2 custom-scrollbar"> <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" /> <CardsDisplay cards={deck} viewMode={viewMode} cardWidth={cardWidth} onCardClick={removeFromDeck} onHover={setHoveredCard} emptyMessage="Your Deck is Empty" source="deck" />
</div>
</div> </div>
</DroppableZone>
</div> </div>
)} )}
</div> </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>
) : null}
</DragOverlay>
</DndContext>
</div> </div>
); );
}; };
@@ -500,13 +615,13 @@ const DeckCardItem = ({ card, useArtCrop, isFoil, onCardClick, onHover }: any) =
onTouchStart={onTouchStart} onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd} onTouchEnd={onTouchEnd}
onTouchMove={onTouchMove} 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'}`}> <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 && <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>} {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 ? ( {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> <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 { Modal } from '../../components/Modal';
import { FoilOverlay } from '../../components/CardPreview'; import { FoilOverlay } from '../../components/CardPreview';
import { useCardTouch } from '../../utils/interaction'; 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 // Helper to normalize card data for visuals
const normalizeCard = (c: any) => ({ 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 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 { interface DraftViewProps {
draftState: any; draftState: any;
roomId: string; // Passed from parent roomId: string; // Passed from parent
@@ -108,21 +123,39 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
const pickedCards = draftState.players[currentPlayerId]?.pool || []; const pickedCards = draftState.players[currentPlayerId]?.pool || [];
const handlePick = (cardId: string) => { const handlePick = (cardId: string) => {
// roomId and playerId are now inferred by the server from socket session
socketService.socket.emit('pick_card', { cardId }); 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 ( 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="flex-1 w-full flex flex-col h-full bg-slate-950 text-white overflow-hidden relative select-none" onContextMenu={(e) => e.preventDefault()}>
<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> <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 */} {/* Top Header: Timer & Pack Info */}
<div className="shrink-0 p-4 z-10"> <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 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 items-center gap-8"> <div className="flex flex-wrap justify-center items-center gap-4 lg:gap-8">
<div> <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"> <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} Pack {draftState.packNumber}
</h2> </h2>
@@ -207,6 +240,7 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
src={(hoveredCard || displayCard).image || (hoveredCard || displayCard).image_uris?.normal || (hoveredCard || displayCard).card_faces?.[0]?.image_uris?.normal} src={(hoveredCard || displayCard).image || (hoveredCard || displayCard).image_uris?.normal || (hoveredCard || displayCard).card_faces?.[0]?.image_uris?.normal}
alt={(hoveredCard || displayCard).name} alt={(hoveredCard || displayCard).name}
className="w-full h-full object-cover rounded-xl shadow-2xl shadow-black ring-1 ring-white/10" className="w-full h-full object-cover rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
draggable={false}
/> />
{/* Foil Overlay for Preview */} {/* Foil Overlay for Preview */}
{((hoveredCard || displayCard).finish === 'foil') && <FoilOverlay />} {((hoveredCard || displayCard).finish === 'foil') && <FoilOverlay />}
@@ -231,6 +265,7 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
src="/images/back.jpg" src="/images/back.jpg"
alt="Card Back" alt="Card Back"
className="w-full h-full object-cover" className="w-full h-full object-cover"
draggable={false}
/> />
</div> </div>
</div> </div>
@@ -280,7 +315,7 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
</div> </div>
{/* Right: Pool (Vertical Column) */} {/* Right: Pool (Vertical Column) */}
<div className="flex-1 bg-slate-900/50 flex flex-col min-w-0 border-l border-slate-800"> <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"> <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"> <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> <span className="w-2 h-2 rounded-full bg-emerald-500"></span>
@@ -294,7 +329,7 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
))} ))}
</div> </div>
</div> </div>
</div> </PoolDroppable>
</div> </div>
) : ( ) : (
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
@@ -339,7 +374,7 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
</div> </div>
{/* Bottom: Pool (Horizontal Strip) */} {/* Bottom: Pool (Horizontal Strip) */}
<div <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" 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` }} style={{ height: `${poolHeight}px` }}
> >
@@ -354,7 +389,7 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
<PoolCardItem key={`${card.id}-${idx}`} card={card} setHoveredCard={setHoveredCard} /> <PoolCardItem key={`${card.id}-${idx}`} card={card} setHoveredCard={setHoveredCard} />
))} ))}
</div> </div>
</div> </PoolDroppable>
</div> </div>
)} )}
@@ -369,6 +404,16 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
cancelLabel="Stay" cancelLabel="Stay"
onConfirm={onExit} onConfirm={onExit}
/> />
{/* 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>
) : null}
</DragOverlay>
</DndContext>
</div> </div>
); );
}; };
@@ -378,10 +423,23 @@ const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any)
const isFoil = card.finish === 'foil'; const isFoil = card.finish === 'foil';
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => handlePick(card.id), card); 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 ( return (
<div <div
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer" ref={setNodeRef}
style={{ width: `${14 * cardScale}rem` }} 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} onClick={onClick}
onMouseEnter={() => setHoveredCard(card)} onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)} onMouseLeave={() => setHoveredCard(null)}
@@ -397,6 +455,7 @@ const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any)
src={card.image} src={card.image}
alt={card.name} alt={card.name}
className="w-full h-full object-cover relative z-10" className="w-full h-full object-cover relative z-10"
draggable={false}
/> />
{isFoil && <FoilOverlay />} {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>} {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} src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
alt={card.name} 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-[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> </div>
) )

View File

@@ -51,6 +51,7 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const [gameState, setGameState] = useState<any>(initialGameState || null); const [gameState, setGameState] = useState<any>(initialGameState || null);
const [draftState, setDraftState] = useState<any>(initialDraftState || null); const [draftState, setDraftState] = useState<any>(initialDraftState || null);
const [mobileTab, setMobileTab] = useState<'game' | 'chat'>('game');
// Derived State // Derived State
const host = room.players.find(p => p.isHost); const host = room.players.find(p => p.isHost);
@@ -234,10 +235,33 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
}; };
return ( return (
<div className="flex h-full gap-4"> <div className="flex h-full flex-col lg:flex-row gap-4 overflow-hidden">
{renderContent()} {/* 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"> <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"> <h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
<Users className="w-4 h-4" /> Lobby <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" "start": "NODE_ENV=production tsx server/index.ts"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"express": "^4.21.2", "express": "^4.21.2",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tsx": "^4.19.2" "tsx": "^4.19.2",
"vite-plugin-pwa": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",

View File

@@ -1,9 +1,34 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import * as path from 'path'; import * as path from 'path';
export default defineConfig({ 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 root: 'client', // Set root to client folder where index.html resides
build: { build: {
outDir: '../dist', // Build to src/dist (outside client) outDir: '../dist', // Build to src/dist (outside client)