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:
Cal Corum 2026-01-17 22:17:16 -06:00
parent e058bc4a6c
commit 52706bed40
18 changed files with 1296 additions and 739 deletions

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
} }

View File

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

View File

@ -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",

View File

@ -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",

View File

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