strat-gameplay-webapp/frontend-sba/pages/games/lineup/[id].vue
Cal Corum f8435a2fae CLAUDE: UI fixes - lineup builder layout and team order
- Lineup builder: Use layout: false to remove white border/padding
- Create game: Swap team order so Away Team appears above Home Team

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 14:59:03 -06:00

958 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { SbaPlayer, LineupPlayerRequest, SubmitLineupsRequest } from '~/types'
import ActionButton from '~/components/UI/ActionButton.vue'
// Use no layout - this page has its own complete UI
definePageMeta({ layout: false })
const route = useRoute()
const router = useRouter()
const config = useRuntimeConfig()
// Game and team data
const gameId = ref(route.params.id as string)
const homeTeamId = ref<number | null>(null)
const awayTeamId = ref<number | null>(null)
const season = ref(3)
// Active tab (home or away)
type TeamTab = 'home' | 'away'
const activeTab = ref<TeamTab>('away') // Away bats first
// Search and filter state
const searchQuery = ref('')
type PositionFilter = 'all' | 'batters' | 'pitchers'
const positionFilter = ref<PositionFilter>('all')
// Player preview modal
const previewPlayer = ref<SbaPlayer | null>(null)
const showPreview = ref(false)
// Roster data
const homeRoster = ref<SbaPlayer[]>([])
const awayRoster = ref<SbaPlayer[]>([])
const loadingRoster = ref(false)
const submittingLineups = ref(false)
// Lineup state - 10 slots each (1-9 batting, 10 pitcher)
interface LineupSlot {
player: SbaPlayer | null
position: string | null
battingOrder: number | null // 1-9 for batters, null for pitcher
}
const homeLineup = ref<LineupSlot[]>(Array(10).fill(null).map((_, i) => ({
player: null,
position: null,
battingOrder: i < 9 ? i + 1 : null // Slots 0-8 are batting order 1-9, slot 9 is pitcher
})))
const awayLineup = ref<LineupSlot[]>(Array(10).fill(null).map((_, i) => ({
player: null,
position: null,
battingOrder: i < 9 ? i + 1 : null
})))
// Available roster for dragging (players not in lineup)
const availableHomeRoster = computed(() => {
const usedPlayerIds = new Set(homeLineup.value.filter(s => s.player).map(s => s.player!.id))
return homeRoster.value.filter(p => !usedPlayerIds.has(p.id))
})
const availableAwayRoster = computed(() => {
const usedPlayerIds = new Set(awayLineup.value.filter(s => s.player).map(s => s.player!.id))
return awayRoster.value.filter(p => !usedPlayerIds.has(p.id))
})
// Current lineup based on active tab
const currentLineup = computed(() => activeTab.value === 'home' ? homeLineup.value : awayLineup.value)
const currentRoster = computed(() => activeTab.value === 'home' ? availableHomeRoster.value : availableAwayRoster.value)
// Position category helpers (defined early for validation)
const PITCHER_POSITIONS = ['P', 'SP', 'RP', 'CP']
// Slot 10 (pitcher) should be disabled if a pitcher is in batting order
const pitcherSlotDisabled = computed(() => {
const lineup = currentLineup.value
return lineup.slice(0, 9).some(slot => slot.position && PITCHER_POSITIONS.includes(slot.position))
})
// Validation
const duplicatePositions = computed(() => {
const lineup = currentLineup.value
const positionCounts = new Map<string, number>()
lineup.forEach(slot => {
if (slot.player && slot.position) {
positionCounts.set(slot.position, (positionCounts.get(slot.position) || 0) + 1)
}
})
return Array.from(positionCounts.entries())
.filter(([_, count]) => count > 1)
.map(([pos, _]) => pos)
})
const validationErrors = computed(() => {
const errors: string[] = []
// Check both lineups
const homeErrors = validateLineup(homeLineup.value, 'Home')
const awayErrors = validateLineup(awayLineup.value, 'Away')
return [...homeErrors, ...awayErrors]
})
function validateLineup(lineup: LineupSlot[], teamName: string): string[] {
const errors: string[] = []
// Check if a pitcher is in batting order (no DH game)
const pitcherInBattingOrder = lineup.slice(0, 9).some(s => s.position && PITCHER_POSITIONS.includes(s.position))
const requiredSlots = pitcherInBattingOrder ? 9 : 10
// Check if all slots filled
const filledSlots = lineup.filter(s => s.player).length
if (filledSlots < requiredSlots) {
errors.push(`${teamName}: Fill all ${requiredSlots} lineup slots`)
}
// Check for missing positions
const missingPositions = lineup.filter(s => s.player && !s.position).length
if (missingPositions > 0) {
errors.push(`${teamName}: ${missingPositions} player${missingPositions > 1 ? 's' : ''} missing position`)
}
// Check for duplicate positions
const positionCounts = new Map<string, number>()
lineup.forEach(slot => {
if (slot.player && slot.position) {
positionCounts.set(slot.position, (positionCounts.get(slot.position) || 0) + 1)
}
})
const duplicates = Array.from(positionCounts.entries())
.filter(([_, count]) => count > 1)
.map(([pos, _]) => pos)
if (duplicates.length > 0) {
errors.push(`${teamName}: Duplicate positions - ${duplicates.join(', ')}`)
}
return errors
}
const canSubmit = computed(() => {
return validationErrors.value.length === 0
})
// Get player's available positions
function getPlayerPositions(player: SbaPlayer): string[] {
const positions: string[] = []
for (let i = 1; i <= 8; i++) {
const pos = player[`pos_${i}` as keyof SbaPlayer]
if (pos && typeof pos === 'string') {
positions.push(pos)
}
}
// Always add DH as an option for all players
if (!positions.includes('DH')) {
positions.push('DH')
}
return positions
}
// Drag handlers
function handleRosterDrag(player: SbaPlayer, toSlotIndex: number, fromSlotIndex?: number) {
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
// If dragging from another slot, swap or move
if (fromSlotIndex !== undefined && fromSlotIndex !== toSlotIndex) {
const fromSlot = lineup[fromSlotIndex]
const toSlot = lineup[toSlotIndex]
// Swap players
const tempPlayer = toSlot.player
const tempPosition = toSlot.position
toSlot.player = fromSlot.player
toSlot.position = fromSlot.position
fromSlot.player = tempPlayer
fromSlot.position = tempPosition
} else if (fromSlotIndex === undefined) {
// Adding from roster pool
lineup[toSlotIndex].player = player
// For pitcher slot (index 9), always use 'P'
if (toSlotIndex === 9) {
lineup[toSlotIndex].position = 'P'
} else {
// Auto-suggest first available position
const availablePositions = getPlayerPositions(player)
if (availablePositions.length > 0) {
lineup[toSlotIndex].position = availablePositions[0]
}
}
}
}
function removePlayer(slotIndex: number) {
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
lineup[slotIndex].player = null
lineup[slotIndex].position = null
}
// Clear entire lineup for current team
function clearLineup() {
const lineup = activeTab.value === 'home' ? homeLineup.value : awayLineup.value
lineup.forEach((slot, index) => {
slot.player = null
slot.position = null
})
}
function playerHasPositionInCategory(player: SbaPlayer, category: PositionFilter): boolean {
if (category === 'all') return true
const positions = getPlayerPositions(player)
const isPitcher = positions.some(p => PITCHER_POSITIONS.includes(p))
switch (category) {
case 'pitchers':
return isPitcher
case 'batters':
return !isPitcher
default:
return true
}
}
// Filtered roster based on search and position filter
const filteredRoster = computed(() => {
let roster = currentRoster.value
// Apply search filter
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase().trim()
roster = roster.filter(p => p.name.toLowerCase().includes(query))
}
// Apply position filter
if (positionFilter.value !== 'all') {
roster = roster.filter(p => playerHasPositionInCategory(p, positionFilter.value))
}
return roster
})
// Count of players in current lineup
const currentLineupCount = computed(() => {
return currentLineup.value.filter(s => s.player).length
})
// Pitcher slot helper (for TypeScript narrowing)
const pitcherPlayer = computed(() => currentLineup.value[9]?.player)
// Show player preview
function openPlayerPreview(player: SbaPlayer) {
previewPlayer.value = player
showPreview.value = true
}
function closePlayerPreview() {
showPreview.value = false
previewPlayer.value = null
}
// Get player preview image with fallback priority: headshot > vanity_card > null
function getPlayerPreviewImage(player: SbaPlayer): string | null {
return player.headshot || player.vanity_card || null
}
// Get player avatar fallback - use first + last initials (e.g., "Alex Verdugo" -> "AV")
// Ignores common suffixes like Jr, Sr, II, III, IV
function getPlayerFallbackInitial(player: SbaPlayer): string {
const suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'v']
const parts = player.name.trim().split(/\s+/).filter(
part => !suffixes.includes(part.toLowerCase())
)
if (parts.length >= 2) {
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
}
return parts[0]?.charAt(0).toUpperCase() || '?'
}
// Fetch game data
async function fetchGameData() {
try {
const response = await fetch(`${config.public.apiUrl}/api/games/${gameId.value}`)
const data = await response.json()
homeTeamId.value = data.home_team_id
awayTeamId.value = data.away_team_id
} catch (error) {
console.error('Failed to fetch game data:', error)
}
}
// Fetch roster for a team
async function fetchRoster(teamId: number) {
try {
loadingRoster.value = true
const response = await fetch(`${config.public.apiUrl}/api/teams/${teamId}/roster?season=${season.value}`)
const data = await response.json()
return data.players as SbaPlayer[]
} catch (error) {
console.error(`Failed to fetch roster for team ${teamId}:`, error)
return []
} finally {
loadingRoster.value = false
}
}
// Submit lineups
async function submitLineups() {
if (!canSubmit.value || submittingLineups.value) return
submittingLineups.value = true
// Build request
const homeLineupRequest: LineupPlayerRequest[] = homeLineup.value
.filter(s => s.player)
.map(s => ({
player_id: s.player!.id,
position: s.position!,
batting_order: s.battingOrder
}))
const awayLineupRequest: LineupPlayerRequest[] = awayLineup.value
.filter(s => s.player)
.map(s => ({
player_id: s.player!.id,
position: s.position!,
batting_order: s.battingOrder
}))
const request: SubmitLineupsRequest = {
home_lineup: homeLineupRequest,
away_lineup: awayLineupRequest
}
console.log('Submitting lineup request:', JSON.stringify(request, null, 2))
try {
const response = await fetch(`${config.public.apiUrl}/api/games/${gameId.value}/lineups`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request)
})
if (!response.ok) {
const error = await response.json()
console.error('Lineup submission error:', error)
// Handle Pydantic validation errors
if (error.detail && Array.isArray(error.detail)) {
const messages = error.detail.map((err: any) => {
if (err.loc) {
const location = err.loc.join(' → ')
return `${location}: ${err.msg}`
}
return err.msg || JSON.stringify(err)
})
throw new Error(`Validation errors:\n${messages.join('\n')}`)
}
throw new Error(typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
}
const result = await response.json()
console.log('Lineups submitted:', result)
// Redirect to game page
router.push(`/games/${gameId.value}`)
} catch (error) {
console.error('Failed to submit lineups:', error)
alert(error instanceof Error ? error.message : 'Failed to submit lineups')
} finally {
submittingLineups.value = false
}
}
// Initialize
onMounted(async () => {
await fetchGameData()
if (homeTeamId.value) {
homeRoster.value = await fetchRoster(homeTeamId.value)
}
if (awayTeamId.value) {
awayRoster.value = await fetchRoster(awayTeamId.value)
}
})
</script>
<template>
<div class="min-h-screen bg-gradient-to-b from-gray-900 via-gray-900 to-gray-950 text-white">
<!-- Header Bar -->
<div class="sticky top-0 z-40 bg-gray-900/95 backdrop-blur-sm border-b border-gray-800">
<div class="max-w-6xl mx-auto px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<NuxtLink
:to="`/games/${gameId}`"
class="flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
>
<span class="text-lg">&larr;</span>
<span class="hidden sm:inline">Back to Game</span>
</NuxtLink>
<div class="h-6 w-px bg-gray-700 hidden sm:block" />
<div>
<h1 class="text-xl font-bold">Build Your Lineup</h1>
<p class="text-xs text-gray-500 hidden sm:block">Drag players to assign positions</p>
</div>
</div>
<div class="text-sm text-gray-400">
Game #{{ gameId.slice(0, 8) }}
</div>
</div>
</div>
</div>
<div class="max-w-6xl mx-auto p-4">
<!-- Team Tabs -->
<div class="flex gap-2 mb-6 bg-gray-800/50 p-1 rounded-xl inline-flex">
<button
:class="[
'px-6 py-2.5 font-semibold rounded-lg transition-all duration-200',
activeTab === 'away'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/25'
: 'text-gray-400 hover:text-white hover:bg-gray-700/50'
]"
@click="activeTab = 'away'"
>
Away
</button>
<button
:class="[
'px-6 py-2.5 font-semibold rounded-lg transition-all duration-200',
activeTab === 'home'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/25'
: 'text-gray-400 hover:text-white hover:bg-gray-700/50'
]"
@click="activeTab = 'home'"
>
Home
</button>
</div>
<!-- Loading state -->
<div v-if="loadingRoster" class="py-12">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Skeleton roster panel -->
<div class="lg:col-span-1">
<div class="bg-gray-800/50 rounded-xl p-4 animate-pulse">
<div class="h-6 w-32 bg-gray-700 rounded mb-4" />
<div class="h-10 w-full bg-gray-700 rounded-lg mb-3" />
<div class="flex gap-1 mb-3">
<div v-for="i in 5" :key="i" class="h-6 w-16 bg-gray-700 rounded-full" />
</div>
<div class="space-y-2">
<div v-for="i in 6" :key="i" class="h-16 bg-gray-700 rounded-lg" />
</div>
</div>
</div>
<!-- Skeleton lineup panel -->
<div class="lg:col-span-2">
<div class="h-6 w-24 bg-gray-700 rounded mb-4 animate-pulse" />
<div class="space-y-2">
<div v-for="i in 9" :key="i" class="h-14 bg-gray-800/50 rounded-lg animate-pulse" />
</div>
</div>
</div>
</div>
<!-- Main content -->
<div v-else class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Roster Pool (Sticky on desktop) -->
<div class="lg:col-span-1 order-2 lg:order-1">
<div class="lg:sticky lg:top-20">
<!-- Roster Panel Header -->
<div class="bg-gray-800/80 backdrop-blur-sm rounded-t-xl border border-gray-700/50 border-b-0 px-4 py-3">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-white">Available Players</h2>
<span class="text-xs font-medium text-gray-400 bg-gray-700/50 px-2 py-1 rounded-full">
{{ filteredRoster.length }}
</span>
</div>
</div>
<!-- Search & Filters -->
<div class="bg-gray-800/60 backdrop-blur-sm border-x border-gray-700/50 px-4 py-3 space-y-3">
<!-- Search Input -->
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 text-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</span>
<input
v-model="searchQuery"
type="text"
placeholder="Search players..."
class="w-full bg-gray-900/50 border border-gray-600/50 rounded-lg pl-10 pr-4 py-2 text-white text-sm placeholder-gray-500 focus:outline-none focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/25 transition-all"
/>
</div>
<!-- Position Filter Tabs -->
<div class="flex flex-wrap gap-1.5">
<button
v-for="filter in (['all', 'batters', 'pitchers'] as PositionFilter[])"
:key="filter"
:class="[
'px-3 py-1.5 text-xs font-medium rounded-lg transition-all duration-200',
positionFilter === filter
? 'bg-blue-600 text-white shadow-md shadow-blue-600/25'
: 'bg-gray-700/50 text-gray-400 hover:text-white hover:bg-gray-700'
]"
@click="positionFilter = filter"
>
{{ filter === 'all' ? 'All' : filter.charAt(0).toUpperCase() + filter.slice(1) }}
</button>
</div>
</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))"
>
<!-- 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"
>
{{ 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)"
>
<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 -->
<div v-if="filteredRoster.length === 0" class="text-center py-8">
<div class="text-gray-500 text-sm">
{{ currentRoster.length === 0 ? 'All players assigned to lineup' : 'No players match your search' }}
</div>
<button
v-if="searchQuery || positionFilter !== 'all'"
class="mt-2 text-xs text-blue-400 hover:text-blue-300 transition-colors"
@click="searchQuery = ''; positionFilter = 'all'"
>
Clear filters
</button>
</div>
</div>
</div>
</div>
<!-- Lineup Slots -->
<div class="lg:col-span-2 order-1 lg:order-2">
<!-- Lineup Header with Clear Button -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h2 class="text-lg font-bold text-white">Batting Order</h2>
<div class="flex items-center gap-1.5 text-xs">
<span class="text-gray-400">Progress:</span>
<div class="w-24 h-2 bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-gradient-to-r from-blue-600 to-blue-500 transition-all duration-300"
:style="{ width: `${(currentLineupCount / (pitcherSlotDisabled ? 9 : 10)) * 100}%` }"
/>
</div>
<span class="font-medium text-gray-300">{{ currentLineupCount }}/{{ pitcherSlotDisabled ? 9 : 10 }}</span>
</div>
</div>
<button
v-if="currentLineupCount > 0"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-900/30 hover:bg-red-900/50 text-red-400 hover:text-red-300 rounded-lg border border-red-800/50 transition-all"
@click="clearLineup"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Clear
</button>
</div>
<!-- Batting order slots (1-9) -->
<div class="space-y-1.5 mb-6">
<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'
]"
>
<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>
<!-- 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
>
<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)"
>
<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>
<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>
<!-- Position selector -->
<div class="w-20 flex-shrink-0">
<select
v-if="slot.player"
v-model="slot.position"
:class="[
'w-full bg-gray-700/80 border rounded-lg px-2 py-1.5 text-sm font-medium transition-all focus:outline-none focus:ring-1',
duplicatePositions.includes(slot.position || '')
? 'border-red-500 text-red-400 focus:ring-red-500/50'
: 'border-gray-600/50 text-white focus:border-blue-500/50 focus:ring-blue-500/25'
]"
>
<option :value="null" class="text-gray-400">Pos</option>
<option
v-for="pos in getPlayerPositions(slot.player)"
:key="pos"
:value="pos"
>
{{ pos }}
</option>
</select>
<div v-else class="text-gray-600 text-xs text-center">
</div>
</div>
</div>
</div>
</div>
<!-- Pitcher slot (10) -->
<div class="mb-6">
<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">
Pitcher in batting order
</span>
</div>
<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'
]"
>
<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>
<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
>
<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) }}
</div>
</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>
</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>
<div class="w-20 flex-shrink-0">
<div class="text-green-500 text-xs text-center font-medium">P</div>
</div>
</div>
</div>
</div>
<!-- Validation errors -->
<div v-if="validationErrors.length > 0" class="bg-red-950/50 border border-red-800/50 rounded-xl p-4 mb-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-red-900/50 flex items-center justify-center">
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<div class="font-semibold text-red-300 text-sm mb-1">Please fix the following:</div>
<ul class="space-y-1">
<li v-for="error in validationErrors" :key="error" class="text-xs text-red-400/90 flex items-center gap-1.5">
<span class="w-1 h-1 rounded-full bg-red-400/60" />
{{ error }}
</li>
</ul>
</div>
</div>
</div>
<!-- Submit button -->
<div class="sticky bottom-4 pt-4">
<ActionButton
variant="success"
size="lg"
full-width
:disabled="!canSubmit"
:loading="submittingLineups"
@click="submitLineups"
>
{{ submittingLineups ? 'Submitting Lineups...' : (canSubmit ? 'Submit Lineups & Start Game' : 'Complete Both Lineups to Continue') }}
</ActionButton>
</div>
</div>
</div>
</div>
<!-- Player Preview Modal -->
<Teleport to="body">
<div
v-if="showPreview && previewPlayer"
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
@click.self="closePlayerPreview"
>
<div class="bg-gray-800 rounded-xl max-w-md w-full max-h-[90vh] overflow-y-auto shadow-2xl">
<!-- Modal Header -->
<div class="relative">
<!-- Large Player Image -->
<div class="h-48 bg-gradient-to-b from-blue-900 to-gray-800 flex items-center justify-center">
<img
v-if="getPlayerPreviewImage(previewPlayer)"
:src="getPlayerPreviewImage(previewPlayer)!"
:alt="previewPlayer.name"
class="w-32 h-32 rounded-full object-cover border-4 border-gray-700 shadow-lg"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div v-else class="w-32 h-32 rounded-full bg-gray-700 flex items-center justify-center text-gray-400 text-3xl font-bold border-4 border-gray-600">
{{ getPlayerFallbackInitial(previewPlayer) }}
</div>
</div>
<!-- Close Button -->
<button
class="absolute top-3 right-3 w-8 h-8 rounded-full bg-gray-900/80 hover:bg-gray-700 text-gray-400 hover:text-white flex items-center justify-center transition-colors"
@click="closePlayerPreview"
>
×
</button>
</div>
<!-- Player Info -->
<div class="p-6">
<h2 class="text-2xl font-bold text-center mb-1">{{ previewPlayer.name }}</h2>
<p class="text-gray-400 text-center text-sm mb-4">
{{ getPlayerPositions(previewPlayer).filter(p => p !== 'DH').join(' / ') || 'Designated Hitter' }}
</p>
<!-- Stats Grid -->
<div class="grid grid-cols-2 gap-4 mb-4">
<!-- Positions -->
<div class="bg-gray-700/50 rounded-lg p-3">
<div class="text-xs text-gray-400 uppercase tracking-wide mb-1">Positions</div>
<div class="flex flex-wrap gap-1">
<span
v-for="pos in getPlayerPositions(previewPlayer).filter(p => p !== 'DH')"
:key="pos"
class="px-2 py-0.5 bg-blue-900 text-blue-200 text-xs rounded"
>
{{ pos }}
</span>
<span v-if="getPlayerPositions(previewPlayer).filter(p => p !== 'DH').length === 0" class="text-gray-500 text-sm">
DH Only
</span>
</div>
</div>
<!-- WAR -->
<div class="bg-gray-700/50 rounded-lg p-3">
<div class="text-xs text-gray-400 uppercase tracking-wide mb-1">WAR</div>
<div class="text-xl font-bold">
{{ previewPlayer.wara?.toFixed(1) || '' }}
</div>
</div>
</div>
<!-- Close Button -->
<button
class="w-full py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 hover:text-white transition-colors"
@click="closePlayerPreview"
>
Close
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>