Compare commits
4 Commits
2bbedfd17f
...
c8d2871126
| Author | SHA1 | Date | |
|---|---|---|---|
| c8d2871126 | |||
| 60db2a91df | |||
| 5bb69c9eb3 | |||
| 7d6ce3995c |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
33
docs/development/devlog/2025-12-18-005000_fix_pwa_prompt.md
Normal file
33
docs/development/devlog/2025-12-18-005000_fix_pwa_prompt.md
Normal 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
|
||||
|
||||
@@ -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
|
||||
1
src/client/dev-dist/registerSW.js
Normal file
1
src/client/dev-dist/registerSW.js
Normal 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
92
src/client/dev-dist/sw.js
Normal 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} didn’t 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: [/^\/$/]
|
||||
}));
|
||||
|
||||
}));
|
||||
3395
src/client/dev-dist/workbox-5a5d9309.js
Normal file
3395
src/client/dev-dist/workbox-5a5d9309.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
136
src/client/src/components/PWAInstallPrompt.tsx
Normal file
136
src/client/src/components/PWAInstallPrompt.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 >
|
||||
);
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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
1
src/client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user