feat: Implement full-width layout, sticky sidebar, and Archidekt-style stacked view for Cube Manager, extracting card preview components.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
12
docs/development/devlog/2025-12-17-003500_cube_full_width.md
Normal file
12
docs/development/devlog/2025-12-17-003500_cube_full_width.md
Normal 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.
|
||||
16
docs/development/devlog/2025-12-17-004500_archidekt_view.md
Normal file
16
docs/development/devlog/2025-12-17-004500_archidekt_view.md
Normal 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.
|
||||
73
src/client/src/components/CardPreview.tsx
Normal file
73
src/client/src/components/CardPreview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user