Compare commits
78 Commits
552eba5ba7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e36157115 | |||
| 139aca6f4f | |||
| 418e9e4507 | |||
| eb453fd906 | |||
| 2794ce71aa | |||
| 664d0e838d | |||
| a3e45b13ce | |||
| fd20c3cfb2 | |||
| 412f696646 | |||
| 1853fd9e28 | |||
| c9266b9604 | |||
| 4585e2a944 | |||
| 7b47d566c2 | |||
| 312530d0f0 | |||
| 755ae73d9e | |||
| 49080d8233 | |||
| bc5eda5e2a | |||
| ca7b5bf7fa | |||
| 842beae419 | |||
| a2a45a995c | |||
| e31323859f | |||
| 87e38bd0a3 | |||
| 6b054ad8fc | |||
| b39da587d4 | |||
| 78af33ec99 | |||
| 6301e0e7f5 | |||
| 642e203baf | |||
| d27cc625e4 | |||
| b7e0d1479c | |||
| bd33f6be24 | |||
| e6e452b030 | |||
| db601048d9 | |||
| ebfdfef5ae | |||
| 851e2aa81d | |||
| 0ca29622ef | |||
| d550bc3d04 | |||
| 12e60d42f3 | |||
| 8995c3f7e8 | |||
| c8d2871126 | |||
| 60db2a91df | |||
| 5bb69c9eb3 | |||
| 7d6ce3995c | |||
| 2bbedfd17f | |||
| bf40784667 | |||
| 79a44173d0 | |||
| 3936260861 | |||
| 2869c35885 | |||
| da3f7fa137 | |||
| 845f83086f | |||
| db785537c9 | |||
| a0c3b7c59a | |||
| 0b374c7630 | |||
| 60c012cbb5 | |||
| 0fb330e10b | |||
| e13aa16766 | |||
| e5750d9729 | |||
| 4ff2eb0ef0 | |||
| 7758b31d6b | |||
| 90d50bf1c2 | |||
| 245ab6414a | |||
| 80de286777 | |||
| 3194be382f | |||
| b0dc734859 | |||
| cc0d60dc9e | |||
| 75ffaa4f2a | |||
| aeab15eb9c | |||
| 97276979bf | |||
| ca2efb5cd7 | |||
| 4ad0cd6fdc | |||
| f9819b324e | |||
| 58288e5195 | |||
| f7d22377fa | |||
| 119af95cee | |||
| 23aa1e96d6 | |||
| 0f82be86c3 | |||
| 66cec64223 | |||
| 0ac657847e | |||
| 2efb66cfc4 |
@@ -1,45 +0,0 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
Valid for all generations:
|
||||
- If foils are not available in the pool, ignore the foil generation
|
||||
|
||||
STANDARD GENERATION:
|
||||
|
||||
Slots 1-6 (Commons): 6 Common cards. Ensure color balance (attempt to include at least 3 distinct colors).
|
||||
Slot 7 (Common/List Slot):
|
||||
- Roll a d100.
|
||||
- 1-87: 1 Common from Main Set.
|
||||
- 88-97: 1 Card from "The List" (Common/Uncommon reprint).
|
||||
- 98-99: 1 Rare/Mythic from "The List".
|
||||
- 100: 1 Special Guest (High Value).
|
||||
Slots 8-10 (Uncommons): 3 Uncommon cards.
|
||||
Slot 11 (Main Rare Slot):
|
||||
- Roll 1d8.
|
||||
- If 1-7: Rare.
|
||||
- If 8: Mythic Rare.
|
||||
Slot 12 (Land): 1 Basic or Common Dual Land (20% chance of Foil).
|
||||
Slot 13 (Non-Foil Wildcard):
|
||||
- Can be any rarity (Common, Uncommon, Rare, Mythic).
|
||||
- Use weighted probability: ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic (simplified for simulation).
|
||||
Slot 14 (Foil Wildcard):
|
||||
- Same rarity weights as Slot 13, but the card must be Foil.
|
||||
Slot 15 (Marketing): Token or Art Card.
|
||||
|
||||
PEASANT GENERATION:
|
||||
|
||||
Slots 1-6 (Commons): 6 Common cards. Ensure color balance (attempt to include at least 3 distinct colors).
|
||||
Slot 7 (Common/List Slot):
|
||||
- Roll a d100.
|
||||
- 1-87: 1 Common from Main Set.
|
||||
- 88-97: 1 Card from "The List" (Common/Uncommon reprint).
|
||||
- 98-100: 1 Uncommon from "The List".
|
||||
Slots 8-11 (Uncommons): 4 Uncommon cards.
|
||||
Slot 12 (Land): 1 Basic or Common Dual Land (20% chance of Foil).
|
||||
Slot 13 (Non-Foil Wildcard):
|
||||
- Can be any rarity (Common, Uncommon, Rare, Mythic).
|
||||
- Use weighted probability: ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic (simplified for simulation).
|
||||
Slot 14 (Foil Wildcard):
|
||||
- Same rarity weights as Slot 13, but the card must be Foil.
|
||||
Slot 15 (Marketing): Token or Art Card.
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -140,4 +140,5 @@ vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
|
||||
src/server/public/cards/*
|
||||
src/server/public/cards/*
|
||||
src/server-data
|
||||
@@ -1,34 +1,7 @@
|
||||
# Development Status (Central)
|
||||
|
||||
## Active Plans
|
||||
- [Enhance 3D Game View](./devlog/2025-12-14-235500_enhance_3d_game_view.md): Active. Transforming the battlefield into a fully immersive 3D environment.
|
||||
- [Deck Tester Feature](./devlog/2025-12-15-002500_deck_tester_feature.md): Completed. Implemented a dedicated view to parse custom decks and instantly launch the 3D game sandbox.
|
||||
- [Game Context Menu & Immersion](./devlog/2025-12-14-235000_game_context_menu.md): Completed. Implemented custom right-click menus and game-feel enhancements.
|
||||
## Active Tasks
|
||||
- [x] Enable Clear Session Button (2025-12-20)
|
||||
|
||||
## Recent Completions
|
||||
- [Game Battlefield & Manual Mode](./devlog/2025-12-14-234500_game_battlefield_plan.md): Completed.
|
||||
- [Helm Chart Config](./devlog/2025-12-14-214500_helm_config.md): Completed.
|
||||
- [CSV Import Robustness](./devlog/2025-12-16-152253_csv_import_robustness.md): Completed. Enhanced CSV parser to dynamically map columns from headers, supporting custom user imports.
|
||||
- [Fix Socket Mixed Content](./devlog/2025-12-16-183000_fix_socket_mixed_content.md): Completed. Resolved mixed content error in production by making socket connection URL environment-aware.
|
||||
- [Draft Rules & Pick Logic](./devlog/2025-12-16-180000_draft_rules_implementation.md): Completed. Enforced 4-player minimum and "Pick 2" rule for 4-player drafts.
|
||||
- [Fix Pack Duplication](./devlog/2025-12-16-184500_fix_pack_duplication.md): Completed. Enforced deep cloning and unique IDs for all draft packs to prevent opening identical packs.
|
||||
- [Reconnection & Auto-Pick](./devlog/2025-12-16-191500_reconnection_and_autopick.md): Completed. Implemented session persistence, seamless reconnection, and 30s auto-pick on disconnect.
|
||||
- [Draft Interface UI Polish](./devlog/2025-12-16-195000_draft_ui_polish.md): Completed. Redesigned the draft view for a cleaner, immersive, game-like experience with no unnecessary scrolls.
|
||||
- [Resizable Draft Interface](./devlog/2025-12-16-200500_resizable_draft_ui.md): Completed. Implemented user-resizable pool panel and card sizes with persistence.
|
||||
- [Draft UI Zoom Zone](./devlog/2025-12-16-203000_zoom_zone.md): Completed. Implemented dedicated zoom zone for card preview.
|
||||
- [Host Disconnect Pause](./devlog/2025-12-16-213500_host_disconnect_pause.md): Completed. Specific logic to pause game when host leaves.
|
||||
- [2025-12-16-215000_anti_tampering.md](./devlog/2025-12-16-215000_anti_tampering.md): Implemented server-side validation for game actions.
|
||||
- [2025-12-16-220000_session_persistence.md](./devlog/2025-12-16-220000_session_persistence.md): Plan for session persistence and safer room exit logic.
|
||||
- [2025-12-16-221000_lobby_improvements.md](./devlog/2025-12-16-221000_lobby_improvements.md): Plan for kick functionality and exit button relocation.
|
||||
- [Fix Draft UI Layout](./devlog/2025-12-16-215500_fix_draft_ui_layout.md): Completed. Fixed "Waiting for next pack" layout to be consistently full-screen.
|
||||
- [Draft Timer Enforcement](./devlog/2025-12-16-222500_draft_timer.md): Completed. Implemented server-side 60s timer per pick, AFK auto-pick, and global draft timer loop.
|
||||
- [Pack Generation Update](./devlog/2025-12-16-224000_pack_generation_update.md): Completed. Implemented new 15-slot Play Booster algorithm with lands, tokens, and wildcards.
|
||||
- [Card Metadata Enhancement](./devlog/2025-12-16-224500_card_metadata_enhancement.md): Completed. Extended Scryfall fetching and `DraftCard` interfaces to include full metadata (CMC, Oracle Text, etc.).
|
||||
- [Rich Metadata Expansion](./devlog/2025-12-16-223000_enhance_metadata.md): Completed. Further expanded metadata to include legalities, finishes, artist, frame effects, and produced mana.
|
||||
- [Persist Metadata](./devlog/2025-12-16-230000_persist_metadata.md): Completed. Implemented IndexedDB persistence for Scryfall metadata to ensure offline availability and reduce API calls.
|
||||
- [Bulk Parse Feedback](./devlog/2025-12-16-231500_bulk_parse_feedback.md): Completed. Updated `CubeManager` to handle metadata generation properly and provide feedback on missing cards.
|
||||
- [Full Metadata Passthrough](./devlog/2025-12-16-234500_full_metadata_passthrough.md): Completed. `DraftCard` now includes a `definition` property containing the complete, raw Scryfall object for future-proofing and advanced algorithm usage.
|
||||
- [Server-Side Caching](./devlog/2025-12-16-235900_server_side_caching.md): Completed. Implemented logic to cache images and metadata on the server upon bulk parsing, and updated client to use local assets.
|
||||
- [Peasant Algorithm Implementation](./devlog/2025-12-16-225700_peasant_algorithm.md): Completed. Implemented Peasant-specific pack generation rules including slot logic for commons, uncommons, lands, and wildcards.
|
||||
- [Multi-Expansion Selection](./devlog/2025-12-16-230500_multi_expansion_selection.md): Completed. Implemented searchable multi-select interface for "From Expansion" pack generation, allowing mixed-set drafts.
|
||||
- [Game Type Filter](./devlog/2025-12-16-231000_game_type_filter.md): Completed. Added Paper/Digital filter to the expansion selection list.
|
||||
## Devlog Index
|
||||
- [Enable Clear Session](./devlog/2025-12-20-014500_enable_clear_session.md) - Improved UI/UX for session clearing in CubeManager.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# PROJ001: Initial Project Setup and Logic Refactoring (Node.js Migration)
|
||||
|
||||
## Status: COMPLETED
|
||||
|
||||
### Achievements
|
||||
- **Architecture**: Pivoted from .NET to a **Node.js Monolith** structure to natively support real-time state synchronization via Socket.IO.
|
||||
- **Frontend Infrastructure**: Configured **React** 19 + **Vite** + **Tailwind CSS** (v3) in `src/client`.
|
||||
- **Backend Infrastructure**: Initialized **Express** server with **Socket.IO** in `src/server` for handling API requests and multiplayer draft state.
|
||||
- **Refactoring**: Successfully ported legacy `gemini-generated.js` logic into specialized TypeScript services:
|
||||
- `CardParserService.ts`: Regex-based list parsing.
|
||||
- `ScryfallService.ts`: Data fetching with caching.
|
||||
- `PackGeneratorService.ts`: Pack creation logic.
|
||||
- **UI Implementation**: Developed `CubeManager`, `PackCard`, and `StackView` components.
|
||||
- **Cleanup**: Removed all .NET artifacts and dependencies.
|
||||
- **Tooling**: Updated `Makefile` for unified Node.js development commands.
|
||||
|
||||
### How to Run
|
||||
- **Install**: `make install` (or `cd src && npm install`)
|
||||
- **Run Development**: `make dev` (Runs Server and Client concurrently)
|
||||
- **Build**: `make build`
|
||||
|
||||
### Manual Verification Steps
|
||||
1. **Run**: `make dev`
|
||||
2. **Access**: Open `http://localhost:5173` (Client).
|
||||
3. **Test**:
|
||||
- Click "Load Demo List" in the Cube Manager.
|
||||
- Verify cards are fetched from Scryfall.
|
||||
- Click "Generate Pools".
|
||||
- Verify packs are generated and visible in Stack/Grid views.
|
||||
|
||||
### Next Steps
|
||||
- Implement `DraftSession` state management in `src/server`.
|
||||
- Define Socket.IO events for lobby creation and player connection.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Migration to Node.js Backend
|
||||
|
||||
## Objective
|
||||
Convert the project from a .NET backend to a Node.js (TypeScript) backend and remove the .NET infrastructure.
|
||||
|
||||
## Plan
|
||||
|
||||
### Phase 1: Structure Initialization
|
||||
- [ ] Initialize `src` as a Node.js project (`package.json`, `tsconfig.json`).
|
||||
- [ ] Create directory structure:
|
||||
- [ ] `src/server`: Backend logic.
|
||||
- [ ] `src/client`: Move existing React frontend here.
|
||||
- [ ] `src/shared`: Shared interfaces/types.
|
||||
|
||||
### Phase 2: React Frontend Migration
|
||||
- [ ] Move `src/MtgDraft.Web/Client` contents to `src/client/src`.
|
||||
- [ ] Move configuration files (`vite.config.ts`, `tailwind.config.js`, etc.) to `src/client` root or adjust as needed.
|
||||
- [ ] Ensure frontend builds and runs via Vite (dev server).
|
||||
|
||||
### Phase 3: Node.js Backend Implementation
|
||||
- [ ] Set up Express/Fastify server in `src/server/index.ts`.
|
||||
- [ ] Configure Socket.IO foundations.
|
||||
- [ ] Configure build scripts to build client and server.
|
||||
|
||||
### Phase 4: Verification
|
||||
- [ ] Verify application runs with `npm run dev`.
|
||||
|
||||
### Phase 5: Cleanup
|
||||
- [ ] Delete `MtgDraft.*` folders.
|
||||
@@ -1,30 +0,0 @@
|
||||
# Implementation of Core Functionalities
|
||||
|
||||
## Status
|
||||
Completed
|
||||
|
||||
## Description
|
||||
Implemented the core functionalities based on the reference `gemini-generated.js` file, refactoring the monolithic logic into a modular architecture.
|
||||
|
||||
## Changes
|
||||
1. **Services**:
|
||||
- Created `CardParserService` for parsing bulk text lists.
|
||||
- Created `ScryfallService` for fetching card data with caching and batching.
|
||||
- Created `PackGeneratorService` for generating booster packs with various rules (Peasant, Standard, Chaos).
|
||||
|
||||
2. **Modules**:
|
||||
- **CubeManager**: Implemented the Draft Preparation Phase UI (Input, Filters, Generation).
|
||||
- **TournamentManager**: Implemented the Tournament Bracket generation logic and UI.
|
||||
|
||||
3. **Components**:
|
||||
- `PackCard`: card component with List, Grid, and Stack views.
|
||||
- `StackView`: 3D card stack visualization.
|
||||
- `TournamentPackView`: "Blind Mode" / Box view for generated packs.
|
||||
|
||||
4. **Architecture**:
|
||||
- Created `App.tsx` as the main shell with Tab navigation (Draft vs Bracket).
|
||||
- Integrated all components into the main entry point.
|
||||
|
||||
## Next Steps
|
||||
- Integrate Socket.IO for real-time draft synchronization (Multiplayer).
|
||||
- Implement the "Live Draft" interface.
|
||||
@@ -1,19 +0,0 @@
|
||||
# Bug Fix: React Render Error and Pack Generation Stability
|
||||
|
||||
## Issue
|
||||
User reported "root.render(" error visible on page and "Generate Packs" button ineffective.
|
||||
|
||||
## Diagnosis
|
||||
1. **main.tsx**: Found nested `root.render( <StrictMode> root.render(...) )` call. This caused runtime errors and visible artifact text.
|
||||
2. **CubeManager.tsx**: Service classes (`ScryfallService`, `PackGeneratorService`) were instantiated inside the functional component body without `useMemo`. This caused recreation on every render, leading to cache loss (`ScryfallService` internal cache) and potential state inconsistencies.
|
||||
3. **Pack Generation**: Double-clicking or rapid state updates caused "phantom" generation runs with empty pools, resetting the packs list to 0 immediately after success.
|
||||
|
||||
## Resolution
|
||||
1. **Fixed main.tsx**: Removed the nested `root.render` call.
|
||||
2. **Refactored CubeManager.tsx**:
|
||||
* Memoized all services using `useMemo`.
|
||||
* Added `loading` state to `generatePacks` to prevent double-submissions.
|
||||
* Wrapped generation logic in `setTimeout` to allow UI updates and `try/catch` for robustness.
|
||||
|
||||
## Status
|
||||
Verified via browser subagent (logs confirmed 241 packs generated). UI now prevents race conditions.
|
||||
@@ -1,24 +0,0 @@
|
||||
# Bug Fix: Card Parser Robustness
|
||||
|
||||
## User Request
|
||||
"The problem is that if the scryfall id is missing, no card is retrieved so no card is generated, instead the system should be able to retrieve cards and generate packs even without scryfall id"
|
||||
|
||||
## Diagnosis
|
||||
The `CardParserService` currently performs basic name extraction. It fails to strip set codes and collector numbers common in export formats (e.g., MTG Arena exports like `1 Shock (M20) 160`).
|
||||
This causes `ScryfallService` to search for "Shock (M20) 160" as an exact name, which fails. The system relies on successful Scryfall matches to populate the card pool; without matches, the pool is empty, and generation produces 0 packs.
|
||||
|
||||
## Implementation Plan
|
||||
1. **Refactor `CardParserService.ts`**:
|
||||
* Enhance regex to explicitly handle and strip:
|
||||
* Parentheses containing text (e.g., `(M20)`).
|
||||
* Collector numbers at the end of lines.
|
||||
* Set codes in square brackets if present.
|
||||
* Maintain support for `Quantity Name` format.
|
||||
* Ensure exact name cleanup to maximize Scryfall "exact match" hits.
|
||||
|
||||
2. **Verification**:
|
||||
* Create a test input imitating Arena export.
|
||||
* Verify via browser subagent that cards are fetched and packs are generated.
|
||||
|
||||
## Update Central
|
||||
Update `CENTRAL.md` with this task.
|
||||
@@ -1,26 +0,0 @@
|
||||
# Enhancement: Set-Based Pack Generation
|
||||
|
||||
## Status: Completed
|
||||
|
||||
## Summary
|
||||
Implemented the ability to fetch entire sets from Scryfall and generate booster boxes.
|
||||
|
||||
## Changes
|
||||
1. **ScryfallService**:
|
||||
* Added `fetchSets()` to retrieve expansion sets.
|
||||
* Added `fetchSetCards(setCode)` to retrieve all cards from a set.
|
||||
2. **PackGeneratorService**:
|
||||
* Added `generateBoosterBox()` to generate packs without depleting the pool.
|
||||
* Added `buildTokenizedPack()` for probabilistic generation (R/M + 3U + 10C).
|
||||
3. **CubeManager UI**:
|
||||
* Added Toggle for "Custom List" vs "From Expansion".
|
||||
* Added Set Selection Dropdown.
|
||||
* Added "Number of Boxes" input.
|
||||
* Integrated new service methods.
|
||||
|
||||
## Usage
|
||||
1. Select "From Expansion" tab.
|
||||
2. Choose a set (e.g., "Vintage Masters").
|
||||
3. Choose number of boxes (default 3).
|
||||
4. Click "Fetch Set".
|
||||
5. Click "Generate Packs".
|
||||
@@ -1,18 +0,0 @@
|
||||
# Cleanup: Remove Tournament Mode
|
||||
|
||||
## Status: Completed
|
||||
|
||||
## Summary
|
||||
Removed the "Tournament Mode" view and "Editor Mode" toggle from the Cube Manager. The user requested a simplified interface that lists packs without grouping them into "Boxes".
|
||||
|
||||
## Changes
|
||||
1. **CubeManager.tsx**:
|
||||
* Removed `tournamentMode` state and setter.
|
||||
* Removed usage of `TournamentPackView` component.
|
||||
* Removed the "Tournament Mode / Editor Mode" toggle button.
|
||||
* Simplified rendering to always show the pack list (grid/list/stack view) directly.
|
||||
* Removed unsused `TournamentPackView` import and icon imports.
|
||||
|
||||
## Impact
|
||||
* The UI is now streamlined for the "Host" to just see generated packs.
|
||||
* The `TournamentPackView` component is no longer used but file remains for now.
|
||||
@@ -1,18 +0,0 @@
|
||||
# Enhancement: UI Simplification for Set Generation
|
||||
|
||||
## Status: Completed
|
||||
|
||||
## Summary
|
||||
Refined the Cube Manager UI to hide redundant options when generating packs from an entire expansion set.
|
||||
|
||||
## Changes
|
||||
1. **CubeManager.tsx**:
|
||||
* **Conditional Rendering**: The "Card Source" options (Chaos Draft vs Split by Expansion) are now **hidden** when "From Expansion" mode is selected.
|
||||
* **Automatic State Handling**:
|
||||
* Selecting "From Expansion" automatically sets generation mode to `by_set`.
|
||||
* Selecting "Custom List" resets generation mode to `mixed` (user can still change it).
|
||||
* **Rationale**: Using an entire set implies preserving its structure (one set), whereas a custom list is often a cube (chaos) or a collection of specific sets where the user might want explicitly mixed packs.
|
||||
|
||||
## Impact
|
||||
* Reduces visual noise for the user when they simply want to draft a specific set.
|
||||
* Prevents invalid configurations (e.g., selecting "Chaos Draft" for a single set, which technically works but is confusing in context of "Set Generation").
|
||||
@@ -1,37 +0,0 @@
|
||||
# Work Plan: Real Game & Online Multiplayer
|
||||
|
||||
## User Epics
|
||||
1. **Lobby System**: Create and join private rooms.
|
||||
2. **Game Setup**: Use generated packs to start a game.
|
||||
3. **Multiplayer Draft**: Real-time drafting with friends.
|
||||
4. **Chat**: In-game communication.
|
||||
|
||||
## Tasks
|
||||
|
||||
### 1. Backend Implementation (Node.js + Socket.IO)
|
||||
- [ ] Create `src/server/managers/RoomManager.ts` to handle room state.
|
||||
- [ ] Implement `Room` and `Player` interfaces.
|
||||
- [ ] Update `src/server/index.ts` to initialize `RoomManager` and handle socket events:
|
||||
- `create_room`
|
||||
- `join_room`
|
||||
- `leave_room`
|
||||
- `send_message`
|
||||
- `start_game` (placeholder for next phase)
|
||||
|
||||
### 2. Frontend Implementation (React)
|
||||
- [ ] Create `src/client/src/modules/lobby` directory.
|
||||
- [ ] Create `LobbyManager.tsx` (The main view for finding/creating rooms).
|
||||
- [ ] Create `GameRoom.tsx` (The specific room view with chat and player list).
|
||||
- [ ] Create `socket.ts` service in `src/client/src/services` for client-side socket handling.
|
||||
- [ ] Update `App.tsx` to include the "Lobby" tab.
|
||||
- [ ] Update `CubeManager.tsx` to add "Create Online Room" button.
|
||||
|
||||
### 3. Integration
|
||||
- [ ] Ensure created room receives the packs from `CubeManager`.
|
||||
- [ ] Verify players can join via Room ID.
|
||||
- [ ] Verify chat works.
|
||||
|
||||
## Technical Notes
|
||||
- Use `socket.io-client` on frontend.
|
||||
- Generate Room IDs (short random strings).
|
||||
- Manage state synchronization for the room (players list updates).
|
||||
@@ -1,15 +0,0 @@
|
||||
# Fix UUID Error in Insecure Contexts
|
||||
|
||||
## Problem
|
||||
The user reported a `TypeError: crypto.randomUUID is not a function` when accessing the application from a public IP. This is because `crypto.randomUUID()` is part of the Web Crypto API, which is often restricted to secure contexts (HTTPS) or localhost. When accessing via `http://PUBLIC_IP:PORT`, the browser disables this API.
|
||||
|
||||
## Solution
|
||||
We need to implement a fallback UUID generation method that works in non-secure contexts.
|
||||
|
||||
## Plan
|
||||
1. Modify `src/client/src/services/PackGeneratorService.ts`.
|
||||
2. Add a private method `generateUUID()` to the `PackGeneratorService` class (or a standalone helper function in the module) that:
|
||||
* Checks if `crypto.randomUUID` is available.
|
||||
* If yes, uses it.
|
||||
* If no, uses a fallback algorithm (e.g., `Math.random()` based v4 UUID generation).
|
||||
3. Replace the call `crypto.randomUUID()` with this new method.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Game Interactions Implementation
|
||||
|
||||
## Objective
|
||||
Implement basic player interactions for the MTG game, including library, battlefield, and other game mechanics.
|
||||
|
||||
## Changes
|
||||
1. **Backend (`src/server/managers/GameManager.ts`)**:
|
||||
* Created `GameManager` class to handle game state.
|
||||
* Defined `GameState`, `PlayerState`, `CardInstance` interfaces.
|
||||
* Implemented `createGame`, `handleAction` (move, tap, draw, life).
|
||||
* Integrated with `socket.io` handlers in `server/index.ts`.
|
||||
|
||||
2. **Frontend (`src/client/src/modules/game`)**:
|
||||
* Created `GameView.tsx`: Main game board with drag-and-drop zones (Hand, Battlefield, Library, Graveyard).
|
||||
* Created `CardComponent.tsx`: Draggable card UI with tap state.
|
||||
* Updated `GameRoom.tsx`: Added game state handling and "Start Game (Test)" functionality.
|
||||
|
||||
3. **Socket Service**:
|
||||
* Identify `start_game` and `game_action` events.
|
||||
* Listen for `game_update` to sync state.
|
||||
|
||||
## Status
|
||||
- Basic sandbox gameplay is operational.
|
||||
- Players can move cards between zones freely (DnD).
|
||||
- Tap/Untap and Life counters implemented.
|
||||
- Test deck (Mountain/Bolt) provided for quick testing.
|
||||
|
||||
## Next Steps
|
||||
- Implement actual rules enforcement (Stack, Priority).
|
||||
- Implement Deck Builder / Draft Integration (load actual drafted decks).
|
||||
- Improve UI/UX (animations, better card layout).
|
||||
@@ -1,41 +0,0 @@
|
||||
# Draft & Deck Building Phase
|
||||
|
||||
## Objective
|
||||
Implement the "Draft Phase" (Pack Passing) and "Deck Building Phase" (Pool + Lands) logic and UI, bridging the gap between Lobby and Game.
|
||||
|
||||
## Changes
|
||||
1. **Backend - Draft Logic (`src/server/managers/DraftManager.ts`)**:
|
||||
* Implemented `DraftManager` class.
|
||||
* Handles pack distribution (3 packs per player).
|
||||
* Implements `pickCard` logic with queue-based passing (Left-Right-Left).
|
||||
* Manages pack rounds (Wait for everyone to finish Pack 1 before opening Pack 2).
|
||||
* Transitions to `deck_building` status upon completion.
|
||||
|
||||
2. **Server Integration (`src/server/index.ts`)**:
|
||||
* Added handlers for `start_draft` and `pick_card`.
|
||||
* Broadcasts `draft_update` events.
|
||||
|
||||
3. **Frontend - Draft UI (`src/client/src/modules/draft/DraftView.tsx`)**:
|
||||
* Displays active booster pack.
|
||||
* Timer (visual only for now).
|
||||
* Click-to-pick interaction.
|
||||
* Preview of drafted pool.
|
||||
|
||||
4. **Frontend - Deck Builder UI (`src/client/src/modules/draft/DeckBuilderView.tsx`)**:
|
||||
* **Split View**: Card Pool vs. Current Deck.
|
||||
* **Drag/Click**: Click card to move between pool and deck.
|
||||
* **Land Station**: Add basic lands (Plains, Island, Swamp, Mountain, Forest) with unlimited supply.
|
||||
* **Submit**: Sends deck to server (via `player_ready` - *Note: Server integration for deck storage pending final game start logic*).
|
||||
|
||||
5. **Integration (`GameRoom.tsx`)**:
|
||||
* Added routing based on room status: `waiting` -> `drafting` -> `deck_building` -> `game`.
|
||||
* Added "Start Real Draft" button to lobby.
|
||||
|
||||
## Status
|
||||
- **Drafting**: Fully functional loop. Players pick cards, pass packs, and proceed through 3 rounds.
|
||||
- **Deck Building**: UI is ready. Players can filter, build, and add lands.
|
||||
- **Next**: Need to finalize the "All players ready" logic in `deck_building` to trigger the actual `start_game` using the submitted decks. Currently, submitting triggers a placeholder event.
|
||||
|
||||
## To Verify
|
||||
- Check passing direction (Left/Right).
|
||||
- Verify Basic Land addition works correctly in the final deck object.
|
||||
@@ -1,37 +0,0 @@
|
||||
# Image Caching Implementation
|
||||
|
||||
## Objective
|
||||
Implement a robust image caching system that downloads card images to the server when creating a draft room, ensuring all players can see images reliably via local serving.
|
||||
|
||||
## Changes
|
||||
1. **Backend - Image Service (`src/server/services/CardService.ts`)**:
|
||||
* Created `CardService` class.
|
||||
* Implements `cacheImages` which downloads images from external URLs to `src/server/public/cards`.
|
||||
* Uses a concurrency limit (5) to avoid rate limiting.
|
||||
* Checks for existence before downloading to avoid redundant work.
|
||||
|
||||
2. **Backend - Server Setup (`src/server/index.ts`)**:
|
||||
* Enabled static file serving for `/cards` endpoint mapping to `src/server/public/cards`.
|
||||
* Added `POST /api/cards/cache` endpoint that accepts a list of cards and triggers cache logic.
|
||||
* Increased JSON body limit to 50mb to handle large set payloads.
|
||||
|
||||
3. **Frontend - Lobby Manager (`LobbyManager.tsx`)**:
|
||||
* Updated `handleCreateRoom` workflow.
|
||||
* **Pre-Creation**: Extracts all unique cards from generated packs.
|
||||
* **Cache Request**: Sends list to `/api/cards/cache`.
|
||||
* **Transformation**: Updates local pack data to point `image` property to the local server URL (`/cards/{scryfallId}.jpg`) instead of remote Scryfall URL.
|
||||
* This ensures that when `create_room` is emitted, the room state on the server (and thus all connected clients) contains valid local URLs.
|
||||
|
||||
4. **Fixes**:
|
||||
* Addressed `GameRoom.tsx` crash by replacing `require` with dynamic imports (or static if preloaded) and fixing clipboard access.
|
||||
* Fixed TS imports in server index.
|
||||
|
||||
## Status
|
||||
- **Image Caching**: Functional. Creating a room now triggers a download process on the terminal.
|
||||
- **Local Serving**: Cards should now load instantly from the server for all peers.
|
||||
|
||||
## How to Verify
|
||||
1. Generate packs in Draft Management.
|
||||
2. Create a Room. Watch server logs for "Cached image: ..." messages.
|
||||
3. Join room.
|
||||
4. Start Draft. Images should appear.
|
||||
@@ -1,17 +0,0 @@
|
||||
# Fix Draft Card Images
|
||||
|
||||
## Issue
|
||||
Users reported that images were not showing in the Draft Card Selection UI.
|
||||
|
||||
## Root Causes
|
||||
1. **Missing Proxy**: The application was attempting to load cached images from `http://localhost:5173/cards/...`. Vite Dev Server (port 5173) was not configured to proxy these requests to the backend (port 3000), resulting in 404 errors for all local images.
|
||||
2. **Incorrect Property Access**: `DraftView.tsx` (and `DeckBuilderView.tsx`) attempted to access `card.image_uris.normal`. However, the `DraftCard` object generated by `PackGeneratorService` and modified by `LobbyManager` stores the image URL in `card.image`. This property was being ignored.
|
||||
|
||||
## Fixes
|
||||
1. **Vite Config**: Added a proxy rule for `/cards` in `src/vite.config.ts` to forward requests to `http://localhost:3000`.
|
||||
2. **Frontend Views**: Updated `DraftView.tsx` and `DeckBuilderView.tsx` to prioritize `card.image` when rendering card images.
|
||||
|
||||
## Verification
|
||||
- Start the draft.
|
||||
- Images should now load correctly from the local cache (or fallback if configured).
|
||||
- Inspect network tab to verify images are loaded from `/cards/...` with a 200 OK status.
|
||||
@@ -1,28 +0,0 @@
|
||||
# Fix Submit Deck Button
|
||||
|
||||
## Issue
|
||||
Users reported that "Submit Deck" button was not working.
|
||||
|
||||
## Root Causes
|
||||
1. **Missing Event Handler**: The server was not listening for the `player_ready` event emitted by the client.
|
||||
2. **Incomplete Payload**: The client was sending `{ roomId, deck }` but the server needed `playerId` to identify who was ready, which was missing from the payload.
|
||||
3. **Missing State Logic**: The `RoomManager` did not have a concept of "Ready" state or "Playing" status, meaning the transition from Deck Building to Game was not fully implemented.
|
||||
|
||||
## Fixes
|
||||
1. **Client (`DeckBuilderView.tsx`)**: Updated `player_ready` emission to include `playerId`.
|
||||
2. **Server (`RoomManager.ts`)**:
|
||||
- Added `ready` and `deck` properties to `Player` interface.
|
||||
- Added `playing` to `Room` status.
|
||||
- Implemented `setPlayerReady` method.
|
||||
3. **Server (`index.ts`)**:
|
||||
- Implemented `player_ready` socket handler.
|
||||
- Added logic to check if *all* active players are ready.
|
||||
- If all ready, automatically transitions room status to `playing` and initializes the game using `GameManager`, loading the submitted decks.
|
||||
- ensured deck loading uses cached images (`card.image`) if available.
|
||||
|
||||
## Verification
|
||||
1. Draft cards.
|
||||
2. Build deck.
|
||||
3. Click "Submit Deck".
|
||||
4. Server logs should show "All players ready...".
|
||||
5. Client should automatically switch to `GameView` (Battlefield).
|
||||
@@ -1,22 +0,0 @@
|
||||
# Fix Hooks Violation and Implement Waiting State
|
||||
|
||||
## Issue
|
||||
1. **React Hook Error**: Users encountered "Rendered fewer hooks than expected" when the game started. This was caused by conditional returns in `GameRoom.tsx` appearing *before* hook declarations (`useState`, `useEffect`).
|
||||
2. **UX Issue**: Players who submitted their decks remained in the Deck Builder view, able to modify their decks, instead of seeing a waiting screen.
|
||||
|
||||
## Fixes
|
||||
1. **Refactored `GameRoom.tsx`**:
|
||||
- Moved all `useState` and `useEffect` hooks to the top level of the component, ensuring they are always called regardless of the render logic.
|
||||
- Encapsulated the view switching logic into a helper function `renderContent()`, which is called inside the main return statement.
|
||||
2. **Implemented Waiting Screen**:
|
||||
- Inside `renderContent`, checking if the room is in `deck_building` status AND if the current player has `ready: true`.
|
||||
- If ready, displays a "Deck Submitted" screen with a list of other players and their readiness status.
|
||||
- Updated the sidebar player list to show a "• Ready" indicator.
|
||||
|
||||
## Verification
|
||||
1. Start a draft with multiple users (or simulate it).
|
||||
2. Complete draft and enter deck building.
|
||||
3. Submit deck as one player.
|
||||
4. Verify that the view changes to "Deck Submitted" / Waiting screen.
|
||||
5. Submit deck as the final player.
|
||||
6. Verify that the game starts automatically for everyone without crashing (React Error).
|
||||
@@ -1,32 +0,0 @@
|
||||
# Game Battlefield & Manual Mode Implementation Plan
|
||||
|
||||
## Goal
|
||||
Implement a 3D-style battlefield and manual game logic for the MTG Draft Maker. The system should allow players to drag and drop cards freely onto the battlefield, tap cards, and manage zones (Hand, Library, Graveyard, Exile) in a manual fashion typical of virtual tabletops.
|
||||
|
||||
## Status: Completed
|
||||
|
||||
## Implemented Features
|
||||
- **3D Battlefield UI**:
|
||||
- Used CSS `perspective: 1000px` and `rotateX` to create a depth effect.
|
||||
- Cards are absolutely positioned on the battlefield based on percentage coordinates (0-100%).
|
||||
- Shadows and gradients enhance the "tabletop" feel.
|
||||
- **Manual Game Logic**:
|
||||
- **Free Drag and Drop**: Players can move cards anywhere on the battlefield. Coordinates are calculated relative to the drop target.
|
||||
- **Z-Index Management**: Backend tracks a `maxZ` counter. Every move or flip brings the card to the front (`z-index` increment).
|
||||
- **Actions**:
|
||||
- **Tap/Untap**: Click to toggle (rotate 90 degrees).
|
||||
- **Flip**: Right-click to toggle face-up/face-down status.
|
||||
- **Draw**: Click library to draw.
|
||||
- **Life**: Buttons to increment/decrement life.
|
||||
- **Multiplayer Synchronization**:
|
||||
- All actions (`MOVE_CARD`, `TAP_CARD`, `FLIP_CARD`, `UPDATE_LIFE`) are broadcast via Socket.IO.
|
||||
- Opponent's battlefield is rendered in a mirrored 3D perspective.
|
||||
|
||||
## Files Modified
|
||||
- `src/client/src/modules/game/GameView.tsx`: Main UI logic.
|
||||
- `src/client/src/modules/game/CardComponent.tsx`: Added context menu support.
|
||||
- `src/server/managers/GameManager.ts`: Logic for actions and state management.
|
||||
|
||||
## Next Steps
|
||||
- Test with real players to fine-tune the "feel" of dragging (maybe add grid snapping option later).
|
||||
- Implement "Search Library" feature (currently just Draw).
|
||||
@@ -1,39 +0,0 @@
|
||||
# Game Context Menu & Immersion Update Plan
|
||||
|
||||
## Goal
|
||||
Implement a robust, video-game-style context menu for the battlefield and cards. This menu will allow players to perform advanced manual actions required for MTG, such as creating tokens and managing counters, while eliminating "browser-like" feel.
|
||||
|
||||
## Status: Completed
|
||||
|
||||
## Implemented Features
|
||||
- **Custom Game Context Menu**:
|
||||
- Replaces default browser context menu.
|
||||
- Dark, video-game themed UI with glassmorphism.
|
||||
- Animated entrance (fade/zoom).
|
||||
- **Functionality**:
|
||||
- **Global (Background)**:
|
||||
- "Create Token" (Default 1/1, 2/2, Treasure).
|
||||
- **Card Specific**:
|
||||
- "Tap / Untap"
|
||||
- "Flip Face Up / Down"
|
||||
- "Add Counter" (Submenu: +1/+1, -1/-1, Loyalty)
|
||||
- "Clone (Copy)" (Creates an exact token copy of the card)
|
||||
- "Delete Object" (Removing tokens or cards)
|
||||
- **Backend Logic**:
|
||||
- `GameManager` now handles:
|
||||
- `ADD_COUNTER`: Adds/removes counters logic.
|
||||
- `CREATE_TOKEN`: Generates new token instances with specific stats/art.
|
||||
- `DELETE_CARD`: Removes objects from the game.
|
||||
- **Frontend Integration**:
|
||||
- `GameView` manages menu state (position, target).
|
||||
- `CardComponent` triggers menu only on itself, bubbling prevented.
|
||||
- Hand cards also support right-click menus.
|
||||
|
||||
## Files Modified
|
||||
- `src/client/src/modules/game/GameContextMenu.tsx`: New component.
|
||||
- `src/client/src/modules/game/GameView.tsx`: Integrated menu.
|
||||
- `src/server/managers/GameManager.ts`: Added token/counter handlers.
|
||||
|
||||
## Next Steps
|
||||
- Add sounds for menu open/click.
|
||||
- Add more token types or a token editor.
|
||||
@@ -1,41 +0,0 @@
|
||||
# Enhancement Plan: True 3D Game Area
|
||||
|
||||
The goal is to transform the game area into a "really 3D game" experience using CSS 3D transforms.
|
||||
|
||||
## Objectives
|
||||
1. **Immersive 3D Table**: Create a convincing 3D perspective of a table where cards are placed.
|
||||
2. **Card Physics Simulation**: Visuals should simulate cards having weight, thickness, and position in 3D space.
|
||||
3. **Dynamic Camera/View**: Fix the viewing angle to be consistent with a player sitting at a table.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Scene Setup (GameView.tsx)
|
||||
- Create a "Scene" container with high `perspective` (e.g., `1200px` to `2000px`).
|
||||
- Create a "World" container that holds the table and other elements, allowing for global rotation if needed.
|
||||
- Implement a "TableSurface" div that is rotated `rotateX(40-60deg)` to simulate a flat surface viewed from an angle.
|
||||
|
||||
### 2. Battlefield Enchancement
|
||||
- The player's battlefield should be the bottom half of the table.
|
||||
- The opponent's battlefield should be the top half.
|
||||
- Use `transform-style: preserve-3d` extensively.
|
||||
- Add a grid/mat texture to the table surface to enhance the depth perception.
|
||||
|
||||
### 3. Card 3D Component (CardComponent.tsx)
|
||||
- Refactor `CardComponent` to use a 3D structure.
|
||||
- Add a container for 3D positioning (`translate3d`).
|
||||
- Add a visual "lift" when dragging or hovering (`translateZ`).
|
||||
- Enhance the shadow to be on the "table" surface, separating from the card when lifting.
|
||||
- *Implementation Note*: The shadow might need to be a separate element `after` the card or a separate div to stay on the table plane while the card lifts.
|
||||
|
||||
### 4. Lighting and Atmosphere
|
||||
- Add a "Light Source" effect (radial gradient overlay).
|
||||
- Adjust colors to be darker/moodier, fitting the "Dark Gaming UI" aesthetic.
|
||||
|
||||
## Tech Stack
|
||||
- CSS via Tailwind + Inline Styles for dynamic coordinates.
|
||||
- React for state/rendering.
|
||||
|
||||
## Execution Order
|
||||
1. Refactor `GameView.tsx` layout to standard CSS 3D Scene structure.
|
||||
2. Update `CardComponent.tsx` to handle 3D props (tilt, lift).
|
||||
3. Fine-tune values for perspective and rotation.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Docker Containerization and Build Fixes
|
||||
|
||||
## Objectives
|
||||
- Create a Dockerfile to package the application as a monolith (Node.js + React).
|
||||
- Fix TypeScript build errors preventing successful compilation.
|
||||
- Verify the build process.
|
||||
|
||||
## Changes
|
||||
- **Dockerfile**: Created multi-stage build using `node:20-alpine`.
|
||||
- Installs dependencies.
|
||||
- Builds frontend.
|
||||
- Prunes dev dependencies.
|
||||
- **Server Entry (`src/server/index.ts`)**: Added logic to serve static `dist` files and handle client-side routing in production.
|
||||
- **Package.json**: Moved `tsx` to dependencies and updated `start` script.
|
||||
- **Code Fixes**: Removed unused variables in client and server code used to satisfy strict TypeScript rules:
|
||||
- `DeckBuilderView.tsx`: Removed unused `payload`.
|
||||
- `DraftView.tsx`: Removed unused `CardComponent`.
|
||||
- `GameView.tsx`: Removed unused `myCommand`, `oppGraveyard`.
|
||||
- `DraftManager.ts`: Removed unused `numPlayers`, `cardIndex`.
|
||||
- `GameManager.ts`: Renamed unused args in `shuffleLibrary`.
|
||||
- **Helm Chart**: Created a complete Helm chart configuration in `helm/mtg-draft-maker`:
|
||||
- `Chart.yaml`: Defined chart metadata.
|
||||
- `values.yaml`: Configured defaults (Image `git.commandware.com/services/mtg-online-drafter:main`, Port 3000).
|
||||
- `templates/`: Added Deployment, Service, Ingress, and ServiceAccount manifests.
|
||||
- **Persistence**: Added configuration to mount a Persistent Volume Claim (PVC) at `/app/server/public/cards` for storing cached images. Disabled by default.
|
||||
- Linted successfully.
|
||||
|
||||
## Status
|
||||
- Docker build successful (`docker build -t mtg-draft-maker .`).
|
||||
- Helm chart created and linted.
|
||||
- Ready for K8s deployment.
|
||||
@@ -1,32 +0,0 @@
|
||||
# Deck Tester Feature Implementation
|
||||
|
||||
## Objective
|
||||
Create a way to add a cards list to generate a deck and directly enter the game ui to test the imported deck, using the same exact game and battlefield of the draft.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Frontend
|
||||
1. **DeckTester Component (`src/client/src/modules/tester/DeckTester.tsx`)**:
|
||||
- Created a new component that allows users to input a deck list (text area or file upload).
|
||||
- Reused `CardParserService` and `ScryfallService` to parse the list and fetch card data.
|
||||
- Implemented image caching logic (sending to `/api/cards/cache`).
|
||||
- Connects to socket and emits `start_solo_test`.
|
||||
- Upon success, switches view to `GameRoom` with the received `room` and `game` state.
|
||||
|
||||
2. **App Integration (`src/client/src/App.tsx`)**:
|
||||
- Added a new "Deck Tester" tab to the main navigation.
|
||||
- Uses the `Play` icon from lucide-react.
|
||||
|
||||
3. **GameRoom Enhancement (`src/client/src/modules/lobby/GameRoom.tsx`)**:
|
||||
- Added `initialGameState` prop to allow initializing the `GameView` immediately without waiting for a socket update (handling potential race conditions or state sync delays).
|
||||
|
||||
### Backend
|
||||
1. **Socket Event (`src/server/index.ts`)**:
|
||||
- Added `start_solo_test` event handler.
|
||||
- Creates a room with status `playing`.
|
||||
- Initializes a game instance.
|
||||
- Adds cards from the provided deck list to the game (library zone).
|
||||
- Emits `room_update` and `game_update` to the client.
|
||||
|
||||
## Outcome
|
||||
The user can now navigate to "Deck Tester", paste a deck list, and immediately enter the 3D Game View to test interactions on the battlefield. This reuses the entire Draft Game infrastructure, ensuring consistency.
|
||||
@@ -1,6 +0,0 @@
|
||||
Implemented CSV export for generated packs and cards.
|
||||
Implemented CSV copy to clipboard functionality.
|
||||
Implemented CSV import template download.
|
||||
Removed demo button and functionality from CubeManager.
|
||||
Updated CSV import template content.
|
||||
Refactored parsing logic to support complex CSV imports.
|
||||
@@ -1,21 +0,0 @@
|
||||
|
||||
# CSV Import Robustness Update
|
||||
|
||||
## Background
|
||||
The user provided a specific CSV format associated with typical automated imports. The requirement was to extract relevant information (Quantity, Name, Finish, Scryfall ID) while ignoring other fields (such as Condition, Date Added, etc.).
|
||||
|
||||
## Changes
|
||||
- Refactored `src/client/src/services/CardParserService.ts` to implement dynamic header parsing.
|
||||
- The `parse` method now:
|
||||
- Detects if the first line is a CSV header containing "Quantity" and "Name".
|
||||
- Maps columns to indices based on the header.
|
||||
- Specifically looks for `Quantity`, `Name`, `Finish`, and `Scryfall ID` (checking common variations like 'scryfall_id', 'id', 'uuid').
|
||||
- Uses strictly mapped columns if a header is detected, ensuring other fields are ignored as requested.
|
||||
- Falls back gracefully to previous generic parsing logic if no matching header is found, preserving backward compatibility with Arena/MTGO exports and simple lists.
|
||||
|
||||
## Verification
|
||||
- Verified manually via a test script that the provided CSV content parses correctly into the `CardIdentifier` memory structure.
|
||||
- The extraction correctly identifies Quantity, Name, Finish (Normal/Foil), and Scryfall UUID.
|
||||
|
||||
## Next Steps
|
||||
- Ensure the frontend `CubeManager` works seamlessly with this update (no changes needed there as it uses the service).
|
||||
@@ -1,17 +0,0 @@
|
||||
# 2025-12-16 - Draft Rules and Logic Implementation
|
||||
|
||||
## Draft Minimum Players
|
||||
- Added backend check in `index.ts` to prevent drafting with fewer than 4 players.
|
||||
- Emit `draft_error` to room if condition is not met.
|
||||
- Added `draft_error` listener in `GameRoom.tsx` to notify users.
|
||||
|
||||
## 4-Player Draft Rules (Pick 2)
|
||||
- Modified `DraftManager.ts`:
|
||||
- Added `pickedInCurrentStep` to track picks within a single pack pass cycle.
|
||||
- Implemented logic in `pickCard`:
|
||||
- If 4 players: Require 2 picks before passing pack.
|
||||
- Else: Require 1 pick.
|
||||
- Logic handles pack exhaustion (if pack runs out before picks completed, it passes).
|
||||
|
||||
## Robustness
|
||||
- Updated `rejoin_room` handler in `index.ts` to send the current `draft` state if the room is in `drafting` status. This allows users to refresh and stay in the draft flow (critical for multi-pick scenarios).
|
||||
@@ -1,14 +0,0 @@
|
||||
# Fix Socket Mixed Content Error
|
||||
|
||||
## Objective
|
||||
Resolve the "Mixed Content" error preventing the Online Lobby and Deck Tester from functioning in the production Kubernetes environment. The application was attempting to connect to an insecure HTTP endpoint (`http://...:3000`) from a secure HTTPS page.
|
||||
|
||||
## Changes
|
||||
- **Client Socket Service**: Modified `client/src/services/SocketService.ts` to make the connection URL environment-aware.
|
||||
- In **Production**: The URL is now `undefined`, allowing Socket.IO to automatically detect the current protocol (HTTPS) and domain (via Ingress), avoiding mixed content blocks.
|
||||
- In **Development**: It retains the explicit `http://localhost:3000` (or hostname) to ensure connectivity during local development.
|
||||
- **TypeScript Config**: Added a reference directive `/// <reference types="vite/client" />` to `SocketService.ts` to ensure `import.meta.env` is correctly typed during the build.
|
||||
|
||||
## Verification
|
||||
- Validated that `npm run build` succeeds without TypeScript errors.
|
||||
- Confirmed that the fix aligns with standard Vite + Socket.IO production deployment patterns.
|
||||
@@ -1,13 +0,0 @@
|
||||
# 2025-12-16 - Fix Pack Duplication in Draft
|
||||
|
||||
## Problem
|
||||
Users reported behavior consistent with "opening the same pack twice". This occurs when the pack objects distributed to players share the same memory reference. If the input source (e.g., from Frontend Generator) contains duplicate references (e.g., created via `Array.fill(pack)`), picking a card from "one" pack would seemingly remove it from "another" pack in a future round, or valid packs would re-appear.
|
||||
|
||||
## Solution
|
||||
- Modified `DraftManager.createDraft` to enforce Strict Isolation of pack instances.
|
||||
- Implemented **Deep Cloning**: Even if the input array contains shared references, we now map over `allPacks`, spreading the pack object and mapping the cards array to new objects.
|
||||
- **Unique IDs**: Re-assigned a unique internal ID to every single pack (format: `draft-pack-{index}-{random}`) to guarantee that every pack in the system is distinct, regardless of the quality of the input data.
|
||||
|
||||
## Impact
|
||||
- Ensures that every "pack" opened in the draft is an independent entity.
|
||||
- Prevents state leakage between rounds or players.
|
||||
@@ -1,21 +0,0 @@
|
||||
# 2025-12-16 - Reconnection and Auto-Pick
|
||||
|
||||
## Reconnection Logic
|
||||
- Use `localStorage.setItem('active_room_id', roomId)` in `LobbyManager` to persist connection state.
|
||||
- Upon page load, if a saved room ID exists, attempted to automatically reconnect via `rejoin_room` socket event.
|
||||
- Updated `socket.on('join_room')` and `rejoin_room` on the server to update the player's socket ID mapping, canceling any pending "disconnect" timers.
|
||||
|
||||
## Disconnection Handling
|
||||
- Updated `RoomManager` to track `socketId` and `isOffline` status for each player.
|
||||
- In `index.ts`, `socket.on('disconnect')`:
|
||||
- Marks player as offline.
|
||||
- Starts a **30-second timer**.
|
||||
- If timer expires (user did not reconnect):
|
||||
- Triggers `draftManager.autoPick(roomId, playerId)`.
|
||||
- `autoPick` selects a random card from the active pack to unblock the draft flow.
|
||||
|
||||
## Auto-Pick Implementation
|
||||
- Added `autoPick` to `DraftManager`:
|
||||
- Checks if player has an active pack.
|
||||
- Selects random index.
|
||||
- Calls `pickCard` internally to process the pick (add to pool, pass pack, etc.).
|
||||
@@ -1,23 +0,0 @@
|
||||
# Draft Interface UI Polish
|
||||
|
||||
## Status
|
||||
- [x] Analyze current UI issues (bottom border, scrolling).
|
||||
- [x] Remove global padding from `App.tsx`.
|
||||
- [x] Refactor `DraftView.tsx` for a cleaner, game-like experience.
|
||||
- [x] Implement immersive 3D effects and tray-style pool view.
|
||||
|
||||
## Context
|
||||
The user requested to improve the draft card pick interface. Specifically to remove the ugly bottom border, avoid page scrolls, and make it feel more like a game.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### `src/client/src/App.tsx`
|
||||
- Removed `pb-20` from the main container to allow full-screen layouts without forced scrolling at the bottom.
|
||||
|
||||
### `src/client/src/modules/draft/DraftView.tsx`
|
||||
- **Layout**: Changed to relative positioning with `overflow-hidden` to contain all elements within the viewport.
|
||||
- **Visuals**:
|
||||
- Added a radial gradient background overlay.
|
||||
- Redesigned the "Current Pack" area with `[perspective:1000px]` and 3D hover transforms.
|
||||
- Redesigned the "Your Pool" bottom area to be a "tray" with `backdrop-blur`, gradient background, and removed the boxy border.
|
||||
- **Scrollbars**: Hidden scrollbars in the main pack view for a cleaner look (`[&::-webkit-scrollbar]:hidden`).
|
||||
@@ -1,34 +0,0 @@
|
||||
# Resizable Draft Interface
|
||||
|
||||
## Status
|
||||
- [x] Implement resizable bottom "Pool" panel.
|
||||
- [x] Implement resizable card size slider.
|
||||
- [x] Persist settings to `localStorage`.
|
||||
|
||||
## Technical Plan
|
||||
|
||||
### `src/client/src/modules/draft/DraftView.tsx`
|
||||
|
||||
1. **State Initialization**:
|
||||
- `poolHeight`: number (default ~220). Load from `localStorage.getItem('draft_poolHeight')`.
|
||||
- `cardScale`: number (default 1 or specific width like 224px). Load from `localStorage.getItem('draft_cardScale')`.
|
||||
|
||||
2. **Resize Handle**:
|
||||
- Insert a `div` cursor-row-resize between the Main Area and the Bottom Area.
|
||||
- Implement `onMouseDown` handler to start dragging.
|
||||
- Implement `onMouseMove` and `onMouseUp` on the window/document to handle the resize logic.
|
||||
|
||||
3. **Card Size Control**:
|
||||
- Add a slider (`<input type="range" />`) in the Top Header area to adjust `cardScale`.
|
||||
- Apply this scale to the card images/containers in the Main Area.
|
||||
|
||||
4. **Persistence**:
|
||||
- `useEffect` hooks to save state changes to `localStorage`.
|
||||
|
||||
5. **Refactoring Styling**:
|
||||
- Change `h-[220px]` class on the bottom panel to `style={{ height: poolHeight }}`.
|
||||
- Update card width class `w-56` to dynamic style or class based on scale.
|
||||
|
||||
## UX Improvements
|
||||
- Add limit constraints (min height for pool, max height for pool).
|
||||
- Add limit constraints for card size (min visible, max huge).
|
||||
@@ -1,13 +0,0 @@
|
||||
# Card Zoom on Hover
|
||||
|
||||
## Status
|
||||
- [x] Add `hoveredCard` state to `DraftView`.
|
||||
- [x] Implement `onMouseEnter`/`onMouseLeave` handlers for cards in both Pick and Pool areas.
|
||||
- [x] rendering a fixed, high z-index preview of the hovered card.
|
||||
- [x] Disable right-click context menu on Draft interface.
|
||||
|
||||
## Implementation Details
|
||||
- **File**: `src/client/src/modules/draft/DraftView.tsx`
|
||||
- **Zoom Component**: A fixed `div` containing the large card image.
|
||||
- **Position**: Fixed to the left or right side of the screen (e.g., `left-10 top-1/2 -translate-y-1/2`) to avoid covering the grid being interacted with (which is usually centered).
|
||||
- **Styling**: Large size (e.g., `w-80` or `h-[500px]`), shadow, border, rounded corners.
|
||||
@@ -1,15 +0,0 @@
|
||||
# Card Zoom on Hover - Dedicated Zone
|
||||
|
||||
## Status
|
||||
- [x] Add `hoveredCard` state to `DraftView` (Already done).
|
||||
- [x] Implement `onMouseEnter`/`onMouseLeave` handlers (Already done).
|
||||
- [x] Refactor `DraftView` layout to include a dedicated sidebar for card preview.
|
||||
- [x] Move the zoomed card image into this dedicated zone instead of a fixed overlay.
|
||||
|
||||
## Implementation Details
|
||||
- **File**: `src/client/src/modules/draft/DraftView.tsx`
|
||||
- **Layout Change**:
|
||||
- Wrap the central card selection area in a `flex-row` container.
|
||||
- Add a Left Sidebar (e.g., `w-80`) reserved for the zoomed card.
|
||||
- Ensure the main card grid takes up the remaining space (`flex-1`).
|
||||
- **Behavior**: When no card is hovered, the sidebar can show a placeholder or remain empty/decorative.
|
||||
@@ -1,22 +0,0 @@
|
||||
# Host Disconnect Pause Logic
|
||||
|
||||
## Objective
|
||||
Ensure the game pauses for all players when the Host disconnects, preventing auto-pick logic from advancing the game state. enable players to leave cleanly.
|
||||
|
||||
## Changes
|
||||
1. **Server (`src/server/index.ts`)**:
|
||||
* Refactored socket handlers.
|
||||
* Implemented `startAutoPickTimer` / `stopAllRoomTimers` helpers.
|
||||
* Updated `disconnect` handler: Checks if disconnected player is passed host. If true, pauses game (stops all timers).
|
||||
* Updated `join_room` / `rejoin_room`: Resumes game (restarts timers) if Host reconnects.
|
||||
* Added `leave_room` event handler to properly remove players from room state.
|
||||
|
||||
2. **Frontend (`src/client/src/modules/lobby/LobbyManager.tsx`)**:
|
||||
* Updated `handleExitRoom` to emit `leave_room` event, preventing "ghost" connections.
|
||||
|
||||
3. **Frontend (`src/client/src/modules/lobby/GameRoom.tsx`)**:
|
||||
* Fixed build error (unused variable `setGameState`) by adding `game_update` listener.
|
||||
* Verified "Game Paused" overlay logic exists and works with the new server state (`isHostOffline`).
|
||||
|
||||
## Result
|
||||
Host disconnection now effectively pauses the draft flow. Reconnection resumes it. Players can leave safely.
|
||||
@@ -1,26 +0,0 @@
|
||||
# Anti-Tampering Implementation
|
||||
|
||||
## Objective
|
||||
Implement a robust anti-tampering system to prevent players (including the host) from manipulating the game state via malicious client-side emissions.
|
||||
|
||||
## Changes
|
||||
1. **Server (`src/server/managers/RoomManager.ts`)**:
|
||||
* Added `getPlayerBySocket(socketId)` to securely identify the player associated with a connection, eliminating reliance on client-provided IDs.
|
||||
|
||||
2. **Server (`src/server/index.ts`)**:
|
||||
* Refactored all major socket event listeners (`pick_card`, `game_action`, `start_draft`, `player_ready`) to use `roomManager.getPlayerBySocket(socket.id)`.
|
||||
* The server now ignores `playerId` and `roomId` sent in the payload (where applicable) and uses the trusted session context instead.
|
||||
* This ensures that a user can only perform actions for *themselves* in the room they are *actually connected to*.
|
||||
|
||||
3. **Server (`src/server/managers/GameManager.ts`)**:
|
||||
* Updated `handleAction` to accept an authentic `actorId`.
|
||||
* Added ownership/controller checks to sensitive actions:
|
||||
* `moveCard`: Only the controller can move a card.
|
||||
* `updateLife`: Only the player can update their own life.
|
||||
* `drawCard`, `createToken`, etc.: Validated against `actorId`.
|
||||
|
||||
4. **Frontend (`GameView.tsx`, `DraftView.tsx`, `DeckBuilderView.tsx`)**:
|
||||
* Cleaned up socket emissions to stop sending redundant `roomId` and `playerId` fields, aligning client behavior with the new secure server expectations (though server would safely ignore them anyway).
|
||||
|
||||
## Result
|
||||
The system is now significantly more resistant to session hijacking or spoofing. Users cannot act as other players or manipulate game state objects they do not control, even if they manually emit socket events from the console.
|
||||
@@ -1,12 +0,0 @@
|
||||
# Fix Draft UI Layout Consistency
|
||||
|
||||
## Objective
|
||||
Fix the layout inconsistency where the "Waiting for next pack..." screen and other views in the Draft interface do not fully occupy the screen width, causing the UI to look collapsed or disconnected from the global sidebars.
|
||||
|
||||
## Changes
|
||||
1. **DraftView.tsx**: Added `flex-1` and `w-full` to the root container. This ensures the component expands to fill the available space in the `GameRoom` flex container, maintaining the full-screen layout even when content (like the "waiting" message) is minimal.
|
||||
2. **DeckBuilderView.tsx**: Added `flex-1` and `w-full` to the root container for consistency and to ensure the deck builder also behaves correctly within the main layout.
|
||||
|
||||
## Verification
|
||||
- The `DraftView` should now stretch to fill the area between the left edge (or internal Zoom sidebar) and the right Lobby/Chat sidebar in `GameRoom`.
|
||||
- The "Waiting for next pack..." message will remain centered within this full-height, full-width area, with the background gradient covering the entire zone.
|
||||
@@ -1,38 +0,0 @@
|
||||
# implementation_plan - Draft Session Persistence and Restoration
|
||||
|
||||
This plan addresses the issue where users are unable to reliably rejoin a draft session as a player after reloading or exiting, often re-entering as a spectator. It ensures robust session synchronization to local storage and handles player "leave" actions safely during active games.
|
||||
|
||||
## User Objectives
|
||||
- **Session Restoring**: Automatically rejoin the correct session and player seat upon reloading the application.
|
||||
- **Prevent Accidental Data Loss**: Ensure "Exiting" a room during an active draft does not destroy the player's seat, allowing them to rejoin.
|
||||
- **Start New Draft**: Maintain the ability for a user to explicitly invalid/abandon an old session to start a new one (handled by creating a new room, which overwrites local storage).
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Server-Side: Safer `leaveRoom` Logic
|
||||
**File**: `src/server/managers/RoomManager.ts`
|
||||
- Modify `leaveRoom` method.
|
||||
- **Logic**:
|
||||
- If `room.status` is `'waiting'`, remove the player (current behavior).
|
||||
- If `room.status` is `'drafting'`, `'deck_building'`, or `'playing'`, **DO NOT** remove the player from `room.players`. Instead, mark them as `isOffline = true` (similar to a disconnect).
|
||||
- This ensures that if the user rejoins with the same `playerId`, they find their existing seat instead of being assigned a new "spectator" role.
|
||||
|
||||
### 2. Server-Side: Robust `rejoin_room` Handler
|
||||
**File**: `src/server/index.ts`
|
||||
- Update `socket.on('rejoin_room')`.
|
||||
- **Change**: Implement an acknowledgement `callback` pattern consistent with other socket events.
|
||||
- **Logic**:
|
||||
- Accept `{ roomId, playerId }`.
|
||||
- If successful, invoke `callback({ success: true, room, draftState })`.
|
||||
- Broadcast `room_update` to other players (to show user is back online).
|
||||
|
||||
### 3. Client-Side: Correct Rejoin Implementation
|
||||
**File**: `src/client/src/modules/lobby/LobbyManager.tsx`
|
||||
- **Fix**: In the `rejoin_room` emit call, explicitly include the `playerId`.
|
||||
- **Enhancement**: Utilize the callback from the server to confirm reconnection before setting state.
|
||||
- **Exit Handling**: The `handleExitRoom` function clears `localStorage`, which is correct for an explicit "Exit". However, thanks to the server-side change, if the user manually rejoins the same room code, they will reclaim their seat effectively.
|
||||
|
||||
## Verification Plan
|
||||
1. **Test Reload**: Start a draft, refresh the browser. Verify user auto-rejoins as Player.
|
||||
2. **Test Exit & Rejoin**: Start a draft, click "Exit Room". Re-enter the Room ID manually. Verify user rejoins as Player (not Spectator).
|
||||
3. **Test New Draft**: Create a room, start draft. Open new tab (or exit), create NEW room. Verify new room works and old session doesn't interfere.
|
||||
@@ -1,46 +0,0 @@
|
||||
# implementation_plan - Lobby Improvements and Kick Functionality
|
||||
|
||||
This plan addresses user feedback regarding the draft resumption experience, exit button placement, and host management controls.
|
||||
|
||||
## User Objectives
|
||||
1. **Resume Draft on Re-entry**: Ensure that manually joining a room (after exiting) correctly restores the draft view if a draft is in progress.
|
||||
2. **Exit Button Placement**: Move the "Exit Room" button to be near the player's name in the lobby sidebar.
|
||||
3. **Kick Player**: Allow the Host to kick players from the room.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Server-Side: Kick Functionality
|
||||
**File**: `src/server/managers/RoomManager.ts`
|
||||
- **Method**: `kickPlayer(roomId, playerId)`
|
||||
- **Logic**:
|
||||
- Remove the player from `room.players`.
|
||||
- If the game is active (drafting/playing), this is a destructive action. We will assume for now it removes them completely (or marks offline? "Kick" usually implies removal).
|
||||
- *Decision*: If kicked, they are removed. If the game breaks, that's the host's responsibility.
|
||||
|
||||
**File**: `src/server/index.ts`
|
||||
- **Event**: `kick_player`
|
||||
- **Logic**:
|
||||
- Verify requester is Host.
|
||||
- Call `roomManager.kickPlayer`.
|
||||
- Broadcast `room_update`.
|
||||
- Emit `kicked` event to the target socket (to force them to client-side exit).
|
||||
|
||||
### 2. Client-Side: Re-entry Logic Fix
|
||||
**File**: `src/client/src/modules/lobby/GameRoom.tsx`
|
||||
- **Logic**: Ensure `GameRoom` correctly initializes or updates `draftState` when receiving new props.
|
||||
- Add a `useEffect` to update local `draftState` if `initialDraftState` prop changes (though `key` change on component might be better, we'll use `useEffect`).
|
||||
|
||||
### 3. Client-Side: UI Updates
|
||||
**File**: `src/client/src/modules/lobby/GameRoom.tsx`
|
||||
- **Sidebar**:
|
||||
- Update the player list rendering.
|
||||
- If `p.id === currentPlayerId`, show an **Exit/LogOut** button next to the name.
|
||||
- If `isMeHost` and `p.id !== me`, show a **Kick/Ban** button next to the name.
|
||||
- **Handlers**:
|
||||
- `handleKick(targetId)`: Warning confirmation -> Emit `kick_player`.
|
||||
- `handleExit()`: Trigger the existing `onExit`.
|
||||
|
||||
## Verification Plan
|
||||
1. **Test Kick**: Host kicks a player. Player should be removed from list and client should revert to lobby (via socket event).
|
||||
2. **Test Exit**: Click new Exit button in sidebar. Should leave room.
|
||||
3. **Test Re-join**: Join the room code again. Should immediately load the Draft View (not the Lobby View).
|
||||
@@ -1,32 +0,0 @@
|
||||
# 2025-12-16 - Draft Timer Enforcement
|
||||
|
||||
## Status
|
||||
Completed
|
||||
|
||||
## Description
|
||||
Implemented server-side timer enforcement for the draft phase to ensure the game progresses even if players are AFK or disconnected.
|
||||
|
||||
## Changes
|
||||
1. **Server: DraftManager.ts**
|
||||
* Updated `DraftState` to include `pickExpiresAt` (timestamp) for each player and `isPaused` for the draft.
|
||||
* Initialize `pickExpiresAt` to 60 seconds from now when a player receives a pack (initial or passed).
|
||||
* Implemented `checkTimers()` method to iterate over all active drafts and players. If `Date.now() > pickExpiresAt`, it triggers `autoPick`.
|
||||
* Implemented `setPaused()` to handle host disconnects. When resuming, timers are reset to 60s to prevent immediate timeout.
|
||||
|
||||
2. **Server: index.ts**
|
||||
* Removed ad-hoc `playerTimers` map and individual `setTimeout` logic associated with socket disconnect events.
|
||||
* Added a global `setInterval` (1 second tick) that calls `draftManager.checkTimers()` and broadcasts updates.
|
||||
* Updated `disconnect` handler to pause the draft if the host disconnects (`draftManager.setPaused(..., true)`).
|
||||
* Updated `join_room` / `rejoin_room` handlers to resume the draft if the host reconnects.
|
||||
|
||||
3. **Client: DraftView.tsx**
|
||||
* Updated the timer display logic to calculate remaining time based on `draftState.players[id].pickExpiresAt` - `Date.now()`.
|
||||
* The timer now accurately reflects the server-enforced deadline.
|
||||
|
||||
## Behavior
|
||||
* **Drafting**: Each pick has a 60-second limit.
|
||||
* **Deck Building**: 120-second limit. If time runs out, the game forces start. Any unready players have their entire draft pool submitted as their deck automatically.
|
||||
* **Timeout**: If time runs out, a random card is automatically picked, and the next pack (if available) is loaded with a fresh 60s timer.
|
||||
* **AFK**: If a user is AFK, the system continues to auto-pick for them until the draft concludes.
|
||||
* **Host Disconnect**: If the host leaves, the draft pauses for everyone. Timer stops.
|
||||
* **Host Reconnect**: Draft resumes, and all active pick timers are reset to 60s.
|
||||
@@ -1,21 +0,0 @@
|
||||
# Plan: Enhance Card Metadata
|
||||
|
||||
## Objective
|
||||
Update Scryfall fetching and parsing logic to include comprehensive metadata for cards. This will enable more precise pack generation algorithms in the future (e.g., filtering by legality, format, artist, or specific frame effects).
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Update `ScryfallCard` Interface (`src/client/src/services/ScryfallService.ts`)**
|
||||
* Add fields for `legalities`, `finishes`, `games`, `produced_mana`, `artist`, `released_at`, `frame_effects`, `security_stamp`, `promo_types`.
|
||||
* Define a more robust `ScryfallCardFace` interface.
|
||||
|
||||
2. **Update `DraftCard` Interface (`src/client/src/services/PackGeneratorService.ts`)**
|
||||
* Add corresponding fields to the internal `DraftCard` interface to store this data in the application state.
|
||||
|
||||
3. **Update `PackGeneratorService.processCards`**
|
||||
* Map the new fields from `ScryfallCard` to `DraftCard` during the processing phase.
|
||||
* Ensure `cardFaces` are also mapped correctly if present (useful for Flip cards where we might want front/back info).
|
||||
|
||||
4. **Verification**
|
||||
* Build the project to ensure no type errors.
|
||||
* (Optional) Run a test script or verify in browser if possible, but static analysis should suffice for interface updates.
|
||||
@@ -1,30 +0,0 @@
|
||||
# Pack Generation Algorithm Update
|
||||
|
||||
## Objective
|
||||
Update the pack generation logic to match a new 15-slot "Play Booster" structure.
|
||||
The new structure includes:
|
||||
- **Slots 1-6:** Commons (Color Balanced).
|
||||
- **Slot 7:** Common (87%), List (C/U 10%, R/M 2%), or Special Guest (1%).
|
||||
- **Slots 8-10:** Uncommons (3).
|
||||
- **Slot 11:** Rare (7/8) or Mythic (1/8).
|
||||
- **Slot 12:** Basic Land or Common Dual Land (20% Foil).
|
||||
- **Slot 13:** Wildcard (Non-Foil) - Weighted Rarity.
|
||||
- **Slot 14:** Wildcard (Foil) - Weighted Rarity.
|
||||
- **Slot 15:** Marketing Token / Art Card.
|
||||
|
||||
## Implementation Details
|
||||
1. **Updated `PackGeneratorService.ts`**:
|
||||
- Modified `processedPools` to explicitly categorize `lands` (Basic + Common Dual) and `tokens`.
|
||||
- Updated `processCards` to sort cards into these new pools (instead of filtering them out completely).
|
||||
- Rewrote `buildSinglePack` (for `standard` rarity mode) to implement the 15-slot sequencing.
|
||||
- Implemented logic for:
|
||||
- Color balancing commons (naive attempt).
|
||||
- "The List" simulation (using Wildcard logic from pools).
|
||||
- Slots 13/14 Wildcards with weighted probabilities.
|
||||
- Foil application (cloning card and setting `finish`).
|
||||
- Slot 12 Land selection (preferring separate land pool).
|
||||
- Added interfaces for `typeLine` and `layout` to `DraftCard`.
|
||||
|
||||
## Status
|
||||
- Implemented and Verified via static check (TS linting was fixed).
|
||||
- Ready for testing in the client.
|
||||
@@ -1,26 +0,0 @@
|
||||
# Card Metadata Enhancement
|
||||
|
||||
## Objective
|
||||
Enhance the Scryfall data fetching and internal card representation to include full metadata (CMC, Oracle Text, Power/Toughness, Collector Number, etc.). This allows strictly precise pack generation and potential future features like mana curve analysis or specific slot targeting.
|
||||
|
||||
## Changes
|
||||
1. **Updated `ScryfallService.ts`**:
|
||||
- Extended `ScryfallCard` interface to include:
|
||||
- `cmc` (number)
|
||||
- `mana_cost` (string)
|
||||
- `oracle_text` (string)
|
||||
- `power`, `toughness` (strings)
|
||||
- `collector_number` (string)
|
||||
- `color_identity` (string[])
|
||||
- `keywords` (string[])
|
||||
- `booster` (boolean)
|
||||
- `promo`, `reprint` (booleans)
|
||||
- Verified that `fetch` calls already return this data; TS interface update exposes it.
|
||||
|
||||
2. **Updated `PackGeneratorService.ts`**:
|
||||
- Extended `DraftCard` internal interface to include the same metadata fields (normalized names like `manaCost`, `oracleText`).
|
||||
- Updated `processCards` function to map these fields from the Scryfall response to the `DraftCard` object.
|
||||
|
||||
## Impact
|
||||
- Pack generation now has access to rich metadata.
|
||||
- Future-proofs the system for "The List" exact matching (via collector number or promo types) and game logic (CMC sorting).
|
||||
@@ -1,20 +0,0 @@
|
||||
# Peasant Algorithm Implementation
|
||||
|
||||
## Overview
|
||||
Implemented the detailed "Peasant" pack generation algorithm in `PackGeneratorService.ts`.
|
||||
|
||||
## Changes
|
||||
- Updated `buildSinglePack` in `PackGeneratorService.ts` to include specific logic for Peasant rarity mode.
|
||||
- Implemented slot-based generation:
|
||||
- Slots 1-6: Commons (Color Balanced)
|
||||
- Slot 7: Common or "The List" (Simulated)
|
||||
- Slots 8-11: Uncommons
|
||||
- Slot 12: Land (20% Foil)
|
||||
- Slot 13: Non-Foil Wildcard (Weighted by rarity)
|
||||
- Slot 14: Foil Wildcard (Weighted by rarity)
|
||||
- Slot 15: Marketing Token
|
||||
|
||||
## Notes
|
||||
- Used existing helper methods `drawColorBalanced` and `drawUniqueCards`.
|
||||
- Simulated "The List" logic using available Common/Uncommon pools as exact "The List" metadata might not be available in standard pools provided to the generator.
|
||||
- Wildcard weights follow the specification (~49% C, ~24% U, ~13% R, ~13% M).
|
||||
@@ -1,28 +0,0 @@
|
||||
# Plan: Persist Scryfall Metadata
|
||||
|
||||
## Objective
|
||||
Persist fetched Scryfall card metadata in the browser's IndexedDB. This ensures that:
|
||||
1. Metadata (including the newly added rich fields) is saved across sessions.
|
||||
2. Pack generation can rely on this data without re-fetching.
|
||||
3. The application works better offline or with poor connection after initial fetch.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Create `src/client/src/utils/db.ts`**
|
||||
* Implement a lightweight IndexedDB wrapper.
|
||||
* Database Name: `mtg-draft-maker`
|
||||
* Store Name: `cards`
|
||||
* Methods: `putCard`, `getCard`, `getAllCards`, `bulkPut`.
|
||||
|
||||
2. **Update `ScryfallService.ts`**
|
||||
* Import the DB utilities.
|
||||
* In `constructor` or a new `initialize()` method, load all persisted cards into memory (`cacheById` and `cacheByName`).
|
||||
* In `fetchCollection`, `fetchSetCards`, etc., whenever cards are fetched from API, save them to DB via `bulkPut`.
|
||||
* Modify `fetchCollection` to check memory cache (which is now pre-filled from DB) before network.
|
||||
|
||||
3. **Refactor `fetchCollection` deduplication**
|
||||
* Since cache is pre-filled, the existing check `if (this.cacheById.has(...))` will effectively check the persisted data.
|
||||
|
||||
## Verification
|
||||
* Reload page -> Check if cards are loaded immediately without network requests (network tab).
|
||||
* Check Application -> Storage -> IndexedDB in browser devtools (mental check).
|
||||
@@ -1,14 +0,0 @@
|
||||
# Multi-Expansion Selection
|
||||
|
||||
## Objective
|
||||
Enhanced the "From Expansion" pack generation feature in the Cube Manager to allow users to select multiple expansions and use a searchable interface.
|
||||
|
||||
## Implementation Details
|
||||
1. **Searchable Interface**: Replaced the simple set dropdown with a dedicated set selection UI featuring a search input for fuzzy filtering by set name or code.
|
||||
2. **Multi-Select Capability**: Users can now check multiple sets from the filtered list.
|
||||
3. **Frontend State Refactor**: Migrated `selectedSet` (string) to `selectedSets` (string array) in `CubeManager.tsx`.
|
||||
4. **Fetch Logic Update**: Updated `fetchAndParse` to iterate through all selected sets, fetching card data for each sequentially and combining the results into the parse pool.
|
||||
5. **Generation Logic**: The existing `generateBoosterBox` logic now naturally consumes the combined pool of cards from multiple sets, effectively allowing for "Chaos Drafts" or custom mixed-set environments based on the user's selection.
|
||||
|
||||
## Status
|
||||
Completed. The Cube Manager UI now supports advanced set selection scenarios.
|
||||
@@ -1,15 +0,0 @@
|
||||
# Game Type Filter for Expansion Selection
|
||||
|
||||
## Objective
|
||||
Add a filter to the "From Expansion" set selection to easily distinguish between Paper and Digital (MTGA/MTGO) sets.
|
||||
|
||||
## Implementation Details
|
||||
1. **ScryfallService Update**: Updated `ScryfallSet` interface to include the `digital` boolean property and mapped it in `fetchSets`.
|
||||
2. **CubeManager UI**: Added a toggle filter bar above the set list with three options:
|
||||
* **All**: Shows all sets.
|
||||
* **Paper**: Shows only sets where `digital` is false.
|
||||
* **Digital**: Shows only sets where `digital` is true.
|
||||
3. **Filter Logic**: Integrated the game type filter into the existing search filter logic in `CubeManager`.
|
||||
|
||||
## Status
|
||||
Completed. Users can now filter the expansion list by game type.
|
||||
@@ -1,20 +0,0 @@
|
||||
# Plan: Improve Parse Bulk Feedback
|
||||
|
||||
## Objective
|
||||
Enhance the "Parse Bulk" workflow in `CubeManager` to provide explicit feedback on the result of the Scryfall metadata fetching. This ensures the user knows that "images and metadata" have been successfully generated (fetched) for their list, fulfilling the request for precision.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Update `CubeManager.tsx`**
|
||||
* In `fetchAndParse` function:
|
||||
* Track `notFoundCount` (identifiers that returned no Scryfall data).
|
||||
* Track `successCount` (identifiers that were successfully enriched).
|
||||
* After the loop, check if `notFoundCount > 0`.
|
||||
* Show a summary notification/alert: "Processed X cards. Y cards could not be identified."
|
||||
* (Optional) If many failures, maybe show a list of names? For now, just the count is a good start.
|
||||
|
||||
2. **Verify Data Integrity**
|
||||
* Ensure that the `processedData` uses the fully enriched `DraftCard` objects (which we know it does from previous steps).
|
||||
|
||||
## Why This Matters
|
||||
The user asked to "Generate image and metadata... upon Parse bulk". While the backend/service logic is done, the UI needs to confirm this action took place to give the user confidence that the underlying algorithm now has the precise data it needs.
|
||||
@@ -1,20 +0,0 @@
|
||||
# Plan: Full Metadata Passthrough
|
||||
|
||||
## Objective
|
||||
Ensure that the `DraftCard` objects used throughout the application (and eventually sent to the backend) contain the **complete** original metadata from Scryfall. The user has explicitly requested access to "all cards informations" for future algorithms.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Update `ScryfallService.ts`**
|
||||
* Add an index signature `[key: string]: any;` to the `ScryfallCard` interface. This acknowledges that the object contains more fields than strictly typed, preventing TypeScript from complaining when accessing obscure fields, and correctly modeling the API response.
|
||||
|
||||
2. **Update `PackGeneratorService.ts`**
|
||||
* Add `sourceData: ScryfallCard;` (or similar name like `scryfallData`) to the `DraftCard` interface.
|
||||
* In `processCards`, assign the incoming `cardData` (the full Scryfall object) to this new property.
|
||||
|
||||
## Impact
|
||||
* **Data Size**: Payload size for rooms will increase, but this is acceptable (and requested) for the richness of data required.
|
||||
* **Flexibility**: Future updates to pack generation (e.g., checking specific `frame_effects` or `prices`) will not require interface updates; the data will already be there in `card.sourceData`.
|
||||
|
||||
## Verification
|
||||
* The valid "Parse Bulk" operation will now produce `DraftCard`s that, if inspected, contain the full Scryfall JSON.
|
||||
@@ -1,25 +0,0 @@
|
||||
# Plan: Server-Side Caching of Bulk Data
|
||||
|
||||
## Objective
|
||||
Implement server-side caching of both card images and metadata upon bulk parsing, ensuring the application relies on local assets rather than external Scryfall URLs.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Refactor Server Architecture (`CardService.ts`)**
|
||||
* Update storage paths to `public/cards/images` (previously `public/cards`) and `public/cards/metadata`.
|
||||
* Implement `cacheMetadata` to save JSON files alongside images.
|
||||
|
||||
2. **Update API Endpoint (`index.ts`)**
|
||||
* Modify `POST /api/cards/cache` to handle metadata saving in addition to image downloading.
|
||||
* Update static file serving to map `/cards` to `public/cards`, making images accessible at `/cards/images/{id}.jpg`.
|
||||
|
||||
3. **Update Client Logic (`CubeManager.tsx`, `PackGeneratorService.ts`, `LobbyManager.tsx`)**
|
||||
* **Generation**: Pass a flag (`useLocalImages`) to the generator service.
|
||||
* **Url Construction**: Generator now produces URLs like `${origin}/cards/images/{id}.jpg` when the flag is set.
|
||||
* **Triggers**: `CubeManager` immediately sends parsed data to the server for caching before generating packs.
|
||||
* **Consistency**: `LobbyManager` updated to look for images in the new `/cards/images` path for multiplayer sessions.
|
||||
|
||||
## Impact
|
||||
* **Performance**: Initial "Parse Bulk" takes slightly longer (due to server cache call), but subsequent interactions are instant and local.
|
||||
* **Reliability**: Application works offline or without Scryfall after initial parse.
|
||||
* **Precision**: Metadata is now persisted as individual JSONs on the backend, ready for future complex backend algorithms.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Enable Clear Session Button in Pack Generator
|
||||
|
||||
## Object
|
||||
Enable and improve the "Clear Session" button in the Cube Manager (Pack Generator) to allow users to restart the generation process from a clean state.
|
||||
|
||||
## Changes
|
||||
- Modified `CubeManager.tsx`:
|
||||
- Updated `handleReset` logic (verified).
|
||||
- enhanced "Clear Session" button styling to be more visible (red border/text) and indicate its destructive nature.
|
||||
- Added `disabled={loading}` to prevent state conflicts during active operations.
|
||||
- **Replaced `window.confirm` with a double-click UI confirmation pattern** to ensure reliability and better UX (fixed issue where native confirmation dialog was failing).
|
||||
|
||||
## Status
|
||||
- [x] Implementation complete.
|
||||
- [x] Verified logic for `localStorage` clearing.
|
||||
- [x] Verified interaction in browser (button changes state, clears data on second click).
|
||||
9250
docs/development/mtg-rulebook/MagicCompRules20251114.txt
Normal file
9250
docs/development/mtg-rulebook/MagicCompRules20251114.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
Slots 1-6 (Commons): 6 Common cards. Ensure color balance (attempt to include at least 3 distinct colors).
|
||||
Slot 7 (Common/List Slot):
|
||||
- Roll a d100.
|
||||
- 1-87: 1 Common from Main Set.
|
||||
- 88-97: 1 Card from "The List" (Common/Uncommon reprint).
|
||||
- 98-100: 1 Uncommon from "The List".
|
||||
Slots 8-11 (Uncommons): 4 Uncommon cards.
|
||||
Slot 12 (Land): 1 Basic or Common Dual Land (20% chance of Foil).
|
||||
Slot 13 (Non-Foil Wildcard):
|
||||
- Can be any rarity (Common, Uncommon, Rare, Mythic).
|
||||
- Use weighted probability: ~62% Common, ~37% Uncommon.
|
||||
- Can be a card from the child sets.
|
||||
Slot 14 (Foil Wildcard):
|
||||
- Same rarity weights as Slot 13, but the card must be Foil.
|
||||
Slot 15 (Marketing): Token or Art Card.
|
||||
@@ -0,0 +1,20 @@
|
||||
Slots 1-6 (Commons): 6 Common cards. Ensure color balance (attempt to include at least 3 distinct colors).
|
||||
Slot 7 (Common/List Slot):
|
||||
- Roll a d100.
|
||||
- 1-87: 1 Common from Main Set.
|
||||
- 88-97: 1 Card from "The List" (Common/Uncommon reprint).
|
||||
- 98-99: 1 Rare/Mythic from "The List".
|
||||
- 100: 1 Special Guest (High Value).
|
||||
Slots 8-10 (Uncommons): 3 Uncommon cards.
|
||||
Slot 11 (Main Rare Slot):
|
||||
- Roll 1d8.
|
||||
- If 1-7: Rare.
|
||||
- If 8: Mythic Rare.
|
||||
Slot 12 (Land): 1 Basic or Common Dual Land (20% chance of Foil).
|
||||
Slot 13 (Non-Foil Wildcard):
|
||||
- Can be any rarity (Common, Uncommon, Rare, Mythic).
|
||||
- Use weighted probability: ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic.
|
||||
- Can be a card from the child sets.
|
||||
Slot 14 (Foil Wildcard):
|
||||
- Same rarity weights as Slot 13, but the card must be Foil.
|
||||
Slot 15 (Marketing): Token or Art Card.
|
||||
@@ -43,7 +43,8 @@ service:
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# kubernetes.io/tls-acme: "true"
|
||||
hosts:
|
||||
|
||||
4
src/.env.example
Normal file
4
src/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
GEMINI_MODEL=gemini-2.0-flash-lite-preview-02-05
|
||||
|
||||
USE_LLM_PICK=true
|
||||
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.g6k3e4tvo1g"
|
||||
}], {});
|
||||
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
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MTG Draft Maker</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-50">
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
src/client/public/favicon.png
Normal file
BIN
src/client/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 414 KiB |
12
src/client/public/icon.svg
Normal file
12
src/client/public/icon.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="120" fill="#0F172A"/>
|
||||
<path d="M128 128H384V384H128V128Z" fill="#1E293B" stroke="#10B981" stroke-width="24" stroke-linejoin="round"/>
|
||||
<path d="M168 168H424V424H168V168Z" fill="#0F172A" stroke="#A855F7" stroke-width="24" stroke-linejoin="round"/>
|
||||
<path d="M256 128V384M128 256H384" stroke="url(#paint0_radial)" stroke-width="4"/>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(256 256) rotate(90) scale(128)">
|
||||
<stop stop-color="#10B981"/>
|
||||
<stop offset="1" stop-color="#A855F7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 738 B |
@@ -5,6 +5,11 @@ import { TournamentManager } from './modules/tournament/TournamentManager';
|
||||
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 { ConfirmDialogProvider } from './components/ConfirmDialog';
|
||||
|
||||
import { PWAInstallPrompt } from './components/PWAInstallPrompt';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>(() => {
|
||||
@@ -22,71 +27,119 @@ export const App: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const [availableLands, setAvailableLands] = useState<any[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('availableLands');
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load lands from storage", e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
localStorage.setItem('activeTab', activeTab);
|
||||
}, [activeTab]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem('generatedPacks', JSON.stringify(generatedPacks));
|
||||
// Optimiziation: Strip 'definition' (ScryfallCard) from cards to save huge amount of space
|
||||
// We only need the properties mapped to DraftCard for the UI and Game
|
||||
const optimizedPacks = generatedPacks.map(p => ({
|
||||
...p,
|
||||
cards: p.cards.map(c => {
|
||||
const { definition, ...rest } = c;
|
||||
return rest;
|
||||
})
|
||||
}));
|
||||
localStorage.setItem('generatedPacks', JSON.stringify(optimizedPacks));
|
||||
} catch (e) {
|
||||
console.error("Failed to save packs to storage", e);
|
||||
console.error("Failed to save packs to storage (Quota likely exceeded)", e);
|
||||
}
|
||||
}, [generatedPacks]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const optimizedLands = availableLands.map(l => {
|
||||
const { definition, ...rest } = l;
|
||||
return rest;
|
||||
});
|
||||
localStorage.setItem('availableLands', JSON.stringify(optimizedLands));
|
||||
} catch (e) {
|
||||
console.error("Failed to save lands to storage", e);
|
||||
}
|
||||
}, [availableLands]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-purple-600 p-2 rounded-lg"><Layers className="w-6 h-6 text-white" /></div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">MTG Peasant Drafter</h1>
|
||||
<p className="text-slate-400 text-xs uppercase tracking-wider">Pack Generator & Tournament Manager</p>
|
||||
<ToastProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<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">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-purple-600 p-2 rounded-lg"><Layers className="w-6 h-6 text-white" /></div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent flex items-center gap-2">
|
||||
MTG Peasant Drafter
|
||||
<span className="px-1.5 py-0.5 rounded-md bg-purple-500/10 border border-purple-500/20 text-[10px] font-bold text-purple-400 tracking-wider shadow-[0_0_10px_rgba(168,85,247,0.1)]">ALPHA</span>
|
||||
</h1>
|
||||
<p className="text-slate-400 text-xs uppercase tracking-wider">Pack Generator & Tournament Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('draft')}
|
||||
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'draft' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<Box className="w-4 h-4" /> <span className="hidden md:inline">Draft Management</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('lobby')}
|
||||
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'lobby' ? 'bg-emerald-600 text-white' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<Users className="w-4 h-4" /> <span className="hidden md:inline">Online Lobby</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('tester')}
|
||||
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'tester' ? 'bg-teal-600 text-white' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<Play className="w-4 h-4" /> <span className="hidden md:inline">Deck Tester</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('bracket')}
|
||||
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'bracket' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<Trophy className="w-4 h-4" /> <span className="hidden md:inline">Tournament</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('draft')}
|
||||
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'draft' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<Box className="w-4 h-4" /> <span className="hidden md:inline">Draft Management</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('lobby')}
|
||||
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'lobby' ? 'bg-emerald-600 text-white' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<Users className="w-4 h-4" /> <span className="hidden md:inline">Online Lobby</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('tester')}
|
||||
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'tester' ? 'bg-teal-600 text-white' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<Play className="w-4 h-4" /> <span className="hidden md:inline">Deck Tester</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('bracket')}
|
||||
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'bracket' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<Trophy className="w-4 h-4" /> <span className="hidden md:inline">Tournament</span>
|
||||
</button>
|
||||
</div>
|
||||
<main className="flex-1 overflow-hidden relative">
|
||||
{activeTab === 'draft' && (
|
||||
<CubeManager
|
||||
packs={generatedPacks}
|
||||
setPacks={setGeneratedPacks}
|
||||
availableLands={availableLands}
|
||||
setAvailableLands={setAvailableLands}
|
||||
onGoToLobby={() => setActiveTab('lobby')}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} availableLands={availableLands} />}
|
||||
{activeTab === 'tester' && <DeckTester />}
|
||||
{activeTab === 'bracket' && <TournamentManager />}
|
||||
</main>
|
||||
|
||||
<footer className="bg-slate-900 border-t border-slate-800 p-2 text-center text-xs text-slate-500 shrink-0">
|
||||
<p>
|
||||
Entire code generated by <span className="text-purple-400 font-medium">Antigravity</span> and <span className="text-sky-400 font-medium">Gemini Pro</span>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-hidden relative">
|
||||
{activeTab === 'draft' && (
|
||||
<CubeManager
|
||||
packs={generatedPacks}
|
||||
setPacks={setGeneratedPacks}
|
||||
onGoToLobby={() => setActiveTab('lobby')}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} />}
|
||||
{activeTab === 'tester' && <DeckTester />}
|
||||
{activeTab === 'bracket' && <TournamentManager />}
|
||||
</main>
|
||||
</div>
|
||||
</ConfirmDialogProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
};
|
||||
|
||||
230
src/client/src/components/CardPreview.tsx
Normal file
230
src/client/src/components/CardPreview.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { DraftCard } from '../services/PackGeneratorService';
|
||||
|
||||
// --- Floating Preview Component ---
|
||||
export const FoilOverlay = () => (
|
||||
<div className="absolute inset-0 z-20 pointer-events-none rounded-xl overflow-hidden">
|
||||
{/* CSS-based Holographic Pattern */}
|
||||
<div className="absolute inset-0 foil-holo" />
|
||||
|
||||
{/* Gaussian Circular Glare - Spinning Radial Gradient (Mildly visible) */}
|
||||
<div className="absolute inset-[-50%] bg-[radial-gradient(circle_at_50%_50%,_rgba(255,255,255,0.25)_0%,_transparent_60%)] mix-blend-overlay opacity-25 animate-spin-slow" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const FloatingPreview: React.FC<{ card: DraftCard; x: number; y: number; isMobile?: boolean; isClosing?: boolean }> = ({ card, x, y, isMobile, isClosing }) => {
|
||||
// Cast finishes to any to allow loose string matching if needed, or just standard check
|
||||
const isFoil = (card.finish as string) === 'foil' || (card.finish as string) === 'etched';
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
// Basic boundary detection
|
||||
const [adjustedPos, setAdjustedPos] = useState({ top: y, left: x });
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Trigger entrance animation
|
||||
requestAnimationFrame(() => setIsMounted(true));
|
||||
}, []);
|
||||
|
||||
const isActive = isMounted && !isClosing;
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) return;
|
||||
|
||||
const OFFSET = 20;
|
||||
const CARD_WIDTH = 300;
|
||||
const CARD_HEIGHT = 420;
|
||||
|
||||
let newX = x + OFFSET;
|
||||
let newY = y + OFFSET;
|
||||
|
||||
if (newX + CARD_WIDTH > window.innerWidth) {
|
||||
newX = x - CARD_WIDTH - OFFSET;
|
||||
}
|
||||
|
||||
if (newY + CARD_HEIGHT > window.innerHeight) {
|
||||
newY = y - CARD_HEIGHT - OFFSET;
|
||||
}
|
||||
|
||||
setAdjustedPos({ top: newY, left: newX });
|
||||
|
||||
}, [x, y, isMobile]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className={`fixed inset-0 z-[9999] pointer-events-none flex items-center justify-center bg-black/60 backdrop-blur-[2px] transition-all duration-300 ease-in-out ${isActive ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<div className={`relative w-[85vw] max-w-sm rounded-2xl overflow-hidden shadow-2xl ring-4 ring-black/50 transition-all duration-300 ${isActive ? 'scale-100 opacity-100 ease-out' : 'scale-95 opacity-0 ease-in'}`}>
|
||||
<img src={card.image} alt={card.name} className="w-full h-auto" />
|
||||
{/* Universal mild brightening overlay */}
|
||||
<div className="absolute inset-0 bg-white/10 pointer-events-none mix-blend-overlay" />
|
||||
{isFoil && <FoilOverlay />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[9999] pointer-events-none"
|
||||
style={{
|
||||
top: adjustedPos.top,
|
||||
left: adjustedPos.left
|
||||
}}
|
||||
>
|
||||
<div className={`relative w-[300px] rounded-xl overflow-hidden shadow-2xl border-4 border-slate-900 bg-black transition-all duration-300 ${isActive ? 'scale-100 opacity-100 ease-out' : 'scale-95 opacity-0 ease-in'}`}>
|
||||
<img ref={imgRef} src={card.image} alt={card.name} className="w-full h-auto" />
|
||||
{/* Universal mild brightening overlay */}
|
||||
<div className="absolute inset-0 bg-white/10 pointer-events-none mix-blend-overlay" />
|
||||
{/* CSS-based Holographic Pattern & Glare */}
|
||||
{isFoil && <FoilOverlay />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Hover Wrapper to handle mouse events ---
|
||||
export const CardHoverWrapper: React.FC<{ card: DraftCard; children: React.ReactNode; className?: string; preventPreview?: boolean }> = ({ card, children, className, preventPreview }) => {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [isLongPressing, setIsLongPressing] = useState(false);
|
||||
const [renderPreview, setRenderPreview] = useState(false);
|
||||
const [coords, setCoords] = useState({ x: 0, y: 0 });
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const initialTouchRef = useRef<{ x: number, y: number } | null>(null);
|
||||
const closeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const hasImage = !!card.image;
|
||||
// Use state for isMobile to handle window resizing and touch capability detection
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
// "Mobile" behavior (No hover, long-press, modal preview) applies if:
|
||||
// 1. Device is primarily touch (pointer: coarse) - e.g. Tablets, Phones
|
||||
// 2. Screen is small (< 1024px) - e.g. Phone in Desktop mode or small window
|
||||
const isTouch = window.matchMedia('(pointer: coarse)').matches;
|
||||
const isSmall = window.innerWidth < 1024;
|
||||
setIsMobile(isTouch || isSmall);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
const shouldShow = (isHovering && !isMobile) || isLongPressing;
|
||||
|
||||
// Handle mounting/unmounting animation
|
||||
useEffect(() => {
|
||||
if (shouldShow) {
|
||||
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
|
||||
setRenderPreview(true);
|
||||
} else {
|
||||
// Delay unmount for animation (all devices)
|
||||
if (renderPreview) {
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
setRenderPreview(false);
|
||||
}, 300); // 300ms matches duration-300
|
||||
} else {
|
||||
setRenderPreview(false);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
|
||||
};
|
||||
}, [shouldShow, isMobile, renderPreview]);
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!hasImage || isMobile) return;
|
||||
setCoords({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent) => {
|
||||
if (isMobile) return;
|
||||
if (preventPreview) return;
|
||||
|
||||
// Check if the card is already "big enough" on screen
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
// Width > 200 && Height > 270 targets readable cards (Stack/Grid) but excludes list rows
|
||||
if (rect.width > 200 && rect.height > 270) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsHovering(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHovering(false);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (!hasImage || !isMobile || preventPreview) return;
|
||||
const touch = e.touches[0];
|
||||
const { clientX, clientY } = touch;
|
||||
|
||||
initialTouchRef.current = { x: clientX, y: clientY };
|
||||
setCoords({ x: clientX, y: clientY });
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
setIsLongPressing(true);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setIsLongPressing(false);
|
||||
initialTouchRef.current = null;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (!initialTouchRef.current) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const moveX = Math.abs(touch.clientX - initialTouchRef.current.x);
|
||||
const moveY = Math.abs(touch.clientY - initialTouchRef.current.y);
|
||||
|
||||
// Cancel if moved more than 10px
|
||||
if (moveX > 10 || moveY > 10) {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
// Do not close if already long pressing
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseMove={handleMouseMove}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchMove={handleTouchMove}
|
||||
onContextMenu={(e) => {
|
||||
// Prevent context menu to allow long-press preview without browser menu
|
||||
// We block it if we are on mobile (trying to open preview)
|
||||
// OR if we are already in long-press state.
|
||||
if ((isMobile && hasImage) || isLongPressing) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{hasImage && renderPreview && (
|
||||
<FloatingPreview
|
||||
card={card}
|
||||
x={coords.x}
|
||||
y={coords.y}
|
||||
isMobile={isMobile}
|
||||
isClosing={!shouldShow}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
77
src/client/src/components/ConfirmDialog.tsx
Normal file
77
src/client/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
interface ConfirmDialogContextType {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const ConfirmDialogContext = createContext<ConfirmDialogContextType | undefined>(undefined);
|
||||
|
||||
export const useConfirm = () => {
|
||||
const context = useContext(ConfirmDialogContext);
|
||||
if (!context) {
|
||||
throw new Error('useConfirm must be used within a ConfirmDialogProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const ConfirmDialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [options, setOptions] = useState<ConfirmOptions>({
|
||||
title: '',
|
||||
message: '',
|
||||
confirmLabel: 'Confirm',
|
||||
cancelLabel: 'Cancel',
|
||||
type: 'warning',
|
||||
});
|
||||
|
||||
const resolveRef = useRef<(value: boolean) => void>(() => { });
|
||||
|
||||
const confirm = useCallback((opts: ConfirmOptions) => {
|
||||
setOptions({
|
||||
confirmLabel: 'Confirm',
|
||||
cancelLabel: 'Cancel',
|
||||
type: 'warning',
|
||||
...opts,
|
||||
});
|
||||
setIsOpen(true);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
resolveRef.current(true);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
resolveRef.current(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmDialogContext.Provider value={{ confirm }}>
|
||||
{children}
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleCancel}
|
||||
title={options.title}
|
||||
message={options.message}
|
||||
type={options.type}
|
||||
confirmLabel={options.confirmLabel}
|
||||
cancelLabel={options.cancelLabel}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</ConfirmDialogContext.Provider>
|
||||
);
|
||||
};
|
||||
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,11 +5,13 @@ interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
message?: string;
|
||||
children?: React.ReactNode;
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
confirmLabel?: string;
|
||||
onConfirm?: () => void;
|
||||
cancelLabel?: string;
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
@@ -17,10 +19,12 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
onClose,
|
||||
title,
|
||||
message,
|
||||
children,
|
||||
type = 'info',
|
||||
confirmLabel = 'OK',
|
||||
onConfirm,
|
||||
cancelLabel
|
||||
cancelLabel,
|
||||
maxWidth = 'max-w-md'
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -45,10 +49,10 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div
|
||||
className={`bg-slate-900 border ${getBorderColor()} rounded-xl shadow-2xl max-w-md w-full p-6 animate-in zoom-in-95 duration-200`}
|
||||
className={`bg-slate-900 border ${getBorderColor()} rounded-xl shadow-2xl ${maxWidth} w-full p-6 animate-in zoom-in-95 duration-200 flex flex-col max-h-[90vh]`}
|
||||
role="dialog"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-start justify-between mb-4 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{getIcon()}
|
||||
<h3 className="text-xl font-bold text-white">{title}</h3>
|
||||
@@ -60,33 +64,42 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-slate-300 mb-8 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
{cancelLabel && onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium transition-colors border border-slate-700"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{message && (
|
||||
<p className="text-slate-300 mb-4 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onConfirm) onConfirm();
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
className={`px-6 py-2 rounded-lg font-bold text-white shadow-lg transition-transform hover:scale-105 ${type === 'error' ? 'bg-red-600 hover:bg-red-500' :
|
||||
type === 'warning' ? 'bg-amber-600 hover:bg-amber-500' :
|
||||
type === 'success' ? 'bg-emerald-600 hover:bg-emerald-500' :
|
||||
'bg-blue-600 hover:bg-blue-500'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{(onConfirm || cancelLabel) && (
|
||||
<div className="flex justify-end gap-3 mt-6 shrink-0">
|
||||
{cancelLabel && onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium transition-colors border border-slate-700"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
{onConfirm && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
className={`px-6 py-2 rounded-lg font-bold text-white shadow-lg transition-transform hover:scale-105 ${type === 'error' ? 'bg-red-600 hover:bg-red-500' :
|
||||
type === 'warning' ? 'bg-amber-600 hover:bg-amber-500' :
|
||||
type === 'success' ? 'bg-emerald-600 hover:bg-emerald-500' :
|
||||
'bg-blue-600 hover:bg-blue-500'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -1,91 +1,15 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React from 'react';
|
||||
import { DraftCard, Pack } from '../services/PackGeneratorService';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { StackView } from './StackView';
|
||||
import { CardHoverWrapper, FoilOverlay } from './CardPreview';
|
||||
|
||||
interface PackCardProps {
|
||||
pack: Pack;
|
||||
viewMode: 'list' | 'grid' | 'stack';
|
||||
cardWidth?: number;
|
||||
}
|
||||
|
||||
// --- Floating Preview Component ---
|
||||
const FloatingPreview: React.FC<{ card: DraftCard; x: number; y: number }> = ({ card, x, y }) => {
|
||||
const isFoil = card.finish === 'foil';
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
// Basic boundary detection to prevent going off-screen
|
||||
// We check window dimensions. This might need customization based on the actual viewport,
|
||||
// but window is a good safe default.
|
||||
const [adjustedPos, setAdjustedPos] = useState({ top: y, left: x });
|
||||
|
||||
useEffect(() => {
|
||||
// Offset from cursor
|
||||
const OFFSET = 20;
|
||||
const CARD_WIDTH = 300; // Approx width of preview
|
||||
const CARD_HEIGHT = 420; // Approx height of preview
|
||||
|
||||
let newX = x + OFFSET;
|
||||
let newY = y + OFFSET;
|
||||
|
||||
// Flip horizontally if too close to right edge
|
||||
if (newX + CARD_WIDTH > window.innerWidth) {
|
||||
newX = x - CARD_WIDTH - OFFSET;
|
||||
}
|
||||
|
||||
// Flip vertically if too close to bottom edge
|
||||
if (newY + CARD_HEIGHT > window.innerHeight) {
|
||||
newY = y - CARD_HEIGHT - OFFSET;
|
||||
}
|
||||
|
||||
setAdjustedPos({ top: newY, left: newX });
|
||||
|
||||
}, [x, y]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[9999] pointer-events-none transition-opacity duration-75"
|
||||
style={{
|
||||
top: adjustedPos.top,
|
||||
left: adjustedPos.left
|
||||
}}
|
||||
>
|
||||
<div className="relative w-[300px] rounded-xl overflow-hidden shadow-2xl border-4 border-slate-900 bg-black">
|
||||
<img ref={imgRef} src={card.image} alt={card.name} className="w-full h-auto" />
|
||||
{isFoil && <div className="absolute inset-0 bg-gradient-to-br from-purple-500/20 to-blue-500/20 mix-blend-overlay animate-pulse"></div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Hover Wrapper to handle mouse events ---
|
||||
const CardHoverWrapper: React.FC<{ card: DraftCard; children: React.ReactNode; className?: string }> = ({ card, children, className }) => {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Only show preview if there is an image
|
||||
const hasImage = !!card.image;
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!hasImage) return;
|
||||
setMousePos({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{children}
|
||||
{isHovering && hasImage && (
|
||||
<FloatingPreview card={card} x={mousePos.x} y={mousePos.y} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
|
||||
const isFoil = (card: DraftCard) => card.finish === 'foil';
|
||||
|
||||
@@ -116,7 +40,8 @@ const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode }) => {
|
||||
export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode, cardWidth = 150 }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const mythics = pack.cards.filter(c => c.rarity === 'mythic');
|
||||
const rares = pack.cards.filter(c => c.rarity === 'rare');
|
||||
const uncommons = pack.cards.filter(c => c.rarity === 'uncommon');
|
||||
@@ -127,24 +52,29 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode }) => {
|
||||
const copyPackToClipboard = () => {
|
||||
const text = pack.cards.map(c => c.name).join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
alert(`Pack list ${pack.id} copied!`);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-slate-800 rounded-xl border border-slate-700 shadow-lg flex flex-col ${viewMode === 'stack' ? 'bg-transparent border-none shadow-none' : ''}`}>
|
||||
<div className="bg-slate-800 rounded-xl border border-slate-700 shadow-lg flex flex-col">
|
||||
{/* Header */}
|
||||
<div className={`p-3 bg-slate-900 border-b border-slate-700 flex justify-between items-center rounded-t-xl ${viewMode === 'stack' ? 'bg-slate-800 border border-slate-700 mb-4 rounded-xl' : ''}`}>
|
||||
<div className="p-3 bg-slate-900 border-b border-slate-700 flex justify-between items-center rounded-t-xl">
|
||||
<div className="flex flex-col">
|
||||
<h3 className="font-bold text-purple-400 text-sm md:text-base">Pack #{pack.id}</h3>
|
||||
<span className="text-xs text-slate-500 font-mono">{pack.setName}</span>
|
||||
</div>
|
||||
<button onClick={copyPackToClipboard} className="text-slate-400 hover:text-white p-1 rounded hover:bg-slate-700 transition-colors flex items-center gap-2 text-xs">
|
||||
<Copy className="w-4 h-4" />
|
||||
<button
|
||||
onClick={copyPackToClipboard}
|
||||
className={`p-1.5 rounded transition-all duration-300 flex items-center gap-2 text-xs border ${copied ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/50' : 'text-slate-400 border-transparent hover:text-white hover:bg-slate-700'}`}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 scale-110 animate-in zoom-in spin-in-12 duration-300" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`${viewMode !== 'stack' ? 'p-4' : ''}`}>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
{viewMode === 'list' && (
|
||||
<div className="text-sm space-y-4">
|
||||
{(mythics.length > 0 || rares.length > 0) && (
|
||||
@@ -172,36 +102,41 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode }) => {
|
||||
)}
|
||||
|
||||
{viewMode === 'grid' && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{pack.cards.map((card) => (
|
||||
<CardHoverWrapper key={card.id} card={card}>
|
||||
<div className="relative group bg-slate-900 rounded-lg">
|
||||
{/* Visual Card */}
|
||||
<div className={`relative 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 cursor-pointer ${isFoil(card) ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}>
|
||||
{isFoil(card) && <div className="absolute inset-0 z-20 bg-gradient-to-tr from-purple-500/10 via-transparent to-pink-500/10 mix-blend-color-dodge pointer-events-none" />}
|
||||
{isFoil(card) && <div className="absolute top-1 right-1 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1 rounded backdrop-blur-sm">FOIL</div>}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{pack.cards.map((card) => {
|
||||
const useArtCrop = cardWidth < 130 && !!card.imageArtCrop;
|
||||
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
||||
|
||||
{card.image ? (
|
||||
<img src={card.image} alt={card.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-xs text-center p-1 text-slate-500 font-bold border-2 border-slate-700 m-1 rounded">
|
||||
{card.name}
|
||||
</div>
|
||||
)}
|
||||
{/* Rarity Stripe */}
|
||||
<div className={`absolute bottom-0 left-0 right-0 h-1.5 ${card.rarity === 'mythic' ? 'bg-gradient-to-r from-orange-500 to-red-600' :
|
||||
card.rarity === 'rare' ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' :
|
||||
card.rarity === 'uncommon' ? 'bg-gradient-to-r from-gray-300 to-gray-500' :
|
||||
'bg-black'
|
||||
}`} />
|
||||
return (
|
||||
<CardHoverWrapper key={card.id} card={card} preventPreview={cardWidth >= 130}>
|
||||
<div style={{ width: cardWidth }} className="relative group bg-slate-900 rounded-lg shrink-0">
|
||||
{/* Visual Card */}
|
||||
<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 cursor-pointer ${isFoil(card) ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}>
|
||||
{isFoil(card) && <FoilOverlay />}
|
||||
{isFoil(card) && <div className="absolute top-1 right-1 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1 rounded backdrop-blur-sm">FOIL</div>}
|
||||
|
||||
{displayImage ? (
|
||||
<img src={displayImage} alt={card.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-xs text-center p-1 text-slate-500 font-bold border-2 border-slate-700 m-1 rounded">
|
||||
{card.name}
|
||||
</div>
|
||||
)}
|
||||
{/* Rarity Stripe */}
|
||||
<div className={`absolute bottom-0 left-0 right-0 h-1.5 ${card.rarity === 'mythic' ? 'bg-gradient-to-r from-orange-500 to-red-600' :
|
||||
card.rarity === 'rare' ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' :
|
||||
card.rarity === 'uncommon' ? 'bg-gradient-to-r from-gray-300 to-gray-500' :
|
||||
'bg-black'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHoverWrapper>
|
||||
))}
|
||||
</CardHoverWrapper>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'stack' && <StackView cards={pack.cards} />}
|
||||
{viewMode === 'stack' && <StackView cards={pack.cards} cardWidth={cardWidth} groupBy="type" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,53 +1,188 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { DraftCard } from '../services/PackGeneratorService';
|
||||
import { FoilOverlay, CardHoverWrapper } from './CardPreview';
|
||||
import { useCardTouch } from '../utils/interaction';
|
||||
|
||||
|
||||
type GroupMode = 'type' | 'color' | 'cmc' | 'rarity';
|
||||
|
||||
interface StackViewProps {
|
||||
cards: DraftCard[];
|
||||
cardWidth?: number;
|
||||
onCardClick?: (card: DraftCard) => void;
|
||||
onHover?: (card: DraftCard | null) => void;
|
||||
disableHoverPreview?: boolean;
|
||||
groupBy?: GroupMode;
|
||||
renderWrapper?: (card: DraftCard, children: React.ReactNode) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const StackView: React.FC<StackViewProps> = ({ cards }) => {
|
||||
const getRarityColorClass = (rarity: string) => {
|
||||
switch (rarity) {
|
||||
case 'common': return 'bg-black text-white border-slate-600';
|
||||
case 'uncommon': return 'bg-slate-300 text-slate-900 border-white';
|
||||
case 'rare': return 'bg-yellow-500 text-yellow-950 border-yellow-200';
|
||||
case 'mythic': return 'bg-orange-600 text-white border-orange-300';
|
||||
default: return 'bg-slate-500';
|
||||
const GROUPS: Record<GroupMode, string[]> = {
|
||||
type: ['Creature', 'Planeswalker', 'Instant', 'Sorcery', 'Enchantment', 'Artifact', 'Battle', 'Land', 'Other'],
|
||||
color: ['White', 'Blue', 'Black', 'Red', 'Green', 'Multicolor', 'Colorless'],
|
||||
cmc: ['0', '1', '2', '3', '4', '5', '6', '7+'],
|
||||
rarity: ['Mythic', 'Rare', 'Uncommon', 'Common']
|
||||
};
|
||||
|
||||
const getCardGroup = (card: DraftCard, mode: GroupMode): string => {
|
||||
if (mode === 'type') {
|
||||
const typeLine = card.typeLine || '';
|
||||
if (typeLine.includes('Creature')) return 'Creature';
|
||||
if (typeLine.includes('Planeswalker')) return 'Planeswalker';
|
||||
if (typeLine.includes('Instant')) return 'Instant';
|
||||
if (typeLine.includes('Sorcery')) return 'Sorcery';
|
||||
if (typeLine.includes('Enchantment')) return 'Enchantment';
|
||||
if (typeLine.includes('Artifact')) return 'Artifact';
|
||||
if (typeLine.includes('Battle')) return 'Battle';
|
||||
if (typeLine.includes('Land')) return 'Land';
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
if (mode === 'color') {
|
||||
const colors = card.colors || [];
|
||||
if (colors.length > 1) return 'Multicolor';
|
||||
if (colors.length === 0) {
|
||||
// Check if land
|
||||
if ((card.typeLine || '').includes('Land')) return 'Colorless';
|
||||
// Artifacts etc
|
||||
return 'Colorless';
|
||||
}
|
||||
};
|
||||
if (colors[0] === 'W') return 'White';
|
||||
if (colors[0] === 'U') return 'Blue';
|
||||
if (colors[0] === 'B') return 'Black';
|
||||
if (colors[0] === 'R') return 'Red';
|
||||
if (colors[0] === 'G') return 'Green';
|
||||
return 'Colorless';
|
||||
}
|
||||
|
||||
if (mode === 'cmc') {
|
||||
const cmc = Math.floor(card.cmc || 0);
|
||||
if (cmc >= 7) return '7+';
|
||||
return cmc.toString();
|
||||
}
|
||||
|
||||
if (mode === 'rarity') {
|
||||
const r = (card.rarity || 'common').toLowerCase();
|
||||
if (r === 'mythic') return 'Mythic';
|
||||
if (r === 'rare') return 'Rare';
|
||||
if (r === 'uncommon') return 'Uncommon';
|
||||
return 'Common';
|
||||
}
|
||||
|
||||
return 'Other';
|
||||
};
|
||||
|
||||
|
||||
export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, onCardClick, onHover, disableHoverPreview = false, groupBy = 'color', renderWrapper }) => {
|
||||
|
||||
const categorizedCards = useMemo(() => {
|
||||
const categories: Record<string, DraftCard[]> = {};
|
||||
const groupKeys = GROUPS[groupBy];
|
||||
groupKeys.forEach(k => categories[k] = []);
|
||||
|
||||
cards.forEach(card => {
|
||||
const group = getCardGroup(card, groupBy);
|
||||
if (categories[group]) {
|
||||
categories[group].push(card);
|
||||
} else {
|
||||
// Fallback for unexpected (shouldn't happen with defined logic coverage)
|
||||
if (!categories['Other']) categories['Other'] = [];
|
||||
categories['Other'].push(card);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort cards within categories by CMC (low to high)
|
||||
// Secondary sort by Name
|
||||
Object.keys(categories).forEach(key => {
|
||||
categories[key].sort((a, b) => {
|
||||
const cmcA = a.cmc || 0;
|
||||
const cmcB = b.cmc || 0;
|
||||
if (cmcA !== cmcB) return cmcA - cmcB;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
});
|
||||
|
||||
return categories;
|
||||
}, [cards, groupBy]);
|
||||
|
||||
const activeGroups = GROUPS[groupBy];
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-sm mx-auto group perspective-1000 py-20">
|
||||
<div className="relative flex flex-col items-center transition-all duration-500 ease-in-out group-hover:space-y-4 space-y-[-16rem] py-10">
|
||||
{cards.map((card, index) => {
|
||||
const colorClass = getRarityColorClass(card.rarity);
|
||||
// Random slight rotation for "organic" look
|
||||
const rotation = (index % 2 === 0 ? 1 : -1) * (Math.random() * 2);
|
||||
<div className="inline-flex flex-row gap-4 pb-8 items-start min-w-full">
|
||||
{activeGroups.map(category => {
|
||||
const catCards = categorizedCards[category];
|
||||
if (catCards.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className="relative w-64 aspect-[2.5/3.5] rounded-xl shadow-2xl transition-transform duration-300 hover:scale-110 hover:z-50 hover:rotate-0 origin-center bg-slate-800 border-2 border-slate-900"
|
||||
style={{
|
||||
zIndex: index,
|
||||
transform: `rotate(${rotation}deg)`
|
||||
}}
|
||||
>
|
||||
{card.image ? (
|
||||
<img src={card.image} alt={card.name} className="w-full h-full object-cover rounded-lg" />
|
||||
) : (
|
||||
<div className="w-full h-full p-4 text-center flex items-center justify-center font-bold text-slate-500">
|
||||
{card.name}
|
||||
</div>
|
||||
)}
|
||||
<div className={`absolute top-2 right-2 w-3 h-3 rounded-full shadow-md z-10 border ${colorClass}`} />
|
||||
return (
|
||||
<div key={category} className="flex-shrink-0 snap-start flex flex-col" style={{ width: cardWidth }}>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-2 px-1 border-b border-slate-700 pb-1 shrink-0 bg-slate-900/80 backdrop-blur z-10 sticky top-0">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">{category}</span>
|
||||
<span className="text-xs font-mono text-slate-500">{catCards.length}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-center text-slate-500 text-xs mt-4 opacity-50 group-hover:opacity-0 transition-opacity">
|
||||
Hover to expand stack
|
||||
</div>
|
||||
|
||||
{/* Stack */}
|
||||
<div className="flex flex-col relative px-2 pb-32">
|
||||
{catCards.map((card, index) => {
|
||||
// Margin calculation: Negative margin to pull up next cards.
|
||||
// To show a "strip" of say 35px at the top of each card.
|
||||
const isLast = index === catCards.length - 1;
|
||||
const useArtCrop = cardWidth < 130 && !!card.imageArtCrop;
|
||||
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
||||
|
||||
return (
|
||||
<StackCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
cardWidth={cardWidth}
|
||||
isLast={isLast}
|
||||
useArtCrop={useArtCrop}
|
||||
displayImage={displayImage}
|
||||
onHover={onHover}
|
||||
onCardClick={onCardClick}
|
||||
disableHoverPreview={disableHoverPreview}
|
||||
renderWrapper={renderWrapper}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StackCardItem = ({ card, cardWidth, isLast, useArtCrop, displayImage, onHover, onCardClick, disableHoverPreview, renderWrapper }: any) => {
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(onHover || (() => { }), () => onCardClick && onCardClick(card), card);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className="relative w-full z-0 hover:z-50 transition-all duration-200 group"
|
||||
onMouseEnter={() => onHover && onHover(card)}
|
||||
onMouseLeave={() => onHover && onHover(null)}
|
||||
onClick={onClick}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchMove={onTouchMove}
|
||||
>
|
||||
<CardHoverWrapper card={card} preventPreview={disableHoverPreview || cardWidth >= 130}>
|
||||
<div
|
||||
className={`relative w-full rounded-lg bg-slate-800 shadow-md border border-slate-950 overflow-hidden cursor-pointer group-hover:ring-2 group-hover:ring-purple-400`}
|
||||
style={{
|
||||
marginBottom: isLast ? '0' : (useArtCrop ? '-85%' : '-125%'),
|
||||
aspectRatio: useArtCrop ? '1/1' : '2.5/3.5'
|
||||
}}
|
||||
>
|
||||
<img src={displayImage} alt={card.name} className="w-full h-full object-cover" />
|
||||
{card.finish === 'foil' && <FoilOverlay />}
|
||||
</div>
|
||||
</CardHoverWrapper>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (renderWrapper) {
|
||||
return renderWrapper(card, content);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
88
src/client/src/components/Toast.tsx
Normal file
88
src/client/src/components/Toast.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { X, Check, AlertCircle, Info } from 'lucide-react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType = 'info') => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
|
||||
// Auto remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<div className="fixed top-6 left-1/2 -translate-x-1/2 z-[9999] flex flex-col gap-3 pointer-events-none w-full max-w-sm px-4">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`
|
||||
pointer-events-auto
|
||||
flex items-center gap-4 px-4 py-3 rounded-xl border shadow-2xl
|
||||
animate-in slide-in-from-top-full fade-in zoom-in-95 duration-300
|
||||
bg-slate-800 text-white
|
||||
${toast.type === 'success' ? 'border-emerald-500/50 shadow-emerald-900/20' :
|
||||
toast.type === 'error' ? 'border-red-500/50 shadow-red-900/20' :
|
||||
toast.type === 'warning' ? 'border-amber-500/50 shadow-amber-900/20' :
|
||||
'border-blue-500/50 shadow-blue-900/20'}
|
||||
`}
|
||||
>
|
||||
<div className={`p-2 rounded-full shrink-0 ${toast.type === 'success' ? 'bg-emerald-500/10 text-emerald-400' :
|
||||
toast.type === 'error' ? 'bg-red-500/10 text-red-400' :
|
||||
toast.type === 'warning' ? 'bg-amber-500/10 text-amber-400' :
|
||||
'bg-blue-500/10 text-blue-400'
|
||||
}`}>
|
||||
{toast.type === 'success' && <Check className="w-5 h-5" />}
|
||||
{toast.type === 'error' && <AlertCircle className="w-5 h-5" />}
|
||||
{toast.type === 'warning' && <AlertCircle className="w-5 h-5" />}
|
||||
{toast.type === 'info' && <Info className="w-5 h-5" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-sm font-medium">
|
||||
{toast.message}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="p-1 hover:bg-slate-700 rounded transition-colors text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -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,22 +1,26 @@
|
||||
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 } from 'lucide-react';
|
||||
import { CardParserService } from '../../services/CardParserService';
|
||||
import { ScryfallService, ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
|
||||
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';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
import { useToast } from '../../components/Toast';
|
||||
import { useConfirm } from '../../components/ConfirmDialog';
|
||||
|
||||
interface CubeManagerProps {
|
||||
packs: Pack[];
|
||||
setPacks: React.Dispatch<React.SetStateAction<Pack[]>>;
|
||||
availableLands: any[];
|
||||
setAvailableLands: React.Dispatch<React.SetStateAction<any[]>>;
|
||||
onGoToLobby: () => void;
|
||||
}
|
||||
|
||||
export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoToLobby }) => {
|
||||
// --- Services ---
|
||||
export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, availableLands, setAvailableLands, onGoToLobby }) => {
|
||||
const { showToast } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
// --- Services ---
|
||||
// Memoize services to persist cache across renders
|
||||
const parserService = React.useMemo(() => new CardParserService(), []);
|
||||
const scryfallService = React.useMemo(() => new ScryfallService(), []);
|
||||
const generatorService = React.useMemo(() => new PackGeneratorService(), []);
|
||||
|
||||
// --- State ---
|
||||
@@ -24,6 +28,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState('');
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
const [rawScryfallData, setRawScryfallData] = useState<ScryfallCard[] | null>(() => {
|
||||
try {
|
||||
@@ -56,21 +61,24 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
try {
|
||||
const saved = localStorage.getItem('cube_filters');
|
||||
return saved ? JSON.parse(saved) : {
|
||||
ignoreBasicLands: true,
|
||||
ignoreCommander: true,
|
||||
ignoreTokens: true
|
||||
ignoreBasicLands: false,
|
||||
ignoreCommander: false,
|
||||
ignoreTokens: false
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ignoreBasicLands: true,
|
||||
ignoreCommander: true,
|
||||
ignoreTokens: true
|
||||
ignoreBasicLands: false,
|
||||
ignoreCommander: false,
|
||||
ignoreTokens: false
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// UI State
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('list');
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>(() => {
|
||||
return (localStorage.getItem('cube_viewMode') as 'list' | 'grid' | 'stack') || 'list';
|
||||
});
|
||||
|
||||
|
||||
// Generation Settings
|
||||
const [genSettings, setGenSettings] = useState<PackGenerationSettings>(() => {
|
||||
@@ -98,12 +106,27 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
});
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [gameTypeFilter, setGameTypeFilter] = useState<'all' | 'paper' | 'digital'>('all'); // Filter state
|
||||
const [gameTypeFilter, setGameTypeFilter] = useState<'all' | 'paper' | 'digital'>(() => {
|
||||
return (localStorage.getItem('cube_gameTypeFilter') as 'all' | 'paper' | 'digital') || 'all';
|
||||
});
|
||||
const [numBoxes, setNumBoxes] = useState<number>(() => {
|
||||
const saved = localStorage.getItem('cube_numBoxes');
|
||||
return saved ? parseInt(saved) : 3;
|
||||
return saved ? parseInt(saved) : 1;
|
||||
});
|
||||
|
||||
const [cardWidth, setCardWidth] = useState(() => {
|
||||
const saved = localStorage.getItem('cube_cardWidth');
|
||||
return saved ? parseInt(saved) : 60;
|
||||
});
|
||||
// Local state for smooth slider
|
||||
const [localCardWidth, setLocalCardWidth] = useState(cardWidth);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalCardWidth(cardWidth);
|
||||
if (containerRef.current) containerRef.current.style.setProperty('--card-width', `${cardWidth}px`);
|
||||
}, [cardWidth]);
|
||||
|
||||
// --- Persistence Effects ---
|
||||
useEffect(() => localStorage.setItem('cube_inputText', inputText), [inputText]);
|
||||
useEffect(() => localStorage.setItem('cube_filters', JSON.stringify(filters)), [filters]);
|
||||
@@ -111,6 +134,9 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
useEffect(() => localStorage.setItem('cube_sourceMode', sourceMode), [sourceMode]);
|
||||
useEffect(() => localStorage.setItem('cube_selectedSets', JSON.stringify(selectedSets)), [selectedSets]);
|
||||
useEffect(() => localStorage.setItem('cube_numBoxes', numBoxes.toString()), [numBoxes]);
|
||||
useEffect(() => localStorage.setItem('cube_cardWidth', cardWidth.toString()), [cardWidth]);
|
||||
useEffect(() => localStorage.setItem('cube_viewMode', viewMode), [viewMode]);
|
||||
useEffect(() => localStorage.setItem('cube_gameTypeFilter', gameTypeFilter), [gameTypeFilter]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -118,18 +144,52 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
useEffect(() => {
|
||||
if (rawScryfallData) {
|
||||
// Use local images: true
|
||||
const result = generatorService.processCards(rawScryfallData, filters, true);
|
||||
const setsMetadata = availableSets.reduce((acc, set) => {
|
||||
acc[set.code] = { parent_set_code: set.parent_set_code };
|
||||
return acc;
|
||||
}, {} as { [code: string]: { parent_set_code?: string } });
|
||||
|
||||
const result = generatorService.processCards(rawScryfallData, filters, true, setsMetadata);
|
||||
setProcessedData(result);
|
||||
}
|
||||
}, [filters, rawScryfallData]);
|
||||
|
||||
useEffect(() => {
|
||||
scryfallService.fetchSets().then(sets => {
|
||||
setAvailableSets(sets.sort((a, b) => new Date(b.released_at).getTime() - new Date(a.released_at).getTime()));
|
||||
});
|
||||
}, [scryfallService]);
|
||||
fetch('/api/sets')
|
||||
.then(res => res.json())
|
||||
.then((sets: ScryfallSet[]) => {
|
||||
setAvailableSets(sets.sort((a, b) => new Date(b.released_at).getTime() - new Date(a.released_at).getTime()));
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
// --- Handlers ---
|
||||
const handlePlayOnline = () => {
|
||||
const totalPacks = packs.length;
|
||||
|
||||
// Rules:
|
||||
// < 12: No draft
|
||||
// 12 <= p < 18: 4 players
|
||||
// 18 <= p < 24: 4 or 6 players
|
||||
// >= 24: 4, 6 or 8 players
|
||||
|
||||
if (totalPacks < 12) {
|
||||
showToast('Need at least 12 packs for a 4-player draft (3 packs/player).', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (totalPacks >= 12 && totalPacks < 18) {
|
||||
showToast('Enough packs for 4 players only.', 'info');
|
||||
} else if (totalPacks >= 18 && totalPacks < 24) {
|
||||
showToast('Enough packs for 4 or 6 players.', 'info');
|
||||
} else {
|
||||
showToast('Enough packs for 8 players!', 'success');
|
||||
}
|
||||
|
||||
// Proceed to lobby
|
||||
onGoToLobby();
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -141,122 +201,232 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
|
||||
|
||||
|
||||
const fetchAndParse = async () => {
|
||||
const handleGenerate = async () => {
|
||||
// Validate inputs
|
||||
if (sourceMode === 'set' && selectedSets.length === 0) return;
|
||||
if (sourceMode === 'upload' && !inputText) return;
|
||||
|
||||
if (sourceMode === 'set' && numBoxes > 10) {
|
||||
showToast("Maximum limit is 10 Boxes (360 Packs) to avoid instability.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setPacks([]);
|
||||
setProgress(sourceMode === 'set' ? 'Fetching set data...' : 'Parsing text...');
|
||||
setPacks([]); // Clear old packs to avoid confusion
|
||||
|
||||
try {
|
||||
let expandedCards: ScryfallCard[] = [];
|
||||
// --- Step 1: Fetch/Parse ---
|
||||
let currentCards: ScryfallCard[] = [];
|
||||
|
||||
setProgress(sourceMode === 'set' ? 'Fetching set data...' : 'Parsing text...');
|
||||
|
||||
if (sourceMode === 'set') {
|
||||
if (selectedSets.length === 0) throw new Error("Please select at least one set.");
|
||||
// Fetch set by set
|
||||
// Fetch sets (Grouping Main + Subsets)
|
||||
// We iterate through selectedSets. If a set has children also in selectedSets (or auto-detected), we fetch them together.
|
||||
// We need to avoid fetching the child set again if it was covered by the parent.
|
||||
|
||||
for (const [index, setCode] of selectedSets.entries()) {
|
||||
// Update progress for set
|
||||
const setInfo = availableSets.find(s => s.code === setCode);
|
||||
const setName = setInfo ? setInfo.name : setCode;
|
||||
const processedSets = new Set<string>();
|
||||
|
||||
setProgress(`Fetching ${setName}... (${index + 1}/${selectedSets.length})`);
|
||||
// We already have `effectiveSelectedSets` which includes auto-added ones.
|
||||
// Let's re-derive effective logic locally for fetching.
|
||||
const allSetsToProcess = [...selectedSets];
|
||||
const linkedSubsets = availableSets.filter(s =>
|
||||
s.parent_set_code &&
|
||||
selectedSets.includes(s.parent_set_code) &&
|
||||
s.code.length === 3 && // 3-letter code filter
|
||||
!selectedSets.includes(s.code)
|
||||
).map(s => s.code);
|
||||
allSetsToProcess.push(...linkedSubsets);
|
||||
|
||||
const cards = await scryfallService.fetchSetCards(setCode, (_count) => {
|
||||
// Progress handled by outer loop mostly, but we could update strictly if needed.
|
||||
let totalCards = 0;
|
||||
let setIndex = 0;
|
||||
|
||||
for (const setCode of allSetsToProcess) {
|
||||
if (processedSets.has(setCode)) continue;
|
||||
|
||||
// Check if this is a Main Set that has children in our list
|
||||
// OR if it's a child that should be fetched with its parent?
|
||||
// Actually, we should look for Main Sets first.
|
||||
|
||||
let currentMain = setCode;
|
||||
let currentRelated: string[] = [];
|
||||
|
||||
// Find children of this set in our list
|
||||
const children = allSetsToProcess.filter(s => {
|
||||
const meta = availableSets.find(as => as.code === s);
|
||||
return meta && meta.parent_set_code === currentMain;
|
||||
});
|
||||
expandedCards.push(...cards);
|
||||
}
|
||||
} else {
|
||||
const identifiers = parserService.parse(inputText);
|
||||
const fetchList = identifiers.map(id => id.type === 'id' ? { id: id.value } : { name: id.value });
|
||||
|
||||
// Identify how many are already cached for feedback
|
||||
let cachedCount = 0;
|
||||
fetchList.forEach(req => {
|
||||
if (scryfallService.getCachedCard(req)) cachedCount++;
|
||||
});
|
||||
|
||||
await scryfallService.fetchCollection(fetchList, (current, total) => {
|
||||
setProgress(`Fetching Scryfall data... (${current}/${total})`);
|
||||
});
|
||||
|
||||
// Re-check cache to get all objects
|
||||
identifiers.forEach(id => {
|
||||
const card = scryfallService.getCachedCard(id.type === 'id' ? { id: id.value } : { name: id.value });
|
||||
if (card) {
|
||||
for (let i = 0; i < id.quantity; i++) {
|
||||
// Clone card to attach unique properties like finish
|
||||
const expandedCard = { ...card };
|
||||
if (id.finish) {
|
||||
expandedCard.finish = id.finish;
|
||||
}
|
||||
expandedCards.push(expandedCard);
|
||||
}
|
||||
// Also check if this set IS a child, and its parent is NOT in the list?
|
||||
// If parent IS in the list, we skip this iteration and let the parent handle it?
|
||||
const meta = availableSets.find(as => as.code === currentMain);
|
||||
if (meta && meta.parent_set_code && allSetsToProcess.includes(meta.parent_set_code)) {
|
||||
// This is a child, and we are processing the parent elsewhere. Skip.
|
||||
// But wait, the loop order is undefined.
|
||||
// Safest: always fetch by Main Set if possible.
|
||||
// If we encounter a Child whose parent is in the list, we skip.
|
||||
continue;
|
||||
}
|
||||
});
|
||||
|
||||
const totalRequested = identifiers.reduce((acc, curr) => acc + curr.quantity, 0);
|
||||
const missing = totalRequested - expandedCards.length;
|
||||
if (children.length > 0) {
|
||||
currentRelated = children;
|
||||
currentRelated.forEach(c => processedSets.add(c));
|
||||
}
|
||||
|
||||
if (missing > 0) {
|
||||
alert(`Warning: ${missing} cards could not be identified or fetched.`);
|
||||
} else {
|
||||
// Optional: Feedback on cache
|
||||
// console.log(`Parsed ${expandedCards.length} cards. (${cachedCount} / ${fetchList.length} unique identifiers were pre-cached)`);
|
||||
processedSets.add(currentMain);
|
||||
setIndex++;
|
||||
|
||||
setProgress(`Fetching set ${currentMain.toUpperCase()} ${currentRelated.length > 0 ? `(+ ${currentRelated.join(', ').toUpperCase()})` : ''}...`);
|
||||
|
||||
const queryParams = currentRelated.length > 0 ? `?related=${currentRelated.join(',')}` : '';
|
||||
const response = await fetch(`/api/sets/${currentMain}/cards${queryParams}`);
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch set ${currentMain}`);
|
||||
const cards: ScryfallCard[] = await response.json();
|
||||
setRawScryfallData(prev => [...(prev || []), ...cards]);
|
||||
totalCards += cards.length;
|
||||
}
|
||||
}
|
||||
|
||||
setRawScryfallData(expandedCards);
|
||||
|
||||
// Cache to server
|
||||
if (expandedCards.length > 0) {
|
||||
setProgress('Loading...');
|
||||
// Deduplicate for shipping to server
|
||||
const uniqueCards = Array.from(new Map(expandedCards.map(c => [c.id, c])).values());
|
||||
|
||||
await fetch('/api/cards/cache', {
|
||||
} else {
|
||||
// Parse Text
|
||||
setProgress('Parsing and fetching from server...');
|
||||
const response = await fetch('/api/cards/parse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cards: uniqueCards }) // Send full metadata
|
||||
body: JSON.stringify({ text: inputText })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error || "Failed to parse cards");
|
||||
}
|
||||
|
||||
currentCards = await response.json();
|
||||
|
||||
}
|
||||
|
||||
// Update local state for UI preview/stats
|
||||
setRawScryfallData(currentCards);
|
||||
|
||||
// --- Step 2: Generate ---
|
||||
setProgress('Generating packs on server...');
|
||||
|
||||
// Re-calculation of effective sets for Payload is safe to match.
|
||||
const payloadSetCodes = [...selectedSets];
|
||||
const linkedPayload = availableSets.filter(s =>
|
||||
s.parent_set_code &&
|
||||
selectedSets.includes(s.parent_set_code) &&
|
||||
s.code.length === 3 && // 3-letter code filter
|
||||
!selectedSets.includes(s.code)
|
||||
).map(s => s.code);
|
||||
payloadSetCodes.push(...linkedPayload);
|
||||
|
||||
const payload = {
|
||||
cards: sourceMode === 'upload' ? currentCards : [], // For set mode, we let server refetch or handle it
|
||||
sourceMode,
|
||||
selectedSets: payloadSetCodes,
|
||||
settings: {
|
||||
...genSettings,
|
||||
withReplacement: sourceMode === 'set'
|
||||
},
|
||||
numBoxes,
|
||||
numPacks: sourceMode === 'set' ? (numBoxes * 36) : undefined,
|
||||
filters
|
||||
};
|
||||
|
||||
if (sourceMode === 'set') {
|
||||
payload.cards = [];
|
||||
}
|
||||
|
||||
const response = await fetch('/api/packs/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error || "Generation failed");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
let newPacks: Pack[] = [];
|
||||
let newLands: any[] = [];
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
newPacks = data;
|
||||
} else {
|
||||
newPacks = data.packs;
|
||||
newLands = data.basicLands || [];
|
||||
}
|
||||
|
||||
if (newPacks.length === 0) {
|
||||
showToast(`No packs generated. Check your card pool settings.`, 'warning');
|
||||
} else {
|
||||
setPacks(newPacks);
|
||||
setAvailableLands(newLands);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Process failed", err);
|
||||
showToast(err.message || "Error during process.", 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setProgress('');
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert(err.message || "Error during process.");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generatePacks = () => {
|
||||
if (!processedData) return;
|
||||
const handleStartSoloTest = async () => {
|
||||
if (packs.length === 0) return;
|
||||
|
||||
// Validate Lands - Warn but allow proceed (server will handle it or deck builder will be landless)
|
||||
if (availableLands.length === 0) {
|
||||
if (!await confirm({
|
||||
title: "No Basic Lands",
|
||||
message: "No basic lands detected in the current pool. Decks might be invalid. Continue?",
|
||||
confirmLabel: "Continue",
|
||||
type: "warning"
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Use setTimeout to allow UI to show loading spinner before sync calculation blocks
|
||||
setTimeout(() => {
|
||||
try {
|
||||
let newPacks: Pack[] = [];
|
||||
if (sourceMode === 'set') {
|
||||
const totalPacks = numBoxes * 36;
|
||||
newPacks = generatorService.generateBoosterBox(processedData.pools, totalPacks, genSettings);
|
||||
} else {
|
||||
newPacks = generatorService.generatePacks(processedData.pools, processedData.sets, genSettings);
|
||||
}
|
||||
try {
|
||||
const playerId = localStorage.getItem('player_id') || 'tester-' + Date.now();
|
||||
const playerName = localStorage.getItem('player_name') || 'Tester';
|
||||
|
||||
if (newPacks.length === 0) {
|
||||
alert(`Not enough cards to generate valid packs.`);
|
||||
} else {
|
||||
setPacks(newPacks);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Generation failed", e);
|
||||
alert("Error generating packs: " + e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!socketService.socket.connected) socketService.connect();
|
||||
|
||||
// Emit new start_solo_test event
|
||||
// Now sends PACKS and LANDS instead of a constructed DECK
|
||||
const response = await socketService.emitPromise('start_solo_test', {
|
||||
playerId,
|
||||
playerName,
|
||||
packs,
|
||||
basicLands: availableLands
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
localStorage.setItem('active_room_id', response.room.id);
|
||||
localStorage.setItem('player_id', playerId);
|
||||
|
||||
// Brief delay to allow socket events to propagate
|
||||
setTimeout(() => {
|
||||
onGoToLobby();
|
||||
}, 100);
|
||||
} else {
|
||||
showToast("Failed to start solo draft: " + response.message, 'error');
|
||||
}
|
||||
}, 50);
|
||||
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
showToast("Error: " + e.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
@@ -286,7 +456,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
3,Banishing Light,Normal,Bloomburrow,25a06f82-ebdb-4dd6-bfe8-958018ce557c
|
||||
4,Barkform Harvester,Normal,Bloomburrow,f77049a6-0f22-415b-bc89-20bcb32accf6
|
||||
1,Bark-Knuckle Boxer,Normal,Bloomburrow,582637a9-6aa0-4824-bed7-d5fc91bda35e
|
||||
1,"Baylen, the Haymaker",Normal,Bloomburrow,00e93be2-e06b-4774-8ba5-ccf82a6da1d8
|
||||
,"Baylen, the Haymaker",Normal,Bloomburrow,00e93be2-e06b-4774-8ba5-ccf82a6da1d8
|
||||
3,Bellowing Crier,Normal,Bloomburrow,ca2215dd-6300-49cf-b9b2-3a840b786c31
|
||||
1,Blacksmith's Talent,Normal,Bloomburrow,4bb318fa-481d-40a7-978e-f01b49101ae0
|
||||
1,Blooming Blast,Normal,Bloomburrow,0cd92a83-cec3-4085-a929-3f204e3e0140
|
||||
@@ -313,7 +483,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy: ', err);
|
||||
alert('Failed to copy CSV to clipboard');
|
||||
showToast('Failed to copy CSV to clipboard', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -322,25 +492,62 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (window.confirm("Are you sure you want to clear this session? All parsed cards and generated packs will be lost.")) {
|
||||
if (!confirmClear) {
|
||||
setConfirmClear(true);
|
||||
// Auto-reset confirmation state after 3 seconds
|
||||
setTimeout(() => setConfirmClear(false), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirmed
|
||||
setConfirmClear(false);
|
||||
|
||||
try {
|
||||
console.log("Clearing session...");
|
||||
|
||||
// 1. Reset Parent State (App.tsx)
|
||||
setPacks([]);
|
||||
setAvailableLands([]);
|
||||
|
||||
// 2. Explicitly clear parent persistence keys to ensure they are gone immediately
|
||||
localStorage.removeItem('generatedPacks');
|
||||
localStorage.removeItem('availableLands');
|
||||
|
||||
// 3. Reset Local State to Defaults
|
||||
// This will trigger the useEffect hooks to update localStorage accordingly
|
||||
setInputText('');
|
||||
setRawScryfallData(null);
|
||||
setProcessedData(null);
|
||||
setProcessedData(null);
|
||||
setSelectedSets([]);
|
||||
localStorage.removeItem('cube_inputText');
|
||||
localStorage.removeItem('cube_rawScryfallData');
|
||||
localStorage.removeItem('cube_selectedSets');
|
||||
// We keep filters and settings as they are user preferences
|
||||
setSearchTerm(''); // Clear search
|
||||
|
||||
setFilters({
|
||||
ignoreBasicLands: false,
|
||||
ignoreCommander: false,
|
||||
ignoreTokens: false
|
||||
});
|
||||
|
||||
setGenSettings({
|
||||
mode: 'mixed',
|
||||
rarityMode: 'peasant'
|
||||
});
|
||||
|
||||
setSourceMode('upload');
|
||||
setNumBoxes(1);
|
||||
setGameTypeFilter('all');
|
||||
|
||||
showToast("Session cleared successfully.", "success");
|
||||
} catch (error) {
|
||||
console.error("Error clearing session:", error);
|
||||
showToast("Failed to clear session fully.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-8 p-4 md:p-6">
|
||||
<div ref={containerRef} className="h-full overflow-y-auto w-full flex flex-col lg:flex-row gap-8 p-4 md:p-6" style={{ '--card-width': `${localCardWidth}px` } as React.CSSProperties}>
|
||||
|
||||
{/* --- LEFT COLUMN: CONTROLS --- */}
|
||||
<div className="lg:col-span-4 flex flex-col gap-4">
|
||||
<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">
|
||||
@@ -410,13 +617,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={fetchAndParse}
|
||||
disabled={loading || !inputText}
|
||||
className={`w-full py-2 mb-4 rounded-lg font-bold flex justify-center items-center gap-2 transition-all ${loading ? 'bg-slate-700 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-500 text-white'}`}
|
||||
>
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> {progress}</> : <><Check className="w-4 h-4" /> 1. Parse Bulk</>}
|
||||
</button>
|
||||
{/* Parse Button Removed per request */}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -535,34 +736,12 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-semibold text-slate-300 mb-2">Quantity</label>
|
||||
<div className="flex items-center gap-2 bg-slate-900 p-2 rounded-lg border border-slate-700">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={numBoxes}
|
||||
onChange={(e) => setNumBoxes(parseInt(e.target.value))}
|
||||
className="w-16 bg-slate-800 border-none rounded p-1 text-center text-white font-mono"
|
||||
disabled={loading}
|
||||
/>
|
||||
<span className="text-slate-400 text-sm">Booster Boxes ({numBoxes * 36} Packs)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={fetchAndParse}
|
||||
disabled={loading || selectedSets.length === 0}
|
||||
className={`w-full py-2 mb-4 rounded-lg font-bold flex justify-center items-center gap-2 transition-all ${loading ? 'bg-slate-700 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-500 text-white'}`}
|
||||
>
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> {progress}</> : <><Check className="w-4 h-4" /> 1. Fetch {selectedSets.length > 1 ? 'Sets' : 'Set'}</>}
|
||||
</button>
|
||||
{/* Fetch Set and Quantity Blocks Removed/Moved */}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Generation Settings */}
|
||||
{processedData && Object.keys(processedData.sets).length > 0 && (
|
||||
{(sourceMode === 'set' ? selectedSets.length > 0 : !!inputText) && (
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-700 mb-4 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||
<h3 className="text-sm font-bold text-white mb-2 flex items-center gap-2">
|
||||
<Settings className="w-4 h-4 text-emerald-400" /> Configuration
|
||||
@@ -606,77 +785,177 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sets Info */}
|
||||
<div className="max-h-40 overflow-y-auto text-xs space-y-1 pr-2 custom-scrollbar border-t border-slate-800 pt-2">
|
||||
{Object.values(processedData.sets).sort((a, b) => b.commons.length - a.commons.length).map(set => (
|
||||
<div key={set.code} className="flex justify-between items-center text-slate-400 border-b border-slate-800 pb-1">
|
||||
<span className="truncate w-32" title={set.name}>{set.name}</span>
|
||||
<span className="font-mono text-[10px]">{set.commons.length}C / {set.uncommons.length}U / {set.rares.length}R</span>
|
||||
{/* Quantity - Moved Here */}
|
||||
{sourceMode === 'set' && (
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase mb-1 block">Quantity</label>
|
||||
<div className="flex items-center gap-3 bg-slate-800 p-2 rounded border border-slate-700">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setNumBoxes(prev => Math.max(1, prev - 1))}
|
||||
disabled={numBoxes <= 1 || loading}
|
||||
className="p-1.5 rounded bg-slate-700 hover:bg-slate-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-white"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-8 text-center font-mono font-bold text-white text-lg">{numBoxes}</span>
|
||||
<button
|
||||
onClick={() => setNumBoxes(prev => Math.min(10, prev + 1))}
|
||||
disabled={numBoxes >= 10 || loading}
|
||||
className="p-1.5 rounded bg-slate-700 hover:bg-slate-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-slate-400 text-xs font-medium border-l border-slate-700 pl-3">
|
||||
<span className="text-white font-bold">{numBoxes * 36}</span> Packs
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sets Info */}
|
||||
{processedData && Object.keys(processedData.sets).length > 0 && (
|
||||
<div className="max-h-40 overflow-y-auto text-xs space-y-1 pr-2 custom-scrollbar border-t border-slate-800 pt-2">
|
||||
{Object.values(processedData.sets).sort((a, b) => b.commons.length - a.commons.length).map(set => (
|
||||
<div key={set.code} className="flex justify-between items-center text-slate-400 border-b border-slate-800 pb-1">
|
||||
<span className="truncate w-32" title={set.name}>{set.name}</span>
|
||||
<span className="font-mono text-[10px]">{set.commons.length}C / {set.uncommons.length}U / {set.rares.length}R</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={generatePacks}
|
||||
disabled={!processedData || Object.keys(processedData.sets).length === 0 || loading}
|
||||
className={`w-full py-3 px-4 rounded-lg font-bold flex justify-center items-center gap-2 transition-all ${!processedData || Object.keys(processedData.sets).length === 0 || loading ? 'bg-slate-700 cursor-not-allowed text-slate-500' : 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-900/20'}`}
|
||||
onClick={handleGenerate}
|
||||
disabled={((sourceMode === 'set' && selectedSets.length === 0) || (sourceMode === 'upload' && !inputText)) || loading}
|
||||
className={`w-full py-3 px-4 rounded-lg font-bold flex justify-center items-center gap-2 transition-all ${((sourceMode === 'set' && selectedSets.length === 0) || (sourceMode === 'upload' && !inputText)) || loading ? 'bg-slate-700 cursor-not-allowed text-slate-500' : 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-900/20'}`}
|
||||
>
|
||||
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <RotateCcw className="w-5 h-5" />}
|
||||
{loading ? 'Generating...' : '2. Generate Packs'}
|
||||
{loading ? progress : 'Generate Packs'}
|
||||
</button>
|
||||
|
||||
{/* Reset Button */}
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="w-full mt-4 py-2 text-xs font-semibold text-slate-500 hover:text-red-400 hover:bg-red-900/10 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
disabled={loading}
|
||||
className={`w-full mt-4 py-2.5 px-4 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-2 ${loading
|
||||
? 'opacity-50 cursor-not-allowed text-slate-600 border border-transparent'
|
||||
: confirmClear
|
||||
? 'bg-red-600 text-white border border-red-500 shadow-md animate-pulse'
|
||||
: 'text-red-400 border border-red-900/30 hover:bg-red-950/30 hover:border-red-500/50 hover:text-red-300 shadow-sm'
|
||||
}`}
|
||||
title="Clear all data and start over"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" /> Clear Session
|
||||
{loading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Trash2 className="w-3 h-3" />}
|
||||
{confirmClear ? "Click again to confirm!" : "Clear Session"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- RIGHT COLUMN: PACKS --- */}
|
||||
<div className="lg:col-span-8">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center mb-6 gap-4 sticky top-4 z-40 bg-slate-900/90 backdrop-blur-sm p-3 rounded-xl border border-white/5 shadow-2xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 w-full min-w-0">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center mb-6 gap-4 sticky top-4 z-40 bg-slate-900/95 backdrop-blur-xl p-3 rounded-xl border border-white/10 shadow-2xl">
|
||||
<div className="flex items-center gap-4 w-full sm:w-auto justify-between sm:justify-start">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<span className="bg-slate-700 text-purple-400 px-3 py-1 rounded-lg text-sm border border-slate-600">{packs.length}</span>
|
||||
Packs
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* Play Button */}
|
||||
<div className="flex gap-2 w-full sm:w-auto justify-end">
|
||||
{/* Actions Menu */}
|
||||
{packs.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={onGoToLobby}
|
||||
className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
|
||||
>
|
||||
<Users className="w-4 h-4" /> <span className="hidden sm:inline">Play Online</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>
|
||||
</button>
|
||||
<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={`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-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="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 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 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"
|
||||
min="60"
|
||||
max="200"
|
||||
step="1"
|
||||
value={localCardWidth}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
setLocalCardWidth(val);
|
||||
if (containerRef.current) containerRef.current.style.setProperty('--card-width', `${val}px`);
|
||||
}}
|
||||
onMouseUp={() => setCardWidth(localCardWidth)}
|
||||
onTouchEnd={() => setCardWidth(localCardWidth)}
|
||||
className="w-24 accent-purple-500 cursor-pointer h-1.5 bg-slate-600 rounded-lg appearance-none"
|
||||
title={`Card Size: ${localCardWidth}px`}
|
||||
/>
|
||||
<div className="w-4 h-6 rounded border border-slate-500 bg-slate-700" title="Large Cards" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex bg-slate-800 rounded-lg p-1 border border-slate-700">
|
||||
<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>
|
||||
@@ -684,19 +963,31 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
</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">
|
||||
<Box className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p>No packs generated.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 pb-20">
|
||||
{packs.map((pack) => (
|
||||
<PackCard key={pack.id} pack={pack} viewMode={viewMode} />
|
||||
))}
|
||||
</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">
|
||||
<Box className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p>No packs generated.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-6 pb-20"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${localCardWidth > 165
|
||||
? (viewMode === 'list' ? '500px' : '750px')
|
||||
: localCardWidth <= 95
|
||||
? (viewMode === 'list' ? '300px' : '450px')
|
||||
: (viewMode === 'list' ? '400px' : '600px')
|
||||
}), 1fr))`
|
||||
}}
|
||||
>
|
||||
{packs.map((pack) => (
|
||||
<PackCard key={pack.id} pack={pack} viewMode={viewMode} cardWidth={localCardWidth} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
|
||||
</div >
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,44 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { LogOut, Columns, LayoutTemplate, ChevronLeft, Eye } from 'lucide-react';
|
||||
import { Modal } from '../../components/Modal';
|
||||
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';
|
||||
import { AutoPicker } from '../../utils/AutoPicker';
|
||||
import { Wand2 } from 'lucide-react';
|
||||
|
||||
// Helper to normalize card data for visuals
|
||||
// Helper to normalize card data for visuals
|
||||
const normalizeCard = (c: any) => {
|
||||
const targetId = c.scryfallId || c.id;
|
||||
const setCode = c.setCode || c.set;
|
||||
|
||||
const localImage = (targetId && setCode)
|
||||
? `/cards/images/${setCode}/full/${targetId}.jpg`
|
||||
: null;
|
||||
|
||||
return {
|
||||
...c,
|
||||
finish: c.finish || 'nonfoil',
|
||||
image: localImage || c.image || c.image_uris?.normal || c.card_faces?.[0]?.image_uris?.normal
|
||||
};
|
||||
};
|
||||
|
||||
// Droppable Wrapper for Pool
|
||||
const PoolDroppable = ({ children, className, style }: any) => {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: 'pool-zone',
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={`${className} ${isOver ? 'ring-4 ring-emerald-500/50 bg-emerald-900/20' : ''}`} style={{ ...style, touchAction: 'none' }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DraftViewProps {
|
||||
draftState: any;
|
||||
@@ -34,240 +70,665 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
return () => clearInterval(interval);
|
||||
}, [pickExpiresAt]);
|
||||
|
||||
|
||||
|
||||
// --- UI State & Persistence ---
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const saved = localStorage.getItem('draft_sidebarWidth');
|
||||
return saved ? parseInt(saved, 10) : 320;
|
||||
});
|
||||
const [poolHeight, setPoolHeight] = useState<number>(() => {
|
||||
const saved = localStorage.getItem('draft_poolHeight');
|
||||
return saved ? parseInt(saved, 10) : 220;
|
||||
});
|
||||
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||
const poolRef = React.useRef<HTMLDivElement>(null);
|
||||
const resizingState = React.useRef<{
|
||||
startX: number,
|
||||
startY: number,
|
||||
startWidth: number,
|
||||
startHeight: number,
|
||||
active: 'sidebar' | 'pool' | null
|
||||
}>({ startX: 0, startY: 0, startWidth: 0, startHeight: 0, active: null });
|
||||
|
||||
// Apply initial sizes visually without causing re-renders
|
||||
useEffect(() => {
|
||||
if (sidebarRef.current) sidebarRef.current.style.width = `${sidebarWidth}px`;
|
||||
if (poolRef.current) poolRef.current.style.height = `${poolHeight}px`;
|
||||
}, []); // Only on mount to set initial visual state, subsequent updates handled by resize logic
|
||||
|
||||
|
||||
const [cardScale, setCardScale] = useState<number>(() => {
|
||||
const saved = localStorage.getItem('draft_cardScale');
|
||||
return saved ? parseFloat(saved) : 0.7;
|
||||
return saved ? parseFloat(saved) : 0.35;
|
||||
});
|
||||
// Local state for smooth slider
|
||||
const [localCardScale, setLocalCardScale] = useState(cardScale);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sync local state if external update happens
|
||||
useEffect(() => {
|
||||
setLocalCardScale(cardScale);
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.setProperty('--card-scale', cardScale.toString());
|
||||
}
|
||||
}, [cardScale]);
|
||||
|
||||
const [layout, setLayout] = useState<'vertical' | 'horizontal'>(() => {
|
||||
const saved = localStorage.getItem('draft_layout');
|
||||
return (saved as 'vertical' | 'horizontal') || 'vertical';
|
||||
});
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
useEffect(() => {
|
||||
localStorage.setItem('draft_layout', layout);
|
||||
}, [layout]);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||
return localStorage.getItem('draft_sidebarCollapsed') === 'true';
|
||||
});
|
||||
|
||||
// Persist settings
|
||||
useEffect(() => {
|
||||
localStorage.setItem('draft_sidebarCollapsed', isSidebarCollapsed.toString());
|
||||
}, [isSidebarCollapsed]);
|
||||
useEffect(() => {
|
||||
localStorage.setItem('draft_poolHeight', poolHeight.toString());
|
||||
}, [poolHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('draft_sidebarWidth', sidebarWidth.toString());
|
||||
}, [sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('draft_cardScale', cardScale.toString());
|
||||
}, [cardScale]);
|
||||
|
||||
// Resize Handlers
|
||||
const startResizing = (e: React.MouseEvent) => {
|
||||
setIsResizing(true);
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
|
||||
const handleResizeStart = (type: 'sidebar' | 'pool', e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid scrolling/selection
|
||||
if (e.cancelable) e.preventDefault();
|
||||
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
resizingState.current = {
|
||||
startX: clientX,
|
||||
startY: clientY,
|
||||
startWidth: sidebarRef.current?.getBoundingClientRect().width || 320,
|
||||
startHeight: poolRef.current?.getBoundingClientRect().height || 220,
|
||||
active: type
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onResizeMove);
|
||||
document.addEventListener('touchmove', onResizeMove, { passive: false });
|
||||
document.addEventListener('mouseup', onResizeEnd);
|
||||
document.addEventListener('touchend', onResizeEnd);
|
||||
document.body.style.cursor = type === 'sidebar' ? 'col-resize' : 'row-resize';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const stopResizing = () => setIsResizing(false);
|
||||
const resize = (e: MouseEvent) => {
|
||||
if (isResizing) {
|
||||
const newHeight = window.innerHeight - e.clientY;
|
||||
// Limits: Min 100px, Max 60% of screen
|
||||
const maxHeight = window.innerHeight * 0.6;
|
||||
if (newHeight >= 100 && newHeight <= maxHeight) {
|
||||
setPoolHeight(newHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
const onResizeMove = React.useCallback((e: MouseEvent | TouchEvent) => {
|
||||
if (!resizingState.current.active) return;
|
||||
|
||||
if (isResizing) {
|
||||
window.addEventListener('mousemove', resize);
|
||||
window.addEventListener('mouseup', stopResizing);
|
||||
if (e.cancelable) e.preventDefault();
|
||||
|
||||
const clientX = (e as TouchEvent).touches ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX;
|
||||
const clientY = (e as TouchEvent).touches ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY;
|
||||
|
||||
// Direct DOM manipulation for performance
|
||||
requestAnimationFrame(() => {
|
||||
if (resizingState.current.active === 'sidebar' && sidebarRef.current) {
|
||||
const delta = clientX - resizingState.current.startX;
|
||||
const newWidth = Math.max(200, Math.min(600, resizingState.current.startWidth + delta));
|
||||
sidebarRef.current.style.width = `${newWidth}px`;
|
||||
}
|
||||
|
||||
if (resizingState.current.active === 'pool' && poolRef.current) {
|
||||
const delta = resizingState.current.startY - clientY; // Dragging up increases height
|
||||
const newHeight = Math.max(100, Math.min(window.innerHeight * 0.6, resizingState.current.startHeight + delta));
|
||||
poolRef.current.style.height = `${newHeight}px`;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onResizeEnd = React.useCallback(() => {
|
||||
// Commit final state
|
||||
if (resizingState.current.active === 'sidebar' && sidebarRef.current) {
|
||||
setSidebarWidth(parseInt(sidebarRef.current.style.width));
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', resize);
|
||||
window.removeEventListener('mouseup', stopResizing);
|
||||
};
|
||||
}, [isResizing]);
|
||||
if (resizingState.current.active === 'pool' && poolRef.current) {
|
||||
setPoolHeight(parseInt(poolRef.current.style.height));
|
||||
}
|
||||
|
||||
resizingState.current.active = null;
|
||||
document.removeEventListener('mousemove', onResizeMove);
|
||||
document.removeEventListener('touchmove', onResizeMove);
|
||||
document.removeEventListener('mouseup', onResizeEnd);
|
||||
document.removeEventListener('touchend', onResizeEnd);
|
||||
document.body.style.cursor = 'default';
|
||||
}, []);
|
||||
|
||||
const [hoveredCard, setHoveredCard] = useState<any>(null);
|
||||
const [displayCard, setDisplayCard] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hoveredCard) {
|
||||
setDisplayCard(normalizeCard(hoveredCard));
|
||||
}
|
||||
}, [hoveredCard]);
|
||||
|
||||
const activePack = draftState.players[currentPlayerId]?.activePack;
|
||||
const pickedCards = draftState.players[currentPlayerId]?.pool || [];
|
||||
|
||||
const handlePick = (cardId: string) => {
|
||||
// roomId and playerId are now inferred by the server from socket session
|
||||
const card = activePack?.cards.find((c: any) => c.id === cardId);
|
||||
console.log(`[DraftView] 👆 Manual/Submit Pick: ${card?.name || 'Unknown'} (${cardId})`);
|
||||
socketService.socket.emit('pick_card', { cardId });
|
||||
};
|
||||
|
||||
// ... inside DraftView return ...
|
||||
const handleAutoPick = async () => {
|
||||
if (activePack && activePack.cards.length > 0) {
|
||||
console.log('[DraftView] Starting Auto-Pick Process...');
|
||||
const bestCard = await AutoPicker.pickBestCardAsync(activePack.cards, pickedCards);
|
||||
if (bestCard) {
|
||||
console.log(`[DraftView] Auto-Pick submitting: ${bestCard.name}`);
|
||||
handlePick(bestCard.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAutoPick = () => {
|
||||
setIsAutoPickEnabled(!isAutoPickEnabled);
|
||||
};
|
||||
|
||||
// --- Auto-Pick / AFK Mode ---
|
||||
const [isAutoPickEnabled, setIsAutoPickEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
if (isAutoPickEnabled && activePack && activePack.cards.length > 0) {
|
||||
// Small delay for visual feedback and to avoid race conditions
|
||||
timeout = setTimeout(() => {
|
||||
handleAutoPick();
|
||||
}, 1500);
|
||||
}
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isAutoPickEnabled, activePack, draftState.packNumber, pickedCards.length]);
|
||||
|
||||
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const [draggedCard, setDraggedCard] = useState<any>(null);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
setDraggedCard(active.data.current?.card);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && over.id === 'pool-zone') {
|
||||
handlePick(active.id as string);
|
||||
}
|
||||
setDraggedCard(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 w-full flex flex-col h-full bg-slate-950 text-white overflow-hidden relative select-none" onContextMenu={(e) => e.preventDefault()}>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black opacity-50 pointer-events-none"></div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 w-full flex flex-col h-full bg-slate-950 text-white overflow-hidden relative select-none"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
style={{ '--card-scale': localCardScale } as React.CSSProperties}
|
||||
>
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black opacity-50 pointer-events-none"></div>
|
||||
|
||||
{/* Top Header: Timer & Pack Info */}
|
||||
<div className="shrink-0 p-4 z-10">
|
||||
<div className="flex justify-between items-center bg-slate-900/80 backdrop-blur border border-slate-800 p-4 rounded-lg shadow-lg">
|
||||
<div className="flex items-center gap-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-orange-500 shadow-amber-500/20 drop-shadow-sm">
|
||||
Pack {draftState.packNumber}
|
||||
</h2>
|
||||
<span className="text-sm text-slate-400 font-medium">Pick {pickedCards.length % 15 + 1}</span>
|
||||
</div>
|
||||
|
||||
{/* Card Scalar */}
|
||||
<div className="hidden md:flex flex-col gap-1 w-32">
|
||||
<label className="text-[10px] text-slate-500 uppercase font-bold tracking-wider">Card Size</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="1.5"
|
||||
step="0.1"
|
||||
value={cardScale}
|
||||
onChange={(e) => setCardScale(parseFloat(e.target.value))}
|
||||
className="w-full h-1 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{!activePack ? (
|
||||
<div className="text-sm font-bold text-amber-500 animate-pulse uppercase tracking-wider">Waiting...</div>
|
||||
) : (
|
||||
<div className="text-4xl font-mono text-emerald-400 font-bold drop-shadow-[0_0_10px_rgba(52,211,153,0.5)]">
|
||||
00:{timer < 10 ? `0${timer}` : timer}
|
||||
{/* Top Header: Timer & Pack Info */}
|
||||
<div className="shrink-0 p-4 z-10">
|
||||
<div className="flex flex-col lg:flex-row justify-between items-center bg-slate-900/80 backdrop-blur border border-slate-800 p-4 rounded-lg shadow-lg gap-4 lg:gap-0">
|
||||
<div className="flex flex-wrap justify-center items-center gap-4 lg:gap-8">
|
||||
<div className="text-center lg:text-left">
|
||||
<h2 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-orange-500 shadow-amber-500/20 drop-shadow-sm">
|
||||
Pack {draftState.packNumber}
|
||||
</h2>
|
||||
<span className="text-sm text-slate-400 font-medium">Pick {pickedCards.length % 15 + 1}</span>
|
||||
</div>
|
||||
)}
|
||||
{onExit && (
|
||||
<button
|
||||
onClick={() => setConfirmExitOpen(true)}
|
||||
className="p-3 bg-slate-800 hover:bg-red-500/20 text-slate-400 hover:text-red-500 border border-slate-700 hover:border-red-500/50 rounded-xl transition-all shadow-lg group"
|
||||
title="Exit to Lobby"
|
||||
>
|
||||
<LogOut className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Content: Zoom Sidebar + Pack Grid */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Layout Switcher */}
|
||||
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700 h-10 items-center">
|
||||
<button
|
||||
onClick={() => setLayout('vertical')}
|
||||
className={`p-1.5 rounded ${layout === 'vertical' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`}
|
||||
title="Vertical Split"
|
||||
>
|
||||
<Columns className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLayout('horizontal')}
|
||||
className={`p-1.5 rounded ${layout === 'horizontal' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`}
|
||||
title="Horizontal Split"
|
||||
>
|
||||
<LayoutTemplate className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dedicated Zoom Zone (Left Sidebar) */}
|
||||
<div className="hidden lg:flex w-80 shrink-0 flex-col items-center justify-start pt-8 border-r border-slate-800/50 bg-slate-900/20 backdrop-blur-sm z-10 transition-all">
|
||||
{hoveredCard ? (
|
||||
<div className="animate-in fade-in slide-in-from-left-4 duration-300 p-4 sticky top-4">
|
||||
<img
|
||||
src={hoveredCard.image || hoveredCard.image_uris?.normal || hoveredCard.card_faces?.[0]?.image_uris?.normal}
|
||||
alt={hoveredCard.name}
|
||||
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
|
||||
/>
|
||||
<div className="mt-4 text-center">
|
||||
<h3 className="text-lg font-bold text-slate-200">{hoveredCard.name}</h3>
|
||||
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{hoveredCard.type_line}</p>
|
||||
{/* Card Scalar */}
|
||||
<div className="flex items-center gap-2 bg-slate-900 rounded-lg px-2 border border-slate-700 h-10">
|
||||
<div className="w-2 h-3 rounded border border-slate-500 bg-slate-700" title="Small Cards" />
|
||||
<input
|
||||
type="range"
|
||||
min="0.35"
|
||||
max="1.0"
|
||||
step="0.01"
|
||||
value={localCardScale}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setLocalCardScale(val);
|
||||
// Direct DOM update for performance
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.setProperty('--card-scale', val.toString());
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => setCardScale(localCardScale)}
|
||||
onTouchEnd={() => setCardScale(localCardScale)}
|
||||
className="w-24 accent-emerald-500 cursor-pointer h-1.5 bg-slate-800 rounded-lg appearance-none"
|
||||
/>
|
||||
<div className="w-3 h-5 rounded border border-slate-500 bg-slate-700" title="Large Cards" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-600 p-8 text-center opacity-50">
|
||||
<div className="w-48 h-64 border-2 border-dashed border-slate-700 rounded-xl mb-4 flex items-center justify-center">
|
||||
<span className="text-xs uppercase font-bold tracking-widest">Hover Card</span>
|
||||
</div>
|
||||
<p className="text-sm">Hover over a card to view clear details.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Area: Current Pack OR Waiting State */}
|
||||
<div className="flex-1 overflow-y-auto p-4 z-0 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
|
||||
{!activePack ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-full pb-10 fade-in animate-in duration-500">
|
||||
<div className="w-24 h-24 mb-6 relative">
|
||||
<div className="absolute inset-0 rounded-full border-4 border-slate-800"></div>
|
||||
<div className="absolute inset-0 rounded-full border-4 border-t-emerald-500 animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<LogOut className="w-8 h-8 text-emerald-500 rotate-180" /> {/* Just a placeholder icon or similar */}
|
||||
<div className="flex items-center gap-6">
|
||||
{!activePack ? (
|
||||
<div className="text-sm font-bold text-amber-500 animate-pulse uppercase tracking-wider">Waiting...</div>
|
||||
) : (
|
||||
<div className="text-4xl font-mono text-emerald-400 font-bold drop-shadow-[0_0_10px_rgba(52,211,153,0.5)]">
|
||||
00:{timer < 10 ? `0${timer}` : timer}
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-white mb-2">Waiting for next pack...</h2>
|
||||
<p className="text-slate-400">Your neighbor is selecting a card.</p>
|
||||
<div className="mt-8 flex gap-2">
|
||||
<div className="w-3 h-3 bg-emerald-500 rounded-full animate-bounce [animation-delay:-0.3s]"></div>
|
||||
<div className="w-3 h-3 bg-emerald-500 rounded-full animate-bounce [animation-delay:-0.15s]"></div>
|
||||
<div className="w-3 h-3 bg-emerald-500 rounded-full animate-bounce"></div>
|
||||
</div>
|
||||
)}
|
||||
{onExit && (
|
||||
<button
|
||||
onClick={() => setConfirmExitOpen(true)}
|
||||
className="p-3 bg-slate-800 hover:bg-red-500/20 text-slate-400 hover:text-red-500 border border-slate-700 hover:border-red-500/50 rounded-xl transition-all shadow-lg group"
|
||||
title="Exit to Lobby"
|
||||
>
|
||||
<LogOut className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Content: Zoom Sidebar + Pack Grid */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
|
||||
{/* Dedicated Zoom Zone (Left Sidebar) */}
|
||||
{/* Collapsed State: Toolbar Column */}
|
||||
{isSidebarCollapsed ? (
|
||||
<div key="collapsed" className="hidden lg:flex shrink-0 w-12 flex-col items-center py-4 bg-slate-900 border-r border-slate-800/50 backdrop-blur-sm z-10 gap-4 transition-all duration-300">
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(false)}
|
||||
className="p-3 rounded-xl transition-all duration-200 group relative text-slate-500 hover:text-purple-400 hover:bg-slate-800"
|
||||
title="Expand Preview"
|
||||
>
|
||||
<Eye className="w-6 h-6" />
|
||||
<span className="absolute left-full ml-3 top-1/2 -translate-y-1/2 bg-slate-800 text-white text-xs font-bold px-2 py-1 rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none ring-1 ring-white/10 z-50">
|
||||
Card Preview
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center min-h-full pb-10">
|
||||
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold mb-8">Select a Card</h3>
|
||||
<div className="flex flex-wrap justify-center gap-6 [perspective:1000px]">
|
||||
{activePack.cards.map((card: any) => (
|
||||
<div
|
||||
key="expanded"
|
||||
ref={sidebarRef}
|
||||
className="hidden lg:flex shrink-0 flex-col items-center justify-start pt-8 border-r border-slate-800/50 bg-slate-900/20 backdrop-blur-sm z-10 relative group/sidebar"
|
||||
style={{ perspective: '1000px', width: `${sidebarWidth}px` }}
|
||||
>
|
||||
{/* Collapse Button */}
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(true)}
|
||||
className="absolute top-2 right-2 p-1.5 bg-slate-800/80 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors z-20 opacity-0 group-hover/sidebar:opacity-100"
|
||||
title="Collapse Preview"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="w-full relative sticky top-8 px-6">
|
||||
<div
|
||||
className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
|
||||
}}
|
||||
>
|
||||
{/* Front Face (Hovered Card) */}
|
||||
<div
|
||||
key={card.id}
|
||||
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer"
|
||||
style={{ width: `${14 * cardScale}rem` }}
|
||||
onClick={() => handlePick(card.id)}
|
||||
onMouseEnter={() => setHoveredCard(card)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
|
||||
style={{ backfaceVisibility: 'hidden' }}
|
||||
>
|
||||
{(hoveredCard || displayCard) && (
|
||||
<div className="w-full h-full flex flex-col bg-slate-900 rounded-xl">
|
||||
<img
|
||||
src={(hoveredCard || displayCard).image || (hoveredCard || displayCard).image_uris?.normal || (hoveredCard || displayCard).card_faces?.[0]?.image_uris?.normal}
|
||||
alt={(hoveredCard || displayCard).name}
|
||||
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="mt-4 text-center">
|
||||
<h3 className="text-lg font-bold text-slate-200">{(hoveredCard || displayCard).name}</h3>
|
||||
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{(hoveredCard || displayCard).typeLine || (hoveredCard || displayCard).type_line}</p>
|
||||
{(hoveredCard || displayCard).oracle_text && (
|
||||
<div className="mt-4 text-xs text-slate-400 text-left bg-slate-950 p-3 rounded-lg border border-slate-800 leading-relaxed max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Back Face (Card Back) */}
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
transform: 'rotateY(180deg)'
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-xl bg-emerald-500 blur-xl opacity-0 group-hover:opacity-40 transition-opacity duration-300"></div>
|
||||
<img
|
||||
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
|
||||
alt={card.name}
|
||||
className="w-full rounded-xl shadow-2xl shadow-black group-hover:ring-2 ring-emerald-400/50 relative z-10"
|
||||
src="/images/back.jpg"
|
||||
alt="Card Back"
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Resize Handle for Sidebar */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 bg-transparent hover:bg-emerald-500/50 cursor-col-resize z-50 flex flex-col justify-center items-center group transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart('sidebar', e)}
|
||||
onTouchStart={(e) => handleResizeStart('sidebar', e)}
|
||||
>
|
||||
<div className="h-8 w-1 bg-slate-700/50 rounded-full group-hover:bg-emerald-400 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/* Main Content Area: Handles both Pack and Pool based on layout */}
|
||||
{layout === 'vertical' ? (
|
||||
<div className="flex-1 flex min-w-0">
|
||||
{/* Left: Pack */}
|
||||
<div className="flex-1 overflow-y-auto p-4 z-0 custom-scrollbar border-r border-slate-800">
|
||||
{!activePack ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-full pb-10 fade-in animate-in duration-500">
|
||||
<div className="w-24 h-24 mb-6 relative">
|
||||
<div className="absolute inset-0 rounded-full border-4 border-slate-800"></div>
|
||||
<div className="absolute inset-0 rounded-full border-t-4 border-emerald-500 animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<LogOut className="w-8 h-8 text-emerald-500 rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-white mb-2">Waiting...</h2>
|
||||
<p className="text-slate-400">Your neighbor is picking.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center min-h-full pb-10">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold">Select a Card</h3>
|
||||
<button
|
||||
onClick={toggleAutoPick}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border shadow-lg font-bold text-xs transition-all hover:scale-105 ${isAutoPickEnabled
|
||||
? 'bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-400/50 animate-pulse'
|
||||
: 'bg-indigo-600 hover:bg-indigo-500 text-white border-indigo-400/50'
|
||||
}`}
|
||||
title={isAutoPickEnabled ? "Disable Auto-Pick" : "Enable Auto-Pick (AFK Mode)"}
|
||||
>
|
||||
<Wand2 className={`w-3 h-3 ${isAutoPickEnabled ? 'animate-spin' : ''}`} />
|
||||
{isAutoPickEnabled ? 'Auto-Pick ON' : 'Auto-Pick'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
{activePack.cards.map((rawCard: any) => (
|
||||
<DraftCardItem
|
||||
key={rawCard.id}
|
||||
rawCard={rawCard}
|
||||
cardScale={cardScale}
|
||||
handlePick={handlePick}
|
||||
setHoveredCard={setHoveredCard}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className="h-1 bg-slate-800 hover:bg-emerald-500 cursor-row-resize z-30 transition-colors w-full flex items-center justify-center shrink-0"
|
||||
onMouseDown={startResizing}
|
||||
>
|
||||
<div className="w-16 h-1 bg-slate-600 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Area: Drafted Pool Preview */}
|
||||
<div
|
||||
className="shrink-0 bg-gradient-to-t from-slate-950 to-slate-900/90 backdrop-blur-md flex flex-col z-20 shadow-[0_-10px_40px_rgba(0,0,0,0.5)] transition-all ease-out duration-75"
|
||||
style={{ height: `${poolHeight}px` }}
|
||||
>
|
||||
<div className="px-6 py-2 flex items-center justify-between shrink-0">
|
||||
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
Your Pool ({pickedCards.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-x-auto flex items-center gap-2 px-6 pb-4 custom-scrollbar">
|
||||
{pickedCards.map((card: any, idx: number) => (
|
||||
<div
|
||||
key={`${card.id}-${idx}`}
|
||||
className="relative group shrink-0 transition-all hover:-translate-y-10 h-full flex items-center"
|
||||
onMouseEnter={() => setHoveredCard(card)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
>
|
||||
<img
|
||||
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
|
||||
alt={card.name}
|
||||
className="h-[90%] w-auto rounded-lg shadow-lg border border-slate-700/50 group-hover:border-emerald-500/50 group-hover:shadow-emerald-500/20 transition-all object-contain"
|
||||
/>
|
||||
{/* Right: Pool (Vertical Column) */}
|
||||
<PoolDroppable className="flex-1 bg-slate-900/50 flex flex-col min-w-0 border-l border-slate-800 transition-colors duration-200">
|
||||
<div className="px-4 py-3 border-b border-slate-800 flex items-center justify-between shrink-0 bg-slate-900/80">
|
||||
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
Your Pool ({pickedCards.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar">
|
||||
<div className="flex flex-wrap gap-4 content-start">
|
||||
{pickedCards.map((card: any, idx: number) => (
|
||||
<PoolCardItem key={`${card.id}-${idx}`} card={card} setHoveredCard={setHoveredCard} vertical={true} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PoolDroppable>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Top: Pack */}
|
||||
<div className="flex-1 overflow-y-auto p-4 z-0 custom-scrollbar">
|
||||
{!activePack ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-full pb-10 fade-in animate-in duration-500">
|
||||
<div className="w-24 h-24 mb-6 relative">
|
||||
<div className="absolute inset-0 rounded-full border-4 border-slate-800"></div>
|
||||
<div className="absolute inset-0 rounded-full border-t-4 border-emerald-500 animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<LogOut className="w-8 h-8 text-emerald-500 rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-white mb-2">Waiting...</h2>
|
||||
<p className="text-slate-400">Your neighbor is picking.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center min-h-full pb-10">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold">Select a Card</h3>
|
||||
<button
|
||||
onClick={toggleAutoPick}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border shadow-lg font-bold text-xs transition-all hover:scale-105 ${isAutoPickEnabled
|
||||
? 'bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-400/50 animate-pulse'
|
||||
: 'bg-indigo-600 hover:bg-indigo-500 text-white border-indigo-400/50'
|
||||
}`}
|
||||
title={isAutoPickEnabled ? "Disable Auto-Pick" : "Enable Auto-Pick (AFK Mode)"}
|
||||
>
|
||||
<Wand2 className={`w-3 h-3 ${isAutoPickEnabled ? 'animate-spin' : ''}`} />
|
||||
{isAutoPickEnabled ? 'Auto-Pick ON' : 'Auto-Pick'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
{activePack.cards.map((rawCard: any) => (
|
||||
<DraftCardItem
|
||||
key={rawCard.id}
|
||||
rawCard={rawCard}
|
||||
cardScale={cardScale}
|
||||
handlePick={handlePick}
|
||||
setHoveredCard={setHoveredCard}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className="h-2 bg-slate-800 hover:bg-emerald-500/50 cursor-row-resize z-30 transition-colors w-full flex items-center justify-center shrink-0 group touch-none"
|
||||
onMouseDown={(e) => handleResizeStart('pool', e)}
|
||||
onTouchStart={(e) => handleResizeStart('pool', e)}
|
||||
>
|
||||
<div className="w-16 h-1 bg-slate-600 rounded-full group-hover:bg-emerald-300"></div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Pool (Horizontal Strip) */}
|
||||
<div ref={poolRef} style={{ height: `${poolHeight}px` }} className="shrink-0 flex flex-col overflow-hidden">
|
||||
<PoolDroppable
|
||||
className="flex-1 bg-slate-900/90 backdrop-blur-md flex flex-col z-20 shadow-[-10px_-10px_30px_rgba(0,0,0,0.3)] border-t border-slate-800 min-h-0"
|
||||
>
|
||||
<div className="px-6 py-2 flex items-center justify-between shrink-0">
|
||||
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
Your Pool ({pickedCards.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-x-auto flex gap-2 px-6 pb-2 pt-2 custom-scrollbar min-h-0">
|
||||
{pickedCards.map((card: any, idx: number) => (
|
||||
<PoolCardItem key={`${card.id}-${idx}`} card={card} setHoveredCard={setHoveredCard} />
|
||||
))}
|
||||
</div>
|
||||
</PoolDroppable>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={confirmExitOpen}
|
||||
onClose={() => setConfirmExitOpen(false)}
|
||||
title="Exit Draft?"
|
||||
message="Are you sure you want to exit the draft? You can rejoin later."
|
||||
type="warning"
|
||||
confirmLabel="Exit Draft"
|
||||
cancelLabel="Stay"
|
||||
onConfirm={onExit}
|
||||
/>
|
||||
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggedCard ? (
|
||||
<div
|
||||
className="opacity-90 rotate-3 cursor-grabbing shadow-2xl rounded-xl"
|
||||
style={{ width: `calc(14rem * var(--card-scale, ${localCardScale}))`, 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, handlePick, setHoveredCard }: any) => {
|
||||
const card = normalizeCard(rawCard);
|
||||
const isFoil = card.finish === 'foil';
|
||||
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,
|
||||
data: { card }
|
||||
});
|
||||
|
||||
const style = transform ? {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
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: `calc(14rem * var(--card-scale))` }}
|
||||
{...attributes}
|
||||
{...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)}
|
||||
>
|
||||
{/* Foil Glow Effect */}
|
||||
{isFoil && <div className="absolute inset-0 -m-1 rounded-xl bg-purple-500 blur-md opacity-20 group-hover:opacity-60 transition-opacity duration-300 animate-pulse"></div>}
|
||||
|
||||
<div className={`relative w-full rounded-xl shadow-2xl shadow-black overflow-hidden bg-slate-900 ${isFoil ? 'ring-2 ring-purple-400/50' : 'group-hover:ring-2 ring-emerald-400/50'}`}>
|
||||
<img
|
||||
src={card.image}
|
||||
alt={card.name}
|
||||
className="w-full h-full object-cover relative z-10"
|
||||
draggable={false}
|
||||
/>
|
||||
{isFoil && <FoilOverlay />}
|
||||
{isFoil && <div className="absolute top-2 right-2 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1.5 rounded backdrop-blur-sm border border-white/20">FOIL</div>}
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={confirmExitOpen}
|
||||
onClose={() => setConfirmExitOpen(false)}
|
||||
title="Exit Draft?"
|
||||
message="Are you sure you want to exit the draft? You can rejoin later."
|
||||
type="warning"
|
||||
confirmLabel="Exit Draft"
|
||||
cancelLabel="Stay"
|
||||
onConfirm={onExit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PoolCardItem = ({ card: rawCard, setHoveredCard, vertical = false }: any) => {
|
||||
const card = normalizeCard(rawCard);
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => {
|
||||
if (window.matchMedia('(pointer: coarse)').matches) return;
|
||||
}, card);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative group shrink-0 flex items-center justify-center cursor-pointer ${vertical ? 'w-24 h-32' : 'h-full aspect-[2.5/3.5] p-2'}`}
|
||||
onMouseEnter={() => setHoveredCard(card)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchMove={onTouchMove}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
src={card.image}
|
||||
alt={card.name}
|
||||
className={`${vertical ? 'w-full h-full object-cover' : 'h-full w-auto object-contain'} rounded-lg shadow-lg border border-slate-700/50 group-hover:border-emerald-500/50 group-hover:shadow-emerald-500/20 transition-all`}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
@@ -1,19 +1,73 @@
|
||||
import React from 'react';
|
||||
import { CardInstance } from '../../types/game';
|
||||
import { useGesture } from './GestureManager';
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
interface CardComponentProps {
|
||||
card: CardInstance;
|
||||
onDragStart: (e: React.DragEvent, cardId: string) => void;
|
||||
onClick: (cardId: string) => void;
|
||||
onContextMenu?: (cardId: string, e: React.MouseEvent) => void;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
onDrop?: (e: React.DragEvent, targetId: string) => void;
|
||||
onDrag?: (e: React.DragEvent) => void;
|
||||
onDragEnd?: (e: React.DragEvent) => void;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
viewMode?: 'normal' | 'cutout';
|
||||
}
|
||||
|
||||
export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, style }) => {
|
||||
export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, onDrop, onDrag, onDragEnd, style, className, viewMode = 'normal' }) => {
|
||||
const { registerCard, unregisterCard } = useGesture();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cardRef.current) {
|
||||
registerCard(card.instanceId, cardRef.current);
|
||||
}
|
||||
return () => unregisterCard(card.instanceId);
|
||||
}, [card.instanceId]);
|
||||
|
||||
// Robustly resolve Art Crop
|
||||
let imageSrc = card.imageUrl;
|
||||
|
||||
if (card.image_uris) {
|
||||
if (viewMode === 'cutout' && card.image_uris.crop) {
|
||||
imageSrc = card.image_uris.crop;
|
||||
} else if (card.image_uris.normal) {
|
||||
imageSrc = card.image_uris.normal;
|
||||
}
|
||||
} else if (card.definition && card.definition.set && card.definition.id) {
|
||||
if (viewMode === 'cutout') {
|
||||
imageSrc = `/cards/images/${card.definition.set}/crop/${card.definition.id}.jpg`;
|
||||
} else {
|
||||
imageSrc = `/cards/images/${card.definition.set}/full/${card.definition.id}.jpg`;
|
||||
}
|
||||
} else if (viewMode === 'cutout' && card.definition) {
|
||||
if (card.definition.image_uris?.art_crop) {
|
||||
imageSrc = card.definition.image_uris.art_crop;
|
||||
} else if (card.definition.card_faces?.[0]?.image_uris?.art_crop) {
|
||||
imageSrc = card.definition.card_faces[0].image_uris.art_crop;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, card.instanceId)}
|
||||
onDrag={(e) => onDrag && onDrag(e)}
|
||||
onDragEnd={(e) => onDragEnd && onDragEnd(e)}
|
||||
onDrop={(e) => {
|
||||
if (onDrop) {
|
||||
e.stopPropagation(); // prevent background drop
|
||||
onDrop(e, card.instanceId);
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (onDrop) e.preventDefault();
|
||||
}}
|
||||
onClick={() => onClick(card.instanceId)}
|
||||
onContextMenu={(e) => {
|
||||
if (onContextMenu) {
|
||||
@@ -21,17 +75,20 @@ export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart,
|
||||
onContextMenu(card.instanceId, e);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={`
|
||||
relative rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105 select-none
|
||||
${card.tapped ? 'rotate-90' : ''}
|
||||
${card.tapped ? 'rotate-45' : ''}
|
||||
${card.zone === 'hand' ? 'w-32 h-44 -ml-12 first:ml-0 hover:z-10 hover:-translate-y-4' : 'w-24 h-32'}
|
||||
${className || ''}
|
||||
`}
|
||||
style={style}
|
||||
>
|
||||
<div className="w-full h-full relative overflow-hidden rounded-lg bg-slate-800 border-2 border-slate-700">
|
||||
{!card.faceDown ? (
|
||||
<img
|
||||
src={card.imageUrl}
|
||||
src={imageSrc}
|
||||
alt={card.name}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
|
||||
@@ -185,23 +185,63 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
|
||||
Battlefield
|
||||
</div>
|
||||
<MenuItem
|
||||
label="Create Token (1/1)"
|
||||
label="Create Token (1/1 Soldier)"
|
||||
onClick={() => handleAction('CREATE_TOKEN', {
|
||||
tokenData: { name: 'Soldier', power: 1, toughness: 1 },
|
||||
definition: {
|
||||
name: 'Soldier',
|
||||
colors: ['W'],
|
||||
types: ['Creature'],
|
||||
subtypes: ['Soldier'],
|
||||
power: 1,
|
||||
toughness: 1,
|
||||
imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' // Generic Soldier?
|
||||
},
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Create Token (2/2)"
|
||||
label="Create Token (2/2 Zombie)"
|
||||
onClick={() => handleAction('CREATE_TOKEN', {
|
||||
tokenData: { name: 'Zombie', power: 2, toughness: 2, imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' },
|
||||
definition: {
|
||||
name: 'Zombie',
|
||||
colors: ['B'],
|
||||
types: ['Creature'],
|
||||
subtypes: ['Zombie'],
|
||||
power: 2,
|
||||
toughness: 2,
|
||||
imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' // Re-use or find standard
|
||||
},
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Add Mana..."
|
||||
onClick={() => handleAction('MANA', { x: request.x, y: request.y })} // Adjusted to use request.x/y as MenuItem's onClick doesn't pass event
|
||||
// icon={<Zap size={14} />} // Zap is not defined in this scope.
|
||||
/>
|
||||
<MenuItem
|
||||
label="Inspect Details"
|
||||
onClick={() => handleAction('INSPECT', {})}
|
||||
// icon={<Maximize size={14} />} // Maximize and RotateCw are not defined in this scope.
|
||||
/>
|
||||
<MenuItem
|
||||
label="Tap / Untap"
|
||||
onClick={() => handleAction('TAP', {})}
|
||||
// icon={<RotateCw size={14} />} // Maximize and RotateCw are not defined in this scope.
|
||||
/>
|
||||
<MenuItem
|
||||
label="Create Treasure"
|
||||
onClick={() => handleAction('CREATE_TOKEN', {
|
||||
tokenData: { name: 'Treasure', power: 0, toughness: 0, imageUrl: 'https://cards.scryfall.io/large/front/2/7/2776c5b9-1d22-4a00-9988-294747734185.jpg' },
|
||||
definition: {
|
||||
name: 'Treasure',
|
||||
colors: [],
|
||||
types: ['Artifact'],
|
||||
subtypes: ['Treasure'],
|
||||
power: 0,
|
||||
toughness: 0,
|
||||
keywords: [],
|
||||
imageUrl: 'https://cards.scryfall.io/large/front/2/7/2776c5b9-1d22-4a00-9988-294747734185.jpg'
|
||||
},
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
150
src/client/src/modules/game/GestureManager.tsx
Normal file
150
src/client/src/modules/game/GestureManager.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
|
||||
import React, { createContext, useContext, useRef, useState } from 'react';
|
||||
|
||||
interface GestureContextType {
|
||||
registerCard: (id: string, element: HTMLElement) => void;
|
||||
unregisterCard: (id: string) => void;
|
||||
}
|
||||
|
||||
const GestureContext = createContext<GestureContextType>({
|
||||
registerCard: () => { },
|
||||
unregisterCard: () => { },
|
||||
});
|
||||
|
||||
export const useGesture = () => useContext(GestureContext);
|
||||
|
||||
interface GestureManagerProps {
|
||||
children: React.ReactNode;
|
||||
onGesture?: (type: 'TAP' | 'ATTACK' | 'CANCEL', cardIds: string[]) => void;
|
||||
}
|
||||
|
||||
export const GestureManager: React.FC<GestureManagerProps> = ({ children, onGesture }) => {
|
||||
const cardRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [gesturePath, setGesturePath] = useState<{ x: number, y: number }[]>([]);
|
||||
const isGesturing = useRef(false);
|
||||
|
||||
const registerCard = (id: string, element: HTMLElement) => {
|
||||
cardRefs.current.set(id, element);
|
||||
};
|
||||
|
||||
const unregisterCard = (id: string) => {
|
||||
cardRefs.current.delete(id);
|
||||
};
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
// Only start gesture if clicking on background or specific handle?
|
||||
// For now, let's assume Right Click or Middle Drag is Gesture Mode?
|
||||
// Or just "Drag on Background".
|
||||
// If e.target is a card, usually DnD handles it.
|
||||
// We check if event target is NOT a card.
|
||||
|
||||
// Simplification: Check if Shift Key is held for Gesture Mode?
|
||||
// Or just native touch swipe.
|
||||
|
||||
// Let's rely on event propagation. If card didn't stopPropagation, maybe background catches it.
|
||||
// Assuming GameView wrapper catches this.
|
||||
|
||||
isGesturing.current = true;
|
||||
setGesturePath([{ x: e.clientX, y: e.clientY }]);
|
||||
|
||||
// Capture pointer
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!isGesturing.current) return;
|
||||
|
||||
setGesturePath(prev => [...prev, { x: e.clientX, y: e.clientY }]);
|
||||
};
|
||||
|
||||
const onPointerUp = (e: React.PointerEvent) => {
|
||||
if (!isGesturing.current) return;
|
||||
isGesturing.current = false;
|
||||
|
||||
// Analyze Path for "Slash" (Swipe to Tap)
|
||||
// Check intersection with cards
|
||||
analyzeGesture(gesturePath);
|
||||
|
||||
setGesturePath([]);
|
||||
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const analyzeGesture = (path: { x: number, y: number }[]) => {
|
||||
if (path.length < 5) return; // Too short
|
||||
|
||||
const start = path[0];
|
||||
const end = path[path.length - 1];
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const absDx = Math.abs(dx);
|
||||
const absDy = Math.abs(dy);
|
||||
|
||||
let gestureType: 'TAP' | 'ATTACK' | 'CANCEL' = 'TAP';
|
||||
|
||||
// If vertical movement is dominant and significant
|
||||
if (absDy > absDx && absDy > 50) {
|
||||
if (dy < 0) gestureType = 'ATTACK'; // Swipe Up
|
||||
else gestureType = 'CANCEL'; // Swipe Down
|
||||
} else {
|
||||
gestureType = 'TAP'; // Horizontal / Slash
|
||||
}
|
||||
|
||||
// Find Logic
|
||||
const intersectedCards = new Set<string>();
|
||||
|
||||
// Bounding Box Optimization
|
||||
const minX = Math.min(start.x, end.x);
|
||||
const maxX = Math.max(start.x, end.x);
|
||||
const minY = Math.min(start.y, end.y);
|
||||
const maxY = Math.max(start.y, end.y);
|
||||
|
||||
cardRefs.current.forEach((el, id) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// Rough Intersection of Line Segment
|
||||
// Check if rect intersects with bbox of path first
|
||||
if (rect.right < minX || rect.left > maxX || rect.bottom < minY || rect.top > maxY) return;
|
||||
|
||||
// Check points (Simpler)
|
||||
for (let i = 0; i < path.length; i += 2) { // Skip some points for perf
|
||||
const p = path[i];
|
||||
if (p.x >= rect.left && p.x <= rect.right && p.y >= rect.top && p.y <= rect.bottom) {
|
||||
intersectedCards.add(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (intersectedCards.size > 0 && onGesture) {
|
||||
onGesture(gestureType, Array.from(intersectedCards));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GestureContext.Provider value={{ registerCard, unregisterCard }}>
|
||||
<div
|
||||
className="relative w-full h-full touch-none"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* SVG Overlay for Path */}
|
||||
{gesturePath.length > 0 && (
|
||||
<svg className="absolute inset-0 pointer-events-none z-50 overflow-visible">
|
||||
<polyline
|
||||
points={gesturePath.map(p => `${p.x},${p.y}`).join(' ')}
|
||||
fill="none"
|
||||
stroke="cyan"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeOpacity="0.6"
|
||||
className="drop-shadow-[0_0_10px_rgba(0,255,255,0.8)]"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</GestureContext.Provider>
|
||||
);
|
||||
};
|
||||
130
src/client/src/modules/game/InspectorOverlay.tsx
Normal file
130
src/client/src/modules/game/InspectorOverlay.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { CardInstance } from '../../types/game';
|
||||
import { X, Sword, Shield, Zap, Layers, Link } from 'lucide-react';
|
||||
|
||||
interface InspectorOverlayProps {
|
||||
card: CardInstance;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const InspectorOverlay: React.FC<InspectorOverlayProps> = ({ card, onClose }) => {
|
||||
// Compute display values
|
||||
const currentPower = card.power ?? card.basePower ?? 0;
|
||||
const currentToughness = card.toughness ?? card.baseToughness ?? 0;
|
||||
|
||||
const isPowerModified = currentPower !== (card.basePower ?? 0);
|
||||
const isToughnessModified = currentToughness !== (card.baseToughness ?? 0);
|
||||
|
||||
const modifiers = useMemo(() => {
|
||||
// Mocking extraction of text descriptions from modifiers if they existed in client type
|
||||
// Since client type just has summary, we show what we have
|
||||
const list = [];
|
||||
|
||||
// Counters
|
||||
if (card.counters && card.counters.length > 0) {
|
||||
card.counters.forEach(c => list.push({ type: 'counter', text: `${c.count}x ${c.type} Counter` }));
|
||||
}
|
||||
|
||||
// P/T Mod
|
||||
if (card.ptModification && (card.ptModification.power !== 0 || card.ptModification.toughness !== 0)) {
|
||||
const signP = card.ptModification.power >= 0 ? '+' : '';
|
||||
const signT = card.ptModification.toughness >= 0 ? '+' : '';
|
||||
list.push({ type: 'effect', text: `Effect Modifier: ${signP}${card.ptModification.power}/${signT}${card.ptModification.toughness}` });
|
||||
}
|
||||
|
||||
// Attachments (Auras/Equipment)
|
||||
// Note: We don't have the list of attached cards ON this card easily in CardInstance alone without scanning all cards.
|
||||
// For this MVP, we inspect the card itself.
|
||||
|
||||
return list;
|
||||
}, [card]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className="relative bg-slate-900 border border-slate-700 rounded-xl shadow-2xl max-w-sm w-full overflow-hidden flex flex-col">
|
||||
|
||||
{/* Header (Image Bkg) */}
|
||||
<div className="relative h-32 bg-slate-800">
|
||||
<img src={card.imageUrl} alt={card.name} className="w-full h-full object-cover opacity-50 mask-image-b-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-900 to-transparent" />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 p-2 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<div className="absolute bottom-2 left-4 right-4">
|
||||
<h2 className="text-xl font-bold text-white truncate drop-shadow-md">{card.name}</h2>
|
||||
<div className="text-xs text-slate-300 flex items-center gap-2">
|
||||
<span className="bg-slate-800/80 px-2 py-0.5 rounded border border-slate-600">{card.typeLine || "Card"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* content */}
|
||||
<div className="p-4 space-y-4">
|
||||
|
||||
{/* Live Stats */}
|
||||
<div className="flex gap-4">
|
||||
{/* Power */}
|
||||
<div className={`flex-1 bg-slate-800 rounded-lg p-3 flex flex-col items-center border ${isPowerModified ? 'border-amber-500/50 bg-amber-500/10' : 'border-slate-700'}`}>
|
||||
<div className="text-xs text-slate-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||
<Sword size={12} /> Power
|
||||
</div>
|
||||
<div className="text-2xl font-black text-white flex items-baseline gap-1">
|
||||
{currentPower}
|
||||
{isPowerModified && <span className="text-xs text-amber-500 font-normal line-through opacity-70">{card.basePower}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toughness */}
|
||||
<div className={`flex-1 bg-slate-800 rounded-lg p-3 flex flex-col items-center border ${isToughnessModified ? 'border-blue-500/50 bg-blue-500/10' : 'border-slate-700'}`}>
|
||||
<div className="text-xs text-slate-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||
<Shield size={12} /> Toughness
|
||||
</div>
|
||||
<div className="text-2xl font-black text-white flex items-baseline gap-1">
|
||||
{currentToughness}
|
||||
{isToughnessModified && <span className="text-xs text-blue-400 font-normal line-through opacity-70">{card.baseToughness}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modifiers List */}
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 font-bold uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<Layers size={12} /> Active Modifiers
|
||||
</div>
|
||||
{modifiers.length === 0 ? (
|
||||
<div className="text-sm text-slate-600 italic text-center py-2 h-20 flex items-center justify-center bg-slate-800/50 rounded">
|
||||
No active modifiers
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{modifiers.map((mod, i) => (
|
||||
<div key={i} className="flex items-center gap-3 bg-slate-800 p-2 rounded border border-slate-700">
|
||||
<div className={`p-1.5 rounded-full ${mod.type === 'counter' ? 'bg-purple-500/20 text-purple-400' : 'bg-emerald-500/20 text-emerald-400'}`}>
|
||||
{mod.type === 'counter' ? <Zap size={12} /> : <Link size={12} />}
|
||||
</div>
|
||||
<span className="text-sm text-slate-200">{mod.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Oracle Text (Scrollable) */}
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 font-bold uppercase tracking-wider mb-1">Oracle Text</div>
|
||||
<div className="text-sm text-slate-300 leading-relaxed max-h-32 overflow-y-auto pr-2 custom-scrollbar">
|
||||
{card.oracleText?.split('\n').map((line, i) => (
|
||||
<p key={i} className="mb-1 last:mb-0">{line}</p>
|
||||
)) || <span className="italic text-slate-600">No text.</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
105
src/client/src/modules/game/MulliganView.tsx
Normal file
105
src/client/src/modules/game/MulliganView.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CardInstance } from '../../types/game';
|
||||
import { CardComponent } from './CardComponent';
|
||||
|
||||
interface MulliganViewProps {
|
||||
hand: CardInstance[];
|
||||
mulliganCount: number;
|
||||
onDecision: (keep: boolean, cardsToBottom: string[]) => void;
|
||||
}
|
||||
|
||||
export const MulliganView: React.FC<MulliganViewProps> = ({ hand, mulliganCount, onDecision }) => {
|
||||
const [selectedToBottom, setSelectedToBottom] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSelection = (cardId: string) => {
|
||||
const newSet = new Set(selectedToBottom);
|
||||
if (newSet.has(cardId)) {
|
||||
newSet.delete(cardId);
|
||||
} else {
|
||||
if (newSet.size < mulliganCount) {
|
||||
newSet.add(cardId);
|
||||
}
|
||||
}
|
||||
setSelectedToBottom(newSet);
|
||||
};
|
||||
|
||||
const isSelectionValid = selectedToBottom.size === mulliganCount;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-[100] bg-black/90 flex flex-col items-center justify-center backdrop-blur-sm">
|
||||
<div className="text-4xl font-black text-transparent bg-clip-text bg-gradient-to-br from-purple-400 to-pink-600 mb-8 drop-shadow-lg">
|
||||
{mulliganCount === 0 ? "Initial Keep Decision" : `London Mulligan: ${hand.length} Cards`}
|
||||
</div>
|
||||
|
||||
{mulliganCount > 0 ? (
|
||||
<div className="text-xl text-slate-300 mb-8 max-w-2xl text-center">
|
||||
You have mulliganed <strong>{mulliganCount}</strong> time{mulliganCount > 1 ? 's' : ''}.<br />
|
||||
Please select <span className="text-red-400 font-bold">{mulliganCount}</span> card{mulliganCount > 1 ? 's' : ''} to put on the bottom of your library.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xl text-slate-300 mb-8">
|
||||
Do you want to keep this hand?
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hand Display */}
|
||||
<div className="flex justify-center -space-x-4 mb-12 perspective-1000">
|
||||
{hand.map((card, index) => {
|
||||
const isSelected = selectedToBottom.has(card.instanceId);
|
||||
return (
|
||||
<div
|
||||
key={card.instanceId}
|
||||
className={`relative transition-all duration-300 cursor-pointer ${isSelected ? 'translate-y-12 opacity-50 grayscale scale-90' : 'hover:-translate-y-4 hover:scale-105 hover:z-50'
|
||||
}`}
|
||||
style={{ zIndex: isSelected ? 0 : 10 + index }}
|
||||
onClick={() => mulliganCount > 0 && toggleSelection(card.instanceId)}
|
||||
>
|
||||
<CardComponent
|
||||
card={card}
|
||||
onDragStart={() => { }}
|
||||
onClick={() => mulliganCount > 0 && toggleSelection(card.instanceId)}
|
||||
// Disable normal interactions
|
||||
onContextMenu={() => { }}
|
||||
className={isSelected ? 'ring-4 ring-red-500' : ''}
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="bg-red-600 text-white font-bold px-2 py-1 rounded shadow-lg text-xs transform rotate-[-15deg]">
|
||||
BOTTOM
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-8">
|
||||
<button
|
||||
onClick={() => onDecision(false, [])}
|
||||
className="px-8 py-4 bg-red-600/20 hover:bg-red-600/40 border border-red-500 text-red-100 rounded-xl font-bold text-lg transition-all flex flex-col items-center gap-1 group"
|
||||
>
|
||||
<span>Mulligan</span>
|
||||
<span className="text-xs text-red-400 group-hover:text-red-200">Draw {hand.length > 0 ? 7 : 7} New Cards</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => isSelectionValid && onDecision(true, Array.from(selectedToBottom))}
|
||||
disabled={!isSelectionValid}
|
||||
className={`px-8 py-4 rounded-xl font-bold text-lg transition-all flex flex-col items-center gap-1 min-w-[200px] ${isSelectionValid
|
||||
? 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-[0_0_20px_rgba(16,185,129,0.4)]'
|
||||
: 'bg-slate-800 text-slate-500 border border-slate-700 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<span>Keep Hand</span>
|
||||
<span className="text-xs opacity-70">
|
||||
{mulliganCount > 0
|
||||
? `${selectedToBottom.size}/${mulliganCount} Selected`
|
||||
: 'Start Game'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
50
src/client/src/modules/game/PhaseStrip.tsx
Normal file
50
src/client/src/modules/game/PhaseStrip.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
|
||||
import React from 'react';
|
||||
import { GameState, Phase, Step } from '../../types/game';
|
||||
import { Sun, Shield, Swords, Hourglass } from 'lucide-react';
|
||||
|
||||
interface PhaseStripProps {
|
||||
gameState: GameState;
|
||||
}
|
||||
|
||||
export const PhaseStrip: React.FC<PhaseStripProps> = ({ gameState }) => {
|
||||
const currentPhase = gameState.phase as Phase;
|
||||
const currentStep = gameState.step as Step;
|
||||
|
||||
// Phase Definitions
|
||||
const phases: { id: Phase; icon: React.ElementType; label: string }[] = [
|
||||
{ id: 'beginning', icon: Sun, label: 'Beginning' },
|
||||
{ id: 'main1', icon: Shield, label: 'Main 1' },
|
||||
{ id: 'combat', icon: Swords, label: 'Combat' },
|
||||
{ id: 'main2', icon: Shield, label: 'Main 2' },
|
||||
{ id: 'ending', icon: Hourglass, label: 'End' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex bg-black/40 backdrop-blur-md rounded-full p-1 border border-white/10 gap-1">
|
||||
{phases.map((p) => {
|
||||
const isActive = p.id === currentPhase;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`
|
||||
relative flex items-center justify-center w-8 h-8 rounded-full transition-all duration-300
|
||||
${isActive ? 'bg-emerald-500 text-white shadow-[0_0_10px_rgba(16,185,129,0.5)] scale-110 z-10' : 'text-slate-500 bg-transparent hover:bg-white/5'}
|
||||
`}
|
||||
title={p.label}
|
||||
>
|
||||
<p.icon size={16} />
|
||||
|
||||
{/* Active Step Indicator (Text below or Tooltip) */}
|
||||
{isActive && (
|
||||
<span className="absolute -bottom-6 left-1/2 -translate-x-1/2 text-[10px] font-bold text-white uppercase tracking-wider whitespace-nowrap bg-black/80 px-2 py-0.5 rounded border border-white/10">
|
||||
{currentStep}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
84
src/client/src/modules/game/RadialMenu.tsx
Normal file
84
src/client/src/modules/game/RadialMenu.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { } from 'react';
|
||||
import { } from 'lucide-react';
|
||||
|
||||
export interface RadialOption {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
color?: string; // CSS color string
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
interface RadialMenuProps {
|
||||
options: RadialOption[];
|
||||
position: { x: number, y: number };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const RadialMenu: React.FC<RadialMenuProps> = ({ options, position, onClose }) => {
|
||||
if (options.length === 0) return null;
|
||||
|
||||
const radius = 60; // Distance from center
|
||||
const buttonSize = 40; // Diameter of option buttons
|
||||
|
||||
return (
|
||||
// Backdrop to close on click outside
|
||||
<div
|
||||
className="fixed inset-0 z-[150] touch-none select-none"
|
||||
onClick={onClose}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}}
|
||||
>
|
||||
{/* Center close/cancel circle (optional) */}
|
||||
<div className="absolute inset-0 w-8 h-8 -translate-x-1/2 -translate-y-1/2 bg-black/50 rounded-full backdrop-blur-sm pointer-events-none" />
|
||||
|
||||
{options.map((opt, index) => {
|
||||
const angle = (index * 360) / options.length;
|
||||
const radian = (angle - 90) * (Math.PI / 180); // -90 to start at top
|
||||
const x = Math.cos(radian) * radius;
|
||||
const y = Math.sin(radian) * radius;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={opt.id}
|
||||
className="absolute flex flex-col items-center justify-center cursor-pointer transition-transform hover:scale-110 active:scale-95 animate-in zoom-in duration-200"
|
||||
style={{
|
||||
left: x,
|
||||
top: y,
|
||||
width: buttonSize,
|
||||
height: buttonSize,
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
opt.onSelect();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
w-full h-full rounded-full shadow-lg border-2 border-white/20 flex items-center justify-center text-white font-bold
|
||||
${opt.color ? '' : 'bg-slate-700'}
|
||||
`}
|
||||
style={{ backgroundColor: opt.color }}
|
||||
>
|
||||
{opt.icon || opt.label.substring(0, 2)}
|
||||
</div>
|
||||
{/* Label tooltip or text below */}
|
||||
<div className="absolute top-full mt-1 bg-black/80 px-1.5 py-0.5 rounded text-[10px] text-white whitespace-nowrap opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
|
||||
{opt.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
129
src/client/src/modules/game/SmartButton.tsx
Normal file
129
src/client/src/modules/game/SmartButton.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { GameState } from '../../types/game';
|
||||
|
||||
interface SmartButtonProps {
|
||||
gameState: GameState;
|
||||
playerId: string;
|
||||
onAction: (type: string, payload?: any) => void;
|
||||
contextData?: any;
|
||||
isYielding?: boolean;
|
||||
onYieldToggle?: () => void;
|
||||
}
|
||||
|
||||
export const SmartButton: React.FC<SmartButtonProps> = ({ gameState, playerId, onAction, contextData, isYielding, onYieldToggle }) => {
|
||||
const isMyPriority = gameState.priorityPlayerId === playerId;
|
||||
const isStackEmpty = !gameState.stack || gameState.stack.length === 0;
|
||||
|
||||
let label = "Wait";
|
||||
let colorClass = "bg-slate-700 text-slate-400 cursor-not-allowed";
|
||||
let actionType: string | null = null;
|
||||
|
||||
if (isYielding) {
|
||||
label = "Yielding... (Tap to Cancel)";
|
||||
colorClass = "bg-sky-600 hover:bg-sky-500 text-white shadow-[0_0_15px_rgba(2,132,199,0.5)] animate-pulse";
|
||||
// Tap to cancel yield
|
||||
actionType = 'CANCEL_YIELD';
|
||||
} else if (isMyPriority) {
|
||||
if (gameState.step === 'declare_attackers') {
|
||||
if (gameState.attackersDeclared) {
|
||||
label = "Pass (to Blockers)";
|
||||
colorClass = "bg-emerald-600 hover:bg-emerald-500 text-white shadow-[0_0_15px_rgba(16,185,129,0.5)] animate-pulse";
|
||||
actionType = 'PASS_PRIORITY';
|
||||
} else {
|
||||
const count = contextData?.attackers?.length || 0;
|
||||
label = count > 0 ? `Attack with ${count}` : "Skip Combat";
|
||||
colorClass = "bg-red-600 hover:bg-red-500 text-white shadow-[0_0_15px_rgba(239,68,68,0.5)] animate-pulse";
|
||||
actionType = 'DECLARE_ATTACKERS';
|
||||
}
|
||||
} else if (gameState.step === 'declare_blockers') {
|
||||
// Todo: blockers context
|
||||
label = "Declare Blockers";
|
||||
colorClass = "bg-blue-600 hover:bg-blue-500 text-white shadow-[0_0_15px_rgba(37,99,235,0.5)] animate-pulse";
|
||||
actionType = 'DECLARE_BLOCKERS';
|
||||
} else if (isStackEmpty) {
|
||||
// Pass Priority / Advance Step
|
||||
// If Main Phase, could technically play land/cast, but button defaults to Pass
|
||||
label = "Pass Turn/Phase";
|
||||
// If we want more granular: "Move to Combat" vs "End Turn" based on phase
|
||||
if (gameState.phase === 'main1') label = "Pass to Combat";
|
||||
else if (gameState.phase === 'main2') label = "End Turn";
|
||||
else label = "Pass";
|
||||
|
||||
colorClass = "bg-emerald-600 hover:bg-emerald-500 text-white shadow-[0_0_15px_rgba(16,185,129,0.5)] animate-pulse";
|
||||
actionType = 'PASS_PRIORITY';
|
||||
} else {
|
||||
// Resolve Top Item
|
||||
const topItem = gameState.stack![gameState.stack!.length - 1];
|
||||
label = `Resolve ${topItem?.name || 'Item'}`;
|
||||
colorClass = "bg-amber-600 hover:bg-amber-500 text-white shadow-[0_0_15px_rgba(245,158,11,0.5)]";
|
||||
actionType = 'PASS_PRIORITY'; // Resolving is just passing priority when stack not empty
|
||||
}
|
||||
}
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isLongPress = useRef(false);
|
||||
|
||||
const handlePointerDown = () => {
|
||||
isLongPress.current = false;
|
||||
timerRef.current = setTimeout(() => {
|
||||
isLongPress.current = true;
|
||||
if (onYieldToggle) {
|
||||
// Visual feedback could be added here
|
||||
onYieldToggle();
|
||||
}
|
||||
}, 600); // 600ms long press for Yield
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
if (!isLongPress.current) {
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (isYielding) {
|
||||
// Cancel logic
|
||||
if (onYieldToggle) onYieldToggle();
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionType) {
|
||||
let payload: any = { type: actionType };
|
||||
|
||||
if (actionType === 'DECLARE_ATTACKERS') {
|
||||
payload.attackers = contextData?.attackers || [];
|
||||
}
|
||||
// TODO: Blockers payload
|
||||
|
||||
onAction('game_strict_action', payload);
|
||||
}
|
||||
};
|
||||
|
||||
// Prevent context menu on long press
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={() => { if (timerRef.current) clearTimeout(timerRef.current); }}
|
||||
onContextMenu={handleContextMenu}
|
||||
disabled={!isMyPriority && !isYielding}
|
||||
className={`
|
||||
px-6 py-3 rounded-xl font-bold text-lg uppercase tracking-wider transition-all duration-300
|
||||
${colorClass}
|
||||
border border-white/10
|
||||
flex items-center justify-center
|
||||
min-w-[200px] select-none
|
||||
`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
75
src/client/src/modules/game/StackVisualizer.tsx
Normal file
75
src/client/src/modules/game/StackVisualizer.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
|
||||
import React from 'react';
|
||||
import { GameState } from '../../types/game';
|
||||
import { ArrowLeft, Sparkles } from 'lucide-react';
|
||||
|
||||
interface StackVisualizerProps {
|
||||
gameState: GameState;
|
||||
}
|
||||
|
||||
export const StackVisualizer: React.FC<StackVisualizerProps> = ({ gameState }) => {
|
||||
const stack = gameState.stack || [];
|
||||
|
||||
if (stack.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex flex-col-reverse gap-2 z-50 pointer-events-none">
|
||||
|
||||
{/* Stack Container */}
|
||||
<div className="flex flex-col-reverse gap-2 items-end">
|
||||
{stack.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`
|
||||
relative group pointer-events-auto
|
||||
w-64 bg-slate-900/90 backdrop-blur-md
|
||||
border-l-4 border-amber-500
|
||||
rounded-r-lg shadow-xl
|
||||
p-3 transform transition-all duration-300
|
||||
hover:scale-105 hover:-translate-x-2
|
||||
flex flex-col gap-1
|
||||
animate-in slide-in-from-right fade-in duration-300
|
||||
`}
|
||||
style={{
|
||||
// Stagger visual for depth
|
||||
marginRight: `${index * 4}px`
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between text-xs text-amber-500 font-bold uppercase tracking-wider">
|
||||
<span>{item.type}</span>
|
||||
<Sparkles size={12} />
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="text-white font-bold leading-tight">
|
||||
{item.name}
|
||||
</div>
|
||||
|
||||
{/* Targets (if any) */}
|
||||
{item.targets && item.targets.length > 0 && (
|
||||
<div className="text-xs text-slate-400 mt-1 flex items-center gap-1">
|
||||
<ArrowLeft size={10} />
|
||||
<span>Targets {item.targets.length} item(s)</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Index Indicator */}
|
||||
<div className="absolute -left-3 top-1/2 -translate-y-1/2 w-6 h-6 bg-amber-600 rounded-full flex items-center justify-center text-xs font-bold text-white border-2 border-slate-900 shadow-lg">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div className="text-right pr-2">
|
||||
<span className="text-amber-500/50 text-[10px] font-bold uppercase tracking-[0.2em] [writing-mode:vertical-rl] rotate-180">
|
||||
The Stack
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -52,7 +52,12 @@ export const ZoneOverlay: React.FC<ZoneOverlayProps> = ({ zoneName, cards, onClo
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={card.imageUrl || 'https://via.placeholder.com/250x350'}
|
||||
src={(() => {
|
||||
if (card.definition?.set && card.definition?.id) {
|
||||
return `/cards/images/${card.definition.set}/full/${card.definition.id}.jpg`;
|
||||
}
|
||||
return card.imageUrl || 'https://via.placeholder.com/250x350';
|
||||
})()}
|
||||
alt={card.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
import { Users, MessageSquare, Send, Play, Copy, Check, Layers, LogOut } from 'lucide-react';
|
||||
import { Share2, Users, Play, LogOut, Copy, Check, Hash, Crown, XCircle, MessageSquare, Send, Bell, BellOff, X, Bot, Layers } from 'lucide-react';
|
||||
import { useConfirm } from '../../components/ConfirmDialog';
|
||||
import { Modal } from '../../components/Modal';
|
||||
import { useToast } from '../../components/Toast';
|
||||
import { GameView } from '../game/GameView';
|
||||
import { DraftView } from '../draft/DraftView';
|
||||
import { DeckBuilderView } from '../draft/DeckBuilderView';
|
||||
@@ -13,6 +14,7 @@ interface Player {
|
||||
isHost: boolean;
|
||||
role: 'player' | 'spectator';
|
||||
isOffline?: boolean;
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
@@ -26,6 +28,7 @@ interface Room {
|
||||
id: string;
|
||||
hostId: string;
|
||||
players: Player[];
|
||||
basicLands?: any[];
|
||||
status: string;
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
@@ -42,7 +45,26 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
// State
|
||||
const [room, setRoom] = useState<Room>(initialRoom);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalConfig, setModalConfig] = useState({ title: '', message: '', type: 'info' as 'info' | 'error' | 'warning' | 'success' });
|
||||
const [modalConfig, setModalConfig] = useState<{
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'info' | 'error' | 'warning' | 'success';
|
||||
confirmLabel?: string;
|
||||
onConfirm?: () => void;
|
||||
cancelLabel?: string;
|
||||
onClose?: () => void;
|
||||
}>({ title: '', message: '', type: 'info' });
|
||||
|
||||
// Side Panel State
|
||||
const [activePanel, setActivePanel] = useState<'lobby' | 'chat' | null>(null);
|
||||
const [notificationsEnabled, setNotificationsEnabled] = useState(() => {
|
||||
return localStorage.getItem('notifications_enabled') !== 'false';
|
||||
});
|
||||
|
||||
// Services
|
||||
const { showToast } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Restored States
|
||||
const [message, setMessage] = useState('');
|
||||
@@ -50,11 +72,58 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [gameState, setGameState] = useState<any>(initialGameState || null);
|
||||
const [draftState, setDraftState] = useState<any>(initialDraftState || null);
|
||||
const [mobileTab, setMobileTab] = useState<'game' | 'chat'>('game'); // Keep for mobile
|
||||
|
||||
// Derived State
|
||||
const host = room.players.find(p => p.isHost);
|
||||
const isHostOffline = host?.isOffline;
|
||||
const isMeHost = currentPlayerId === host?.id;
|
||||
const prevPlayersRef = useRef<Player[]>(initialRoom.players);
|
||||
|
||||
// Persistence
|
||||
useEffect(() => {
|
||||
localStorage.setItem('notifications_enabled', notificationsEnabled.toString());
|
||||
}, [notificationsEnabled]);
|
||||
|
||||
// Player Notification Logic
|
||||
useEffect(() => {
|
||||
if (!notificationsEnabled) {
|
||||
prevPlayersRef.current = room.players;
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = prevPlayersRef.current;
|
||||
const curr = room.players;
|
||||
|
||||
// 1. New Players
|
||||
curr.forEach(p => {
|
||||
if (!prev.find(old => old.id === p.id)) {
|
||||
showToast(`${p.name} (${p.role}) joined the room.`, 'info');
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Left Players
|
||||
prev.forEach(p => {
|
||||
if (!curr.find(newP => newP.id === p.id)) {
|
||||
showToast(`${p.name} left the room.`, 'warning');
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Status Changes (Disconnect/Reconnect)
|
||||
curr.forEach(p => {
|
||||
const old = prev.find(o => o.id === p.id);
|
||||
if (old) {
|
||||
if (!old.isOffline && p.isOffline) {
|
||||
showToast(`${p.name} lost connection.`, 'error');
|
||||
}
|
||||
if (old.isOffline && !p.isOffline) {
|
||||
showToast(`${p.name} reconnected!`, 'success');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
prevPlayersRef.current = curr;
|
||||
}, [room.players, notificationsEnabled, showToast]);
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
@@ -73,8 +142,16 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
useEffect(() => {
|
||||
const socket = socketService.socket;
|
||||
const onKicked = () => {
|
||||
alert("You have been kicked from the room.");
|
||||
onExit();
|
||||
// alert("You have been kicked from the room.");
|
||||
// onExit();
|
||||
setModalConfig({
|
||||
title: 'Kicked',
|
||||
message: 'You have been kicked from the room.',
|
||||
type: 'error',
|
||||
confirmLabel: 'Back to Lobby',
|
||||
onConfirm: () => onExit()
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
socket.on('kicked', onKicked);
|
||||
return () => { socket.off('kicked', onKicked); };
|
||||
@@ -148,20 +225,7 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartGame = () => {
|
||||
const testDeck = Array.from({ length: 40 }).map((_, i) => ({
|
||||
id: `card-${i}`,
|
||||
name: i % 2 === 0 ? "Mountain" : "Lightning Bolt",
|
||||
image_uris: {
|
||||
normal: i % 2 === 0
|
||||
? "https://cards.scryfall.io/normal/front/1/9/194459f0-2586-444a-be7d-786d5e7e9bc4.jpg"
|
||||
: "https://cards.scryfall.io/normal/front/f/2/f29ba16f-c8fb-42fe-aabf-87089cb211a7.jpg"
|
||||
}
|
||||
}));
|
||||
|
||||
const decks = room.players.reduce((acc, p) => ({ ...acc, [p.id]: testDeck }), {});
|
||||
socketService.socket.emit('start_game', { roomId: room.id, decks });
|
||||
};
|
||||
|
||||
const handleStartDraft = () => {
|
||||
socketService.socket.emit('start_draft', { roomId: room.id });
|
||||
@@ -192,8 +256,8 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
{room.players.filter(p => p.role === 'player').map(p => {
|
||||
const isReady = (p as any).ready;
|
||||
return (
|
||||
<div key={p.id} className={`flex items-center gap-2 px-4 py-2 rounded-lg border ${isReady ? 'bg-emerald-900/30 border-emerald-500/50' : 'bg-slate-700/30 border-slate-700'}`}>
|
||||
<div className={`w-2 h-2 rounded-full ${isReady ? 'bg-emerald-500' : 'bg-slate-600'}`}></div>
|
||||
<div key={p.id} className={`flex items - center gap - 2 px - 4 py - 2 rounded - lg border ${isReady ? 'bg-emerald-900/30 border-emerald-500/50' : 'bg-slate-700/30 border-slate-700'} `}>
|
||||
<div className={`w - 2 h - 2 rounded - full ${isReady ? 'bg-emerald-500' : 'bg-slate-600'} `}></div>
|
||||
<span className={isReady ? 'text-emerald-200' : 'text-slate-500'}>{p.name}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -205,7 +269,7 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
}
|
||||
|
||||
const myPool = draftState.players[currentPlayerId]?.pool || [];
|
||||
return <DeckBuilderView roomId={room.id} currentPlayerId={currentPlayerId} initialPool={myPool} />;
|
||||
return <DeckBuilderView roomId={room.id} currentPlayerId={currentPlayerId} initialPool={myPool} availableBasicLands={room.basicLands} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -236,15 +300,14 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
disabled={room.status !== 'waiting'}
|
||||
className="px-8 py-3 bg-purple-600 hover:bg-purple-500 text-white font-bold rounded-lg flex items-center gap-2 shadow-lg shadow-purple-900/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Layers className="w-5 h-5" /> Start Real Draft
|
||||
<Layers className="w-5 h-5" /> Start Draft
|
||||
</button>
|
||||
<span className="text-xs text-slate-500 text-center">- OR -</span>
|
||||
<button
|
||||
onClick={handleStartGame}
|
||||
disabled={room.status !== 'waiting'}
|
||||
className="px-8 py-3 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg flex items-center gap-2 shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed text-xs uppercase tracking-wider"
|
||||
onClick={() => socketService.socket.emit('add_bot', { roomId: room.id })}
|
||||
disabled={room.status !== 'waiting' || room.players.length >= 8}
|
||||
className="px-8 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-lg flex items-center gap-2 shadow-lg shadow-indigo-900/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Play className="w-4 h-4" /> Quick Play (Test Decks)
|
||||
<Bot className="w-5 h-5" /> Add Bot
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -253,97 +316,242 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full gap-4">
|
||||
{renderContent()}
|
||||
|
||||
<div className="w-80 flex flex-col gap-4">
|
||||
<div className="flex-1 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
||||
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
|
||||
<Users className="w-4 h-4" /> Lobby
|
||||
</h3>
|
||||
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
|
||||
{room.players.map(p => {
|
||||
const isReady = (p as any).ready;
|
||||
const isMe = p.id === currentPlayerId;
|
||||
|
||||
return (
|
||||
<div key={p.id} className="flex items-center justify-between bg-slate-900/50 p-2 rounded-lg border border-slate-700/50 group">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs ${p.role === 'spectator' ? 'bg-slate-700 text-slate-300' : 'bg-gradient-to-br from-purple-500 to-blue-500 text-white'}`}>
|
||||
{p.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className={`text-sm font-medium ${isMe ? 'text-white' : 'text-slate-300'}`}>
|
||||
{p.name} {isMe && '(You)'}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider text-slate-500">
|
||||
{p.role} {p.isHost && <span className="text-amber-500 ml-1">• Host</span>}
|
||||
{isReady && room.status === 'deck_building' && <span className="text-emerald-500 ml-1">• Ready</span>}
|
||||
{p.isOffline && <span className="text-red-500 ml-1">• Offline</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{isMe && (
|
||||
<button
|
||||
onClick={onExit}
|
||||
className="p-1 hover:bg-slate-700 rounded text-slate-400 hover:text-red-400"
|
||||
title="Leave Room"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{isMeHost && !isMe && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Kick ${p.name}?`)) {
|
||||
socketService.socket.emit('kick_player', { roomId: room.id, targetId: p.id });
|
||||
}
|
||||
}}
|
||||
className="p-1 hover:bg-red-900/50 rounded text-slate-500 hover:text-red-500"
|
||||
title="Kick Player"
|
||||
>
|
||||
<LogOut className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex h-full w-full overflow-hidden relative">
|
||||
{/* --- MOBILE LAYOUT (Keep simplified tabs for small screens) --- */}
|
||||
<div className="lg:hidden flex flex-col w-full h-full">
|
||||
{/* Mobile Tab Bar */}
|
||||
<div className="shrink-0 flex items-center bg-slate-800 border-b border-slate-700">
|
||||
<button
|
||||
onClick={() => setMobileTab('game')}
|
||||
className={`flex - 1 p - 3 flex items - center justify - center gap - 2 text - sm font - bold transition - colors ${mobileTab === 'game' ? 'text-emerald-400 bg-slate-700/50 border-b-2 border-emerald-500' : 'text-slate-400 hover:text-slate-200'} `}
|
||||
>
|
||||
<Layers className="w-4 h-4" /> Game
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMobileTab('chat')}
|
||||
className={`flex - 1 p - 3 flex items - center justify - center gap - 2 text - sm font - bold transition - colors ${mobileTab === 'chat' ? 'text-purple-400 bg-slate-700/50 border-b-2 border-purple-500' : 'text-slate-400 hover:text-slate-200'} `}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="text-slate-600">/</span>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</div>
|
||||
Lobby & Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-1/2 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl flex flex-col">
|
||||
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4" /> Chat
|
||||
</h3>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 mb-3 pr-1 custom-scrollbar">
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} className="text-sm">
|
||||
<span className="font-bold text-purple-400 text-xs">{msg.sender}: </span>
|
||||
<span className="text-slate-300">{msg.text}</span>
|
||||
{/* Mobile Content */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
{mobileTab === 'game' ? (
|
||||
renderContent()
|
||||
) : (
|
||||
<div className="absolute inset-0 overflow-y-auto p-4 bg-slate-900">
|
||||
{/* Mobile Chat/Lobby merged view for simplicity, reusing logic if possible or duplicating strictly for mobile structure */}
|
||||
{/* Re-implementing simplified mobile view directly here to avoid layout conflicts */}
|
||||
<div className="space-y-4">
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
|
||||
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2"><Users className="w-4 h-4" /> Lobby</h3>
|
||||
{room.players.map(p => (
|
||||
<div key={p.id} className="flex items-center justify-between bg-slate-900/50 p-2 rounded mb-2 text-sm">
|
||||
<span className={p.id === currentPlayerId ? 'text-white font-bold' : 'text-slate-300'}>{p.name}</span>
|
||||
<span className="text-[10px] text-slate-500">{p.role}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 h-96 flex flex-col">
|
||||
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3"><MessageSquare className="w-4 h-4 inline mr-2" /> Chat</h3>
|
||||
<div className="flex-1 overflow-y-auto mb-2 space-y-2">
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} className="text-sm"><span className="font-bold text-purple-400">{msg.sender}:</span> <span className="text-slate-300">{msg.text}</span></div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<form onSubmit={sendMessage} className="flex gap-2">
|
||||
<input type="text" value={message} onChange={e => setMessage(e.target.value)} className="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 text-sm text-white" placeholder="Type..." />
|
||||
<button type="submit" className="bg-purple-600 rounded px-3 py-1 text-white"><Send className="w-4 h-4" /></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<form onSubmit={sendMessage} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
className="flex-1 bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Type..."
|
||||
/>
|
||||
<button type="submit" className="p-2 bg-purple-600 hover:bg-purple-500 rounded-lg text-white transition-colors">
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- DESKTOP LAYOUT --- */}
|
||||
{/* Main Content Area - Full Width */}
|
||||
<div className="hidden lg:flex flex-1 min-w-0 flex-col h-full relative z-0">
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* Right Collapsible Toolbar */}
|
||||
<div className="hidden lg:flex w-14 shrink-0 flex-col items-center gap-4 py-4 bg-slate-900 border-l border-slate-800 z-30 relative">
|
||||
<button
|
||||
onClick={() => setActivePanel(activePanel === 'lobby' ? null : 'lobby')}
|
||||
className={`p - 3 rounded - xl transition - all duration - 200 group relative ${activePanel === 'lobby' ? 'bg-purple-600 text-white shadow-lg shadow-purple-900/50' : 'text-slate-500 hover:text-purple-400 hover:bg-slate-800'} `}
|
||||
title="Lobby & Players"
|
||||
>
|
||||
<Users className="w-6 h-6" />
|
||||
<span className="absolute right-full mr-3 top-1/2 -translate-y-1/2 bg-slate-800 text-white text-xs font-bold px-2 py-1 rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none ring-1 ring-white/10">
|
||||
Lobby
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActivePanel(activePanel === 'chat' ? null : 'chat')}
|
||||
className={`p - 3 rounded - xl transition - all duration - 200 group relative ${activePanel === 'chat' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/50' : 'text-slate-500 hover:text-blue-400 hover:bg-slate-800'} `}
|
||||
title="Chat"
|
||||
>
|
||||
<div className="relative">
|
||||
<MessageSquare className="w-6 h-6" />
|
||||
{/* Unread indicator could go here */}
|
||||
</div>
|
||||
<span className="absolute right-full mr-3 top-1/2 -translate-y-1/2 bg-slate-800 text-white text-xs font-bold px-2 py-1 rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none ring-1 ring-white/10">
|
||||
Chat
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Floating Panel (Desktop) */}
|
||||
{activePanel && (
|
||||
<div className="hidden lg:flex absolute right-16 top-4 bottom-4 w-96 bg-slate-800/95 backdrop-blur-xl border border-slate-700/50 rounded-2xl shadow-2xl z-40 flex-col animate-in slide-in-from-right-10 fade-in duration-200 overflow-hidden ring-1 ring-white/10">
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-700 flex justify-between items-center bg-slate-900/50">
|
||||
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||||
{activePanel === 'lobby' ? <><Users className="w-5 h-5 text-purple-400" /> Lobby</> : <><MessageSquare className="w-5 h-5 text-blue-400" /> Chat</>}
|
||||
</h3>
|
||||
<button onClick={() => setActivePanel(null)} className="p-1 hover:bg-slate-700 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lobby Content */}
|
||||
{activePanel === 'lobby' && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Controls */}
|
||||
<div className="p-3 bg-slate-900/30 flex items-center justify-between border-b border-slate-800">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">{room.players.length} Connected</span>
|
||||
<button
|
||||
onClick={() => setNotificationsEnabled(!notificationsEnabled)}
|
||||
className={`flex items - center gap - 2 text - xs font - bold px - 2 py - 1 rounded - lg transition - colors border ${notificationsEnabled ? 'bg-slate-800 border-slate-600 text-slate-300 hover:text-white' : 'bg-red-900/20 border-red-900/50 text-red-400'} `}
|
||||
title={notificationsEnabled ? "Disable Notifications" : "Enable Notifications"}
|
||||
>
|
||||
{notificationsEnabled ? <Bell className="w-3 h-3" /> : <BellOff className="w-3 h-3" />}
|
||||
{notificationsEnabled ? 'On' : 'Off'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Player List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2 custom-scrollbar">
|
||||
{room.players.map(p => {
|
||||
const isReady = (p as any).ready;
|
||||
const isMe = p.id === currentPlayerId;
|
||||
const isSolo = room.players.length === 1 && room.status === 'playing';
|
||||
|
||||
return (
|
||||
<div key={p.id} className="flex items-center justify-between bg-slate-900/80 p-3 rounded-xl border border-slate-700/50 hover:border-slate-600 transition-colors group">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w - 10 h - 10 rounded - full flex items - center justify - center font - bold text - sm shadow - inner ${p.isBot ? 'bg-indigo-900 text-indigo-200 border border-indigo-500' : p.role === 'spectator' ? 'bg-slate-800 text-slate-500' : 'bg-gradient-to-br from-purple-600 to-blue-600 text-white shadow-purple-900/30'} `}>
|
||||
{p.isBot ? <Bot className="w-5 h-5" /> : p.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className={`text - sm font - bold ${isMe ? 'text-white' : 'text-slate-200'} `}>
|
||||
{p.name} {isMe && <span className="text-slate-500 font-normal">(You)</span>}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider text-slate-500 flex items-center gap-1">
|
||||
{p.role}
|
||||
{p.isHost && <span className="text-amber-500 flex items-center">• Host</span>}
|
||||
{p.isBot && <span className="text-indigo-400 flex items-center">• Bot</span>}
|
||||
{isReady && room.status === 'deck_building' && <span className="text-emerald-500 flex items-center">• Ready</span>}
|
||||
{p.isOffline && <span className="text-red-500 flex items-center">• Offline</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex gap - 1 ${isSolo ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition - opacity`}>
|
||||
{isMeHost && !isMe && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (await confirm({
|
||||
title: 'Kick Player?',
|
||||
message: `Are you sure you want to kick ${p.name}?`,
|
||||
confirmLabel: 'Kick',
|
||||
type: 'error'
|
||||
})) {
|
||||
socketService.socket.emit('kick_player', { roomId: room.id, targetId: p.id });
|
||||
}
|
||||
}}
|
||||
className="p-1.5 hover:bg-red-500/10 rounded-lg text-slate-500 hover:text-red-500 transition-colors"
|
||||
title="Kick Player"
|
||||
>
|
||||
<LogOut className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
)}
|
||||
{isMeHost && p.isBot && (
|
||||
<button
|
||||
onClick={() => {
|
||||
socketService.socket.emit('remove_bot', { roomId: room.id, botId: p.id });
|
||||
}}
|
||||
className="p-1.5 hover:bg-red-500/10 rounded-lg text-slate-500 hover:text-red-500 transition-colors"
|
||||
title="Remove Bot"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{isMe && (
|
||||
<button onClick={onExit} className="p-1.5 hover:bg-red-500/10 rounded-lg text-slate-400 hover:text-red-400 transition-colors" title="Accions">
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Content */}
|
||||
{activePanel === 'chat' && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-slate-600 mt-10 text-sm italic">
|
||||
No messages yet. Say hello!
|
||||
</div>
|
||||
)}
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} className={`flex flex - col ${msg.sender === (room.players.find(p => p.id === currentPlayerId)?.name) ? 'items-end' : 'items-start'} `}>
|
||||
<div className={`max - w - [85 %] px - 3 py - 2 rounded - xl text - sm ${msg.sender === (room.players.find(p => p.id === currentPlayerId)?.name) ? 'bg-blue-600 text-white rounded-br-none shadow-blue-900/20' : 'bg-slate-700 text-slate-200 rounded-bl-none'} `}>
|
||||
{msg.text}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 mt-1 font-medium">{msg.sender}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className="p-3 bg-slate-900/50 border-t border-slate-700">
|
||||
<form onSubmit={sendMessage} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
className="flex-1 bg-slate-950 border border-slate-700 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
<button type="submit" className="p-2.5 bg-blue-600 hover:bg-blue-500 rounded-xl text-white transition-all shadow-lg shadow-blue-900/20 disabled:opacity-50" disabled={!message.trim()}>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Host Disconnected Overlay */}
|
||||
{isHostOffline && !isMeHost && (
|
||||
<div className="absolute inset-0 z-50 bg-black/80 backdrop-blur-md flex flex-col items-center justify-center p-8 animate-in fade-in duration-500">
|
||||
@@ -363,8 +571,13 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm("Are you sure you want to leave the game?")) {
|
||||
onClick={async () => {
|
||||
if (await confirm({
|
||||
title: 'Leave Game?',
|
||||
message: "Are you sure you want to leave the game? You can rejoin later.",
|
||||
confirmLabel: 'Leave',
|
||||
type: 'warning'
|
||||
})) {
|
||||
onExit();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -3,19 +3,22 @@ import React, { useState } from 'react';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
import { GameRoom } from './GameRoom';
|
||||
import { Pack } from '../../services/PackGeneratorService';
|
||||
import { Users, PlusCircle, LogIn, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Users, PlusCircle, LogIn, AlertCircle, Loader2, Package, Check } from 'lucide-react';
|
||||
import { Modal } from '../../components/Modal';
|
||||
|
||||
interface LobbyManagerProps {
|
||||
generatedPacks: Pack[];
|
||||
availableLands: any[]; // DraftCard[]
|
||||
}
|
||||
|
||||
export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) => {
|
||||
export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, availableLands = [] }) => {
|
||||
const [activeRoom, setActiveRoom] = useState<any>(null);
|
||||
const [playerName, setPlayerName] = useState(() => localStorage.getItem('player_name') || '');
|
||||
const [joinRoomId, setJoinRoomId] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialDraftState, setInitialDraftState] = useState<any>(null);
|
||||
const [initialGameState, setInitialGameState] = useState<any>(null);
|
||||
|
||||
const [playerId] = useState(() => {
|
||||
const saved = localStorage.getItem('player_id');
|
||||
@@ -30,35 +33,32 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
|
||||
localStorage.setItem('player_name', playerName);
|
||||
}, [playerName]);
|
||||
|
||||
const [showBoxSelection, setShowBoxSelection] = useState(false);
|
||||
const [availableBoxes, setAvailableBoxes] = useState<{ id: string, title: string, packs: Pack[], setCode: string, packCount: number }[]>([]);
|
||||
|
||||
const connect = () => {
|
||||
if (!socketService.socket.connected) {
|
||||
socketService.connect();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRoom = async () => {
|
||||
if (!playerName) {
|
||||
setError('Please enter your name');
|
||||
return;
|
||||
}
|
||||
if (generatedPacks.length === 0) {
|
||||
setError('No packs generated! Please go to Draft Management and generate packs first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const executeCreateRoom = async (packsToUse: Pack[]) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
connect();
|
||||
|
||||
try {
|
||||
// Collect all cards
|
||||
const allCards = generatedPacks.flatMap(p => p.cards);
|
||||
// Collect all cards for caching (packs + basic lands)
|
||||
const allCards = packsToUse.flatMap(p => p.cards);
|
||||
const allCardsAndLands = [...allCards, ...availableLands];
|
||||
|
||||
// Deduplicate by Scryfall ID
|
||||
const uniqueCards = Array.from(new Map(allCards.map(c => [c.scryfallId, c])).values());
|
||||
const uniqueCards = Array.from(new Map(allCardsAndLands.map(c => [c.scryfallId, c])).values());
|
||||
|
||||
// Prepare payload for server (generic structure expected by CardService)
|
||||
const cardsToCache = uniqueCards.map(c => ({
|
||||
id: c.scryfallId,
|
||||
set: c.setCode, // Required for folder organization
|
||||
image_uris: { normal: c.image }
|
||||
}));
|
||||
|
||||
@@ -76,23 +76,29 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
|
||||
const cacheResult = await cacheResponse.json();
|
||||
console.log('Cached result:', cacheResult);
|
||||
|
||||
// Transform packs to use local URLs
|
||||
// Transform packs and lands to use local URLs
|
||||
// Note: For multiplayer, clients need to access this URL.
|
||||
const baseUrl = `${window.location.protocol}//${window.location.host}/cards/images`;
|
||||
|
||||
const updatedPacks = generatedPacks.map(pack => ({
|
||||
const updatedPacks = packsToUse.map(pack => ({
|
||||
...pack,
|
||||
cards: pack.cards.map(c => ({
|
||||
...c,
|
||||
// Update the single image property used by DraftCard
|
||||
image: `${baseUrl}/${c.scryfallId}.jpg`
|
||||
image: `${baseUrl}/${c.setCode}/${c.scryfallId}.jpg`
|
||||
}))
|
||||
}));
|
||||
|
||||
const updatedBasicLands = availableLands.map(l => ({
|
||||
...l,
|
||||
image: `${baseUrl}/${l.setCode}/${l.scryfallId}.jpg`
|
||||
}));
|
||||
|
||||
const response = await socketService.emitPromise('create_room', {
|
||||
hostId: playerId,
|
||||
hostName: playerName,
|
||||
packs: updatedPacks
|
||||
packs: updatedPacks,
|
||||
basicLands: updatedBasicLands
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
@@ -105,9 +111,68 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
|
||||
setError(err.message || 'Connection error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowBoxSelection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRoom = async () => {
|
||||
if (!playerName) {
|
||||
setError('Please enter your name');
|
||||
return;
|
||||
}
|
||||
if (generatedPacks.length === 0) {
|
||||
setError('No packs generated! Please go to Draft Management and generate packs first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Logic to detect Multiple Boxes
|
||||
// 1. Group by Set Name
|
||||
const packsBySet: Record<string, Pack[]> = {};
|
||||
generatedPacks.forEach(p => {
|
||||
const key = p.setName;
|
||||
if (!packsBySet[key]) packsBySet[key] = [];
|
||||
packsBySet[key].push(p);
|
||||
});
|
||||
|
||||
const boxes: { id: string, title: string, packs: Pack[], setCode: string, packCount: number }[] = [];
|
||||
|
||||
// Sort sets alphabetically
|
||||
Object.keys(packsBySet).sort().forEach(setName => {
|
||||
const setPacks = packsBySet[setName];
|
||||
const BOX_SIZE = 36;
|
||||
|
||||
// Split into chunks of 36
|
||||
for (let i = 0; i < setPacks.length; i += BOX_SIZE) {
|
||||
const chunk = setPacks.slice(i, i + BOX_SIZE);
|
||||
const boxNum = Math.floor(i / BOX_SIZE) + 1;
|
||||
const setCode = (chunk[0].cards[0]?.setCode || 'unk').toLowerCase();
|
||||
|
||||
boxes.push({
|
||||
id: `${setCode}-${boxNum}-${Date.now()}`, // Unique ID
|
||||
title: `${setName} - Box ${boxNum}`,
|
||||
packs: chunk,
|
||||
setCode: setCode,
|
||||
packCount: chunk.length
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Strategy: If we have multiple boxes, or if we have > 36 packs but maybe not multiple "boxes" (e.g. 50 packs of mixed),
|
||||
// we should interpret them.
|
||||
// The prompt says: "more than 1 box has been generated".
|
||||
// If I generate 2 boxes (72 packs), `boxes` array will have length 2.
|
||||
// If I generate 1 box (36 packs), `boxes` array will have length 1.
|
||||
|
||||
if (boxes.length > 1) {
|
||||
setAvailableBoxes(boxes);
|
||||
setShowBoxSelection(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If only 1 box (or partial), just use all packs
|
||||
executeCreateRoom(generatedPacks);
|
||||
};
|
||||
|
||||
const handleJoinRoom = async () => {
|
||||
if (!playerName) {
|
||||
setError('Please enter your name');
|
||||
@@ -131,6 +196,7 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
|
||||
|
||||
if (response.success) {
|
||||
setInitialDraftState(response.draftState || null);
|
||||
setInitialGameState(response.gameState || null);
|
||||
setActiveRoom(response.room);
|
||||
} else {
|
||||
setError(response.message || 'Failed to join room');
|
||||
@@ -149,33 +215,79 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
|
||||
}
|
||||
}, [activeRoom]);
|
||||
|
||||
// Reconnection logic
|
||||
// Reconnection logic (Initial Mount)
|
||||
React.useEffect(() => {
|
||||
const savedRoomId = localStorage.getItem('active_room_id');
|
||||
|
||||
if (savedRoomId && !activeRoom && playerId) {
|
||||
console.log(`[LobbyManager] Found saved session ${savedRoomId}. Attempting to reconnect...`);
|
||||
setLoading(true);
|
||||
connect();
|
||||
socketService.emitPromise('rejoin_room', { roomId: savedRoomId, playerId })
|
||||
.then((response: any) => {
|
||||
|
||||
const handleRejoin = async () => {
|
||||
try {
|
||||
console.log(`[LobbyManager] Emitting rejoin_room...`);
|
||||
const response = await socketService.emitPromise('rejoin_room', { roomId: savedRoomId, playerId });
|
||||
|
||||
if (response.success) {
|
||||
console.log("Rejoined session successfully");
|
||||
console.log("[LobbyManager] Rejoined session successfully");
|
||||
setActiveRoom(response.room);
|
||||
if (response.draftState) {
|
||||
setInitialDraftState(response.draftState);
|
||||
}
|
||||
if (response.gameState) {
|
||||
setInitialGameState(response.gameState);
|
||||
}
|
||||
} else {
|
||||
console.warn("Rejoin failed by server: ", response.message);
|
||||
localStorage.removeItem('active_room_id');
|
||||
console.warn("[LobbyManager] Rejoin failed by server: ", response.message);
|
||||
// Only clear if explicitly rejected (e.g. Room closed), not connection error
|
||||
if (response.message !== 'Connection error') {
|
||||
localStorage.removeItem('active_room_id');
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn("Reconnection failed", err);
|
||||
localStorage.removeItem('active_room_id'); // Clear invalid session
|
||||
} catch (err: any) {
|
||||
console.warn("[LobbyManager] Reconnection failed", err);
|
||||
// Do not clear ID immediately on network error, allow retry
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!socketService.socket.connected) {
|
||||
console.log(`[LobbyManager] Socket not connected. Connecting...`);
|
||||
connect();
|
||||
socketService.socket.once('connect', handleRejoin);
|
||||
} else {
|
||||
handleRejoin();
|
||||
}
|
||||
|
||||
return () => {
|
||||
socketService.socket.off('connect', handleRejoin);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
}, []); // Run once on mount
|
||||
|
||||
// Auto-Rejoin on Socket Reconnect (e.g. Server Restart)
|
||||
React.useEffect(() => {
|
||||
const socket = socketService.socket;
|
||||
|
||||
const onConnect = () => {
|
||||
if (activeRoom && playerId) {
|
||||
console.log("Socket reconnected. Attempting to restore session for room:", activeRoom.id);
|
||||
socketService.emitPromise('rejoin_room', { roomId: activeRoom.id, playerId })
|
||||
.then((response: any) => {
|
||||
if (response.success) {
|
||||
console.log("Session restored successfully.");
|
||||
} else {
|
||||
console.warn("Failed to restore session:", response.message);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Session restore error:", err));
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('connect', onConnect);
|
||||
return () => { socket.off('connect', onConnect); };
|
||||
}, [activeRoom, playerId]);
|
||||
|
||||
// Listener for room updates to switch view
|
||||
React.useEffect(() => {
|
||||
@@ -197,11 +309,12 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
|
||||
}
|
||||
setActiveRoom(null);
|
||||
setInitialDraftState(null);
|
||||
setInitialGameState(null);
|
||||
localStorage.removeItem('active_room_id');
|
||||
};
|
||||
|
||||
if (activeRoom) {
|
||||
return <GameRoom room={activeRoom} currentPlayerId={playerId} onExit={handleExitRoom} initialDraftState={initialDraftState} />;
|
||||
return <GameRoom room={activeRoom} currentPlayerId={playerId} onExit={handleExitRoom} initialDraftState={initialDraftState} initialGameState={initialGameState} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -234,8 +347,41 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-4 border-t border-slate-700">
|
||||
{/* Create Room */}
|
||||
<div className={`space-y-4 ${generatedPacks.length === 0 ? 'opacity-50' : ''}`}>
|
||||
<h3 className="text-xl font-bold text-white">Create Room</h3>
|
||||
<p className="text-sm text-slate-400">Start a new draft with your {generatedPacks.length} generated packs.</p>
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="text-xl font-bold text-white">Create Room</h3>
|
||||
<div className="group relative">
|
||||
<AlertCircle className="w-5 h-5 text-slate-500 cursor-help hover:text-white transition-colors" />
|
||||
<div className="absolute w-64 right-0 bottom-full mb-2 bg-slate-900 border border-slate-700 p-3 rounded-lg shadow-xl text-xs text-slate-300 hidden group-hover:block z-50">
|
||||
<strong className="block text-white mb-2 pb-1 border-b border-slate-700">Draft Rules (3 packs/player)</strong>
|
||||
<ul className="space-y-1">
|
||||
<li className={generatedPacks.length < 12 ? 'text-red-400' : 'text-slate-500'}>
|
||||
• < 12 Packs: Not enough for draft
|
||||
</li>
|
||||
<li className={(generatedPacks.length >= 12 && generatedPacks.length < 18) ? 'text-emerald-400 font-bold' : 'text-slate-500'}>
|
||||
• 12-17 Packs: 4 Players
|
||||
</li>
|
||||
<li className={(generatedPacks.length >= 18 && generatedPacks.length < 24) ? 'text-emerald-400 font-bold' : 'text-slate-500'}>
|
||||
• 18-23 Packs: 4 or 6 Players
|
||||
</li>
|
||||
<li className={generatedPacks.length >= 24 ? 'text-emerald-400 font-bold' : 'text-slate-500'}>
|
||||
• 24+ Packs: 4, 6 or 8 Players
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-slate-400">
|
||||
Start a new draft with your <span className="text-white font-bold">{generatedPacks.length}</span> generated packs.
|
||||
<div className="mt-1 text-xs">
|
||||
Supported Players: {' '}
|
||||
{generatedPacks.length < 12 && <span className="text-red-400 font-bold">None (Generate more packs)</span>}
|
||||
{generatedPacks.length >= 12 && generatedPacks.length < 18 && <span className="text-emerald-400 font-bold">4 Only</span>}
|
||||
{generatedPacks.length >= 18 && generatedPacks.length < 24 && <span className="text-emerald-400 font-bold">4 or 6</span>}
|
||||
{generatedPacks.length >= 24 && <span className="text-emerald-400 font-bold">4, 6 or 8</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreateRoom}
|
||||
disabled={loading || generatedPacks.length === 0}
|
||||
@@ -273,6 +419,62 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Box Selection Modal */}
|
||||
<Modal
|
||||
isOpen={showBoxSelection}
|
||||
onClose={() => setShowBoxSelection(false)}
|
||||
title="Select Sealed Box"
|
||||
message="Multiple boxes available. Please select a sealed box to open for this draft."
|
||||
type="info"
|
||||
maxWidth="max-w-3xl"
|
||||
>
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto custom-scrollbar p-1">
|
||||
{availableBoxes.map(box => (
|
||||
<button
|
||||
key={box.id}
|
||||
onClick={() => executeCreateRoom(box.packs)}
|
||||
className="group relative flex flex-col items-center p-6 bg-slate-900 border border-slate-700 rounded-xl hover:border-purple-500 hover:bg-slate-800 transition-all shadow-xl hover:shadow-purple-900/20"
|
||||
>
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="bg-purple-600 rounded-full p-1 shadow-lg shadow-purple-500/50">
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Box Graphic simulation */}
|
||||
<div className="w-24 h-32 mb-4 relative perspective-1000 group-hover:scale-105 transition-transform duration-300">
|
||||
<div className="absolute inset-0 bg-slate-800 rounded border border-slate-600 transform rotate-y-12 translate-z-4 shadow-2xl flex items-center justify-center overflow-hidden">
|
||||
{/* Set Icon as Box art */}
|
||||
<img
|
||||
src={`https://svgs.scryfall.io/sets/${box.setCode}.svg?1734307200`}
|
||||
alt={box.setCode}
|
||||
className="w-16 h-16 opacity-20 group-hover:opacity-50 transition-opacity invert"
|
||||
/>
|
||||
<Package className="absolute bottom-2 right-2 w-6 h-6 text-slate-500" />
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-transparent to-black/50 pointer-events-none rounded"></div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-bold text-white text-center text-lg leading-tight mb-1 group-hover:text-purple-400 transition-colors">
|
||||
{box.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 font-mono uppercase tracking-wider">
|
||||
<span className="bg-slate-800 px-2 py-0.5 rounded border border-slate-700">{box.setCode.toUpperCase()}</span>
|
||||
<span>•</span>
|
||||
<span>{box.packCount} Packs</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowBoxSelection(false)}
|
||||
className="px-4 py-2 text-slate-400 hover:text-white transition-colors text-sm font-bold"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useToast } from '../../components/Toast';
|
||||
|
||||
interface Match {
|
||||
id: number;
|
||||
@@ -15,6 +16,7 @@ interface Bracket {
|
||||
export const TournamentManager: React.FC = () => {
|
||||
const [playerInput, setPlayerInput] = useState('');
|
||||
const [bracket, setBracket] = useState<Bracket | null>(null);
|
||||
const { showToast } = useToast();
|
||||
|
||||
const shuffleArray = (array: any[]) => {
|
||||
let currentIndex = array.length, randomIndex;
|
||||
@@ -30,7 +32,10 @@ export const TournamentManager: React.FC = () => {
|
||||
const generateBracket = () => {
|
||||
if (!playerInput.trim()) return;
|
||||
const names = playerInput.split('\n').filter(n => n.trim() !== '').map(n => n.trim());
|
||||
if (names.length < 2) { alert("Enter at least 2 players."); return; }
|
||||
if (names.length < 2) {
|
||||
showToast("Enter at least 2 players.", 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const shuffled = shuffleArray(names);
|
||||
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(shuffled.length)));
|
||||
|
||||
@@ -9,10 +9,12 @@ export interface DraftCard {
|
||||
layout?: string; // Add layout
|
||||
colors: string[];
|
||||
image: string;
|
||||
imageArtCrop?: string;
|
||||
set: string;
|
||||
setCode: string;
|
||||
setType: string;
|
||||
finish?: 'foil' | 'normal';
|
||||
edhrecRank?: number; // Added EDHREC Rank
|
||||
// Extended Metadata
|
||||
cmc?: number;
|
||||
manaCost?: string;
|
||||
@@ -57,6 +59,7 @@ export interface ProcessedPools {
|
||||
mythics: DraftCard[];
|
||||
lands: DraftCard[];
|
||||
tokens: DraftCard[];
|
||||
specialGuests: DraftCard[];
|
||||
}
|
||||
|
||||
export interface SetsMap {
|
||||
@@ -69,20 +72,23 @@ export interface SetsMap {
|
||||
mythics: DraftCard[];
|
||||
lands: DraftCard[];
|
||||
tokens: DraftCard[];
|
||||
specialGuests: DraftCard[];
|
||||
}
|
||||
}
|
||||
|
||||
export interface PackGenerationSettings {
|
||||
mode: 'mixed' | 'by_set';
|
||||
rarityMode: 'peasant' | 'standard'; // Peasant: 10C/3U, Standard: 10C/3U/1R
|
||||
withReplacement?: boolean;
|
||||
}
|
||||
|
||||
export class PackGeneratorService {
|
||||
|
||||
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }, useLocalImages: boolean = false): { pools: ProcessedPools, sets: SetsMap } {
|
||||
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] };
|
||||
processCards(cards: ScryfallCard[], filters: { ignoreBasicLands: boolean, ignoreCommander: boolean, ignoreTokens: boolean }, useLocalImages: boolean = false, setsMetadata: { [code: string]: { parent_set_code?: string } } = {}): { pools: ProcessedPools, sets: SetsMap } {
|
||||
const pools: ProcessedPools = { commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [], specialGuests: [] };
|
||||
const setsMap: SetsMap = {};
|
||||
|
||||
// 1. First Pass: Organize into SetsMap
|
||||
cards.forEach(cardData => {
|
||||
const rarity = cardData.rarity;
|
||||
const typeLine = cardData.type_line || '';
|
||||
@@ -105,12 +111,16 @@ export class PackGeneratorService {
|
||||
layout: layout,
|
||||
colors: cardData.colors || [],
|
||||
image: useLocalImages
|
||||
? `${window.location.origin}/cards/images/${cardData.id}.jpg`
|
||||
? `${window.location.origin}/cards/images/${cardData.set}/full/${cardData.id}.jpg`
|
||||
: (cardData.image_uris?.normal || cardData.card_faces?.[0]?.image_uris?.normal || ''),
|
||||
imageArtCrop: useLocalImages
|
||||
? `${window.location.origin}/cards/images/${cardData.set}/crop/${cardData.id}.jpg`
|
||||
: (cardData.image_uris?.art_crop || cardData.card_faces?.[0]?.image_uris?.art_crop || ''),
|
||||
set: cardData.set_name,
|
||||
setCode: cardData.set,
|
||||
setType: setType,
|
||||
finish: cardData.finish,
|
||||
edhrecRank: cardData.edhrec_rank, // Map EDHREC Rank
|
||||
// Extended Metadata mapping
|
||||
cmc: cardData.cmc,
|
||||
manaCost: cardData.mana_cost,
|
||||
@@ -152,10 +162,11 @@ export class PackGeneratorService {
|
||||
else if (rarity === 'uncommon') pools.uncommons.push(cardObj);
|
||||
else if (rarity === 'rare') pools.rares.push(cardObj);
|
||||
else if (rarity === 'mythic') pools.mythics.push(cardObj);
|
||||
else pools.specialGuests.push(cardObj); // Catch-all for special/bonus
|
||||
|
||||
// Add to Sets Map
|
||||
if (!setsMap[cardData.set]) {
|
||||
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [] };
|
||||
setsMap[cardData.set] = { name: cardData.set_name, code: cardData.set, commons: [], uncommons: [], rares: [], mythics: [], lands: [], tokens: [], specialGuests: [] };
|
||||
}
|
||||
const setEntry = setsMap[cardData.set];
|
||||
|
||||
@@ -175,6 +186,43 @@ export class PackGeneratorService {
|
||||
else if (rarity === 'uncommon') { pools.uncommons.push(cardObj); setEntry.uncommons.push(cardObj); }
|
||||
else if (rarity === 'rare') { pools.rares.push(cardObj); setEntry.rares.push(cardObj); }
|
||||
else if (rarity === 'mythic') { pools.mythics.push(cardObj); setEntry.mythics.push(cardObj); }
|
||||
else { pools.specialGuests.push(cardObj); setEntry.specialGuests.push(cardObj); } // Catch-all for special/bonus
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Second Pass: Merge Subsets (Masterpieces) into Parents
|
||||
Object.keys(setsMap).forEach(setCode => {
|
||||
const meta = setsMetadata[setCode];
|
||||
if (meta && meta.parent_set_code) {
|
||||
const parentCode = meta.parent_set_code;
|
||||
if (setsMap[parentCode]) {
|
||||
const parentSet = setsMap[parentCode];
|
||||
const childSet = setsMap[setCode];
|
||||
|
||||
// Move ALL cards from child set to parent's 'specialGuests' pool
|
||||
// We iterate all pools of the child set
|
||||
const allChildCards = [
|
||||
...childSet.commons,
|
||||
...childSet.uncommons,
|
||||
...childSet.rares,
|
||||
...childSet.mythics,
|
||||
...childSet.specialGuests, // Include explicit specials
|
||||
// ...childSet.lands, // usually keeps land separate? or special lands?
|
||||
// Let's treat everything non-token as special guest candidate
|
||||
];
|
||||
|
||||
parentSet.specialGuests.push(...allChildCards);
|
||||
pools.specialGuests.push(...allChildCards);
|
||||
|
||||
// IMPORTANT: If we are in 'by_set' mode, we might NOT want to generate packs for the child set anymore?
|
||||
// Or we leave them there but they are ALSO in the parent's special pool?
|
||||
// The request implies "merged".
|
||||
// If we leave them in setsMap under their own code, they will generate their own packs in 'by_set' mode.
|
||||
// If the user selected BOTH, they probably want the "Special Guest" experience AND maybe separate packs?
|
||||
// Usually "Drafting WOT" separately is possible.
|
||||
// But "Drafting WOE" should include "WOT".
|
||||
// So copying is correct.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -191,7 +239,8 @@ export class PackGeneratorService {
|
||||
rares: this.shuffle(pools.rares),
|
||||
mythics: this.shuffle(pools.mythics),
|
||||
lands: this.shuffle(pools.lands),
|
||||
tokens: this.shuffle(pools.tokens)
|
||||
tokens: this.shuffle(pools.tokens),
|
||||
specialGuests: this.shuffle(pools.specialGuests)
|
||||
};
|
||||
|
||||
let packId = 1;
|
||||
@@ -217,7 +266,8 @@ export class PackGeneratorService {
|
||||
rares: this.shuffle(setData.rares),
|
||||
mythics: this.shuffle(setData.mythics),
|
||||
lands: this.shuffle(setData.lands),
|
||||
tokens: this.shuffle(setData.tokens)
|
||||
tokens: this.shuffle(setData.tokens),
|
||||
specialGuests: this.shuffle(setData.specialGuests)
|
||||
};
|
||||
|
||||
while (true) {
|
||||
@@ -244,10 +294,6 @@ export class PackGeneratorService {
|
||||
const drawC = this.drawColorBalanced(currentPools.commons, commonsNeeded, namesInThisPack);
|
||||
|
||||
if (!drawC.success && currentPools.commons.length >= commonsNeeded) {
|
||||
// If we have enough cards but failed strict color balancing, we might accept it or fail.
|
||||
// Standard algo returns null on failure. Let's do same to be safe, or just accept partial.
|
||||
// Given "Naive approach" in drawColorBalanced, if it returns success=false but has cards, it meant it couldn't find unique ones?
|
||||
// drawUniqueCards (called by drawColorBalanced) checks if we have enough cards.
|
||||
return null;
|
||||
} else if (currentPools.commons.length < commonsNeeded) {
|
||||
return null;
|
||||
@@ -258,9 +304,9 @@ export class PackGeneratorService {
|
||||
drawC.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
|
||||
// 2. Slot 7: Common / The List
|
||||
// 1-87: Common from Main Set
|
||||
// 88-97: Card from "The List" (Common/Uncommon)
|
||||
// 98-100: Uncommon from "The List"
|
||||
// 1-87: 1 Common from Main Set.
|
||||
// 88-97: 1 Card from "The List" (Common/Uncommon reprint).
|
||||
// 98-100: 1 Uncommon from "The List".
|
||||
const roll7 = Math.floor(Math.random() * 100) + 1;
|
||||
let slot7Card: DraftCard | undefined;
|
||||
|
||||
@@ -269,25 +315,30 @@ export class PackGeneratorService {
|
||||
const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.commons = res.remainingPool; }
|
||||
} else if (roll7 <= 97) {
|
||||
// List (Common/Uncommon). Simulating by picking 50/50 C/U if actual List not available
|
||||
const useUncommon = Math.random() < 0.5;
|
||||
const pool = useUncommon ? currentPools.uncommons : currentPools.commons;
|
||||
// Fallback if one pool is empty
|
||||
const effectivePool = pool.length > 0 ? pool : (useUncommon ? currentPools.commons : currentPools.uncommons);
|
||||
|
||||
if (effectivePool.length > 0) {
|
||||
const res = this.drawUniqueCards(effectivePool, 1, namesInThisPack);
|
||||
// List (Common/Uncommon). Use SpecialGuests or 50/50 fallback
|
||||
if (currentPools.specialGuests.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
|
||||
} else {
|
||||
// Fallback
|
||||
const pool = Math.random() < 0.5 ? currentPools.commons : currentPools.uncommons;
|
||||
const res = this.drawUniqueCards(pool, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
slot7Card = res.selected[0];
|
||||
// Identify which pool to update
|
||||
if (effectivePool === currentPools.uncommons) currentPools.uncommons = res.remainingPool;
|
||||
else currentPools.commons = res.remainingPool;
|
||||
if (pool === currentPools.commons) currentPools.commons = res.remainingPool;
|
||||
else currentPools.uncommons = res.remainingPool;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 98-100: Uncommon (from List or pool)
|
||||
const res = this.drawUniqueCards(currentPools.uncommons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.uncommons = res.remainingPool; }
|
||||
// 98-100: Uncommon from "The List"
|
||||
if (currentPools.specialGuests.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
|
||||
} else {
|
||||
// Fallback
|
||||
const res = this.drawUniqueCards(currentPools.uncommons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.uncommons = res.remainingPool; }
|
||||
}
|
||||
}
|
||||
|
||||
if (slot7Card) {
|
||||
@@ -298,7 +349,6 @@ export class PackGeneratorService {
|
||||
// 3. Slots 8-11: Uncommons (4 cards)
|
||||
const uncommonsNeeded = 4;
|
||||
const drawU = this.drawUniqueCards(currentPools.uncommons, uncommonsNeeded, namesInThisPack);
|
||||
// We accept partial if pool depleted to avoid crashing, but standard behavior is usually strict.
|
||||
packCards.push(...drawU.selected);
|
||||
currentPools.uncommons = drawU.remainingPool;
|
||||
drawU.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
@@ -322,25 +372,19 @@ export class PackGeneratorService {
|
||||
namesInThisPack.add(landCard.name);
|
||||
}
|
||||
|
||||
// Helper for Wildcards
|
||||
// Helper for Wildcards (Peasant)
|
||||
const drawWildcard = (foil: boolean) => {
|
||||
// ~62% Common, ~37% Uncommon
|
||||
const wRoll = Math.random() * 100;
|
||||
let wRarity = 'common';
|
||||
// ~49% Common, ~24% Uncommon, ~13% Rare, ~13% Mythic
|
||||
if (wRoll > 87) wRarity = 'mythic';
|
||||
else if (wRoll > 74) wRarity = 'rare';
|
||||
else if (wRoll > 50) wRarity = 'uncommon';
|
||||
else wRarity = 'common';
|
||||
if (wRoll > 62) wRarity = 'uncommon';
|
||||
|
||||
let poolToUse: DraftCard[] = [];
|
||||
let updatePool = (_newPool: DraftCard[]) => { };
|
||||
|
||||
if (wRarity === 'mythic') { poolToUse = currentPools.mythics; updatePool = (p) => currentPools.mythics = p; }
|
||||
else if (wRarity === 'rare') { poolToUse = currentPools.rares; updatePool = (p) => currentPools.rares = p; }
|
||||
else if (wRarity === 'uncommon') { poolToUse = currentPools.uncommons; updatePool = (p) => currentPools.uncommons = p; }
|
||||
if (wRarity === 'uncommon') { poolToUse = currentPools.uncommons; updatePool = (p) => currentPools.uncommons = p; }
|
||||
else { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
|
||||
|
||||
// Fallback
|
||||
if (poolToUse.length === 0) {
|
||||
if (currentPools.commons.length > 0) { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
|
||||
}
|
||||
@@ -373,14 +417,14 @@ export class PackGeneratorService {
|
||||
}
|
||||
|
||||
} else {
|
||||
// --- NEW ALGORITHM (Play Booster) ---
|
||||
// --- NEW ALGORITHM (Standard / Play Booster) ---
|
||||
|
||||
// 1. Slots 1-6: Commons (Color Balanced)
|
||||
const commonsNeeded = 6;
|
||||
const drawC = this.drawColorBalanced(currentPools.commons, commonsNeeded, namesInThisPack);
|
||||
if (!drawC.success) return null;
|
||||
packCards.push(...drawC.selected);
|
||||
currentPools.commons = drawC.remainingPool; // Update pool
|
||||
currentPools.commons = drawC.remainingPool;
|
||||
drawC.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
|
||||
// 2. Slots 8-10: Uncommons (3 cards)
|
||||
@@ -392,7 +436,7 @@ export class PackGeneratorService {
|
||||
drawU.selected.forEach(c => namesInThisPack.add(c.name));
|
||||
|
||||
// 3. Slot 11: Main Rare/Mythic (1/8 Mythic, 7/8 Rare)
|
||||
const isMythic = Math.random() < (1 / 8);
|
||||
const isMythic = Math.random() < 0.125;
|
||||
let rarePicked = false;
|
||||
|
||||
if (isMythic && currentPools.mythics.length > 0) {
|
||||
@@ -415,10 +459,11 @@ export class PackGeneratorService {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback if Rare pool empty but Mythic not (or vice versa) handled by just skipping
|
||||
|
||||
// 4. Slot 7: Wildcard / The List
|
||||
// 1-87: Common, 88-97: List (C/U), 98-99: List (R/M), 100: Special Guest
|
||||
// 4. Slot 7: Common / The List / Special Guest
|
||||
// 1-87: 1 Common from Main Set.
|
||||
// 88-97: 1 Card from "The List" (Common/Uncommon reprint).
|
||||
// 98-99: 1 Rare/Mythic from "The List".
|
||||
// 100: 1 Special Guest (High Value).
|
||||
const roll7 = Math.floor(Math.random() * 100) + 1;
|
||||
let slot7Card: DraftCard | undefined;
|
||||
|
||||
@@ -427,36 +472,42 @@ export class PackGeneratorService {
|
||||
const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.commons = res.remainingPool; }
|
||||
} else if (roll7 <= 97) {
|
||||
// "The List" (Common/Uncommon). Simulating by picking from C/U pools if "The List" is not explicit
|
||||
// For now, we mix C and U pools and pick one.
|
||||
const listPool = [...currentPools.commons, ...currentPools.uncommons]; // Simplification
|
||||
if (listPool.length > 0) {
|
||||
const rnd = Math.floor(Math.random() * listPool.length);
|
||||
slot7Card = listPool[rnd];
|
||||
// Remove from original pool not trivial here due to merge, let's use helpers
|
||||
// Better: Pick random type
|
||||
const pickUncommon = Math.random() < 0.3; // Arbitrary weight
|
||||
if (pickUncommon) {
|
||||
const res = this.drawUniqueCards(currentPools.uncommons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.uncommons = res.remainingPool; }
|
||||
} else {
|
||||
const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.commons = res.remainingPool; }
|
||||
// List (Common/Uncommon)
|
||||
if (currentPools.specialGuests.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
|
||||
} else {
|
||||
const pool = Math.random() < 0.5 ? currentPools.commons : currentPools.uncommons;
|
||||
const res = this.drawUniqueCards(pool, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
slot7Card = res.selected[0];
|
||||
if (pool === currentPools.commons) currentPools.commons = res.remainingPool;
|
||||
else currentPools.uncommons = res.remainingPool;
|
||||
}
|
||||
}
|
||||
} else if (roll7 <= 99) {
|
||||
// List (Rare/Mythic)
|
||||
if (currentPools.specialGuests.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
|
||||
} else {
|
||||
const pool = Math.random() < 0.125 ? currentPools.mythics : currentPools.rares;
|
||||
const res = this.drawUniqueCards(pool, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
slot7Card = res.selected[0];
|
||||
if (pool === currentPools.mythics) currentPools.mythics = res.remainingPool;
|
||||
else currentPools.rares = res.remainingPool;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 98-100: Rare/Mythic/Special Guest
|
||||
// Pick Rare or Mythic
|
||||
// 98-99 (2%) vs 100 (1%) -> 2:1 ratio
|
||||
const isGuest = roll7 === 100;
|
||||
const useMythic = isGuest || Math.random() < 0.2;
|
||||
|
||||
if (useMythic && currentPools.mythics.length > 0) {
|
||||
// 100: Special Guest
|
||||
if (currentPools.specialGuests.length > 0) {
|
||||
const res = this.drawUniqueCards(currentPools.specialGuests, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.specialGuests = res.remainingPool; }
|
||||
} else {
|
||||
// Fallback Mythic
|
||||
const res = this.drawUniqueCards(currentPools.mythics, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.mythics = res.remainingPool; }
|
||||
} else {
|
||||
const res = this.drawUniqueCards(currentPools.rares, 1, namesInThisPack);
|
||||
if (res.success) { slot7Card = res.selected[0]; currentPools.rares = res.remainingPool; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,7 +532,6 @@ export class PackGeneratorService {
|
||||
// Fallback: Pick a Common if no lands
|
||||
// const res = this.drawUniqueCards(currentPools.commons, 1, namesInThisPack);
|
||||
// if (res.success) { landCard = { ...res.selected[0] }; ... }
|
||||
// Better to just have no land than a non-land
|
||||
}
|
||||
|
||||
if (landCard) {
|
||||
@@ -491,8 +541,7 @@ export class PackGeneratorService {
|
||||
}
|
||||
|
||||
// 6. Slot 13: Wildcard (Non-Foil)
|
||||
// Weights: ~49% C, ~24% U, ~13% R, ~13% M => Sum=99.
|
||||
// Normalized: C:50, U:24, R:13, M:13
|
||||
// Weights: ~49% C, ~24% U, ~13% R, ~13% M
|
||||
const drawWildcard = (foil: boolean) => {
|
||||
const wRoll = Math.random() * 100;
|
||||
let wRarity = 'common';
|
||||
@@ -501,7 +550,6 @@ export class PackGeneratorService {
|
||||
else if (wRoll > 50) wRarity = 'uncommon';
|
||||
else wRarity = 'common';
|
||||
|
||||
// Adjust buckets
|
||||
let poolToUse: DraftCard[] = [];
|
||||
let updatePool = (_newPool: DraftCard[]) => { };
|
||||
|
||||
@@ -511,7 +559,6 @@ export class PackGeneratorService {
|
||||
else { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
|
||||
|
||||
if (poolToUse.length === 0) {
|
||||
// Fallback cascade
|
||||
if (currentPools.commons.length > 0) { poolToUse = currentPools.commons; updatePool = (p) => currentPools.commons = p; }
|
||||
}
|
||||
|
||||
@@ -534,26 +581,15 @@ export class PackGeneratorService {
|
||||
|
||||
// 8. Slot 15: Marketing / Token
|
||||
if (currentPools.tokens.length > 0) {
|
||||
// Just pick one, duplicates allowed for tokens? user said unique cards... but for tokens?
|
||||
// "drawUniqueCards" handles uniqueness check.
|
||||
const res = this.drawUniqueCards(currentPools.tokens, 1, namesInThisPack);
|
||||
if (res.success) {
|
||||
packCards.push(res.selected[0]);
|
||||
currentPools.tokens = res.remainingPool;
|
||||
// Don't care about uniqueness for tokens as much, but let's stick to it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: Mythic -> Rare -> Uncommon -> Common -> Land -> Token
|
||||
// We already have rarityWeight.
|
||||
// Assign weight to 'land' or 'token'?
|
||||
// DraftCard has 'rarity' string.
|
||||
// Standard rarities: common, uncommon, rare, mythic.
|
||||
// Basic Land has rarity 'common' usually? or 'basic'.
|
||||
// Token has rarity 'common' or 'token' (if we set it?). Scryfall tokens often have no rarity or 'common'.
|
||||
|
||||
// Custom sort
|
||||
const getWeight = (c: DraftCard) => {
|
||||
if (c.layout === 'token' || c.typeLine?.includes('Token')) return 0;
|
||||
if (c.typeLine?.includes('Land') && (c.rarity === 'common' || c.rarity === 'basic')) return 1;
|
||||
|
||||
@@ -162,14 +162,16 @@ export class ScryfallService {
|
||||
const data = await response.json();
|
||||
if (data.data) {
|
||||
return data.data.filter((s: any) =>
|
||||
['core', 'expansion', 'masters', 'draft_innovation'].includes(s.set_type)
|
||||
['core', 'expansion', 'masters', 'draft_innovation', 'commander', 'funny', 'masterpiece', 'eternal'].includes(s.set_type)
|
||||
).map((s: any) => ({
|
||||
code: s.code,
|
||||
name: s.name,
|
||||
set_type: s.set_type,
|
||||
released_at: s.released_at,
|
||||
icon_svg_uri: s.icon_svg_uri,
|
||||
digital: s.digital
|
||||
digital: s.digital,
|
||||
parent_set_code: s.parent_set_code,
|
||||
card_count: s.card_count
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -178,7 +180,7 @@ export class ScryfallService {
|
||||
return [];
|
||||
}
|
||||
|
||||
async fetchSetCards(setCode: string, onProgress?: (current: number) => void): Promise<ScryfallCard[]> {
|
||||
async fetchSetCards(setCode: string, relatedSets: string[] = [], onProgress?: (current: number) => void): Promise<ScryfallCard[]> {
|
||||
if (this.initPromise) await this.initPromise;
|
||||
|
||||
// Check if we already have a significant number of cards from this set in cache?
|
||||
@@ -186,7 +188,9 @@ export class ScryfallService {
|
||||
// But for now, we just fetch and merge.
|
||||
|
||||
let cards: ScryfallCard[] = [];
|
||||
let url = `https://api.scryfall.com/cards/search?q=set:${setCode}&unique=cards`;
|
||||
const setClause = `e:${setCode}` + relatedSets.map(s => ` OR e:${s}`).join('');
|
||||
// User requested pattern: (e:main or e:sub) and is:booster unique=prints
|
||||
let url = `https://api.scryfall.com/cards/search?q=(${setClause}) unique=prints is:booster`;
|
||||
|
||||
while (url) {
|
||||
try {
|
||||
@@ -228,4 +232,6 @@ export interface ScryfallSet {
|
||||
released_at: string;
|
||||
icon_svg_uri: string;
|
||||
digital: boolean;
|
||||
parent_set_code?: string;
|
||||
card_count: number;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,101 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.animate-bg-roll {
|
||||
animation: bg-roll 3s linear infinite;
|
||||
}
|
||||
.animate-spin-slow {
|
||||
animation: spin 8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bg-roll {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.foil-holo {
|
||||
--space: 5%;
|
||||
--angle: 133deg;
|
||||
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgb(255, 119, 115) calc(var(--space)*1),
|
||||
rgba(255,237,95,1) calc(var(--space)*2),
|
||||
rgba(168,255,95,1) calc(var(--space)*3),
|
||||
rgba(131,255,247,1) calc(var(--space)*4),
|
||||
rgba(120,148,255,1) calc(var(--space)*5),
|
||||
rgb(216,117,255) calc(var(--space)*6),
|
||||
rgb(255,119,115) calc(var(--space)*7)
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
var(--angle),
|
||||
#0e152e 0%,
|
||||
hsl(180, 10%, 60%) 3.8%,
|
||||
hsl(180, 29%, 66%) 4.5%,
|
||||
hsl(180, 10%, 60%) 5.2%,
|
||||
#0e152e 10%,
|
||||
#0e152e 12%
|
||||
);
|
||||
|
||||
background-blend-mode: screen, hue;
|
||||
background-size: 200% 700%, 300% 200%;
|
||||
background-position: 0% 50%, 0% 50%;
|
||||
|
||||
filter: brightness(0.8) contrast(1.5) saturate(0.8);
|
||||
mix-blend-mode: color-dodge;
|
||||
opacity: 0.35;
|
||||
|
||||
animation: foil-shift 15s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes foil-shift {
|
||||
0% { background-position: 0% 50%, 0% 0%; }
|
||||
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,3 +1,23 @@
|
||||
|
||||
export type Phase = 'setup' | 'beginning' | 'main1' | 'combat' | 'main2' | 'ending';
|
||||
|
||||
export type Step =
|
||||
| 'mulligan'
|
||||
| 'untap' | 'upkeep' | 'draw'
|
||||
| 'main'
|
||||
| 'beginning_combat' | 'declare_attackers' | 'declare_blockers' | 'combat_damage' | 'end_combat'
|
||||
| 'end' | 'cleanup';
|
||||
|
||||
export interface StackObject {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
controllerId: string;
|
||||
type: 'spell' | 'ability' | 'trigger';
|
||||
name: string;
|
||||
text: string;
|
||||
targets: string[];
|
||||
}
|
||||
|
||||
export interface CardInstance {
|
||||
instanceId: string;
|
||||
oracleId: string; // Scryfall ID
|
||||
@@ -5,12 +25,35 @@ export interface CardInstance {
|
||||
imageUrl: string;
|
||||
controllerId: string;
|
||||
ownerId: string;
|
||||
zone: 'library' | 'hand' | 'battlefield' | 'graveyard' | 'exile' | 'command';
|
||||
zone: 'library' | 'hand' | 'battlefield' | 'graveyard' | 'exile' | 'command' | 'stack';
|
||||
tapped: boolean;
|
||||
faceDown: boolean;
|
||||
position: { x: number; y: number; z: number }; // For freeform placement
|
||||
attacking?: string; // Player/Planeswalker ID
|
||||
blocking?: string[]; // List of attacker IDs blocked by this card
|
||||
attachedTo?: string; // ID of card/player this aura/equipment is attached to
|
||||
counters: { type: string; count: number }[];
|
||||
ptModification: { power: number; toughness: number };
|
||||
power?: number; // Current Calculated Power
|
||||
toughness?: number; // Current Calculated Toughness
|
||||
basePower?: number; // Base Power
|
||||
baseToughness?: number; // Base Toughness
|
||||
position: { x: number; y: number; z: number }; // For freeform placement
|
||||
typeLine?: string;
|
||||
types?: string[];
|
||||
supertypes?: string[];
|
||||
subtypes?: string[];
|
||||
oracleText?: string;
|
||||
manaCost?: string;
|
||||
definition?: any;
|
||||
image_uris?: {
|
||||
normal?: string;
|
||||
crop?: string;
|
||||
art_crop?: string;
|
||||
small?: string;
|
||||
large?: string;
|
||||
png?: string;
|
||||
border_crop?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
@@ -20,6 +63,10 @@ export interface PlayerState {
|
||||
poison: number;
|
||||
energy: number;
|
||||
isActive: boolean;
|
||||
hasPassed?: boolean;
|
||||
manaPool?: Record<string, number>;
|
||||
handKept?: boolean;
|
||||
mulliganCount?: number;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
@@ -28,5 +75,12 @@ export interface GameState {
|
||||
cards: Record<string, CardInstance>; // Keyed by instanceId
|
||||
order: string[]; // Turn order (player IDs)
|
||||
turn: number;
|
||||
phase: string;
|
||||
// Strict Mode Extension
|
||||
phase: string | Phase;
|
||||
step?: Step;
|
||||
stack?: StackObject[];
|
||||
activePlayerId?: string; // Explicitly tracked in strict
|
||||
priorityPlayerId?: string;
|
||||
attackersDeclared?: boolean;
|
||||
blockersDeclared?: boolean;
|
||||
}
|
||||
|
||||
346
src/client/src/utils/AutoDeckBuilder.ts
Normal file
346
src/client/src/utils/AutoDeckBuilder.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
|
||||
export interface Card {
|
||||
id: string;
|
||||
name: string;
|
||||
mana_cost?: string; // Standard Scryfall
|
||||
manaCost?: string; // Legacy support
|
||||
type_line?: string; // Standard Scryfall
|
||||
typeLine?: string; // Legacy support
|
||||
colors?: string[]; // e.g. ['W', 'U']
|
||||
colorIdentity?: string[];
|
||||
rarity?: 'common' | 'uncommon' | 'rare' | 'mythic' | string;
|
||||
cmc?: number;
|
||||
power?: string;
|
||||
toughness?: string;
|
||||
edhrecRank?: number; // Added EDHREC Rank
|
||||
card_faces?: any[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export class AutoDeckBuilder {
|
||||
|
||||
/**
|
||||
* Main entry point to build a deck from a pool.
|
||||
* Now purely local and synchronous in execution (wrapped in Promise for API comp).
|
||||
*/
|
||||
static async buildDeckAsync(pool: Card[], basicLands: Card[]): Promise<Card[]> {
|
||||
console.log(`[AutoDeckBuilder] 🏗️ Building deck from pool of ${pool.length} cards...`);
|
||||
|
||||
// We force a small delay to not block UI thread if it was heavy, though for 90 cards it's fast.
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
|
||||
return this.calculateHeuristicDeck(pool, basicLands);
|
||||
}
|
||||
|
||||
// --- Core Heuristic Logic ---
|
||||
|
||||
private static calculateHeuristicDeck(pool: Card[], basicLands: Card[]): Card[] {
|
||||
const TARGET_SPELL_COUNT = 23;
|
||||
|
||||
// 1. Identify best 2-color combination
|
||||
const bestPair = this.findBestColorPair(pool);
|
||||
console.log(`[AutoDeckBuilder] 🎨 Best pair identified: ${bestPair.join('/')}`);
|
||||
|
||||
// 2. Filter available spells for that pair + Artifacts
|
||||
const mainColors = bestPair;
|
||||
let candidates = pool.filter(c => {
|
||||
// Exclude Basic Lands from pool (they are added later)
|
||||
if (this.isBasicLand(c)) return false;
|
||||
|
||||
const colors = c.colors || [];
|
||||
if (colors.length === 0) return true; // Artifacts
|
||||
return colors.every(col => mainColors.includes(col)); // On-color
|
||||
});
|
||||
|
||||
// 3. Score and Select Spells
|
||||
// Logic:
|
||||
// a. Score every candidate
|
||||
// b. Sort by score
|
||||
// c. Fill Curve:
|
||||
// - Ensure minimum 2-drops, 3-drops?
|
||||
// - Or just pick best cards?
|
||||
// - Let's do a weighted curve approach: Fill slots with best cards for that slot.
|
||||
|
||||
const scoredCandidates = candidates.map(c => ({
|
||||
card: c,
|
||||
score: this.calculateCardScore(c, mainColors)
|
||||
}));
|
||||
|
||||
// Sort Descending
|
||||
scoredCandidates.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Curve Buckets (Min-Max goal)
|
||||
// 1-2 CMC: 4-6
|
||||
// 3 CMC: 4-6
|
||||
// 4 CMC: 4-5
|
||||
// 5 CMC: 2-3
|
||||
// 6+ CMC: 1-2
|
||||
// Creatures check: Ensure at least ~13 creatures
|
||||
const deckSpells: Card[] = [];
|
||||
// const creatureCount = () => deckSpells.filter(c => c.typeLine?.includes('Creature')).length;
|
||||
|
||||
|
||||
// Simple pass: Just take top 23?
|
||||
// No, expensive cards might clog.
|
||||
// Let's iterate and enforce limits.
|
||||
|
||||
const curveCounts: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
|
||||
const getCmcBucket = (c: Card) => {
|
||||
const val = c.cmc || 0;
|
||||
if (val <= 2) return 2; // Merge 0,1,2 for simplicity
|
||||
if (val >= 6) return 6;
|
||||
return val;
|
||||
};
|
||||
|
||||
// Soft caps for each bucket to ensure distribution
|
||||
const curveLimits: Record<number, number> = { 2: 8, 3: 7, 4: 6, 5: 4, 6: 3 };
|
||||
|
||||
// Pass 1: Fill using curve limits
|
||||
for (const item of scoredCandidates) {
|
||||
if (deckSpells.length >= TARGET_SPELL_COUNT) break;
|
||||
const bucket = getCmcBucket(item.card);
|
||||
if (curveCounts[bucket] < curveLimits[bucket]) {
|
||||
deckSpells.push(item.card);
|
||||
curveCounts[bucket]++;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Fill remaining slots with best available ignoring curve (to reach 23)
|
||||
if (deckSpells.length < TARGET_SPELL_COUNT) {
|
||||
const remaining = scoredCandidates.filter(item => !deckSpells.includes(item.card));
|
||||
for (const item of remaining) {
|
||||
if (deckSpells.length >= TARGET_SPELL_COUNT) break;
|
||||
deckSpells.push(item.card);
|
||||
}
|
||||
}
|
||||
|
||||
// Creature Balance Check (Simplistic)
|
||||
// If creatures < 12, swap worst non-creatures for best available creatures?
|
||||
// Skipping for now to keep it deterministic and simple.
|
||||
|
||||
// 4. Lands
|
||||
// Fetch Basic Lands based on piping
|
||||
const deckLands = this.generateBasicLands(deckSpells, basicLands, 40 - deckSpells.length);
|
||||
|
||||
return [...deckSpells, ...deckLands];
|
||||
}
|
||||
|
||||
|
||||
// --- Helper: Find Best Pair ---
|
||||
|
||||
private static findBestColorPair(pool: Card[]): string[] {
|
||||
const colors = ['W', 'U', 'B', 'R', 'G'];
|
||||
const pairs: string[][] = [];
|
||||
|
||||
// Generating all unique pairs
|
||||
for (let i = 0; i < colors.length; i++) {
|
||||
for (let j = i + 1; j < colors.length; j++) {
|
||||
pairs.push([colors[i], colors[j]]);
|
||||
}
|
||||
}
|
||||
|
||||
let bestPair = ['W', 'U'];
|
||||
let maxScore = -1;
|
||||
|
||||
pairs.forEach(pair => {
|
||||
const score = this.evaluateColorPair(pool, pair);
|
||||
// console.log(`Pair ${pair.join('')} Score: ${score}`);
|
||||
if (score > maxScore) {
|
||||
maxScore = score;
|
||||
bestPair = pair;
|
||||
}
|
||||
});
|
||||
|
||||
return bestPair;
|
||||
}
|
||||
|
||||
private static evaluateColorPair(pool: Card[], pair: string[]): number {
|
||||
// Score based on:
|
||||
// 1. Quantity of playable cards in these colors
|
||||
// 2. Specific bonuses for Rares/Mythics
|
||||
|
||||
let score = 0;
|
||||
|
||||
pool.forEach(c => {
|
||||
// Skip lands for archetype selection power (mostly)
|
||||
if (this.isLand(c)) return;
|
||||
|
||||
const cardColors = c.colors || [];
|
||||
|
||||
// Artifacts count for everyone but less
|
||||
if (cardColors.length === 0) {
|
||||
score += 0.5;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if card fits in pair
|
||||
const fits = cardColors.every(col => pair.includes(col));
|
||||
if (!fits) return;
|
||||
|
||||
// Base score
|
||||
let cardVal = 1;
|
||||
|
||||
// Rarity Bonus
|
||||
if (c.rarity === 'uncommon') cardVal += 1.5;
|
||||
if (c.rarity === 'rare') cardVal += 3.5;
|
||||
if (c.rarity === 'mythic') cardVal += 4.5;
|
||||
|
||||
// Gold Card Bonus (Signpost) - If it uses BOTH colors, it's a strong signal
|
||||
if (cardColors.length === 2 && cardColors.includes(pair[0]) && cardColors.includes(pair[1])) {
|
||||
cardVal += 2;
|
||||
}
|
||||
|
||||
score += cardVal;
|
||||
});
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
// --- Helper: Card Scoring ---
|
||||
|
||||
private static calculateCardScore(c: Card, mainColors: string[]): number {
|
||||
let score = 0;
|
||||
|
||||
// 1. Rarity Base
|
||||
switch (c.rarity) {
|
||||
case 'mythic': score = 5.0; break;
|
||||
case 'rare': score = 4.0; break;
|
||||
case 'uncommon': score = 2.5; break;
|
||||
default: score = 1.0; break; // Common
|
||||
}
|
||||
|
||||
// 2. Removal Bonus (Heuristic based on type + text is hard, so just type for now)
|
||||
// Instants/Sorceries tend to be removal or interaction
|
||||
const typeLine = c.typeLine || c.type_line || '';
|
||||
if (typeLine.includes('Instant') || typeLine.includes('Sorcery')) {
|
||||
score += 0.5;
|
||||
}
|
||||
|
||||
// 3. Gold Card Synergy
|
||||
const colors = c.colors || [];
|
||||
if (colors.length > 1) {
|
||||
score += 0.5; // Multicolored cards are usually stronger rate-wise
|
||||
|
||||
// Bonus if it perfectly matches our main colors (Signpost)
|
||||
if (mainColors.length === 2 && colors.includes(mainColors[0]) && colors.includes(mainColors[1])) {
|
||||
score += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. CMC Check (Penalty for very high cost)
|
||||
if ((c.cmc || 0) > 6) score -= 0.5;
|
||||
|
||||
// 5. EDHREC Score (Mild Influence)
|
||||
// Rank 1000 => +2.0, Rank 5000 => +1.0
|
||||
// Formula: 3 * (1 - (rank/10000)) limited to 0
|
||||
if (c.edhrecRank !== undefined && c.edhrecRank !== null) {
|
||||
const rank = c.edhrecRank;
|
||||
if (rank < 10000) {
|
||||
score += (3 * (1 - (rank / 10000)));
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
// --- Helper: Lands ---
|
||||
|
||||
private static generateBasicLands(deckSpells: Card[], basicLandPool: Card[], countNeeded: number): Card[] {
|
||||
const deckLands: Card[] = [];
|
||||
if (countNeeded <= 0) return deckLands;
|
||||
|
||||
// Count pips
|
||||
const pips = { W: 0, U: 0, B: 0, R: 0, G: 0 };
|
||||
deckSpells.forEach(c => {
|
||||
const cost = c.mana_cost || c.manaCost || '';
|
||||
if (cost.includes('W')) pips.W += (cost.match(/W/g) || []).length;
|
||||
if (cost.includes('U')) pips.U += (cost.match(/U/g) || []).length;
|
||||
if (cost.includes('B')) pips.B += (cost.match(/B/g) || []).length;
|
||||
if (cost.includes('R')) pips.R += (cost.match(/R/g) || []).length;
|
||||
if (cost.includes('G')) pips.G += (cost.match(/G/g) || []).length;
|
||||
});
|
||||
|
||||
const totalPips = Object.values(pips).reduce((a, b) => a + b, 0) || 1;
|
||||
|
||||
// Allocate
|
||||
const allocation = {
|
||||
W: Math.round((pips.W / totalPips) * countNeeded),
|
||||
U: Math.round((pips.U / totalPips) * countNeeded),
|
||||
B: Math.round((pips.B / totalPips) * countNeeded),
|
||||
R: Math.round((pips.R / totalPips) * countNeeded),
|
||||
G: Math.round((pips.G / totalPips) * countNeeded),
|
||||
};
|
||||
|
||||
// Adjust for rounding errors
|
||||
let currentTotal = Object.values(allocation).reduce((a, b) => a + b, 0);
|
||||
|
||||
// 1. If we are short, add to the color with most pips
|
||||
while (currentTotal < countNeeded) {
|
||||
const topColor = Object.entries(allocation).sort((a, b) => b[1] - a[1])[0][0];
|
||||
allocation[topColor as keyof typeof allocation]++;
|
||||
currentTotal++;
|
||||
}
|
||||
|
||||
// 2. If we are over, subtract from the color with most lands (that has > 0)
|
||||
while (currentTotal > countNeeded) {
|
||||
const topColor = Object.entries(allocation).sort((a, b) => b[1] - a[1])[0][0];
|
||||
if (allocation[topColor as keyof typeof allocation] > 0) {
|
||||
allocation[topColor as keyof typeof allocation]--;
|
||||
currentTotal--;
|
||||
} else {
|
||||
// Fallback to remove from anyone
|
||||
const anyColor = Object.keys(allocation).find(k => allocation[k as keyof typeof allocation] > 0);
|
||||
if (anyColor) allocation[anyColor as keyof typeof allocation]--;
|
||||
currentTotal--;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Objects
|
||||
Object.entries(allocation).forEach(([color, qty]) => {
|
||||
if (qty <= 0) return;
|
||||
const landName = this.getBasicLandName(color);
|
||||
|
||||
// Find source
|
||||
let source = basicLandPool.find(l => l.name === landName)
|
||||
|| basicLandPool.find(l => l.name.includes(landName)); // Fuzzy
|
||||
|
||||
if (!source && basicLandPool.length > 0) source = basicLandPool[0]; // Fallback?
|
||||
|
||||
// If we have a source, clone it. If not, we might be in trouble but let's assume source exists or we make a dummy.
|
||||
for (let i = 0; i < qty; i++) {
|
||||
deckLands.push({
|
||||
...source!,
|
||||
name: landName, // Ensure correct name
|
||||
typeLine: `Basic Land — ${landName}`,
|
||||
id: `land-${color}-${Date.now()}-${Math.random().toString(36).substring(7)}`,
|
||||
isLandSource: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return deckLands;
|
||||
}
|
||||
|
||||
// --- Utilities ---
|
||||
|
||||
private static isLand(c: Card): boolean {
|
||||
const t = c.typeLine || c.type_line || '';
|
||||
return t.includes('Land');
|
||||
}
|
||||
|
||||
private static isBasicLand(c: Card): boolean {
|
||||
const t = c.typeLine || c.type_line || '';
|
||||
return t.includes('Basic Land');
|
||||
}
|
||||
|
||||
private static getBasicLandName(color: string): string {
|
||||
switch (color) {
|
||||
case 'W': return 'Plains';
|
||||
case 'U': return 'Island';
|
||||
case 'B': return 'Swamp';
|
||||
case 'R': return 'Mountain';
|
||||
case 'G': return 'Forest';
|
||||
default: return 'Wastes';
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/client/src/utils/AutoPicker.ts
Normal file
102
src/client/src/utils/AutoPicker.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
|
||||
interface Card {
|
||||
id: string;
|
||||
name: string;
|
||||
manaCost?: string;
|
||||
typeLine?: string;
|
||||
type_line?: string;
|
||||
colors?: string[];
|
||||
colorIdentity?: string[];
|
||||
rarity?: string;
|
||||
cmc?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export class AutoPicker {
|
||||
|
||||
static async pickBestCardAsync(pack: Card[], pool: Card[]): Promise<Card | null> {
|
||||
if (!pack || pack.length === 0) return null;
|
||||
|
||||
console.log('[AutoPicker] 🧠 Calculating Heuristic Pick...');
|
||||
// 1. Calculate Heuristic (Local)
|
||||
console.log(`[AutoPicker] 🏁 Starting Best Card Calculation for pack of ${pack.length} cards...`);
|
||||
|
||||
// 1. Analyze Pool to find top 2 colors
|
||||
const colorCounts = { W: 0, U: 0, B: 0, R: 0, G: 0 };
|
||||
pool.forEach(card => {
|
||||
const weight = this.getRarityWeight(card.rarity);
|
||||
const colors = card.colors || [];
|
||||
colors.forEach(c => {
|
||||
if (colorCounts[c as keyof typeof colorCounts] !== undefined) {
|
||||
colorCounts[c as keyof typeof colorCounts] += weight;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const sortedColors = Object.entries(colorCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([color]) => color);
|
||||
const mainColors = sortedColors.slice(0, 2);
|
||||
|
||||
let bestCard: Card | null = null;
|
||||
let maxScore = -1;
|
||||
|
||||
pack.forEach(card => {
|
||||
let score = 0;
|
||||
score += this.getRarityWeight(card.rarity);
|
||||
const colors = card.colors || [];
|
||||
if (colors.length === 0) {
|
||||
score += 2;
|
||||
} else {
|
||||
const matches = colors.filter(c => mainColors.includes(c)).length;
|
||||
if (matches === colors.length) score += 4;
|
||||
else if (matches > 0) score += 1;
|
||||
else score -= 10;
|
||||
}
|
||||
if ((card.typeLine || card.type_line || '').includes('Basic Land')) score -= 20;
|
||||
if (score > maxScore) {
|
||||
maxScore = score;
|
||||
bestCard = card;
|
||||
}
|
||||
});
|
||||
|
||||
const heuristicPick = bestCard || pack[0];
|
||||
console.log(`[AutoPicker] 🤖 Heuristic Suggestion: ${heuristicPick.name} (Score: ${maxScore})`);
|
||||
|
||||
// 2. Call Server AI (Async)
|
||||
try {
|
||||
console.log('[AutoPicker] 📡 Sending context to Server AI...');
|
||||
const response = await fetch('/api/ai/pick', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pack,
|
||||
pool,
|
||||
suggestion: heuristicPick.id
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log(`[AutoPicker] ✅ Server AI Response: Pick ID ${data.pick}`);
|
||||
const pickedCard = pack.find(c => c.id === data.pick);
|
||||
return pickedCard || heuristicPick;
|
||||
} else {
|
||||
console.warn('[AutoPicker] ⚠️ Server AI Request failed, using heuristic.');
|
||||
return heuristicPick;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AutoPicker] ❌ Error contacting AI Server:', err);
|
||||
return heuristicPick;
|
||||
}
|
||||
}
|
||||
|
||||
private static getRarityWeight(rarity?: string): number {
|
||||
switch (rarity) {
|
||||
case 'mythic': return 5;
|
||||
case 'rare': return 4;
|
||||
case 'uncommon': return 2;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export const db = {
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = (event) => reject(transaction.error);
|
||||
transaction.onerror = (_event) => reject(transaction.error);
|
||||
|
||||
cards.forEach(card => store.put(card));
|
||||
});
|
||||
|
||||
67
src/client/src/utils/interaction.ts
Normal file
67
src/client/src/utils/interaction.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
onClick: () => void,
|
||||
cardPayload: any
|
||||
) {
|
||||
const timerRef = useRef<any>(null);
|
||||
const isLongPress = useRef(false);
|
||||
const touchStartCount = useRef(0);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchStartCount.current = e.touches.length;
|
||||
isLongPress.current = false;
|
||||
|
||||
// Start Preview Timer (1 finger is standard mobile long-press)
|
||||
if (e.touches.length === 1) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
isLongPress.current = true;
|
||||
onHover(cardPayload);
|
||||
}, 500); // 500ms threshold (standard long press)
|
||||
}
|
||||
}, [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);
|
||||
isLongPress.current = false;
|
||||
return;
|
||||
}
|
||||
}, [onHover]);
|
||||
|
||||
const handleTouchMove = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
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]);
|
||||
|
||||
return {
|
||||
onTouchStart: handleTouchStart,
|
||||
onTouchEnd: handleTouchEnd,
|
||||
onTouchMove: handleTouchMove,
|
||||
onClick: handleClick
|
||||
};
|
||||
}
|
||||
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" />
|
||||
4257
src/package-lock.json
generated
4257
src/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,16 +11,24 @@
|
||||
"start": "NODE_ENV=production tsx server/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"ioredis": "^5.8.2",
|
||||
"lucide-react": "^0.475.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tsx": "^4.19.2"
|
||||
"tsx": "^4.19.2",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.1",
|
||||
@@ -32,4 +40,4 @@
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user