- Replace .border-l-4.border-gray-600 checks with .catcher-pill - Update isExpanded prop references to isSelected - Adjust expanded view tests for new side-by-side layout - matchup-card-blue = runner full card - matchup-card = catcher full card - Fix deselection test to check isSelected prop (Transition keeps DOM during animation) All 15 RunnersOnBase tests passing All 16 RunnerCard tests passing
263 lines
8.7 KiB
Vue
263 lines
8.7 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) -->
|
|
<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 bg-white border border-gray-200 shadow-sm text-center">
|
|
<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 is selected) -->
|
|
<Transition name="expand">
|
|
<div v-if="hasSelection && selectedRunnerPlayer" class="mt-3 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>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } 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' | null>(null)
|
|
|
|
// Helper constants for iteration
|
|
const baseKeys = ['first', 'second', 'third'] as const
|
|
const baseLabels: ('1B' | '2B' | '3B')[] = ['1B', '2B', '3B']
|
|
const baseNameToLabel: Record<string, string> = { first: '1B', second: '2B', third: '3B' }
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Auto-deselect when the selected runner's base becomes empty
|
|
watch(() => props.runners, (newRunners) => {
|
|
if (selectedRunner.value && !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>
|