feat: Implement useCardTouch hook to standardize card interaction and touch event handling across components.
Some checks failed
Build and Deploy / build (push) Failing after 56s

This commit is contained in:
2025-12-17 18:47:48 +01:00
parent 3936260861
commit 79a44173d0
5 changed files with 210 additions and 84 deletions

View File

@@ -144,7 +144,7 @@ export const CardHoverWrapper: React.FC<{ card: DraftCard; children: React.React
}; };
const handleTouchStart = (e: React.TouchEvent) => { const handleTouchStart = (e: React.TouchEvent) => {
if (!hasImage || !isMobile) return; if (!hasImage || !isMobile || preventPreview) return;
const touch = e.touches[0]; const touch = e.touches[0];
const { clientX, clientY } = touch; const { clientX, clientY } = touch;

View File

@@ -1,6 +1,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { DraftCard } from '../services/PackGeneratorService'; import { DraftCard } from '../services/PackGeneratorService';
import { FoilOverlay, CardHoverWrapper } from './CardPreview'; import { FoilOverlay, CardHoverWrapper } from './CardPreview';
import { useCardTouch } from '../utils/interaction';
interface StackViewProps { interface StackViewProps {
cards: DraftCard[]; cards: DraftCard[];
@@ -80,30 +81,18 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, on
const displayImage = useArtCrop ? card.imageArtCrop : card.image; const displayImage = useArtCrop ? card.imageArtCrop : card.image;
return ( return (
<div <StackCardItem
key={card.id} key={card.id}
className="relative w-full z-0 hover:z-50 transition-all duration-200 group" card={card}
onMouseEnter={() => onHover && onHover(card)} cardWidth={cardWidth}
onMouseLeave={() => onHover && onHover(null)} isLast={isLast}
onClick={() => onCardClick && onCardClick(card)} useArtCrop={useArtCrop}
> displayImage={displayImage}
<CardHoverWrapper card={card} preventPreview={disableHoverPreview || cardWidth >= 200}> onHover={onHover}
<div onCardClick={onCardClick}
className={`relative w-full rounded-lg bg-slate-800 shadow-md border border-slate-950 overflow-hidden cursor-pointer group-hover:ring-2 group-hover:ring-purple-400`} disableHoverPreview={disableHoverPreview}
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' : (useArtCrop ? '-85%' : '-125%'), // Negative margin to show header. Square cards need less negative margin.
aspectRatio: useArtCrop ? '1/1' : '2.5/3.5'
}}
>
<img src={displayImage} alt={card.name} className="w-full h-full object-cover" />
{/* Optional: Shine effect for foils if visible? */}
{card.finish === 'foil' && <FoilOverlay />}
</div>
</CardHoverWrapper>
</div>
)
})} })}
</div> </div>
</div> </div>
@@ -112,3 +101,32 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, on
</div> </div>
); );
}; };
const StackCardItem = ({ card, cardWidth, isLast, useArtCrop, displayImage, onHover, onCardClick, disableHoverPreview }: any) => {
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(onHover || (() => { }), () => onCardClick && onCardClick(card), card);
return (
<div
className="relative w-full z-0 hover:z-50 transition-all duration-200 group"
onMouseEnter={() => onHover && onHover(card)}
onMouseLeave={() => onHover && onHover(null)}
onClick={onClick}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchMove={onTouchMove}
>
<CardHoverWrapper card={card} preventPreview={disableHoverPreview || cardWidth >= 200}>
<div
className={`relative w-full rounded-lg bg-slate-800 shadow-md border border-slate-950 overflow-hidden cursor-pointer group-hover:ring-2 group-hover:ring-purple-400`}
style={{
marginBottom: isLast ? '0' : (useArtCrop ? '-85%' : '-125%'),
aspectRatio: useArtCrop ? '1/1' : '2.5/3.5'
}}
>
<img src={displayImage} alt={card.name} className="w-full h-full object-cover" />
{card.finish === 'foil' && <FoilOverlay />}
</div>
</CardHoverWrapper>
</div>
);
};

View File

@@ -4,6 +4,7 @@ import { Save, Layers, Clock, Columns, LayoutTemplate, List, LayoutGrid } from '
import { StackView } from '../../components/StackView'; import { StackView } from '../../components/StackView';
import { FoilOverlay } from '../../components/CardPreview'; import { FoilOverlay } from '../../components/CardPreview';
import { DraftCard } from '../../services/PackGeneratorService'; import { DraftCard } from '../../services/PackGeneratorService';
import { useCardTouch } from '../../utils/interaction';
interface DeckBuilderViewProps { interface DeckBuilderViewProps {
roomId: string; roomId: string;
@@ -34,11 +35,16 @@ const ListItem: React.FC<{ card: DraftCard; onClick?: () => void; onHover?: (c:
} }
}; };
const { onTouchStart, onTouchEnd, onTouchMove, onClick: handleTouchClick } = useCardTouch(onHover || (() => { }), onClick || (() => { }), card);
return ( return (
<div <div
onClick={onClick} onClick={handleTouchClick}
onMouseEnter={() => onHover && onHover(card)} onMouseEnter={() => onHover && onHover(card)}
onMouseLeave={() => onHover && onHover(null)} onMouseLeave={() => onHover && onHover(null)}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchMove={onTouchMove}
className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700/50 cursor-pointer transition-colors w-full group" className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700/50 cursor-pointer transition-colors w-full group"
> >
<span className={`font-medium flex items-center gap-2 truncate ${card.rarity === 'mythic' ? 'text-orange-400' : card.rarity === 'rare' ? 'text-yellow-400' : card.rarity === 'uncommon' ? 'text-slate-200' : 'text-slate-400'}`}> <span className={`font-medium flex items-center gap-2 truncate ${card.rarity === 'mythic' ? 'text-orange-400' : card.rarity === 'rare' ? 'text-yellow-400' : card.rarity === 'uncommon' ? 'text-slate-200' : 'text-slate-400'}`}>
@@ -109,28 +115,18 @@ const CardsDisplay: React.FC<{
{cards.map(c => { {cards.map(c => {
const card = normalizeCard(c); const card = normalizeCard(c);
const useArtCrop = cardWidth < 200 && !!card.imageArtCrop; const useArtCrop = cardWidth < 200 && !!card.imageArtCrop;
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
const isFoil = card.finish === 'foil'; const isFoil = card.finish === 'foil';
return ( return (
<div <DeckCardItem
key={card.id} key={card.id}
onClick={() => onCardClick(c)} card={card}
onMouseEnter={() => onHover(card)} useArtCrop={useArtCrop}
onMouseLeave={() => onHover(null)} isFoil={isFoil}
className="relative group bg-slate-900 rounded-lg shrink-0 cursor-pointer hover:scale-105 transition-transform" onCardClick={onCardClick}
> onHover={onHover}
<div className={`relative ${useArtCrop ? 'aspect-square' : 'aspect-[2.5/3.5]'} overflow-hidden rounded-lg shadow-xl border transition-all duration-200 group-hover:ring-2 group-hover:ring-purple-400 group-hover:shadow-purple-500/30 ${isFoil ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}> />
{isFoil && <FoilOverlay />}
{isFoil && <div className="absolute top-1 right-1 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1 rounded backdrop-blur-sm">FOIL</div>}
{displayImage ? (
<img src={displayImage} alt={card.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-center p-1 text-slate-500 font-bold border-2 border-slate-700 m-1 rounded">{card.name}</div>
)}
<div className={`absolute bottom-0 left-0 right-0 h-1.5 ${card.rarity === 'mythic' ? 'bg-gradient-to-r from-orange-500 to-red-600' : card.rarity === 'rare' ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' : card.rarity === 'uncommon' ? 'bg-gradient-to-r from-gray-300 to-gray-500' : 'bg-black'}`} />
</div>
</div>
); );
})} })}
</div> </div>
@@ -341,7 +337,7 @@ 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"> <div className="flex-1 w-full flex h-full bg-slate-950 text-white overflow-hidden flex-col select-none" onContextMenu={(e) => e.preventDefault()}>
{/* Global Toolbar - Inlined */} {/* Global Toolbar - Inlined */}
<div className="h-14 bg-slate-800 border-b border-slate-700 flex items-center justify-between px-4 shrink-0"> <div className="h-14 bg-slate-800 border-b border-slate-700 flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -491,3 +487,31 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
</div> </div>
); );
}; };
const DeckCardItem = ({ card, useArtCrop, isFoil, onCardClick, onHover }: any) => {
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(onHover, () => onCardClick(card), card);
return (
<div
onClick={onClick}
onMouseEnter={() => onHover(card)}
onMouseLeave={() => onHover(null)}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchMove={onTouchMove}
className="relative group bg-slate-900 rounded-lg shrink-0 cursor-pointer hover:scale-105 transition-transform"
>
<div className={`relative ${useArtCrop ? 'aspect-square' : 'aspect-[2.5/3.5]'} overflow-hidden rounded-lg shadow-xl border transition-all duration-200 group-hover:ring-2 group-hover:ring-purple-400 group-hover:shadow-purple-500/30 ${isFoil ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}>
{isFoil && <FoilOverlay />}
{isFoil && <div className="absolute top-1 right-1 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1.5 rounded backdrop-blur-sm">FOIL</div>}
{displayImage ? (
<img src={displayImage} alt={card.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-center p-1 text-slate-500 font-bold border-2 border-slate-700 m-1 rounded">{card.name}</div>
)}
<div className={`absolute bottom-0 left-0 right-0 h-1.5 ${card.rarity === 'mythic' ? 'bg-gradient-to-r from-orange-500 to-red-600' : card.rarity === 'rare' ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' : card.rarity === 'uncommon' ? 'bg-gradient-to-r from-gray-300 to-gray-500' : 'bg-black'}`} />
</div>
</div>
);
};

View File

@@ -4,6 +4,7 @@ import { socketService } from '../../services/SocketService';
import { LogOut } from 'lucide-react'; import { LogOut } from 'lucide-react';
import { Modal } from '../../components/Modal'; import { Modal } from '../../components/Modal';
import { FoilOverlay } from '../../components/CardPreview'; import { FoilOverlay } from '../../components/CardPreview';
import { useCardTouch } from '../../utils/interaction';
// Helper to normalize card data for visuals // Helper to normalize card data for visuals
const normalizeCard = (c: any) => ({ const normalizeCard = (c: any) => ({
@@ -247,34 +248,15 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
<div className="flex flex-col items-center justify-center min-h-full pb-10"> <div className="flex flex-col items-center justify-center min-h-full pb-10">
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold mb-8">Select a Card</h3> <h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold mb-8">Select a Card</h3>
<div className="flex flex-wrap justify-center gap-6 [perspective:1000px]"> <div className="flex flex-wrap justify-center gap-6 [perspective:1000px]">
{activePack.cards.map((rawCard: any) => { {activePack.cards.map((rawCard: any) => (
const card = normalizeCard(rawCard); <DraftCardItem
const isFoil = card.finish === 'foil'; key={rawCard.id}
rawCard={rawCard}
return ( cardScale={cardScale}
<div handlePick={handlePick}
key={card.id} setHoveredCard={setHoveredCard}
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer" />
style={{ width: `${14 * cardScale}rem` }} ))}
onClick={() => handlePick(card.id)}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
>
{/* Foil Glow Effect */}
{isFoil && <div className="absolute inset-0 -m-1 rounded-xl bg-purple-500 blur-md opacity-20 group-hover:opacity-60 transition-opacity duration-300 animate-pulse"></div>}
<div className={`relative w-full rounded-xl shadow-2xl shadow-black overflow-hidden bg-slate-900 ${isFoil ? 'ring-2 ring-purple-400/50' : 'group-hover:ring-2 ring-emerald-400/50'}`}>
<img
src={card.image}
alt={card.name}
className="w-full h-full object-cover relative z-10"
/>
{isFoil && <FoilOverlay />}
{isFoil && <div className="absolute top-2 right-2 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1.5 rounded backdrop-blur-sm border border-white/20">FOIL</div>}
</div>
</div>
);
})}
</div> </div>
</div> </div>
)} )}
@@ -303,18 +285,7 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
</div> </div>
<div className="flex-1 overflow-x-auto flex items-center gap-2 px-6 pb-4 custom-scrollbar"> <div className="flex-1 overflow-x-auto flex items-center gap-2 px-6 pb-4 custom-scrollbar">
{pickedCards.map((card: any, idx: number) => ( {pickedCards.map((card: any, idx: number) => (
<div <PoolCardItem key={`${card.id}-${idx}`} card={card} setHoveredCard={setHoveredCard} />
key={`${card.id}-${idx}`}
className="relative group shrink-0 transition-all h-full flex items-center"
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
>
<img
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
alt={card.name}
className="h-[90%] w-auto rounded-lg shadow-lg border border-slate-700/50 group-hover:border-emerald-500/50 group-hover:shadow-emerald-500/20 transition-all object-contain"
/>
</div>
))} ))}
</div> </div>
</div> </div>
@@ -331,3 +302,57 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
</div> </div>
); );
}; };
const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any) => {
const card = normalizeCard(rawCard);
const isFoil = card.finish === 'foil';
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => handlePick(card.id), card);
return (
<div
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer"
style={{ width: `${14 * cardScale}rem` }}
onClick={onClick}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchMove={onTouchMove}
>
{/* Foil Glow Effect */}
{isFoil && <div className="absolute inset-0 -m-1 rounded-xl bg-purple-500 blur-md opacity-20 group-hover:opacity-60 transition-opacity duration-300 animate-pulse"></div>}
<div className={`relative w-full rounded-xl shadow-2xl shadow-black overflow-hidden bg-slate-900 ${isFoil ? 'ring-2 ring-purple-400/50' : 'group-hover:ring-2 ring-emerald-400/50'}`}>
<img
src={card.image}
alt={card.name}
className="w-full h-full object-cover relative z-10"
/>
{isFoil && <FoilOverlay />}
{isFoil && <div className="absolute top-2 right-2 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1.5 rounded backdrop-blur-sm border border-white/20">FOIL</div>}
</div>
</div>
);
};
const PoolCardItem = ({ card, setHoveredCard }: any) => {
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => { }, card);
return (
<div
className="relative group shrink-0 transition-all h-full flex items-center"
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchMove={onTouchMove}
onClick={onClick}
>
<img
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
alt={card.name}
className="h-[90%] w-auto rounded-lg shadow-lg border border-slate-700/50 group-hover:border-emerald-500/50 group-hover:shadow-emerald-500/20 transition-all object-contain"
/>
</div>
)
};

View File

@@ -0,0 +1,59 @@
import { useRef, useCallback } from 'react';
/**
* Hook to handle touch interactions for cards (Long Press for Preview).
* - Tap: Click
* - Long Press: Preview (Hover)
* - Drag/Scroll: Cancel
*/
export function useCardTouch(
onHover: (card: any | null) => void,
onClick: () => void,
cardPayload: any
) {
const timerRef = useRef<any>(null);
const isLongPress = useRef(false);
const handleTouchStart = useCallback(() => {
isLongPress.current = false;
timerRef.current = setTimeout(() => {
isLongPress.current = true;
onHover(cardPayload);
}, 400); // 400ms threshold
}, [onHover, cardPayload]);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (timerRef.current) clearTimeout(timerRef.current);
if (isLongPress.current) {
if (e.cancelable) e.preventDefault();
onHover(null); // Clear preview on release, mimicking "hover out"
}
}, [onHover]);
const handleTouchMove = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
// If we were already previewing?
// If user moves finger while holding, maybe we should effectively cancel the "click" potential too?
// Usually moving means scrolling.
isLongPress.current = false; // ensure we validly cancel any queued longpress action
}
}, []);
const handleClick = useCallback((e: React.MouseEvent) => {
if (isLongPress.current) {
e.preventDefault();
e.stopPropagation();
return;
}
onClick();
}, [onClick]);
return {
onTouchStart: handleTouchStart,
onTouchEnd: handleTouchEnd,
onTouchMove: handleTouchMove,
onClick: handleClick
};
}