feat: refactor StackView for dynamic grouping and add sorting controls to Deck Builder while reducing card size slider ranges.

This commit is contained in:
2025-12-18 01:30:48 +01:00
parent 0ca29622ef
commit 851e2aa81d
6 changed files with 159 additions and 55 deletions

View File

@@ -97,3 +97,4 @@
- [Mobile Touch Preview](./devlog/2025-12-18-012500_mobile_touch_preview.md): Completed. Updated card preview logic to disable hover and enable long-press on touch devices, improving usability on tablets and mobile. - [Mobile Touch Preview](./devlog/2025-12-18-012500_mobile_touch_preview.md): Completed. Updated card preview logic to disable hover and enable long-press on touch devices, improving usability on tablets and mobile.
- [Minimize Slider Defaults](./devlog/2025-12-18-013000_minimize_slider_defaults.md): Completed. Set default card size settings to their minimum values across Cube Manager, Draft View, and Deck Builder. - [Minimize Slider Defaults](./devlog/2025-12-18-013000_minimize_slider_defaults.md): Completed. Set default card size settings to their minimum values across Cube Manager, Draft View, and Deck Builder.
- [Deck Builder Touch Interaction](./devlog/2025-12-18-014500_deck_builder_touch.md): Completed. Renamed "Deck" to "Library" and implemented tap-to-preview logic on touch devices, disabling tap-to-move. - [Deck Builder Touch Interaction](./devlog/2025-12-18-014500_deck_builder_touch.md): Completed. Renamed "Deck" to "Library" and implemented tap-to-preview logic on touch devices, disabling tap-to-move.
- [Stack View Sorting & Sliders](./devlog/2025-12-18-020000_stack_sorting_sliders.md): Completed. Refactored StackView to group by Color by default, added sorting controls to Deck Builder, and reduced slider scales globally to allow smaller sizes.

View File

@@ -0,0 +1,34 @@
# Work Plan - Stack View Sorting & Slider Updates
## Request
1. **Slider Adjustment**: Decrease the scale of sliders globally.
* New Min (0%) should be smaller (~50% of previous min?).
* New Max (100%) should be equivalent to old 50%.
2. **Stack View Default Sort**: "Order for Color and Mana Cost" by default everywhere.
3. **Deck Builder Sorting**: Add UI to change sort order manually in Deck Builder.
## Changes
- **StackView.tsx**:
- Refactored to support dynamic `groupBy` logic (Type, Color, CMC, Rarity).
- Implemented categorization logic for Color, CMC, and Rarity.
- Set default `groupBy` to `'color'` (sorts by Color groups, then CMC within groups).
- Fixed syntax errors from previous edit.
- **DeckBuilderView.tsx**:
- Added `groupBy` state (default `'color'`).
- Added "Sort:" dropdown to toolbar when in Stack View.
- Updated `CardsDisplay` to pass sorting preferences.
- Updated Slider range to `min="60" max="200"` (Default `60`).
- **CubeManager.tsx**:
- Updated Slider range to `min="60" max="200"`.
- Updated default `cardWidth` to `60`.
- **DraftView.tsx**:
- Updated Slider range to `min="0.35" max="1.0"`.
- Updated default `cardScale` to `0.35`.
## Verification
- Verified `StackView` defaults to Color grouping in `CubeManager` (implicitly via default prop).
- Verified Deck Builder has sorting controls.
- Verified all sliders allow for much smaller card sizes.

View File

@@ -3,63 +3,111 @@ import { DraftCard } from '../services/PackGeneratorService';
import { FoilOverlay, CardHoverWrapper } from './CardPreview'; import { FoilOverlay, CardHoverWrapper } from './CardPreview';
import { useCardTouch } from '../utils/interaction'; import { useCardTouch } from '../utils/interaction';
type GroupMode = 'type' | 'color' | 'cmc' | 'rarity';
interface StackViewProps { interface StackViewProps {
cards: DraftCard[]; cards: DraftCard[];
cardWidth?: number; cardWidth?: number;
onCardClick?: (card: DraftCard) => void; onCardClick?: (card: DraftCard) => void;
onHover?: (card: DraftCard | null) => void; onHover?: (card: DraftCard | null) => void;
disableHoverPreview?: boolean; disableHoverPreview?: boolean;
groupBy?: GroupMode;
} }
const CATEGORY_ORDER = [ const GROUPS: Record<GroupMode, string[]> = {
'Creature', type: ['Creature', 'Planeswalker', 'Instant', 'Sorcery', 'Enchantment', 'Artifact', 'Battle', 'Land', 'Other'],
'Planeswalker', color: ['White', 'Blue', 'Black', 'Red', 'Green', 'Multicolor', 'Colorless'],
'Instant', cmc: ['0', '1', '2', '3', '4', '5', '6', '7+'],
'Sorcery', rarity: ['Mythic', 'Rare', 'Uncommon', 'Common']
'Enchantment', };
'Artifact',
'Land',
'Battle',
'Other'
];
export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, onCardClick, onHover, disableHoverPreview = false }) => { const getCardGroup = (card: DraftCard, mode: GroupMode): string => {
if (mode === 'type') {
const typeLine = card.typeLine || '';
if (typeLine.includes('Creature')) return 'Creature';
if (typeLine.includes('Planeswalker')) return 'Planeswalker';
if (typeLine.includes('Instant')) return 'Instant';
if (typeLine.includes('Sorcery')) return 'Sorcery';
if (typeLine.includes('Enchantment')) return 'Enchantment';
if (typeLine.includes('Artifact')) return 'Artifact';
if (typeLine.includes('Battle')) return 'Battle';
if (typeLine.includes('Land')) return 'Land';
return 'Other';
}
if (mode === 'color') {
const colors = card.colors || [];
if (colors.length > 1) return 'Multicolor';
if (colors.length === 0) {
// Check if land
if ((card.typeLine || '').includes('Land')) return 'Colorless';
// Artifacts etc
return 'Colorless';
}
if (colors[0] === 'W') return 'White';
if (colors[0] === 'U') return 'Blue';
if (colors[0] === 'B') return 'Black';
if (colors[0] === 'R') return 'Red';
if (colors[0] === 'G') return 'Green';
return 'Colorless';
}
if (mode === 'cmc') {
const cmc = Math.floor(card.cmc || 0);
if (cmc >= 7) return '7+';
return cmc.toString();
}
if (mode === 'rarity') {
const r = (card.rarity || 'common').toLowerCase();
if (r === 'mythic') return 'Mythic';
if (r === 'rare') return 'Rare';
if (r === 'uncommon') return 'Uncommon';
return 'Common';
}
return 'Other';
};
export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, onCardClick, onHover, disableHoverPreview = false, groupBy = 'color' }) => {
const categorizedCards = useMemo(() => { const categorizedCards = useMemo(() => {
const categories: Record<string, DraftCard[]> = {}; const categories: Record<string, DraftCard[]> = {};
CATEGORY_ORDER.forEach(c => categories[c] = []); const groupKeys = GROUPS[groupBy];
groupKeys.forEach(k => categories[k] = []);
cards.forEach(card => { cards.forEach(card => {
let category = 'Other'; const group = getCardGroup(card, groupBy);
const typeLine = card.typeLine || ''; if (categories[group]) {
categories[group].push(card);
if (typeLine.includes('Creature')) category = 'Creature'; // Includes Artifact Creature, Ench Creature } else {
else if (typeLine.includes('Planeswalker')) category = 'Planeswalker'; // Fallback for unexpected (shouldn't happen with defined logic coverage)
else if (typeLine.includes('Instant')) category = 'Instant'; if (!categories['Other']) categories['Other'] = [];
else if (typeLine.includes('Sorcery')) category = 'Sorcery'; categories['Other'].push(card);
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? // Sort cards within categories by CMC (low to high)
// Archidekt usually sorts by CMC. // Secondary sort by Name
Object.keys(categories).forEach(key => { Object.keys(categories).forEach(key => {
categories[key].sort((a, b) => (a.cmc || 0) - (b.cmc || 0)); categories[key].sort((a, b) => {
const cmcA = a.cmc || 0;
const cmcB = b.cmc || 0;
if (cmcA !== cmcB) return cmcA - cmcB;
return a.name.localeCompare(b.name);
});
}); });
return categories; return categories;
}, [cards]); }, [cards, groupBy]);
const activeGroups = GROUPS[groupBy];
return ( return (
<div className="flex flex-row gap-4 overflow-x-auto pb-8 snap-x items-start"> <div className="flex flex-row gap-4 overflow-x-auto pb-8 snap-x items-start">
{CATEGORY_ORDER.map(category => { {activeGroups.map(category => {
const catCards = categorizedCards[category]; const catCards = categorizedCards[category];
if (catCards.length === 0) return null; if (catCards.length === 0) return null;

View File

@@ -113,7 +113,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
const [cardWidth, setCardWidth] = useState(() => { const [cardWidth, setCardWidth] = useState(() => {
const saved = localStorage.getItem('cube_cardWidth'); const saved = localStorage.getItem('cube_cardWidth');
return saved ? parseInt(saved) : 100; return saved ? parseInt(saved) : 60;
}); });
// --- Persistence Effects --- // --- Persistence Effects ---
@@ -838,8 +838,8 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
<div className="w-3 h-4 rounded border border-slate-500 bg-slate-700" title="Small Cards" /> <div className="w-3 h-4 rounded border border-slate-500 bg-slate-700" title="Small Cards" />
<input <input
type="range" type="range"
min="100" min="60"
max="300" max="200"
step="1" step="1"
value={cardWidth} value={cardWidth}
onChange={(e) => setCardWidth(parseInt(e.target.value))} onChange={(e) => setCardWidth(parseInt(e.target.value))}

View File

@@ -134,7 +134,8 @@ const CardsDisplay: React.FC<{
onHover: (c: any) => void; onHover: (c: any) => void;
emptyMessage: string; emptyMessage: string;
source: 'pool' | 'deck'; source: 'pool' | 'deck';
}> = ({ cards, viewMode, cardWidth, onCardClick, onHover, emptyMessage, source }) => { groupBy?: 'type' | 'color' | 'cmc' | 'rarity';
}> = ({ cards, viewMode, cardWidth, onCardClick, onHover, emptyMessage, source, groupBy = 'color' }) => {
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">
@@ -174,6 +175,7 @@ const CardsDisplay: React.FC<{
}} }}
onHover={(c) => onHover(c)} onHover={(c) => onHover(c)}
disableHoverPreview={true} disableHoverPreview={true}
groupBy={groupBy}
/> />
</div> </div>
) )
@@ -213,8 +215,9 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
// Unlimited Timer (Static for now) // Unlimited Timer (Static for now)
const [timer] = useState<string>("Unlimited"); const [timer] = useState<string>("Unlimited");
const [layout, setLayout] = useState<'vertical' | 'horizontal'>('vertical'); const [layout, setLayout] = useState<'vertical' | 'horizontal'>('vertical');
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('grid'); const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('stack'); // Default to stack as requested? Or keep grid. User didn't say default view, just default Order.
const [cardWidth, setCardWidth] = useState(100); const [groupBy, setGroupBy] = useState<'type' | 'color' | 'cmc' | 'rarity'>('color');
const [cardWidth, setCardWidth] = useState(60);
const [pool, setPool] = useState<any[]>(initialPool); const [pool, setPool] = useState<any[]>(initialPool);
const [deck, setDeck] = useState<any[]>([]); const [deck, setDeck] = useState<any[]>([]);
@@ -459,15 +462,10 @@ 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()}>
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
{/* Global Toolbar */}
{/* Global Toolbar */} {/* 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="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 */}
<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 */} {/* View Mode Switcher */}
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700"> <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('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>
@@ -475,13 +473,36 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
<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> <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> </div>
{/* Group By Dropdown (Only relevant for Stack View usually, but nice to have) */}
{viewMode === 'stack' && (
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700 h-9 items-center px-2 gap-2">
<span className="text-[10px] text-slate-500 uppercase font-bold">Sort:</span>
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value as any)}
className="bg-transparent text-xs font-bold text-white outline-none cursor-pointer"
>
<option value="color">Color</option>
<option value="type">Type</option>
<option value="cmc">Mana Value</option>
<option value="rarity">Rarity</option>
</select>
</div>
)}
{/* 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>
{/* Slider */} {/* 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="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"
min="100" min="60"
max="300" max="200"
step="1" step="1"
value={cardWidth} value={cardWidth}
onChange={(e) => setCardWidth(parseInt(e.target.value))} onChange={(e) => setCardWidth(parseInt(e.target.value))}
@@ -570,7 +591,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
</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" source="pool" /> <CardsDisplay cards={pool} viewMode={viewMode} cardWidth={cardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" source="pool" groupBy={groupBy} />
</div> </div>
</DroppableZone> </DroppableZone>
{/* Deck Column */} {/* Deck Column */}
@@ -579,7 +600,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
<span>Library ({deck.length})</span> <span>Library ({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 Library is Empty" source="deck" /> <CardsDisplay cards={deck} viewMode={viewMode} cardWidth={cardWidth} onCardClick={removeFromDeck} onHover={setHoveredCard} emptyMessage="Your Library is Empty" source="deck" groupBy={groupBy} />
</div> </div>
</DroppableZone> </DroppableZone>
</div> </div>
@@ -592,7 +613,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
</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" source="pool" /> <CardsDisplay cards={pool} viewMode={viewMode} cardWidth={cardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" source="pool" groupBy={groupBy} />
</div> </div>
</DroppableZone> </DroppableZone>
{/* Bottom: Deck */} {/* Bottom: Deck */}
@@ -601,7 +622,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
<span>Library ({deck.length})</span> <span>Library ({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 Library is Empty" source="deck" /> <CardsDisplay cards={deck} viewMode={viewMode} cardWidth={cardWidth} onCardClick={removeFromDeck} onHover={setHoveredCard} emptyMessage="Your Library is Empty" source="deck" groupBy={groupBy} />
</div> </div>
</DroppableZone> </DroppableZone>
</div> </div>

View File

@@ -66,7 +66,7 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
const [cardScale, setCardScale] = useState<number>(() => { const [cardScale, setCardScale] = useState<number>(() => {
const saved = localStorage.getItem('draft_cardScale'); const saved = localStorage.getItem('draft_cardScale');
return saved ? parseFloat(saved) : 0.5; return saved ? parseFloat(saved) : 0.35;
}); });
const [layout, setLayout] = useState<'vertical' | 'horizontal'>('horizontal'); const [layout, setLayout] = useState<'vertical' | 'horizontal'>('horizontal');
@@ -190,8 +190,8 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
<label className="text-[10px] text-slate-500 uppercase font-bold tracking-wider">Card Size</label> <label className="text-[10px] text-slate-500 uppercase font-bold tracking-wider">Card Size</label>
<input <input
type="range" type="range"
min="0.5" min="0.35"
max="1.5" max="1.0"
step="0.01" step="0.01"
value={cardScale} value={cardScale}
onChange={(e) => setCardScale(parseFloat(e.target.value))} onChange={(e) => setCardScale(parseFloat(e.target.value))}