491 lines
21 KiB
Vue
491 lines
21 KiB
Vue
<template>
|
|
<div class="live-mode-root">
|
|
<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" dir="auto" :title="podiumName(podiumOrdered[0])" :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" dir="auto" :title="podiumName(podiumOrdered[1])" :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" dir="auto" :title="podiumName(podiumOrdered[2])" :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>
|
|
|
|
<footer class="live-mode-footer live-mode-fixed-footer">
|
|
<div class="live-footer-slot left">
|
|
<img src="/simon_logo.png" alt="Simon logo" class="live-footer-logo" />
|
|
</div>
|
|
|
|
<div class="live-footer-slot center">
|
|
<img src="/arture_logo.png" alt="Arture logo" class="live-footer-logo" />
|
|
</div>
|
|
<div class="live-footer-slot right">
|
|
<img src="/datwyler_logo.png" alt="Datwyler logo" class="live-footer-logo" />
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</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>
|