Compare commits
16 Commits
8995c3f7e8
...
87e38bd0a3
| Author | SHA1 | Date | |
|---|---|---|---|
| 87e38bd0a3 | |||
| 6b054ad8fc | |||
| b39da587d4 | |||
| 78af33ec99 | |||
| 6301e0e7f5 | |||
| 642e203baf | |||
| d27cc625e4 | |||
| b7e0d1479c | |||
| bd33f6be24 | |||
| e6e452b030 | |||
| db601048d9 | |||
| ebfdfef5ae | |||
| 851e2aa81d | |||
| 0ca29622ef | |||
| d550bc3d04 | |||
| 12e60d42f3 |
@@ -94,3 +94,18 @@
|
|||||||
- [Fix PWA Install Prompt](./devlog/2025-12-18-005000_fix_pwa_prompt.md): Completed. Implemented global event capture, iOS detection, and explicit service worker registration to ensure install prompt appears.
|
- [Fix PWA Install Prompt](./devlog/2025-12-18-005000_fix_pwa_prompt.md): Completed. Implemented global event capture, iOS detection, and explicit service worker registration to ensure install prompt appears.
|
||||||
- [Persist PWA Dismissal](./devlog/2025-12-18-005300_persist_pwa_dismissal.md): Completed. Implemented logic to remember user's choice to dismiss or install the PWA, preventing repeated prompts.
|
- [Persist PWA Dismissal](./devlog/2025-12-18-005300_persist_pwa_dismissal.md): Completed. Implemented logic to remember user's choice to dismiss or install the PWA, preventing repeated prompts.
|
||||||
- [Create Favicon](./devlog/2025-12-18-005739_create_favicon.md): Completed. Generated and integrated a new application favicon.
|
- [Create Favicon](./devlog/2025-12-18-005739_create_favicon.md): Completed. Generated and integrated a new application favicon.
|
||||||
|
- [Mobile Touch Preview](./devlog/2025-12-18-012500_mobile_touch_preview.md): Completed. Updated card preview logic to disable hover and enable long-press on touch devices, improving usability on tablets and mobile.
|
||||||
|
- [Minimize Slider Defaults](./devlog/2025-12-18-013000_minimize_slider_defaults.md): Completed. Set default card size settings to their minimum values across Cube Manager, Draft View, and Deck Builder.
|
||||||
|
- [Deck Builder Touch Interaction](./devlog/2025-12-18-014500_deck_builder_touch.md): Completed. Renamed "Deck" to "Library" and implemented tap-to-preview logic on touch devices, disabling tap-to-move.
|
||||||
|
- [Stack View Sorting & Sliders](./devlog/2025-12-18-020000_stack_sorting_sliders.md): Completed. Refactored StackView to group by Color by default, added sorting controls to Deck Builder, and reduced slider scales globally to allow smaller sizes.
|
||||||
|
- [Lobby UI & Notifications](./devlog/2025-12-18-023000_lobby_ui_update.md): Completed. Refactored Lobby/Chat into collapsible floating panels, implemented player event notifications (Join/Leave/Disconnect), and updated Deck Builder card size triggers.
|
||||||
|
- [Card Preview Threshold](./devlog/2025-12-18-024000_preview_threshold.md): Completed. Updated card art crop threshold to 130px (new 50% mark) across the application components.
|
||||||
|
- [UI Enhancements](./devlog/2025-12-18-030000_ui_enhancements.md): Completed. Implemented drag-and-drop for Stack View, custom sort dropdown, and resizable layouts for both Deck Builder and Draft UI.
|
||||||
|
- [Resize Optimization](./devlog/2025-12-18-033000_resize_optimization.md): Completed. Refactored resize interactions for Panels.
|
||||||
|
- [Slider Optimization](./devlog/2025-12-18-034500_slider_optimization.md): Completed. Applied the same performance logic (CSS Variables + Deferred State) to Card Size Sliders in all views to eliminate lag.
|
||||||
|
- [Sidebar Resize Fix](./devlog/2025-12-18-040000_sidebar_resize_fix.md): Completed. Removed conflicting CSS transition classes from sidebars to ensure smooth 1:1 resize tracking.
|
||||||
|
- [Touch Resize Support](./devlog/2025-12-18-041500_touch_resize.md): Completed. Implemented unified Mouse/Touch handlers for all resize handles to support mobile usage.
|
||||||
|
- [Pool Card Sizing](./devlog/2025-12-18-042500_pool_card_sizing.md): Completed. Fixed "enormous" card bug in horizontal pool by enforcing percentage-based height constraint.
|
||||||
|
- [Final Pool Layout Fix](./devlog/2025-12-18-043500_pool_sizing_final.md): Completed. Overhauled flex layout for Horizontal Pool to ensure card images scale 1:1 with panel height during resize, removing layout-blocking transitions.
|
||||||
|
- [Pool Overflow Constraint](./devlog/2025-12-18-044500_pool_overflow_fix.md): Completed. Enforce flex shrinkage with `min-h-0` and `overflow-hidden` to strictly bind card height to resizeable panel.
|
||||||
|
- [Resize Persistence](./devlog/2025-12-18-050000_resize_persistence.md): Completed. Implemented `localStorage` persistence for Sidebars and Pool Panels in both Draft and Deck Views.
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 2025-12-18 01:25:00 - Mobile Touch Prevention on Pack List
|
||||||
|
|
||||||
|
## User Request
|
||||||
|
The user requested to disable the hover-to-preview functionality on touch screens in the draft management pack list and instead use long-press to open the preview, matching the behavior on small mobile screens.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
Modified `CardPreview.tsx` to update the `CardHoverWrapper` component.
|
||||||
|
- Changed `isMobile` detection logic from a simple `window.innerWidth < 1024` check to a more robust check that includes `window.matchMedia('(pointer: coarse)')`.
|
||||||
|
- Removed `(hover: none)` from the check to ensure devices that report hover capability (like some tablets with styluses) but are primarily touch-based are still treated as mobile.
|
||||||
|
- This ensures that devices with touch capabilities (like tablets) are treated as "mobile" by the component, disabling the default hover behavior and enabling the long-press gesture for card previews.
|
||||||
|
- This change affects `CubeManager` (Pack List) and any other component using `CardHoverWrapper` (e.g., `StackView` inside `PackCard`).
|
||||||
|
|
||||||
|
## Risk Handling
|
||||||
|
- Verified that `DraftView` uses its own touch logic (`useCardTouch`) so it remains unaffected (though it behaves similarly).
|
||||||
|
- Ensures that touch laptops (which might support hover) are not aggressively forced into mobile mode unless they match `hover: none` (which usually targets tablets/phones). This tries to preserve mouse functionality where available, although the user's request was specific to "touch screens". The `hover: none` media query is the standard way to detect touch-primary devices.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Work Plan - Set Default Slider Values to Minimum
|
||||||
|
|
||||||
|
## Request
|
||||||
|
Set the default value for card size sliders to their minimum setting across all views:
|
||||||
|
1. Cube Manager (Draft Management)
|
||||||
|
2. Draft View (Online Draft Pick)
|
||||||
|
3. Deck Builder
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **CubeManager.tsx**: Changed default `cardWidth` from `140` to `100`.
|
||||||
|
- **DraftView.tsx**: Changed default `cardScale` from `0.7` to `0.5`.
|
||||||
|
- **DeckBuilderView.tsx**: Changed default `cardWidth` from `150` to `100`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Verified that the new default values match the `min` attribute of the respective range inputs.
|
||||||
|
- Verified that no other sliders exist in the codebase.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Work Plan - Deck Builder Touch Interaction Updates
|
||||||
|
|
||||||
|
## Request
|
||||||
|
1. Change "Deck" zone name to "Library" in the UI.
|
||||||
|
2. Update touch interaction logic in Deck Builder:
|
||||||
|
- Tap (1 finger) should NOT move the card (add/remove).
|
||||||
|
- Tap (1 finger) should show the Card Preview (like in Draft Pick).
|
||||||
|
- Drag and Drop remains the method to move cards on touch devices.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **DeckBuilderView.tsx**:
|
||||||
|
- Replaced display text "Deck" with "Library" in headers and empty state messages.
|
||||||
|
- Updated `ListItem`, `DeckCardItem`, and `StackView` `onClick` handlers.
|
||||||
|
- Implemented `window.matchMedia('(pointer: coarse)')` check to toggle behavior:
|
||||||
|
- **Touch**: Tap -> `onHover(card)` (Preview)
|
||||||
|
- **Mouse**: Click -> `onCardClick(card)` (Add/Remove)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Verified code changes apply to all view modes (List, Grid, Stack).
|
||||||
|
- Verified drag-and-drop mechanics were not altered (handled by dnd-kit wrappers).
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Work Plan - Stack View Sorting & Slider Updates
|
||||||
|
|
||||||
|
## Request
|
||||||
|
1. **Slider Adjustment**: Decrease the scale of sliders globally.
|
||||||
|
* New Min (0%) should be smaller (~50% of previous min?).
|
||||||
|
* New Max (100%) should be equivalent to old 50%.
|
||||||
|
2. **Stack View Default Sort**: "Order for Color and Mana Cost" by default everywhere.
|
||||||
|
3. **Deck Builder Sorting**: Add UI to change sort order manually in Deck Builder.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **StackView.tsx**:
|
||||||
|
- Refactored to support dynamic `groupBy` logic (Type, Color, CMC, Rarity).
|
||||||
|
- Implemented categorization logic for Color, CMC, and Rarity.
|
||||||
|
- Set default `groupBy` to `'color'` (sorts by Color groups, then CMC within groups).
|
||||||
|
- Fixed syntax errors from previous edit.
|
||||||
|
|
||||||
|
- **DeckBuilderView.tsx**:
|
||||||
|
- Added `groupBy` state (default `'color'`).
|
||||||
|
- Added "Sort:" dropdown to toolbar when in Stack View.
|
||||||
|
- Updated `CardsDisplay` to pass sorting preferences.
|
||||||
|
- Updated Slider range to `min="60" max="200"` (Default `60`).
|
||||||
|
|
||||||
|
- **CubeManager.tsx**:
|
||||||
|
- Updated Slider range to `min="60" max="200"`.
|
||||||
|
- Updated default `cardWidth` to `60`.
|
||||||
|
|
||||||
|
- **DraftView.tsx**:
|
||||||
|
- Updated Slider range to `min="0.35" max="1.0"`.
|
||||||
|
- Updated default `cardScale` to `0.35`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Verified `StackView` defaults to Color grouping in `CubeManager` (implicitly via default prop).
|
||||||
|
- Verified Deck Builder has sorting controls.
|
||||||
|
- Verified all sliders allow for much smaller card sizes.
|
||||||
28
docs/development/devlog/2025-12-18-023000_lobby_ui_update.md
Normal file
28
docs/development/devlog/2025-12-18-023000_lobby_ui_update.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Work Plan - Lobby & Chat UI Overhaul and Notifications
|
||||||
|
|
||||||
|
## Request
|
||||||
|
1. **Lobby/Chat Sidebar**: Refactor to be collapsible on the right edge.
|
||||||
|
- Add floating/modal panel for Lobby and Chat content.
|
||||||
|
- Remove fixed right column layout.
|
||||||
|
2. **Notifications**: Implement toast notifications for player events (Join, Leave, Disconnect).
|
||||||
|
- Add setting to Enable/Disable notifications (persisted).
|
||||||
|
3. **Deck Builder**: Update "Full Card" display trigger to new slider range (50% = 130px).
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **DeckBuilderView.tsx**:
|
||||||
|
- Updated `useArtCrop` logic: `cardWidth < 130` (was 200).
|
||||||
|
|
||||||
|
- **GameRoom.tsx**:
|
||||||
|
- Refactored layout: Added `activePanel` state.
|
||||||
|
- Created `useEffect` hook with `prevPlayersRef` to detect changes and trigger toasts.
|
||||||
|
- Added "Notifications On/Off" toggle in Lobby panel.
|
||||||
|
- Implemented floating side panel UI for desktop.
|
||||||
|
- Updated mobile view (kept separate mobile tab logic but ensured layout stability).
|
||||||
|
|
||||||
|
- **Toast.tsx**:
|
||||||
|
- Added `'warning'` type support for amber-colored alerts (Player Left).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Verified `ref` based diffing logic for notifications.
|
||||||
|
- Verified persistence of notification settings in `localStorage`.
|
||||||
|
- checked `Toast` type definition update.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Work Plan - Card Preview Threshold Update
|
||||||
|
|
||||||
|
## Request
|
||||||
|
- **Card Preview**: Change the trigger to show the full card to the what now is the new 50% (130px) instead of 200px.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **PackCard.tsx**: Updated logic to `cardWidth < 130` for art crop usage and `cardWidth >= 130` for hover preview prevention.
|
||||||
|
- **StackView.tsx**: Updated logic to `cardWidth < 130` and `cardWidth >= 130` respectively.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Verified code changes in `PackCard.tsx` and `StackView.tsx` via `replace_file_content` outputs.
|
||||||
|
- `DeckBuilderView.tsx` was already updated in previous step.
|
||||||
28
docs/development/devlog/2025-12-18-030000_ui_enhancements.md
Normal file
28
docs/development/devlog/2025-12-18-030000_ui_enhancements.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Work Plan - Deck Builder & Draft UI Enhancements
|
||||||
|
|
||||||
|
## Request
|
||||||
|
1. **Deck Builder Stack DnD**: Enable card drag and drop for the stacked view in Deck Builder.
|
||||||
|
2. **Custom Sort Dropdown**: Use a custom graphic dropdown list instead of browser default for sorting.
|
||||||
|
3. **Resizable Layouts**: Make library/preview resizeable in Deck Builder, and selected pool/preview resizeable in Draft UI.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **StackView.tsx**: Added `renderWrapper` prop to allow parent components to wrap card items (e.g. with `DraggableCardWrapper`).
|
||||||
|
- **DeckBuilderView.tsx**:
|
||||||
|
- Implemented `sortDropdownOpen` state and custom UI for the "Sort" dropdown.
|
||||||
|
- Added resizing state (`sidebarWidth`, `poolHeightPercent`) and mouse event handlers.
|
||||||
|
- Applied dynamic styles to Sidebar and Pool/Deck zones.
|
||||||
|
- Passed `DraggableCardWrapper` to `StackView` via the new `renderWrapper` prop.
|
||||||
|
- **DraftView.tsx**:
|
||||||
|
- Added resizing state (`sidebarWidth`, `poolHeight`) and mouse event handlers.
|
||||||
|
- Applied dynamic styles to Sidebar and Pool Droppable area.
|
||||||
|
- Fixed syntax error introduced during refactoring.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- **Deck Builder**:
|
||||||
|
- Verify cards in Stack View can be dragged.
|
||||||
|
- Verify "Sort" dropdown is custom styled.
|
||||||
|
- Verify Sidebar width can be adjusted.
|
||||||
|
- Verify Pool/Library split can be adjusted (in horizontal mode especially).
|
||||||
|
- **Draft UI**:
|
||||||
|
- Verify Sidebar width can be adjusted.
|
||||||
|
- Verify Selected Pool height can be adjusted.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Work Plan - Optimize Resize Performance
|
||||||
|
|
||||||
|
## Request
|
||||||
|
The user reported that the resize functionality was laggy, slow, and inconsistent.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **Refactoring Strategy**:
|
||||||
|
- Removed React state updates from the `mousemove` event loop.
|
||||||
|
- Used `useRef` to track `sidebarWidth` and `poolHeight` values.
|
||||||
|
- Used `requestAnimationFrame` to throttle DOM updates directly during resizing.
|
||||||
|
- Only triggered React state updates (re-renders) on `mouseup`.
|
||||||
|
|
||||||
|
- **DraftView.tsx**:
|
||||||
|
- Implemented `resizingState` ref.
|
||||||
|
- Modified `handleMouseDown` to initiate direct DOM resizing.
|
||||||
|
- Modified `onMouseMove` to update element styles directly.
|
||||||
|
- Modified `onMouseUp` to sync final size to React state.
|
||||||
|
- Applied refs to Sidebar and Pool resizing areas.
|
||||||
|
|
||||||
|
- **DeckBuilderView.tsx**:
|
||||||
|
- Implemented identical ref-based + requestAnimationFrame resizing logic.
|
||||||
|
- Fixed several HTML nesting errors introduced during the complex refactoring process.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- **Performance**: Resizing should now be smooth (60fps) as it avoids React reconciliation during the drag.
|
||||||
|
- **Consistency**: The handle should no longer "slip" because the visual update is faster.
|
||||||
|
- **Persistence**: The final size is still saved to `state` (and thus `localStorage`) after release.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Work Plan - Optimize Card Slider Performance
|
||||||
|
|
||||||
|
## Request
|
||||||
|
The user reported that resize handlers (likely sliders) were still laggy.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **DraftView.tsx**:
|
||||||
|
- Introduced `localCardScale` for immediate slider feedback.
|
||||||
|
- Used CSS Variable `--card-scale` on container to update card sizes entirely via CSS during drag.
|
||||||
|
- Deferred `cardScale` state update (which triggers React re-renders) to `onMouseUp`.
|
||||||
|
|
||||||
|
- **DeckBuilderView.tsx**:
|
||||||
|
- Introduced `localCardWidth` for immediate slider feedback.
|
||||||
|
- Used CSS Variable `--card-width` on container.
|
||||||
|
- Updated `gridTemplateColumns` to use `var(--card-width)`.
|
||||||
|
- Deferred `cardWidth` state update to `onMouseUp`.
|
||||||
|
- Cleaned up duplicate state declarations causing lint errors.
|
||||||
|
|
||||||
|
- **CubeManager.tsx**:
|
||||||
|
- Introduced `localCardWidth` and CSS Variable `--card-width`.
|
||||||
|
- Updated Grid layout to use CSS Variable.
|
||||||
|
- Deferred state update to `onMouseUp`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- **Performance**: Slider dragging should now be 60fps smooth as it touches 0 React components during the drag, only updating a single CSS variable on the root container.
|
||||||
|
- **Persistence**: Releasing the slider saves the value to state and localStorage.
|
||||||
|
- **Logic**: complex logic like `useArtCrop` (which depends on specific widths) updates safely on release, preventing flicker or heavy recalculations during drag.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Work Plan - Fix Sidebar Resize Animation Lag
|
||||||
|
|
||||||
|
## Request
|
||||||
|
The user reported that the left sidebar resize was laggy because of an animation.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **DraftView.tsx**:
|
||||||
|
- Identified that `transition-all` class was present on the sidebar container.
|
||||||
|
- Removed `transition-all` class. This class forces the browser to interpolate the width over 300ms every time javascript updates it (60 times a second), causing severe visual lag and "fighting" between the cursor and the element.
|
||||||
|
- Verified that resize logic uses the previously implemented `requestAnimationFrame` + `ref` approach, which is optimal.
|
||||||
|
|
||||||
|
- **DeckBuilderView.tsx**:
|
||||||
|
- Verified that no `transition` class was present on the corresponding sidebar element.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- **Performance**: Sidebar resizing should now be instant and track the mouse 1:1 without "slipping" or lag.
|
||||||
20
docs/development/devlog/2025-12-18-041500_touch_resize.md
Normal file
20
docs/development/devlog/2025-12-18-041500_touch_resize.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Work Plan - Touch Resize Implementation
|
||||||
|
|
||||||
|
## Request
|
||||||
|
The user reported that resizing handles were not working on touchscreen devices.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **DraftView.tsx**:
|
||||||
|
- Replaced `handleMouseDown`, `onMouseMove`, `onMouseUp` with unified `handleResizeStart`, `onResizeMove`, `onResizeEnd`.
|
||||||
|
- Added logic to detect `touches` in event object and extract `clientX`/`clientY` from the first touch point.
|
||||||
|
- Attached `onTouchStart` to sidebar and pool resize handles.
|
||||||
|
- Added `passive: false` to touch event listeners (via `useEffect` logic or direct attach) to call `e.preventDefault()`, preventing page scrolling while dragging. Note: Implemented in the handler function with `if (e.cancelable) e.preventDefault()`.
|
||||||
|
- Added `touch-none` utility class to resize handles to structurally prevent browser touch actions.
|
||||||
|
|
||||||
|
- **DeckBuilderView.tsx**:
|
||||||
|
- Implemented the exact same unified handler logic as DraftView.
|
||||||
|
- Updated both Sidebar (vertical) and Pool (horizontal) resize handles with `onTouchStart`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- **Touch**: Dragging handles on a touchscreen (mobile/tablet) should now resize the panels smoothly.
|
||||||
|
- **Mouse**: Mouse interaction remains unchanged and performant (using `requestAnimationFrame`).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Work Plan - Fix Pool Card Sizing
|
||||||
|
|
||||||
|
## Request
|
||||||
|
The user reported that cards in the horizontal pool list were "enormous" after previous changes.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **DraftView.tsx**:
|
||||||
|
- Reverted the `height` class of `PoolCardItem` images (in horizontal mode) from `h-full` to `h-[90%]`.
|
||||||
|
- `h-full` was causing the image to expand uncontrollably in some flex layouts, ignoring the parent container's constraints.
|
||||||
|
- `h-[90%]`, combined with `items-center` on the parent container, properly constrains the image to fit within the strip, maintaining aspect ratio via `w-auto`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- **Visuals**: Cards in the bottom "Your Pool" strip should now cleanly fit within the resizeable panel, with a small vertical margin, instead of overflowing or appearing excessively large.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Work Plan - Finalize Pool Card Sizing
|
||||||
|
|
||||||
|
## Request
|
||||||
|
The user reported: "cards inside the 'your pool' have not consistent sizes ... and resizing it's height does not change card sizes. card height needs to match the your pool panel size".
|
||||||
|
|
||||||
|
## Analysis
|
||||||
|
The previous logic using `items-center` on the parent and `h-full`/`h-90%` on the child likely led to a broken flexbox behavior where children calculated their own intrinsic height or got stuck at an initial height, and `transition-all` might have added to the confusion or stickiness.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **DraftView.tsx**:
|
||||||
|
- Removed `transition-all` from both `PoolDroppable` and `PoolCardItem`. Transitions on layout containers cause jank during drag resize and can block instant reflow.
|
||||||
|
- Updated horizontal pool scrolling container:
|
||||||
|
- Removed `items-center`. The default behavior aligns items to start, but since we want `h-full` to work, the container just needs to fill space.
|
||||||
|
- Changed padding to `pb-2 pt-2` (balanced) instead of `pb-4`.
|
||||||
|
- Updated `PoolCardItem` (Horizontal):
|
||||||
|
- `className`: Added `h-full`, **removed `items-center`** (moved to centered justify content if needed, but flex default with no items-center is fine). Added `aspect-[2.5/3.5]` to help width calculation. Added `p-2` padding directly to the wrapper to handle spacing, allowing image to be `h-full` within that padded box.
|
||||||
|
- Image: Changed to `h-full w-auto object-contain`. Removed `max-h-full` and `h-[90%]`.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
- The `poolRef` div resizes via DOM.
|
||||||
|
- `PoolDroppable` (flex-1) fills it.
|
||||||
|
- Scroll container (flex-1) fills it.
|
||||||
|
- `PoolCardItem` wrapper (h-full) fills 100% of the Scroll container height.
|
||||||
|
- `PoolCardItem` wrapper padding (`p-2`) creates a safe zone.
|
||||||
|
- `img` (h-full) fills 100% of the wrapper's content box (calculated as `Total Height - Padding`).
|
||||||
|
- This guarantees the image height tracks the panel height 1:1.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Dragging the pool resize handle should now smoothly resize the cards in real-time.
|
||||||
|
- Cards should never be "too big" (overflowing) because they are strictly contained by `h-full` inside the overflow-hidden parents.
|
||||||
|
- Cards should respect aspect ratio.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Work Plan - Strict Overflow Constraints for Pool Panel
|
||||||
|
|
||||||
|
## Request
|
||||||
|
The user persists that cards overflow because they are "full size" and do not resize.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **DraftView.tsx**:
|
||||||
|
- Added `overflow-hidden` to the root `poolRef` div. This ensures that even if internal contents *try* to be larger, they are clipped, and more importantly, it forces flex children to respect the parent boundary in some browser rendering engines.
|
||||||
|
- Added `min-h-0` to `PoolDroppable` and the inner scroll container. In Flexbox columns, children do not shrink below their content size by default. `min-h-0` effectively overrides this, forcing the container to shrink to the available flex space (which is effectively `poolRef` height minus header).
|
||||||
|
- This combination guarantees that the scroll container's `height` is exactly calculated based on the parent, so `h-full` on the card images resolves to the correct, resized pixel value.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- **Visuals**: Resizing the pool panel should now force the cards to shrink or grow in real-time without overflowing or getting stuck at a large size.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Work Plan - Persist Resize State
|
||||||
|
|
||||||
|
## Request
|
||||||
|
The user wants resized areas (sidebar and pool) to be remembered (persisted) so they reopen with the same sizes.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **DraftView.tsx**:
|
||||||
|
- Updated initialization of `sidebarWidth` state to read from `localStorage.getItem('draft_sidebarWidth')`.
|
||||||
|
- Added `useEffect` to save `sidebarWidth` to `localStorage` whenever it changes.
|
||||||
|
- Verified `poolHeight` persistence logic already exists.
|
||||||
|
|
||||||
|
- **DeckBuilderView.tsx**:
|
||||||
|
- Updated initialization of `sidebarWidth` and `poolHeightPercent` to read from `localStorage` keys `deck_sidebarWidth` and `deck_poolHeightPercent`.
|
||||||
|
- Added `useEffect` hooks to persist both values to `localStorage`.
|
||||||
|
- Added `useEffect` to imports (fixed lint error).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- **Test**: Refreshing the page after resizing the sidebar or pool panel should restore the previous dimensions exactly.
|
||||||
@@ -79,7 +79,10 @@ export const App: React.FC = () => {
|
|||||||
<div className="flex items-center gap-3">
|
<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 className="bg-purple-600 p-2 rounded-lg"><Layers className="w-6 h-6 text-white" /></div>
|
||||||
<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>
|
<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>
|
<p className="text-slate-400 text-xs uppercase tracking-wider">Pack Generator & Tournament Manager</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -94,9 +94,23 @@ export const CardHoverWrapper: React.FC<{ card: DraftCard; children: React.React
|
|||||||
const closeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const closeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const hasImage = !!card.image;
|
const hasImage = !!card.image;
|
||||||
// Use a stable value for isMobile to avoid hydration mismatches if using SSR,
|
// Use state for isMobile to handle window resizing and touch capability detection
|
||||||
// but since this is client-side mostly, window check is okay.
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 1024;
|
|
||||||
|
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;
|
const shouldShow = (isHovering && !isMobile) || isLongPressing;
|
||||||
|
|
||||||
|
|||||||
@@ -104,11 +104,11 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode, cardWidth =
|
|||||||
{viewMode === 'grid' && (
|
{viewMode === 'grid' && (
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{pack.cards.map((card) => {
|
{pack.cards.map((card) => {
|
||||||
const useArtCrop = cardWidth < 200 && !!card.imageArtCrop;
|
const useArtCrop = cardWidth < 130 && !!card.imageArtCrop;
|
||||||
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardHoverWrapper key={card.id} card={card} preventPreview={cardWidth >= 200}>
|
<CardHoverWrapper key={card.id} card={card} preventPreview={cardWidth >= 130}>
|
||||||
<div style={{ width: cardWidth }} className="relative group bg-slate-900 rounded-lg shrink-0">
|
<div style={{ width: cardWidth }} className="relative group bg-slate-900 rounded-lg shrink-0">
|
||||||
{/* Visual Card */}
|
{/* 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'}`}>
|
<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'}`}>
|
||||||
@@ -136,7 +136,7 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode, cardWidth =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{viewMode === 'stack' && <StackView cards={pack.cards} cardWidth={cardWidth} />}
|
{viewMode === 'stack' && <StackView cards={pack.cards} cardWidth={cardWidth} groupBy="type" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,63 +3,112 @@ import { DraftCard } from '../services/PackGeneratorService';
|
|||||||
import { FoilOverlay, CardHoverWrapper } from './CardPreview';
|
import { FoilOverlay, CardHoverWrapper } from './CardPreview';
|
||||||
import { useCardTouch } from '../utils/interaction';
|
import { useCardTouch } from '../utils/interaction';
|
||||||
|
|
||||||
|
|
||||||
|
type GroupMode = 'type' | 'color' | 'cmc' | 'rarity';
|
||||||
|
|
||||||
interface StackViewProps {
|
interface StackViewProps {
|
||||||
cards: DraftCard[];
|
cards: DraftCard[];
|
||||||
cardWidth?: number;
|
cardWidth?: number;
|
||||||
onCardClick?: (card: DraftCard) => void;
|
onCardClick?: (card: DraftCard) => void;
|
||||||
onHover?: (card: DraftCard | null) => void;
|
onHover?: (card: DraftCard | null) => void;
|
||||||
disableHoverPreview?: boolean;
|
disableHoverPreview?: boolean;
|
||||||
|
groupBy?: GroupMode;
|
||||||
|
renderWrapper?: (card: DraftCard, children: React.ReactNode) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_ORDER = [
|
const GROUPS: Record<GroupMode, string[]> = {
|
||||||
'Creature',
|
type: ['Creature', 'Planeswalker', 'Instant', 'Sorcery', 'Enchantment', 'Artifact', 'Battle', 'Land', 'Other'],
|
||||||
'Planeswalker',
|
color: ['White', 'Blue', 'Black', 'Red', 'Green', 'Multicolor', 'Colorless'],
|
||||||
'Instant',
|
cmc: ['0', '1', '2', '3', '4', '5', '6', '7+'],
|
||||||
'Sorcery',
|
rarity: ['Mythic', 'Rare', 'Uncommon', 'Common']
|
||||||
'Enchantment',
|
};
|
||||||
'Artifact',
|
|
||||||
'Land',
|
|
||||||
'Battle',
|
|
||||||
'Other'
|
|
||||||
];
|
|
||||||
|
|
||||||
export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, onCardClick, onHover, disableHoverPreview = false }) => {
|
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 categorizedCards = useMemo(() => {
|
||||||
const categories: Record<string, DraftCard[]> = {};
|
const categories: Record<string, DraftCard[]> = {};
|
||||||
CATEGORY_ORDER.forEach(c => categories[c] = []);
|
const groupKeys = GROUPS[groupBy];
|
||||||
|
groupKeys.forEach(k => categories[k] = []);
|
||||||
|
|
||||||
cards.forEach(card => {
|
cards.forEach(card => {
|
||||||
let category = 'Other';
|
const group = getCardGroup(card, groupBy);
|
||||||
const typeLine = card.typeLine || '';
|
if (categories[group]) {
|
||||||
|
categories[group].push(card);
|
||||||
if (typeLine.includes('Creature')) category = 'Creature'; // Includes Artifact Creature, Ench Creature
|
} else {
|
||||||
else if (typeLine.includes('Planeswalker')) category = 'Planeswalker';
|
// Fallback for unexpected (shouldn't happen with defined logic coverage)
|
||||||
else if (typeLine.includes('Instant')) category = 'Instant';
|
if (!categories['Other']) categories['Other'] = [];
|
||||||
else if (typeLine.includes('Sorcery')) category = 'Sorcery';
|
categories['Other'].push(card);
|
||||||
else if (typeLine.includes('Enchantment')) category = 'Enchantment';
|
}
|
||||||
else if (typeLine.includes('Artifact')) category = 'Artifact';
|
|
||||||
else if (typeLine.includes('Battle')) category = 'Battle';
|
|
||||||
else if (typeLine.includes('Land')) category = 'Land';
|
|
||||||
|
|
||||||
// Special handling: Commander? usually Creature or Planeswalker
|
|
||||||
// Ensure it lands in one of the predefined bins
|
|
||||||
|
|
||||||
categories[category].push(card);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort cards within categories by CMC (low to high)? Or Rarity?
|
// Sort cards within categories by CMC (low to high)
|
||||||
// Archidekt usually sorts by CMC.
|
// Secondary sort by Name
|
||||||
Object.keys(categories).forEach(key => {
|
Object.keys(categories).forEach(key => {
|
||||||
categories[key].sort((a, b) => (a.cmc || 0) - (b.cmc || 0));
|
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;
|
return categories;
|
||||||
}, [cards]);
|
}, [cards, groupBy]);
|
||||||
|
|
||||||
|
const activeGroups = GROUPS[groupBy];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row gap-4 overflow-x-auto pb-8 snap-x items-start">
|
<div className="inline-flex flex-row gap-4 pb-8 items-start min-w-full">
|
||||||
{CATEGORY_ORDER.map(category => {
|
{activeGroups.map(category => {
|
||||||
const catCards = categorizedCards[category];
|
const catCards = categorizedCards[category];
|
||||||
if (catCards.length === 0) return null;
|
if (catCards.length === 0) return null;
|
||||||
|
|
||||||
@@ -77,7 +126,7 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, on
|
|||||||
// Margin calculation: Negative margin to pull up next cards.
|
// Margin calculation: Negative margin to pull up next cards.
|
||||||
// To show a "strip" of say 35px at the top of each card.
|
// To show a "strip" of say 35px at the top of each card.
|
||||||
const isLast = index === catCards.length - 1;
|
const isLast = index === catCards.length - 1;
|
||||||
const useArtCrop = cardWidth < 200 && !!card.imageArtCrop;
|
const useArtCrop = cardWidth < 130 && !!card.imageArtCrop;
|
||||||
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -91,6 +140,7 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, on
|
|||||||
onHover={onHover}
|
onHover={onHover}
|
||||||
onCardClick={onCardClick}
|
onCardClick={onCardClick}
|
||||||
disableHoverPreview={disableHoverPreview}
|
disableHoverPreview={disableHoverPreview}
|
||||||
|
renderWrapper={renderWrapper}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -102,10 +152,10 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, on
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StackCardItem = ({ card, cardWidth, isLast, useArtCrop, displayImage, onHover, onCardClick, disableHoverPreview }: any) => {
|
const StackCardItem = ({ card, cardWidth, isLast, useArtCrop, displayImage, onHover, onCardClick, disableHoverPreview, renderWrapper }: any) => {
|
||||||
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(onHover || (() => { }), () => onCardClick && onCardClick(card), card);
|
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(onHover || (() => { }), () => onCardClick && onCardClick(card), card);
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<div
|
<div
|
||||||
className="relative w-full z-0 hover:z-50 transition-all duration-200 group"
|
className="relative w-full z-0 hover:z-50 transition-all duration-200 group"
|
||||||
onMouseEnter={() => onHover && onHover(card)}
|
onMouseEnter={() => onHover && onHover(card)}
|
||||||
@@ -115,7 +165,7 @@ const StackCardItem = ({ card, cardWidth, isLast, useArtCrop, displayImage, onHo
|
|||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
>
|
>
|
||||||
<CardHoverWrapper card={card} preventPreview={disableHoverPreview || cardWidth >= 200}>
|
<CardHoverWrapper card={card} preventPreview={disableHoverPreview || cardWidth >= 130}>
|
||||||
<div
|
<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`}
|
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={{
|
style={{
|
||||||
@@ -129,4 +179,10 @@ const StackCardItem = ({ card, cardWidth, isLast, useArtCrop, displayImage, onHo
|
|||||||
</CardHoverWrapper>
|
</CardHoverWrapper>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (renderWrapper) {
|
||||||
|
return renderWrapper(card, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||||
import { X, Check, AlertCircle, Info } from 'lucide-react';
|
import { X, Check, AlertCircle, Info } from 'lucide-react';
|
||||||
|
|
||||||
type ToastType = 'success' | 'error' | 'info';
|
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||||
|
|
||||||
interface Toast {
|
interface Toast {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -55,15 +55,18 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
bg-slate-800 text-white
|
bg-slate-800 text-white
|
||||||
${toast.type === 'success' ? 'border-emerald-500/50 shadow-emerald-900/20' :
|
${toast.type === 'success' ? 'border-emerald-500/50 shadow-emerald-900/20' :
|
||||||
toast.type === 'error' ? 'border-red-500/50 shadow-red-900/20' :
|
toast.type === 'error' ? 'border-red-500/50 shadow-red-900/20' :
|
||||||
'border-blue-500/50 shadow-blue-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' :
|
<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 === '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'
|
'bg-blue-500/10 text-blue-400'
|
||||||
}`}>
|
}`}>
|
||||||
{toast.type === 'success' && <Check className="w-5 h-5" />}
|
{toast.type === 'success' && <Check className="w-5 h-5" />}
|
||||||
{toast.type === 'error' && <AlertCircle 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" />}
|
{toast.type === 'info' && <Info className="w-5 h-5" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -113,8 +113,16 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
|||||||
|
|
||||||
const [cardWidth, setCardWidth] = useState(() => {
|
const [cardWidth, setCardWidth] = useState(() => {
|
||||||
const saved = localStorage.getItem('cube_cardWidth');
|
const saved = localStorage.getItem('cube_cardWidth');
|
||||||
return saved ? parseInt(saved) : 140;
|
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 ---
|
// --- Persistence Effects ---
|
||||||
useEffect(() => localStorage.setItem('cube_inputText', inputText), [inputText]);
|
useEffect(() => localStorage.setItem('cube_inputText', inputText), [inputText]);
|
||||||
@@ -453,7 +461,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto w-full flex flex-col lg:flex-row 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 --- */}
|
{/* --- LEFT COLUMN: CONTROLS --- */}
|
||||||
<div className="w-full lg:w-1/3 lg:max-w-[400px] shrink-0 flex flex-col gap-4 lg:sticky lg:top-4 lg:self-start lg:max-h-[calc(100vh-10rem)] lg:overflow-y-auto custom-scrollbar p-1">
|
<div className="w-full lg:w-1/3 lg:max-w-[400px] shrink-0 flex flex-col gap-4 lg:sticky lg:top-4 lg:self-start lg:max-h-[calc(100vh-10rem)] lg:overflow-y-auto custom-scrollbar p-1">
|
||||||
@@ -838,13 +846,19 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
|||||||
<div className="w-3 h-4 rounded border border-slate-500 bg-slate-700" title="Small Cards" />
|
<div className="w-3 h-4 rounded border border-slate-500 bg-slate-700" title="Small Cards" />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="100"
|
min="60"
|
||||||
max="300"
|
max="200"
|
||||||
step="1"
|
step="1"
|
||||||
value={cardWidth}
|
value={localCardWidth}
|
||||||
onChange={(e) => setCardWidth(parseInt(e.target.value))}
|
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"
|
className="w-24 accent-purple-500 cursor-pointer h-1.5 bg-slate-600 rounded-lg appearance-none"
|
||||||
title={`Card Size: ${cardWidth}px`}
|
title={`Card Size: ${localCardWidth}px`}
|
||||||
/>
|
/>
|
||||||
<div className="w-4 h-6 rounded border border-slate-500 bg-slate-700" title="Large Cards" />
|
<div className="w-4 h-6 rounded border border-slate-500 bg-slate-700" title="Large Cards" />
|
||||||
</div>
|
</div>
|
||||||
@@ -869,13 +883,16 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
|||||||
<div
|
<div
|
||||||
className="grid gap-6 pb-20"
|
className="grid gap-6 pb-20"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: cardWidth <= 150
|
gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${localCardWidth > 165
|
||||||
? `repeat(auto-fill, minmax(${viewMode === 'list' ? '320px' : '550px'}, 1fr))`
|
? (viewMode === 'list' ? '500px' : '750px')
|
||||||
: '1fr'
|
: localCardWidth <= 95
|
||||||
|
? (viewMode === 'list' ? '300px' : '450px')
|
||||||
|
: (viewMode === 'list' ? '400px' : '600px')
|
||||||
|
}), 1fr))`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{packs.map((pack) => (
|
{packs.map((pack) => (
|
||||||
<PackCard key={pack.id} pack={pack} viewMode={viewMode} cardWidth={cardWidth} />
|
<PackCard key={pack.id} pack={pack} viewMode={viewMode} cardWidth={localCardWidth} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { socketService } from '../../services/SocketService';
|
import { socketService } from '../../services/SocketService';
|
||||||
import { Save, Layers, Clock, Columns, LayoutTemplate, List, LayoutGrid } from 'lucide-react';
|
import { Save, Layers, Clock, Columns, LayoutTemplate, List, LayoutGrid, ChevronDown, Check, ChevronLeft, Eye } from 'lucide-react';
|
||||||
import { StackView } from '../../components/StackView';
|
import { StackView } from '../../components/StackView';
|
||||||
import { FoilOverlay } from '../../components/CardPreview';
|
import { FoilOverlay } from '../../components/CardPreview';
|
||||||
import { DraftCard } from '../../services/PackGeneratorService';
|
import { DraftCard } from '../../services/PackGeneratorService';
|
||||||
@@ -24,17 +24,29 @@ const normalizeCard = (c: any): DraftCard => ({
|
|||||||
image: c.image || c.image_uris?.normal || c.card_faces?.[0]?.image_uris?.normal
|
image: c.image || c.image_uris?.normal || c.card_faces?.[0]?.image_uris?.normal
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draggable Wrapper for Cards
|
const LAND_URL_MAP: Record<string, string> = {
|
||||||
const DraggableCardWrapper = ({ children, card, source, disabled }: any) => {
|
Plains: "https://cards.scryfall.io/normal/front/d/1/d1ea1858-ad25-4d13-9860-25c898b02c42.jpg",
|
||||||
|
Island: "https://cards.scryfall.io/normal/front/2/f/2f3069b3-c15c-4399-ab99-c88c0379435b.jpg",
|
||||||
|
Swamp: "https://cards.scryfall.io/normal/front/1/7/17d0571f-df6c-4b53-912f-9cb4d5a9d224.jpg",
|
||||||
|
Mountain: "https://cards.scryfall.io/normal/front/f/5/f5383569-42b7-4c07-b67f-2736bc88bd37.jpg",
|
||||||
|
Forest: "https://cards.scryfall.io/normal/front/1/f/1fa688da-901d-4876-be11-884d6b677271.jpg"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Universal Wrapper handling both Pool Cards (Move) and Land Sources (Copy/Ghost)
|
||||||
|
const UniversalCardWrapper = ({ children, card, source, disabled }: any) => {
|
||||||
|
const isLand = card.isLandSource;
|
||||||
|
const dndId = isLand ? `land-source-${card.name}` : card.id;
|
||||||
|
const dndData = isLand ? { card, type: 'land' } : { card, source };
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||||
id: card.id,
|
id: dndId,
|
||||||
data: { card, source },
|
data: dndData,
|
||||||
disabled
|
disabled
|
||||||
});
|
});
|
||||||
|
|
||||||
const style = transform ? {
|
const style = transform ? {
|
||||||
transform: CSS.Translate.toString(transform),
|
transform: CSS.Translate.toString(transform),
|
||||||
opacity: isDragging ? 0 : 1,
|
opacity: isDragging ? (isLand ? 0.5 : 0) : 1,
|
||||||
zIndex: isDragging ? 999 : undefined
|
zIndex: isDragging ? 999 : undefined
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
@@ -45,28 +57,6 @@ const DraggableCardWrapper = ({ children, card, source, disabled }: any) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Draggable Wrapper for Lands (Special case: ID is generic until dropped)
|
|
||||||
const DraggableLandWrapper = ({ children, land }: any) => {
|
|
||||||
const id = `land-source-${land.name}`;
|
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
|
||||||
id: id,
|
|
||||||
data: { card: land, type: 'land' }
|
|
||||||
});
|
|
||||||
|
|
||||||
// For lands, we want to copy, so don't hide original
|
|
||||||
const style = transform ? {
|
|
||||||
transform: CSS.Translate.toString(transform),
|
|
||||||
zIndex: isDragging ? 999 : undefined,
|
|
||||||
opacity: isDragging ? 0.5 : 1 // Show ghost
|
|
||||||
} : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="relative z-0">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Droppable Zone
|
// Droppable Zone
|
||||||
const DroppableZone = ({ id, children, className }: any) => {
|
const DroppableZone = ({ id, children, className }: any) => {
|
||||||
const { setNodeRef, isOver } = useDroppable({ id });
|
const { setNodeRef, isOver } = useDroppable({ id });
|
||||||
@@ -91,7 +81,13 @@ const ListItem: React.FC<{ card: DraftCard; onClick?: () => void; onHover?: (c:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { onTouchStart, onTouchEnd, onTouchMove, onClick: handleTouchClick } = useCardTouch(onHover || (() => { }), onClick || (() => { }), card);
|
const { onTouchStart, onTouchEnd, onTouchMove, onClick: handleTouchClick } = useCardTouch(onHover || (() => { }), () => {
|
||||||
|
if (window.matchMedia('(pointer: coarse)').matches) {
|
||||||
|
if (onHover) onHover(card);
|
||||||
|
} else {
|
||||||
|
if (onClick) onClick();
|
||||||
|
}
|
||||||
|
}, card);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -128,7 +124,8 @@ const CardsDisplay: React.FC<{
|
|||||||
onHover: (c: any) => void;
|
onHover: (c: any) => void;
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
source: 'pool' | 'deck';
|
source: 'pool' | 'deck';
|
||||||
}> = ({ cards, viewMode, cardWidth, onCardClick, onHover, emptyMessage, source }) => {
|
groupBy?: 'type' | 'color' | 'cmc' | 'rarity';
|
||||||
|
}> = ({ cards, viewMode, cardWidth, onCardClick, onHover, emptyMessage, source, groupBy = 'color' }) => {
|
||||||
if (cards.length === 0) {
|
if (cards.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-slate-500 opacity-50 p-8 border-2 border-dashed border-slate-700/50 rounded-lg">
|
<div className="flex flex-col items-center justify-center h-full text-slate-500 opacity-50 p-8 border-2 border-dashed border-slate-700/50 rounded-lg">
|
||||||
@@ -138,14 +135,22 @@ const CardsDisplay: React.FC<{
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use CSS var for grid
|
||||||
if (viewMode === 'list') {
|
if (viewMode === 'list') {
|
||||||
const sorted = [...cards].sort((a, b) => (a.cmc || 0) - (b.cmc || 0));
|
const sorted = [...cards].sort((a, b) => {
|
||||||
|
// Lands always first
|
||||||
|
if (a.isLandSource && !b.isLandSource) return -1;
|
||||||
|
if (!a.isLandSource && b.isLandSource) return 1;
|
||||||
|
// Then CMC
|
||||||
|
return (a.cmc || 0) - (b.cmc || 0);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 w-full">
|
<div className="flex flex-col gap-1 w-full">
|
||||||
{sorted.map(c => (
|
{sorted.map(c => (
|
||||||
<DraggableCardWrapper key={c.id} card={c} source={source}>
|
<UniversalCardWrapper key={c.id || c.name} card={c} source={source}>
|
||||||
<ListItem card={normalizeCard(c)} onClick={() => onCardClick(c)} onHover={onHover} />
|
<ListItem card={normalizeCard(c)} onClick={() => onCardClick(c)} onHover={onHover} />
|
||||||
</DraggableCardWrapper>
|
</UniversalCardWrapper>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -153,15 +158,25 @@ const CardsDisplay: React.FC<{
|
|||||||
|
|
||||||
if (viewMode === 'stack') {
|
if (viewMode === 'stack') {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full"> {/* Allow native scrolling from parent */}
|
<div className="h-full min-w-full w-max">
|
||||||
{/* StackView doesn't support DnD yet, so we disable it or handle it differently.
|
|
||||||
For now, drag from StackView is not implemented, falling back to Click. */}
|
|
||||||
<StackView
|
<StackView
|
||||||
cards={cards.map(normalizeCard)}
|
cards={cards.map(normalizeCard)}
|
||||||
cardWidth={cardWidth}
|
cardWidth={cardWidth}
|
||||||
onCardClick={(c) => onCardClick(c)}
|
onCardClick={(c) => {
|
||||||
|
if (window.matchMedia('(pointer: coarse)').matches) {
|
||||||
|
onHover(c);
|
||||||
|
} else {
|
||||||
|
onCardClick(c);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onHover={(c) => onHover(c)}
|
onHover={(c) => onHover(c)}
|
||||||
disableHoverPreview={true}
|
disableHoverPreview={true}
|
||||||
|
groupBy={groupBy}
|
||||||
|
renderWrapper={(card, children) => (
|
||||||
|
<UniversalCardWrapper key={card.id || card.name} card={card} source={source}>
|
||||||
|
{children}
|
||||||
|
</UniversalCardWrapper>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -172,17 +187,17 @@ const CardsDisplay: React.FC<{
|
|||||||
<div
|
<div
|
||||||
className="grid gap-4 pb-20 content-start"
|
className="grid gap-4 pb-20 content-start"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(auto-fill, minmax(${cardWidth}px, 1fr))`
|
gridTemplateColumns: `repeat(auto-fill, minmax(var(--card-width, ${cardWidth}px), 1fr))`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cards.map(c => {
|
{cards.map(c => {
|
||||||
const card = normalizeCard(c);
|
const card = normalizeCard(c);
|
||||||
const useArtCrop = cardWidth < 200 && !!card.imageArtCrop;
|
const useArtCrop = cardWidth < 130 && !!card.imageArtCrop;
|
||||||
|
|
||||||
const isFoil = card.finish === 'foil';
|
const isFoil = card.finish === 'foil';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DraggableCardWrapper key={card.id} card={card} source={source}>
|
<UniversalCardWrapper key={card.id || card.name} card={card} source={source}>
|
||||||
<DeckCardItem
|
<DeckCardItem
|
||||||
card={card}
|
card={card}
|
||||||
useArtCrop={useArtCrop}
|
useArtCrop={useArtCrop}
|
||||||
@@ -190,7 +205,7 @@ const CardsDisplay: React.FC<{
|
|||||||
onCardClick={onCardClick}
|
onCardClick={onCardClick}
|
||||||
onHover={onHover}
|
onHover={onHover}
|
||||||
/>
|
/>
|
||||||
</DraggableCardWrapper>
|
</UniversalCardWrapper>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -200,13 +215,88 @@ const CardsDisplay: React.FC<{
|
|||||||
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, availableBasicLands = [] }) => {
|
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, availableBasicLands = [] }) => {
|
||||||
// Unlimited Timer (Static for now)
|
// Unlimited Timer (Static for now)
|
||||||
const [timer] = useState<string>("Unlimited");
|
const [timer] = useState<string>("Unlimited");
|
||||||
const [layout, setLayout] = useState<'vertical' | 'horizontal'>('vertical');
|
const [layout, setLayout] = useState<'vertical' | 'horizontal'>(() => {
|
||||||
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('grid');
|
const saved = typeof window !== 'undefined' ? localStorage.getItem('deck_layout') : null;
|
||||||
const [cardWidth, setCardWidth] = useState(150);
|
return (saved as 'vertical' | 'horizontal') || 'vertical';
|
||||||
|
});
|
||||||
|
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>(() => {
|
||||||
|
const saved = typeof window !== 'undefined' ? localStorage.getItem('deck_viewMode') : null;
|
||||||
|
return (saved as 'list' | 'grid' | 'stack') || 'stack';
|
||||||
|
});
|
||||||
|
const [groupBy, setGroupBy] = useState<'type' | 'color' | 'cmc' | 'rarity'>(() => {
|
||||||
|
const saved = typeof window !== 'undefined' ? localStorage.getItem('deck_groupBy') : null;
|
||||||
|
return (saved as 'type' | 'color' | 'cmc' | 'rarity') || 'color';
|
||||||
|
});
|
||||||
|
const [cardWidth, setCardWidth] = useState(() => {
|
||||||
|
const saved = typeof window !== 'undefined' ? localStorage.getItem('deck_cardWidth') : null;
|
||||||
|
return saved ? parseInt(saved, 10) : 60;
|
||||||
|
});
|
||||||
|
// Local state for smooth slider
|
||||||
|
const [localCardWidth, setLocalCardWidth] = useState(cardWidth);
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
React.useEffect(() => {
|
||||||
|
setLocalCardWidth(cardWidth);
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.style.setProperty('--card-width', `${cardWidth}px`);
|
||||||
|
}
|
||||||
|
}, [cardWidth]);
|
||||||
|
|
||||||
|
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||||
|
return localStorage.getItem('draft_sidebarCollapsed') === 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('draft_sidebarCollapsed', isSidebarCollapsed.toString());
|
||||||
|
}, [isSidebarCollapsed]);
|
||||||
|
|
||||||
|
// --- Resize State ---
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
|
const saved = typeof window !== 'undefined' ? localStorage.getItem('deck_sidebarWidth') : null;
|
||||||
|
return saved ? parseInt(saved, 10) : 320;
|
||||||
|
});
|
||||||
|
// We now control the Library (Bottom) height in pixels, matching DraftView consistency
|
||||||
|
const [libraryHeight, setLibraryHeight] = useState(() => {
|
||||||
|
const saved = typeof window !== 'undefined' ? localStorage.getItem('deck_libraryHeight') : null;
|
||||||
|
return saved ? parseInt(saved, 10) : 300;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const libraryRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const resizingState = React.useRef<{
|
||||||
|
startX: number,
|
||||||
|
startY: number,
|
||||||
|
startWidth: number,
|
||||||
|
startHeight: number,
|
||||||
|
active: 'sidebar' | 'library' | null
|
||||||
|
}>({ startX: 0, startY: 0, startWidth: 0, startHeight: 0, active: null });
|
||||||
|
|
||||||
|
// Initial visual set
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (sidebarRef.current) sidebarRef.current.style.width = `${sidebarWidth}px`;
|
||||||
|
if (libraryRef.current) libraryRef.current.style.height = `${libraryHeight}px`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Persist Resize
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('deck_sidebarWidth', sidebarWidth.toString());
|
||||||
|
}, [sidebarWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('deck_libraryHeight', libraryHeight.toString());
|
||||||
|
}, [libraryHeight]);
|
||||||
|
|
||||||
|
// Persist Settings
|
||||||
|
useEffect(() => localStorage.setItem('deck_layout', layout), [layout]);
|
||||||
|
useEffect(() => localStorage.setItem('deck_viewMode', viewMode), [viewMode]);
|
||||||
|
useEffect(() => localStorage.setItem('deck_groupBy', groupBy), [groupBy]);
|
||||||
|
useEffect(() => localStorage.setItem('deck_cardWidth', cardWidth.toString()), [cardWidth]);
|
||||||
|
|
||||||
const [pool, setPool] = useState<any[]>(initialPool);
|
const [pool, setPool] = useState<any[]>(initialPool);
|
||||||
const [deck, setDeck] = useState<any[]>([]);
|
const [deck, setDeck] = useState<any[]>([]);
|
||||||
const [lands, setLands] = useState({ Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 });
|
// const [lands, setLands] = useState(...); // REMOVED: Managed directly in deck now
|
||||||
const [hoveredCard, setHoveredCard] = useState<any>(null);
|
const [hoveredCard, setHoveredCard] = useState<any>(null);
|
||||||
const [displayCard, setDisplayCard] = useState<any>(null);
|
const [displayCard, setDisplayCard] = useState<any>(null);
|
||||||
|
|
||||||
@@ -267,26 +357,38 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
|||||||
|
|
||||||
const applySuggestion = () => {
|
const applySuggestion = () => {
|
||||||
if (!landSuggestion) return;
|
if (!landSuggestion) return;
|
||||||
if (availableBasicLands && availableBasicLands.length > 0) {
|
|
||||||
const newLands: any[] = [];
|
const newLands: any[] = [];
|
||||||
Object.entries(landSuggestion).forEach(([type, count]) => {
|
Object.entries(landSuggestion).forEach(([type, count]) => {
|
||||||
if (count <= 0) return;
|
if ((count as number) <= 0) return;
|
||||||
const landCard = availableBasicLands.find(l => l.name === type) || availableBasicLands.find(l => l.name.includes(type));
|
|
||||||
if (landCard) {
|
// Find real land from cube or create generic
|
||||||
for (let i = 0; i < count; i++) {
|
let landCard = availableBasicLands && availableBasicLands.length > 0
|
||||||
const newLand = {
|
? (availableBasicLands.find(l => l.name === type) || availableBasicLands.find(l => l.name.includes(type)))
|
||||||
...landCard,
|
: null;
|
||||||
id: `land-${landCard.scryfallId}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}-${i}`,
|
|
||||||
image_uris: landCard.image_uris || { normal: landCard.image }
|
if (!landCard) {
|
||||||
};
|
landCard = {
|
||||||
newLands.push(newLand);
|
id: `basic-source-${type}`,
|
||||||
}
|
name: type,
|
||||||
}
|
image_uris: { normal: LAND_URL_MAP[type] },
|
||||||
});
|
typeLine: "Basic Land",
|
||||||
if (newLands.length > 0) setDeck(prev => [...prev, ...newLands]);
|
scryfallId: `generic-${type}`
|
||||||
} else {
|
};
|
||||||
setLands(landSuggestion);
|
}
|
||||||
}
|
|
||||||
|
for (let i = 0; i < (count as number); i++) {
|
||||||
|
const newLand = {
|
||||||
|
...landCard,
|
||||||
|
id: `land-${type}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}-${i}`,
|
||||||
|
image_uris: landCard.image_uris || { normal: landCard.image || LAND_URL_MAP[type] },
|
||||||
|
typeLine: landCard.typeLine || "Basic Land"
|
||||||
|
};
|
||||||
|
newLands.push(newLand);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newLands.length > 0) setDeck(prev => [...prev, ...newLands]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
@@ -313,35 +415,10 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLandChange = (type: string, delta: number) => {
|
|
||||||
setLands(prev => ({ ...prev, [type]: Math.max(0, prev[type as keyof typeof lands] + delta) }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitDeck = () => {
|
const submitDeck = () => {
|
||||||
const genericLandCards = Object.entries(lands).flatMap(([type, count]) => {
|
socketService.socket.emit('player_ready', { deck });
|
||||||
const landUrlMap: any = {
|
|
||||||
Plains: "https://cards.scryfall.io/normal/front/d/1/d1ea1858-ad25-4d13-9860-25c898b02c42.jpg",
|
|
||||||
Island: "https://cards.scryfall.io/normal/front/2/f/2f3069b3-c15c-4399-ab99-c88c0379435b.jpg",
|
|
||||||
Swamp: "https://cards.scryfall.io/normal/front/1/7/17d0571f-df6c-4b53-912f-9cb4d5a9d224.jpg",
|
|
||||||
Mountain: "https://cards.scryfall.io/normal/front/f/5/f5383569-42b7-4c07-b67f-2736bc88bd37.jpg",
|
|
||||||
Forest: "https://cards.scryfall.io/normal/front/1/f/1fa688da-901d-4876-be11-884d6b677271.jpg"
|
|
||||||
};
|
|
||||||
return Array(count).fill(null).map((_, i) => ({
|
|
||||||
id: `basic-${type}-${i}`,
|
|
||||||
name: type,
|
|
||||||
image_uris: { normal: landUrlMap[type] },
|
|
||||||
typeLine: "Basic Land"
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
const fullDeck = [...deck, ...genericLandCards];
|
|
||||||
socketService.socket.emit('player_ready', { deck: fullDeck });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedLands = useMemo(() => {
|
|
||||||
return [...(availableBasicLands || [])].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}, [availableBasicLands]);
|
|
||||||
|
|
||||||
// --- DnD Handlers ---
|
// --- DnD Handlers ---
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
|
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
|
||||||
@@ -380,82 +457,174 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
|||||||
setDraggedCard(null);
|
setDraggedCard(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Resize Handlers ---
|
||||||
|
// --- Resize Handlers ---
|
||||||
|
const handleResizeStart = (type: 'sidebar' | 'library', 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: libraryRef.current?.getBoundingClientRect().height || 300,
|
||||||
|
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';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResizeMove = React.useCallback((e: MouseEvent | TouchEvent) => {
|
||||||
|
if (!resizingState.current.active) return;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 === 'library' && libraryRef.current) {
|
||||||
|
// Dragging UP increases height of bottom panel
|
||||||
|
const delta = resizingState.current.startY - clientY;
|
||||||
|
const newHeight = Math.max(100, Math.min(window.innerHeight * 0.8, resizingState.current.startHeight + delta));
|
||||||
|
libraryRef.current.style.height = `${newHeight}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onResizeEnd = React.useCallback(() => {
|
||||||
|
if (resizingState.current.active === 'sidebar' && sidebarRef.current) {
|
||||||
|
setSidebarWidth(parseInt(sidebarRef.current.style.width));
|
||||||
|
}
|
||||||
|
if (resizingState.current.active === 'library' && libraryRef.current) {
|
||||||
|
setLibraryHeight(parseInt(libraryRef.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';
|
||||||
|
}, []);
|
||||||
|
|
||||||
// --- Render Functions ---
|
// --- Render Functions ---
|
||||||
const renderLandStation = () => (
|
// --- Consolidated Pool Logic ---
|
||||||
<div className="bg-slate-900/40 rounded border border-slate-700/50 p-2 mb-2 shrink-0 flex flex-col gap-2">
|
const landSourceCards = useMemo(() => {
|
||||||
{/* Header & Advisor */}
|
// If we have specific lands from cube, use them.
|
||||||
<div className="flex justify-between items-center bg-slate-800/50 p-2 rounded">
|
if (availableBasicLands && availableBasicLands.length > 0) {
|
||||||
<h4 className="text-xs font-bold text-slate-400 uppercase">Land Station</h4>
|
return availableBasicLands.map(land => ({
|
||||||
{landSuggestion ? (
|
...land,
|
||||||
<div className="flex items-center gap-2">
|
id: `land-source-${land.name}`, // stable ID for list
|
||||||
<span className="text-[10px] text-slate-500">Advice:</span>
|
isLandSource: true,
|
||||||
<div className="flex gap-1">
|
// Ensure image is set for display
|
||||||
|
image: land.image || land.image_uris?.normal
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise generate generic basics
|
||||||
|
const types = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest'];
|
||||||
|
return types.map(type => ({
|
||||||
|
id: `basic-source-${type}`,
|
||||||
|
name: type,
|
||||||
|
isLandSource: true,
|
||||||
|
image: LAND_URL_MAP[type],
|
||||||
|
typeLine: `Basic Land — ${type}`,
|
||||||
|
rarity: 'common',
|
||||||
|
cmc: 0,
|
||||||
|
set: 'LEA', // Dummy set for visuals
|
||||||
|
colors: type === 'Plains' ? ['W'] : type === 'Island' ? ['U'] : type === 'Swamp' ? ['B'] : type === 'Mountain' ? ['R'] : ['G']
|
||||||
|
}));
|
||||||
|
}, [availableBasicLands]);
|
||||||
|
|
||||||
|
// Removed displayPool memo to keep them separate
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const LandAdvice = () => {
|
||||||
|
if (!landSuggestion) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between bg-amber-900/40 p-2 rounded-lg border border-amber-700/50 mb-2 mx-1 animate-in fade-in slide-in-from-top-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-amber-500/20 p-1.5 rounded-md">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-amber-400"><circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path d="M12 8h.01" /></svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-bold text-amber-200 uppercase tracking-wider">Recommended Lands</span>
|
||||||
|
<div className="flex gap-2 text-xs font-medium text-slate-300">
|
||||||
{Object.entries(landSuggestion).map(([type, count]) => {
|
{Object.entries(landSuggestion).map(([type, count]) => {
|
||||||
if ((count as number) <= 0) return null;
|
if ((count as number) <= 0) return null;
|
||||||
const color = type === 'Plains' ? 'text-amber-200' : type === 'Island' ? 'text-blue-200' : type === 'Swamp' ? 'text-purple-200' : type === 'Mountain' ? 'text-red-200' : 'text-emerald-200';
|
const colorClass = type === 'Plains' ? 'text-yellow-200' : type === 'Island' ? 'text-blue-200' : type === 'Swamp' ? 'text-purple-200' : type === 'Mountain' ? 'text-red-200' : 'text-emerald-200';
|
||||||
return <span key={type} className={`text-[10px] font-bold ${color}`}>{type[0]}:{count as number}</span>
|
return <span key={type} className={colorClass}>{count as number} {type}</span>
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={applySuggestion} className="bg-emerald-700 hover:bg-emerald-600 text-white text-[10px] px-2 py-0.5 rounded shadow font-bold uppercase">Auto-Fill</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<span className="text-[10px] text-slate-600 italic">Add spells for advice</span>
|
<button
|
||||||
)}
|
onClick={applySuggestion}
|
||||||
|
className="bg-amber-600 hover:bg-amber-500 text-white text-xs px-3 py-1.5 rounded-md shadow-lg font-bold uppercase tracking-wider transition-all hover:scale-105 active:scale-95 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Check className="w-3 h-3" /> Auto-Fill
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
{/* Land Scroll */}
|
const LandRow = () => (
|
||||||
{availableBasicLands && availableBasicLands.length > 0 ? (
|
<div className="flex flex-col gap-2 mb-4 shrink-0">
|
||||||
<div className="flex items-center gap-2 overflow-x-auto custom-scrollbar pb-1">
|
<LandAdvice />
|
||||||
{sortedLands.map((land) => (
|
<div className="flex flex-wrap gap-2 px-1 justify-center sm:justify-start">
|
||||||
<DraggableLandWrapper key={land.scryfallId} land={land}>
|
{landSourceCards.map(land => (
|
||||||
<div
|
<div
|
||||||
className="relative group cursor-pointer shrink-0"
|
key={land.id}
|
||||||
onClick={() => addLandToDeck(land)}
|
onClick={() => addLandToDeck(land)}
|
||||||
onMouseEnter={() => setHoveredCard(land)}
|
onMouseEnter={() => setHoveredCard(land)}
|
||||||
onMouseLeave={() => setHoveredCard(null)}
|
onMouseLeave={() => setHoveredCard(null)}
|
||||||
>
|
className="relative group cursor-pointer hover:scale-105 transition-transform"
|
||||||
<img
|
style={{ width: '85px' }}
|
||||||
src={land.image || land.image_uris?.normal}
|
>
|
||||||
className="w-16 rounded shadow group-hover:scale-105 transition-transform"
|
<div className="aspect-[2.5/3.5] rounded-md overflow-hidden shadow-sm border border-slate-700 group-hover:border-purple-400 relative">
|
||||||
alt={land.name}
|
<img src={land.image || land.image_uris?.normal} className="w-full h-full object-cover" draggable={false} />
|
||||||
draggable={false}
|
{/* Click Only Indicator */}
|
||||||
/>
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 bg-black/40 rounded transition-opacity">
|
|
||||||
<span className="text-white font-bold text-[10px] bg-black/50 px-1 rounded">+</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DraggableLandWrapper>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex justify-between px-2">
|
|
||||||
{Object.keys(lands).map(type => (
|
|
||||||
<div key={type} className="flex flex-col items-center">
|
|
||||||
<div className="text-[10px] font-bold text-slate-500">{type[0]}</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button onClick={() => handleLandChange(type, -1)} className="w-5 h-5 bg-slate-700 rounded text-slate-300 flex items-center justify-center font-bold text-xs">-</button>
|
|
||||||
<span className="w-4 text-center text-xs font-bold">{lands[type as keyof typeof lands]}</span>
|
|
||||||
<button onClick={() => handleLandChange(type, 1)} className="w-5 h-5 bg-slate-700 rounded text-slate-300 flex items-center justify-center font-bold text-xs">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||||
</div>
|
<span className="text-white text-xs font-bold bg-emerald-600/90 px-2 py-1 rounded shadow-lg backdrop-blur-sm border border-emerald-400/50 flex items-center gap-1">
|
||||||
)}
|
<span className="text-[10px]">+</span> ADD
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-gradient-to-r from-transparent via-slate-700 to-transparent w-full mt-2" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 w-full flex h-full bg-slate-950 text-white overflow-hidden flex-col select-none" onContextMenu={(e) => e.preventDefault()}>
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex-1 w-full flex h-full bg-slate-950 text-white overflow-hidden flex-col select-none"
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
style={{ '--card-width': `${localCardWidth}px` } as React.CSSProperties}
|
||||||
|
>
|
||||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||||
|
{/* Global Toolbar */}
|
||||||
{/* Global Toolbar */}
|
{/* Global Toolbar */}
|
||||||
<div className="h-14 bg-slate-800 border-b border-slate-700 flex items-center justify-between px-4 shrink-0 overflow-x-auto text-xs sm:text-sm">
|
<div className="h-14 bg-slate-800 border-b border-slate-700 flex items-center justify-between px-4 shrink-0 overflow-x-auto text-xs sm:text-sm">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Layout Switcher */}
|
|
||||||
<div className="hidden sm:flex bg-slate-900 rounded-lg p-1 border border-slate-700">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* View Mode Switcher */}
|
{/* View Mode Switcher */}
|
||||||
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700">
|
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700">
|
||||||
<button onClick={() => setViewMode('list')} className={`p-1.5 rounded ${viewMode === 'list' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="List View"><List className="w-4 h-4" /></button>
|
<button onClick={() => setViewMode('list')} className={`p-1.5 rounded ${viewMode === 'list' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="List View"><List className="w-4 h-4" /></button>
|
||||||
@@ -463,19 +632,104 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
|||||||
<button onClick={() => setViewMode('stack')} className={`p-1.5 rounded ${viewMode === 'stack' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="Stack View"><Layers className="w-4 h-4" /></button>
|
<button onClick={() => setViewMode('stack')} className={`p-1.5 rounded ${viewMode === 'stack' ? 'bg-slate-700 text-white shadow' : 'text-slate-500 hover:text-white'}`} title="Stack View"><Layers className="w-4 h-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Group By Dropdown (Custom UI) */}
|
||||||
|
{viewMode === 'stack' && (
|
||||||
|
<div className="relative z-50">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Store position for fixed dropdown
|
||||||
|
// We'll just use the button's position relative to viewport
|
||||||
|
// But since we can't easily pass state to the dropdown without more state,
|
||||||
|
// we'll just toggle and use fixed positioning in the dropdown render.
|
||||||
|
// Actually, let's use a simple state for position if needed, or just CSS.
|
||||||
|
setSortDropdownOpen(!sortDropdownOpen);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 bg-slate-900 rounded-lg p-1.5 border border-slate-700 h-9 px-3 text-xs font-bold text-white hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-slate-500 uppercase">Sort:</span>
|
||||||
|
<span className="capitalize">{groupBy === 'cmc' ? 'Mana Value' : groupBy}</span>
|
||||||
|
<ChevronDown className={`w-3 h-3 text-slate-400 transition-transform ${sortDropdownOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{sortDropdownOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-[900]" onClick={() => setSortDropdownOpen(false)} />
|
||||||
|
<div
|
||||||
|
className="fixed z-[999] bg-slate-800 border border-slate-700 rounded-xl shadow-xl overflow-hidden animate-in fade-in zoom-in-95 duration-200 flex flex-col gap-1 p-2 w-48"
|
||||||
|
style={{
|
||||||
|
top: (containerRef.current?.getBoundingClientRect()?.top || 0) + 60,
|
||||||
|
left: (containerRef.current?.getBoundingClientRect()?.left || 0) + 140
|
||||||
|
}}
|
||||||
|
// Improving position logic: Render close to the button would be better, but without refs it's hard.
|
||||||
|
// Let's rely on fixed centering or top-left offset if we can't get button rect easily.
|
||||||
|
// Actually, let's just render it relative to the logic above or modify button to set a ref.
|
||||||
|
// We can use a ref for the button which we don't have yet.
|
||||||
|
// Let's make it simple: Fixed position centered or just use a known offset?
|
||||||
|
// The tool-bar is overflow-x-auto, so relative position is risky.
|
||||||
|
// Let's use `top: 60px` (toolbar height ~56px) and some `left`.
|
||||||
|
// A better way is to attach a ref to the button now.
|
||||||
|
ref={(el) => {
|
||||||
|
if (el && el.previousElementSibling) { // The button is the previous sibling in DOM? No, the overlay is.
|
||||||
|
// This is getting hacky. Let's just fix the overflow issue in the Toolbar instead?
|
||||||
|
// User specifically asked to "take inspiration" and "sort list is opening below everything".
|
||||||
|
// Fixed positioning is safer.
|
||||||
|
// I will use a simple effect to position it if I had a ref.
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* We'll use a style hack to position it. OR just remove overflow-x-auto from toolbar if it's not needed. check resizing. */}
|
||||||
|
{/* User said "scrolls inside it's container". */}
|
||||||
|
{/* Let's use `position: fixed` and put it explicitly. */}
|
||||||
|
<div className="text-[10px] font-bold text-slate-500 px-2 py-1 uppercase tracking-wider">Group Cards By</div>
|
||||||
|
{[
|
||||||
|
{ value: 'color', label: 'Color' },
|
||||||
|
{ value: 'type', label: 'Type' },
|
||||||
|
{ value: 'cmc', label: 'Mana Value' },
|
||||||
|
{ value: 'rarity', label: 'Rarity' }
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => {
|
||||||
|
setGroupBy(opt.value as any);
|
||||||
|
setSortDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-2 text-xs font-bold rounded-lg flex items-center justify-between transition-colors ${groupBy === opt.value ? 'bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-md' : 'text-slate-300 hover:bg-slate-700 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
{groupBy === opt.value && <Check className="w-3 h-3 text-white" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Layout Switcher */}
|
||||||
|
<div className="hidden sm:flex bg-slate-900 rounded-lg p-1 border border-slate-700">
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* Slider */}
|
{/* Slider */}
|
||||||
<div className="hidden sm:flex items-center gap-2 bg-slate-900 rounded-lg px-2 py-1 border border-slate-700 h-9">
|
<div className="hidden sm:flex items-center gap-2 bg-slate-900 rounded-lg px-2 border border-slate-700 h-9">
|
||||||
<div className="w-2 h-3 rounded border border-slate-500 bg-slate-700" />
|
<div className="w-2 h-3 rounded border border-slate-500 bg-slate-700" title="Small Cards" />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="100"
|
min="60"
|
||||||
max="300"
|
max="200"
|
||||||
step="1"
|
step="1"
|
||||||
value={cardWidth}
|
value={localCardWidth}
|
||||||
onChange={(e) => setCardWidth(parseInt(e.target.value))}
|
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-800 rounded-lg appearance-none"
|
className="w-24 accent-purple-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" />
|
<div className="w-3 h-5 rounded border border-slate-500 bg-slate-700" title="Large Cards" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -494,117 +748,202 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
|||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden lg:flex-row flex-col">
|
<div className="flex-1 flex overflow-hidden lg:flex-row flex-col">
|
||||||
{/* Zoom Sidebar */}
|
{/* Zoom Sidebar */}
|
||||||
<div className="hidden xl:flex w-72 shrink-0 flex-col items-center justify-start pt-4 border-r border-slate-800 bg-slate-900 z-10 p-4" style={{ perspective: '1000px' }}>
|
{/* Collapsed State: Toolbar Column */}
|
||||||
<div className="w-full relative sticky top-4">
|
{/* Collapsed State: Toolbar Column */}
|
||||||
<div
|
{isSidebarCollapsed ? (
|
||||||
className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
|
<div key="collapsed" className="hidden xl:flex shrink-0 w-12 flex-col items-center py-4 bg-slate-900 border-r border-slate-800 z-10 gap-4 transition-all duration-300">
|
||||||
style={{
|
<button
|
||||||
transformStyle: 'preserve-3d',
|
onClick={() => setIsSidebarCollapsed(false)}
|
||||||
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
|
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"
|
||||||
>
|
>
|
||||||
{/* Front Face (Hovered Card) */}
|
<Eye className="w-6 h-6" />
|
||||||
<div
|
<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">
|
||||||
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
|
Card Preview
|
||||||
style={{ backfaceVisibility: 'hidden' }}
|
</span>
|
||||||
>
|
</button>
|
||||||
{(hoveredCard || displayCard) && (
|
</div>
|
||||||
<div className="w-full h-full flex flex-col bg-slate-900 rounded-xl">
|
) : (
|
||||||
<img
|
<div
|
||||||
src={(hoveredCard || displayCard).image || (hoveredCard || displayCard).image_uris?.normal || (hoveredCard || displayCard).card_faces?.[0]?.image_uris?.normal}
|
key="expanded"
|
||||||
alt={(hoveredCard || displayCard).name}
|
ref={sidebarRef}
|
||||||
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
|
className="hidden xl:flex shrink-0 flex-col items-center justify-start pt-4 border-r border-slate-800 bg-slate-900 z-10 p-4 relative group/sidebar"
|
||||||
draggable={false}
|
style={{ perspective: '1000px', width: sidebarWidth }}
|
||||||
/>
|
>
|
||||||
<div className="mt-4 text-center">
|
{/* Collapse Button */}
|
||||||
<h3 className="text-lg font-bold text-slate-200">{(hoveredCard || displayCard).name}</h3>
|
<button
|
||||||
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{(hoveredCard || displayCard).typeLine || (hoveredCard || displayCard).type_line}</p>
|
onClick={() => setIsSidebarCollapsed(true)}
|
||||||
{(hoveredCard || displayCard).oracle_text && (
|
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"
|
||||||
<div className="mt-4 text-xs text-slate-400 text-left bg-slate-950 p-3 rounded-lg border border-slate-800 leading-relaxed">
|
title="Collapse Preview"
|
||||||
{(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)}
|
>
|
||||||
</div>
|
<ChevronLeft className="w-4 h-4" />
|
||||||
)}
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Back Face (Card Back) */}
|
{/* Front content ... */}
|
||||||
|
<div className="w-full relative sticky top-4">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
|
className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
|
||||||
style={{
|
style={{
|
||||||
backfaceVisibility: 'hidden',
|
transformStyle: 'preserve-3d',
|
||||||
transform: 'rotateY(180deg)'
|
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
{/* Front Face (Hovered Card) */}
|
||||||
src="/images/back.jpg"
|
<div
|
||||||
alt="Card Back"
|
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
|
||||||
className="w-full h-full object-cover"
|
style={{ backfaceVisibility: 'hidden' }}
|
||||||
draggable={false}
|
>
|
||||||
/>
|
{(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)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/back.jpg"
|
||||||
|
alt="Card Back"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Resize Handle */}
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-1 bg-transparent hover:bg-purple-500/50 cursor-col-resize z-50 flex flex-col justify-center items-center group transition-colors touch-none"
|
||||||
|
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-purple-400 transition-colors" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
{layout === 'vertical' ? (
|
{layout === 'vertical' ? (
|
||||||
<div className="flex-1 flex flex-col lg:flex-row">
|
<div className="flex-1 flex flex-col lg:flex-row min-w-0">
|
||||||
|
{/* Vertical layout typically means Pool Left / Deck Right or vice versa.
|
||||||
|
The previous code had them side-by-side with equal flex.
|
||||||
|
The request asks for Library to be resizable. In vertical mode they share width.
|
||||||
|
We can add a splitter here if needed, but horizontal split (top/bottom) is more common for resizing.
|
||||||
|
Let's stick to equal flex for vertical column mode for now, as it's cleaner,
|
||||||
|
or implement width resizing if specifically requested.
|
||||||
|
Given the constraints of "library section ... needs to be resizable", a Top/Bottom split is the only one
|
||||||
|
where resizing makes distinct sense vs side-by-side.
|
||||||
|
Wait, "library section" usually implies the Deck list.
|
||||||
|
In side-by-side, we can resize the split.
|
||||||
|
*/}
|
||||||
{/* Pool Column */}
|
{/* Pool Column */}
|
||||||
<DroppableZone id="pool-zone" className="flex-1 flex flex-col min-w-0 border-r border-slate-800 bg-slate-900/50">
|
<DroppableZone id="pool-zone" className="flex-1 flex flex-col min-w-0 border-r border-slate-800 bg-slate-900/50">
|
||||||
<div className="p-3 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between">
|
<div className="p-3 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between">
|
||||||
<span>Card Pool ({pool.length})</span>
|
<span>Card Pool ({pool.length})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto p-2 custom-scrollbar flex flex-col">
|
<div className="flex-1 overflow-auto p-2 custom-scrollbar flex flex-col">
|
||||||
{renderLandStation()}
|
{/* Land Station Merged into Display */}
|
||||||
<CardsDisplay cards={pool} viewMode={viewMode} cardWidth={cardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" source="pool" />
|
<LandRow />
|
||||||
|
<CardsDisplay cards={pool} viewMode={viewMode} cardWidth={localCardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" source="pool" groupBy={groupBy} />
|
||||||
</div>
|
</div>
|
||||||
</DroppableZone>
|
</DroppableZone>
|
||||||
|
|
||||||
{/* Deck Column */}
|
{/* Deck Column */}
|
||||||
<DroppableZone id="deck-zone" className="flex-1 flex flex-col min-w-0 bg-slate-900/50">
|
<DroppableZone id="deck-zone" className="flex-1 flex flex-col min-w-0 bg-slate-900/50">
|
||||||
<div className="p-3 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between">
|
<div className="p-3 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between">
|
||||||
<span>Deck ({deck.length})</span>
|
<span>Library ({deck.length})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto p-2 custom-scrollbar">
|
<div className="flex-1 overflow-auto p-2 custom-scrollbar">
|
||||||
<CardsDisplay cards={deck} viewMode={viewMode} cardWidth={cardWidth} onCardClick={removeFromDeck} onHover={setHoveredCard} emptyMessage="Your Deck is Empty" source="deck" />
|
<CardsDisplay cards={deck} viewMode={viewMode} cardWidth={localCardWidth} onCardClick={removeFromDeck} onHover={setHoveredCard} emptyMessage="Your Library is Empty" source="deck" groupBy={groupBy} />
|
||||||
</div>
|
</div>
|
||||||
</DroppableZone>
|
</DroppableZone>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col min-h-0 min-w-0 relative">
|
||||||
{/* Top: Pool + Land Station */}
|
{/* Top: Pool + Land Station */}
|
||||||
<DroppableZone id="pool-zone" className="flex-1 flex flex-col min-h-0 border-b border-slate-800 bg-slate-900/50">
|
<div className="flex-1 flex flex-col border-b border-slate-800 bg-slate-900/50 overflow-hidden min-h-0">
|
||||||
<div className="p-2 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between shrink-0">
|
<DroppableZone
|
||||||
<span>Card Pool ({pool.length})</span>
|
id="pool-zone"
|
||||||
</div>
|
className="flex-1 flex flex-col overflow-hidden"
|
||||||
<div className="flex-1 overflow-auto p-2 custom-scrollbar flex flex-col">
|
>
|
||||||
{renderLandStation()}
|
<div className="p-2 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between shrink-0">
|
||||||
<CardsDisplay cards={pool} viewMode={viewMode} cardWidth={cardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" source="pool" />
|
<span>Card Pool ({pool.length})</span>
|
||||||
</div>
|
</div>
|
||||||
</DroppableZone>
|
<div className="flex-1 overflow-auto p-2 custom-scrollbar flex flex-col">
|
||||||
{/* Bottom: Deck */}
|
<LandRow />
|
||||||
<DroppableZone id="deck-zone" className="h-[40%] flex flex-col min-h-0 bg-slate-900/50">
|
<CardsDisplay cards={pool} viewMode={viewMode} cardWidth={localCardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" source="pool" groupBy={groupBy} />
|
||||||
<div className="p-2 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between shrink-0">
|
</div>
|
||||||
<span>Deck ({deck.length})</span>
|
</DroppableZone>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto p-2 custom-scrollbar">
|
|
||||||
<CardsDisplay cards={deck} viewMode={viewMode} cardWidth={cardWidth} onCardClick={removeFromDeck} onHover={setHoveredCard} emptyMessage="Your Deck is Empty" source="deck" />
|
{/* Resizer Handle */}
|
||||||
</div>
|
<div
|
||||||
</DroppableZone>
|
className="h-2 bg-slate-800 hover:bg-purple-500/50 cursor-row-resize flex items-center justify-center shrink-0 z-20 group transition-colors touch-none w-full"
|
||||||
|
onMouseDown={(e) => handleResizeStart('library', e)}
|
||||||
|
onTouchStart={(e) => handleResizeStart('library', e)}
|
||||||
|
>
|
||||||
|
<div className="w-16 h-1 bg-slate-600 rounded-full group-hover:bg-purple-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom: Library */}
|
||||||
|
<div
|
||||||
|
ref={libraryRef}
|
||||||
|
style={{ height: `${libraryHeight}px` }}
|
||||||
|
className="shrink-0 flex flex-col border-t border-slate-800 bg-slate-900/50 overflow-hidden z-10"
|
||||||
|
>
|
||||||
|
<DroppableZone
|
||||||
|
id="deck-zone"
|
||||||
|
className="flex-1 flex flex-col min-h-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="p-2 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between shrink-0">
|
||||||
|
<span>Library ({deck.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-2 custom-scrollbar">
|
||||||
|
<CardsDisplay cards={deck} viewMode={viewMode} cardWidth={localCardWidth} onCardClick={removeFromDeck} onHover={setHoveredCard} emptyMessage="Your Library is Empty" source="deck" groupBy={groupBy} />
|
||||||
|
</div>
|
||||||
|
</DroppableZone>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DragOverlay dropAnimation={null}>
|
<DragOverlay dropAnimation={null}>
|
||||||
{draggedCard ? (
|
{draggedCard ? (() => {
|
||||||
<div
|
const useArtCrop = localCardWidth < 130 && !!draggedCard.imageArtCrop;
|
||||||
style={{ width: `${cardWidth}px` }}
|
const displayImage = useArtCrop ? draggedCard.imageArtCrop : (draggedCard.image || draggedCard.image_uris?.normal);
|
||||||
className={`rounded-xl shadow-2xl opacity-90 rotate-3 cursor-grabbing overflow-hidden ring-2 ring-emerald-500 bg-slate-900 aspect-[2.5/3.5]`}
|
// Default to square for crop, standard ratio otherwise
|
||||||
>
|
const aspectRatio = useArtCrop ? 'aspect-square' : 'aspect-[2.5/3.5]';
|
||||||
<img src={draggedCard.image || draggedCard.image_uris?.normal} alt={draggedCard.name} className="w-full h-full object-cover" draggable={false} />
|
|
||||||
</div>
|
return (
|
||||||
) : null}
|
<div
|
||||||
|
style={{ width: `${localCardWidth}px` }}
|
||||||
|
className={`rounded-xl shadow-2xl opacity-90 rotate-3 cursor-grabbing overflow-hidden ring-2 ring-emerald-500 bg-slate-900 ${aspectRatio}`}
|
||||||
|
>
|
||||||
|
<img src={displayImage} alt={draggedCard.name} className="w-full h-full object-cover" draggable={false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
@@ -613,7 +952,13 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
|||||||
|
|
||||||
const DeckCardItem = ({ card, useArtCrop, isFoil, onCardClick, onHover }: any) => {
|
const DeckCardItem = ({ card, useArtCrop, isFoil, onCardClick, onHover }: any) => {
|
||||||
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
||||||
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(onHover, () => onCardClick(card), card);
|
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(onHover, () => {
|
||||||
|
if (window.matchMedia('(pointer: coarse)').matches) {
|
||||||
|
onHover(card);
|
||||||
|
} else {
|
||||||
|
onCardClick(card);
|
||||||
|
}
|
||||||
|
}, card);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { socketService } from '../../services/SocketService';
|
import { socketService } from '../../services/SocketService';
|
||||||
import { LogOut, Columns, LayoutTemplate } from 'lucide-react';
|
import { LogOut, Columns, LayoutTemplate, ChevronLeft, Eye } from 'lucide-react';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { FoilOverlay, FloatingPreview } from '../../components/CardPreview';
|
import { FoilOverlay, FloatingPreview } from '../../components/CardPreview';
|
||||||
import { useCardTouch } from '../../utils/interaction';
|
import { useCardTouch } from '../../utils/interaction';
|
||||||
@@ -58,57 +58,141 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [pickExpiresAt]);
|
}, [pickExpiresAt]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// --- UI State & Persistence ---
|
// --- 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 [poolHeight, setPoolHeight] = useState<number>(() => {
|
||||||
const saved = localStorage.getItem('draft_poolHeight');
|
const saved = localStorage.getItem('draft_poolHeight');
|
||||||
return saved ? parseInt(saved, 10) : 220;
|
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 [cardScale, setCardScale] = useState<number>(() => {
|
||||||
const saved = localStorage.getItem('draft_cardScale');
|
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 [layout, setLayout] = useState<'vertical' | 'horizontal'>('horizontal');
|
useEffect(() => {
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
localStorage.setItem('draft_layout', layout);
|
||||||
|
}, [layout]);
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||||
|
return localStorage.getItem('draft_sidebarCollapsed') === 'true';
|
||||||
|
});
|
||||||
|
|
||||||
// Persist settings
|
// Persist settings
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('draft_sidebarCollapsed', isSidebarCollapsed.toString());
|
||||||
|
}, [isSidebarCollapsed]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('draft_poolHeight', poolHeight.toString());
|
localStorage.setItem('draft_poolHeight', poolHeight.toString());
|
||||||
}, [poolHeight]);
|
}, [poolHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('draft_sidebarWidth', sidebarWidth.toString());
|
||||||
|
}, [sidebarWidth]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('draft_cardScale', cardScale.toString());
|
localStorage.setItem('draft_cardScale', cardScale.toString());
|
||||||
}, [cardScale]);
|
}, [cardScale]);
|
||||||
|
|
||||||
// Resize Handlers
|
const handleResizeStart = (type: 'sidebar' | 'pool', e: React.MouseEvent | React.TouchEvent) => {
|
||||||
const startResizing = (e: React.MouseEvent) => {
|
// Prevent default to avoid scrolling/selection
|
||||||
setIsResizing(true);
|
if (e.cancelable) e.preventDefault();
|
||||||
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 onResizeMove = React.useCallback((e: MouseEvent | TouchEvent) => {
|
||||||
const stopResizing = () => setIsResizing(false);
|
if (!resizingState.current.active) return;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isResizing) {
|
if (e.cancelable) e.preventDefault();
|
||||||
document.addEventListener('mousemove', resize);
|
|
||||||
document.addEventListener('mouseup', stopResizing);
|
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 () => {
|
if (resizingState.current.active === 'pool' && poolRef.current) {
|
||||||
document.removeEventListener('mousemove', resize);
|
setPoolHeight(parseInt(poolRef.current.style.height));
|
||||||
document.removeEventListener('mouseup', stopResizing);
|
}
|
||||||
};
|
|
||||||
}, [isResizing]);
|
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 [hoveredCard, setHoveredCard] = useState<any>(null);
|
||||||
const [displayCard, setDisplayCard] = useState<any>(null);
|
const [displayCard, setDisplayCard] = useState<any>(null);
|
||||||
@@ -152,7 +236,12 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
||||||
|
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}>
|
<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>
|
<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>
|
||||||
|
|
||||||
@@ -186,17 +275,27 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card Scalar */}
|
{/* Card Scalar */}
|
||||||
<div className="flex flex-col gap-1 w-24 md:w-32">
|
<div className="flex items-center gap-2 bg-slate-900 rounded-lg px-2 border border-slate-700 h-10">
|
||||||
<label className="text-[10px] text-slate-500 uppercase font-bold tracking-wider">Card Size</label>
|
<div className="w-2 h-3 rounded border border-slate-500 bg-slate-700" title="Small Cards" />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0.5"
|
min="0.35"
|
||||||
max="1.5"
|
max="1.0"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={cardScale}
|
value={localCardScale}
|
||||||
onChange={(e) => setCardScale(parseFloat(e.target.value))}
|
onChange={(e) => {
|
||||||
className="w-full h-1 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-emerald-500"
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -225,64 +324,97 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
|||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
|
||||||
{/* Dedicated Zoom Zone (Left Sidebar) */}
|
{/* 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" style={{ perspective: '1000px' }}>
|
{/* Collapsed State: Toolbar Column */}
|
||||||
<div className="w-full relative sticky top-8 px-6">
|
{isSidebarCollapsed ? (
|
||||||
<div
|
<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">
|
||||||
className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
|
<button
|
||||||
style={{
|
onClick={() => setIsSidebarCollapsed(false)}
|
||||||
transformStyle: 'preserve-3d',
|
className="p-3 rounded-xl transition-all duration-200 group relative text-slate-500 hover:text-purple-400 hover:bg-slate-800"
|
||||||
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
|
title="Expand Preview"
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Front Face (Hovered Card) */}
|
<Eye className="w-6 h-6" />
|
||||||
<div
|
<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">
|
||||||
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
|
Card Preview
|
||||||
style={{ backfaceVisibility: 'hidden' }}
|
</span>
|
||||||
>
|
</button>
|
||||||
{(hoveredCard || displayCard) && (
|
</div>
|
||||||
<div className="w-full h-full flex flex-col bg-slate-900 rounded-xl relative overflow-hidden">
|
) : (
|
||||||
<img
|
<div
|
||||||
src={(hoveredCard || displayCard).image || (hoveredCard || displayCard).image_uris?.normal || (hoveredCard || displayCard).card_faces?.[0]?.image_uris?.normal}
|
key="expanded"
|
||||||
alt={(hoveredCard || displayCard).name}
|
ref={sidebarRef}
|
||||||
className="w-full h-full object-cover rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
|
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"
|
||||||
draggable={false}
|
style={{ perspective: '1000px', width: `${sidebarWidth}px` }}
|
||||||
/>
|
>
|
||||||
{/* Foil Overlay for Preview */}
|
{/* Collapse Button */}
|
||||||
{((hoveredCard || displayCard).finish === 'foil') && <FoilOverlay />}
|
<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="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent p-4 text-center z-20">
|
<div className="w-full relative sticky top-8 px-6">
|
||||||
<h3 className="text-lg font-bold text-slate-200">{(hoveredCard || displayCard).name}</h3>
|
|
||||||
<p className="text-xs text-slate-300 uppercase tracking-wider mt-1">{(hoveredCard || displayCard).type_line}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Back Face (Card Back) */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
|
className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
|
||||||
style={{
|
style={{
|
||||||
backfaceVisibility: 'hidden',
|
transformStyle: 'preserve-3d',
|
||||||
transform: 'rotateY(180deg)'
|
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
{/* Front Face (Hovered Card) */}
|
||||||
src="/images/back.jpg"
|
<div
|
||||||
alt="Card Back"
|
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
|
||||||
className="w-full h-full object-cover"
|
style={{ backfaceVisibility: 'hidden' }}
|
||||||
draggable={false}
|
>
|
||||||
/>
|
{(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)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/back.jpg"
|
||||||
|
alt="Card Back"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Resize Handle for Sidebar */}
|
||||||
{/* Oracle Text Box Below Card */}
|
<div
|
||||||
{(hoveredCard || displayCard)?.oracle_text && (
|
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"
|
||||||
<div className={`mt-6 text-xs text-slate-300 text-left bg-slate-900/80 backdrop-blur p-4 rounded-lg border border-slate-700 leading-relaxed transition-opacity duration-300 ${hoveredCard ? 'opacity-100' : 'opacity-0'}`}>
|
onMouseDown={(e) => handleResizeStart('sidebar', e)}
|
||||||
{(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-2 last:mb-0">{line}</p>)}
|
onTouchStart={(e) => handleResizeStart('sidebar', e)}
|
||||||
</div>
|
>
|
||||||
)}
|
<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 */}
|
{/* Main Content Area: Handles both Pack and Pool based on layout */}
|
||||||
{layout === 'vertical' ? (
|
{layout === 'vertical' ? (
|
||||||
@@ -372,29 +504,31 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
|||||||
|
|
||||||
{/* Resize Handle */}
|
{/* Resize Handle */}
|
||||||
<div
|
<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"
|
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={startResizing}
|
onMouseDown={(e) => handleResizeStart('pool', e)}
|
||||||
|
onTouchStart={(e) => handleResizeStart('pool', e)}
|
||||||
>
|
>
|
||||||
<div className="w-16 h-1 bg-slate-600 rounded-full"></div>
|
<div className="w-16 h-1 bg-slate-600 rounded-full group-hover:bg-emerald-300"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom: Pool (Horizontal Strip) */}
|
{/* Bottom: Pool (Horizontal Strip) */}
|
||||||
<PoolDroppable
|
<div ref={poolRef} style={{ height: `${poolHeight}px` }} className="shrink-0 flex flex-col overflow-hidden">
|
||||||
className="shrink-0 bg-slate-900/90 backdrop-blur-md flex flex-col z-20 shadow-[-10px_-10px_30px_rgba(0,0,0,0.3)] transition-all ease-out duration-75 border-t border-slate-800"
|
<PoolDroppable
|
||||||
style={{ height: `${poolHeight}px` }}
|
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">
|
<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">
|
<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>
|
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||||
Your Pool ({pickedCards.length})
|
Your Pool ({pickedCards.length})
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-x-auto flex items-center gap-2 px-6 pb-4 custom-scrollbar">
|
<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) => (
|
{pickedCards.map((card: any, idx: number) => (
|
||||||
<PoolCardItem key={`${card.id}-${idx}`} card={card} setHoveredCard={setHoveredCard} />
|
<PoolCardItem key={`${card.id}-${idx}`} card={card} setHoveredCard={setHoveredCard} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</PoolDroppable>
|
</PoolDroppable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -415,7 +549,7 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
|||||||
{draggedCard ? (
|
{draggedCard ? (
|
||||||
<div
|
<div
|
||||||
className="opacity-90 rotate-3 cursor-grabbing shadow-2xl rounded-xl"
|
className="opacity-90 rotate-3 cursor-grabbing shadow-2xl rounded-xl"
|
||||||
style={{ width: `${14 * cardScale}rem`, aspectRatio: '2.5/3.5' }}
|
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} />
|
<img src={draggedCard.image} alt={draggedCard.name} className="w-full h-full object-cover rounded-xl" draggable={false} />
|
||||||
</div>
|
</div>
|
||||||
@@ -435,7 +569,7 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any) => {
|
const DraftCardItem = ({ rawCard, handlePick, setHoveredCard }: any) => {
|
||||||
const card = normalizeCard(rawCard);
|
const card = normalizeCard(rawCard);
|
||||||
const isFoil = card.finish === 'foil';
|
const isFoil = card.finish === 'foil';
|
||||||
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => {
|
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => {
|
||||||
@@ -474,7 +608,7 @@ const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any)
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={{ ...style, width: `${14 * cardScale}rem` }}
|
style={{ ...style, width: `calc(14rem * var(--card-scale))` }}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...mergedListeners}
|
{...mergedListeners}
|
||||||
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer"
|
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer"
|
||||||
@@ -506,7 +640,7 @@ const PoolCardItem = ({ card, setHoveredCard, vertical = false }: any) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative group shrink-0 transition-all flex items-center cursor-pointer ${vertical ? 'w-24 h-32' : 'h-full'}`}
|
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)}
|
onMouseEnter={() => setHoveredCard(card)}
|
||||||
onMouseLeave={() => setHoveredCard(null)}
|
onMouseLeave={() => setHoveredCard(null)}
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
@@ -517,7 +651,7 @@ const PoolCardItem = ({ card, setHoveredCard, vertical = false }: any) => {
|
|||||||
<img
|
<img
|
||||||
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
|
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
|
||||||
alt={card.name}
|
alt={card.name}
|
||||||
className={`${vertical ? 'w-full h-full object-cover' : 'h-[90%] 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`}
|
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}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { socketService } from '../../services/SocketService';
|
import { socketService } from '../../services/SocketService';
|
||||||
import { Users, MessageSquare, Send, Copy, Check, Layers, LogOut } from 'lucide-react';
|
import { Users, MessageSquare, Send, Copy, Check, Layers, LogOut, Bell, BellOff, X } from 'lucide-react';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
|
import { useToast } from '../../components/Toast';
|
||||||
import { GameView } from '../game/GameView';
|
import { GameView } from '../game/GameView';
|
||||||
import { DraftView } from '../draft/DraftView';
|
import { DraftView } from '../draft/DraftView';
|
||||||
import { DeckBuilderView } from '../draft/DeckBuilderView';
|
import { DeckBuilderView } from '../draft/DeckBuilderView';
|
||||||
@@ -45,18 +46,73 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [modalConfig, setModalConfig] = useState({ title: '', message: '', type: 'info' as 'info' | 'error' | 'warning' | 'success' });
|
const [modalConfig, setModalConfig] = useState({ title: '', message: '', type: 'info' as 'info' | 'error' | 'warning' | 'success' });
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
// Restored States
|
// Restored States
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>(initialRoom.messages || []);
|
const [messages, setMessages] = useState<ChatMessage[]>(initialRoom.messages || []);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const [gameState, setGameState] = useState<any>(initialGameState || null);
|
const [gameState, setGameState] = useState<any>(initialGameState || null);
|
||||||
const [draftState, setDraftState] = useState<any>(initialDraftState || null);
|
const [draftState, setDraftState] = useState<any>(initialDraftState || null);
|
||||||
const [mobileTab, setMobileTab] = useState<'game' | 'chat'>('game');
|
const [mobileTab, setMobileTab] = useState<'game' | 'chat'>('game'); // Keep for mobile
|
||||||
|
|
||||||
// Derived State
|
// Derived State
|
||||||
const host = room.players.find(p => p.isHost);
|
const host = room.players.find(p => p.isHost);
|
||||||
const isHostOffline = host?.isOffline;
|
const isHostOffline = host?.isOffline;
|
||||||
const isMeHost = currentPlayerId === host?.id;
|
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
|
// Effects
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -235,125 +291,225 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col lg:flex-row gap-4 overflow-hidden">
|
<div className="flex h-full w-full overflow-hidden relative">
|
||||||
{/* Mobile Tab Bar */}
|
{/* --- MOBILE LAYOUT (Keep simplified tabs for small screens) --- */}
|
||||||
<div className="lg:hidden shrink-0 flex items-center bg-slate-800 border-b border-slate-700">
|
<div className="lg:hidden flex flex-col w-full h-full">
|
||||||
<button
|
{/* Mobile Tab Bar */}
|
||||||
onClick={() => setMobileTab('game')}
|
<div className="shrink-0 flex items-center bg-slate-800 border-b border-slate-700">
|
||||||
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'}`}
|
<button
|
||||||
>
|
onClick={() => setMobileTab('game')}
|
||||||
<Layers className="w-4 h-4" /> 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'}`}
|
||||||
</button>
|
>
|
||||||
<button
|
<Layers className="w-4 h-4" /> Game
|
||||||
onClick={() => setMobileTab('chat')}
|
</button>
|
||||||
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'}`}
|
<button
|
||||||
>
|
onClick={() => setMobileTab('chat')}
|
||||||
<div className="flex items-center gap-1">
|
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'}`}
|
||||||
<Users className="w-4 h-4" />
|
>
|
||||||
<span className="text-slate-600">/</span>
|
<div className="flex items-center gap-1">
|
||||||
<MessageSquare className="w-4 h-4" />
|
<Users className="w-4 h-4" />
|
||||||
</div>
|
<span className="text-slate-600">/</span>
|
||||||
Lobby & Chat
|
<MessageSquare className="w-4 h-4" />
|
||||||
</button>
|
</div>
|
||||||
|
Lobby & Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex-1 min-h-0 flex flex-col ${mobileTab === 'game' ? 'flex' : 'hidden lg:flex'}`}>
|
{/* --- 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()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`w-full lg:w-80 shrink-0 flex flex-col gap-4 min-h-0 ${mobileTab === 'chat' ? 'flex' : 'hidden lg:flex'}`}>
|
{/* Right Collapsible Toolbar */}
|
||||||
<div className="flex-1 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
<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">
|
||||||
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
|
<button
|
||||||
<Users className="w-4 h-4" /> Lobby
|
onClick={() => setActivePanel(activePanel === 'lobby' ? null : 'lobby')}
|
||||||
</h3>
|
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
|
||||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
|
onClick={() => setActivePanel(activePanel === 'chat' ? null : 'chat')}
|
||||||
{room.players.map(p => {
|
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'}`}
|
||||||
const isReady = (p as any).ready;
|
title="Chat"
|
||||||
const isMe = p.id === currentPlayerId;
|
>
|
||||||
const isSolo = room.players.length === 1 && room.status === 'playing';
|
<div className="relative">
|
||||||
|
<MessageSquare className="w-6 h-6" />
|
||||||
return (
|
{/* Unread indicator could go here */}
|
||||||
<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 ${isSolo ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
|
|
||||||
{isMe && (
|
|
||||||
<button
|
|
||||||
onClick={onExit}
|
|
||||||
className={`p-1 rounded flex items-center gap-2 transition-colors ${isSolo
|
|
||||||
? 'bg-red-900/40 text-red-200 hover:bg-red-900/60 px-3 py-1.5'
|
|
||||||
: 'hover:bg-slate-700 text-slate-400 hover:text-red-400'
|
|
||||||
}`}
|
|
||||||
title={isSolo ? "End Solo Session" : "Leave Room"}
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
{isSolo && <span className="text-xs font-bold">End Test</span>}
|
|
||||||
</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>
|
||||||
</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
|
||||||
<div className="h-1/2 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl flex flex-col">
|
</span>
|
||||||
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
|
</button>
|
||||||
<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>
|
|
||||||
</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>
|
||||||
|
|
||||||
|
{/* 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.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.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>}
|
||||||
|
{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={() => {
|
||||||
|
if (confirm(`Kick ${p.name}?`)) {
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{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 */}
|
{/* Host Disconnected Overlay */}
|
||||||
{isHostOffline && !isMeHost && (
|
{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">
|
<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">
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ export function useCardTouch(
|
|||||||
touchStartCount.current = e.touches.length;
|
touchStartCount.current = e.touches.length;
|
||||||
isLongPress.current = false;
|
isLongPress.current = false;
|
||||||
|
|
||||||
// Only Start "Preview" Timer if 2 fingers
|
// Start Preview Timer (1 finger is standard mobile long-press)
|
||||||
if (e.touches.length === 2) {
|
if (e.touches.length === 1) {
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
isLongPress.current = true;
|
isLongPress.current = true;
|
||||||
onHover(cardPayload);
|
onHover(cardPayload);
|
||||||
}, 400); // 400ms threshold
|
}, 500); // 500ms threshold (standard long press)
|
||||||
}
|
}
|
||||||
}, [onHover, cardPayload]);
|
}, [onHover, cardPayload]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user