feat: Implement full-width layout, sticky sidebar, and Archidekt-style stacked view for Cube Manager, extracting card preview components.

This commit is contained in:
2025-12-17 00:32:39 +01:00
parent 66cec64223
commit 0f82be86c3
8 changed files with 208 additions and 118 deletions

View File

@@ -0,0 +1,73 @@
import React, { useState, useEffect, useRef } from 'react';
import { DraftCard } from '../services/PackGeneratorService';
// --- Floating Preview Component ---
export const FloatingPreview: React.FC<{ card: DraftCard; x: number; y: number }> = ({ card, x, y }) => {
const isFoil = card.finish === 'foil';
const imgRef = useRef<HTMLImageElement>(null);
// Basic boundary detection
const [adjustedPos, setAdjustedPos] = useState({ top: y, left: x });
useEffect(() => {
const OFFSET = 20;
const CARD_WIDTH = 300;
const CARD_HEIGHT = 420;
let newX = x + OFFSET;
let newY = y + OFFSET;
if (newX + CARD_WIDTH > window.innerWidth) {
newX = x - CARD_WIDTH - OFFSET;
}
if (newY + CARD_HEIGHT > window.innerHeight) {
newY = y - CARD_HEIGHT - OFFSET;
}
setAdjustedPos({ top: newY, left: newX });
}, [x, y]);
return (
<div
className="fixed z-[9999] pointer-events-none transition-opacity duration-75"
style={{
top: adjustedPos.top,
left: adjustedPos.left
}}
>
<div className="relative w-[300px] rounded-xl overflow-hidden shadow-2xl border-4 border-slate-900 bg-black">
<img ref={imgRef} src={card.image} alt={card.name} className="w-full h-auto" />
{isFoil && <div className="absolute inset-0 bg-gradient-to-br from-purple-500/20 to-blue-500/20 mix-blend-overlay animate-pulse"></div>}
</div>
</div>
);
};
// --- Hover Wrapper to handle mouse events ---
export const CardHoverWrapper: React.FC<{ card: DraftCard; children: React.ReactNode; className?: string }> = ({ card, children, className }) => {
const [isHovering, setIsHovering] = useState(false);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const hasImage = !!card.image;
const handleMouseMove = (e: React.MouseEvent) => {
if (!hasImage) return;
setMousePos({ x: e.clientX, y: e.clientY });
};
return (
<div
className={className}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onMouseMove={handleMouseMove}
>
{children}
{isHovering && hasImage && (
<FloatingPreview card={card} x={mousePos.x} y={mousePos.y} />
)}
</div>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React from 'react';
import { DraftCard, Pack } from '../services/PackGeneratorService';
import { Copy } from 'lucide-react';
import { StackView } from './StackView';
@@ -8,82 +8,7 @@ interface PackCardProps {
viewMode: 'list' | 'grid' | 'stack';
}
// --- Floating Preview Component ---
const FloatingPreview: React.FC<{ card: DraftCard; x: number; y: number }> = ({ card, x, y }) => {
const isFoil = card.finish === 'foil';
const imgRef = useRef<HTMLImageElement>(null);
// Basic boundary detection to prevent going off-screen
// We check window dimensions. This might need customization based on the actual viewport,
// but window is a good safe default.
const [adjustedPos, setAdjustedPos] = useState({ top: y, left: x });
useEffect(() => {
// Offset from cursor
const OFFSET = 20;
const CARD_WIDTH = 300; // Approx width of preview
const CARD_HEIGHT = 420; // Approx height of preview
let newX = x + OFFSET;
let newY = y + OFFSET;
// Flip horizontally if too close to right edge
if (newX + CARD_WIDTH > window.innerWidth) {
newX = x - CARD_WIDTH - OFFSET;
}
// Flip vertically if too close to bottom edge
if (newY + CARD_HEIGHT > window.innerHeight) {
newY = y - CARD_HEIGHT - OFFSET;
}
setAdjustedPos({ top: newY, left: newX });
}, [x, y]);
return (
<div
className="fixed z-[9999] pointer-events-none transition-opacity duration-75"
style={{
top: adjustedPos.top,
left: adjustedPos.left
}}
>
<div className="relative w-[300px] rounded-xl overflow-hidden shadow-2xl border-4 border-slate-900 bg-black">
<img ref={imgRef} src={card.image} alt={card.name} className="w-full h-auto" />
{isFoil && <div className="absolute inset-0 bg-gradient-to-br from-purple-500/20 to-blue-500/20 mix-blend-overlay animate-pulse"></div>}
</div>
</div>
);
};
// --- Hover Wrapper to handle mouse events ---
const CardHoverWrapper: React.FC<{ card: DraftCard; children: React.ReactNode; className?: string }> = ({ card, children, className }) => {
const [isHovering, setIsHovering] = useState(false);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
// Only show preview if there is an image
const hasImage = !!card.image;
const handleMouseMove = (e: React.MouseEvent) => {
if (!hasImage) return;
setMousePos({ x: e.clientX, y: e.clientY });
};
return (
<div
className={className}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onMouseMove={handleMouseMove}
>
{children}
{isHovering && hasImage && (
<FloatingPreview card={card} x={mousePos.x} y={mousePos.y} />
)}
</div>
);
};
import { CardHoverWrapper } from './CardPreview';
const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {

View File

@@ -1,53 +1,100 @@
import React from 'react';
import React, { useMemo } from 'react';
import { DraftCard } from '../services/PackGeneratorService';
import { CardHoverWrapper } from './CardPreview';
interface StackViewProps {
cards: DraftCard[];
}
const CATEGORY_ORDER = [
'Creature',
'Planeswalker',
'Instant',
'Sorcery',
'Enchantment',
'Artifact',
'Land',
'Battle',
'Other'
];
export const StackView: React.FC<StackViewProps> = ({ cards }) => {
const getRarityColorClass = (rarity: string) => {
switch (rarity) {
case 'common': return 'bg-black text-white border-slate-600';
case 'uncommon': return 'bg-slate-300 text-slate-900 border-white';
case 'rare': return 'bg-yellow-500 text-yellow-950 border-yellow-200';
case 'mythic': return 'bg-orange-600 text-white border-orange-300';
default: return 'bg-slate-500';
}
};
const categorizedCards = useMemo(() => {
const categories: Record<string, DraftCard[]> = {};
CATEGORY_ORDER.forEach(c => categories[c] = []);
cards.forEach(card => {
let category = 'Other';
const typeLine = card.typeLine || '';
if (typeLine.includes('Creature')) category = 'Creature'; // Includes Artifact Creature, Ench Creature
else if (typeLine.includes('Planeswalker')) category = 'Planeswalker';
else if (typeLine.includes('Instant')) category = 'Instant';
else if (typeLine.includes('Sorcery')) category = 'Sorcery';
else if (typeLine.includes('Enchantment')) category = 'Enchantment';
else if (typeLine.includes('Artifact')) category = 'Artifact';
else if (typeLine.includes('Battle')) category = 'Battle';
else if (typeLine.includes('Land')) category = 'Land';
// Special handling: Commander? usually Creature or Planeswalker
// Ensure it lands in one of the predefined bins
categories[category].push(card);
});
// Sort cards within categories by CMC (low to high)? Or Rarity?
// Archidekt usually sorts by CMC.
Object.keys(categories).forEach(key => {
categories[key].sort((a, b) => (a.cmc || 0) - (b.cmc || 0));
});
return categories;
}, [cards]);
return (
<div className="relative w-full max-w-sm mx-auto group perspective-1000 py-20">
<div className="relative flex flex-col items-center transition-all duration-500 ease-in-out group-hover:space-y-4 space-y-[-16rem] py-10">
{cards.map((card, index) => {
const colorClass = getRarityColorClass(card.rarity);
// Random slight rotation for "organic" look
const rotation = (index % 2 === 0 ? 1 : -1) * (Math.random() * 2);
<div className="flex flex-row gap-2 overflow-x-auto pb-8 snap-x">
{CATEGORY_ORDER.map(category => {
const catCards = categorizedCards[category];
if (catCards.length === 0) return null;
return (
<div
key={card.id}
className="relative w-64 aspect-[2.5/3.5] rounded-xl shadow-2xl transition-transform duration-300 hover:scale-110 hover:z-50 hover:rotate-0 origin-center bg-slate-800 border-2 border-slate-900"
style={{
zIndex: index,
transform: `rotate(${rotation}deg)`
}}
>
{card.image ? (
<img src={card.image} alt={card.name} className="w-full h-full object-cover rounded-lg" />
) : (
<div className="w-full h-full p-4 text-center flex items-center justify-center font-bold text-slate-500">
{card.name}
</div>
)}
<div className={`absolute top-2 right-2 w-3 h-3 rounded-full shadow-md z-10 border ${colorClass}`} />
return (
<div key={category} className="flex-shrink-0 w-44 snap-start">
{/* Header */}
<div className="flex justify-between items-center mb-2 px-1 border-b border-slate-700 pb-1">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">{category}</span>
<span className="text-xs font-mono text-slate-500">{catCards.length}</span>
</div>
);
})}
</div>
<div className="text-center text-slate-500 text-xs mt-4 opacity-50 group-hover:opacity-0 transition-opacity">
Hover to expand stack
</div>
{/* Stack */}
<div className="flex flex-col relative px-2">
{catCards.map((card, index) => {
// Margin calculation: Negative margin to pull up next cards.
// To show a "strip" of say 35px at the top of each card.
const isLast = index === catCards.length - 1;
return (
<CardHoverWrapper key={card.id} card={card} className="relative w-full z-0 hover:z-50 transition-all duration-200">
<div
className={`relative w-full rounded-lg bg-slate-800 shadow-md border border-slate-950 overflow-hidden cursor-pointer group`}
style={{
// Aspect ratio is maintained by image or div dimensions
// With overlap, we just render them one after another with negative margin
marginBottom: isLast ? '0' : '-125%', // Negative margin to show header
aspectRatio: '2.5/3.5'
}}
>
<img src={card.image} alt={card.name} className="w-full h-full object-cover" />
{/* Optional: Shine effect for foils if visible? */}
{card.finish === 'foil' && <div className="absolute inset-0 bg-white/10 opacity-30 pointer-events-none" />}
</div>
</CardHoverWrapper>
)
})}
</div>
</div>
)
})}
</div>
);
};

View File

@@ -324,10 +324,10 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
};
return (
<div className="h-full overflow-y-auto max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-8 p-4 md:p-6">
<div className="h-full overflow-y-auto w-full grid grid-cols-1 lg:grid-cols-12 gap-8 p-4 md:p-6">
{/* --- LEFT COLUMN: CONTROLS --- */}
<div className="lg:col-span-4 flex flex-col gap-4">
<div className="lg:col-span-4 flex flex-col gap-4 sticky top-4 self-start max-h-[calc(100vh-2rem)] overflow-y-auto custom-scrollbar p-1">
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl">
{/* Source Toggle */}
<div className="flex p-1 bg-slate-900 rounded-lg mb-4 border border-slate-700">