Backend: - Add game_metadata to load_game_state() return dict in DatabaseOperations - Populate team display fields (name, color, thumbnail) in _rebuild_state_from_data() so recovered games show team colors/names Frontend: - Add text-outline CSS for score visibility on any background (light logos, gradients) - Handle thumbnail 404 with @error event, show enhanced shadow when no thumbnail - Apply consistent outline across mobile and desktop layouts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
319 lines
12 KiB
Vue
319 lines
12 KiB
Vue
<template>
|
|
<div class="relative text-white shadow-lg overflow-hidden">
|
|
<!-- Team colors gradient background -->
|
|
<div
|
|
class="absolute inset-0"
|
|
:style="gradientStyle"
|
|
/>
|
|
<!-- Dark overlay for text readability -->
|
|
<div class="absolute inset-0 bg-black/20" />
|
|
<!-- Content -->
|
|
<div class="relative container mx-auto px-3 py-4">
|
|
<!-- Mobile Layout (default) -->
|
|
<div class="lg:hidden">
|
|
<!-- Score Display with Game Situation -->
|
|
<div class="flex items-center justify-between">
|
|
<!-- Away Team -->
|
|
<div class="flex-1 text-center relative">
|
|
<img
|
|
v-if="awayTeamThumbnail && !awayThumbnailFailed"
|
|
:src="awayTeamThumbnail"
|
|
alt=""
|
|
class="absolute inset-0 w-full h-full object-contain opacity-15 pointer-events-none"
|
|
@error="awayThumbnailFailed = true"
|
|
>
|
|
<div class="relative">
|
|
<div
|
|
class="text-xs font-medium mb-1 text-outline"
|
|
:class="showAwayShadow ? 'text-white text-outline-strong' : 'text-blue-100'"
|
|
>AWAY</div>
|
|
<div
|
|
class="text-4xl font-bold tabular-nums text-outline"
|
|
:class="showAwayShadow ? 'text-outline-strong' : ''"
|
|
>{{ awayScore }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Center: Inning + Runners/Outs -->
|
|
<div class="flex items-center gap-2">
|
|
<!-- Inning Indicator -->
|
|
<div class="bg-white/20 backdrop-blur rounded-lg px-3 py-2 text-center">
|
|
<div class="text-xs font-medium text-blue-100">INNING</div>
|
|
<div class="text-2xl font-bold">{{ inning }}</div>
|
|
<div class="text-xs font-medium">
|
|
<span
|
|
class="px-2 py-0.5 rounded-full text-xs font-bold"
|
|
:class="half === 'top' ? 'bg-blue-200 text-blue-900' : 'bg-yellow-400 text-yellow-900'"
|
|
>
|
|
{{ half === 'top' ? '▲ TOP' : '▼ BOT' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Runners + Outs Column -->
|
|
<div class="flex flex-col gap-0.5 w-20">
|
|
<!-- Runners (Mini Diamond) -->
|
|
<div class="bg-white/10 backdrop-blur rounded-lg px-2 py-1">
|
|
<div class="flex items-center justify-center">
|
|
<div class="relative w-11 h-11">
|
|
<!-- 2nd Base (Top) -->
|
|
<div
|
|
class="absolute top-0.5 left-1/2 -translate-x-1/2 w-5 h-5 rotate-45 transition-all"
|
|
:class="runners.second ? 'bg-yellow-400 shadow-lg shadow-yellow-500/50' : 'bg-white/20'"
|
|
/>
|
|
|
|
<!-- 1st Base (Right) -->
|
|
<div
|
|
class="absolute top-[64%] -right-1 -translate-y-1/2 w-5 h-5 rotate-45 transition-all"
|
|
:class="runners.first ? 'bg-yellow-400 shadow-lg shadow-yellow-500/50' : 'bg-white/20'"
|
|
/>
|
|
|
|
<!-- 3rd Base (Left) -->
|
|
<div
|
|
class="absolute top-[64%] -left-1 -translate-y-1/2 w-5 h-5 rotate-45 transition-all"
|
|
:class="runners.third ? 'bg-yellow-400 shadow-lg shadow-yellow-500/50' : 'bg-white/20'"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Outs -->
|
|
<div class="bg-white/10 backdrop-blur rounded-lg px-2 py-1.5">
|
|
<div class="flex items-center justify-center gap-1.5">
|
|
<div
|
|
v-for="i in 3"
|
|
:key="i"
|
|
class="w-3 h-3 rounded-full transition-all"
|
|
:class="i <= outs ? 'bg-red-400 shadow-lg shadow-red-500/50' : 'bg-white/30'"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Home Team -->
|
|
<div class="flex-1 text-center relative">
|
|
<img
|
|
v-if="homeTeamThumbnail && !homeThumbnailFailed"
|
|
:src="homeTeamThumbnail"
|
|
alt=""
|
|
class="absolute inset-0 w-full h-full object-contain opacity-15 pointer-events-none"
|
|
@error="homeThumbnailFailed = true"
|
|
>
|
|
<div class="relative">
|
|
<div
|
|
class="text-xs font-medium mb-1 text-outline"
|
|
:class="showHomeShadow ? 'text-white text-outline-strong' : 'text-blue-100'"
|
|
>HOME</div>
|
|
<div
|
|
class="text-4xl font-bold tabular-nums text-outline"
|
|
:class="showHomeShadow ? 'text-outline-strong' : ''"
|
|
>{{ homeScore }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Desktop Layout (lg and up) -->
|
|
<div class="hidden lg:flex items-center justify-between">
|
|
<!-- Left: Away Team Score -->
|
|
<div class="flex items-center gap-4">
|
|
<div class="text-center min-w-[100px] relative">
|
|
<img
|
|
v-if="awayTeamThumbnail && !awayThumbnailFailed"
|
|
:src="awayTeamThumbnail"
|
|
alt=""
|
|
class="absolute inset-0 w-full h-full object-contain opacity-15 pointer-events-none"
|
|
@error="awayThumbnailFailed = true"
|
|
>
|
|
<div class="relative">
|
|
<div
|
|
class="text-sm font-medium text-outline"
|
|
:class="showAwayShadow ? 'text-white text-outline-strong' : 'text-blue-100'"
|
|
>AWAY</div>
|
|
<div
|
|
class="text-5xl font-bold tabular-nums text-outline"
|
|
:class="showAwayShadow ? 'text-outline-strong' : ''"
|
|
>{{ awayScore }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Center: Game Situation -->
|
|
<div class="flex items-center gap-6">
|
|
<!-- Inning -->
|
|
<div class="bg-white/20 backdrop-blur rounded-lg px-6 py-3 text-center min-w-[120px]">
|
|
<div class="text-sm font-medium text-blue-100">INNING</div>
|
|
<div class="text-3xl font-bold">{{ inning }}</div>
|
|
<div class="mt-1">
|
|
<span
|
|
class="px-3 py-1 rounded-full text-sm font-bold"
|
|
:class="half === 'top' ? 'bg-blue-200 text-blue-900' : 'bg-yellow-400 text-yellow-900'"
|
|
>
|
|
{{ half === 'top' ? '▲ TOP' : '▼ BOTTOM' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Runners + Outs Column -->
|
|
<div class="flex flex-col gap-1 w-28">
|
|
<!-- Runners (Mini Diamond) - Fixed positioning -->
|
|
<div class="bg-white/10 backdrop-blur rounded-lg px-4 py-1.5">
|
|
<div class="flex items-center justify-center">
|
|
<div class="relative w-14 h-14">
|
|
<!-- 2nd Base (Top) -->
|
|
<div
|
|
class="absolute top-0.5 left-1/2 -translate-x-1/2 w-6 h-6 rotate-45 transition-all"
|
|
:class="runners.second ? 'bg-yellow-400 shadow-lg shadow-yellow-500/50 animate-pulse' : 'bg-white/20'"
|
|
/>
|
|
|
|
<!-- 1st Base (Right) -->
|
|
<div
|
|
class="absolute top-[60%] -right-1 -translate-y-1/2 w-6 h-6 rotate-45 transition-all"
|
|
:class="runners.first ? 'bg-yellow-400 shadow-lg shadow-yellow-500/50 animate-pulse' : 'bg-white/20'"
|
|
/>
|
|
|
|
<!-- 3rd Base (Left) -->
|
|
<div
|
|
class="absolute top-[60%] -left-1 -translate-y-1/2 w-6 h-6 rotate-45 transition-all"
|
|
:class="runners.third ? 'bg-yellow-400 shadow-lg shadow-yellow-500/50 animate-pulse' : 'bg-white/20'"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Outs -->
|
|
<div class="bg-white/10 backdrop-blur rounded-lg px-4 py-2 flex items-center justify-center">
|
|
<div class="flex gap-2">
|
|
<div
|
|
v-for="i in 3"
|
|
:key="i"
|
|
class="w-4 h-4 rounded-full transition-all"
|
|
:class="i <= outs ? 'bg-red-400 shadow-lg shadow-red-500/50' : 'bg-white/30'"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right: Home Team Score -->
|
|
<div class="flex items-center gap-4">
|
|
<div class="text-center min-w-[100px] relative">
|
|
<img
|
|
v-if="homeTeamThumbnail && !homeThumbnailFailed"
|
|
:src="homeTeamThumbnail"
|
|
alt=""
|
|
class="absolute inset-0 w-full h-full object-contain opacity-15 pointer-events-none"
|
|
@error="homeThumbnailFailed = true"
|
|
>
|
|
<div class="relative">
|
|
<div
|
|
class="text-sm font-medium text-outline"
|
|
:class="showHomeShadow ? 'text-white text-outline-strong' : 'text-blue-100'"
|
|
>HOME</div>
|
|
<div
|
|
class="text-5xl font-bold tabular-nums text-outline"
|
|
:class="showHomeShadow ? 'text-outline-strong' : ''"
|
|
>{{ homeScore }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref } from 'vue'
|
|
import type { InningHalf } from '~/types/game'
|
|
|
|
interface Props {
|
|
homeScore?: number
|
|
awayScore?: number
|
|
inning?: number
|
|
half?: InningHalf
|
|
outs?: number
|
|
runners?: {
|
|
first: boolean
|
|
second: boolean
|
|
third: boolean
|
|
}
|
|
awayTeamColor?: string
|
|
homeTeamColor?: string
|
|
awayTeamThumbnail?: string | null
|
|
homeTeamThumbnail?: string | null
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
homeScore: 0,
|
|
awayScore: 0,
|
|
inning: 1,
|
|
half: 'top',
|
|
outs: 0,
|
|
runners: () => ({ first: false, second: false, third: false }),
|
|
awayTeamColor: undefined,
|
|
homeTeamColor: undefined,
|
|
awayTeamThumbnail: undefined,
|
|
homeTeamThumbnail: undefined
|
|
})
|
|
|
|
// Track thumbnail load failures
|
|
const awayThumbnailFailed = ref(false)
|
|
const homeThumbnailFailed = ref(false)
|
|
|
|
// Show enhanced shadow effect when no thumbnail (missing or failed to load)
|
|
// Even with thumbnails, we use a subtle outline for readability
|
|
const showAwayShadow = computed(() => !props.awayTeamThumbnail || awayThumbnailFailed.value)
|
|
const showHomeShadow = computed(() => !props.homeTeamThumbnail || homeThumbnailFailed.value)
|
|
|
|
// Generate gradient style from team colors
|
|
// Uses Option 7: Solid blocks with center blend (away 30% -> dark center 50% -> home 70%)
|
|
const gradientStyle = computed(() => {
|
|
const awayColor = props.awayTeamColor || '#1e40af' // Default: SBA blue
|
|
const homeColor = props.homeTeamColor || '#1e40af' // Default: SBA blue
|
|
const centerColor = '#1f2937' // gray-800
|
|
|
|
return {
|
|
background: `linear-gradient(to right, ${awayColor} 30%, ${centerColor} 50%, ${homeColor} 70%)`
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Ensure tabular numbers for consistent score display */
|
|
.tabular-nums {
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* Text outline for readability on any background (light logos, gradients) */
|
|
.text-outline {
|
|
text-shadow:
|
|
-1px -1px 0 rgba(0, 0, 0, 0.5),
|
|
1px -1px 0 rgba(0, 0, 0, 0.5),
|
|
-1px 1px 0 rgba(0, 0, 0, 0.5),
|
|
1px 1px 0 rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
/* Enhanced outline when no thumbnail - more prominent shadow */
|
|
.text-outline-strong {
|
|
text-shadow:
|
|
-1px -1px 0 rgba(0, 0, 0, 0.8),
|
|
1px -1px 0 rgba(0, 0, 0, 0.8),
|
|
-1px 1px 0 rgba(0, 0, 0, 0.8),
|
|
1px 1px 0 rgba(0, 0, 0, 0.8),
|
|
0 2px 8px rgba(0, 0, 0, 0.9);
|
|
}
|
|
|
|
/* Pulse animation for runners */
|
|
@keyframes pulse {
|
|
0%, 100% {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
50% {
|
|
opacity: 0.8;
|
|
transform: scale(1.1);
|
|
}
|
|
}
|
|
</style>
|