feat: Introduce custom global context menu for text inputs, refine card touch interactions, and apply global user-select and scrollbar styles.
This commit is contained in:
@@ -6,6 +6,7 @@ import { LobbyManager } from './modules/lobby/LobbyManager';
|
|||||||
import { DeckTester } from './modules/tester/DeckTester';
|
import { DeckTester } from './modules/tester/DeckTester';
|
||||||
import { Pack } from './services/PackGeneratorService';
|
import { Pack } from './services/PackGeneratorService';
|
||||||
import { ToastProvider } from './components/Toast';
|
import { ToastProvider } from './components/Toast';
|
||||||
|
import { GlobalContextMenu } from './components/GlobalContextMenu';
|
||||||
|
|
||||||
export const App: React.FC = () => {
|
export const App: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>(() => {
|
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>(() => {
|
||||||
@@ -68,6 +69,7 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<GlobalContextMenu />
|
||||||
<div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden">
|
<div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden">
|
||||||
<header className="bg-slate-800 border-b border-slate-700 p-4 shrink-0 z-50 shadow-lg">
|
<header className="bg-slate-800 border-b border-slate-700 p-4 shrink-0 z-50 shadow-lg">
|
||||||
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
|
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
|
|||||||
183
src/client/src/components/GlobalContextMenu.tsx
Normal file
183
src/client/src/components/GlobalContextMenu.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import { Copy, Scissors, Clipboard } from 'lucide-react';
|
||||||
|
|
||||||
|
interface MenuPosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GlobalContextMenu: React.FC = () => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [position, setPosition] = useState<MenuPosition>({ x: 0, y: 0 });
|
||||||
|
const [targetElement, setTargetElement] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleContextMenu = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
// Check if target is an input or textarea
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||||
|
const inputTarget = target as HTMLInputElement | HTMLTextAreaElement;
|
||||||
|
|
||||||
|
// Only allow text-based inputs (ignore range, checkbox, etc.)
|
||||||
|
if (target.tagName === 'INPUT') {
|
||||||
|
const type = (target as HTMLInputElement).type;
|
||||||
|
if (!['text', 'password', 'email', 'number', 'search', 'tel', 'url'].includes(type)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
setTargetElement(inputTarget);
|
||||||
|
|
||||||
|
// Position menu within viewport
|
||||||
|
const menuWidth = 150;
|
||||||
|
const menuHeight = 120; // approx
|
||||||
|
let x = e.clientX;
|
||||||
|
let y = e.clientY;
|
||||||
|
|
||||||
|
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 10;
|
||||||
|
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 10;
|
||||||
|
|
||||||
|
setPosition({ x, y });
|
||||||
|
setVisible(true);
|
||||||
|
} else {
|
||||||
|
// Disable context menu for everything else
|
||||||
|
e.preventDefault();
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
// Close menu on any click outside
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use capture to ensure we intercept early
|
||||||
|
document.addEventListener('contextmenu', handleContextMenu);
|
||||||
|
document.addEventListener('click', handleClick);
|
||||||
|
document.addEventListener('scroll', () => setVisible(false)); // Close on scroll
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('contextmenu', handleContextMenu);
|
||||||
|
document.removeEventListener('click', handleClick);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!targetElement) return;
|
||||||
|
const text = targetElement.value.substring(targetElement.selectionStart || 0, targetElement.selectionEnd || 0);
|
||||||
|
if (text) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
setVisible(false);
|
||||||
|
targetElement.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCut = async () => {
|
||||||
|
if (!targetElement) return;
|
||||||
|
const start = targetElement.selectionStart || 0;
|
||||||
|
const end = targetElement.selectionEnd || 0;
|
||||||
|
const text = targetElement.value.substring(start, end);
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
|
||||||
|
// Update value
|
||||||
|
const newVal = targetElement.value.slice(0, start) + targetElement.value.slice(end);
|
||||||
|
|
||||||
|
// React state update hack: Trigger native value setter and event
|
||||||
|
// This ensures React controlled components update their state
|
||||||
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
"value"
|
||||||
|
)?.set;
|
||||||
|
|
||||||
|
if (nativeInputValueSetter) {
|
||||||
|
nativeInputValueSetter.call(targetElement, newVal);
|
||||||
|
} else {
|
||||||
|
targetElement.value = newVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = new Event('input', { bubbles: true });
|
||||||
|
targetElement.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
setVisible(false);
|
||||||
|
targetElement.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = async () => {
|
||||||
|
if (!targetElement) return;
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const start = targetElement.selectionStart || 0;
|
||||||
|
const end = targetElement.selectionEnd || 0;
|
||||||
|
|
||||||
|
const currentVal = targetElement.value;
|
||||||
|
const newVal = currentVal.slice(0, start) + text + currentVal.slice(end);
|
||||||
|
|
||||||
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
"value"
|
||||||
|
)?.set;
|
||||||
|
|
||||||
|
if (nativeInputValueSetter) {
|
||||||
|
nativeInputValueSetter.call(targetElement, newVal);
|
||||||
|
} else {
|
||||||
|
targetElement.value = newVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = new Event('input', { bubbles: true });
|
||||||
|
targetElement.dispatchEvent(event);
|
||||||
|
|
||||||
|
// Move cursor
|
||||||
|
// Timeout needed for React to process input event first
|
||||||
|
setTimeout(() => {
|
||||||
|
targetElement.setSelectionRange(start + text.length, start + text.length);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to read clipboard', err);
|
||||||
|
}
|
||||||
|
setVisible(false);
|
||||||
|
targetElement.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="fixed z-[10000] bg-slate-800 border border-slate-600 rounded-lg shadow-2xl py-1 w-36 overflow-hidden animate-in fade-in zoom-in duration-75"
|
||||||
|
style={{ top: position.y, left: position.x }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleCut}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 hover:text-white flex items-center gap-2 transition-colors disabled:opacity-50"
|
||||||
|
disabled={!targetElement?.value || targetElement?.selectionStart === targetElement?.selectionEnd}
|
||||||
|
>
|
||||||
|
<Scissors className="w-4 h-4" /> Cut
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 hover:text-white flex items-center gap-2 transition-colors disabled:opacity-50"
|
||||||
|
disabled={!targetElement?.value || targetElement?.selectionStart === targetElement?.selectionEnd}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" /> Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePaste}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 hover:text-white flex items-center gap-2 transition-colors border-t border-slate-700 mt-1 pt-2"
|
||||||
|
>
|
||||||
|
<Clipboard className="w-4 h-4" /> Paste
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,6 +5,8 @@ import './styles/main.css';
|
|||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (rootElement) {
|
if (rootElement) {
|
||||||
const root = createRoot(rootElement);
|
const root = createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users, Download, Copy, FileDown, Trash2, Search, X, PlayCircle, Plus, Minus } from 'lucide-react';
|
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users, Download, Copy, FileDown, Trash2, Search, X, PlayCircle, Plus, Minus, ChevronDown, MoreHorizontal } from 'lucide-react';
|
||||||
import { ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
|
import { ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
|
||||||
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
|
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
|
||||||
import { PackCard } from '../../components/PackCard';
|
import { PackCard } from '../../components/PackCard';
|
||||||
@@ -766,43 +766,71 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 w-full sm:w-auto justify-end overflow-x-auto pb-1 sm:pb-0">
|
<div className="flex gap-2 w-full sm:w-auto justify-end">
|
||||||
{/* Play Button */}
|
{/* Actions Menu */}
|
||||||
{packs.length > 0 && (
|
{packs.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<button
|
<div className="relative group z-50">
|
||||||
onClick={handlePlayOnline}
|
<button className="px-4 py-2 bg-gradient-to-r from-slate-700 to-slate-800 hover:from-slate-600 hover:to-slate-700 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 transition-all ring-1 ring-white/10">
|
||||||
className={`px-4 py-2 font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in whitespace-nowrap transition-colors
|
<MoreHorizontal className="w-4 h-4 text-emerald-400" /> <span className="hidden sm:inline">Actions</span> <ChevronDown className="w-4 h-4 text-slate-400 group-hover:rotate-180 transition-transform" />
|
||||||
${packs.length < 12
|
</button>
|
||||||
? 'bg-slate-700 text-slate-400 cursor-not-allowed hover:bg-slate-600'
|
|
||||||
: 'bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white'
|
{/* Dropdown */}
|
||||||
}`}
|
<div className="absolute right-0 top-full mt-2 w-56 bg-slate-800 border border-slate-700 rounded-xl shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 transform origin-top-right p-2 flex flex-col gap-2 z-[9999]">
|
||||||
>
|
|
||||||
<Users className="w-4 h-4" /> <span className="hidden sm:inline">Play Online</span>
|
{/* Play Online */}
|
||||||
</button>
|
<button
|
||||||
<button
|
onClick={handlePlayOnline}
|
||||||
onClick={handleStartSoloTest}
|
className={`w-full text-left px-3 py-3 rounded-lg flex items-center gap-3 transition-all shadow-md ${packs.length < 12
|
||||||
disabled={loading}
|
? 'bg-slate-700 text-slate-400 cursor-not-allowed'
|
||||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
|
: 'bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white shadow-purple-900/20'
|
||||||
title="Test a randomized deck from these packs right now"
|
}`}
|
||||||
>
|
>
|
||||||
<PlayCircle className="w-4 h-4 text-emerald-400" /> <span className="hidden sm:inline">Test Solo</span>
|
<Users className="w-5 h-5 shrink-0" />
|
||||||
</button>
|
<div>
|
||||||
<button
|
<span className="block text-sm font-bold leading-tight">Play Online</span>
|
||||||
onClick={handleExportCsv}
|
<span className={`block text-[10px] leading-tight mt-0.5 ${packs.length < 12 ? 'text-slate-500' : 'text-purple-100'}`}>
|
||||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
|
Start a multiplayer draft
|
||||||
title="Export as CSV"
|
</span>
|
||||||
>
|
</div>
|
||||||
<Download className="w-4 h-4" /> <span className="hidden sm:inline">Export</span>
|
</button>
|
||||||
</button>
|
|
||||||
<button
|
<div className="h-px bg-slate-700/50 mx-1" />
|
||||||
onClick={handleCopyCsv}
|
|
||||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
|
{/* Test Solo */}
|
||||||
title="Copy CSV to Clipboard"
|
<button
|
||||||
>
|
onClick={handleStartSoloTest}
|
||||||
{copySuccess ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
disabled={loading}
|
||||||
<span className="hidden sm:inline">{copySuccess ? 'Copied!' : 'Copy'}</span>
|
className="w-full text-left px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg flex items-center gap-3 transition-colors shadow-sm"
|
||||||
</button>
|
>
|
||||||
|
<PlayCircle className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="block text-sm font-bold">Test Solo</span>
|
||||||
|
<span className="block text-[10px] text-slate-400 leading-none mt-0.5">Draft against bots</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Export */}
|
||||||
|
<button
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
className="w-full text-left px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg flex items-center gap-3 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 text-blue-400 shrink-0" />
|
||||||
|
<span className="text-sm font-bold">Export CSV</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Copy */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopyCsv}
|
||||||
|
className="w-full text-left px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg flex items-center gap-3 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
{copySuccess ? <Check className="w-4 h-4 text-emerald-400 shrink-0" /> : <Copy className="w-4 h-4 text-slate-400 shrink-0" />}
|
||||||
|
<span className="text-sm font-bold">{copySuccess ? 'Copied!' : 'Copy List'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Size Slider */}
|
{/* Size Slider */}
|
||||||
<div className="flex items-center gap-2 bg-slate-800 rounded-lg px-2 py-1 border border-slate-700 h-9 mr-2 flex">
|
<div className="flex items-center gap-2 bg-slate-800 rounded-lg px-2 py-1 border border-slate-700 h-9 mr-2 flex">
|
||||||
@@ -830,26 +858,28 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{packs.length === 0 ? (
|
{
|
||||||
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed border-slate-700 rounded-2xl bg-slate-800/30 text-slate-500">
|
packs.length === 0 ? (
|
||||||
<Box className="w-12 h-12 mb-4 opacity-50" />
|
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed border-slate-700 rounded-2xl bg-slate-800/30 text-slate-500">
|
||||||
<p>No packs generated.</p>
|
<Box className="w-12 h-12 mb-4 opacity-50" />
|
||||||
</div>
|
<p>No packs generated.</p>
|
||||||
) : (
|
</div>
|
||||||
<div
|
) : (
|
||||||
className="grid gap-6 pb-20"
|
<div
|
||||||
style={{
|
className="grid gap-6 pb-20"
|
||||||
gridTemplateColumns: cardWidth <= 150
|
style={{
|
||||||
? `repeat(auto-fill, minmax(${viewMode === 'list' ? '320px' : '550px'}, 1fr))`
|
gridTemplateColumns: cardWidth <= 150
|
||||||
: '1fr'
|
? `repeat(auto-fill, minmax(${viewMode === 'list' ? '320px' : '550px'}, 1fr))`
|
||||||
}}
|
: '1fr'
|
||||||
>
|
}}
|
||||||
{packs.map((pack) => (
|
>
|
||||||
<PackCard key={pack.id} pack={pack} viewMode={viewMode} cardWidth={cardWidth} />
|
{packs.map((pack) => (
|
||||||
))}
|
<PackCard key={pack.id} pack={pack} viewMode={viewMode} cardWidth={cardWidth} />
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)
|
||||||
|
}
|
||||||
|
</div >
|
||||||
|
|
||||||
</div >
|
</div >
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const DraggableCardWrapper = ({ children, card, source, disabled }: any) => {
|
|||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="touch-none">
|
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="relative z-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -61,7 +61,7 @@ const DraggableLandWrapper = ({ children, land }: any) => {
|
|||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="touch-none">
|
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="relative z-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -345,7 +345,12 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
|||||||
// --- DnD Handlers ---
|
// --- DnD Handlers ---
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
|
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
|
||||||
useSensor(TouchSensor, { activationConstraint: { distance: 10 } })
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 250,
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const [draggedCard, setDraggedCard] = useState<any>(null);
|
const [draggedCard, setDraggedCard] = useState<any>(null);
|
||||||
@@ -591,9 +596,12 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DragOverlay dropAnimation={{ duration: 200, easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)' }}>
|
<DragOverlay dropAnimation={null}>
|
||||||
{draggedCard ? (
|
{draggedCard ? (
|
||||||
<div className={`w-36 rounded-xl shadow-2xl opacity-90 rotate-3 cursor-grabbing overflow-hidden ring-2 ring-emerald-500 bg-slate-900 aspect-[2.5/3.5]`}>
|
<div
|
||||||
|
style={{ width: `${cardWidth}px` }}
|
||||||
|
className={`rounded-xl shadow-2xl opacity-90 rotate-3 cursor-grabbing overflow-hidden ring-2 ring-emerald-500 bg-slate-900 aspect-[2.5/3.5]`}
|
||||||
|
>
|
||||||
<img src={draggedCard.image || draggedCard.image_uris?.normal} alt={draggedCard.name} className="w-full h-full object-cover" draggable={false} />
|
<img src={draggedCard.image || draggedCard.image_uris?.normal} alt={draggedCard.name} className="w-full h-full object-cover" draggable={false} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -615,7 +623,7 @@ const DeckCardItem = ({ card, useArtCrop, isFoil, onCardClick, onHover }: any) =
|
|||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
className="relative group bg-slate-900 rounded-lg shrink-0 cursor-pointer hover:scale-105 transition-transform touch-none"
|
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'}`}>
|
<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 && <FoilOverlay />}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { socketService } from '../../services/SocketService';
|
import { socketService } from '../../services/SocketService';
|
||||||
import { LogOut, Columns, LayoutTemplate } from 'lucide-react';
|
import { LogOut, Columns, LayoutTemplate } from 'lucide-react';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { FoilOverlay } from '../../components/CardPreview';
|
import { FoilOverlay, FloatingPreview } from '../../components/CardPreview';
|
||||||
import { useCardTouch } from '../../utils/interaction';
|
import { useCardTouch } from '../../utils/interaction';
|
||||||
import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core';
|
import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
@@ -22,7 +22,7 @@ const PoolDroppable = ({ children, className, style }: any) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} className={`${className} ${isOver ? 'ring-4 ring-emerald-500/50 bg-emerald-900/20' : ''}`} style={style}>
|
<div ref={setNodeRef} className={`${className} ${isOver ? 'ring-4 ring-emerald-500/50 bg-emerald-900/20' : ''}`} style={{ ...style, touchAction: 'none' }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -128,7 +128,12 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
|||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
|
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
|
||||||
useSensor(TouchSensor, { activationConstraint: { distance: 10 } })
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 250,
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const [draggedCard, setDraggedCard] = useState<any>(null);
|
const [draggedCard, setDraggedCard] = useState<any>(null);
|
||||||
@@ -408,20 +413,36 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
|||||||
{/* Drag Overlay */}
|
{/* Drag Overlay */}
|
||||||
<DragOverlay dropAnimation={null}>
|
<DragOverlay dropAnimation={null}>
|
||||||
{draggedCard ? (
|
{draggedCard ? (
|
||||||
<div className="w-32 h-44 opacity-90 rotate-3 cursor-grabbing shadow-2xl">
|
<div
|
||||||
|
className="opacity-90 rotate-3 cursor-grabbing shadow-2xl rounded-xl"
|
||||||
|
style={{ width: `${14 * cardScale}rem`, aspectRatio: '2.5/3.5' }}
|
||||||
|
>
|
||||||
<img src={draggedCard.image} alt={draggedCard.name} className="w-full h-full object-cover rounded-xl" draggable={false} />
|
<img src={draggedCard.image} alt={draggedCard.name} className="w-full h-full object-cover rounded-xl" draggable={false} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</div>
|
|
||||||
|
{/* Mobile Full Screen Preview (triggered by 2-finger long press) */}
|
||||||
|
{
|
||||||
|
hoveredCard && (
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<FloatingPreview card={hoveredCard} x={0} y={0} isMobile={true} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any) => {
|
const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any) => {
|
||||||
const card = normalizeCard(rawCard);
|
const card = normalizeCard(rawCard);
|
||||||
const isFoil = card.finish === 'foil';
|
const isFoil = card.finish === 'foil';
|
||||||
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => handlePick(card.id), card);
|
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => {
|
||||||
|
// Disable tap-to-pick on touch devices, rely on Drag and Drop
|
||||||
|
if (window.matchMedia('(pointer: coarse)').matches) return;
|
||||||
|
handlePick(card.id);
|
||||||
|
}, card);
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||||
id: card.id,
|
id: card.id,
|
||||||
@@ -433,19 +454,33 @@ const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any)
|
|||||||
opacity: isDragging ? 0 : 1, // Hide original when dragging
|
opacity: isDragging ? 0 : 1, // Hide original when dragging
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
|
// Merge listeners to avoid overriding dnd-kit's TouchSensor
|
||||||
|
const mergedListeners = {
|
||||||
|
...listeners,
|
||||||
|
onTouchStart: (e: any) => {
|
||||||
|
listeners?.onTouchStart?.(e);
|
||||||
|
onTouchStart(e);
|
||||||
|
},
|
||||||
|
onTouchEnd: (e: any) => {
|
||||||
|
listeners?.onTouchEnd?.(e);
|
||||||
|
onTouchEnd(e);
|
||||||
|
},
|
||||||
|
onTouchMove: (e: any) => {
|
||||||
|
listeners?.onTouchMove?.(e);
|
||||||
|
onTouchMove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={{ ...style, width: `${14 * cardScale}rem` }}
|
style={{ ...style, width: `${14 * cardScale}rem` }}
|
||||||
{...listeners}
|
|
||||||
{...attributes}
|
{...attributes}
|
||||||
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer touch-none"
|
{...mergedListeners}
|
||||||
|
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onMouseEnter={() => setHoveredCard(card)}
|
onMouseEnter={() => setHoveredCard(card)}
|
||||||
onMouseLeave={() => setHoveredCard(null)}
|
onMouseLeave={() => setHoveredCard(null)}
|
||||||
onTouchStart={onTouchStart}
|
|
||||||
onTouchEnd={onTouchEnd}
|
|
||||||
onTouchMove={onTouchMove}
|
|
||||||
>
|
>
|
||||||
{/* Foil Glow Effect */}
|
{/* 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>}
|
{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>}
|
||||||
@@ -465,7 +500,9 @@ const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PoolCardItem = ({ card, setHoveredCard, vertical = false }: any) => {
|
const PoolCardItem = ({ card, setHoveredCard, vertical = false }: any) => {
|
||||||
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => { }, card);
|
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => {
|
||||||
|
if (window.matchMedia('(pointer: coarse)').matches) return;
|
||||||
|
}, card);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -61,3 +61,41 @@
|
|||||||
50% { background-position: 100% 50%, 100% 100%; }
|
50% { background-position: 100% 50%, 100% 100%; }
|
||||||
100% { background-position: 0% 50%, 0% 0%; }
|
100% { background-position: 0% 50%, 0% 0%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global interaction resets */
|
||||||
|
body {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow selection in inputs and textareas */
|
||||||
|
input, textarea {
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #0f172a; /* slate-900 */
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #334155; /* slate-700 */
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #475569; /* slate-600 */
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useRef, useCallback } from 'react';
|
import { useRef, useCallback } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to handle touch interactions for cards (Long Press for Preview).
|
* Hook to handle touch interactions for cards.
|
||||||
* - Tap: Click
|
* - Tap: Click (can be disabled by caller)
|
||||||
* - Long Press: Preview (Hover)
|
* - 1-Finger Long Press: Drag (handled externally by dnd-kit usually, so we ignore here)
|
||||||
* - Drag/Scroll: Cancel
|
* - 2-Finger Long Press: Preview (onHover)
|
||||||
*/
|
*/
|
||||||
export function useCardTouch(
|
export function useCardTouch(
|
||||||
onHover: (card: any | null) => void,
|
onHover: (card: any | null) => void,
|
||||||
@@ -13,40 +13,48 @@ export function useCardTouch(
|
|||||||
) {
|
) {
|
||||||
const timerRef = useRef<any>(null);
|
const timerRef = useRef<any>(null);
|
||||||
const isLongPress = useRef(false);
|
const isLongPress = useRef(false);
|
||||||
|
const touchStartCount = useRef(0);
|
||||||
|
|
||||||
const handleTouchStart = useCallback(() => {
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
touchStartCount.current = e.touches.length;
|
||||||
isLongPress.current = false;
|
isLongPress.current = false;
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
isLongPress.current = true;
|
// Only Start "Preview" Timer if 2 fingers
|
||||||
onHover(cardPayload);
|
if (e.touches.length === 2) {
|
||||||
}, 400); // 400ms threshold
|
timerRef.current = setTimeout(() => {
|
||||||
|
isLongPress.current = true;
|
||||||
|
onHover(cardPayload);
|
||||||
|
}, 400); // 400ms threshold
|
||||||
|
}
|
||||||
}, [onHover, cardPayload]);
|
}, [onHover, cardPayload]);
|
||||||
|
|
||||||
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
|
||||||
|
// If it was a 2-finger long press, clear hover on release
|
||||||
if (isLongPress.current) {
|
if (isLongPress.current) {
|
||||||
if (e.cancelable) e.preventDefault();
|
if (e.cancelable) e.preventDefault();
|
||||||
onHover(null); // Clear preview on release, mimicking "hover out"
|
onHover(null);
|
||||||
|
isLongPress.current = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [onHover]);
|
}, [onHover]);
|
||||||
|
|
||||||
const handleTouchMove = useCallback(() => {
|
const handleTouchMove = useCallback(() => {
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
// If we were already previewing?
|
isLongPress.current = false;
|
||||||
// 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) => {
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
// If it was a long press, block click
|
||||||
if (isLongPress.current) {
|
if (isLongPress.current) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Simple click
|
||||||
onClick();
|
onClick();
|
||||||
}, [onClick]);
|
}, [onClick]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user