Compare commits

...

4 Commits

19 changed files with 4147 additions and 91 deletions

View File

@@ -89,3 +89,7 @@
- [2025-12-17-184000_fix_draft_pool_ui](./devlog/2025-12-17-184000_fix_draft_pool_ui.md): Completed. Fixed "Your Pool" resizing bugs and removed unwanted hover animation.
- [Customizable Deck Builder Layout](./devlog/2025-12-17-170000_customizable_deck_builder.md): Completed. Implemented switchable Vertical (Side-by-Side) and Horizontal (Top-Bottom) layouts, with an integrated, improved Land Station.
- [Draft View Layout Selection](./devlog/2025-12-17-185000_draft_view_layout.md): Completed. Implemented Vertical/Horizontal layout selection for Draft View to match Deck Builder, optimizing screen space and preventing overlap.
- [Fix Cube Sidebar Scrolling](./devlog/2025-12-18-004109_fix_cube_sidebar_scrolling.md): Completed. Adjusted sidebar max-height to ensure autonomous scrolling and button accessibility on tablet screens.
- [PWA Install Prompt](./devlog/2025-12-18-004600_pwa_install_prompt.md): Completed. Implemented `PWAInstallPrompt` component in `App.tsx` and enabled PWA dev options.
- [Fix PWA Install Prompt](./devlog/2025-12-18-005000_fix_pwa_prompt.md): Completed. Implemented global event capture, iOS detection, and explicit service worker registration to ensure install prompt appears.
- [Persist PWA Dismissal](./devlog/2025-12-18-005300_persist_pwa_dismissal.md): Completed. Implemented logic to remember user's choice to dismiss or install the PWA, preventing repeated prompts.

View File

@@ -0,0 +1,14 @@
# Fix Cube Manager Sidebar Scrolling on Tablets
## Context
The user reported an issue on small tablet screens where the sticky sidebar in the Pack Generation UI (Cube Manager) cannot be scrolled to the bottom. The `max-height` of the sidebar was set to `100vh - 2rem`, which exceeds the actual visible viewport height when accounting for the application header (and potentially browser bars), causing the bottom of the sidebar (containing the critical "Generate Packs" button) to be clipped and inaccessible without scrolling the main page.
## Changes
- Modified `src/client/src/modules/cube/CubeManager.tsx` to adjust the sidebar `max-height`.
- Changed `lg:max-h-[calc(100vh-2rem)]` to `lg:max-h-[calc(100vh-10rem)]`.
- This calculation (~ 10rem) accounts for the header height (approx 5-6rem), footer height (approx 2.5rem), and margins, ensuring the sidebar fits completely within the visible viewport.
- This ensures the `overflow-y-auto` property on the sidebar triggers correctly, allowing autonomous scrolling of the configuration panel regardless of the main page scroll position.
## Verification
- Verified layout structure in `App.tsx` and proper nesting.
- The change applies to `lg` screens (desktop and horizontal tablet), where the sidebar is displayed as a sticky column.

View File

@@ -0,0 +1,23 @@
# PWA Install Prompt Implementation Plan
## Objective
Implement a user interface that prompts the user to install the application as a PWA on supported devices (primarily Android/Chrome).
## Tasks
1. Create `src/client/src/components/PWAInstallPrompt.tsx` that:
- Listens for `beforeinstallprompt` event.
- Displays a custom UI (toast/banner) when the event is captured.
- Calls `prompt()` on the event object when the user clicks "Install".
- Handles the user's choice.
2. Integrate `PWAInstallPrompt` into `App.tsx`.
3. Verify `vite.config.ts` PWA settings (already done, looks good).
## Implementation Details
- The component will use a fixed position styling (bottom right/center) to be noticeable but not blocking.
- It will use existing design system (Tailwind).
## Status
- [x] Create Component
- [x] Integrate into App
- [x] Update Config
- **Completed**: 2025-12-18

View File

@@ -0,0 +1,33 @@
# Fix PWA Install Prompt Implementation Plan
## Objective
Ensure the PWA install prompt appears reliably on mobile devices, addressing potential issues with event timing, iOS compatibility, and explicit Service Worker registration.
## Root Causes to Address
1. **Late Event Listener**: The `beforeinstallprompt` event might fire before the React component mounts. We need to capture it globally as early as possible.
2. **Missing Service Worker Registration**: While `vite-plugin-pwa` can auto-inject, explicit registration in `main.tsx` ensures it's active and handles updates.
3. **iOS Compatibility**: iOS does not support `beforeinstallprompt`. We must detect iOS and show specific "Share -> Add to Home Screen" instructions.
4. **Secure Context**: PWA features require HTTPS. While we can't force this on the user's network, we can ensure the app behaves best-effort and maybe warn if needed (skipped for now to avoid noise, focusing on logical fixes).
## Tasks
1. **Update `src/client/src/main.tsx`**:
- Import `registerSW` from `virtual:pwa-register`.
- Add a global `window` event listener for `beforeinstallprompt` immediately upon script execution to capture the event.
- Expose the captured event on `window` (or a global store) so the React component can consume it.
2. **Update `src/client/src/components/PWAInstallPrompt.tsx`**:
- Check `window.deferredInstallPrompt` (or similar) on mount.
- Add User Agent detection for iOS (iPhone/iPad/iPod).
- If iOS, display a custom "Add to Home Screen" instruction banner.
- If Android/Desktop, use the captured prompt.
3. **Docs**: Update devlog.
## Technical Details
- **Global Property**: `window.deferredInstallPrompt`
- **iOS Detection**: Regex on `navigator.userAgent` looking for `iPhone|iPad|iPod` (and not `MSStream`).
## Status
- [x] Register SW explicitly in main.tsx
- [x] Capture `beforeinstallprompt` globally
- [x] Add iOS specific UI
- **Completed**: 2025-12-18

View File

@@ -0,0 +1,20 @@
# Persist PWA Prompt Dismissal
## Objective
Ensure the PWA install prompt honors the user's previous interactions. If the user dismisses the prompt (clicks X) or initiates the install flow, the prompt should not appear again in subsequent sessions.
## Implementation Details
1. **Storage**: Use `localStorage` key `pwa_prompt_dismissed` (value: 'true').
2. **Logic Update** in `PWAInstallPrompt.tsx`:
- On mount: Check if `localStorage.getItem('pwa_prompt_dismissed') === 'true'`. If so, return `null` immediately.
- On Dismiss (X click): Set `localStorage.setItem('pwa_prompt_dismissed', 'true')` and hide UI.
- On Install Click: Set `localStorage.setItem('pwa_prompt_dismissed', 'true')` immediately. Even if they cancel the native dialog, we respect their choice to have interacted with it once. (Or should we? The user said "after a use choice". I will assume entering the flow counts).
## Refinements
- We might want to allow re-prompting after a long time (e.g., storing a timestamp), but the request is simple: "do not show... after a use choice". I will stick to simple boolean persistence for now.
## Status
- [x] Add logic to check/set `pwa_prompt_dismissed`
- [x] Update dismissal (X button) logic
- [x] Update install logic
- **Completed**: 2025-12-18

View File

@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

92
src/client/dev-dist/sw.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.7uegorivig4"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
}));

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,9 @@ import { LobbyManager } from './modules/lobby/LobbyManager';
import { DeckTester } from './modules/tester/DeckTester';
import { Pack } from './services/PackGeneratorService';
import { ToastProvider } from './components/Toast';
import { GlobalContextMenu } from './components/GlobalContextMenu';
import { PWAInstallPrompt } from './components/PWAInstallPrompt';
export const App: React.FC = () => {
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>(() => {
@@ -68,6 +71,8 @@ export const App: React.FC = () => {
return (
<ToastProvider>
<GlobalContextMenu />
<PWAInstallPrompt />
<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">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">

View 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>
);
};

View File

@@ -0,0 +1,136 @@
import React, { useEffect, useState } from 'react';
import { Download, X, Share } from 'lucide-react';
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{
outcome: 'accepted' | 'dismissed';
platform: string;
}>;
prompt(): Promise<void>;
}
export const PWAInstallPrompt: React.FC = () => {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [showIOSPrompt, setShowIOSPrompt] = useState(false);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// 0. Check persistence
const isDismissed = localStorage.getItem('pwa_prompt_dismissed') === 'true';
if (isDismissed) return;
// 1. Check if event was already captured globally
const globalPrompt = (window as any).deferredInstallPrompt;
if (globalPrompt) {
setDeferredPrompt(globalPrompt);
setIsVisible(true);
}
// 2. Listen for future events (if not yet fired)
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setIsVisible(true);
(window as any).deferredInstallPrompt = e; // Sync global just in case
};
// 3. Listen for our custom event from main.tsx
const customHandler = () => {
const global = (window as any).deferredInstallPrompt;
if (global) {
setDeferredPrompt(global);
setIsVisible(true);
}
};
window.addEventListener('beforeinstallprompt', handler);
window.addEventListener('deferred-prompt-ready', customHandler);
// 4. Check for iOS
const userAgent = window.navigator.userAgent.toLowerCase();
const isIOS = /iphone|ipad|ipod/.test(userAgent);
const isStandalone = ('standalone' in window.navigator) && (window.navigator as any).standalone;
if (isIOS && !isStandalone) {
// Delay slightly to start fresh
setTimeout(() => setIsVisible(true), 1000);
setShowIOSPrompt(true);
}
return () => {
window.removeEventListener('beforeinstallprompt', handler);
window.removeEventListener('deferred-prompt-ready', customHandler);
};
}, []);
const handleDismiss = () => {
setIsVisible(false);
localStorage.setItem('pwa_prompt_dismissed', 'true');
};
const handleInstallClick = async () => {
if (!deferredPrompt) return;
setIsVisible(false);
localStorage.setItem('pwa_prompt_dismissed', 'true'); // Don't ask again after user tries to install
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`User response to the install prompt: ${outcome}`);
setDeferredPrompt(null);
(window as any).deferredInstallPrompt = null;
};
if (!isVisible) return null;
// iOS Specific Prompt
if (showIOSPrompt) {
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-80 bg-slate-800 border border-purple-500 rounded-lg shadow-2xl p-4 z-50 flex flex-col gap-3 animate-in slide-in-from-bottom-5">
<div className="flex justify-between items-start">
<h3 className="font-bold text-slate-100">Install App</h3>
<button onClick={handleDismiss} className="text-slate-400 hover:text-white">
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm text-slate-300">
To install this app on your iPhone/iPad:
</p>
<ol className="text-sm text-slate-400 list-decimal list-inside space-y-1">
<li className="flex items-center gap-2">Tap the <Share className="w-4 h-4 inline" /> Share button</li>
<li>Scroll down and tap <span className="text-slate-200 font-semibold">Add to Home Screen</span></li>
</ol>
</div>
);
}
// Android / Desktop Prompt
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-slate-800 border border-purple-500 rounded-lg shadow-2xl p-4 z-50 flex flex-col gap-3 animate-in slide-in-from-bottom-5">
<div className="flex justify-between items-start">
<div className="flex items-center gap-3">
<div className="bg-purple-600/20 p-2 rounded-lg">
<Download className="w-6 h-6 text-purple-400" />
</div>
<div>
<h3 className="font-bold text-slate-100">Install App</h3>
<p className="text-xs text-slate-400">Add to Home Screen for better experience</p>
</div>
</div>
<button
onClick={handleDismiss}
className="text-slate-400 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<button
onClick={handleInstallClick}
className="w-full bg-purple-600 hover:bg-purple-500 text-white py-2 rounded-md font-bold text-sm transition-colors shadow-lg shadow-purple-900/20"
>
Install Now
</button>
</div>
);
};

View File

@@ -2,6 +2,30 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles/main.css';
import { registerSW } from 'virtual:pwa-register';
// Register Service Worker
const updateSW = registerSW({
onNeedRefresh() {
// We could show a prompt here, but for now we'll just log or auto-reload
console.log("New content available, auto-updating...");
updateSW(true);
},
onOfflineReady() {
console.log("App ready for offline use.");
},
});
// Capture install prompt early
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
// Store the event so it can be triggered later.
// We attach it to valid window property or custom one
(window as any).deferredInstallPrompt = e;
// Dispatch a custom event to notify components if they are already mounted
window.dispatchEvent(new Event('deferred-prompt-ready'));
console.log("Captured beforeinstallprompt event");
});
const rootElement = document.getElementById('root');

View File

@@ -1,5 +1,5 @@
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 { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
import { PackCard } from '../../components/PackCard';
@@ -76,6 +76,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
return (localStorage.getItem('cube_viewMode') as 'list' | 'grid' | 'stack') || 'list';
});
// Generation Settings
const [genSettings, setGenSettings] = useState<PackGenerationSettings>(() => {
try {
@@ -455,7 +456,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
<div className="h-full overflow-y-auto w-full flex flex-col lg:flex-row gap-8 p-4 md:p-6">
{/* --- LEFT COLUMN: CONTROLS --- */}
<div className="w-full lg:w-1/3 lg:max-w-[400px] shrink-0 flex flex-col gap-4 lg:sticky lg:top-4 lg:self-start lg:max-h-[calc(100vh-2rem)] lg:overflow-y-auto custom-scrollbar p-1">
<div className="w-full lg:w-1/3 lg:max-w-[400px] shrink-0 flex flex-col gap-4 lg:sticky lg:top-4 lg:self-start lg:max-h-[calc(100vh-10rem)] lg:overflow-y-auto custom-scrollbar p-1">
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl">
{/* Source Toggle */}
<div className="flex p-1 bg-slate-900 rounded-lg mb-4 border border-slate-700">
@@ -766,46 +767,74 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
</h2>
</div>
<div className="flex gap-2 w-full sm:w-auto justify-end overflow-x-auto pb-1 sm:pb-0">
{/* Play Button */}
<div className="flex gap-2 w-full sm:w-auto justify-end">
{/* Actions Menu */}
{packs.length > 0 && (
<>
<div className="relative group z-50">
<button className="h-10 px-4 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">
<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" />
</button>
{/* 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]">
{/* Play Online */}
<button
onClick={handlePlayOnline}
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
${packs.length < 12
? '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'
className={`w-full text-left px-3 py-3 rounded-lg flex items-center gap-3 transition-all shadow-md ${packs.length < 12
? 'bg-slate-700 text-slate-400 cursor-not-allowed'
: 'bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white shadow-purple-900/20'
}`}
>
<Users className="w-4 h-4" /> <span className="hidden sm:inline">Play Online</span>
<Users className="w-5 h-5 shrink-0" />
<div>
<span className="block text-sm font-bold leading-tight">Play Online</span>
<span className={`block text-[10px] leading-tight mt-0.5 ${packs.length < 12 ? 'text-slate-500' : 'text-purple-100'}`}>
Start a multiplayer draft
</span>
</div>
</button>
<div className="h-px bg-slate-700/50 mx-1" />
{/* Test Solo */}
<button
onClick={handleStartSoloTest}
disabled={loading}
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"
title="Test a randomized deck from these packs right now"
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"
>
<PlayCircle className="w-4 h-4 text-emerald-400" /> <span className="hidden sm:inline">Test Solo</span>
</button>
<button
onClick={handleExportCsv}
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"
title="Export as CSV"
>
<Download className="w-4 h-4" /> <span className="hidden sm:inline">Export</span>
</button>
<button
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"
title="Copy CSV to Clipboard"
>
{copySuccess ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
<span className="hidden sm:inline">{copySuccess ? 'Copied!' : 'Copy'}</span>
<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 */}
<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 border border-slate-700 h-10 flex">
<div className="w-3 h-4 rounded border border-slate-500 bg-slate-700" title="Small Cards" />
<input
type="range"
@@ -822,7 +851,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
</>
)}
<div className="flex bg-slate-800 rounded-lg p-1 border border-slate-700 shrink-0">
<div className="flex bg-slate-800 rounded-lg p-1 border border-slate-700 shrink-0 h-10 items-center">
<button onClick={() => setViewMode('list')} className={`p-2 rounded ${viewMode === 'list' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><List className="w-4 h-4" /></button>
<button onClick={() => setViewMode('grid')} className={`p-2 rounded ${viewMode === 'grid' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><LayoutGrid className="w-4 h-4" /></button>
<button onClick={() => setViewMode('stack')} className={`p-2 rounded ${viewMode === 'stack' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><Layers className="w-4 h-4" /></button>
@@ -830,7 +859,8 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
</div>
</div>
{packs.length === 0 ? (
{
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">
<Box className="w-12 h-12 mb-4 opacity-50" />
<p>No packs generated.</p>
@@ -848,8 +878,9 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
<PackCard key={pack.id} pack={pack} viewMode={viewMode} cardWidth={cardWidth} />
))}
</div>
)}
</div>
)
}
</div >
</div >
);

View File

@@ -39,7 +39,7 @@ const DraggableCardWrapper = ({ children, card, source, disabled }: any) => {
} : undefined;
return (
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="touch-none">
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="relative z-0">
{children}
</div>
);
@@ -61,7 +61,7 @@ const DraggableLandWrapper = ({ children, land }: any) => {
} : undefined;
return (
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="touch-none">
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="relative z-0">
{children}
</div>
);
@@ -345,7 +345,12 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
// --- DnD Handlers ---
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
useSensor(TouchSensor, { activationConstraint: { distance: 10 } })
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
const [draggedCard, setDraggedCard] = useState<any>(null);
@@ -591,9 +596,12 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
)}
</div>
<DragOverlay dropAnimation={{ duration: 200, easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)' }}>
<DragOverlay dropAnimation={null}>
{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} />
</div>
) : null}
@@ -615,7 +623,7 @@ const DeckCardItem = ({ card, useArtCrop, isFoil, onCardClick, onHover }: any) =
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
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'}`}>
{isFoil && <FoilOverlay />}

View File

@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
import { socketService } from '../../services/SocketService';
import { LogOut, Columns, LayoutTemplate } from 'lucide-react';
import { Modal } from '../../components/Modal';
import { FoilOverlay } from '../../components/CardPreview';
import { FoilOverlay, FloatingPreview } from '../../components/CardPreview';
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';
@@ -22,7 +22,7 @@ const PoolDroppable = ({ children, className, style }: any) => {
});
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}
</div>
);
@@ -128,7 +128,12 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
useSensor(TouchSensor, { activationConstraint: { distance: 10 } })
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
const [draggedCard, setDraggedCard] = useState<any>(null);
@@ -408,20 +413,36 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
{/* Drag Overlay */}
<DragOverlay dropAnimation={null}>
{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} />
</div>
) : null}
</DragOverlay>
</DndContext>
{/* 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 card = normalizeCard(rawCard);
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({
id: card.id,
@@ -433,19 +454,33 @@ const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any)
opacity: isDragging ? 0 : 1, // Hide original when dragging
} : 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 (
<div
ref={setNodeRef}
style={{ ...style, width: `${14 * cardScale}rem` }}
{...listeners}
{...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}
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>}
@@ -465,7 +500,9 @@ const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: 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 (
<div

View File

@@ -61,3 +61,41 @@
50% { background-position: 100% 50%, 100% 100%; }
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 */
}

View File

@@ -1,10 +1,10 @@
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
* Hook to handle touch interactions for cards.
* - Tap: Click (can be disabled by caller)
* - 1-Finger Long Press: Drag (handled externally by dnd-kit usually, so we ignore here)
* - 2-Finger Long Press: Preview (onHover)
*/
export function useCardTouch(
onHover: (card: any | null) => void,
@@ -13,40 +13,48 @@ export function useCardTouch(
) {
const timerRef = useRef<any>(null);
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;
// Only Start "Preview" Timer if 2 fingers
if (e.touches.length === 2) {
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 it was a 2-finger long press, clear hover on release
if (isLongPress.current) {
if (e.cancelable) e.preventDefault();
onHover(null); // Clear preview on release, mimicking "hover out"
onHover(null);
isLongPress.current = false;
return;
}
}, [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
isLongPress.current = false;
}
}, []);
const handleClick = useCallback((e: React.MouseEvent) => {
// If it was a long press, block click
if (isLongPress.current) {
e.preventDefault();
e.stopPropagation();
return;
}
// Simple click
onClick();
}, [onClick]);

1
src/client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite-plugin-pwa/client" />

View File

@@ -9,6 +9,9 @@ export default defineConfig({
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['icon.svg'],
devOptions: {
enabled: true
},
manifest: {
name: 'MTG Draft Maker',
short_name: 'MTG Draft',