Files
shooting-event/frontend/src/components/LiveModePanel.vue

476 lines
20 KiB
Vue
Raw Normal View History

2026-04-03 09:55:36 +04:00
<template>
<section class="live-mode-shell">
<div class="live-mode-overlay">
<header class="live-mode-header">
<h2>{{ t('sections.liveModeTitle') }}</h2>
<p>{{ t('sections.liveModeSubtitle') }}</p>
</header>
<div class="live-screen-head live-head-selection">
<span class="live-chip strong">{{ activeViewLabel }}</span>
</div>
<div class="live-mode-grid single">
<article v-if="activeView === 'group_live'" class="live-screen-card wide">
<div class="live-screen-head">
<h3>{{ t('sections.liveTitle') }}</h3>
<div class="live-chip-row">
<span class="live-chip">{{ liveSettings.groupDisplayMode === 'fixed' ? t('actions.liveModeFixed') : t('actions.liveModeRotate') }}</span>
</div>
</div>
<Transition name="live-swap" mode="out-in">
<div :key="'live-group-swap-' + (liveGroupCode || 'u')" class="live-swap-block">
<div class="live-focus-banner" :class="'focus-' + groupClassKey">
<span class="live-focus-label">{{ t('labels.group') }}</span>
<strong class="live-focus-title">{{ liveGroupCode || t('labels.unassigned') }}</strong>
</div>
<div class="table-wrap">
<table class="score-table live-score-table">
<thead>
<tr>
<th>{{ t('table.rank') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.round1') }}</th>
<th>{{ t('table.round2') }}</th>
<th>{{ t('table.round3') }}</th>
<th>{{ t('table.total') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in rankedLiveGroupMembers" :key="'live-group-' + entry.player.id">
<td class="mono rank">{{ entry.rank }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(entry.player)" :alt="entry.player.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(entry.player) }}</p>
<p class="name-sub">{{ secondaryName(entry.player) }}</p>
</div>
</div>
</td>
<td class="mono">{{ scoreFor('prelim_r1', entry.player.id) }}</td>
<td class="mono">{{ scoreFor('prelim_r2', entry.player.id) }}</td>
<td class="mono">{{ scoreFor('prelim_r3', entry.player.id) }}</td>
<td class="mono strong">{{ entry.total }}</td>
</tr>
<tr v-if="liveGroupMembers.length === 0"><td colspan="6" class="muted center">{{ t('labels.emptyLive') }}</td></tr>
</tbody>
</table>
</div>
</div>
</Transition>
</article>
<article v-else-if="activeView === 'prelim_tie'" class="live-screen-card wide">
<div class="live-screen-head">
<h3>{{ t('sections.prelimTieTitle') }}</h3>
<div class="live-chip-row">
<span class="live-chip">{{ t('table.tieScore') }}</span>
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
</div>
</div>
<Transition name="live-swap" mode="out-in">
<div :key="'prelim-tie-page-' + listPageIndex" class="live-swap-block">
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>#</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.tieScore') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in pagedPrelimTieRows" :key="'live-pre-tie-' + row.playerId">
<td class="mono">{{ listPageStart + index + 1 }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td class="mono strong">{{ row.tieBreak }}</td>
</tr>
<tr v-if="prelimTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noPrelimTie') }}</td></tr>
</tbody>
</table>
</div>
</div>
</Transition>
</article>
<article v-else-if="activeView === 'prelim_overall'" class="live-screen-card wide">
<div class="live-screen-head">
<h3>{{ t('sections.overallTitle') }}</h3>
<div class="live-chip-row">
<span class="live-chip strong">{{ t('labels.top12') }}: {{ finalists.length }}</span>
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
</div>
</div>
<Transition name="live-swap" mode="out-in">
<div :key="'prelim-overall-page-' + listPageIndex" class="live-swap-block">
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.rank') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.total') }}</th>
<th>{{ t('table.tieScore') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in pagedPreliminaryRows"
:key="'live-pre-overall-' + row.playerId"
:class="{ 'qualified-row': isFinalist(row.playerId) }"
>
<td class="mono rank">{{ row.rank }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td class="mono strong">{{ row.score }}</td>
<td class="mono">{{ row.tieBreak }}</td>
</tr>
<tr v-if="preliminaryRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
</tbody>
</table>
</div>
</div>
</Transition>
</article>
<article v-else-if="activeView === 'final_groups'" class="live-screen-card wide">
<div class="live-screen-head">
<h3>{{ t('sections.finalTitle') }}</h3>
<div class="live-chip-row">
<span class="live-chip">{{ liveSettings.finalDisplayMode === 'fixed' ? t('actions.liveModeFixed') : t('actions.liveModeRotate') }}</span>
</div>
</div>
<Transition name="live-swap" mode="out-in">
<div :key="'live-final-swap-' + liveFinalGroup" class="live-swap-block">
<div class="live-focus-banner focus-final">
<span class="live-focus-label">{{ t('tabs.final') }}</span>
<strong class="live-focus-title">{{ currentFinalGroupLabel }}</strong>
</div>
<div class="table-wrap">
<table class="score-table live-score-table">
<thead>
<tr>
<th>{{ t('table.rank') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.round1') }}</th>
<th>{{ t('table.round2') }}</th>
<th>{{ t('table.total') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in rankedLiveFinalRows" :key="'live-final-group-' + entry.row.playerId">
<td class="mono rank">{{ entry.rank }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(entry.row) }}</p>
<p class="name-sub">{{ secondaryName(entry.row) }}</p>
</div>
</div>
</td>
<td class="mono">{{ scoreFor('final_r1', entry.row.playerId) }}</td>
<td class="mono">{{ scoreFor('final_r2', entry.row.playerId) }}</td>
<td class="mono strong">{{ entry.total }}</td>
</tr>
<tr v-if="liveFinalRows.length === 0"><td colspan="5" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
</tbody>
</table>
</div>
</div>
</Transition>
</article>
<article v-else-if="activeView === 'final_tie'" class="live-screen-card wide">
<div class="live-screen-head">
<h3>{{ t('sections.finalTieTitle') }}</h3>
<div class="live-chip-row">
<span class="live-chip">{{ t('table.tieScore') }}</span>
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
</div>
</div>
<Transition name="live-swap" mode="out-in">
<div :key="'final-tie-page-' + listPageIndex" class="live-swap-block">
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>#</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.tieScore') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in pagedFinalTieRows" :key="'live-final-tie-' + row.playerId">
<td class="mono">{{ listPageStart + index + 1 }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td class="mono strong">{{ row.tieBreak }}</td>
</tr>
<tr v-if="finalTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noFinalTie') }}</td></tr>
</tbody>
</table>
</div>
</div>
</Transition>
</article>
<article v-else class="live-screen-card podium-live-card wide">
<div class="live-screen-head">
<h3>{{ t('sections.podiumTitle') }}</h3>
</div>
<div class="podium-wrapper live-podium">
<article class="podium-col pos-2">
<div class="podium-avatar-wrap">
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
</div>
<div class="podium-medal-icon">🥈</div>
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
<div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
</div>
</article>
<article class="podium-col pos-1">
<div class="podium-avatar-wrap">
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
</div>
<div class="podium-medal-icon">🥇</div>
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
<div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
</div>
</article>
<article class="podium-col pos-3">
<div class="podium-avatar-wrap">
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
</div>
<div class="podium-medal-icon">🥉</div>
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
<div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
</div>
</article>
</div>
</article>
</div>
</div>
</section>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps({
t: { type: Function, required: true },
liveSettings: { type: Object, required: true },
liveGroupCode: { type: String, default: '' },
liveGroupMembers: { type: Array, required: true },
prelimTieRows: { type: Array, required: true },
preliminaryRows: { type: Array, required: true },
finalists: { type: Array, required: true },
liveFinalGroup: { type: Number, required: true },
liveFinalRows: { type: Array, required: true },
finalTieRows: { type: Array, required: true },
podiumOrdered: { type: Array, required: true },
playerImage: { type: Function, required: true },
displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true },
scoreFor: { type: Function, required: true },
prelimTotal: { type: Function, required: true },
finalTotal: { type: Function, required: true },
podiumImage: { type: Function, required: true },
podiumName: { type: Function, required: true },
podiumHasResult: { type: Function, required: true },
podiumScoreMain: { type: Function, required: true },
podiumTieSubtitle: { type: Function, required: true },
})
const finalistIds = computed(() => new Set((props.finalists || []).map((row) => row.playerId)))
const activeView = computed(() => props.liveSettings.activeView || 'group_live')
const listPageIndex = ref(0)
let listRotationTimer = null
const rotationIntervalSec = computed(() => {
const raw = Number(props.liveSettings.rotationIntervalSec || 5)
if (!Number.isFinite(raw)) return 5
return Math.max(3, raw)
})
const rotationPlayerCount = computed(() => {
const raw = Number(props.liveSettings.rotationPlayerCount || 12)
if (!Number.isFinite(raw)) return 12
if (raw < 3) return 3
if (raw > 40) return 40
return Math.floor(raw)
})
const listRowsForActiveView = computed(() => {
if (activeView.value === 'prelim_tie') return props.prelimTieRows || []
if (activeView.value === 'prelim_overall') return props.preliminaryRows || []
if (activeView.value === 'final_tie') return props.finalTieRows || []
return []
})
const listPageCount = computed(() => {
const total = listRowsForActiveView.value.length
if (total <= 0) return 1
return Math.max(1, Math.ceil(total / rotationPlayerCount.value))
})
const listPageStart = computed(() => listPageIndex.value * rotationPlayerCount.value)
const currentListPageDisplay = computed(() => `${listPageIndex.value + 1} / ${listPageCount.value}`)
const pagedPrelimTieRows = computed(() => paginateRows(props.prelimTieRows || []))
const pagedPreliminaryRows = computed(() => paginateRows(props.preliminaryRows || []))
const pagedFinalTieRows = computed(() => paginateRows(props.finalTieRows || []))
const rankedLiveGroupMembers = computed(() => {
const rows = (props.liveGroupMembers || []).map((player) => ({
player,
total: Number(props.prelimTotal(player.id) || 0),
}))
rows.sort((a, b) => {
if (a.total !== b.total) return b.total - a.total
return a.player.id - b.player.id
})
let prevTotal = null
let prevRank = 0
return rows.map((entry, idx) => {
const rank = prevTotal === entry.total ? prevRank : idx + 1
prevTotal = entry.total
prevRank = rank
return { ...entry, rank }
})
})
const activeViewLabel = computed(() => {
const map = {
group_live: props.t('labels.liveViewGroup'),
prelim_tie: props.t('labels.liveViewPrelimTie'),
prelim_overall: props.t('labels.liveViewPrelimOverall'),
final_groups: props.t('labels.liveViewFinalGroups'),
final_tie: props.t('labels.liveViewFinalTie'),
podium: props.t('labels.liveViewPodium'),
}
return map[activeView.value] || map.group_live
})
const currentFinalGroupLabel = computed(() => {
if (props.liveFinalGroup === 2) return props.t('labels.finalGroup2')
return props.t('labels.finalGroup1')
})
const groupClassKey = computed(() => {
const code = String(props.liveGroupCode || '').trim().toUpperCase()
if (code.startsWith('A')) return 'a'
if (code.startsWith('B')) return 'b'
if (code.startsWith('C')) return 'c'
if (code.startsWith('D')) return 'd'
return 'u'
})
const rankedLiveFinalRows = computed(() => {
const rows = (props.liveFinalRows || []).map((row) => ({
row,
total: Number(props.finalTotal(row.playerId) || 0),
}))
rows.sort((a, b) => {
if (a.total !== b.total) return b.total - a.total
const seedA = Number(a.row.seed || 0)
const seedB = Number(b.row.seed || 0)
if (seedA !== seedB) return seedA - seedB
return a.row.playerId - b.row.playerId
})
let prevTotal = null
let prevRank = 0
return rows.map((entry, idx) => {
const rank = prevTotal === entry.total ? prevRank : idx + 1
prevTotal = entry.total
prevRank = rank
return { ...entry, rank }
})
})
function isFinalist(playerId) {
return finalistIds.value.has(playerId)
}
watch(
() =>
`${activeView.value}|${rotationIntervalSec.value}|${rotationPlayerCount.value}|${props.prelimTieRows.length}|${props.preliminaryRows.length}|${props.finalTieRows.length}`,
() => {
restartListRotation()
},
{ immediate: true },
)
onBeforeUnmount(() => {
stopListRotation()
})
function paginateRows(rows) {
const start = listPageIndex.value * rotationPlayerCount.value
return rows.slice(start, start + rotationPlayerCount.value)
}
function restartListRotation() {
stopListRotation()
listPageIndex.value = 0
const autoRotateView = activeView.value === 'prelim_tie' || activeView.value === 'prelim_overall' || activeView.value === 'final_tie'
if (!autoRotateView) return
if (listPageCount.value <= 1) return
listRotationTimer = setInterval(() => {
if (listPageCount.value <= 1) {
listPageIndex.value = 0
return
}
listPageIndex.value = (listPageIndex.value + 1) % listPageCount.value
}, rotationIntervalSec.value * 1000)
}
function stopListRotation() {
if (listRotationTimer) {
clearInterval(listRotationTimer)
listRotationTimer = null
}
}
</script>