Features: - PlayerCardModal: Tap any player to view full playing card image - OutcomeWizard: Progressive 3-step outcome selection (On Base/Out/X-Check) - GameBoard: Expandable view showing all 9 fielder positions - Post-roll card display: Shows batter/pitcher card based on d6 roll - CurrentSituation: Tappable player cards with modal integration Bug fixes: - Fix batter not advancing after play (state_manager recovery logic) - Add dark mode support for buttons and panels (partial - iOS issue noted) New files: - PlayerCardModal.vue, OutcomeWizard.vue, BottomSheet.vue - outcomeFlow.ts constants for outcome category mapping - TEST_PLAN_UI_OVERHAUL.md with 23/24 tests passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
319 lines
13 KiB
Vue
319 lines
13 KiB
Vue
<template>
|
|
<div class="current-situation">
|
|
<!-- Mobile Layout (Stacked) -->
|
|
<div class="lg:hidden space-y-3">
|
|
<!-- Current Pitcher Card -->
|
|
<button
|
|
v-if="currentPitcher"
|
|
class="w-full text-left bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-4 border-2 border-blue-200 dark:border-blue-700 shadow-md hover:shadow-lg transition-shadow cursor-pointer"
|
|
@click="openPlayerCard('pitcher')"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<!-- Pitcher Image/Badge -->
|
|
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden ring-2 ring-blue-300 ring-offset-1">
|
|
<img
|
|
v-if="getPlayerPreviewImage(pitcherPlayer)"
|
|
:src="getPlayerPreviewImage(pitcherPlayer)!"
|
|
:alt="pitcherName"
|
|
class="w-full h-full object-cover"
|
|
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
|
>
|
|
<div v-else class="w-full h-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-lg">
|
|
{{ getPlayerFallbackInitial(pitcherPlayer) }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pitcher Info -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase tracking-wide mb-0.5">
|
|
Pitching
|
|
</div>
|
|
<div class="text-base font-bold text-gray-900 dark:text-white truncate">
|
|
{{ pitcherName }}
|
|
</div>
|
|
<div class="text-xs text-gray-600 dark:text-gray-400">
|
|
{{ currentPitcher.position }}
|
|
<span class="ml-2 text-blue-500">Tap to view card</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
<!-- VS Indicator -->
|
|
<div class="flex items-center justify-center">
|
|
<div class="px-4 py-1 bg-gray-800 dark:bg-gray-700 text-white rounded-full text-xs font-bold shadow-lg">
|
|
VS
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Current Batter Card -->
|
|
<button
|
|
v-if="currentBatter"
|
|
class="w-full text-left bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 rounded-xl p-4 border-2 border-red-200 dark:border-red-700 shadow-md hover:shadow-lg transition-shadow cursor-pointer"
|
|
@click="openPlayerCard('batter')"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<!-- Batter Image/Badge -->
|
|
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden ring-2 ring-red-300 ring-offset-1">
|
|
<img
|
|
v-if="getPlayerPreviewImage(batterPlayer)"
|
|
:src="getPlayerPreviewImage(batterPlayer)!"
|
|
:alt="batterName"
|
|
class="w-full h-full object-cover"
|
|
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
|
>
|
|
<div v-else class="w-full h-full bg-gradient-to-br from-red-500 to-red-600 flex items-center justify-center text-white font-bold text-lg">
|
|
{{ getPlayerFallbackInitial(batterPlayer) }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Batter Info -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-xs font-semibold text-red-600 dark:text-red-400 uppercase tracking-wide mb-0.5">
|
|
At Bat
|
|
</div>
|
|
<div class="text-base font-bold text-gray-900 dark:text-white truncate">
|
|
{{ batterName }}
|
|
</div>
|
|
<div class="text-xs text-gray-600 dark:text-gray-400">
|
|
{{ currentBatter.position }}
|
|
<span v-if="currentBatter.batting_order" class="ml-1">• Batting {{ currentBatter.batting_order }}</span>
|
|
<span class="ml-2 text-red-500">Tap to view card</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Desktop Layout (Side-by-Side) -->
|
|
<div class="hidden lg:grid lg:grid-cols-2 gap-6">
|
|
<!-- Current Pitcher Card -->
|
|
<button
|
|
v-if="currentPitcher"
|
|
class="text-left bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-6 border-2 border-blue-200 dark:border-blue-700 shadow-lg hover:shadow-xl transition-shadow cursor-pointer"
|
|
@click="openPlayerCard('pitcher')"
|
|
>
|
|
<div class="flex items-start gap-4">
|
|
<!-- Pitcher Image/Badge -->
|
|
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden ring-2 ring-blue-300 ring-offset-2">
|
|
<img
|
|
v-if="getPlayerPreviewImage(pitcherPlayer)"
|
|
:src="getPlayerPreviewImage(pitcherPlayer)!"
|
|
:alt="pitcherName"
|
|
class="w-full h-full object-cover"
|
|
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
|
>
|
|
<div v-else class="w-full h-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-2xl">
|
|
{{ getPlayerFallbackInitial(pitcherPlayer) }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pitcher Details -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-semibold text-blue-600 dark:text-blue-400 uppercase tracking-wide mb-1">
|
|
Pitching
|
|
</div>
|
|
<div class="text-xl font-bold text-gray-900 dark:text-white mb-1 truncate">
|
|
{{ pitcherName }}
|
|
</div>
|
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
{{ currentPitcher.position }}
|
|
<span class="ml-2 text-blue-500">Click to view card</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
<!-- Current Batter Card -->
|
|
<button
|
|
v-if="currentBatter"
|
|
class="text-left bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 rounded-xl p-6 border-2 border-red-200 dark:border-red-700 shadow-lg hover:shadow-xl transition-shadow cursor-pointer"
|
|
@click="openPlayerCard('batter')"
|
|
>
|
|
<div class="flex items-start gap-4">
|
|
<!-- Batter Image/Badge -->
|
|
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden ring-2 ring-red-300 ring-offset-2">
|
|
<img
|
|
v-if="getPlayerPreviewImage(batterPlayer)"
|
|
:src="getPlayerPreviewImage(batterPlayer)!"
|
|
:alt="batterName"
|
|
class="w-full h-full object-cover"
|
|
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
|
>
|
|
<div v-else class="w-full h-full bg-gradient-to-br from-red-500 to-red-600 flex items-center justify-center text-white font-bold text-2xl">
|
|
{{ getPlayerFallbackInitial(batterPlayer) }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Batter Details -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-semibold text-red-600 dark:text-red-400 uppercase tracking-wide mb-1">
|
|
At Bat
|
|
</div>
|
|
<div class="text-xl font-bold text-gray-900 dark:text-white mb-1 truncate">
|
|
{{ batterName }}
|
|
</div>
|
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
{{ currentBatter.position }}
|
|
<span v-if="currentBatter.batting_order" class="ml-2">• Batting {{ currentBatter.batting_order }}</span>
|
|
<span class="ml-2 text-red-500">Click to view card</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div
|
|
v-if="!currentBatter && !currentPitcher"
|
|
class="text-center py-12 px-4 bg-gray-50 dark:bg-gray-800 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-700"
|
|
>
|
|
<div class="w-16 h-16 mx-auto mb-4 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<p class="text-gray-500 dark:text-gray-400 font-medium">Waiting for game to start...</p>
|
|
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Players will appear here once the game begins.</p>
|
|
</div>
|
|
|
|
<!-- Player Card Modal -->
|
|
<PlayerCardModal
|
|
:is-open="isPlayerCardOpen"
|
|
:player="selectedPlayerData"
|
|
:position="selectedPlayerPosition"
|
|
:team-name="selectedPlayerTeam"
|
|
:show-substitute-button="false"
|
|
@close="closePlayerCard"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, watch, toRefs, ref } from 'vue'
|
|
import type { LineupPlayerState } from '~/types/game'
|
|
import { useGameStore } from '~/store/game'
|
|
import PlayerCardModal from '~/components/Player/PlayerCardModal.vue'
|
|
|
|
interface Props {
|
|
currentBatter?: LineupPlayerState | null
|
|
currentPitcher?: LineupPlayerState | null
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
currentBatter: null,
|
|
currentPitcher: null
|
|
})
|
|
|
|
// Debug: Watch for prop changes
|
|
const { currentBatter } = toRefs(props)
|
|
watch(currentBatter, (newBatter, oldBatter) => {
|
|
const oldInfo = oldBatter
|
|
? `lineup_id=${oldBatter.lineup_id}, batting_order=${oldBatter.batting_order}`
|
|
: 'None'
|
|
const newInfo = newBatter
|
|
? `lineup_id=${newBatter.lineup_id}, batting_order=${newBatter.batting_order}`
|
|
: 'None'
|
|
console.log('[CurrentSituation] currentBatter prop changed:', oldInfo, '->', newInfo)
|
|
}, { immediate: true })
|
|
|
|
const gameStore = useGameStore()
|
|
|
|
// Resolve player data from lineup using lineup_id
|
|
const batterPlayer = computed(() => {
|
|
if (!props.currentBatter) return null
|
|
const lineupEntry = gameStore.findPlayerInLineup(props.currentBatter.lineup_id)
|
|
return lineupEntry?.player ?? null
|
|
})
|
|
|
|
const pitcherPlayer = computed(() => {
|
|
if (!props.currentPitcher) return null
|
|
const lineupEntry = gameStore.findPlayerInLineup(props.currentPitcher.lineup_id)
|
|
return lineupEntry?.player ?? null
|
|
})
|
|
|
|
// Computed properties for player names with fallback
|
|
const batterName = computed(() => {
|
|
if (batterPlayer.value?.name) return batterPlayer.value.name
|
|
if (!props.currentBatter) return 'Unknown Batter'
|
|
return `Player #${props.currentBatter.card_id || props.currentBatter.lineup_id}`
|
|
})
|
|
|
|
const pitcherName = computed(() => {
|
|
if (pitcherPlayer.value?.name) return pitcherPlayer.value.name
|
|
if (!props.currentPitcher) return 'Unknown Pitcher'
|
|
return `Player #${props.currentPitcher.card_id || props.currentPitcher.lineup_id}`
|
|
})
|
|
|
|
// Get player preview image with fallback priority: headshot > vanity_card > null
|
|
function getPlayerPreviewImage(player: { headshot?: string | null; vanity_card?: string | null } | null): string | null {
|
|
if (!player) return null
|
|
return player.headshot || player.vanity_card || null
|
|
}
|
|
|
|
// Get player avatar fallback - use first + last initials (e.g., "Alex Verdugo" -> "AV")
|
|
// Ignores common suffixes like Jr, Sr, II, III, IV
|
|
function getPlayerFallbackInitial(player: { name: string } | null): string {
|
|
if (!player) return '?'
|
|
const suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'v']
|
|
const parts = player.name.trim().split(/\s+/).filter(
|
|
part => !suffixes.includes(part.toLowerCase())
|
|
)
|
|
if (parts.length === 0) return '?'
|
|
if (parts.length === 1) return parts[0].charAt(0).toUpperCase()
|
|
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
|
|
}
|
|
|
|
// Player card modal state
|
|
const isPlayerCardOpen = ref(false)
|
|
const selectedPlayerData = ref<{
|
|
id: number
|
|
name: string
|
|
image: string
|
|
headshot?: string
|
|
} | null>(null)
|
|
const selectedPlayerPosition = ref('')
|
|
const selectedPlayerTeam = ref('')
|
|
|
|
// Open player card modal for batter or pitcher
|
|
function openPlayerCard(type: 'batter' | 'pitcher') {
|
|
const player = type === 'batter' ? batterPlayer.value : pitcherPlayer.value
|
|
const state = type === 'batter' ? props.currentBatter : props.currentPitcher
|
|
|
|
if (!player) return
|
|
|
|
selectedPlayerData.value = {
|
|
id: player.id,
|
|
name: player.name,
|
|
image: player.image || '',
|
|
headshot: player.headshot || undefined
|
|
}
|
|
selectedPlayerPosition.value = state?.position || ''
|
|
selectedPlayerTeam.value = '' // Could be enhanced to show team name
|
|
isPlayerCardOpen.value = true
|
|
}
|
|
|
|
function closePlayerCard() {
|
|
isPlayerCardOpen.value = false
|
|
selectedPlayerData.value = null
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Optional: Add subtle animations */
|
|
.current-situation > div {
|
|
animation: fadeIn 0.3s ease-in;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
</style>
|