Bug fixes: - Fix pitcher filter to recognize SP, RP, CP positions (not just P) - Fix validation to allow 9 players when pitcher bats (no DH games) - Simplify position filters to All/Batters/Pitchers Image display improvements: - Use headshot > vanity_card priority (avoid card images in circles) - Show player initials as fallback (e.g., "AV" for Alex Verdugo) - Handle name suffixes (Jr, Sr, II, III, IV) correctly Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
955 lines
39 KiB
Vue
955 lines
39 KiB
Vue
<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'
|
||
|
||
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">←</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>
|