- 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
188 lines
5.3 KiB
Vue
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>
|