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

@@ -34,3 +34,6 @@
- [Game Type Filter](./devlog/2025-12-16-231000_game_type_filter.md): Completed. Added Paper/Digital filter to the expansion selection list.
- [Incremental Caching](./devlog/2025-12-16-233000_incremental_caching.md): Completed. Refactored data fetching to cache cards to the server incrementally per set, preventing PayloadTooLarge errors.
- [Server Graceful Shutdown Fix](./devlog/2025-12-17-002000_server_shutdown_fix.md): Completed. Implemented signal handling to ensure clean process exit on Ctrl+C.
- [Cube Sticky Sidebar](./devlog/2025-12-17-003000_cube_sticky_sidebar.md): Completed. Made the Cube Manager left sidebar sticky to improve usability with long pack lists.
- [Cube Full Width Layout](./devlog/2025-12-17-003500_cube_full_width.md): Completed. Updated Cube Manager to use the full screen width.
- [Cube Archidekt View](./devlog/2025-12-17-004500_archidekt_view.md): Completed. Implemented column-based stacked view for packs.

View File

@@ -0,0 +1,14 @@
# Cube Manager Sticky Sidebar
## Objective
Update the `CubeManager` layout to make the left-side settings/controls panel sticky. This allows the user to access controls (Generate, Reset, etc.) while scrolling through a long list of generated packs on the right.
## Changes
- Modified `src/client/src/modules/cube/CubeManager.tsx`:
- Added `sticky top-4` to the left column wrapper.
- Added `self-start` to ensure the sticky element doesn't stretch to the full height of the container (which would negate stickiness).
- Added `max-h-[calc(100vh-2rem)]` and `overflow-y-auto` to the left panel to ensure its content remains accessible if it exceeds the viewport height.
- Added `custom-scrollbar` styling for consistent aesthetics.
## Result
The left panel now follows the user's scroll position, improving usability for large pack generations.

View File

@@ -0,0 +1,12 @@
# Cube Manager Full Width Layout
## Objective
Update the `CubeManager` layout to utilize the full width of the screen, removing the maximum width constraint. This allows for better utilization of screen real estate, especially on wider monitors, and provides more space for the pack grid.
## Changes
- Modified `src/client/src/modules/cube/CubeManager.tsx`:
- Removed `max-w-7xl` and `mx-auto` classes from the main container.
- Added `w-full` to ensure the container spans the entire available width.
## Result
The Cube Manager interface now stretches to fill the viewport width, providing a more expansive view for managing packs and settings.

View File

@@ -0,0 +1,16 @@
# Cube Manager Archidekt View
## Objective
Implement an "Archidekt-style" stacked view for pack generation. This view organizes cards into columns by type (Creature, Instant, Land, etc.) with vertical overlapping to save space while keeping headers visible.
## Changes
1. **Refactor PackCard**: Extracted `FloatingPreview` and `CardHoverWrapper` into `src/client/src/components/CardPreview.tsx` to resolve circular dependencies and clean up `PackCard.tsx`.
2. **Update StackView**:
- Rewrite `StackView.tsx` to group cards by `typeLine` (categories: Creature, Planeswalker, Instant, Sorcery, Enchantment, Artifact, Land, Battle, Other).
- Sort cards within categories by CMC.
- Render columns using Flexbox.
- Implement overlapping "card strip" look using negative `margin-bottom` on cards.
- Value tuning: `margin-bottom: -125%` seems appropriate for a standard card aspect ratio to reveal the title bar.
## Result
The "Stack" view option in Cube Manager now renders packs as organized, sorted columns similar to deck-builder interfaces.

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={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>
{/* 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
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"
className={`relative w-full rounded-lg bg-slate-800 shadow-md border border-slate-950 overflow-hidden cursor-pointer group`}
style={{
zIndex: index,
transform: `rotate(${rotation}deg)`
// 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'
}}
>
{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}
<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>
)}
<div className={`absolute top-2 right-2 w-3 h-3 rounded-full shadow-md z-10 border ${colorClass}`} />
</div>
);
</CardHoverWrapper>
)
})}
</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>
)
})}
</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">