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
def validate_position(cls, v: str) -> str:
"""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:
raise ValueError(f"Position must be one of {valid_positions}")
return v

View File

@ -121,7 +121,7 @@ class LineupService:
"""
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)
3. Returns TeamLineupState with player info populated
@ -131,10 +131,10 @@ class LineupService:
league_id: League identifier ('sba' or 'pd')
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
lineup_entries = await self.db_ops.get_active_lineup(game_id, team_id)
# Step 1: Get full lineup from database (active + bench)
lineup_entries = await self.db_ops.get_full_lineup(game_id, team_id)
if not lineup_entries:
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">
Select Action
</label>
<div class="grid grid-cols-1 gap-3">
<div class="grid grid-cols-1 gap-3 select-none">
<button
v-for="option in availableActions"
:key="option.value"
type="button"
:disabled="!isActive || option.disabled"
:class="getActionButtonClasses(option.value, option.disabled)"
class="touch-manipulation"
:title="option.disabledReason"
@click="selectAction(option.value)"
>

View File

@ -293,34 +293,8 @@
</div>
</div>
<!-- Substitution Panel Modal (Phase F5) -->
<Teleport to="body">
<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 -->
<!-- Floating Action Button - Undo -->
<div class="fixed bottom-6 right-6 z-40">
<button
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"
@ -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" />
</svg>
</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>
</template>
@ -359,7 +321,6 @@ import CurrentSituation from '~/components/Game/CurrentSituation.vue'
import PlayByPlay from '~/components/Game/PlayByPlay.vue'
import DecisionPanel from '~/components/Decisions/DecisionPanel.vue'
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
import SubstitutionPanel from '~/components/Substitutions/SubstitutionPanel.vue'
import type { DefensiveDecision, OffensiveDecision, PlayOutcome } from '~/types/game'
// Props
@ -418,7 +379,6 @@ const currentDecisionPrompt = computed(() => gameStore.currentDecisionPrompt)
// Local UI state
const isLoading = ref(true)
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting')
const showSubstitutions = ref(false)
// Determine which team the user controls
// For demo/testing: user controls whichever team needs to act
@ -537,42 +497,11 @@ const showGameplay = computed(() => {
!needsOffensiveDecision.value
})
const canMakeSubstitutions = computed(() => {
return gameState.value?.status === 'active' && isMyTurn.value
})
const canUndo = computed(() => {
// Can only undo if game is active and there are plays to undo
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)
const handleRollDice = async () => {
console.log('[GamePlay] Rolling dice')
@ -642,58 +571,6 @@ const handleStealAttemptsSubmit = (attempts: number[]) => {
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
const handleUndoLastPlay = () => {
console.log('[GamePlay] Undoing last play')

View File

@ -1,5 +1,6 @@
<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 ActionButton from '~/components/UI/ActionButton.vue'
@ -191,7 +192,185 @@ function getPlayerPositions(player: SbaPlayer): string[] {
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) {
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
@ -347,6 +526,7 @@ async function fetchRoster(teamId: number) {
async function submitTeamLineup(team: 'home' | 'away') {
const teamId = team === 'home' ? homeTeamId.value : awayTeamId.value
const lineup = team === 'home' ? homeLineup.value : awayLineup.value
const availableRoster = team === 'home' ? availableHomeRoster.value : availableAwayRoster.value
const isSubmitting = team === 'home' ? submittingHome : submittingAway
const submitted = team === 'home' ? homeSubmitted : awaySubmitted
const canSubmitTeam = team === 'home' ? canSubmitHome.value : canSubmitAway.value
@ -355,7 +535,7 @@ async function submitTeamLineup(team: 'home' | 'away') {
isSubmitting.value = true
// Build request
// Build starting lineup request
const lineupRequest = lineup
.filter(s => s.player)
.map(s => ({
@ -364,9 +544,15 @@ async function submitTeamLineup(team: 'home' | 'away') {
batting_order: s.battingOrder
}))
// Build bench request (players not in starting lineup)
const benchRequest = availableRoster.map(p => ({
player_id: p.id
}))
const request = {
team_id: teamId,
lineup: lineupRequest
lineup: lineupRequest,
bench: benchRequest
}
console.log(`Submitting ${team} lineup:`, JSON.stringify(request, null, 2))
@ -722,58 +908,68 @@ onMounted(async () => {
</div>
<!-- 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
v-for="player in filteredRoster"
:key="player.id"
draggable="true"
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"
@dragstart="(e) => e.dataTransfer?.setData('player', JSON.stringify(player))"
<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">
<draggable
:list="filteredRoster"
:group="{ name: 'lineup', pull: 'clone', put: false }"
:clone="clonePlayer"
:sort="false"
item-key="id"
v-bind="dragOptions"
class="space-y-1.5"
@start="handleDragStart"
@end="handleDragEnd"
>
<!-- Player Headshot -->
<div class="flex-shrink-0 relative">
<img
v-if="getPlayerPreviewImage(player)"
:src="getPlayerPreviewImage(player)!"
:alt="player.name"
class="w-10 h-10 rounded-full object-cover bg-gray-600 ring-2 ring-gray-600/50"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<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>
<template #item="{ element: player }">
<div
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"
>
<!-- Player Headshot -->
<div class="flex-shrink-0 relative">
<img
v-if="getPlayerPreviewImage(player)"
:src="getPlayerPreviewImage(player)!"
:alt="player.name"
class="w-10 h-10 rounded-full object-cover bg-gray-600 ring-2 ring-gray-600/50"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<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 -->
<div class="flex-1 min-w-0">
<div class="font-medium text-white truncate text-sm">{{ player.name }}</div>
<div class="flex items-center gap-1 mt-0.5">
<span
v-for="pos in getPlayerPositions(player).filter(p => p !== 'DH').slice(0, 3)"
:key="pos"
class="text-[10px] font-medium text-gray-400 bg-gray-800/80 px-1.5 py-0.5 rounded"
<!-- Player Info -->
<div class="flex-1 min-w-0">
<div class="font-medium text-white truncate text-sm">{{ player.name }}</div>
<div class="flex items-center gap-1 mt-0.5">
<span
v-for="pos in getPlayerPositions(player).filter(p => p !== 'DH').slice(0, 3)"
:key="pos"
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 }}
</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>
<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>
</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)"
>
<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>
</template>
</draggable>
<!-- Empty state -->
<div v-if="filteredRoster.length === 0" class="text-center py-8">
@ -822,96 +1018,117 @@ onMounted(async () => {
</div>
<!-- Batting order slots (1-9) -->
<div class="space-y-1.5 mb-6">
<div class="space-y-1.5 mb-6 select-none">
<div
v-for="(slot, index) in currentLineup.slice(0, 9)"
:key="index"
:class="[
'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">
<!-- Batting order number -->
<div class="flex-shrink-0 w-8 h-8 rounded-lg bg-gray-700/50 flex items-center justify-center">
<span class="text-sm font-bold text-gray-400">{{ index + 1 }}</span>
<div :class="[
'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>
<!-- Player slot -->
<div
class="flex-1 min-w-0"
@drop.prevent="(e) => {
const playerData = e.dataTransfer?.getData('player')
const fromSlotData = e.dataTransfer?.getData('fromSlot')
if (playerData) {
const player = JSON.parse(playerData) as SbaPlayer
const fromSlot = fromSlotData ? parseInt(fromSlotData) : undefined
handleRosterDrag(player, index, fromSlot)
}
}"
@dragover.prevent
<!-- Player slot - vuedraggable drop zone -->
<draggable
:list="getSlotPlayers(index)"
:group="{ name: 'lineup', pull: true, put: true }"
item-key="id"
v-bind="dragOptions"
:class="[
'flex-1 min-w-0 min-h-[44px] slot-drop-zone',
isReplacementMode(index) ? 'replacement-mode' : ''
]"
@change="(e: any) => handleSlotChange(index, e)"
@start="handleDragStart"
@end="handleDragEnd"
:move="(evt: any) => handleSlotMove(evt, index)"
>
<div
v-if="slot.player"
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"
draggable="true"
@dragstart="(e) => {
e.dataTransfer?.setData('player', JSON.stringify(slot.player))
e.dataTransfer?.setData('fromSlot', index.toString())
}"
>
<!-- 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)"
<template #item="{ element: player }">
<div
:class="[
'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',
isReplacementMode(index)
? 'bg-amber-900/40 border-amber-600/50 opacity-60'
: 'bg-blue-900/50 hover:bg-blue-900/70 border-blue-700/30'
]"
>
<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>
<!-- Player Headshot -->
<div class="flex-shrink-0">
<img
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 -->
<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)"
<!-- Player Info -->
<div class="flex-1 min-w-0">
<div class="font-medium text-white truncate text-sm">{{ player.name }}</div>
<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">
<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 player here
</div>
</div>
<span class="text-xs font-medium text-amber-400 bg-amber-950/80 px-2 py-1 rounded">
Replacing {{ getReplacedPlayer(index)?.name }}
</span>
</div>
<!-- Empty slot placeholder -->
<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
</div>
</template>
</draggable>
<!-- Position selector -->
<div class="w-20 flex-shrink-0">
@ -943,7 +1160,7 @@ onMounted(async () => {
</div>
<!-- Pitcher slot (10) -->
<div class="mb-6">
<div class="mb-6 select-none">
<div class="flex items-center gap-2 mb-3">
<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">
@ -953,83 +1170,108 @@ onMounted(async () => {
<div
:class="[
'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-shrink-0 w-8 h-8 rounded-lg bg-green-900/30 flex items-center justify-center">
<span class="text-sm font-bold text-green-400">P</span>
<div :class="[
'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
class="flex-1 min-w-0"
@drop.prevent="(e) => {
const playerData = e.dataTransfer?.getData('player')
const fromSlotData = e.dataTransfer?.getData('fromSlot')
if (playerData) {
const player = JSON.parse(playerData) as SbaPlayer
const fromSlot = fromSlotData ? parseInt(fromSlotData) : undefined
handleRosterDrag(player, 9, fromSlot)
}
}"
@dragover.prevent
<!-- Pitcher slot - vuedraggable drop zone -->
<draggable
:list="getSlotPlayers(9)"
:group="{ name: 'lineup', pull: true, put: !pitcherSlotDisabled }"
item-key="id"
v-bind="dragOptions"
:class="[
'flex-1 min-w-0 min-h-[44px] slot-drop-zone',
isReplacementMode(9) ? 'replacement-mode' : ''
]"
@change="(e: any) => handleSlotChange(9, e)"
@start="handleDragStart"
@end="handleDragEnd"
:move="(evt: any) => handleSlotMove(evt, 9)"
>
<div
v-if="pitcherPlayer"
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"
draggable="true"
@dragstart="(e) => {
e.dataTransfer?.setData('player', JSON.stringify(pitcherPlayer))
e.dataTransfer?.setData('fromSlot', '9')
}"
>
<!-- Player Headshot -->
<div class="flex-shrink-0">
<img
v-if="getPlayerPreviewImage(pitcherPlayer)"
:src="getPlayerPreviewImage(pitcherPlayer)!"
:alt="pitcherPlayer.name"
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'"
/>
<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) }}
<template #item="{ element: player }">
<div
:class="[
'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',
isReplacementMode(9)
? 'bg-amber-900/40 border-amber-600/50 opacity-60'
: 'bg-green-900/40 hover:bg-green-900/60 border-green-700/30'
]"
>
<!-- Player Headshot -->
<div class="flex-shrink-0">
<img
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-green-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-green-700 to-green-800 flex items-center justify-center text-green-200 text-sm font-bold ring-2 ring-green-600/30">
{{ getPlayerFallbackInitial(player) }}
</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>
<!-- Player Info -->
<div class="flex-1 min-w-0">
<div class="font-medium text-white truncate text-sm">{{ pitcherPlayer.name }}</div>
<div class="text-[10px] text-green-400/80 mt-0.5">Pitcher</div>
</template>
<template #footer>
<!-- Replacement indicator when dragging over occupied pitcher slot -->
<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>
<!-- 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(pitcherPlayer)"
>
<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>
<!-- Empty slot placeholder -->
<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">
Drop pitcher here
</div>
</template>
</draggable>
<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>
@ -1188,3 +1430,79 @@ onMounted(async () => {
</Teleport>
</div>
</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
@click="handlePlayGame"
: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' }}
</button>

View File

@ -11,7 +11,7 @@
</div>
<!-- Tab Navigation -->
<div class="tab-navigation">
<div class="tab-navigation select-none">
<button
v-for="tab in tabs"
:key="tab.type"
@ -75,11 +75,11 @@
<!-- Player Selection (if no player selected) -->
<div v-if="!selectedDefensivePlayer" class="player-selection">
<div class="selection-label">Select player to replace:</div>
<div class="player-grid">
<div class="player-grid select-none">
<button
v-for="player in activeFielders"
:key="player.lineup_id"
class="player-button"
class="player-button touch-manipulation"
@click="selectDefensivePlayer(player)"
>
<div class="player-name">{{ player.player.name }}</div>
@ -326,6 +326,9 @@ const handleCancel = () => {
.tab-button {
@apply flex items-center gap-2 px-4 py-3 font-semibold transition-all duration-200;
@apply border-b-2 -mb-px;
-webkit-user-select: none;
user-select: none;
touch-action: manipulation;
}
.tab-inactive {
@ -398,6 +401,8 @@ const handleCancel = () => {
@apply hover:border-blue-400 hover:bg-blue-50;
@apply transition-all duration-150;
@apply text-left min-h-[70px];
-webkit-user-select: none;
user-select: none;
}
.player-name {

View File

@ -3,6 +3,7 @@
:type="type"
:disabled="disabled || loading"
:class="buttonClasses"
class="select-none touch-manipulation"
@click="handleClick"
>
<!-- Loading Spinner -->
@ -76,3 +77,15 @@ const handleClick = (event: MouseEvent) => {
}
}
</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>
<div :class="containerClasses">
<div :class="containerClasses" class="select-none">
<button
v-for="(option, index) in options"
:key="option.value"
@ -124,3 +124,19 @@ const handleSelect = (value: string) => {
}
}
</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>
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 select-none">
<!-- Toggle Switch -->
<button
type="button"
@ -109,3 +109,19 @@ const handleToggle = () => {
}
}
</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
*/
@ -368,6 +382,7 @@ export function useGameActions(gameId?: string) {
// Data requests
getLineup,
getBench,
getBoxScore,
requestGameState,
}

View File

@ -622,6 +622,11 @@ export function useWebSocket() {
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) => {
console.log('[WebSocket] Box score data received')
// Box score will be handled by dedicated component

View File

@ -14,7 +14,8 @@
"socket.io-client": "^4.8.1",
"vue": "^3.5.22",
"vue-draggable-plus": "^0.6.0",
"vue-router": "^4.6.3"
"vue-router": "^4.6.3",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@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": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
@ -15697,6 +15704,18 @@
"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": {
"version": "3.0.1",
"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",
"vue": "^3.5.22",
"vue-draggable-plus": "^0.6.0",
"vue-router": "^4.6.3"
"vue-router": "^4.6.3",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@nuxt/eslint": "^1.10.0",

View File

@ -50,9 +50,11 @@
<!-- Lineups Tab (use v-show to preserve state when switching tabs) -->
<div v-show="activeTab === 'lineups'" class="container mx-auto px-4 py-6">
<LineupBuilder
<UnifiedLineupTab
:game-id="gameId"
:team-id="myManagedTeamId"
:my-team-id="myManagedTeamId"
:home-team-name="homeTeamName"
:away-team-name="awayTeamName"
@lineups-submitted="handleLineupsSubmitted"
/>
</div>
@ -71,7 +73,7 @@ import { useAuthStore } from '~/store/auth'
import { useUiStore } from '~/store/ui'
import ScoreBoard from '~/components/Game/ScoreBoard.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'
definePageMeta({
@ -136,6 +138,10 @@ const awayTeamThumbnail = computed(() => gameState.value?.away_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
const isUserManager = computed(() => {
if (!gameState.value) return false