CLAUDE: Mobile drag-drop lineup builder and touch-friendly UI improvements
- Add vuedraggable for mobile-friendly lineup building - Add touch delay and threshold settings for better mobile UX - Add drag ghost/chosen/dragging visual states - Add replacement mode visual feedback when dragging over occupied slots - Add getBench action to useGameActions for substitution panel - Add BN (bench) to valid positions in LineupPlayerState - Update lineup service to load full lineup (active + bench) - Add touch-manipulation CSS to UI components (ActionButton, ButtonGroup, ToggleSwitch) - Add select-none to prevent text selection during touch interactions - Add mobile touch patterns documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e058bc4a6c
commit
52706bed40
@ -60,7 +60,7 @@ class LineupPlayerState(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def validate_position(cls, v: str) -> str:
|
def validate_position(cls, v: str) -> str:
|
||||||
"""Ensure position is valid"""
|
"""Ensure position is valid"""
|
||||||
valid_positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"]
|
valid_positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH", "BN"]
|
||||||
if v not in valid_positions:
|
if v not in valid_positions:
|
||||||
raise ValueError(f"Position must be one of {valid_positions}")
|
raise ValueError(f"Position must be one of {valid_positions}")
|
||||||
return v
|
return v
|
||||||
|
|||||||
@ -121,7 +121,7 @@ class LineupService:
|
|||||||
"""
|
"""
|
||||||
Load existing team lineup from database with player data.
|
Load existing team lineup from database with player data.
|
||||||
|
|
||||||
1. Fetches active lineup from database
|
1. Fetches full lineup (active + bench) from database
|
||||||
2. Fetches player data from SBA API (for SBA league)
|
2. Fetches player data from SBA API (for SBA league)
|
||||||
3. Returns TeamLineupState with player info populated
|
3. Returns TeamLineupState with player info populated
|
||||||
|
|
||||||
@ -131,10 +131,10 @@ class LineupService:
|
|||||||
league_id: League identifier ('sba' or 'pd')
|
league_id: League identifier ('sba' or 'pd')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
TeamLineupState with player data, or None if no lineup found
|
TeamLineupState with player data (including bench), or None if no lineup found
|
||||||
"""
|
"""
|
||||||
# Step 1: Get lineup from database
|
# Step 1: Get full lineup from database (active + bench)
|
||||||
lineup_entries = await self.db_ops.get_active_lineup(game_id, team_id)
|
lineup_entries = await self.db_ops.get_full_lineup(game_id, team_id)
|
||||||
if not lineup_entries:
|
if not lineup_entries:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
164
frontend-sba/.claude/MOBILE_TEXT_SELECTION_REVIEW.md
Normal file
164
frontend-sba/.claude/MOBILE_TEXT_SELECTION_REVIEW.md
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
# Mobile Text Selection Prevention Review
|
||||||
|
|
||||||
|
**Date**: 2026-01-17
|
||||||
|
**Purpose**: Review and apply text selection prevention patterns for mobile touch/drag interactions
|
||||||
|
|
||||||
|
## Pattern Applied
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Prevent text selection on all draggable/interactive elements AND their children */
|
||||||
|
:deep([draggable="true"]),
|
||||||
|
:deep([draggable="true"] *),
|
||||||
|
:deep(.sortable-item),
|
||||||
|
:deep(.sortable-item *),
|
||||||
|
.interactive-item,
|
||||||
|
.interactive-item * {
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
-moz-user-select: none !important;
|
||||||
|
-ms-user-select: none !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-touch-callout: none !important; /* Prevent iOS callout menu */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch action on containers only */
|
||||||
|
:deep([draggable="true"]),
|
||||||
|
:deep(.sortable-item) {
|
||||||
|
touch-action: manipulation; /* Allow pan/zoom but optimize for touch */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tailwind utilities used**: `select-none` and `touch-manipulation`
|
||||||
|
|
||||||
|
## Components Reviewed
|
||||||
|
|
||||||
|
### ✅ Already Compliant (No Changes Needed)
|
||||||
|
|
||||||
|
#### LineupBuilder.vue
|
||||||
|
- **Status**: Fully compliant
|
||||||
|
- **Reason**: Already has comprehensive text selection prevention for vuedraggable components
|
||||||
|
- **Implementation**: Uses `:deep()` selectors with complete iOS support
|
||||||
|
- **Details**:
|
||||||
|
- Prevents text selection on all draggable roster items
|
||||||
|
- Prevents text selection on lineup slot items
|
||||||
|
- Applies to all child elements using wildcard selectors
|
||||||
|
- Includes iOS-specific `-webkit-touch-callout: none` for callout menu prevention
|
||||||
|
- Uses `touch-action: manipulation` for proper touch optimization
|
||||||
|
|
||||||
|
### ✅ Updated Components
|
||||||
|
|
||||||
|
#### 1. UI/ToggleSwitch.vue
|
||||||
|
- **Changes**:
|
||||||
|
- Added `select-none` class to root container
|
||||||
|
- Added `<style scoped>` block with text selection prevention for button and all children
|
||||||
|
- Added `touch-action: manipulation` to button
|
||||||
|
- **Reason**: Toggle switches are frequently tapped on mobile and users were selecting text accidentally
|
||||||
|
|
||||||
|
#### 2. UI/ButtonGroup.vue
|
||||||
|
- **Changes**:
|
||||||
|
- Added `select-none` class to root container
|
||||||
|
- Added `<style scoped>` block with text selection prevention for all buttons and children
|
||||||
|
- Added `touch-action: manipulation` to buttons
|
||||||
|
- **Reason**: Button groups used for decisions (infield depth, outfield depth) are touch-intensive
|
||||||
|
|
||||||
|
#### 3. UI/ActionButton.vue
|
||||||
|
- **Changes**:
|
||||||
|
- Added `select-none touch-manipulation` Tailwind classes to button element
|
||||||
|
- Added `<style scoped>` block with text selection prevention for button and all children
|
||||||
|
- **Reason**: Primary action buttons (Submit, Roll Dice, etc.) are core mobile interactions
|
||||||
|
|
||||||
|
#### 4. Schedule/GameCard.vue
|
||||||
|
- **Changes**:
|
||||||
|
- Added `select-none touch-manipulation` classes to "Play This Game" button
|
||||||
|
- **Reason**: Game card buttons are frequently tapped to start games
|
||||||
|
|
||||||
|
#### 5. Substitutions/SubstitutionPanel.vue
|
||||||
|
- **Changes**:
|
||||||
|
- Added `select-none` class to tab navigation container
|
||||||
|
- Added `select-none` class to player selection grid
|
||||||
|
- Added `touch-manipulation` class to player buttons
|
||||||
|
- Updated CSS for `.tab-button` with user-select and touch-action properties
|
||||||
|
- Updated CSS for `.player-button` with user-select properties
|
||||||
|
- **Reason**: Tab navigation and player selection involve frequent tapping on mobile
|
||||||
|
|
||||||
|
#### 6. Decisions/OffensiveApproach.vue
|
||||||
|
- **Changes**:
|
||||||
|
- Added `select-none` class to action selection grid container
|
||||||
|
- Added `touch-manipulation` class to action buttons
|
||||||
|
- **Reason**: Action selection buttons (Swing Away, Steal, Hit and Run, etc.) are tapped frequently
|
||||||
|
|
||||||
|
### ⚠️ Components NOT Modified (No Touch/Drag Interactions)
|
||||||
|
|
||||||
|
#### Display/Read-Only Components
|
||||||
|
- **Game/ScoreBoard.vue** - Pure display, no interaction
|
||||||
|
- **Game/GameBoard.vue** - Visual diamond display, no dragging
|
||||||
|
- **Game/CurrentSituation.vue** - Player card display
|
||||||
|
- **Game/PlayByPlay.vue** - Text feed, needs text selection for copying plays
|
||||||
|
- **Game/GameStats.vue** - Tabular data display
|
||||||
|
|
||||||
|
#### Decision Input Components (Already Use UI Components)
|
||||||
|
- **Decisions/DecisionPanel.vue** - Container only, uses child components that were updated
|
||||||
|
- **Decisions/DefensiveSetup.vue** - Uses ButtonGroup and ToggleSwitch (already updated)
|
||||||
|
- **Decisions/StolenBaseInputs.vue** - Uses ToggleSwitch (already updated)
|
||||||
|
|
||||||
|
#### Gameplay Components
|
||||||
|
- **Gameplay/DiceRoller.vue** - Uses ActionButton (already updated)
|
||||||
|
- **Gameplay/ManualOutcomeEntry.vue** - Form inputs (needs text selection)
|
||||||
|
- **Gameplay/PlayResult.vue** - Display only
|
||||||
|
|
||||||
|
#### Substitution Components (Use Updated SubstitutionPanel)
|
||||||
|
- **Substitutions/PinchHitterSelector.vue** - Uses parent panel's classes
|
||||||
|
- **Substitutions/PitchingChangeSelector.vue** - Uses parent panel's classes
|
||||||
|
- **Substitutions/DefensiveReplacementSelector.vue** - Uses parent panel's classes
|
||||||
|
|
||||||
|
## Summary Statistics
|
||||||
|
|
||||||
|
- **Total Components Reviewed**: 33 Vue files
|
||||||
|
- **Components Updated**: 6
|
||||||
|
- **Components Already Compliant**: 1 (LineupBuilder.vue)
|
||||||
|
- **Components Skipped (Read-Only/Form Inputs)**: 26
|
||||||
|
|
||||||
|
## Testing Recommendations
|
||||||
|
|
||||||
|
Test on actual mobile devices:
|
||||||
|
1. **iPhone/iPad** (iOS Safari) - Test `-webkit-touch-callout` prevention
|
||||||
|
2. **Android** (Chrome Mobile) - Test general touch behavior
|
||||||
|
3. **Focus Areas**:
|
||||||
|
- Tap buttons rapidly without text selection
|
||||||
|
- Drag roster players in LineupBuilder without selecting text
|
||||||
|
- Toggle switches in defensive/offensive decisions
|
||||||
|
- Tap player cards in substitution panel
|
||||||
|
- Select actions in OffensiveApproach
|
||||||
|
|
||||||
|
## Pattern Rationale
|
||||||
|
|
||||||
|
### Why `select-none` on Interactive Elements?
|
||||||
|
- Mobile users often tap and hold slightly too long, triggering text selection
|
||||||
|
- Text selection on buttons/cards creates confusing blue highlight overlays
|
||||||
|
- Improves perceived responsiveness of the UI
|
||||||
|
|
||||||
|
### Why `touch-manipulation`?
|
||||||
|
- Optimizes touch events for browser (faster response)
|
||||||
|
- Allows pan/zoom gestures but disables double-tap-to-zoom on these elements
|
||||||
|
- Better UX for game controls
|
||||||
|
|
||||||
|
### Why NOT Apply to Everything?
|
||||||
|
- **Form inputs** need text selection for editing values
|
||||||
|
- **Play-by-play text** users may want to copy/paste plays
|
||||||
|
- **Score displays** may be useful to copy scores
|
||||||
|
- Only apply where text selection is **purely accidental** and **never intentional**
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
If new components are added with:
|
||||||
|
- Drag-and-drop functionality
|
||||||
|
- Touch-based sliders/toggles
|
||||||
|
- Clickable cards/buttons
|
||||||
|
- Tab navigation
|
||||||
|
|
||||||
|
Remember to apply this pattern immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Reviewed By**: Claude (Atlas - Principal Engineer)
|
||||||
|
**Review Type**: Mobile UX Enhancement
|
||||||
|
**Compliance**: Mobile-First Design Standards
|
||||||
156
frontend-sba/.claude/MOBILE_TOUCH_PATTERNS.md
Normal file
156
frontend-sba/.claude/MOBILE_TOUCH_PATTERNS.md
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
# Mobile Touch Patterns
|
||||||
|
|
||||||
|
## Text Selection Prevention for Touch Interactions
|
||||||
|
|
||||||
|
When building components with drag-and-drop, touch gestures, or interactive elements on mobile, text selection can interfere with the user experience. Users attempting to drag items may accidentally select text instead.
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
On mobile devices:
|
||||||
|
- Touch-and-hold triggers text selection
|
||||||
|
- Dragging while text is selected feels broken
|
||||||
|
- iOS shows callout menus on long press
|
||||||
|
- Double-tap zoom can interfere with interactions
|
||||||
|
|
||||||
|
### The Solution
|
||||||
|
|
||||||
|
Apply a combination of CSS rules and Tailwind classes to prevent text selection on interactive elements.
|
||||||
|
|
||||||
|
## CSS Pattern
|
||||||
|
|
||||||
|
Add this to your component's `<style scoped>` section:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Prevent text selection on all draggable/interactive elements AND their children */
|
||||||
|
:deep([draggable="true"]),
|
||||||
|
:deep([draggable="true"] *),
|
||||||
|
:deep(.sortable-item),
|
||||||
|
:deep(.sortable-item *),
|
||||||
|
.interactive-item,
|
||||||
|
.interactive-item * {
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
-moz-user-select: none !important;
|
||||||
|
-ms-user-select: none !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-touch-callout: none !important; /* Prevent iOS callout menu */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch action on containers only (not children) */
|
||||||
|
:deep([draggable="true"]),
|
||||||
|
:deep(.sortable-item) {
|
||||||
|
touch-action: manipulation; /* Allow pan/zoom but optimize for touch */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply to chosen/active drag states */
|
||||||
|
:deep(.sortable-chosen),
|
||||||
|
:deep(.sortable-chosen *) {
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
user-select: none !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tailwind Classes
|
||||||
|
|
||||||
|
Add these classes to interactive container elements:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="select-none touch-manipulation">
|
||||||
|
<!-- Interactive content -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `select-none` - Prevents text selection (`user-select: none`)
|
||||||
|
- `touch-manipulation` - Optimizes touch handling (`touch-action: manipulation`)
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Apply this pattern to:
|
||||||
|
|
||||||
|
1. **Draggable elements** - Any element using vuedraggable, SortableJS, or native drag-and-drop
|
||||||
|
2. **Interactive cards/list items** - Items users tap frequently
|
||||||
|
3. **Custom sliders/controls** - Touch-based UI controls
|
||||||
|
4. **Bottom sheets/modals** - Draggable overlays
|
||||||
|
5. **Swipeable elements** - Carousels, dismissible items
|
||||||
|
|
||||||
|
## When NOT to Apply
|
||||||
|
|
||||||
|
Do NOT apply to:
|
||||||
|
|
||||||
|
1. **Form inputs** - Text fields, textareas need selection
|
||||||
|
2. **Read-only content** - Articles, documentation, static text
|
||||||
|
3. **Copyable content** - Code blocks, IDs, URLs users might copy
|
||||||
|
|
||||||
|
## Vuedraggable Configuration
|
||||||
|
|
||||||
|
When using vuedraggable, also configure touch-friendly options:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const dragOptions = {
|
||||||
|
animation: 200,
|
||||||
|
ghostClass: 'drag-ghost',
|
||||||
|
chosenClass: 'drag-chosen',
|
||||||
|
dragClass: 'drag-dragging',
|
||||||
|
// Touch settings for mobile
|
||||||
|
delay: 50, // Small delay before drag starts
|
||||||
|
delayOnTouchOnly: true, // Only apply delay on touch devices
|
||||||
|
touchStartThreshold: 3, // Pixels of movement before drag starts
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Implementation
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="select-none">
|
||||||
|
<draggable
|
||||||
|
:list="items"
|
||||||
|
item-key="id"
|
||||||
|
v-bind="dragOptions"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<div class="item-card select-none touch-manipulation cursor-grab active:cursor-grabbing">
|
||||||
|
<span class="font-medium">{{ element.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Full pattern from above */
|
||||||
|
:deep([draggable="true"]),
|
||||||
|
:deep([draggable="true"] *) {
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-touch-callout: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep([draggable="true"]) {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
When testing on mobile:
|
||||||
|
|
||||||
|
- [ ] Can drag items without text selection appearing
|
||||||
|
- [ ] No iOS callout menu on long press
|
||||||
|
- [ ] Drag feels responsive (not delayed)
|
||||||
|
- [ ] Can still scroll the page normally
|
||||||
|
- [ ] Form inputs still allow text selection
|
||||||
|
- [ ] No double-tap zoom interference
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [MDN: user-select](https://developer.mozilla.org/en-US/docs/Web/CSS/user-select)
|
||||||
|
- [MDN: touch-action](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action)
|
||||||
|
- [SortableJS Options](https://github.com/SortableJS/Sortable#options)
|
||||||
|
- [Vuedraggable Documentation](https://github.com/SortableJS/vue.draggable.next)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Created**: 2025-01-17
|
||||||
|
**Pattern Source**: LineupBuilder.vue mobile drag-and-drop implementation
|
||||||
File diff suppressed because it is too large
Load Diff
@ -21,13 +21,14 @@
|
|||||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||||
Select Action
|
Select Action
|
||||||
</label>
|
</label>
|
||||||
<div class="grid grid-cols-1 gap-3">
|
<div class="grid grid-cols-1 gap-3 select-none">
|
||||||
<button
|
<button
|
||||||
v-for="option in availableActions"
|
v-for="option in availableActions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="!isActive || option.disabled"
|
:disabled="!isActive || option.disabled"
|
||||||
:class="getActionButtonClasses(option.value, option.disabled)"
|
:class="getActionButtonClasses(option.value, option.disabled)"
|
||||||
|
class="touch-manipulation"
|
||||||
:title="option.disabledReason"
|
:title="option.disabledReason"
|
||||||
@click="selectAction(option.value)"
|
@click="selectAction(option.value)"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -293,34 +293,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Substitution Panel Modal (Phase F5) -->
|
<!-- Floating Action Button - Undo -->
|
||||||
<Teleport to="body">
|
<div class="fixed bottom-6 right-6 z-40">
|
||||||
<div
|
|
||||||
v-if="showSubstitutions"
|
|
||||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
|
||||||
@click.self="handleSubstitutionCancel"
|
|
||||||
>
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
|
||||||
<SubstitutionPanel
|
|
||||||
v-if="myTeamId"
|
|
||||||
:game-id="gameId"
|
|
||||||
:team-id="myTeamId"
|
|
||||||
:current-lineup="currentLineup"
|
|
||||||
:bench-players="benchPlayers"
|
|
||||||
:current-pitcher="currentPitcher"
|
|
||||||
:current-batter="currentBatter"
|
|
||||||
@pinch-hitter="handlePinchHitter"
|
|
||||||
@defensive-replacement="handleDefensiveReplacement"
|
|
||||||
@pitching-change="handlePitchingChange"
|
|
||||||
@cancel="handleSubstitutionCancel"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- Floating Action Buttons -->
|
|
||||||
<div class="fixed bottom-6 right-6 flex flex-col gap-3 z-40">
|
|
||||||
<!-- Undo Last Play Button -->
|
|
||||||
<button
|
<button
|
||||||
v-if="canUndo"
|
v-if="canUndo"
|
||||||
class="w-14 h-14 bg-amber-500 hover:bg-amber-600 text-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
|
class="w-14 h-14 bg-amber-500 hover:bg-amber-600 text-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
|
||||||
@ -332,18 +306,6 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Substitutions Button -->
|
|
||||||
<button
|
|
||||||
v-if="canMakeSubstitutions"
|
|
||||||
class="w-16 h-16 bg-primary hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
|
|
||||||
aria-label="Open Substitutions"
|
|
||||||
@click="showSubstitutions = true"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -359,7 +321,6 @@ import CurrentSituation from '~/components/Game/CurrentSituation.vue'
|
|||||||
import PlayByPlay from '~/components/Game/PlayByPlay.vue'
|
import PlayByPlay from '~/components/Game/PlayByPlay.vue'
|
||||||
import DecisionPanel from '~/components/Decisions/DecisionPanel.vue'
|
import DecisionPanel from '~/components/Decisions/DecisionPanel.vue'
|
||||||
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
|
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
|
||||||
import SubstitutionPanel from '~/components/Substitutions/SubstitutionPanel.vue'
|
|
||||||
import type { DefensiveDecision, OffensiveDecision, PlayOutcome } from '~/types/game'
|
import type { DefensiveDecision, OffensiveDecision, PlayOutcome } from '~/types/game'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
@ -418,7 +379,6 @@ const currentDecisionPrompt = computed(() => gameStore.currentDecisionPrompt)
|
|||||||
// Local UI state
|
// Local UI state
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting')
|
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting')
|
||||||
const showSubstitutions = ref(false)
|
|
||||||
|
|
||||||
// Determine which team the user controls
|
// Determine which team the user controls
|
||||||
// For demo/testing: user controls whichever team needs to act
|
// For demo/testing: user controls whichever team needs to act
|
||||||
@ -537,42 +497,11 @@ const showGameplay = computed(() => {
|
|||||||
!needsOffensiveDecision.value
|
!needsOffensiveDecision.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const canMakeSubstitutions = computed(() => {
|
|
||||||
return gameState.value?.status === 'active' && isMyTurn.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const canUndo = computed(() => {
|
const canUndo = computed(() => {
|
||||||
// Can only undo if game is active and there are plays to undo
|
// Can only undo if game is active and there are plays to undo
|
||||||
return gameState.value?.status === 'active' && (gameState.value?.play_count ?? 0) > 0
|
return gameState.value?.status === 'active' && (gameState.value?.play_count ?? 0) > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// Lineup helpers for substitutions
|
|
||||||
const currentLineup = computed(() => {
|
|
||||||
if (!myTeamId.value) return []
|
|
||||||
return myTeamId.value === gameState.value?.home_team_id
|
|
||||||
? gameStore.homeLineup.filter(l => l.is_active)
|
|
||||||
: gameStore.awayLineup.filter(l => l.is_active)
|
|
||||||
})
|
|
||||||
|
|
||||||
const benchPlayers = computed(() => {
|
|
||||||
if (!myTeamId.value) return []
|
|
||||||
return myTeamId.value === gameState.value?.home_team_id
|
|
||||||
? gameStore.homeLineup.filter(l => !l.is_active)
|
|
||||||
: gameStore.awayLineup.filter(l => !l.is_active)
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentBatter = computed(() => {
|
|
||||||
const batterState = gameState.value?.current_batter
|
|
||||||
if (!batterState) return null
|
|
||||||
return gameStore.findPlayerInLineup(batterState.lineup_id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentPitcher = computed(() => {
|
|
||||||
const pitcherState = gameState.value?.current_pitcher
|
|
||||||
if (!pitcherState) return null
|
|
||||||
return gameStore.findPlayerInLineup(pitcherState.lineup_id)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Methods - Gameplay (Phase F4)
|
// Methods - Gameplay (Phase F4)
|
||||||
const handleRollDice = async () => {
|
const handleRollDice = async () => {
|
||||||
console.log('[GamePlay] Rolling dice')
|
console.log('[GamePlay] Rolling dice')
|
||||||
@ -642,58 +571,6 @@ const handleStealAttemptsSubmit = (attempts: number[]) => {
|
|||||||
gameStore.setPendingStealAttempts(attempts)
|
gameStore.setPendingStealAttempts(attempts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Methods - Substitutions (Phase F5)
|
|
||||||
const handlePinchHitter = async (data: { playerOutLineupId: number; playerInCardId: number; teamId: number }) => {
|
|
||||||
console.log('[GamePlay] Submitting pinch hitter:', data)
|
|
||||||
try {
|
|
||||||
await actions.submitSubstitution(
|
|
||||||
'pinch_hitter',
|
|
||||||
data.playerOutLineupId,
|
|
||||||
data.playerInCardId,
|
|
||||||
data.teamId
|
|
||||||
)
|
|
||||||
showSubstitutions.value = false
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[GamePlay] Failed to submit pinch hitter:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDefensiveReplacement = async (data: { playerOutLineupId: number; playerInCardId: number; newPosition: string; teamId: number }) => {
|
|
||||||
console.log('[GamePlay] Submitting defensive replacement:', data)
|
|
||||||
try {
|
|
||||||
await actions.submitSubstitution(
|
|
||||||
'defensive_replacement',
|
|
||||||
data.playerOutLineupId,
|
|
||||||
data.playerInCardId,
|
|
||||||
data.teamId,
|
|
||||||
data.newPosition
|
|
||||||
)
|
|
||||||
showSubstitutions.value = false
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[GamePlay] Failed to submit defensive replacement:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePitchingChange = async (data: { playerOutLineupId: number; playerInCardId: number; teamId: number }) => {
|
|
||||||
console.log('[GamePlay] Submitting pitching change:', data)
|
|
||||||
try {
|
|
||||||
await actions.submitSubstitution(
|
|
||||||
'pitching_change',
|
|
||||||
data.playerOutLineupId,
|
|
||||||
data.playerInCardId,
|
|
||||||
data.teamId
|
|
||||||
)
|
|
||||||
showSubstitutions.value = false
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[GamePlay] Failed to submit pitching change:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubstitutionCancel = () => {
|
|
||||||
console.log('[GamePlay] Cancelling substitution')
|
|
||||||
showSubstitutions.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Undo handler
|
// Undo handler
|
||||||
const handleUndoLastPlay = () => {
|
const handleUndoLastPlay = () => {
|
||||||
console.log('[GamePlay] Undoing last play')
|
console.log('[GamePlay] Undoing last play')
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
import type { SbaPlayer, LineupPlayerRequest, SubmitLineupsRequest } from '~/types'
|
import type { SbaPlayer, LineupPlayerRequest, SubmitLineupsRequest } from '~/types'
|
||||||
import ActionButton from '~/components/UI/ActionButton.vue'
|
import ActionButton from '~/components/UI/ActionButton.vue'
|
||||||
|
|
||||||
@ -191,7 +192,185 @@ function getPlayerPositions(player: SbaPlayer): string[] {
|
|||||||
return positions
|
return positions
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag handlers
|
// Vuedraggable configuration
|
||||||
|
const dragOptions = {
|
||||||
|
animation: 200,
|
||||||
|
ghostClass: 'drag-ghost',
|
||||||
|
chosenClass: 'drag-chosen',
|
||||||
|
dragClass: 'drag-dragging',
|
||||||
|
// Touch settings for mobile
|
||||||
|
delay: 50,
|
||||||
|
delayOnTouchOnly: true,
|
||||||
|
touchStartThreshold: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track which slot is being hovered during drag (for replacement mode visual)
|
||||||
|
const dragHoverSlot = ref<number | null>(null)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
|
||||||
|
// Check if a slot is in "replacement mode" (occupied and being hovered)
|
||||||
|
function isReplacementMode(slotIndex: number): boolean {
|
||||||
|
return isDragging.value && dragHoverSlot.value === slotIndex && !!currentLineup.value[slotIndex]?.player
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the player being replaced (for visual feedback)
|
||||||
|
function getReplacedPlayer(slotIndex: number): SbaPlayer | null {
|
||||||
|
if (isReplacementMode(slotIndex)) {
|
||||||
|
return currentLineup.value[slotIndex]?.player || null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle drag start - track that dragging is active
|
||||||
|
function handleDragStart() {
|
||||||
|
isDragging.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle drag end - clear all drag state
|
||||||
|
function handleDragEnd() {
|
||||||
|
isDragging.value = false
|
||||||
|
dragHoverSlot.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle move event - fires when dragging over a slot
|
||||||
|
function handleSlotMove(evt: any, slotIndex: number) {
|
||||||
|
dragHoverSlot.value = slotIndex
|
||||||
|
// Always allow the move
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle mouse/touch leave on slot - clear hover state
|
||||||
|
function handleSlotLeave(slotIndex: number) {
|
||||||
|
if (dragHoverSlot.value === slotIndex) {
|
||||||
|
dragHoverSlot.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone player when dragging from roster (don't remove from roster)
|
||||||
|
function clonePlayer(player: SbaPlayer): SbaPlayer {
|
||||||
|
return { ...player }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle when a player is added to a batting slot from roster or another slot
|
||||||
|
function handleSlotAdd(slotIndex: number, event: any) {
|
||||||
|
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
|
||||||
|
const player = event.item?._underlying_vm_ || event.clone?._underlying_vm_
|
||||||
|
|
||||||
|
if (!player) return
|
||||||
|
|
||||||
|
// If slot already has a player, swap logic would be needed
|
||||||
|
// But vuedraggable handles removal from source automatically for moves
|
||||||
|
lineup[slotIndex].player = player
|
||||||
|
|
||||||
|
// Auto-assign position
|
||||||
|
if (slotIndex === 9) {
|
||||||
|
lineup[slotIndex].position = 'P'
|
||||||
|
} else {
|
||||||
|
const availablePositions = getPlayerPositions(player)
|
||||||
|
if (availablePositions.length > 0) {
|
||||||
|
lineup[slotIndex].position = availablePositions[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle when a player is removed from a slot (moved to another slot)
|
||||||
|
function handleSlotRemove(slotIndex: number) {
|
||||||
|
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
|
||||||
|
lineup[slotIndex].player = null
|
||||||
|
lineup[slotIndex].position = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactive slot arrays for vuedraggable - each slot is an array of 0-1 players
|
||||||
|
// These are synced with the lineup slots via watchers
|
||||||
|
const homeSlotArrays = ref<SbaPlayer[][]>(Array(10).fill(null).map(() => []))
|
||||||
|
const awaySlotArrays = ref<SbaPlayer[][]>(Array(10).fill(null).map(() => []))
|
||||||
|
|
||||||
|
// Current slot arrays based on active tab
|
||||||
|
const currentSlotArrays = computed(() =>
|
||||||
|
activeTab.value === 'home' ? homeSlotArrays.value : awaySlotArrays.value
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get slot players array for vuedraggable
|
||||||
|
function getSlotPlayers(slotIndex: number): SbaPlayer[] {
|
||||||
|
return currentSlotArrays.value[slotIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync slot arrays when lineup changes (e.g., from populateLineupsFromData)
|
||||||
|
// Modifies arrays in-place to preserve vuedraggable's reference
|
||||||
|
function syncSlotArraysFromLineup(lineup: LineupSlot[], slotArrays: SbaPlayer[][]) {
|
||||||
|
lineup.forEach((slot, index) => {
|
||||||
|
const currentArr = slotArrays[index]
|
||||||
|
const shouldHavePlayer = !!slot.player
|
||||||
|
|
||||||
|
// Check if sync is needed
|
||||||
|
const currentPlayer = currentArr[0]
|
||||||
|
const isSame = shouldHavePlayer
|
||||||
|
? (currentPlayer && currentPlayer.id === slot.player!.id)
|
||||||
|
: (currentArr.length === 0)
|
||||||
|
|
||||||
|
if (!isSame) {
|
||||||
|
// Modify array in place to preserve vuedraggable's reference
|
||||||
|
currentArr.length = 0
|
||||||
|
if (slot.player) {
|
||||||
|
currentArr.push(slot.player)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch lineup changes and sync to slot arrays
|
||||||
|
watch(homeLineup, (newLineup) => {
|
||||||
|
syncSlotArraysFromLineup(newLineup, homeSlotArrays.value)
|
||||||
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
|
watch(awayLineup, (newLineup) => {
|
||||||
|
syncSlotArraysFromLineup(newLineup, awaySlotArrays.value)
|
||||||
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
|
// Handle slot change event from vuedraggable
|
||||||
|
function handleSlotChange(slotIndex: number, event: any) {
|
||||||
|
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
|
||||||
|
const slotArrays = activeTab.value === 'home' ? homeSlotArrays.value : awaySlotArrays.value
|
||||||
|
const slotArray = slotArrays[slotIndex]
|
||||||
|
|
||||||
|
if (event.added) {
|
||||||
|
// Get the newly added player (last in array if multiple)
|
||||||
|
const addedPlayer = event.added.element as SbaPlayer
|
||||||
|
|
||||||
|
// CRITICAL: Enforce single player per slot
|
||||||
|
// If there are multiple players (dropped onto existing), keep only the new one
|
||||||
|
if (slotArray.length > 1) {
|
||||||
|
// Find and remove the old player(s), keeping only the newly added one
|
||||||
|
const playersToRemove = slotArray.filter((p: SbaPlayer) => p.id !== addedPlayer.id)
|
||||||
|
|
||||||
|
// Clear the array and add only the new player
|
||||||
|
slotArray.length = 0
|
||||||
|
slotArray.push(addedPlayer)
|
||||||
|
|
||||||
|
console.log(`[LineupBuilder] Slot ${slotIndex}: Replaced ${playersToRemove.map((p: SbaPlayer) => p.name).join(', ')} with ${addedPlayer.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the lineup slot with the new player
|
||||||
|
lineup[slotIndex].player = addedPlayer
|
||||||
|
|
||||||
|
// Auto-assign position
|
||||||
|
if (slotIndex === 9) {
|
||||||
|
lineup[slotIndex].position = 'P'
|
||||||
|
} else {
|
||||||
|
const availablePositions = getPlayerPositions(addedPlayer)
|
||||||
|
if (availablePositions.length > 0) {
|
||||||
|
lineup[slotIndex].position = availablePositions[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.removed) {
|
||||||
|
// Player was removed from this slot
|
||||||
|
lineup[slotIndex].player = null
|
||||||
|
lineup[slotIndex].position = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy drag handler (kept for desktop native drag-drop fallback)
|
||||||
function handleRosterDrag(player: SbaPlayer, toSlotIndex: number, fromSlotIndex?: number) {
|
function handleRosterDrag(player: SbaPlayer, toSlotIndex: number, fromSlotIndex?: number) {
|
||||||
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
|
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
|
||||||
|
|
||||||
@ -347,6 +526,7 @@ async function fetchRoster(teamId: number) {
|
|||||||
async function submitTeamLineup(team: 'home' | 'away') {
|
async function submitTeamLineup(team: 'home' | 'away') {
|
||||||
const teamId = team === 'home' ? homeTeamId.value : awayTeamId.value
|
const teamId = team === 'home' ? homeTeamId.value : awayTeamId.value
|
||||||
const lineup = team === 'home' ? homeLineup.value : awayLineup.value
|
const lineup = team === 'home' ? homeLineup.value : awayLineup.value
|
||||||
|
const availableRoster = team === 'home' ? availableHomeRoster.value : availableAwayRoster.value
|
||||||
const isSubmitting = team === 'home' ? submittingHome : submittingAway
|
const isSubmitting = team === 'home' ? submittingHome : submittingAway
|
||||||
const submitted = team === 'home' ? homeSubmitted : awaySubmitted
|
const submitted = team === 'home' ? homeSubmitted : awaySubmitted
|
||||||
const canSubmitTeam = team === 'home' ? canSubmitHome.value : canSubmitAway.value
|
const canSubmitTeam = team === 'home' ? canSubmitHome.value : canSubmitAway.value
|
||||||
@ -355,7 +535,7 @@ async function submitTeamLineup(team: 'home' | 'away') {
|
|||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
|
|
||||||
// Build request
|
// Build starting lineup request
|
||||||
const lineupRequest = lineup
|
const lineupRequest = lineup
|
||||||
.filter(s => s.player)
|
.filter(s => s.player)
|
||||||
.map(s => ({
|
.map(s => ({
|
||||||
@ -364,9 +544,15 @@ async function submitTeamLineup(team: 'home' | 'away') {
|
|||||||
batting_order: s.battingOrder
|
batting_order: s.battingOrder
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Build bench request (players not in starting lineup)
|
||||||
|
const benchRequest = availableRoster.map(p => ({
|
||||||
|
player_id: p.id
|
||||||
|
}))
|
||||||
|
|
||||||
const request = {
|
const request = {
|
||||||
team_id: teamId,
|
team_id: teamId,
|
||||||
lineup: lineupRequest
|
lineup: lineupRequest,
|
||||||
|
bench: benchRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Submitting ${team} lineup:`, JSON.stringify(request, null, 2))
|
console.log(`Submitting ${team} lineup:`, JSON.stringify(request, null, 2))
|
||||||
@ -722,58 +908,68 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Roster List -->
|
<!-- Roster List -->
|
||||||
<div class="bg-gray-800/40 backdrop-blur-sm rounded-b-xl border border-gray-700/50 border-t-0 p-2 space-y-1.5 max-h-[calc(100vh-22rem)] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-transparent">
|
<div class="bg-gray-800/40 backdrop-blur-sm rounded-b-xl border border-gray-700/50 border-t-0 p-2 max-h-[calc(100vh-22rem)] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-transparent select-none">
|
||||||
<div
|
<draggable
|
||||||
v-for="player in filteredRoster"
|
:list="filteredRoster"
|
||||||
:key="player.id"
|
:group="{ name: 'lineup', pull: 'clone', put: false }"
|
||||||
draggable="true"
|
:clone="clonePlayer"
|
||||||
class="bg-gray-700/60 hover:bg-gray-700 rounded-lg p-2.5 cursor-grab active:cursor-grabbing transition-all duration-150 flex items-center gap-3 group hover:shadow-md hover:shadow-black/20 border border-transparent hover:border-gray-600/50"
|
:sort="false"
|
||||||
@dragstart="(e) => e.dataTransfer?.setData('player', JSON.stringify(player))"
|
item-key="id"
|
||||||
|
v-bind="dragOptions"
|
||||||
|
class="space-y-1.5"
|
||||||
|
@start="handleDragStart"
|
||||||
|
@end="handleDragEnd"
|
||||||
>
|
>
|
||||||
<!-- Player Headshot -->
|
<template #item="{ element: player }">
|
||||||
<div class="flex-shrink-0 relative">
|
<div
|
||||||
<img
|
class="bg-gray-700/60 hover:bg-gray-700 rounded-lg p-2.5 cursor-grab active:cursor-grabbing transition-all duration-150 flex items-center gap-3 group hover:shadow-md hover:shadow-black/20 border border-transparent hover:border-gray-600/50 select-none touch-manipulation"
|
||||||
v-if="getPlayerPreviewImage(player)"
|
>
|
||||||
:src="getPlayerPreviewImage(player)!"
|
<!-- Player Headshot -->
|
||||||
:alt="player.name"
|
<div class="flex-shrink-0 relative">
|
||||||
class="w-10 h-10 rounded-full object-cover bg-gray-600 ring-2 ring-gray-600/50"
|
<img
|
||||||
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
v-if="getPlayerPreviewImage(player)"
|
||||||
/>
|
:src="getPlayerPreviewImage(player)!"
|
||||||
<div v-else class="w-10 h-10 rounded-full bg-gradient-to-br from-gray-600 to-gray-700 flex items-center justify-center text-gray-300 text-sm font-bold ring-2 ring-gray-600/50">
|
:alt="player.name"
|
||||||
{{ getPlayerFallbackInitial(player) }}
|
class="w-10 h-10 rounded-full object-cover bg-gray-600 ring-2 ring-gray-600/50"
|
||||||
</div>
|
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||||
</div>
|
/>
|
||||||
|
<div v-else class="w-10 h-10 rounded-full bg-gradient-to-br from-gray-600 to-gray-700 flex items-center justify-center text-gray-300 text-sm font-bold ring-2 ring-gray-600/50">
|
||||||
|
{{ getPlayerFallbackInitial(player) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Player Info -->
|
<!-- Player Info -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-medium text-white truncate text-sm">{{ player.name }}</div>
|
<div class="font-medium text-white truncate text-sm">{{ player.name }}</div>
|
||||||
<div class="flex items-center gap-1 mt-0.5">
|
<div class="flex items-center gap-1 mt-0.5">
|
||||||
<span
|
<span
|
||||||
v-for="pos in getPlayerPositions(player).filter(p => p !== 'DH').slice(0, 3)"
|
v-for="pos in getPlayerPositions(player).filter(p => p !== 'DH').slice(0, 3)"
|
||||||
:key="pos"
|
:key="pos"
|
||||||
class="text-[10px] font-medium text-gray-400 bg-gray-800/80 px-1.5 py-0.5 rounded"
|
class="text-[10px] font-medium text-gray-400 bg-gray-800/80 px-1.5 py-0.5 rounded"
|
||||||
|
>
|
||||||
|
{{ pos }}
|
||||||
|
</span>
|
||||||
|
<span v-if="getPlayerPositions(player).filter(p => p !== 'DH').length > 3" class="text-[10px] text-gray-500">
|
||||||
|
+{{ getPlayerPositions(player).filter(p => p !== 'DH').length - 3 }}
|
||||||
|
</span>
|
||||||
|
<span v-if="getPlayerPositions(player).filter(p => p !== 'DH').length === 0" class="text-[10px] text-gray-500">
|
||||||
|
DH
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Button -->
|
||||||
|
<button
|
||||||
|
class="flex-shrink-0 w-7 h-7 rounded-full bg-gray-600/50 hover:bg-blue-600 text-gray-400 hover:text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
@click.stop="openPlayerPreview(player)"
|
||||||
>
|
>
|
||||||
{{ pos }}
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</span>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
<span v-if="getPlayerPositions(player).filter(p => p !== 'DH').length > 3" class="text-[10px] text-gray-500">
|
</svg>
|
||||||
+{{ getPlayerPositions(player).filter(p => p !== 'DH').length - 3 }}
|
</button>
|
||||||
</span>
|
|
||||||
<span v-if="getPlayerPositions(player).filter(p => p !== 'DH').length === 0" class="text-[10px] text-gray-500">
|
|
||||||
DH
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</draggable>
|
||||||
<!-- Info Button -->
|
|
||||||
<button
|
|
||||||
class="flex-shrink-0 w-7 h-7 rounded-full bg-gray-600/50 hover:bg-blue-600 text-gray-400 hover:text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
|
|
||||||
@click.stop="openPlayerPreview(player)"
|
|
||||||
>
|
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div v-if="filteredRoster.length === 0" class="text-center py-8">
|
<div v-if="filteredRoster.length === 0" class="text-center py-8">
|
||||||
@ -822,96 +1018,117 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Batting order slots (1-9) -->
|
<!-- Batting order slots (1-9) -->
|
||||||
<div class="space-y-1.5 mb-6">
|
<div class="space-y-1.5 mb-6 select-none">
|
||||||
<div
|
<div
|
||||||
v-for="(slot, index) in currentLineup.slice(0, 9)"
|
v-for="(slot, index) in currentLineup.slice(0, 9)"
|
||||||
:key="index"
|
:key="index"
|
||||||
:class="[
|
:class="[
|
||||||
'bg-gray-800/60 backdrop-blur-sm rounded-lg border transition-all duration-200',
|
'bg-gray-800/60 backdrop-blur-sm rounded-lg border transition-all duration-200',
|
||||||
slot.player ? 'border-gray-700/50' : 'border-gray-700/30'
|
slot.player ? 'border-gray-700/50' : 'border-gray-700/30',
|
||||||
|
isReplacementMode(index) ? 'ring-2 ring-amber-500/50 border-amber-500/50' : ''
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3 p-2.5">
|
<div class="flex items-center gap-3 p-2.5">
|
||||||
<!-- Batting order number -->
|
<!-- Batting order number -->
|
||||||
<div class="flex-shrink-0 w-8 h-8 rounded-lg bg-gray-700/50 flex items-center justify-center">
|
<div :class="[
|
||||||
<span class="text-sm font-bold text-gray-400">{{ index + 1 }}</span>
|
'flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center transition-colors',
|
||||||
|
isReplacementMode(index) ? 'bg-amber-900/50' : 'bg-gray-700/50'
|
||||||
|
]">
|
||||||
|
<span :class="[
|
||||||
|
'text-sm font-bold',
|
||||||
|
isReplacementMode(index) ? 'text-amber-400' : 'text-gray-400'
|
||||||
|
]">{{ index + 1 }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Player slot -->
|
<!-- Player slot - vuedraggable drop zone -->
|
||||||
<div
|
<draggable
|
||||||
class="flex-1 min-w-0"
|
:list="getSlotPlayers(index)"
|
||||||
@drop.prevent="(e) => {
|
:group="{ name: 'lineup', pull: true, put: true }"
|
||||||
const playerData = e.dataTransfer?.getData('player')
|
item-key="id"
|
||||||
const fromSlotData = e.dataTransfer?.getData('fromSlot')
|
v-bind="dragOptions"
|
||||||
if (playerData) {
|
:class="[
|
||||||
const player = JSON.parse(playerData) as SbaPlayer
|
'flex-1 min-w-0 min-h-[44px] slot-drop-zone',
|
||||||
const fromSlot = fromSlotData ? parseInt(fromSlotData) : undefined
|
isReplacementMode(index) ? 'replacement-mode' : ''
|
||||||
handleRosterDrag(player, index, fromSlot)
|
]"
|
||||||
}
|
@change="(e: any) => handleSlotChange(index, e)"
|
||||||
}"
|
@start="handleDragStart"
|
||||||
@dragover.prevent
|
@end="handleDragEnd"
|
||||||
|
:move="(evt: any) => handleSlotMove(evt, index)"
|
||||||
>
|
>
|
||||||
<div
|
<template #item="{ element: player }">
|
||||||
v-if="slot.player"
|
<div
|
||||||
class="bg-blue-900/50 hover:bg-blue-900/70 rounded-lg p-2 cursor-grab active:cursor-grabbing transition-all duration-150 flex items-center gap-2.5 group border border-blue-700/30"
|
:class="[
|
||||||
draggable="true"
|
'rounded-lg p-2 cursor-grab active:cursor-grabbing transition-all duration-150 flex items-center gap-2.5 group border select-none touch-manipulation',
|
||||||
@dragstart="(e) => {
|
isReplacementMode(index)
|
||||||
e.dataTransfer?.setData('player', JSON.stringify(slot.player))
|
? 'bg-amber-900/40 border-amber-600/50 opacity-60'
|
||||||
e.dataTransfer?.setData('fromSlot', index.toString())
|
: 'bg-blue-900/50 hover:bg-blue-900/70 border-blue-700/30'
|
||||||
}"
|
]"
|
||||||
>
|
|
||||||
<!-- Player Headshot -->
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<img
|
|
||||||
v-if="getPlayerPreviewImage(slot.player)"
|
|
||||||
:src="getPlayerPreviewImage(slot.player)!"
|
|
||||||
:alt="slot.player.name"
|
|
||||||
class="w-9 h-9 rounded-full object-cover bg-gray-600 ring-2 ring-blue-600/30"
|
|
||||||
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
|
||||||
/>
|
|
||||||
<div v-else class="w-9 h-9 rounded-full bg-gradient-to-br from-blue-700 to-blue-800 flex items-center justify-center text-blue-200 text-sm font-bold ring-2 ring-blue-600/30">
|
|
||||||
{{ getPlayerFallbackInitial(slot.player) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Player Info -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="font-medium text-white truncate text-sm">{{ slot.player.name }}</div>
|
|
||||||
<div class="flex items-center gap-1 mt-0.5">
|
|
||||||
<span
|
|
||||||
v-for="pos in getPlayerPositions(slot.player).filter(p => p !== 'DH').slice(0, 2)"
|
|
||||||
:key="pos"
|
|
||||||
class="text-[10px] font-medium text-blue-300/80 bg-blue-950/50 px-1.5 py-0.5 rounded"
|
|
||||||
>
|
|
||||||
{{ pos }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Info Button -->
|
|
||||||
<button
|
|
||||||
class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-800/50 hover:bg-blue-600 text-blue-300 hover:text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
|
|
||||||
@click.stop="openPlayerPreview(slot.player)"
|
|
||||||
>
|
>
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<!-- Player Headshot -->
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<div class="flex-shrink-0">
|
||||||
</svg>
|
<img
|
||||||
</button>
|
v-if="getPlayerPreviewImage(player)"
|
||||||
|
:src="getPlayerPreviewImage(player)!"
|
||||||
|
:alt="player.name"
|
||||||
|
class="w-9 h-9 rounded-full object-cover bg-gray-600 ring-2 ring-blue-600/30"
|
||||||
|
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-9 h-9 rounded-full bg-gradient-to-br from-blue-700 to-blue-800 flex items-center justify-center text-blue-200 text-sm font-bold ring-2 ring-blue-600/30">
|
||||||
|
{{ getPlayerFallbackInitial(player) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Remove Button -->
|
<!-- Player Info -->
|
||||||
<button
|
<div class="flex-1 min-w-0">
|
||||||
class="flex-shrink-0 w-6 h-6 rounded-full bg-red-900/30 hover:bg-red-900/60 text-red-400 hover:text-red-300 text-xs flex items-center justify-center transition-all"
|
<div class="font-medium text-white truncate text-sm">{{ player.name }}</div>
|
||||||
@click.stop="removePlayer(index)"
|
<div class="flex items-center gap-1 mt-0.5">
|
||||||
|
<span
|
||||||
|
v-for="pos in getPlayerPositions(player).filter(p => p !== 'DH').slice(0, 2)"
|
||||||
|
:key="pos"
|
||||||
|
class="text-[10px] font-medium text-blue-300/80 bg-blue-950/50 px-1.5 py-0.5 rounded"
|
||||||
|
>
|
||||||
|
{{ pos }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Button -->
|
||||||
|
<button
|
||||||
|
class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-800/50 hover:bg-blue-600 text-blue-300 hover:text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
@click.stop="openPlayerPreview(player)"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Remove Button -->
|
||||||
|
<button
|
||||||
|
class="flex-shrink-0 w-6 h-6 rounded-full bg-red-900/30 hover:bg-red-900/60 text-red-400 hover:text-red-300 text-xs flex items-center justify-center transition-all"
|
||||||
|
@click.stop="removePlayer(index)"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<!-- Replacement indicator when dragging over occupied slot -->
|
||||||
|
<div
|
||||||
|
v-if="isReplacementMode(index)"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-amber-900/20 rounded-lg pointer-events-none z-10"
|
||||||
>
|
>
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span class="text-xs font-medium text-amber-400 bg-amber-950/80 px-2 py-1 rounded">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
Replacing {{ getReplacedPlayer(index)?.name }}
|
||||||
</svg>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
<!-- Empty slot placeholder -->
|
||||||
<div v-else class="border-2 border-dashed border-gray-600/50 rounded-lg py-3 px-4 text-center text-gray-500 text-xs transition-colors hover:border-gray-500/50 hover:bg-gray-800/30">
|
<div v-else-if="!slot.player" class="border-2 border-dashed border-gray-600/50 rounded-lg py-3 px-4 text-center text-gray-500 text-xs transition-colors hover:border-gray-500/50 hover:bg-gray-800/30">
|
||||||
Drop player here
|
Drop player here
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</draggable>
|
||||||
|
|
||||||
<!-- Position selector -->
|
<!-- Position selector -->
|
||||||
<div class="w-20 flex-shrink-0">
|
<div class="w-20 flex-shrink-0">
|
||||||
@ -943,7 +1160,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pitcher slot (10) -->
|
<!-- Pitcher slot (10) -->
|
||||||
<div class="mb-6">
|
<div class="mb-6 select-none">
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<h3 class="text-sm font-semibold text-gray-300">Starting Pitcher</h3>
|
<h3 class="text-sm font-semibold text-gray-300">Starting Pitcher</h3>
|
||||||
<span v-if="pitcherSlotDisabled" class="text-xs text-yellow-500 bg-yellow-500/10 px-2 py-0.5 rounded-full">
|
<span v-if="pitcherSlotDisabled" class="text-xs text-yellow-500 bg-yellow-500/10 px-2 py-0.5 rounded-full">
|
||||||
@ -953,83 +1170,108 @@ onMounted(async () => {
|
|||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'bg-gray-800/60 backdrop-blur-sm rounded-lg border transition-all duration-200',
|
'bg-gray-800/60 backdrop-blur-sm rounded-lg border transition-all duration-200',
|
||||||
pitcherSlotDisabled ? 'opacity-40 pointer-events-none border-gray-700/20' : 'border-gray-700/50'
|
pitcherSlotDisabled ? 'opacity-40 pointer-events-none border-gray-700/20' : 'border-gray-700/50',
|
||||||
|
isReplacementMode(9) ? 'ring-2 ring-amber-500/50 border-amber-500/50' : ''
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3 p-2.5">
|
<div class="flex items-center gap-3 p-2.5">
|
||||||
<div class="flex-shrink-0 w-8 h-8 rounded-lg bg-green-900/30 flex items-center justify-center">
|
<div :class="[
|
||||||
<span class="text-sm font-bold text-green-400">P</span>
|
'flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center transition-colors',
|
||||||
|
isReplacementMode(9) ? 'bg-amber-900/50' : 'bg-green-900/30'
|
||||||
|
]">
|
||||||
|
<span :class="[
|
||||||
|
'text-sm font-bold',
|
||||||
|
isReplacementMode(9) ? 'text-amber-400' : 'text-green-400'
|
||||||
|
]">P</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<!-- Pitcher slot - vuedraggable drop zone -->
|
||||||
class="flex-1 min-w-0"
|
<draggable
|
||||||
@drop.prevent="(e) => {
|
:list="getSlotPlayers(9)"
|
||||||
const playerData = e.dataTransfer?.getData('player')
|
:group="{ name: 'lineup', pull: true, put: !pitcherSlotDisabled }"
|
||||||
const fromSlotData = e.dataTransfer?.getData('fromSlot')
|
item-key="id"
|
||||||
if (playerData) {
|
v-bind="dragOptions"
|
||||||
const player = JSON.parse(playerData) as SbaPlayer
|
:class="[
|
||||||
const fromSlot = fromSlotData ? parseInt(fromSlotData) : undefined
|
'flex-1 min-w-0 min-h-[44px] slot-drop-zone',
|
||||||
handleRosterDrag(player, 9, fromSlot)
|
isReplacementMode(9) ? 'replacement-mode' : ''
|
||||||
}
|
]"
|
||||||
}"
|
@change="(e: any) => handleSlotChange(9, e)"
|
||||||
@dragover.prevent
|
@start="handleDragStart"
|
||||||
|
@end="handleDragEnd"
|
||||||
|
:move="(evt: any) => handleSlotMove(evt, 9)"
|
||||||
>
|
>
|
||||||
<div
|
<template #item="{ element: player }">
|
||||||
v-if="pitcherPlayer"
|
<div
|
||||||
class="bg-green-900/40 hover:bg-green-900/60 rounded-lg p-2 cursor-grab active:cursor-grabbing transition-all duration-150 flex items-center gap-2.5 group border border-green-700/30"
|
:class="[
|
||||||
draggable="true"
|
'rounded-lg p-2 cursor-grab active:cursor-grabbing transition-all duration-150 flex items-center gap-2.5 group border select-none touch-manipulation',
|
||||||
@dragstart="(e) => {
|
isReplacementMode(9)
|
||||||
e.dataTransfer?.setData('player', JSON.stringify(pitcherPlayer))
|
? 'bg-amber-900/40 border-amber-600/50 opacity-60'
|
||||||
e.dataTransfer?.setData('fromSlot', '9')
|
: 'bg-green-900/40 hover:bg-green-900/60 border-green-700/30'
|
||||||
}"
|
]"
|
||||||
>
|
>
|
||||||
<!-- Player Headshot -->
|
<!-- Player Headshot -->
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
v-if="getPlayerPreviewImage(pitcherPlayer)"
|
v-if="getPlayerPreviewImage(player)"
|
||||||
:src="getPlayerPreviewImage(pitcherPlayer)!"
|
:src="getPlayerPreviewImage(player)!"
|
||||||
:alt="pitcherPlayer.name"
|
:alt="player.name"
|
||||||
class="w-9 h-9 rounded-full object-cover bg-gray-600 ring-2 ring-green-600/30"
|
class="w-9 h-9 rounded-full object-cover bg-gray-600 ring-2 ring-green-600/30"
|
||||||
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||||
/>
|
/>
|
||||||
<div v-else class="w-9 h-9 rounded-full bg-gradient-to-br from-green-700 to-green-800 flex items-center justify-center text-green-200 text-sm font-bold ring-2 ring-green-600/30">
|
<div v-else class="w-9 h-9 rounded-full bg-gradient-to-br from-green-700 to-green-800 flex items-center justify-center text-green-200 text-sm font-bold ring-2 ring-green-600/30">
|
||||||
{{ getPlayerFallbackInitial(pitcherPlayer) }}
|
{{ getPlayerFallbackInitial(player) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Player Info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-white truncate text-sm">{{ player.name }}</div>
|
||||||
|
<div class="text-[10px] text-green-400/80 mt-0.5">Pitcher</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Button -->
|
||||||
|
<button
|
||||||
|
class="flex-shrink-0 w-6 h-6 rounded-full bg-green-800/50 hover:bg-green-600 text-green-300 hover:text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
@click.stop="openPlayerPreview(player)"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Remove Button -->
|
||||||
|
<button
|
||||||
|
class="flex-shrink-0 w-6 h-6 rounded-full bg-red-900/30 hover:bg-red-900/60 text-red-400 hover:text-red-300 text-xs flex items-center justify-center transition-all"
|
||||||
|
@click.stop="removePlayer(9)"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
<!-- Player Info -->
|
<template #footer>
|
||||||
<div class="flex-1 min-w-0">
|
<!-- Replacement indicator when dragging over occupied pitcher slot -->
|
||||||
<div class="font-medium text-white truncate text-sm">{{ pitcherPlayer.name }}</div>
|
<div
|
||||||
<div class="text-[10px] text-green-400/80 mt-0.5">Pitcher</div>
|
v-if="isReplacementMode(9)"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-amber-900/20 rounded-lg pointer-events-none z-10"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-medium text-amber-400 bg-amber-950/80 px-2 py-1 rounded">
|
||||||
|
Replacing {{ getReplacedPlayer(9)?.name }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Empty slot placeholder -->
|
||||||
<!-- Info Button -->
|
<div v-else-if="!pitcherPlayer" class="border-2 border-dashed border-gray-600/50 rounded-lg py-3 px-4 text-center text-gray-500 text-xs transition-colors hover:border-gray-500/50 hover:bg-gray-800/30">
|
||||||
<button
|
Drop pitcher here
|
||||||
class="flex-shrink-0 w-6 h-6 rounded-full bg-green-800/50 hover:bg-green-600 text-green-300 hover:text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
|
</div>
|
||||||
@click.stop="openPlayerPreview(pitcherPlayer)"
|
</template>
|
||||||
>
|
</draggable>
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Remove Button -->
|
|
||||||
<button
|
|
||||||
class="flex-shrink-0 w-6 h-6 rounded-full bg-red-900/30 hover:bg-red-900/60 text-red-400 hover:text-red-300 text-xs flex items-center justify-center transition-all"
|
|
||||||
@click.stop="removePlayer(9)"
|
|
||||||
>
|
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="border-2 border-dashed border-gray-600/50 rounded-lg py-3 px-4 text-center text-gray-500 text-xs transition-colors hover:border-gray-500/50 hover:bg-gray-800/30">
|
|
||||||
Drop pitcher here
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-20 flex-shrink-0">
|
<div class="w-20 flex-shrink-0">
|
||||||
<div class="text-green-500 text-xs text-center font-medium">P</div>
|
<div :class="[
|
||||||
|
'text-xs text-center font-medium',
|
||||||
|
isReplacementMode(9) ? 'text-amber-400' : 'text-green-500'
|
||||||
|
]">P</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1188,3 +1430,79 @@ onMounted(async () => {
|
|||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Prevent text selection on all draggable elements AND their children - critical for mobile UX */
|
||||||
|
:deep([draggable="true"]),
|
||||||
|
:deep([draggable="true"] *),
|
||||||
|
:deep(.sortable-item),
|
||||||
|
:deep(.sortable-item *),
|
||||||
|
.roster-item,
|
||||||
|
.roster-item *,
|
||||||
|
.lineup-slot-item,
|
||||||
|
.lineup-slot-item * {
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
-moz-user-select: none !important;
|
||||||
|
-ms-user-select: none !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-touch-callout: none !important; /* Prevent iOS callout menu */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch action on containers only (not children) */
|
||||||
|
:deep([draggable="true"]),
|
||||||
|
:deep(.sortable-item) {
|
||||||
|
touch-action: manipulation; /* Allow pan/zoom but optimize for touch */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply to the draggable container itself */
|
||||||
|
:deep(.sortable-chosen),
|
||||||
|
:deep(.sortable-chosen *) {
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
user-select: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slot drop zone - constrain to single item height */
|
||||||
|
.slot-drop-zone {
|
||||||
|
position: relative;
|
||||||
|
max-height: 60px; /* Constrain to single player card height */
|
||||||
|
overflow: hidden; /* Hide stacking preview */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Replacement mode - show that an item will be replaced */
|
||||||
|
.slot-drop-zone.replacement-mode {
|
||||||
|
max-height: none; /* Allow replacement indicator to show */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the ghost/preview when in replacement mode to prevent stacking visual */
|
||||||
|
.slot-drop-zone.replacement-mode :deep(.sortable-ghost) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vuedraggable drag effect styles */
|
||||||
|
.drag-ghost {
|
||||||
|
@apply opacity-50 bg-blue-900/30 border-2 border-dashed border-blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-chosen {
|
||||||
|
@apply ring-2 ring-blue-500 ring-offset-2 ring-offset-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-dragging {
|
||||||
|
@apply opacity-75 scale-105 shadow-xl shadow-blue-500/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure draggable containers have proper touch handling */
|
||||||
|
:deep(.sortable-drag) {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sortable-ghost) {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In replacement mode, style the ghost differently */
|
||||||
|
.slot-drop-zone.replacement-mode :deep(.sortable-fallback) {
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -49,7 +49,7 @@
|
|||||||
<button
|
<button
|
||||||
@click="handlePlayGame"
|
@click="handlePlayGame"
|
||||||
:disabled="isCreating"
|
:disabled="isCreating"
|
||||||
class="w-full px-4 py-2 bg-primary hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium rounded-lg transition disabled:cursor-not-allowed"
|
class="w-full px-4 py-2 bg-primary hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium rounded-lg transition disabled:cursor-not-allowed select-none touch-manipulation"
|
||||||
>
|
>
|
||||||
{{ isCreating ? 'Creating...' : 'Play This Game' }}
|
{{ isCreating ? 'Creating...' : 'Play This Game' }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
<!-- Tab Navigation -->
|
||||||
<div class="tab-navigation">
|
<div class="tab-navigation select-none">
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
:key="tab.type"
|
:key="tab.type"
|
||||||
@ -75,11 +75,11 @@
|
|||||||
<!-- Player Selection (if no player selected) -->
|
<!-- Player Selection (if no player selected) -->
|
||||||
<div v-if="!selectedDefensivePlayer" class="player-selection">
|
<div v-if="!selectedDefensivePlayer" class="player-selection">
|
||||||
<div class="selection-label">Select player to replace:</div>
|
<div class="selection-label">Select player to replace:</div>
|
||||||
<div class="player-grid">
|
<div class="player-grid select-none">
|
||||||
<button
|
<button
|
||||||
v-for="player in activeFielders"
|
v-for="player in activeFielders"
|
||||||
:key="player.lineup_id"
|
:key="player.lineup_id"
|
||||||
class="player-button"
|
class="player-button touch-manipulation"
|
||||||
@click="selectDefensivePlayer(player)"
|
@click="selectDefensivePlayer(player)"
|
||||||
>
|
>
|
||||||
<div class="player-name">{{ player.player.name }}</div>
|
<div class="player-name">{{ player.player.name }}</div>
|
||||||
@ -326,6 +326,9 @@ const handleCancel = () => {
|
|||||||
.tab-button {
|
.tab-button {
|
||||||
@apply flex items-center gap-2 px-4 py-3 font-semibold transition-all duration-200;
|
@apply flex items-center gap-2 px-4 py-3 font-semibold transition-all duration-200;
|
||||||
@apply border-b-2 -mb-px;
|
@apply border-b-2 -mb-px;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-inactive {
|
.tab-inactive {
|
||||||
@ -398,6 +401,8 @@ const handleCancel = () => {
|
|||||||
@apply hover:border-blue-400 hover:bg-blue-50;
|
@apply hover:border-blue-400 hover:bg-blue-50;
|
||||||
@apply transition-all duration-150;
|
@apply transition-all duration-150;
|
||||||
@apply text-left min-h-[70px];
|
@apply text-left min-h-[70px];
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-name {
|
.player-name {
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
:type="type"
|
:type="type"
|
||||||
:disabled="disabled || loading"
|
:disabled="disabled || loading"
|
||||||
:class="buttonClasses"
|
:class="buttonClasses"
|
||||||
|
class="select-none touch-manipulation"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<!-- Loading Spinner -->
|
<!-- Loading Spinner -->
|
||||||
@ -76,3 +77,15 @@ const handleClick = (event: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Prevent text selection on buttons - critical for mobile UX */
|
||||||
|
button,
|
||||||
|
button * {
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
-moz-user-select: none !important;
|
||||||
|
-ms-user-select: none !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-touch-callout: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="containerClasses">
|
<div :class="containerClasses" class="select-none">
|
||||||
<button
|
<button
|
||||||
v-for="(option, index) in options"
|
v-for="(option, index) in options"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
@ -124,3 +124,19 @@ const handleSelect = (value: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Prevent text selection on button group - critical for mobile UX */
|
||||||
|
:deep(button),
|
||||||
|
:deep(button *) {
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
-moz-user-select: none !important;
|
||||||
|
-ms-user-select: none !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-touch-callout: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 select-none">
|
||||||
<!-- Toggle Switch -->
|
<!-- Toggle Switch -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -109,3 +109,19 @@ const handleToggle = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Prevent text selection on toggle elements - critical for mobile UX */
|
||||||
|
:deep(button),
|
||||||
|
:deep(button *) {
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
-moz-user-select: none !important;
|
||||||
|
-ms-user-select: none !important;
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-touch-callout: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -315,6 +315,20 @@ export function useGameActions(gameId?: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bench players for a team (for substitutions)
|
||||||
|
*/
|
||||||
|
function getBench(teamId: number) {
|
||||||
|
if (!validateConnection()) return
|
||||||
|
|
||||||
|
console.log('[GameActions] Requesting bench for team:', teamId)
|
||||||
|
|
||||||
|
socket.value!.emit('get_bench', {
|
||||||
|
game_id: currentGameId.value!,
|
||||||
|
team_id: teamId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get box score
|
* Get box score
|
||||||
*/
|
*/
|
||||||
@ -368,6 +382,7 @@ export function useGameActions(gameId?: string) {
|
|||||||
|
|
||||||
// Data requests
|
// Data requests
|
||||||
getLineup,
|
getLineup,
|
||||||
|
getBench,
|
||||||
getBoxScore,
|
getBoxScore,
|
||||||
requestGameState,
|
requestGameState,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -622,6 +622,11 @@ export function useWebSocket() {
|
|||||||
gameStore.updateLineup(data.team_id, data.players)
|
gameStore.updateLineup(data.team_id, data.players)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
state.socketInstance.on('bench_data', (data) => {
|
||||||
|
console.log('[WebSocket] Bench data received for team:', data.team_id, '- players:', data.players.length)
|
||||||
|
gameStore.setBench(data.team_id, data.players)
|
||||||
|
})
|
||||||
|
|
||||||
state.socketInstance.on('box_score_data', (data) => {
|
state.socketInstance.on('box_score_data', (data) => {
|
||||||
console.log('[WebSocket] Box score data received')
|
console.log('[WebSocket] Box score data received')
|
||||||
// Box score will be handled by dedicated component
|
// Box score will be handled by dedicated component
|
||||||
|
|||||||
21
frontend-sba/package-lock.json
generated
21
frontend-sba/package-lock.json
generated
@ -14,7 +14,8 @@
|
|||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-draggable-plus": "^0.6.0",
|
"vue-draggable-plus": "^0.6.0",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/eslint": "^1.10.0",
|
"@nuxt/eslint": "^1.10.0",
|
||||||
@ -13179,6 +13180,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sortablejs": {
|
||||||
|
"version": "1.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
|
||||||
|
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.7.6",
|
"version": "0.7.6",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
|
||||||
@ -15697,6 +15704,18 @@
|
|||||||
"typescript": ">=5.0.0"
|
"typescript": ">=5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vuedraggable": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sortablejs": "1.14.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
|||||||
@ -23,7 +23,8 @@
|
|||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-draggable-plus": "^0.6.0",
|
"vue-draggable-plus": "^0.6.0",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/eslint": "^1.10.0",
|
"@nuxt/eslint": "^1.10.0",
|
||||||
|
|||||||
@ -50,9 +50,11 @@
|
|||||||
|
|
||||||
<!-- Lineups Tab (use v-show to preserve state when switching tabs) -->
|
<!-- Lineups Tab (use v-show to preserve state when switching tabs) -->
|
||||||
<div v-show="activeTab === 'lineups'" class="container mx-auto px-4 py-6">
|
<div v-show="activeTab === 'lineups'" class="container mx-auto px-4 py-6">
|
||||||
<LineupBuilder
|
<UnifiedLineupTab
|
||||||
:game-id="gameId"
|
:game-id="gameId"
|
||||||
:team-id="myManagedTeamId"
|
:my-team-id="myManagedTeamId"
|
||||||
|
:home-team-name="homeTeamName"
|
||||||
|
:away-team-name="awayTeamName"
|
||||||
@lineups-submitted="handleLineupsSubmitted"
|
@lineups-submitted="handleLineupsSubmitted"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -71,7 +73,7 @@ import { useAuthStore } from '~/store/auth'
|
|||||||
import { useUiStore } from '~/store/ui'
|
import { useUiStore } from '~/store/ui'
|
||||||
import ScoreBoard from '~/components/Game/ScoreBoard.vue'
|
import ScoreBoard from '~/components/Game/ScoreBoard.vue'
|
||||||
import GamePlay from '~/components/Game/GamePlay.vue'
|
import GamePlay from '~/components/Game/GamePlay.vue'
|
||||||
import LineupBuilder from '~/components/Game/LineupBuilder.vue'
|
import UnifiedLineupTab from '~/components/Lineup/UnifiedLineupTab.vue'
|
||||||
import GameStats from '~/components/Game/GameStats.vue'
|
import GameStats from '~/components/Game/GameStats.vue'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
@ -136,6 +138,10 @@ const awayTeamThumbnail = computed(() => gameState.value?.away_team_thumbnail)
|
|||||||
|
|
||||||
const homeTeamThumbnail = computed(() => gameState.value?.home_team_thumbnail)
|
const homeTeamThumbnail = computed(() => gameState.value?.home_team_thumbnail)
|
||||||
|
|
||||||
|
// Team names for UnifiedLineupTab
|
||||||
|
const awayTeamName = computed(() => gameState.value?.away_team_name ?? 'Away')
|
||||||
|
const homeTeamName = computed(() => gameState.value?.home_team_name ?? 'Home')
|
||||||
|
|
||||||
// Check if user is a manager of either team in this game
|
// Check if user is a manager of either team in this game
|
||||||
const isUserManager = computed(() => {
|
const isUserManager = computed(() => {
|
||||||
if (!gameState.value) return false
|
if (!gameState.value) return false
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user