Compare commits

...

16 Commits

Author SHA1 Message Date
87e38bd0a3 feat: Group pack stack view by type, enhance pack display grid responsiveness, and adjust long-press preview to single-finger with a 500ms delay.
All checks were successful
Build and Deploy / build (push) Successful in 1m26s
2025-12-18 03:47:55 +01:00
6b054ad8fc feat: Consolidate card and land dragging into a single wrapper and manage basic lands directly in the deck. 2025-12-18 03:19:32 +01:00
b39da587d4 feat: Enhance card size slider UI with tooltips and improved layout/styling in draft and deck builder views. 2025-12-18 03:04:41 +01:00
78af33ec99 feat: Add an ALPHA tag to the app title and implement a collapsible card preview sidebar with persistence in draft and deck builder views. 2025-12-18 02:58:48 +01:00
6301e0e7f5 feat: embed card oracle text and type line directly into the draft preview panel with scrollable content. 2025-12-18 02:35:15 +01:00
642e203baf fix: prevent DeckBuilderView content overflow by adding min-w-0 2025-12-18 02:32:31 +01:00
d27cc625e4 feat: Conditionally render dragged card art crop and square aspect ratio for small sizes. 2025-12-18 02:30:20 +01:00
b7e0d1479c feat: enable horizontal scrolling for StackView and use local card width in DeckBuilderView. 2025-12-18 02:27:49 +01:00
bd33f6be24 feat: Persist DeckBuilder UI settings and library height to local storage, and fix sort dropdown positioning. 2025-12-18 02:21:18 +01:00
e6e452b030 feat: Implement localStorage persistence for UI panel resize states in Draft and Deck views. 2025-12-18 02:09:44 +01:00
db601048d9 feat: enhance UI with custom sort dropdown, resizable layouts, StackView DnD, and optimize slider/resize performance with layout fixes. 2025-12-18 02:06:57 +01:00
ebfdfef5ae feat: refactor lobby UI with collapsible panels, add player event notifications, and update card art crop threshold to 130px 2025-12-18 01:38:28 +01:00
851e2aa81d feat: refactor StackView for dynamic grouping and add sorting controls to Deck Builder while reducing card size slider ranges. 2025-12-18 01:30:48 +01:00
0ca29622ef feat: rename Deck to Library and implement tap-to-preview for cards in Deck Builder on touch devices. 2025-12-18 01:26:07 +01:00
d550bc3d04 feat: set default card size and scale values to their minimum in Cube Manager, Draft View, and Deck Builder. 2025-12-18 01:19:11 +01:00
12e60d42f3 feat: Update card preview to use long-press instead of hover on touch devices by improving mobile detection logic. 2025-12-18 01:11:54 +01:00
26 changed files with 1581 additions and 520 deletions

View File

@@ -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.
- [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.
- [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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View 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.

View File

@@ -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.

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View 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`).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -79,7 +79,10 @@ export const App: React.FC = () => {
<div className="flex items-center gap-3">
<div className="bg-purple-600 p-2 rounded-lg"><Layers className="w-6 h-6 text-white" /></div>
<div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">MTG Peasant Drafter</h1>
<h1 className="text-2xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent flex items-center gap-2">
MTG Peasant Drafter
<span className="px-1.5 py-0.5 rounded-md bg-purple-500/10 border border-purple-500/20 text-[10px] font-bold text-purple-400 tracking-wider shadow-[0_0_10px_rgba(168,85,247,0.1)]">ALPHA</span>
</h1>
<p className="text-slate-400 text-xs uppercase tracking-wider">Pack Generator & Tournament Manager</p>
</div>
</div>

View File

@@ -94,9 +94,23 @@ export const CardHoverWrapper: React.FC<{ card: DraftCard; children: React.React
const closeTimerRef = useRef<NodeJS.Timeout | null>(null);
const hasImage = !!card.image;
// Use a stable value for isMobile to avoid hydration mismatches if using SSR,
// but since this is client-side mostly, window check is okay.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 1024;
// Use state for isMobile to handle window resizing and touch capability detection
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => {
// "Mobile" behavior (No hover, long-press, modal preview) applies if:
// 1. Device is primarily touch (pointer: coarse) - e.g. Tablets, Phones
// 2. Screen is small (< 1024px) - e.g. Phone in Desktop mode or small window
const isTouch = window.matchMedia('(pointer: coarse)').matches;
const isSmall = window.innerWidth < 1024;
setIsMobile(isTouch || isSmall);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const shouldShow = (isHovering && !isMobile) || isLongPressing;

View File

@@ -104,11 +104,11 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode, cardWidth =
{viewMode === 'grid' && (
<div className="flex flex-wrap gap-3">
{pack.cards.map((card) => {
const useArtCrop = cardWidth < 200 && !!card.imageArtCrop;
const useArtCrop = cardWidth < 130 && !!card.imageArtCrop;
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
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">
{/* 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'}`}>
@@ -136,7 +136,7 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode, cardWidth =
</div>
)}
{viewMode === 'stack' && <StackView cards={pack.cards} cardWidth={cardWidth} />}
{viewMode === 'stack' && <StackView cards={pack.cards} cardWidth={cardWidth} groupBy="type" />}
</div>
</div>
);

View File

@@ -3,63 +3,112 @@ import { DraftCard } from '../services/PackGeneratorService';
import { FoilOverlay, CardHoverWrapper } from './CardPreview';
import { useCardTouch } from '../utils/interaction';
type GroupMode = 'type' | 'color' | 'cmc' | 'rarity';
interface StackViewProps {
cards: DraftCard[];
cardWidth?: number;
onCardClick?: (card: DraftCard) => void;
onHover?: (card: DraftCard | null) => void;
disableHoverPreview?: boolean;
groupBy?: GroupMode;
renderWrapper?: (card: DraftCard, children: React.ReactNode) => React.ReactNode;
}
const CATEGORY_ORDER = [
'Creature',
'Planeswalker',
'Instant',
'Sorcery',
'Enchantment',
'Artifact',
'Land',
'Battle',
'Other'
];
const GROUPS: Record<GroupMode, string[]> = {
type: ['Creature', 'Planeswalker', 'Instant', 'Sorcery', 'Enchantment', 'Artifact', 'Battle', 'Land', 'Other'],
color: ['White', 'Blue', 'Black', 'Red', 'Green', 'Multicolor', 'Colorless'],
cmc: ['0', '1', '2', '3', '4', '5', '6', '7+'],
rarity: ['Mythic', 'Rare', 'Uncommon', 'Common']
};
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 categories: Record<string, DraftCard[]> = {};
CATEGORY_ORDER.forEach(c => categories[c] = []);
const groupKeys = GROUPS[groupBy];
groupKeys.forEach(k => categories[k] = []);
cards.forEach(card => {
let category = 'Other';
const typeLine = card.typeLine || '';
if (typeLine.includes('Creature')) category = 'Creature'; // Includes Artifact Creature, Ench Creature
else if (typeLine.includes('Planeswalker')) category = 'Planeswalker';
else if (typeLine.includes('Instant')) category = 'Instant';
else if (typeLine.includes('Sorcery')) category = 'Sorcery';
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);
const group = getCardGroup(card, groupBy);
if (categories[group]) {
categories[group].push(card);
} else {
// Fallback for unexpected (shouldn't happen with defined logic coverage)
if (!categories['Other']) categories['Other'] = [];
categories['Other'].push(card);
}
});
// Sort cards within categories by CMC (low to high)? Or Rarity?
// Archidekt usually sorts by CMC.
// Sort cards within categories by CMC (low to high)
// Secondary sort by Name
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;
}, [cards]);
}, [cards, groupBy]);
const activeGroups = GROUPS[groupBy];
return (
<div className="flex flex-row gap-4 overflow-x-auto pb-8 snap-x items-start">
{CATEGORY_ORDER.map(category => {
<div className="inline-flex flex-row gap-4 pb-8 items-start min-w-full">
{activeGroups.map(category => {
const catCards = categorizedCards[category];
if (catCards.length === 0) return null;
@@ -77,7 +126,7 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, on
// Margin calculation: Negative margin to pull up next cards.
// To show a "strip" of say 35px at the top of each card.
const isLast = index === catCards.length - 1;
const useArtCrop = cardWidth < 200 && !!card.imageArtCrop;
const useArtCrop = cardWidth < 130 && !!card.imageArtCrop;
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
return (
@@ -91,6 +140,7 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, on
onHover={onHover}
onCardClick={onCardClick}
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);
return (
const content = (
<div
className="relative w-full z-0 hover:z-50 transition-all duration-200 group"
onMouseEnter={() => onHover && onHover(card)}
@@ -115,7 +165,7 @@ const StackCardItem = ({ card, cardWidth, isLast, useArtCrop, displayImage, onHo
onTouchEnd={onTouchEnd}
onTouchMove={onTouchMove}
>
<CardHoverWrapper card={card} preventPreview={disableHoverPreview || cardWidth >= 200}>
<CardHoverWrapper card={card} preventPreview={disableHoverPreview || cardWidth >= 130}>
<div
className={`relative w-full rounded-lg bg-slate-800 shadow-md border border-slate-950 overflow-hidden cursor-pointer group-hover:ring-2 group-hover:ring-purple-400`}
style={{
@@ -129,4 +179,10 @@ const StackCardItem = ({ card, cardWidth, isLast, useArtCrop, displayImage, onHo
</CardHoverWrapper>
</div>
);
if (renderWrapper) {
return renderWrapper(card, content);
}
return content;
};

View File

@@ -2,7 +2,7 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { X, Check, AlertCircle, Info } from 'lucide-react';
type ToastType = 'success' | 'error' | 'info';
type ToastType = 'success' | 'error' | 'info' | 'warning';
interface Toast {
id: string;
@@ -55,15 +55,18 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
bg-slate-800 text-white
${toast.type === 'success' ? 'border-emerald-500/50 shadow-emerald-900/20' :
toast.type === 'error' ? 'border-red-500/50 shadow-red-900/20' :
'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' :
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'
}`}>
{toast.type === 'success' && <Check className="w-5 h-5" />}
{toast.type === 'error' && <AlertCircle className="w-5 h-5" />}
{toast.type === 'warning' && <AlertCircle className="w-5 h-5" />}
{toast.type === 'info' && <Info className="w-5 h-5" />}
</div>

View File

@@ -113,8 +113,16 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
const [cardWidth, setCardWidth] = useState(() => {
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 ---
useEffect(() => localStorage.setItem('cube_inputText', inputText), [inputText]);
@@ -453,7 +461,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
};
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 --- */}
<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" />
<input
type="range"
min="100"
max="300"
min="60"
max="200"
step="1"
value={cardWidth}
onChange={(e) => setCardWidth(parseInt(e.target.value))}
value={localCardWidth}
onChange={(e) => {
const val = parseInt(e.target.value);
setLocalCardWidth(val);
if (containerRef.current) containerRef.current.style.setProperty('--card-width', `${val}px`);
}}
onMouseUp={() => setCardWidth(localCardWidth)}
onTouchEnd={() => setCardWidth(localCardWidth)}
className="w-24 accent-purple-500 cursor-pointer h-1.5 bg-slate-600 rounded-lg appearance-none"
title={`Card Size: ${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>
@@ -869,13 +883,16 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
<div
className="grid gap-6 pb-20"
style={{
gridTemplateColumns: cardWidth <= 150
? `repeat(auto-fill, minmax(${viewMode === 'list' ? '320px' : '550px'}, 1fr))`
: '1fr'
gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${localCardWidth > 165
? (viewMode === 'list' ? '500px' : '750px')
: localCardWidth <= 95
? (viewMode === 'list' ? '300px' : '450px')
: (viewMode === 'list' ? '400px' : '600px')
}), 1fr))`
}}
>
{packs.map((pack) => (
<PackCard key={pack.id} pack={pack} viewMode={viewMode} cardWidth={cardWidth} />
<PackCard key={pack.id} pack={pack} viewMode={viewMode} cardWidth={localCardWidth} />
))}
</div>
)

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
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 { FoilOverlay } from '../../components/CardPreview';
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
});
// Draggable Wrapper for Cards
const DraggableCardWrapper = ({ children, card, source, disabled }: any) => {
const LAND_URL_MAP: Record<string, string> = {
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({
id: card.id,
data: { card, source },
id: dndId,
data: dndData,
disabled
});
const style = transform ? {
transform: CSS.Translate.toString(transform),
opacity: isDragging ? 0 : 1,
opacity: isDragging ? (isLand ? 0.5 : 0) : 1,
zIndex: isDragging ? 999 : 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
const DroppableZone = ({ id, children, className }: any) => {
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 (
<div
@@ -128,7 +124,8 @@ const CardsDisplay: React.FC<{
onHover: (c: any) => void;
emptyMessage: string;
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) {
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">
@@ -138,14 +135,22 @@ const CardsDisplay: React.FC<{
)
}
// Use CSS var for grid
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 (
<div className="flex flex-col gap-1 w-full">
{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} />
</DraggableCardWrapper>
</UniversalCardWrapper>
))}
</div>
);
@@ -153,15 +158,25 @@ const CardsDisplay: React.FC<{
if (viewMode === 'stack') {
return (
<div className="w-full h-full"> {/* Allow native scrolling from parent */}
{/* 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. */}
<div className="h-full min-w-full w-max">
<StackView
cards={cards.map(normalizeCard)}
cardWidth={cardWidth}
onCardClick={(c) => onCardClick(c)}
onCardClick={(c) => {
if (window.matchMedia('(pointer: coarse)').matches) {
onHover(c);
} else {
onCardClick(c);
}
}}
onHover={(c) => onHover(c)}
disableHoverPreview={true}
groupBy={groupBy}
renderWrapper={(card, children) => (
<UniversalCardWrapper key={card.id || card.name} card={card} source={source}>
{children}
</UniversalCardWrapper>
)}
/>
</div>
)
@@ -172,17 +187,17 @@ const CardsDisplay: React.FC<{
<div
className="grid gap-4 pb-20 content-start"
style={{
gridTemplateColumns: `repeat(auto-fill, minmax(${cardWidth}px, 1fr))`
gridTemplateColumns: `repeat(auto-fill, minmax(var(--card-width, ${cardWidth}px), 1fr))`
}}
>
{cards.map(c => {
const card = normalizeCard(c);
const useArtCrop = cardWidth < 200 && !!card.imageArtCrop;
const useArtCrop = cardWidth < 130 && !!card.imageArtCrop;
const isFoil = card.finish === 'foil';
return (
<DraggableCardWrapper key={card.id} card={card} source={source}>
<UniversalCardWrapper key={card.id || card.name} card={card} source={source}>
<DeckCardItem
card={card}
useArtCrop={useArtCrop}
@@ -190,7 +205,7 @@ const CardsDisplay: React.FC<{
onCardClick={onCardClick}
onHover={onHover}
/>
</DraggableCardWrapper>
</UniversalCardWrapper>
);
})}
</div>
@@ -200,13 +215,88 @@ const CardsDisplay: React.FC<{
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, availableBasicLands = [] }) => {
// Unlimited Timer (Static for now)
const [timer] = useState<string>("Unlimited");
const [layout, setLayout] = useState<'vertical' | 'horizontal'>('vertical');
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('grid');
const [cardWidth, setCardWidth] = useState(150);
const [layout, setLayout] = useState<'vertical' | 'horizontal'>(() => {
const saved = typeof window !== 'undefined' ? localStorage.getItem('deck_layout') : null;
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 [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 [displayCard, setDisplayCard] = useState<any>(null);
@@ -267,26 +357,38 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
const applySuggestion = () => {
if (!landSuggestion) return;
if (availableBasicLands && availableBasicLands.length > 0) {
const newLands: any[] = [];
Object.entries(landSuggestion).forEach(([type, count]) => {
if (count <= 0) return;
const landCard = availableBasicLands.find(l => l.name === type) || availableBasicLands.find(l => l.name.includes(type));
if (landCard) {
for (let i = 0; i < count; i++) {
const newLand = {
...landCard,
id: `land-${landCard.scryfallId}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}-${i}`,
image_uris: landCard.image_uris || { normal: landCard.image }
};
newLands.push(newLand);
}
}
});
if (newLands.length > 0) setDeck(prev => [...prev, ...newLands]);
} else {
setLands(landSuggestion);
}
const newLands: any[] = [];
Object.entries(landSuggestion).forEach(([type, count]) => {
if ((count as number) <= 0) return;
// Find real land from cube or create generic
let landCard = availableBasicLands && availableBasicLands.length > 0
? (availableBasicLands.find(l => l.name === type) || availableBasicLands.find(l => l.name.includes(type)))
: null;
if (!landCard) {
landCard = {
id: `basic-source-${type}`,
name: type,
image_uris: { normal: LAND_URL_MAP[type] },
typeLine: "Basic Land",
scryfallId: `generic-${type}`
};
}
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 ---
@@ -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 genericLandCards = Object.entries(lands).flatMap(([type, count]) => {
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 });
socketService.socket.emit('player_ready', { deck });
};
const sortedLands = useMemo(() => {
return [...(availableBasicLands || [])].sort((a, b) => a.name.localeCompare(b.name));
}, [availableBasicLands]);
// --- DnD Handlers ---
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
@@ -380,82 +457,174 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
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 ---
const renderLandStation = () => (
<div className="bg-slate-900/40 rounded border border-slate-700/50 p-2 mb-2 shrink-0 flex flex-col gap-2">
{/* Header & Advisor */}
<div className="flex justify-between items-center bg-slate-800/50 p-2 rounded">
<h4 className="text-xs font-bold text-slate-400 uppercase">Land Station</h4>
{landSuggestion ? (
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-500">Advice:</span>
<div className="flex gap-1">
// --- Consolidated Pool Logic ---
const landSourceCards = useMemo(() => {
// If we have specific lands from cube, use them.
if (availableBasicLands && availableBasicLands.length > 0) {
return availableBasicLands.map(land => ({
...land,
id: `land-source-${land.name}`, // stable ID for list
isLandSource: true,
// 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]) => {
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';
return <span key={type} className={`text-[10px] font-bold ${color}`}>{type[0]}:{count as number}</span>
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={colorClass}>{count as number} {type}</span>
})}
</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>
) : (
<span className="text-[10px] text-slate-600 italic">Add spells for advice</span>
)}
</div>
<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>
);
};
{/* Land Scroll */}
{availableBasicLands && availableBasicLands.length > 0 ? (
<div className="flex items-center gap-2 overflow-x-auto custom-scrollbar pb-1">
{sortedLands.map((land) => (
<DraggableLandWrapper key={land.scryfallId} land={land}>
<div
className="relative group cursor-pointer shrink-0"
onClick={() => addLandToDeck(land)}
onMouseEnter={() => setHoveredCard(land)}
onMouseLeave={() => setHoveredCard(null)}
>
<img
src={land.image || land.image_uris?.normal}
className="w-16 rounded shadow group-hover:scale-105 transition-transform"
alt={land.name}
draggable={false}
/>
<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>
const LandRow = () => (
<div className="flex flex-col gap-2 mb-4 shrink-0">
<LandAdvice />
<div className="flex flex-wrap gap-2 px-1 justify-center sm:justify-start">
{landSourceCards.map(land => (
<div
key={land.id}
onClick={() => addLandToDeck(land)}
onMouseEnter={() => setHoveredCard(land)}
onMouseLeave={() => setHoveredCard(null)}
className="relative group cursor-pointer hover:scale-105 transition-transform"
style={{ width: '85px' }}
>
<div className="aspect-[2.5/3.5] rounded-md overflow-hidden shadow-sm border border-slate-700 group-hover:border-purple-400 relative">
<img src={land.image || land.image_uris?.normal} className="w-full h-full object-cover" draggable={false} />
{/* Click Only Indicator */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
</div>
))}
</div>
)}
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<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>
);
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}>
{/* 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="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 */}
<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>
@@ -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>
</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 */}
<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="w-2 h-3 rounded border border-slate-500 bg-slate-700" />
<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" title="Small Cards" />
<input
type="range"
min="100"
max="300"
min="60"
max="200"
step="1"
value={cardWidth}
onChange={(e) => setCardWidth(parseInt(e.target.value))}
value={localCardWidth}
onChange={(e) => {
const val = parseInt(e.target.value);
setLocalCardWidth(val);
if (containerRef.current) containerRef.current.style.setProperty('--card-width', `${val}px`);
}}
onMouseUp={() => setCardWidth(localCardWidth)}
onTouchEnd={() => setCardWidth(localCardWidth)}
className="w-24 accent-purple-500 cursor-pointer h-1.5 bg-slate-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>
@@ -494,117 +748,202 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
<div className="flex-1 flex overflow-hidden lg:flex-row flex-col">
{/* 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' }}>
<div className="w-full relative sticky top-4">
<div
className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
style={{
transformStyle: 'preserve-3d',
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
}}
{/* Collapsed State: Toolbar Column */}
{/* Collapsed State: Toolbar Column */}
{isSidebarCollapsed ? (
<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">
<button
onClick={() => setIsSidebarCollapsed(false)}
className="p-3 rounded-xl transition-all duration-200 group relative text-slate-500 hover:text-purple-400 hover:bg-slate-800"
title="Expand Preview"
>
{/* Front Face (Hovered Card) */}
<div
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
style={{ backfaceVisibility: 'hidden' }}
>
{(hoveredCard || displayCard) && (
<div className="w-full h-full flex flex-col bg-slate-900 rounded-xl">
<img
src={(hoveredCard || displayCard).image || (hoveredCard || displayCard).image_uris?.normal || (hoveredCard || displayCard).card_faces?.[0]?.image_uris?.normal}
alt={(hoveredCard || displayCard).name}
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
draggable={false}
/>
<div className="mt-4 text-center">
<h3 className="text-lg font-bold text-slate-200">{(hoveredCard || displayCard).name}</h3>
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{(hoveredCard || displayCard).typeLine || (hoveredCard || displayCard).type_line}</p>
{(hoveredCard || displayCard).oracle_text && (
<div className="mt-4 text-xs text-slate-400 text-left bg-slate-950 p-3 rounded-lg border border-slate-800 leading-relaxed">
{(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)}
</div>
)}
</div>
</div>
)}
</div>
<Eye className="w-6 h-6" />
<span className="absolute left-full ml-3 top-1/2 -translate-y-1/2 bg-slate-800 text-white text-xs font-bold px-2 py-1 rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none ring-1 ring-white/10 z-50">
Card Preview
</span>
</button>
</div>
) : (
<div
key="expanded"
ref={sidebarRef}
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"
style={{ perspective: '1000px', width: sidebarWidth }}
>
{/* Collapse Button */}
<button
onClick={() => setIsSidebarCollapsed(true)}
className="absolute top-2 right-2 p-1.5 bg-slate-800/80 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors z-20 opacity-0 group-hover/sidebar:opacity-100"
title="Collapse Preview"
>
<ChevronLeft className="w-4 h-4" />
</button>
{/* Back Face (Card Back) */}
{/* Front content ... */}
<div className="w-full relative sticky top-4">
<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={{
backfaceVisibility: 'hidden',
transform: 'rotateY(180deg)'
transformStyle: 'preserve-3d',
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
}}
>
<img
src="/images/back.jpg"
alt="Card Back"
className="w-full h-full object-cover"
draggable={false}
/>
{/* Front Face (Hovered Card) */}
<div
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
style={{ backfaceVisibility: 'hidden' }}
>
{(hoveredCard || displayCard) && (
<div className="w-full h-full flex flex-col bg-slate-900 rounded-xl">
<img
src={(hoveredCard || displayCard).image || (hoveredCard || displayCard).image_uris?.normal || (hoveredCard || displayCard).card_faces?.[0]?.image_uris?.normal}
alt={(hoveredCard || displayCard).name}
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
draggable={false}
/>
<div className="mt-4 text-center">
<h3 className="text-lg font-bold text-slate-200">{(hoveredCard || displayCard).name}</h3>
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{(hoveredCard || displayCard).typeLine || (hoveredCard || displayCard).type_line}</p>
{(hoveredCard || displayCard).oracle_text && (
<div className="mt-4 text-xs text-slate-400 text-left bg-slate-950 p-3 rounded-lg border border-slate-800 leading-relaxed max-h-60 overflow-y-auto custom-scrollbar">
{(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)}
</div>
)}
</div>
</div>
)}
</div>
{/* Back Face (Card Back) */}
<div
className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
style={{
backfaceVisibility: 'hidden',
transform: 'rotateY(180deg)'
}}
>
<img
src="/images/back.jpg"
alt="Card Back"
className="w-full h-full object-cover"
draggable={false}
/>
</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>
)}
{/* Content Area */}
{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 */}
<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">
<span>Card Pool ({pool.length})</span>
</div>
<div className="flex-1 overflow-auto p-2 custom-scrollbar flex flex-col">
{renderLandStation()}
<CardsDisplay cards={pool} viewMode={viewMode} cardWidth={cardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" source="pool" />
{/* Land Station Merged into Display */}
<LandRow />
<CardsDisplay cards={pool} viewMode={viewMode} cardWidth={localCardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" source="pool" groupBy={groupBy} />
</div>
</DroppableZone>
{/* Deck Column */}
<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">
<span>Deck ({deck.length})</span>
<span>Library ({deck.length})</span>
</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" />
<CardsDisplay cards={deck} viewMode={viewMode} cardWidth={localCardWidth} onCardClick={removeFromDeck} onHover={setHoveredCard} emptyMessage="Your Library is Empty" source="deck" groupBy={groupBy} />
</div>
</DroppableZone>
</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 */}
<DroppableZone id="pool-zone" className="flex-1 flex flex-col min-h-0 border-b border-slate-800 bg-slate-900/50">
<div className="p-2 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between shrink-0">
<span>Card Pool ({pool.length})</span>
</div>
<div className="flex-1 overflow-auto p-2 custom-scrollbar flex flex-col">
{renderLandStation()}
<CardsDisplay cards={pool} viewMode={viewMode} cardWidth={cardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" source="pool" />
</div>
</DroppableZone>
{/* Bottom: Deck */}
<DroppableZone id="deck-zone" className="h-[40%] flex flex-col min-h-0 bg-slate-900/50">
<div className="p-2 border-b border-slate-800 font-bold text-slate-400 uppercase text-xs flex justify-between shrink-0">
<span>Deck ({deck.length})</span>
</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" />
</div>
</DroppableZone>
<div className="flex-1 flex flex-col border-b border-slate-800 bg-slate-900/50 overflow-hidden min-h-0">
<DroppableZone
id="pool-zone"
className="flex-1 flex flex-col 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>Card Pool ({pool.length})</span>
</div>
<div className="flex-1 overflow-auto p-2 custom-scrollbar flex flex-col">
<LandRow />
<CardsDisplay cards={pool} viewMode={viewMode} cardWidth={localCardWidth} onCardClick={addToDeck} onHover={setHoveredCard} emptyMessage="Pool Empty" source="pool" groupBy={groupBy} />
</div>
</DroppableZone>
</div>
{/* Resizer Handle */}
<div
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>
<DragOverlay dropAnimation={null}>
{draggedCard ? (
<div
style={{ width: `${cardWidth}px` }}
className={`rounded-xl shadow-2xl opacity-90 rotate-3 cursor-grabbing overflow-hidden ring-2 ring-emerald-500 bg-slate-900 aspect-[2.5/3.5]`}
>
<img src={draggedCard.image || draggedCard.image_uris?.normal} alt={draggedCard.name} className="w-full h-full object-cover" draggable={false} />
</div>
) : null}
{draggedCard ? (() => {
const useArtCrop = localCardWidth < 130 && !!draggedCard.imageArtCrop;
const displayImage = useArtCrop ? draggedCard.imageArtCrop : (draggedCard.image || draggedCard.image_uris?.normal);
// Default to square for crop, standard ratio otherwise
const aspectRatio = useArtCrop ? 'aspect-square' : 'aspect-[2.5/3.5]';
return (
<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>
</DndContext>
</div>
@@ -613,7 +952,13 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
const DeckCardItem = ({ card, useArtCrop, isFoil, onCardClick, onHover }: any) => {
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 (
<div

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
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 { FoilOverlay, FloatingPreview } from '../../components/CardPreview';
import { useCardTouch } from '../../utils/interaction';
@@ -58,57 +58,141 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
return () => clearInterval(interval);
}, [pickExpiresAt]);
// --- UI State & Persistence ---
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem('draft_sidebarWidth');
return saved ? parseInt(saved, 10) : 320;
});
const [poolHeight, setPoolHeight] = useState<number>(() => {
const saved = localStorage.getItem('draft_poolHeight');
return saved ? parseInt(saved, 10) : 220;
});
const sidebarRef = React.useRef<HTMLDivElement>(null);
const poolRef = React.useRef<HTMLDivElement>(null);
const resizingState = React.useRef<{
startX: number,
startY: number,
startWidth: number,
startHeight: number,
active: 'sidebar' | 'pool' | null
}>({ startX: 0, startY: 0, startWidth: 0, startHeight: 0, active: null });
// Apply initial sizes visually without causing re-renders
useEffect(() => {
if (sidebarRef.current) sidebarRef.current.style.width = `${sidebarWidth}px`;
if (poolRef.current) poolRef.current.style.height = `${poolHeight}px`;
}, []); // Only on mount to set initial visual state, subsequent updates handled by resize logic
const [cardScale, setCardScale] = useState<number>(() => {
const saved = localStorage.getItem('draft_cardScale');
return saved ? parseFloat(saved) : 0.7;
return saved ? parseFloat(saved) : 0.35;
});
// Local state for smooth slider
const [localCardScale, setLocalCardScale] = useState(cardScale);
const containerRef = useRef<HTMLDivElement>(null);
// Sync local state if external update happens
useEffect(() => {
setLocalCardScale(cardScale);
if (containerRef.current) {
containerRef.current.style.setProperty('--card-scale', cardScale.toString());
}
}, [cardScale]);
const [layout, setLayout] = useState<'vertical' | 'horizontal'>(() => {
const saved = localStorage.getItem('draft_layout');
return (saved as 'vertical' | 'horizontal') || 'vertical';
});
const [layout, setLayout] = useState<'vertical' | 'horizontal'>('horizontal');
const [isResizing, setIsResizing] = useState(false);
useEffect(() => {
localStorage.setItem('draft_layout', layout);
}, [layout]);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('draft_sidebarCollapsed') === 'true';
});
// Persist settings
useEffect(() => {
localStorage.setItem('draft_sidebarCollapsed', isSidebarCollapsed.toString());
}, [isSidebarCollapsed]);
useEffect(() => {
localStorage.setItem('draft_poolHeight', poolHeight.toString());
}, [poolHeight]);
useEffect(() => {
localStorage.setItem('draft_sidebarWidth', sidebarWidth.toString());
}, [sidebarWidth]);
useEffect(() => {
localStorage.setItem('draft_cardScale', cardScale.toString());
}, [cardScale]);
// Resize Handlers
const startResizing = (e: React.MouseEvent) => {
setIsResizing(true);
e.preventDefault();
const handleResizeStart = (type: 'sidebar' | 'pool', e: React.MouseEvent | React.TouchEvent) => {
// Prevent default to avoid scrolling/selection
if (e.cancelable) e.preventDefault();
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
resizingState.current = {
startX: clientX,
startY: clientY,
startWidth: sidebarRef.current?.getBoundingClientRect().width || 320,
startHeight: poolRef.current?.getBoundingClientRect().height || 220,
active: type
};
document.addEventListener('mousemove', onResizeMove);
document.addEventListener('touchmove', onResizeMove, { passive: false });
document.addEventListener('mouseup', onResizeEnd);
document.addEventListener('touchend', onResizeEnd);
document.body.style.cursor = type === 'sidebar' ? 'col-resize' : 'row-resize';
};
useEffect(() => {
const stopResizing = () => setIsResizing(false);
const resize = (e: MouseEvent) => {
if (isResizing) {
const newHeight = window.innerHeight - e.clientY;
// Limits: Min 100px, Max 60% of screen
const maxHeight = window.innerHeight * 0.6;
if (newHeight >= 100 && newHeight <= maxHeight) {
setPoolHeight(newHeight);
}
}
};
const onResizeMove = React.useCallback((e: MouseEvent | TouchEvent) => {
if (!resizingState.current.active) return;
if (isResizing) {
document.addEventListener('mousemove', resize);
document.addEventListener('mouseup', stopResizing);
if (e.cancelable) e.preventDefault();
const clientX = (e as TouchEvent).touches ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX;
const clientY = (e as TouchEvent).touches ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY;
// Direct DOM manipulation for performance
requestAnimationFrame(() => {
if (resizingState.current.active === 'sidebar' && sidebarRef.current) {
const delta = clientX - resizingState.current.startX;
const newWidth = Math.max(200, Math.min(600, resizingState.current.startWidth + delta));
sidebarRef.current.style.width = `${newWidth}px`;
}
if (resizingState.current.active === 'pool' && poolRef.current) {
const delta = resizingState.current.startY - clientY; // Dragging up increases height
const newHeight = Math.max(100, Math.min(window.innerHeight * 0.6, resizingState.current.startHeight + delta));
poolRef.current.style.height = `${newHeight}px`;
}
});
}, []);
const onResizeEnd = React.useCallback(() => {
// Commit final state
if (resizingState.current.active === 'sidebar' && sidebarRef.current) {
setSidebarWidth(parseInt(sidebarRef.current.style.width));
}
return () => {
document.removeEventListener('mousemove', resize);
document.removeEventListener('mouseup', stopResizing);
};
}, [isResizing]);
if (resizingState.current.active === 'pool' && poolRef.current) {
setPoolHeight(parseInt(poolRef.current.style.height));
}
resizingState.current.active = null;
document.removeEventListener('mousemove', onResizeMove);
document.removeEventListener('touchmove', onResizeMove);
document.removeEventListener('mouseup', onResizeEnd);
document.removeEventListener('touchend', onResizeEnd);
document.body.style.cursor = 'default';
}, []);
const [hoveredCard, setHoveredCard] = useState<any>(null);
const [displayCard, setDisplayCard] = useState<any>(null);
@@ -152,7 +236,12 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
};
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}>
<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>
{/* Card Scalar */}
<div className="flex flex-col gap-1 w-24 md:w-32">
<label className="text-[10px] text-slate-500 uppercase font-bold tracking-wider">Card Size</label>
<div className="flex items-center gap-2 bg-slate-900 rounded-lg px-2 border border-slate-700 h-10">
<div className="w-2 h-3 rounded border border-slate-500 bg-slate-700" title="Small Cards" />
<input
type="range"
min="0.5"
max="1.5"
min="0.35"
max="1.0"
step="0.01"
value={cardScale}
onChange={(e) => setCardScale(parseFloat(e.target.value))}
className="w-full h-1 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-emerald-500"
value={localCardScale}
onChange={(e) => {
const val = parseFloat(e.target.value);
setLocalCardScale(val);
// Direct DOM update for performance
if (containerRef.current) {
containerRef.current.style.setProperty('--card-scale', val.toString());
}
}}
onMouseUp={() => setCardScale(localCardScale)}
onTouchEnd={() => setCardScale(localCardScale)}
className="w-24 accent-emerald-500 cursor-pointer h-1.5 bg-slate-800 rounded-lg appearance-none"
/>
<div className="w-3 h-5 rounded border border-slate-500 bg-slate-700" title="Large Cards" />
</div>
</div>
@@ -225,64 +324,97 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
<div className="flex-1 flex overflow-hidden">
{/* 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' }}>
<div className="w-full relative sticky top-8 px-6">
<div
className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out"
style={{
transformStyle: 'preserve-3d',
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
}}
{/* Collapsed State: Toolbar Column */}
{isSidebarCollapsed ? (
<div key="collapsed" className="hidden lg:flex shrink-0 w-12 flex-col items-center py-4 bg-slate-900 border-r border-slate-800/50 backdrop-blur-sm z-10 gap-4 transition-all duration-300">
<button
onClick={() => setIsSidebarCollapsed(false)}
className="p-3 rounded-xl transition-all duration-200 group relative text-slate-500 hover:text-purple-400 hover:bg-slate-800"
title="Expand Preview"
>
{/* Front Face (Hovered Card) */}
<div
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
style={{ backfaceVisibility: 'hidden' }}
>
{(hoveredCard || displayCard) && (
<div className="w-full h-full flex flex-col bg-slate-900 rounded-xl relative overflow-hidden">
<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 h-full object-cover rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
draggable={false}
/>
{/* Foil Overlay for Preview */}
{((hoveredCard || displayCard).finish === 'foil') && <FoilOverlay />}
<Eye className="w-6 h-6" />
<span className="absolute left-full ml-3 top-1/2 -translate-y-1/2 bg-slate-800 text-white text-xs font-bold px-2 py-1 rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none ring-1 ring-white/10 z-50">
Card Preview
</span>
</button>
</div>
) : (
<div
key="expanded"
ref={sidebarRef}
className="hidden lg:flex shrink-0 flex-col items-center justify-start pt-8 border-r border-slate-800/50 bg-slate-900/20 backdrop-blur-sm z-10 relative group/sidebar"
style={{ perspective: '1000px', width: `${sidebarWidth}px` }}
>
{/* Collapse Button */}
<button
onClick={() => setIsSidebarCollapsed(true)}
className="absolute top-2 right-2 p-1.5 bg-slate-800/80 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors z-20 opacity-0 group-hover/sidebar:opacity-100"
title="Collapse Preview"
>
<ChevronLeft className="w-4 h-4" />
</button>
<div className="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">
<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 className="w-full relative sticky top-8 px-6">
<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={{
backfaceVisibility: 'hidden',
transform: 'rotateY(180deg)'
transformStyle: 'preserve-3d',
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)'
}}
>
<img
src="/images/back.jpg"
alt="Card Back"
className="w-full h-full object-cover"
draggable={false}
/>
{/* Front Face (Hovered Card) */}
<div
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
style={{ backfaceVisibility: 'hidden' }}
>
{(hoveredCard || displayCard) && (
<div className="w-full h-full flex flex-col bg-slate-900 rounded-xl">
<img
src={(hoveredCard || displayCard).image || (hoveredCard || displayCard).image_uris?.normal || (hoveredCard || displayCard).card_faces?.[0]?.image_uris?.normal}
alt={(hoveredCard || displayCard).name}
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
draggable={false}
/>
<div className="mt-4 text-center">
<h3 className="text-lg font-bold text-slate-200">{(hoveredCard || displayCard).name}</h3>
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{(hoveredCard || displayCard).typeLine || (hoveredCard || displayCard).type_line}</p>
{(hoveredCard || displayCard).oracle_text && (
<div className="mt-4 text-xs text-slate-400 text-left bg-slate-950 p-3 rounded-lg border border-slate-800 leading-relaxed max-h-60 overflow-y-auto custom-scrollbar">
{(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)}
</div>
)}
</div>
</div>
)}
</div>
{/* Back Face (Card Back) */}
<div
className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
style={{
backfaceVisibility: 'hidden',
transform: 'rotateY(180deg)'
}}
>
<img
src="/images/back.jpg"
alt="Card Back"
className="w-full h-full object-cover"
draggable={false}
/>
</div>
</div>
</div>
{/* Oracle Text Box Below Card */}
{(hoveredCard || displayCard)?.oracle_text && (
<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'}`}>
{(hoveredCard || displayCard).oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-2 last:mb-0">{line}</p>)}
</div>
)}
{/* Resize Handle for Sidebar */}
<div
className="absolute right-0 top-0 bottom-0 w-1 bg-transparent hover:bg-emerald-500/50 cursor-col-resize z-50 flex flex-col justify-center items-center group transition-colors"
onMouseDown={(e) => handleResizeStart('sidebar', e)}
onTouchStart={(e) => handleResizeStart('sidebar', e)}
>
<div className="h-8 w-1 bg-slate-700/50 rounded-full group-hover:bg-emerald-400 transition-colors" />
</div>
</div>
</div>
)}
{/* Main Content Area: Handles both Pack and Pool based on layout */}
{layout === 'vertical' ? (
@@ -372,29 +504,31 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
{/* Resize Handle */}
<div
className="h-1 bg-slate-800 hover:bg-emerald-500 cursor-row-resize z-30 transition-colors w-full flex items-center justify-center shrink-0"
onMouseDown={startResizing}
className="h-2 bg-slate-800 hover:bg-emerald-500/50 cursor-row-resize z-30 transition-colors w-full flex items-center justify-center shrink-0 group touch-none"
onMouseDown={(e) => handleResizeStart('pool', e)}
onTouchStart={(e) => handleResizeStart('pool', e)}
>
<div className="w-16 h-1 bg-slate-600 rounded-full"></div>
<div className="w-16 h-1 bg-slate-600 rounded-full group-hover:bg-emerald-300"></div>
</div>
{/* Bottom: Pool (Horizontal Strip) */}
<PoolDroppable
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"
style={{ height: `${poolHeight}px` }}
>
<div className="px-6 py-2 flex items-center justify-between shrink-0">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
Your Pool ({pickedCards.length})
</h3>
</div>
<div className="flex-1 overflow-x-auto flex items-center gap-2 px-6 pb-4 custom-scrollbar">
{pickedCards.map((card: any, idx: number) => (
<PoolCardItem key={`${card.id}-${idx}`} card={card} setHoveredCard={setHoveredCard} />
))}
</div>
</PoolDroppable>
<div ref={poolRef} style={{ height: `${poolHeight}px` }} className="shrink-0 flex flex-col overflow-hidden">
<PoolDroppable
className="flex-1 bg-slate-900/90 backdrop-blur-md flex flex-col z-20 shadow-[-10px_-10px_30px_rgba(0,0,0,0.3)] border-t border-slate-800 min-h-0"
>
<div className="px-6 py-2 flex items-center justify-between shrink-0">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
Your Pool ({pickedCards.length})
</h3>
</div>
<div className="flex-1 overflow-x-auto flex gap-2 px-6 pb-2 pt-2 custom-scrollbar min-h-0">
{pickedCards.map((card: any, idx: number) => (
<PoolCardItem key={`${card.id}-${idx}`} card={card} setHoveredCard={setHoveredCard} />
))}
</div>
</PoolDroppable>
</div>
</div>
)}
@@ -415,7 +549,7 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
{draggedCard ? (
<div
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} />
</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 isFoil = card.finish === 'foil';
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => {
@@ -474,7 +608,7 @@ const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any)
return (
<div
ref={setNodeRef}
style={{ ...style, width: `${14 * cardScale}rem` }}
style={{ ...style, width: `calc(14rem * var(--card-scale))` }}
{...attributes}
{...mergedListeners}
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer"
@@ -506,7 +640,7 @@ const PoolCardItem = ({ card, setHoveredCard, vertical = false }: any) => {
return (
<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)}
onMouseLeave={() => setHoveredCard(null)}
onTouchStart={onTouchStart}
@@ -517,7 +651,7 @@ const PoolCardItem = ({ card, setHoveredCard, vertical = false }: any) => {
<img
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
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}
/>
</div>

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect, useRef } from 'react';
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 { useToast } from '../../components/Toast';
import { GameView } from '../game/GameView';
import { DraftView } from '../draft/DraftView';
import { DeckBuilderView } from '../draft/DeckBuilderView';
@@ -45,18 +46,73 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
const [modalOpen, setModalOpen] = useState(false);
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
const [message, setMessage] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>(initialRoom.messages || []);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [gameState, setGameState] = useState<any>(initialGameState || null);
const [draftState, setDraftState] = useState<any>(initialDraftState || null);
const [mobileTab, setMobileTab] = useState<'game' | 'chat'>('game');
const [mobileTab, setMobileTab] = useState<'game' | 'chat'>('game'); // Keep for mobile
// Derived State
const host = room.players.find(p => p.isHost);
const isHostOffline = host?.isOffline;
const isMeHost = currentPlayerId === host?.id;
const prevPlayersRef = useRef<Player[]>(initialRoom.players);
// Persistence
useEffect(() => {
localStorage.setItem('notifications_enabled', notificationsEnabled.toString());
}, [notificationsEnabled]);
// Player Notification Logic
useEffect(() => {
if (!notificationsEnabled) {
prevPlayersRef.current = room.players;
return;
}
const prev = prevPlayersRef.current;
const curr = room.players;
// 1. New Players
curr.forEach(p => {
if (!prev.find(old => old.id === p.id)) {
showToast(`${p.name} (${p.role}) joined the room.`, 'info');
}
});
// 2. Left Players
prev.forEach(p => {
if (!curr.find(newP => newP.id === p.id)) {
showToast(`${p.name} left the room.`, 'warning');
}
});
// 3. Status Changes (Disconnect/Reconnect)
curr.forEach(p => {
const old = prev.find(o => o.id === p.id);
if (old) {
if (!old.isOffline && p.isOffline) {
showToast(`${p.name} lost connection.`, 'error');
}
if (old.isOffline && !p.isOffline) {
showToast(`${p.name} reconnected!`, 'success');
}
}
});
prevPlayersRef.current = curr;
}, [room.players, notificationsEnabled, showToast]);
// Effects
useEffect(() => {
@@ -235,125 +291,225 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
};
return (
<div className="flex h-full flex-col lg:flex-row gap-4 overflow-hidden">
{/* Mobile Tab Bar */}
<div className="lg:hidden shrink-0 flex items-center bg-slate-800 border-b border-slate-700">
<button
onClick={() => setMobileTab('game')}
className={`flex-1 p-3 flex items-center justify-center gap-2 text-sm font-bold transition-colors ${mobileTab === 'game' ? 'text-emerald-400 bg-slate-700/50 border-b-2 border-emerald-500' : 'text-slate-400 hover:text-slate-200'}`}
>
<Layers className="w-4 h-4" /> Game
</button>
<button
onClick={() => setMobileTab('chat')}
className={`flex-1 p-3 flex items-center justify-center gap-2 text-sm font-bold transition-colors ${mobileTab === 'chat' ? 'text-purple-400 bg-slate-700/50 border-b-2 border-purple-500' : 'text-slate-400 hover:text-slate-200'}`}
>
<div className="flex items-center gap-1">
<Users className="w-4 h-4" />
<span className="text-slate-600">/</span>
<MessageSquare className="w-4 h-4" />
</div>
Lobby & Chat
</button>
<div className="flex h-full w-full overflow-hidden relative">
{/* --- MOBILE LAYOUT (Keep simplified tabs for small screens) --- */}
<div className="lg:hidden flex flex-col w-full h-full">
{/* Mobile Tab Bar */}
<div className="shrink-0 flex items-center bg-slate-800 border-b border-slate-700">
<button
onClick={() => setMobileTab('game')}
className={`flex-1 p-3 flex items-center justify-center gap-2 text-sm font-bold transition-colors ${mobileTab === 'game' ? 'text-emerald-400 bg-slate-700/50 border-b-2 border-emerald-500' : 'text-slate-400 hover:text-slate-200'}`}
>
<Layers className="w-4 h-4" /> Game
</button>
<button
onClick={() => setMobileTab('chat')}
className={`flex-1 p-3 flex items-center justify-center gap-2 text-sm font-bold transition-colors ${mobileTab === 'chat' ? 'text-purple-400 bg-slate-700/50 border-b-2 border-purple-500' : 'text-slate-400 hover:text-slate-200'}`}
>
<div className="flex items-center gap-1">
<Users className="w-4 h-4" />
<span className="text-slate-600">/</span>
<MessageSquare className="w-4 h-4" />
</div>
Lobby & Chat
</button>
</div>
{/* 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 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()}
</div>
<div className={`w-full lg:w-80 shrink-0 flex flex-col gap-4 min-h-0 ${mobileTab === 'chat' ? 'flex' : 'hidden lg:flex'}`}>
<div className="flex-1 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl overflow-hidden flex flex-col">
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
<Users className="w-4 h-4" /> Lobby
</h3>
{/* Right Collapsible Toolbar */}
<div className="hidden lg:flex w-14 shrink-0 flex-col items-center gap-4 py-4 bg-slate-900 border-l border-slate-800 z-30 relative">
<button
onClick={() => setActivePanel(activePanel === 'lobby' ? null : 'lobby')}
className={`p-3 rounded-xl transition-all duration-200 group relative ${activePanel === 'lobby' ? 'bg-purple-600 text-white shadow-lg shadow-purple-900/50' : 'text-slate-500 hover:text-purple-400 hover:bg-slate-800'}`}
title="Lobby & Players"
>
<Users className="w-6 h-6" />
<span className="absolute right-full mr-3 top-1/2 -translate-y-1/2 bg-slate-800 text-white text-xs font-bold px-2 py-1 rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none ring-1 ring-white/10">
Lobby
</span>
</button>
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
{room.players.map(p => {
const isReady = (p as any).ready;
const isMe = p.id === currentPlayerId;
const isSolo = room.players.length === 1 && room.status === 'playing';
return (
<div key={p.id} className="flex items-center justify-between bg-slate-900/50 p-2 rounded-lg border border-slate-700/50 group">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs ${p.role === 'spectator' ? 'bg-slate-700 text-slate-300' : 'bg-gradient-to-br from-purple-500 to-blue-500 text-white'}`}>
{p.name.substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col">
<span className={`text-sm font-medium ${isMe ? 'text-white' : 'text-slate-300'}`}>
{p.name} {isMe && '(You)'}
</span>
<span className="text-[10px] uppercase font-bold tracking-wider text-slate-500">
{p.role} {p.isHost && <span className="text-amber-500 ml-1"> Host</span>}
{isReady && room.status === 'deck_building' && <span className="text-emerald-500 ml-1"> Ready</span>}
{p.isOffline && <span className="text-red-500 ml-1"> Offline</span>}
</span>
</div>
</div>
<div className={`flex gap-2 ${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>
)
})}
<button
onClick={() => setActivePanel(activePanel === 'chat' ? null : 'chat')}
className={`p-3 rounded-xl transition-all duration-200 group relative ${activePanel === 'chat' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/50' : 'text-slate-500 hover:text-blue-400 hover:bg-slate-800'}`}
title="Chat"
>
<div className="relative">
<MessageSquare className="w-6 h-6" />
{/* Unread indicator could go here */}
</div>
</div>
<div className="h-1/2 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl flex flex-col">
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
<MessageSquare className="w-4 h-4" /> Chat
</h3>
<div className="flex-1 overflow-y-auto space-y-2 mb-3 pr-1 custom-scrollbar">
{messages.map(msg => (
<div key={msg.id} className="text-sm">
<span className="font-bold text-purple-400 text-xs">{msg.sender}: </span>
<span className="text-slate-300">{msg.text}</span>
</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>
<span className="absolute right-full mr-3 top-1/2 -translate-y-1/2 bg-slate-800 text-white text-xs font-bold px-2 py-1 rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none ring-1 ring-white/10">
Chat
</span>
</button>
</div>
{/* Floating Panel (Desktop) */}
{activePanel && (
<div className="hidden lg:flex absolute right-16 top-4 bottom-4 w-96 bg-slate-800/95 backdrop-blur-xl border border-slate-700/50 rounded-2xl shadow-2xl z-40 flex-col animate-in slide-in-from-right-10 fade-in duration-200 overflow-hidden ring-1 ring-white/10">
{/* Header */}
<div className="p-4 border-b border-slate-700 flex justify-between items-center bg-slate-900/50">
<h3 className="text-lg font-bold text-white flex items-center gap-2">
{activePanel === 'lobby' ? <><Users className="w-5 h-5 text-purple-400" /> Lobby</> : <><MessageSquare className="w-5 h-5 text-blue-400" /> Chat</>}
</h3>
<button onClick={() => setActivePanel(null)} className="p-1 hover:bg-slate-700 rounded-lg text-slate-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Lobby Content */}
{activePanel === 'lobby' && (
<div className="flex-1 flex flex-col min-h-0">
{/* Controls */}
<div className="p-3 bg-slate-900/30 flex items-center justify-between border-b border-slate-800">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">{room.players.length} Connected</span>
<button
onClick={() => setNotificationsEnabled(!notificationsEnabled)}
className={`flex items-center gap-2 text-xs font-bold px-2 py-1 rounded-lg transition-colors border ${notificationsEnabled ? 'bg-slate-800 border-slate-600 text-slate-300 hover:text-white' : 'bg-red-900/20 border-red-900/50 text-red-400'}`}
title={notificationsEnabled ? "Disable Notifications" : "Enable Notifications"}
>
{notificationsEnabled ? <Bell className="w-3 h-3" /> : <BellOff className="w-3 h-3" />}
{notificationsEnabled ? 'On' : 'Off'}
</button>
</div>
{/* Player List */}
<div className="flex-1 overflow-y-auto p-4 space-y-2 custom-scrollbar">
{room.players.map(p => {
const isReady = (p as any).ready;
const isMe = p.id === currentPlayerId;
const isSolo = room.players.length === 1 && room.status === 'playing';
return (
<div key={p.id} className="flex items-center justify-between bg-slate-900/80 p-3 rounded-xl border border-slate-700/50 hover:border-slate-600 transition-colors group">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm shadow-inner ${p.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 */}
{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">

View File

@@ -19,12 +19,12 @@ export function useCardTouch(
touchStartCount.current = e.touches.length;
isLongPress.current = false;
// Only Start "Preview" Timer if 2 fingers
if (e.touches.length === 2) {
// Start Preview Timer (1 finger is standard mobile long-press)
if (e.touches.length === 1) {
timerRef.current = setTimeout(() => {
isLongPress.current = true;
onHover(cardPayload);
}, 400); // 400ms threshold
}, 500); // 500ms threshold (standard long press)
}
}, [onHover, cardPayload]);