- Add player_positions JSONB column to roster_links (migration 006) - Add player_data JSONB column to cache name/image/headshot (migration 007) - Add is_pitcher/is_batter computed properties for two-way player support - Update lineup submission to populate RosterLink with all players + positions - Update get_bench handler to use cached data (no runtime API calls) - Add BenchPlayer type to frontend with proper filtering - Add new Lineup components: InlineSubstitutionPanel, LineupSlotRow, PositionSelector, UnifiedLineupTab - Add integration tests for get_bench_players Bench players now load instantly without API dependency, and properly filter batters vs pitchers (including CP closer position). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
206 lines
5.3 KiB
Vue
206 lines
5.3 KiB
Vue
<template>
|
|
<div
|
|
:class="[
|
|
'rounded-lg border mb-1.5 transition-all',
|
|
containerClass
|
|
]"
|
|
>
|
|
<!-- Slot Header Row -->
|
|
<div class="flex items-center gap-3 p-2.5">
|
|
<!-- Order Number / Position Badge -->
|
|
<div :class="['w-8 h-8 rounded-lg flex items-center justify-center', badgeClass]">
|
|
<span class="text-sm font-bold">{{ displayBadge }}</span>
|
|
</div>
|
|
|
|
<!-- Player Avatar -->
|
|
<div
|
|
:class="[
|
|
'w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold',
|
|
avatarClass
|
|
]"
|
|
>
|
|
<img
|
|
v-if="player.player.headshot"
|
|
:src="player.player.headshot"
|
|
:alt="player.player.name"
|
|
class="w-full h-full rounded-full object-cover"
|
|
>
|
|
<span v-else>{{ initials }}</span>
|
|
</div>
|
|
|
|
<!-- Player Info -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="font-medium text-sm truncate">{{ player.player.name }}</div>
|
|
<div :class="['text-xs', subtextClass]">
|
|
{{ player.position }}
|
|
<template v-if="statusText">
|
|
<span class="mx-1">{{ statusText }}</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<template v-if="showActions && !isExpanded">
|
|
<button
|
|
class="px-3 py-1.5 text-xs font-medium bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors select-none touch-manipulation"
|
|
@click="$emit('substitute')"
|
|
>
|
|
{{ substituteLabel }}
|
|
</button>
|
|
<button
|
|
v-if="showPositionChange"
|
|
class="px-2 py-1.5 text-xs font-medium bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors select-none touch-manipulation"
|
|
title="Change Position"
|
|
@click="$emit('changePosition')"
|
|
>
|
|
✎
|
|
</button>
|
|
</template>
|
|
|
|
<!-- Cancel Button (when expanded) -->
|
|
<button
|
|
v-if="isExpanded"
|
|
class="px-3 py-1.5 text-xs font-medium bg-red-900/50 hover:bg-red-900/70 text-red-300 rounded-lg transition-colors select-none touch-manipulation"
|
|
@click="$emit('cancel')"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Slot for expanded content (accordion) -->
|
|
<div
|
|
v-if="isExpanded"
|
|
class="border-t border-gray-700/50"
|
|
>
|
|
<slot name="expanded" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import type { Lineup } from '~/types'
|
|
|
|
type SlotState = 'normal' | 'at_bat' | 'on_base' | 'expanded' | 'position_change'
|
|
|
|
interface Props {
|
|
player: Lineup
|
|
battingOrder?: number | null
|
|
state?: SlotState
|
|
isExpanded?: boolean
|
|
showActions?: boolean
|
|
showPositionChange?: boolean
|
|
substituteLabel?: string
|
|
isPitcher?: boolean
|
|
basePosition?: '1B' | '2B' | '3B' | null // For runners on base display
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
battingOrder: null,
|
|
state: 'normal',
|
|
isExpanded: false,
|
|
showActions: true,
|
|
showPositionChange: true,
|
|
substituteLabel: 'Substitute',
|
|
isPitcher: false,
|
|
basePosition: null,
|
|
})
|
|
|
|
defineEmits<{
|
|
substitute: []
|
|
changePosition: []
|
|
cancel: []
|
|
}>()
|
|
|
|
// Compute initials for avatar fallback
|
|
const initials = computed(() => {
|
|
const parts = props.player.player.name.split(' ')
|
|
if (parts.length >= 2) {
|
|
return parts[0][0] + parts[parts.length - 1][0]
|
|
}
|
|
return props.player.player.name.substring(0, 2).toUpperCase()
|
|
})
|
|
|
|
// Display badge (order number or base position)
|
|
const displayBadge = computed(() => {
|
|
if (props.basePosition) {
|
|
return props.basePosition
|
|
}
|
|
if (props.isPitcher) {
|
|
return 'P'
|
|
}
|
|
return props.battingOrder ?? props.player.batting_order ?? '?'
|
|
})
|
|
|
|
// Container styling based on state
|
|
const containerClass = computed(() => {
|
|
if (props.isExpanded) {
|
|
if (props.state === 'position_change') {
|
|
return 'bg-gray-800/60 border-blue-500/50 ring-2 ring-blue-500/30'
|
|
}
|
|
return 'bg-gray-800/60 border-amber-500/50 ring-2 ring-amber-500/30'
|
|
}
|
|
|
|
switch (props.state) {
|
|
case 'at_bat':
|
|
return 'bg-gray-800/60 border-amber-500/50 ring-2 ring-amber-500/30'
|
|
case 'on_base':
|
|
return 'bg-gray-800/60 border-green-700/50'
|
|
default:
|
|
return 'bg-gray-800/60 border-gray-700/50'
|
|
}
|
|
})
|
|
|
|
// Badge styling
|
|
const badgeClass = computed(() => {
|
|
if (props.basePosition) {
|
|
return 'bg-green-800/50 text-green-400'
|
|
}
|
|
if (props.isPitcher) {
|
|
return 'bg-blue-900/50 text-blue-400'
|
|
}
|
|
|
|
switch (props.state) {
|
|
case 'at_bat':
|
|
return 'bg-amber-900/50 text-amber-400'
|
|
default:
|
|
return 'bg-gray-700/50 text-gray-400'
|
|
}
|
|
})
|
|
|
|
// Avatar styling
|
|
const avatarClass = computed(() => {
|
|
switch (props.state) {
|
|
case 'at_bat':
|
|
return 'bg-gradient-to-br from-amber-700 to-amber-800'
|
|
case 'on_base':
|
|
return 'bg-gradient-to-br from-green-700 to-green-800 ring-2 ring-green-500/50'
|
|
default:
|
|
return 'bg-gradient-to-br from-gray-600 to-gray-700'
|
|
}
|
|
})
|
|
|
|
// Subtext styling
|
|
const subtextClass = computed(() => {
|
|
switch (props.state) {
|
|
case 'at_bat':
|
|
return 'text-amber-400'
|
|
case 'on_base':
|
|
return 'text-green-400'
|
|
default:
|
|
return 'text-gray-400'
|
|
}
|
|
})
|
|
|
|
// Status text (AT BAT, On 1st, etc.)
|
|
const statusText = computed(() => {
|
|
if (props.state === 'at_bat') {
|
|
return 'AT BAT'
|
|
}
|
|
if (props.basePosition) {
|
|
return `On ${props.basePosition}`
|
|
}
|
|
return null
|
|
})
|
|
</script>
|