final
This commit is contained in:
475
frontend/src/components/LiveModePanel.vue
Normal file
475
frontend/src/components/LiveModePanel.vue
Normal file
@@ -0,0 +1,475 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user