Runner highlights and cards: - Pills: red-500 ring, red-50 background when selected - Full cards: red gradient (red-900 to red-950), red-600 border - Pulse glow: red animation (rgba(239, 68, 68)) - Hardcoded red color (#ef4444) for runner pill borders Catcher highlights and cards: - Pill: blue-500 ring, blue-50 background when selected - Full card: blue gradient (blue-900 to blue-950), blue-600 border - Pulse glow: blue animation (rgba(59, 130, 246)) Updated tests to expect new colors All 15 RunnersOnBase tests passing All 16 RunnerCard tests passing
311 lines
10 KiB
Vue
311 lines
10 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="'#ef4444'"
|
|
@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-blue-50 border-blue-500 ring-2 ring-blue-500' : 'bg-white border-gray-200 hover:bg-blue-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 && displayedRunnerPlayer" class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<!-- Lead/Selected Runner full card (RED) -->
|
|
<div class="matchup-card-red bg-gradient-to-b from-red-900 to-red-950 border-2 border-red-600 rounded-xl overflow-hidden shadow-lg">
|
|
<div class="bg-red-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="displayedRunnerPlayer?.image"
|
|
:src="displayedRunnerPlayer.image"
|
|
:alt="`${selectedRunnerName} card`"
|
|
class="w-full h-auto"
|
|
>
|
|
<div v-else class="w-full aspect-[4/5.5] bg-gradient-to-br from-red-700 to-red-900 flex items-center justify-center">
|
|
<span class="text-5xl font-bold text-white/60">{{ selectedRunnerInitials }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Catcher full card (BLUE) -->
|
|
<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: 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-blue-700 to-blue-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, 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()
|
|
})
|
|
|
|
// Lead runner determination (for catcher-only selection)
|
|
const leadRunnerBase = computed(() => {
|
|
if (props.runners.third) return 'third'
|
|
if (props.runners.second) return 'second'
|
|
if (props.runners.first) return 'first'
|
|
return null
|
|
})
|
|
|
|
// Displayed runner: selected runner OR lead runner (when catcher selected)
|
|
const displayedRunnerBase = computed(() => {
|
|
if (selectedRunner.value === 'catcher') {
|
|
return leadRunnerBase.value
|
|
}
|
|
return selectedRunner.value
|
|
})
|
|
|
|
// Selected runner data resolution (mirrors catcher pattern)
|
|
const selectedRunnerState = computed(() => {
|
|
if (!displayedRunnerBase.value || displayedRunnerBase.value === 'catcher') return null
|
|
return props.runners[displayedRunnerBase.value]
|
|
})
|
|
|
|
const selectedRunnerLineup = computed(() => {
|
|
if (!selectedRunnerState.value) return null
|
|
return gameStore.findPlayerInLineup(selectedRunnerState.value.lineup_id)
|
|
})
|
|
|
|
const selectedRunnerPlayer = computed(() => selectedRunnerLineup.value?.player ?? null)
|
|
|
|
// For display: use displayedRunnerBase instead of selectedRunner
|
|
const displayedRunnerPlayer = computed(() => selectedRunnerPlayer.value)
|
|
|
|
const selectedRunnerName = computed(() => selectedRunnerPlayer.value?.name ?? 'Unknown Runner')
|
|
|
|
const selectedBase = computed(() => {
|
|
if (!displayedRunnerBase.value || displayedRunnerBase.value === 'catcher') return ''
|
|
return baseNameToLabel[displayedRunnerBase.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-red {
|
|
animation: pulseGlowRed 2s ease-in-out infinite;
|
|
}
|
|
|
|
.matchup-card-blue {
|
|
animation: pulseGlowBlue 2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulseGlowRed {
|
|
0%, 100% {
|
|
box-shadow: 0 0 15px 2px rgba(239, 68, 68, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 30px 8px rgba(239, 68, 68, 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>
|