- Swap base order to 3B, 2B, 1B (left to right, closer to baseball diamond) - Auto-select lead runner on mount (priority: 3B > 2B > 1B) - Make catcher pill clickable to show catcher card only - Add 'catcher' as a selection option alongside runner bases - Update expanded view to handle catcher-only display (centered, single card) - Add toggleCatcher() function - Update tests for new base order and auto-selection behavior All 15 RunnersOnBase tests passing All 16 RunnerCard tests passing
319 lines
11 KiB
Vue
319 lines
11 KiB
Vue
<template>
|
|
<!-- TODO: Spruce up the appearance of this component - improve styling, colors, animations, and visual polish -->
|
|
<div v-if="hasRunners" class="runners-on-base-container">
|
|
<!-- TOP ROW: Runner pills + Catcher summary -->
|
|
<div class="flex gap-2 items-stretch">
|
|
<!-- Runner pills (flex, equal width) -->
|
|
<div class="flex-1 flex gap-2">
|
|
<RunnerCard
|
|
v-for="(key, idx) in baseKeys"
|
|
:key="key"
|
|
class="flex-1"
|
|
:base="baseLabels[idx]"
|
|
:runner="runners[key]"
|
|
:is-selected="selectedRunner === key"
|
|
:team-color="battingTeamColor"
|
|
@click="toggleRunner(key)"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Catcher summary pill (fixed width, full row height, clickable) -->
|
|
<div class="w-28 md:w-36 flex-shrink-0">
|
|
<div
|
|
:class="[
|
|
'catcher-pill h-full flex flex-col items-center justify-center p-2 rounded-lg border shadow-sm text-center cursor-pointer transition-all duration-200',
|
|
selectedRunner === 'catcher' ? 'bg-green-50 border-green-500 ring-2 ring-green-500' : 'bg-white border-gray-200 hover:bg-green-50'
|
|
]"
|
|
@click="toggleCatcher"
|
|
>
|
|
<div class="w-10 h-10 rounded-full overflow-hidden border-2 border-gray-600 flex-shrink-0">
|
|
<img
|
|
v-if="catcherPlayer?.headshot || catcherPlayer?.image"
|
|
:src="catcherPlayer.headshot || catcherPlayer.image"
|
|
:alt="catcherName"
|
|
class="w-full h-full object-cover"
|
|
>
|
|
<div v-else class="w-full h-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold">
|
|
C
|
|
</div>
|
|
</div>
|
|
<div class="text-xs font-bold text-gray-900 mt-1 truncate w-full px-1">{{ catcherName }}</div>
|
|
<div class="text-[10px] text-gray-500">C</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- EXPANDED ROW: Runner card + Catcher card side by side (when a runner or catcher is selected) -->
|
|
<Transition name="expand">
|
|
<div v-if="hasSelection" class="mt-3">
|
|
<!-- Catcher only view (when catcher selected) -->
|
|
<div v-if="selectedRunner === 'catcher'" class="flex justify-center">
|
|
<div class="matchup-card bg-gradient-to-b from-green-900 to-green-950 border-2 border-green-600 rounded-xl overflow-hidden shadow-lg max-w-md w-full">
|
|
<div class="bg-green-800/80 px-3 py-2 flex items-center gap-2 text-white text-sm font-semibold">
|
|
<span class="font-bold text-white/90" :style="{ color: fieldingTeamColor }">
|
|
{{ fieldingTeamAbbrev }}
|
|
</span>
|
|
<span class="text-white/70">C</span>
|
|
<span class="truncate flex-1 text-right font-bold">{{ catcherName }}</span>
|
|
</div>
|
|
<div class="p-0">
|
|
<img
|
|
v-if="catcherPlayer?.image"
|
|
:src="catcherPlayer.image"
|
|
:alt="`${catcherName} card`"
|
|
class="w-full h-auto"
|
|
>
|
|
<div v-else class="w-full aspect-[4/5.5] bg-gradient-to-br from-green-700 to-green-900 flex items-center justify-center">
|
|
<span class="text-5xl font-bold text-white/60">{{ getCatcherInitials }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Runner + Catcher view (when runner selected) -->
|
|
<div v-else-if="selectedRunnerPlayer" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<!-- Selected Runner full card -->
|
|
<div class="matchup-card-blue bg-gradient-to-b from-blue-900 to-blue-950 border-2 border-blue-600 rounded-xl overflow-hidden shadow-lg">
|
|
<div class="bg-blue-800/80 px-3 py-2 flex items-center gap-2 text-white text-sm font-semibold">
|
|
<span class="font-bold text-white/90" :style="{ color: battingTeamColor }">
|
|
{{ battingTeamAbbrev }}
|
|
</span>
|
|
<span class="text-white/70">{{ selectedBase }}</span>
|
|
<span class="truncate flex-1 text-right font-bold">{{ selectedRunnerName }}</span>
|
|
</div>
|
|
<div class="p-0">
|
|
<img
|
|
v-if="selectedRunnerPlayer?.image"
|
|
:src="selectedRunnerPlayer.image"
|
|
:alt="`${selectedRunnerName} card`"
|
|
class="w-full h-auto"
|
|
>
|
|
<div v-else class="w-full aspect-[4/5.5] bg-gradient-to-br from-blue-700 to-blue-900 flex items-center justify-center">
|
|
<span class="text-5xl font-bold text-white/60">{{ selectedRunnerInitials }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Catcher full card -->
|
|
<div class="matchup-card bg-gradient-to-b from-green-900 to-green-950 border-2 border-green-600 rounded-xl overflow-hidden shadow-lg">
|
|
<div class="bg-green-800/80 px-3 py-2 flex items-center gap-2 text-white text-sm font-semibold">
|
|
<span class="font-bold text-white/90" :style="{ color: fieldingTeamColor }">
|
|
{{ fieldingTeamAbbrev }}
|
|
</span>
|
|
<span class="text-white/70">C</span>
|
|
<span class="truncate flex-1 text-right font-bold">{{ catcherName }}</span>
|
|
</div>
|
|
<div class="p-0">
|
|
<img
|
|
v-if="catcherPlayer?.image"
|
|
:src="catcherPlayer.image"
|
|
:alt="`${catcherName} card`"
|
|
class="w-full h-auto"
|
|
>
|
|
<div v-else class="w-full aspect-[4/5.5] bg-gradient-to-br from-green-700 to-green-900 flex items-center justify-center">
|
|
<span class="text-5xl font-bold text-white/60">{{ getCatcherInitials }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch, onMounted } from 'vue'
|
|
import type { LineupPlayerState } from '~/types/game'
|
|
import type { Lineup } from '~/types/player'
|
|
import { useGameStore } from '~/store/game'
|
|
import RunnerCard from './RunnerCard.vue'
|
|
|
|
interface Props {
|
|
runners: {
|
|
first: LineupPlayerState | null
|
|
second: LineupPlayerState | null
|
|
third: LineupPlayerState | null
|
|
}
|
|
fieldingLineup?: Lineup[]
|
|
battingTeamColor?: string
|
|
fieldingTeamColor?: string
|
|
battingTeamAbbrev?: string
|
|
fieldingTeamAbbrev?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
fieldingLineup: () => [],
|
|
battingTeamColor: '#3b82f6',
|
|
fieldingTeamColor: '#10b981',
|
|
battingTeamAbbrev: '',
|
|
fieldingTeamAbbrev: '',
|
|
})
|
|
|
|
const gameStore = useGameStore()
|
|
const selectedRunner = ref<'first' | 'second' | 'third' | 'catcher' | null>(null)
|
|
|
|
// Helper constants for iteration (3B, 2B, 1B order - left to right)
|
|
const baseKeys = ['third', 'second', 'first'] as const
|
|
const baseLabels: ('1B' | '2B' | '3B')[] = ['3B', '2B', '1B']
|
|
const baseNameToLabel: Record<string, string> = { first: '1B', second: '2B', third: '3B' }
|
|
|
|
// Auto-select lead runner on mount
|
|
onMounted(() => {
|
|
if (!selectedRunner.value) {
|
|
// Select lead runner (third → second → first priority)
|
|
if (props.runners.third) {
|
|
selectedRunner.value = 'third'
|
|
} else if (props.runners.second) {
|
|
selectedRunner.value = 'second'
|
|
} else if (props.runners.first) {
|
|
selectedRunner.value = 'first'
|
|
}
|
|
}
|
|
})
|
|
|
|
// Check if any runners on base
|
|
const hasRunners = computed(() => {
|
|
return !!(props.runners.first || props.runners.second || props.runners.third)
|
|
})
|
|
|
|
const hasSelection = computed(() => selectedRunner.value !== null)
|
|
|
|
// Get catcher from fielding lineup
|
|
const catcherLineup = computed(() => {
|
|
return props.fieldingLineup.find(p => p.position === 'C')
|
|
})
|
|
|
|
const catcherPlayer = computed(() => catcherLineup.value?.player ?? null)
|
|
|
|
const catcherName = computed(() => catcherPlayer.value?.name ?? 'Unknown Catcher')
|
|
|
|
const catcherNumber = computed(() => {
|
|
// Try to extract jersey number from player data if available
|
|
// For now, default to a placeholder
|
|
return '00'
|
|
})
|
|
|
|
const getCatcherInitials = computed(() => {
|
|
if (!catcherPlayer.value) return 'C'
|
|
const parts = catcherPlayer.value.name.split(' ')
|
|
if (parts.length >= 2) {
|
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
}
|
|
return catcherPlayer.value.name.substring(0, 2).toUpperCase()
|
|
})
|
|
|
|
// Selected runner data resolution (mirrors catcher pattern)
|
|
const selectedRunnerState = computed(() => {
|
|
if (!selectedRunner.value) return null
|
|
return props.runners[selectedRunner.value]
|
|
})
|
|
|
|
const selectedRunnerLineup = computed(() => {
|
|
if (!selectedRunnerState.value) return null
|
|
return gameStore.findPlayerInLineup(selectedRunnerState.value.lineup_id)
|
|
})
|
|
|
|
const selectedRunnerPlayer = computed(() => selectedRunnerLineup.value?.player ?? null)
|
|
|
|
const selectedRunnerName = computed(() => selectedRunnerPlayer.value?.name ?? 'Unknown Runner')
|
|
|
|
const selectedBase = computed(() => {
|
|
if (!selectedRunner.value) return ''
|
|
return baseNameToLabel[selectedRunner.value] ?? ''
|
|
})
|
|
|
|
const selectedRunnerInitials = computed(() => {
|
|
if (!selectedRunnerPlayer.value) return '?'
|
|
const parts = selectedRunnerPlayer.value.name.split(' ')
|
|
if (parts.length >= 2) {
|
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
}
|
|
return selectedRunnerPlayer.value.name.substring(0, 2).toUpperCase()
|
|
})
|
|
|
|
function toggleRunner(base: 'first' | 'second' | 'third') {
|
|
// Can't select empty base
|
|
if (!props.runners[base]) return
|
|
|
|
// Toggle selection
|
|
if (selectedRunner.value === base) {
|
|
selectedRunner.value = null
|
|
} else {
|
|
selectedRunner.value = base
|
|
}
|
|
}
|
|
|
|
function toggleCatcher() {
|
|
// Toggle catcher selection
|
|
if (selectedRunner.value === 'catcher') {
|
|
selectedRunner.value = null
|
|
} else {
|
|
selectedRunner.value = 'catcher'
|
|
}
|
|
}
|
|
|
|
// Auto-deselect when the selected runner's base becomes empty
|
|
watch(() => props.runners, (newRunners) => {
|
|
if (selectedRunner.value && selectedRunner.value !== 'catcher' && !newRunners[selectedRunner.value]) {
|
|
selectedRunner.value = null
|
|
}
|
|
}, { deep: true })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.runners-on-base-container {
|
|
@apply mb-6;
|
|
}
|
|
|
|
/* Expanded row animations */
|
|
.expand-enter-active {
|
|
animation: expandHeight 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.expand-leave-active {
|
|
animation: expandHeight 0.3s cubic-bezier(0.4, 0, 0.2, 1) reverse;
|
|
}
|
|
|
|
.matchup-card {
|
|
animation: pulseGlowGreen 2s ease-in-out infinite;
|
|
}
|
|
|
|
.matchup-card-blue {
|
|
animation: pulseGlowBlue 2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulseGlowGreen {
|
|
0%, 100% {
|
|
box-shadow: 0 0 15px 2px rgba(16, 185, 129, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 30px 8px rgba(16, 185, 129, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
|
}
|
|
}
|
|
|
|
@keyframes pulseGlowBlue {
|
|
0%, 100% {
|
|
box-shadow: 0 0 15px 2px rgba(59, 130, 246, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 30px 8px rgba(59, 130, 246, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
|
}
|
|
}
|
|
|
|
@keyframes expandHeight {
|
|
from {
|
|
max-height: 0;
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
max-height: 800px;
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
</style>
|