strat-gameplay-webapp/frontend-sba/components/Game/RunnerCard.vue
Cal Corum 5d20d84568 CLAUDE: Add RunnersOnBase component with expanding cards and runner/catcher matchup
- Created RunnersOnBase.vue component with split layout (runners left, catcher right)
- Created RunnerCard.vue for individual runner cards with expand-in-place functionality
- Integrated component into GamePlay.vue (mobile and desktop layouts)
- Added team color computed properties for batting/fielding teams
- Component only shows when runners on base (hasRunners computed)
- Click runner card to expand in place and show full player card + catcher matchup
- Smooth CSS transitions and animations matching pitcher/batter card style
- Includes design documentation and HTML mockup for reference
2026-02-06 19:13:52 -06:00

188 lines
5.3 KiB
Vue

<template>
<div
:class="[
'runner-card',
runner ? 'occupied' : 'empty',
isExpanded ? 'expanded' : ''
]"
@click="handleClick"
>
<!-- Summary (always visible) -->
<div class="runner-summary">
<template v-if="runner">
<!-- Occupied base -->
<div class="w-10 h-10 rounded-full flex-shrink-0 overflow-hidden border-2" :style="{ borderColor: teamColor }">
<img
v-if="runnerPlayer?.headshot || runnerPlayer?.image"
:src="runnerPlayer.headshot || runnerPlayer.image"
:alt="runnerName"
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 text-sm">
{{ base }}
</div>
</div>
<div class="ml-3 flex-1">
<div class="text-sm font-bold text-gray-900">{{ runnerName }}</div>
<div class="text-xs text-gray-600">#{{ runnerNumber }} • {{ base }}</div>
</div>
<div class="text-xs text-gray-400">
<svg
xmlns="http://www.w3.org/2000/svg"
:class="['h-4 w-4 transition-transform', isExpanded ? 'rotate-90' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</template>
<template v-else>
<!-- Empty base -->
<div class="w-10 h-10 rounded-full bg-gray-200 border-2 border-dashed border-gray-400 flex items-center justify-center flex-shrink-0">
<span class="text-gray-400 text-sm font-bold">{{ base }}</span>
</div>
<div class="ml-3 text-sm text-gray-400 font-medium">Empty</div>
</template>
</div>
<!-- Expanded view (full card) -->
<div v-if="runner && isExpanded" class="runner-expanded">
<div class="bg-gradient-to-b from-blue-900 to-blue-950 rounded-b-lg overflow-hidden matchup-card-blue">
<div class="bg-blue-800/80 px-3 py-2 flex items-center gap-2 text-white text-xs font-semibold">
<span class="font-bold text-white/90">RUNNER</span>
<span class="text-white/70">{{ base }}</span>
<span class="truncate flex-1 text-right font-bold">{{ runnerName }}</span>
</div>
<div class="p-0">
<img
v-if="runnerPlayer?.image"
:src="runnerPlayer.image"
:alt="`${runnerName} 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">{{ getRunnerInitials }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { LineupPlayerState } from '~/types/game'
import { useGameStore } from '~/store/game'
interface Props {
base: '1B' | '2B' | '3B'
runner: LineupPlayerState | null
isExpanded: boolean
teamColor: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
click: []
}>()
const gameStore = useGameStore()
// Resolve player data from lineup
const runnerPlayer = computed(() => {
if (!props.runner) return null
const lineupEntry = gameStore.findPlayerInLineup(props.runner.lineup_id)
return lineupEntry?.player ?? null
})
const runnerName = computed(() => {
if (!runnerPlayer.value) return 'Unknown Runner'
return runnerPlayer.value.name
})
const runnerNumber = computed(() => {
// Try to extract jersey number from player data if available
// For now, default to a placeholder based on lineup_id
return props.runner?.lineup_id?.toString().padStart(2, '0') ?? '00'
})
const getRunnerInitials = computed(() => {
if (!runnerPlayer.value) return '?'
const parts = runnerPlayer.value.name.split(' ')
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
return runnerPlayer.value.name.substring(0, 2).toUpperCase()
})
function handleClick() {
if (props.runner) {
emit('click')
}
}
</script>
<style scoped>
.runner-card {
@apply bg-white border-l-4 rounded-lg shadow-sm transition-all duration-400;
}
.runner-card.empty {
@apply bg-gray-50 border-gray-300;
}
.runner-card.occupied {
@apply cursor-pointer;
}
.runner-card.occupied:hover:not(.expanded) {
@apply transform translate-x-1;
background: rgba(255, 255, 255, 0.95);
}
.runner-card.expanded {
@apply transform scale-105 z-10;
}
.runner-summary {
@apply p-2 flex items-center;
}
.runner-expanded {
@apply overflow-hidden;
animation: expandHeight 0.4s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeIn 0.3s ease-out;
}
.matchup-card-blue {
animation: pulseGlowBlue 2s ease-in-out infinite;
}
@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>