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
Some checks failed
Build and Deploy / build (push) Failing after 56s
This commit is contained in:
@@ -144,7 +144,7 @@ export const CardHoverWrapper: React.FC<{ card: DraftCard; children: React.React
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (!hasImage || !isMobile) return;
|
||||
if (!hasImage || !isMobile || preventPreview) return;
|
||||
const touch = e.touches[0];
|
||||
const { clientX, clientY } = touch;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { DraftCard } from '../services/PackGeneratorService';
|
||||
import { FoilOverlay, CardHoverWrapper } from './CardPreview';
|
||||
import { useCardTouch } from '../utils/interaction';
|
||||
|
||||
interface StackViewProps {
|
||||
cards: DraftCard[];
|
||||
@@ -80,30 +81,18 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, on
|
||||
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
||||
|
||||
return (
|
||||
<div
|
||||
<StackCardItem
|
||||
key={card.id}
|
||||
className="relative w-full z-0 hover:z-50 transition-all duration-200 group"
|
||||
onMouseEnter={() => onHover && onHover(card)}
|
||||
onMouseLeave={() => onHover && onHover(null)}
|
||||
onClick={() => onCardClick && onCardClick(card)}
|
||||
>
|
||||
<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={{
|
||||
// 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>
|
||||
)
|
||||
card={card}
|
||||
cardWidth={cardWidth}
|
||||
isLast={isLast}
|
||||
useArtCrop={useArtCrop}
|
||||
displayImage={displayImage}
|
||||
onHover={onHover}
|
||||
onCardClick={onCardClick}
|
||||
disableHoverPreview={disableHoverPreview}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,3 +101,32 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, on
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Save, Layers, Clock, Columns, LayoutTemplate, List, LayoutGrid } from '
|
||||
import { StackView } from '../../components/StackView';
|
||||
import { FoilOverlay } from '../../components/CardPreview';
|
||||
import { DraftCard } from '../../services/PackGeneratorService';
|
||||
import { useCardTouch } from '../../utils/interaction';
|
||||
|
||||
interface DeckBuilderViewProps {
|
||||
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 (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onClick={handleTouchClick}
|
||||
onMouseEnter={() => onHover && onHover(card)}
|
||||
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"
|
||||
>
|
||||
<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 => {
|
||||
const card = normalizeCard(c);
|
||||
const useArtCrop = cardWidth < 200 && !!card.imageArtCrop;
|
||||
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
||||
|
||||
const isFoil = card.finish === 'foil';
|
||||
|
||||
return (
|
||||
<div
|
||||
<DeckCardItem
|
||||
key={card.id}
|
||||
onClick={() => onCardClick(c)}
|
||||
onMouseEnter={() => onHover(card)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
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 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>
|
||||
card={card}
|
||||
useArtCrop={useArtCrop}
|
||||
isFoil={isFoil}
|
||||
onCardClick={onCardClick}
|
||||
onHover={onHover}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -341,7 +337,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
);
|
||||
|
||||
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 */}
|
||||
<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">
|
||||
@@ -491,3 +487,31 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { socketService } from '../../services/SocketService';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { Modal } from '../../components/Modal';
|
||||
import { FoilOverlay } from '../../components/CardPreview';
|
||||
import { useCardTouch } from '../../utils/interaction';
|
||||
|
||||
// Helper to normalize card data for visuals
|
||||
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">
|
||||
<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]">
|
||||
{activePack.cards.map((rawCard: any) => {
|
||||
const card = normalizeCard(rawCard);
|
||||
const isFoil = card.finish === 'foil';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{activePack.cards.map((rawCard: any) => (
|
||||
<DraftCardItem
|
||||
key={rawCard.id}
|
||||
rawCard={rawCard}
|
||||
cardScale={cardScale}
|
||||
handlePick={handlePick}
|
||||
setHoveredCard={setHoveredCard}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -303,18 +285,7 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
</div>
|
||||
<div className="flex-1 overflow-x-auto flex items-center gap-2 px-6 pb-4 custom-scrollbar">
|
||||
{pickedCards.map((card: any, idx: number) => (
|
||||
<div
|
||||
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>
|
||||
<PoolCardItem key={`${card.id}-${idx}`} card={card} setHoveredCard={setHoveredCard} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,3 +302,57 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
</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>
|
||||
)
|
||||
};
|
||||
|
||||
59
src/client/src/utils/interaction.ts
Normal file
59
src/client/src/utils/interaction.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user