Backend: - Add home_team_dice_color and away_team_dice_color to GameState model - Extract dice_color from game metadata in StateManager (default: cc0000) - Add runners_on_base param to roll_ab for chaos check skipping Frontend - Dice Display: - Create DiceShapes.vue with SVG d6 (square) and d20 (hexagon) shapes - Apply home team's dice_color to d6 dice, white for resolution d20 - Show chaos d20 in amber only when WP/PB check triggered - Add automatic text contrast based on color luminance - Reduce blank space and remove info bubble from dice results Frontend - Player Cards: - Consolidate pitcher/batter cards to single location below diamond - Add active card highlighting based on dice roll (d6_one: 1-3=batter, 4-6=pitcher) - New card header format: [Team] Position [Name] with full card image - Remove redundant card displays from GameBoard and GameplayPanel - Enlarge PlayerCardModal on desktop (max-w-3xl at 1024px+) Tests: - Add DiceShapes.spec.ts with 34 tests for color calculations and rendering - Update DiceRoller.spec.ts for new DiceShapes integration - Fix test_roll_dice_success for new runners_on_base parameter Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
359 lines
7.5 KiB
Vue
359 lines
7.5 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<Transition name="modal">
|
|
<div
|
|
v-if="isOpen"
|
|
class="player-card-modal-overlay"
|
|
@click.self="close"
|
|
>
|
|
<div
|
|
ref="modalRef"
|
|
class="player-card-modal"
|
|
@touchstart="onTouchStart"
|
|
@touchmove="onTouchMove"
|
|
@touchend="onTouchEnd"
|
|
>
|
|
<!-- Drag handle for mobile -->
|
|
<div class="drag-handle">
|
|
<div class="drag-indicator" />
|
|
</div>
|
|
|
|
<!-- Close button -->
|
|
<button
|
|
class="close-button"
|
|
@click="close"
|
|
aria-label="Close"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Player card image -->
|
|
<div class="card-image-container">
|
|
<img
|
|
v-if="player?.image"
|
|
:src="player.image"
|
|
:alt="`${player.name} playing card`"
|
|
class="card-image"
|
|
@error="onImageError"
|
|
>
|
|
<div v-else class="card-placeholder">
|
|
<span class="placeholder-text">{{ playerInitials }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Player info -->
|
|
<div class="player-info">
|
|
<h2 class="player-name">{{ player?.name || 'Unknown Player' }}</h2>
|
|
<div class="player-details">
|
|
<span v-if="position" class="position-badge">{{ position }}</span>
|
|
<span v-if="teamName" class="team-name">{{ teamName }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Substitute button -->
|
|
<button
|
|
v-if="showSubstituteButton"
|
|
class="substitute-button"
|
|
@click="onSubstitute"
|
|
>
|
|
Substitute Player
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import type { Lineup, BenchPlayer } from '~/types/player'
|
|
|
|
interface PlayerData {
|
|
id: number
|
|
name: string
|
|
image: string
|
|
headshot?: string
|
|
}
|
|
|
|
interface Props {
|
|
isOpen: boolean
|
|
player: PlayerData | null
|
|
position?: string
|
|
teamName?: string
|
|
showSubstituteButton?: boolean
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
position: '',
|
|
teamName: '',
|
|
showSubstituteButton: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
substitute: [playerId: number]
|
|
}>()
|
|
|
|
const modalRef = ref<HTMLElement | null>(null)
|
|
const touchStartY = ref(0)
|
|
const touchCurrentY = ref(0)
|
|
const isDragging = ref(false)
|
|
|
|
// Computed
|
|
const playerInitials = computed(() => {
|
|
if (!props.player?.name) return '?'
|
|
const parts = props.player.name.split(' ')
|
|
if (parts.length >= 2) {
|
|
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
|
|
}
|
|
return parts[0][0].toUpperCase()
|
|
})
|
|
|
|
// Methods
|
|
const close = () => {
|
|
emit('close')
|
|
}
|
|
|
|
const onSubstitute = () => {
|
|
if (props.player) {
|
|
emit('substitute', props.player.id)
|
|
}
|
|
}
|
|
|
|
const onImageError = (event: Event) => {
|
|
const target = event.target as HTMLImageElement
|
|
target.style.display = 'none'
|
|
}
|
|
|
|
// Touch handling for swipe-to-close
|
|
const onTouchStart = (event: TouchEvent) => {
|
|
touchStartY.value = event.touches[0].clientY
|
|
isDragging.value = true
|
|
}
|
|
|
|
const onTouchMove = (event: TouchEvent) => {
|
|
if (!isDragging.value) return
|
|
touchCurrentY.value = event.touches[0].clientY
|
|
|
|
const deltaY = touchCurrentY.value - touchStartY.value
|
|
if (deltaY > 0 && modalRef.value) {
|
|
// Only allow dragging downward
|
|
modalRef.value.style.transform = `translateY(${deltaY}px)`
|
|
}
|
|
}
|
|
|
|
const onTouchEnd = () => {
|
|
if (!isDragging.value) return
|
|
isDragging.value = false
|
|
|
|
const deltaY = touchCurrentY.value - touchStartY.value
|
|
if (deltaY > 100) {
|
|
// Swipe down threshold reached - close the modal
|
|
close()
|
|
}
|
|
|
|
// Reset position
|
|
if (modalRef.value) {
|
|
modalRef.value.style.transform = ''
|
|
}
|
|
}
|
|
|
|
// Reset touch state when modal closes
|
|
watch(() => props.isOpen, (isOpen) => {
|
|
if (!isOpen) {
|
|
touchStartY.value = 0
|
|
touchCurrentY.value = 0
|
|
isDragging.value = false
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.player-card-modal-overlay {
|
|
@apply fixed inset-0 z-50 flex items-end justify-center;
|
|
@apply bg-black/60 backdrop-blur-sm;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.player-card-modal-overlay {
|
|
@apply items-center;
|
|
}
|
|
}
|
|
|
|
.player-card-modal {
|
|
@apply relative bg-white rounded-t-2xl w-full max-w-md;
|
|
@apply pb-6 pt-2 px-4;
|
|
@apply shadow-2xl;
|
|
@apply transition-transform duration-200;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.player-card-modal {
|
|
@apply rounded-2xl max-w-2xl;
|
|
max-height: 85vh;
|
|
}
|
|
}
|
|
|
|
@media (min-width: 1024px) {
|
|
.player-card-modal {
|
|
@apply max-w-3xl;
|
|
max-height: 90vh;
|
|
}
|
|
}
|
|
|
|
/* Drag handle */
|
|
.drag-handle {
|
|
@apply flex justify-center py-2 mb-2;
|
|
}
|
|
|
|
.drag-indicator {
|
|
@apply w-10 h-1 bg-gray-300 rounded-full;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.drag-handle {
|
|
@apply hidden;
|
|
}
|
|
}
|
|
|
|
/* Close button */
|
|
.close-button {
|
|
@apply absolute top-3 right-3 p-2 rounded-full;
|
|
@apply bg-gray-100 text-gray-600;
|
|
@apply hover:bg-gray-200 transition-colors;
|
|
@apply min-w-[44px] min-h-[44px] flex items-center justify-center;
|
|
}
|
|
|
|
/* Card image */
|
|
.card-image-container {
|
|
@apply flex justify-center mb-4;
|
|
}
|
|
|
|
.card-image {
|
|
@apply max-w-full max-h-[50vh] object-contain rounded-lg shadow-lg;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.card-image {
|
|
max-height: 60vh;
|
|
}
|
|
}
|
|
|
|
.card-placeholder {
|
|
@apply w-48 h-64 bg-gradient-to-br from-gray-200 to-gray-300;
|
|
@apply rounded-lg flex items-center justify-center;
|
|
@apply shadow-lg;
|
|
}
|
|
|
|
.placeholder-text {
|
|
@apply text-4xl font-bold text-gray-500;
|
|
}
|
|
|
|
/* Player info */
|
|
.player-info {
|
|
@apply text-center mb-4;
|
|
}
|
|
|
|
.player-name {
|
|
@apply text-xl font-bold text-gray-900 mb-1;
|
|
}
|
|
|
|
.player-details {
|
|
@apply flex items-center justify-center gap-2 flex-wrap;
|
|
}
|
|
|
|
.position-badge {
|
|
@apply px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-semibold;
|
|
}
|
|
|
|
.team-name {
|
|
@apply text-gray-600 text-sm;
|
|
}
|
|
|
|
/* Substitute button */
|
|
.substitute-button {
|
|
@apply w-full py-4 px-6 rounded-xl;
|
|
@apply bg-gradient-to-r from-orange-500 to-orange-600 text-white;
|
|
@apply font-bold text-lg;
|
|
@apply hover:from-orange-600 hover:to-orange-700;
|
|
@apply active:scale-95 transition-all;
|
|
@apply min-h-[52px];
|
|
}
|
|
|
|
/* Transition animations */
|
|
.modal-enter-active {
|
|
@apply transition-all duration-300 ease-out;
|
|
}
|
|
|
|
.modal-leave-active {
|
|
@apply transition-all duration-200 ease-in;
|
|
}
|
|
|
|
.modal-enter-from {
|
|
@apply opacity-0;
|
|
}
|
|
|
|
.modal-enter-from .player-card-modal {
|
|
@apply translate-y-full;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.modal-enter-from .player-card-modal {
|
|
@apply translate-y-0 scale-95;
|
|
}
|
|
}
|
|
|
|
.modal-leave-to {
|
|
@apply opacity-0;
|
|
}
|
|
|
|
.modal-leave-to .player-card-modal {
|
|
@apply translate-y-full;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.modal-leave-to .player-card-modal {
|
|
@apply translate-y-0 scale-95;
|
|
}
|
|
}
|
|
|
|
/* Dark mode */
|
|
@media (prefers-color-scheme: dark) {
|
|
.player-card-modal {
|
|
@apply bg-gray-800;
|
|
}
|
|
|
|
.drag-indicator {
|
|
@apply bg-gray-600;
|
|
}
|
|
|
|
.close-button {
|
|
@apply bg-gray-700 text-gray-300 hover:bg-gray-600;
|
|
}
|
|
|
|
.card-placeholder {
|
|
@apply from-gray-700 to-gray-600;
|
|
}
|
|
|
|
.placeholder-text {
|
|
@apply text-gray-400;
|
|
}
|
|
|
|
.player-name {
|
|
@apply text-gray-100;
|
|
}
|
|
|
|
.position-badge {
|
|
@apply bg-blue-900 text-blue-200;
|
|
}
|
|
|
|
.team-name {
|
|
@apply text-gray-400;
|
|
}
|
|
}
|
|
</style>
|