From ac21657bc7dee715ea125de7e6e43b770bf74ccc Mon Sep 17 00:00:00 2001 From: dnviti Date: Mon, 22 Dec 2025 18:45:28 +0100 Subject: [PATCH] created a new reusable component for the card lsft side preview --- src/client/src/components/CardVisual.tsx | 142 +++++++++++++++++ .../src/components/SidePanelPreview.tsx | 148 ++++++++++++++++++ .../src/modules/draft/DeckBuilderView.tsx | 114 ++------------ src/client/src/modules/draft/DraftView.tsx | 102 ++---------- src/client/src/modules/game/CardComponent.tsx | 63 ++------ src/client/src/modules/game/GameView.tsx | 132 ++-------------- 6 files changed, 334 insertions(+), 367 deletions(-) create mode 100644 src/client/src/components/CardVisual.tsx create mode 100644 src/client/src/components/SidePanelPreview.tsx diff --git a/src/client/src/components/CardVisual.tsx b/src/client/src/components/CardVisual.tsx new file mode 100644 index 0000000..6e2a136 --- /dev/null +++ b/src/client/src/components/CardVisual.tsx @@ -0,0 +1,142 @@ +import React, { useMemo } from 'react'; + + +// Union type to support both Game cards and Draft cards +// Union type to support both Game cards and Draft cards +export type VisualCard = { + // Common properties that might be needed + id?: string; + instanceId?: string; + name?: string; + imageUrl?: string; + image?: string; + image_uris?: { + normal?: string; + large?: string; + png?: string; + art_crop?: string; + border_crop?: string; + crop?: string; + }; + definition?: any; // Scryfall definition + card_faces?: any[]; + tapped?: boolean; + faceDown?: boolean; + counters?: any[]; + finish?: string; + // Loose typing for properties that might vary between Game and Draft models + power?: string | number; + toughness?: string | number; + manaCost?: string; + mana_cost?: string; + typeLine?: string; + type_line?: string; + oracleText?: string; + oracle_text?: string; + [key: string]: any; // Allow other properties loosely +}; + +interface CardVisualProps { + card: VisualCard; + viewMode?: 'normal' | 'cutout'; + isFoil?: boolean; // Explicit foil styling override + className?: string; + style?: React.CSSProperties; + // Optional overlays + showCounters?: boolean; + children?: React.ReactNode; +} + +export const CardVisual: React.FC = ({ + card, + viewMode = 'normal', + isFoil = false, + className, + style, + showCounters = true, + children +}) => { + + const imageSrc = useMemo(() => { + // Robustly resolve Image Source based on viewMode + let src = card.imageUrl || card.image; + + if (viewMode === 'cutout') { + // Priority 1: Local Cache (standard naming convention) - PREFERRED BY USER + if (card.definition?.set && card.definition?.id) { + src = `/cards/images/${card.definition.set}/crop/${card.definition.id}.jpg`; + } + // Priority 2: Direct Image URIs (if available) - Fallback + else if (card.image_uris?.art_crop || card.image_uris?.crop) { + src = card.image_uris.art_crop || card.image_uris.crop!; + } + // Priority 3: Deep Definition Data + else if (card.definition?.image_uris?.art_crop) { + src = card.definition.image_uris.art_crop; + } + else if (card.definition?.card_faces?.[0]?.image_uris?.art_crop) { + src = card.definition.card_faces[0].image_uris.art_crop; + } + // Priority 4: If card has a manually set image property that looks like a crop (less reliable) + + // Fallback: If no crop found, src remains whatever it was (likely full) + } else { + // Normal / Full View + // Priority 1: Local Cache (standard naming convention) - PREFERRED + if (card.definition?.set && card.definition?.id) { + // Check if we want standard full image path + src = `/cards/images/${card.definition.set}/full/${card.definition.id}.jpg`; + } + // Priority 2: Direct Image URIs + else if (card.image_uris?.normal) { + src = card.image_uris.normal; + } + else if (card.definition?.image_uris?.normal) { + src = card.definition.image_uris.normal; + } + else if (card.card_faces?.[0]?.image_uris?.normal) { + src = card.card_faces[0].image_uris.normal; + } + } + return src; + }, [card, viewMode]); + + // Counters logic (only for Game cards usually) + const totalCounters = useMemo(() => { + if (!card.counters) return 0; + return card.counters.map((c: any) => c.count).reduce((a: number, b: number) => a + b, 0); + }, [card.counters]); + + return ( +
+ {!card.faceDown ? ( + {card.name + ) : ( +
+
+ )} + + {/* Foil Overlay */} + {(isFoil || card.finish === 'foil') && !card.faceDown && ( +
+ )} + + {/* Counters */} + {showCounters && totalCounters > 0 && ( +
+ {totalCounters} +
+ )} + + {children} +
+ ); +}; diff --git a/src/client/src/components/SidePanelPreview.tsx b/src/client/src/components/SidePanelPreview.tsx new file mode 100644 index 0000000..7a9339b --- /dev/null +++ b/src/client/src/components/SidePanelPreview.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { CardVisual, VisualCard } from './CardVisual'; +import { Eye, ChevronLeft } from 'lucide-react'; +import { ManaIcon } from './ManaIcon'; +import { formatOracleText } from '../utils/textUtils'; + +interface SidePanelPreviewProps { + card: VisualCard | null; + width: number; + isCollapsed: boolean; + onToggleCollapse: (collapsed: boolean) => void; + onResizeStart?: (e: React.MouseEvent | React.TouchEvent) => void; + className?: string; // For additional styling (positioning, z-index, etc) +} + +export const SidePanelPreview: React.FC = ({ + card, + width, + isCollapsed, + onToggleCollapse, + onResizeStart, + className, + children +}) => { + // If collapsed, render the collapsed strip + if (isCollapsed) { + return ( +
+ +
+ ); + } + + // Expanded View + return ( +
+ {/* Collapse Button */} + + + {/* 3D Card Container */} +
+
+
+ {/* Front Face */} +
+ {card && ( + + )} +
+ + {/* Back Face */} +
+ Card Back +
+
+
+ + {/* Details Section */} + {card && ( +
+

{card.name}

+ + {/* Mana Cost */} + {(card['manaCost'] || (card as any).mana_cost) && ( +
+ {((card['manaCost'] || (card as any).mana_cost) as string).match(/\{([^}]+)\}/g)?.map((s, i) => { + const sym = s.replace(/[{}]/g, '').toLowerCase().replace('/', ''); + return ; + }) || {card['manaCost'] || (card as any).mana_cost}} +
+ )} + + {/* Type Line */} + {(card['typeLine'] || (card as any).type_line) && ( +
+ {card['typeLine'] || (card as any).type_line} +
+ )} + + {/* Oracle Text */} + {(card['oracleText'] || (card as any).oracle_text) && ( +
+ {formatOracleText(card['oracleText'] || (card as any).oracle_text)} +
+ )} +
+ )} + {children} +
+ + {/* Resize Handle */} + {onResizeStart && ( +
+
+
+ )} +
+ ); +}; diff --git a/src/client/src/modules/draft/DeckBuilderView.tsx b/src/client/src/modules/draft/DeckBuilderView.tsx index 2aee8d1..67075ba 100644 --- a/src/client/src/modules/draft/DeckBuilderView.tsx +++ b/src/client/src/modules/draft/DeckBuilderView.tsx @@ -1,8 +1,9 @@ import React, { useState, useMemo, useEffect } from 'react'; import { socketService } from '../../services/SocketService'; -import { Save, Layers, Clock, Columns, LayoutTemplate, List, LayoutGrid, ChevronDown, Check, ChevronLeft, Eye } from 'lucide-react'; +import { Save, Layers, Clock, Columns, LayoutTemplate, List, LayoutGrid, ChevronDown, Check } from 'lucide-react'; import { StackView } from '../../components/StackView'; import { FoilOverlay } from '../../components/CardPreview'; +import { SidePanelPreview } from '../../components/SidePanelPreview'; import { DraftCard } from '../../services/PackGeneratorService'; import { useCardTouch } from '../../utils/interaction'; import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core'; @@ -882,106 +883,19 @@ export const DeckBuilderView: React.FC = ({ initialPool, a
{/* Zoom Sidebar */} - {/* Collapsed State: Toolbar Column */} - {/* Collapsed State: Toolbar Column */} - {isSidebarCollapsed ? ( -
- + handleResizeStart('sidebar', e)} + > + {/* Mana Curve at Bottom */} +
+
Mana Curve
+
- ) : ( -
- {/* Collapse Button */} - - - {/* Front content ... */} -
-
- {/* Front Face (Hovered Card) */} -
- {(hoveredCard || displayCard) && ( -
- {(hoveredCard -
-

{(hoveredCard || displayCard).name}

-

{(hoveredCard || displayCard).typeLine || (hoveredCard || displayCard).type_line}

- {(hoveredCard || displayCard).oracle_text && ( -
- {(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) =>

{line}

)} -
- )} -
-
- )} -
- - {/* Back Face (Card Back) */} -
- Card Back -
-
-
- - {/* Mana Curve at Bottom */} -
-
Mana Curve
- -
- - {/* Resize Handle */} -
handleResizeStart('sidebar', e)} - onTouchStart={(e) => handleResizeStart('sidebar', e)} - > -
-
-
- )} + {/* Content Area */} {layout === 'vertical' ? ( diff --git a/src/client/src/modules/draft/DraftView.tsx b/src/client/src/modules/draft/DraftView.tsx index 3b5b295..929ec70 100644 --- a/src/client/src/modules/draft/DraftView.tsx +++ b/src/client/src/modules/draft/DraftView.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect, useRef } from 'react'; import { socketService } from '../../services/SocketService'; -import { LogOut, Columns, LayoutTemplate, ChevronLeft, Eye } from 'lucide-react'; +import { LogOut, Columns, LayoutTemplate } from 'lucide-react'; import { Modal } from '../../components/Modal'; import { FoilOverlay, FloatingPreview } from '../../components/CardPreview'; +import { SidePanelPreview } from '../../components/SidePanelPreview'; 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'; @@ -373,96 +374,15 @@ export const DraftView: React.FC = ({ draftState, currentPlayerI {/* Dedicated Zoom Zone (Left Sidebar) */} {/* Collapsed State: Toolbar Column */} - {isSidebarCollapsed ? ( -
- -
- ) : ( -
- {/* Collapse Button */} - - -
-
- {/* Front Face (Hovered Card) */} -
- {(hoveredCard || displayCard) && ( -
- {(hoveredCard -
-

{(hoveredCard || displayCard).name}

-

{(hoveredCard || displayCard).typeLine || (hoveredCard || displayCard).type_line}

- {(hoveredCard || displayCard).oracle_text && ( -
- {(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) =>

{line}

)} -
- )} -
-
- )} -
- - {/* Back Face (Card Back) */} -
- Card Back -
-
-
- {/* Resize Handle for Sidebar */} -
handleResizeStart('sidebar', e)} - onTouchStart={(e) => handleResizeStart('sidebar', e)} - > -
-
-
- )} + {/* Dedicated Zoom Zone (Left Sidebar) */} + handleResizeStart('sidebar', e)} + className="hidden lg:flex" + /> {/* Main Content Area: Handles both Pack and Pool based on layout */} {layout === 'vertical' ? ( diff --git a/src/client/src/modules/game/CardComponent.tsx b/src/client/src/modules/game/CardComponent.tsx index 08af049..1e72190 100644 --- a/src/client/src/modules/game/CardComponent.tsx +++ b/src/client/src/modules/game/CardComponent.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { CardInstance } from '../../types/game'; import { useGesture } from './GestureManager'; import { useRef, useEffect } from 'react'; +import { CardVisual } from '../../components/CardVisual'; interface CardComponentProps { card: CardInstance; @@ -29,41 +30,7 @@ export const CardComponent: React.FC = ({ card, onDragStart, return () => unregisterCard(card.instanceId); }, [card.instanceId]); - // Robustly resolve Image Source based on viewMode - let imageSrc = card.imageUrl; - - if (viewMode === 'cutout') { - // Priority 1: Local Cache (standard naming convention) - PREFERRED BY USER - if (card.definition?.set && card.definition?.id) { - imageSrc = `/cards/images/${card.definition.set}/crop/${card.definition.id}.jpg`; - } - // Priority 2: Direct Image URIs (if available) - Fallback - else if (card.image_uris?.art_crop || card.image_uris?.crop) { - imageSrc = card.image_uris.art_crop || card.image_uris.crop!; - } - // Priority 3: Deep Definition Data - else if (card.definition?.image_uris?.art_crop) { - imageSrc = card.definition.image_uris.art_crop; - } - else if (card.definition?.card_faces?.[0]?.image_uris?.art_crop) { - imageSrc = card.definition.card_faces[0].image_uris.art_crop; - } - // Fallback: If no crop found, imageSrc remains card.imageUrl (likely full) - } else { - // Normal / Full View - // Priority 1: Local Cache (standard naming convention) - PREFERRED - if (card.definition?.set && card.definition?.id) { - // Check if we want standard full image path - imageSrc = `/cards/images/${card.definition.set}/full/${card.definition.id}.jpg`; - } - // Priority 2: Direct Image URIs - else if (card.image_uris?.normal) { - imageSrc = card.image_uris.normal; - } - else if (card.definition?.image_uris?.normal) { - imageSrc = card.definition.image_uris.normal; - } - } + // Robustly resolve Image Source based on viewMode is now handled in CardVisual return (
= ({ card, onDragStart, `} style={style} > -
- {!card.faceDown ? ( - {card.name} - ) : ( -
-
- )} +
+ + + - {/* Counters / PowerToughness overlays can go here */} - {(card.counters.length > 0) && ( -
- {card.counters.map(c => c.count).reduce((a, b) => a + b, 0)} -
- )}
); diff --git a/src/client/src/modules/game/GameView.tsx b/src/client/src/modules/game/GameView.tsx index 2793252..af38371 100644 --- a/src/client/src/modules/game/GameView.tsx +++ b/src/client/src/modules/game/GameView.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { useConfirm } from '../../components/ConfirmDialog'; -import { ChevronLeft, Eye, RotateCcw } from 'lucide-react'; +import { RotateCcw } from 'lucide-react'; import { ManaIcon } from '../../components/ManaIcon'; import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core'; import { CSS } from '@dnd-kit/utilities'; @@ -16,7 +16,7 @@ import { GestureManager } from './GestureManager'; import { MulliganView } from './MulliganView'; import { RadialMenu, RadialOption } from './RadialMenu'; import { InspectorOverlay } from './InspectorOverlay'; -import { formatOracleText } from '../../utils/textUtils'; +import { SidePanelPreview } from '../../components/SidePanelPreview'; // --- DnD Helpers --- const DraggableCardWrapper = ({ children, card, disabled }: { children: React.ReactNode, card: CardInstance, disabled?: boolean }) => { @@ -459,127 +459,13 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } } {/* Zoom Sidebar */} - { - isSidebarCollapsed ? ( -
- -
- ) : ( -
- {/* Collapse Button */} - - -
-
-
- {/* Front Face (Hovered Card) */} -
- {hoveredCard && ( - { - if (hoveredCard.image_uris?.normal) { - return hoveredCard.image_uris.normal; - } - if (hoveredCard.definition?.set && hoveredCard.definition?.id) { - return `/cards/images/${hoveredCard.definition.set}/full/${hoveredCard.definition.id}.jpg`; - } - return hoveredCard.imageUrl; - })()} - alt={hoveredCard.name} - className="w-full h-full object-cover rounded-xl shadow-2xl shadow-black ring-1 ring-white/10" - /> - )} -
- - {/* Back Face (Card Back) */} -
- Card Back -
-
-
- - {/* Oracle Text & Details - Only when card is hovered */} - {hoveredCard && ( -
-

{hoveredCard.name}

- - {hoveredCard.manaCost && ( -
- {hoveredCard.manaCost.match(/\{([^}]+)\}/g)?.map((s, i) => { - const sym = s.replace(/[{}]/g, '').toLowerCase().replace('/', ''); - return ; - }) || {hoveredCard.manaCost}} -
- )} - - {hoveredCard.typeLine && ( -
- {hoveredCard.typeLine} -
- )} - - - {hoveredCard.oracleText && ( -
- {formatOracleText(hoveredCard.oracleText)} -
- )} -
- )} -
- - {/* Resize Handle */} -
-
-
-
- ) - } + {/* Main Game Area */}