Files
shooting-event/frontend/src/App.vue

1630 lines
50 KiB
Vue
Raw Normal View History

2026-04-01 11:47:03 +04:00
<template>
<div class="app-shell" :class="'lang-' + language">
<input ref="imageInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onImageSelected" />
<input ref="scoreProofInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onScoreProofSelected" />
<AppHeader
:t="t"
:competition-title="competitionTitle"
:mode="mode"
:language="language"
2026-04-29 00:45:48 +04:00
:is-fullscreen="isFullscreen"
2026-04-01 11:47:03 +04:00
:server-time="state.serverTime ? formatServerTime(state.serverTime) : ''"
@change-mode="mode = $event"
@change-language="setLanguage"
2026-04-29 00:45:48 +04:00
@toggle-fullscreen="toggleFullscreen"
2026-04-01 11:47:03 +04:00
/>
<main class="page-content">
<section v-if="loading" class="loading-state">
<div class="spinner" />
<p>{{ t('labels.loading') }}</p>
</section>
<template v-else>
<Transition name="mode-fade" mode="out-in">
<div :key="'mode-' + mode">
<ViewModePanel
v-if="mode === 'view'"
:t="t"
:view-tabs="viewTabs"
:view-tab="viewTab"
:group-summaries="groupSummaries"
:players-sorted="playersSorted"
:row-class="rowClass"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:live-mode="liveMode"
:selected-live-group="selectedLiveGroup"
:live-selectable-groups="liveSelectableGroups"
:live-group-code="liveGroupCode"
:live-members="liveMembers"
:live-tick="liveTick"
:preliminary-rows="preliminaryRows"
:finalists="finalists"
:is-finalist="isFinalist"
:prelim-tie="prelimTie"
:final-group1="finalGroup1"
:final-group2="finalGroup2"
:final-rows="finalRows"
:final-tie="finalTie"
2026-04-03 09:55:36 +04:00
:final-total-score="finalTotalScore"
:preliminary-proof-stage="preliminaryProofStage"
:final-proof-stage="finalProofStage"
2026-04-01 11:47:03 +04:00
:podium-ordered="podiumOrdered"
:podium-image="podiumImage"
:podium-name="podiumName"
:podium-has-result="podiumHasResult"
2026-04-03 09:55:36 +04:00
:podium-score-main="podiumScoreMain"
:podium-tie-subtitle="podiumTieSubtitle"
2026-04-01 11:47:03 +04:00
:can-view-proofs="canViewProofs"
:has-score-proof="hasScoreProof"
:score-proof-for="scoreProofFor"
@change-tab="viewTab = $event"
@change-live-mode="setLiveMode"
@change-live-group="setSelectedLiveGroup"
@open-proof="onOpenProofFromPayload"
/>
2026-04-03 09:55:36 +04:00
<LiveModePanel
v-else-if="mode === 'live'"
:t="t"
:live-settings="liveSettings"
:live-group-code="liveMonitorGroupCode"
:live-group-members="liveMonitorMembers"
:prelim-tie-rows="prelimTieRows"
:preliminary-rows="preliminaryRows"
:finalists="finalists"
:live-final-group="liveMonitorFinalGroup"
:live-final-rows="liveMonitorFinalRows"
:final-tie-rows="finalTieRows"
:podium-ordered="podiumOrdered"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:score-for="scoreFor"
:prelim-total="preliminaryTotalScore"
:final-total="finalTotalScore"
:podium-image="podiumImage"
:podium-name="podiumName"
:podium-has-result="podiumHasResult"
:podium-score-main="podiumScoreMain"
:podium-tie-subtitle="podiumTieSubtitle"
/>
2026-04-01 11:47:03 +04:00
<template v-else>
<AdminLoginPanel
v-if="!adminToken"
:t="t"
:username="login.username"
:password="login.password"
:error="loginError"
@update:username="login.username = $event"
@update:password="login.password = $event"
@submit="adminLogin"
/>
<AdminPanel
v-else
:t="t"
:admin-tabs="adminTabs"
:admin-tab="adminTab"
:view-proof-in-view="canViewProofs"
2026-04-03 09:55:36 +04:00
:live-settings="liveSettings"
2026-04-01 11:47:03 +04:00
:group-setup-input="groupSetupInput"
:admin-group-cards="adminGroupCards"
:new-player="newPlayer"
:assignable-groups="assignableGroups"
:players-sorted="playersSorted"
:player-filter="playerFilter"
:player-sort="playerSort"
:player-image="playerImage"
:group-option-label="groupOptionLabel"
:normalized-group-code="normalizedGroupCode"
2026-04-03 09:55:36 +04:00
:preliminary-score-rows="adminPreliminaryRows"
:prelim-tie-score-rows="adminPrelimTieRows"
:final-score-rows="adminFinalRows"
:final-tie-score-rows="adminFinalTieRows"
2026-04-01 11:47:03 +04:00
:final-rows="finalRows"
2026-04-03 09:55:36 +04:00
:preliminary-total-score="preliminaryTotalScore"
:final-total-score="finalTotalScore"
2026-04-01 11:47:03 +04:00
:prelim-tie="prelimTie"
:final-tie="finalTie"
:score-filters="scoreFilters"
:display-name="displayName"
:secondary-name="secondaryName"
:score-input-value="scoreInputValue"
2026-04-29 00:45:48 +04:00
:score-input-invalid="scoreInputInvalid"
2026-04-01 11:47:03 +04:00
:on-score-focus="onScoreFocus"
:on-score-input="onScoreInput"
:on-score-commit="onScoreCommit"
:has-score-proof="hasScoreProof"
:score-proof-for="scoreProofFor"
:open-score-proof-uploader="openScoreProofUploader"
:remove-score-proof="removeScoreProof"
:open-proof-preview="openProofPreview"
:request-score-advice="requestScoreAdvice"
@refresh="fetchState()"
@logout="adminLogout"
@toggle-view-proof="updateViewProofSetting"
2026-04-03 09:55:36 +04:00
@update-live-setting="updateLiveModeSetting"
2026-04-01 11:47:03 +04:00
@change-admin-tab="adminTab = $event"
@update:group-setup-input="groupSetupInput = $event"
@save-group-setup="saveGroupSetup"
@auto-group-even="autoGroupEvenly"
@update:new-player="updateNewPlayer"
@create-player="createPlayer"
@convert-new-name="convertNewPlayerName"
@update:player-filter="playerFilter = $event"
@update:player-sort="playerSort = $event"
@open-image-uploader="openImageUploader"
@update-player-field="onUpdatePlayerFieldPayload"
@update-player-group="onUpdatePlayerGroupPayload"
@convert-row-name="convertRowPlayerName"
@remove-player-image="removePlayerImage"
@delete-player="deletePlayer"
@request-reset-stage="openResetStageModal"
@update-score-filter="updateScoreFilter"
/>
</template>
</div>
</Transition>
</template>
</main>
<ProofModal :open="proofModal.open" :image="proofModal.image" :title="proofModal.title" @close="closeProofModal" />
<ResetStageModal :open="resetModal.open" :t="t" @cancel="closeResetStageModal" @confirm="confirmResetStage" />
<ImageCropModal
:open="avatarCropModal.open"
:source-image="avatarCropModal.sourceImage"
:t="t"
@close="closeAvatarCropModal"
@confirm="onAvatarCropConfirm"
/>
<ScoreAdviceModal
:open="scoreAdviceModal.open"
:t="t"
:advice="scoreAdviceModal.advice"
:current-score="scoreAdviceModal.currentScore"
:image="scoreAdviceModal.image"
:loading="scoreAdviceModal.loading"
:error="scoreAdviceModal.error"
@close="closeScoreAdviceModal"
@refresh="refreshScoreAdvice"
@apply="applySuggestedScore"
/>
<div class="toast" :class="[toast.kind, { show: toast.show }]">{{ toast.message }}</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import AppHeader from './components/AppHeader.vue'
import ViewModePanel from './components/ViewModePanel.vue'
2026-04-03 09:55:36 +04:00
import LiveModePanel from './components/LiveModePanel.vue'
2026-04-01 11:47:03 +04:00
import AdminLoginPanel from './components/AdminLoginPanel.vue'
import AdminPanel from './components/AdminPanel.vue'
import ProofModal from './components/ProofModal.vue'
import ResetStageModal from './components/ResetStageModal.vue'
import ImageCropModal from './components/ImageCropModal.vue'
import ScoreAdviceModal from './components/ScoreAdviceModal.vue'
import { I18N, createInitialState } from './constants/i18n'
import { DEFAULT_PRIMARY_GROUPS, groupKey, loadPrimaryGroups, normalizedGroupCode, parseGroupList } from './utils/groups'
import { convertNameAuto } from './utils/nameTransliteration'
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
const FALLBACK_SYNC_MS = 15000
const STREAM_RETRY_MS = 1500
2026-04-03 09:55:36 +04:00
const PRELIM_ROUND_STAGES = ['prelim_r1', 'prelim_r2', 'prelim_r3']
const FINAL_ROUND_STAGES = ['final_r1', 'final_r2']
2026-04-01 11:47:03 +04:00
const loading = ref(true)
2026-04-03 09:55:36 +04:00
const mode = ref('live')
2026-04-01 11:47:03 +04:00
const viewTab = ref('groups')
const adminTab = ref('players')
const language = ref(localStorage.getItem('shooting_lang') === 'en' ? 'en' : 'ar')
2026-04-29 00:45:48 +04:00
const isFullscreen = ref(false)
2026-04-01 11:47:03 +04:00
const state = ref(createInitialState())
const primaryGroups = ref(loadPrimaryGroups(localStorage))
const adminToken = ref(localStorage.getItem('shooting_admin_token') || '')
const login = reactive({ username: 'datwyler', password: 'datwyler' })
const loginError = ref('')
const newPlayer = reactive({ nameAr: '', nameEn: '', groupCode: '' })
const imageInput = ref(null)
const scoreProofInput = ref(null)
const uploadingPlayerId = ref(null)
const uploadingScoreProof = reactive({ stage: '', playerId: null })
const groupSetupInput = ref(primaryGroups.value.join(', '))
const playerFilter = ref('')
const playerSort = ref('id')
const scoreFilters = reactive({
preliminary: '',
2026-04-03 09:55:36 +04:00
final_common: '',
2026-04-01 11:47:03 +04:00
prelim_tiebreak: '',
final_tiebreak: '',
})
const liveGroupIndex = ref(0)
const liveMode = ref('rotate')
const selectedLiveGroup = ref('')
const liveTick = ref(0)
let liveTimer = null
2026-04-03 09:55:36 +04:00
const liveMonitorGroupIndex = ref(0)
const liveMonitorFinalIndex = ref(0)
let liveMonitorTimer = null
2026-04-01 11:47:03 +04:00
let syncTimer = null
let stream = null
let streamRetryTimer = null
let streamConnected = false
let fetchingState = false
const proofModal = reactive({ open: false, image: '', title: '' })
const resetModal = reactive({ open: false, stage: '' })
const avatarCropModal = reactive({ open: false, sourceImage: '' })
const scoreAdviceModal = reactive({
open: false,
loading: false,
error: '',
image: '',
advice: null,
currentScore: 0,
stage: '',
playerId: 0,
})
const scoreAdviceCache = reactive({})
const scoreDrafts = reactive({})
const toast = reactive({ show: false, kind: 'success', message: '' })
let toastTimer = null
const competitionTitle = computed(() => {
const fallback = t('titleFallback')
const title = language.value === 'ar' ? state.value.competition?.titleAr : state.value.competition?.titleEn
return title || fallback
})
const playersSorted = computed(() => [...(state.value.players || [])].sort((a, b) => a.id - b.id))
const preliminaryRows = computed(() => state.value.derived?.preliminaryRanking?.rows || [])
const finalists = computed(() => state.value.derived?.finalists || [])
const finalGroup1 = computed(() => state.value.derived?.finalGroups?.group1 || [])
const finalGroup2 = computed(() => state.value.derived?.finalGroups?.group2 || [])
const finalRows = computed(() => state.value.derived?.finalRanking?.rows || [])
const podiumRows = computed(() => state.value.derived?.podium || [])
const prelimTie = computed(() => state.value.derived?.preliminaryRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] })
const finalTie = computed(() => state.value.derived?.finalRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] })
const canViewProofs = computed(() => Boolean(state.value.settings?.viewProofInView))
2026-04-03 09:55:36 +04:00
const liveSettings = computed(() => {
const raw = state.value.settings?.liveMode || {}
const groupDisplayMode = raw.groupDisplayMode === 'fixed' ? 'fixed' : 'rotate'
const finalDisplayMode = raw.finalDisplayMode === 'fixed' ? 'fixed' : 'rotate'
const activeView = normalizeLiveActiveView(raw.activeView || 'group_live')
return {
activeView,
showGroupLive: raw.showGroupLive !== false,
groupDisplayMode,
groupFixedCode: normalizedGroupCode(raw.groupFixedCode || ''),
showPrelimTie: raw.showPrelimTie !== false,
showPrelimOverall: raw.showPrelimOverall !== false,
showFinalGroups: raw.showFinalGroups !== false,
finalDisplayMode,
finalFixedGroup: Number(raw.finalFixedGroup) === 2 ? 2 : 1,
showFinalTie: raw.showFinalTie !== false,
showPodium: raw.showPodium !== false,
rotationIntervalSec: Number(raw.rotationIntervalSec) > 0 ? Number(raw.rotationIntervalSec) : 5,
rotationPlayerCount: Number(raw.rotationPlayerCount) > 0 ? Number(raw.rotationPlayerCount) : 12,
}
})
2026-04-01 11:47:03 +04:00
const viewTabs = computed(() => [
{ id: 'groups', label: t('tabs.groups') },
{ id: 'live', label: t('tabs.live') },
{ id: 'overall', label: t('tabs.overall') },
{ id: 'final', label: t('tabs.final') },
{ id: 'podium', label: t('tabs.podium') },
])
const adminTabs = computed(() => [
{ id: 'players', label: t('tabs.players') },
2026-04-03 09:55:36 +04:00
{ id: 'liveMode', label: t('tabs.liveModeAdmin') },
2026-04-01 11:47:03 +04:00
{ id: 'preliminary', label: t('tabs.preliminary') },
{ id: 'prelimTie', label: t('tabs.prelimTie') },
{ id: 'final', label: t('tabs.final') },
{ id: 'finalTie', label: t('tabs.finalTie') },
])
const groupsBoard = computed(() => {
const map = new Map()
for (const player of playersSorted.value) {
const code = (player.groupCode || '').trim() || t('labels.unassigned')
if (!map.has(code)) map.set(code, [])
map.get(code).push(player)
}
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([code, players]) => ({ code, players }))
})
const groupSummaries = computed(() => groupsBoard.value.map((group) => ({ code: group.code, key: groupKey(group.code), count: group.players.length })))
const adminGroupStats = computed(() => {
const counts = { unassigned: 0 }
for (const code of primaryGroups.value) {
counts[code] = 0
}
const other = new Map()
for (const player of playersSorted.value) {
const code = normalizedGroupCode(player.groupCode)
if (!code) {
counts.unassigned += 1
} else if (primaryGroups.value.includes(code)) {
counts[code] += 1
} else {
other.set(code, (other.get(code) || 0) + 1)
}
}
return { counts, other: [...other.entries()].sort((a, b) => a[0].localeCompare(b[0])) }
})
const assignableGroups = computed(() => {
const extra = adminGroupStats.value.other.map(([code]) => code)
return [...primaryGroups.value, ...extra]
})
const adminGroupCards = computed(() => {
const cards = primaryGroups.value.map((code) => ({
key: code.toLowerCase(),
label: `${t('labels.group')} ${code}`,
count: adminGroupStats.value.counts[code],
}))
cards.push({
key: 'u',
label: t('labels.unassigned'),
count: adminGroupStats.value.counts.unassigned,
})
return cards
})
const activeGroupCodes = computed(() => {
const seen = new Set()
for (const player of playersSorted.value) {
const code = normalizedGroupCode(player.groupCode)
if (code) seen.add(code)
}
return [...seen].sort((a, b) => a.localeCompare(b))
})
const liveSelectableGroups = computed(() => assignableGroups.value)
const liveRotationGroups = computed(() => activeGroupCodes.value)
const liveGroupCode = computed(() => {
if (liveMode.value === 'fixed') {
return selectedLiveGroup.value || liveSelectableGroups.value[0] || ''
}
return liveRotationGroups.value[liveGroupIndex.value] || ''
})
const liveMembers = computed(() => {
const code = normalizedGroupCode(liveGroupCode.value)
if (!code) return []
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
})
2026-04-03 09:55:36 +04:00
const liveMonitorGroupRotationCodes = computed(() => activeGroupCodes.value)
const liveMonitorGroupCode = computed(() => {
if (liveSettings.value.groupDisplayMode === 'fixed') {
const fixed = normalizedGroupCode(liveSettings.value.groupFixedCode)
if (fixed) return fixed
return liveMonitorGroupRotationCodes.value[0] || ''
}
return liveMonitorGroupRotationCodes.value[liveMonitorGroupIndex.value] || ''
})
const liveMonitorMembers = computed(() => {
const code = normalizedGroupCode(liveMonitorGroupCode.value)
if (!code) return []
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
})
const liveMonitorFinalGroup = computed(() => {
if (liveSettings.value.finalDisplayMode === 'fixed') {
return liveSettings.value.finalFixedGroup === 2 ? 2 : 1
}
return liveMonitorFinalIndex.value % 2 === 0 ? 1 : 2
})
const liveMonitorFinalRows = computed(() => (liveMonitorFinalGroup.value === 2 ? finalGroup2.value : finalGroup1.value))
2026-04-01 11:47:03 +04:00
const podiumOrdered = computed(() => [podiumRows.value[1] || null, podiumRows.value[0] || null, podiumRows.value[2] || null])
const prelimTieRows = computed(() => {
const ids = new Set(prelimTie.value.playerIds || [])
return preliminaryRows.value.filter((row) => ids.has(row.playerId))
})
const finalTieRows = computed(() => {
const ids = new Set(finalTie.value.playerIds || [])
return finalRows.value.filter((row) => ids.has(row.playerId))
})
2026-04-03 09:55:36 +04:00
const playerByID = computed(() => {
const map = new Map()
for (const player of playersSorted.value) {
map.set(player.id, player)
}
return map
})
const preliminaryRowByID = computed(() => {
const map = new Map()
for (const row of preliminaryRows.value) {
map.set(row.playerId, row)
}
return map
})
const finalRowByID = computed(() => {
const map = new Map()
for (const row of finalRows.value) {
map.set(row.playerId, row)
}
return map
})
const adminPreliminaryRows = computed(() => {
const rows = playersSorted.value.map((player) => {
const derived = preliminaryRowByID.value.get(player.id)
return {
playerId: player.id,
nameAr: player.nameAr,
nameEn: player.nameEn,
groupCode: player.groupCode,
imageData: player.imageData,
score: derived?.score ?? preliminaryTotalScore(player.id),
tieBreak: derived?.tieBreak ?? 0,
rank: derived?.rank ?? 0,
}
})
rows.sort(compareRowsByGroupThenId)
return rows
})
const adminPrelimTieRows = computed(() => {
const rows = (prelimTie.value.playerIds || [])
.map((playerId) => {
const player = playerByID.value.get(playerId)
if (!player) return null
const derived = preliminaryRowByID.value.get(playerId)
return {
playerId,
nameAr: player.nameAr,
nameEn: player.nameEn,
groupCode: player.groupCode,
imageData: player.imageData,
score: derived?.score ?? preliminaryTotalScore(playerId),
tieBreak: derived?.tieBreak ?? 0,
}
})
.filter(Boolean)
rows.sort(compareRowsByScoreGroupThenId)
return rows
})
const adminFinalRows = computed(() => {
const rows = [...finalGroup1.value, ...finalGroup2.value].map((row) => {
const player = playerByID.value.get(row.playerId)
const ranked = finalRowByID.value.get(row.playerId)
return {
playerId: row.playerId,
nameAr: player?.nameAr || row.nameAr,
nameEn: player?.nameEn || row.nameEn,
groupCode: player?.groupCode || row.groupCode,
imageData: player?.imageData || row.imageData,
finalGroup: row.finalGroup,
seed: row.seed,
score: ranked?.score ?? row.score ?? 0,
tieBreak: ranked?.tieBreak ?? row.tieBreak ?? 0,
}
})
rows.sort((a, b) => {
const groupA = Number(a.finalGroup || 0)
const groupB = Number(b.finalGroup || 0)
if (groupA !== groupB) return groupA - groupB
const seedA = Number(a.seed || 0)
const seedB = Number(b.seed || 0)
if (seedA !== seedB) return seedA - seedB
return a.playerId - b.playerId
})
return rows
})
const adminFinalTieRows = computed(() => {
const rows = (finalTie.value.playerIds || [])
.map((playerId) => {
const player = playerByID.value.get(playerId)
if (!player) return null
const derived = finalRowByID.value.get(playerId)
return {
playerId,
nameAr: player.nameAr,
nameEn: player.nameEn,
groupCode: player.groupCode,
imageData: player.imageData,
finalGroup: derived?.finalGroup ?? 0,
seed: derived?.seed ?? 0,
score: derived?.score ?? finalTotalScore(playerId),
tieBreak: derived?.tieBreak ?? 0,
}
})
.filter(Boolean)
rows.sort(compareRowsByScoreGroupThenId)
return rows
})
2026-04-01 11:47:03 +04:00
watch(language, (value) => {
localStorage.setItem('shooting_lang', value)
document.documentElement.lang = value
document.documentElement.dir = value === 'ar' ? 'rtl' : 'ltr'
})
watch(
() => `${mode.value}|${viewTab.value}|${liveMode.value}|${liveRotationGroups.value.join('|')}`,
() => {
if (mode.value === 'view' && viewTab.value === 'live' && liveMode.value === 'rotate') {
startLiveRotation()
return
}
stopLiveRotation()
},
)
2026-04-03 09:55:36 +04:00
watch(
() =>
`${mode.value}|${liveSettings.value.activeView}|${liveSettings.value.groupDisplayMode}|${liveSettings.value.finalDisplayMode}|${liveSettings.value.rotationIntervalSec}|${liveMonitorGroupRotationCodes.value.join('|')}|${finalGroup1.value.length}|${finalGroup2.value.length}`,
() => {
if (mode.value === 'live') {
startLiveMonitorRotation()
return
}
stopLiveMonitorRotation()
},
)
2026-04-01 11:47:03 +04:00
watch(
() => liveSelectableGroups.value.join('|'),
() => {
if (liveSelectableGroups.value.length === 0) {
selectedLiveGroup.value = ''
return
}
if (!liveSelectableGroups.value.includes(selectedLiveGroup.value)) {
selectedLiveGroup.value = liveSelectableGroups.value[0]
}
},
{ immediate: true },
)
watch(
() => liveRotationGroups.value.join('|'),
() => {
if (liveRotationGroups.value.length === 0) {
liveGroupIndex.value = 0
return
}
if (liveGroupIndex.value >= liveRotationGroups.value.length) {
liveGroupIndex.value = 0
}
},
{ immediate: true },
)
2026-04-03 09:55:36 +04:00
watch(
() => liveMonitorGroupRotationCodes.value.join('|'),
() => {
if (liveMonitorGroupRotationCodes.value.length === 0) {
liveMonitorGroupIndex.value = 0
return
}
if (liveMonitorGroupIndex.value >= liveMonitorGroupRotationCodes.value.length) {
liveMonitorGroupIndex.value = 0
}
},
{ immediate: true },
)
watch(
() => `${finalGroup1.value.length}|${finalGroup2.value.length}`,
() => {
if (liveMonitorFinalIndex.value < 0) {
liveMonitorFinalIndex.value = 0
}
},
{ immediate: true },
)
2026-04-01 11:47:03 +04:00
onMounted(async () => {
2026-04-29 00:45:48 +04:00
onFullscreenChange()
document.addEventListener('fullscreenchange', onFullscreenChange)
document.addEventListener('webkitfullscreenchange', onFullscreenChange)
document.addEventListener('mozfullscreenchange', onFullscreenChange)
document.addEventListener('MSFullscreenChange', onFullscreenChange)
2026-04-01 11:47:03 +04:00
document.documentElement.lang = language.value
document.documentElement.dir = language.value === 'ar' ? 'rtl' : 'ltr'
await fetchState()
startRealtimeSync()
})
onBeforeUnmount(() => {
2026-04-29 00:45:48 +04:00
document.removeEventListener('fullscreenchange', onFullscreenChange)
document.removeEventListener('webkitfullscreenchange', onFullscreenChange)
document.removeEventListener('mozfullscreenchange', onFullscreenChange)
document.removeEventListener('MSFullscreenChange', onFullscreenChange)
2026-04-01 11:47:03 +04:00
stopLiveRotation()
2026-04-03 09:55:36 +04:00
stopLiveMonitorRotation()
2026-04-01 11:47:03 +04:00
stopRealtimeSync()
if (toastTimer) clearTimeout(toastTimer)
})
function t(path) {
const source = I18N[language.value]
return path.split('.').reduce((value, key) => (value && value[key] !== undefined ? value[key] : path), source)
}
function setLanguage(next) {
if (next === 'ar' || next === 'en') language.value = next
}
2026-04-29 00:45:48 +04:00
function getFullscreenElement() {
return (
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement ||
null
)
}
function onFullscreenChange() {
isFullscreen.value = Boolean(getFullscreenElement())
}
function requestFullscreenFor(element) {
if (!element) return Promise.resolve()
const fn =
element.requestFullscreen ||
element.webkitRequestFullscreen ||
element.mozRequestFullScreen ||
element.msRequestFullscreen
if (!fn) return Promise.resolve()
const result = fn.call(element)
if (result && typeof result.then === 'function') return result
return Promise.resolve()
}
function exitFullscreen() {
const fn =
document.exitFullscreen ||
document.webkitExitFullscreen ||
document.mozCancelFullScreen ||
document.msExitFullscreen
if (!fn) return Promise.resolve()
const result = fn.call(document)
if (result && typeof result.then === 'function') return result
return Promise.resolve()
}
async function toggleFullscreen() {
try {
if (getFullscreenElement()) {
await exitFullscreen()
return
}
await requestFullscreenFor(document.documentElement)
} catch {
// Ignore fullscreen errors, mostly caused by browser policy.
}
}
2026-04-03 09:55:36 +04:00
function normalizeLiveActiveView(value) {
const normalized = String(value || '').trim().toLowerCase()
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'podium'])
return allowed.has(normalized) ? normalized : 'group_live'
}
2026-04-01 11:47:03 +04:00
function formatServerTime(iso) {
try {
return new Intl.DateTimeFormat(language.value === 'ar' ? 'ar-AE' : 'en-US', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(iso))
} catch {
return iso
}
}
function groupOptionLabel(code) {
const normalized = normalizedGroupCode(code)
if (!normalized) {
return `${t('labels.unassigned')} (${adminGroupStats.value.counts.unassigned})`
}
if (primaryGroups.value.includes(normalized)) {
return `${normalized} (${adminGroupStats.value.counts[normalized]})`
}
const count = adminGroupStats.value.other.find(([key]) => key === normalized)?.[1] || 0
return `${normalized} (${count})`
}
2026-04-03 09:55:36 +04:00
function groupOrderValue(code) {
const normalized = normalizedGroupCode(code)
if (!normalized) return Number.MAX_SAFE_INTEGER - 1
const idx = assignableGroups.value.indexOf(normalized)
if (idx >= 0) return idx
return Number.MAX_SAFE_INTEGER
}
function compareRowsByGroupThenId(a, b) {
const orderA = groupOrderValue(a.groupCode)
const orderB = groupOrderValue(b.groupCode)
if (orderA !== orderB) return orderA - orderB
const groupA = normalizedGroupCode(a.groupCode)
const groupB = normalizedGroupCode(b.groupCode)
if (groupA !== groupB) {
if (!groupA) return 1
if (!groupB) return -1
return groupA.localeCompare(groupB)
}
const idA = Number(a.playerId || a.id || 0)
const idB = Number(b.playerId || b.id || 0)
return idA - idB
}
function compareRowsByScoreGroupThenId(a, b) {
const scoreA = Number(a.score || 0)
const scoreB = Number(b.score || 0)
if (scoreA !== scoreB) return scoreB - scoreA
return compareRowsByGroupThenId(a, b)
}
2026-04-01 11:47:03 +04:00
function saveGroupSetup() {
const parsed = parseGroupList(groupSetupInput.value)
primaryGroups.value = parsed.length > 0 ? parsed : DEFAULT_PRIMARY_GROUPS
groupSetupInput.value = primaryGroups.value.join(', ')
localStorage.setItem('shooting_group_list', primaryGroups.value.join(','))
if (!primaryGroups.value.includes(normalizedGroupCode(newPlayer.groupCode))) {
newPlayer.groupCode = ''
}
showToast(t('messages.saved'))
}
function updateNewPlayer({ key, value }) {
if (!Object.prototype.hasOwnProperty.call(newPlayer, key)) return
newPlayer[key] = value
}
function convertNewPlayerName(direction) {
if (direction === 'ar_to_en') {
const source = String(newPlayer.nameAr || '').trim()
if (!source) {
showToast(t('messages.noNameToConvert'), 'error')
return
}
newPlayer.nameEn = convertNameAuto('ar_to_en', source)
return
}
if (direction === 'en_to_ar') {
const source = String(newPlayer.nameEn || '').trim()
if (!source) {
showToast(t('messages.noNameToConvert'), 'error')
return
}
newPlayer.nameAr = convertNameAuto('en_to_ar', source)
}
}
function updateScoreFilter({ stage, value }) {
if (!Object.prototype.hasOwnProperty.call(scoreFilters, stage)) return
scoreFilters[stage] = value
}
async function autoGroupEvenly() {
if (primaryGroups.value.length === 0) {
showToast(t('messages.noGroupsConfigured'), 'error')
return
}
try {
state.value = await api('/admin/players/auto-group', {
method: 'POST',
admin: true,
body: { groups: primaryGroups.value },
})
showToast(t('messages.saved'))
} catch (error) {
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
}
}
function setLiveMode(nextMode) {
if (nextMode !== 'rotate' && nextMode !== 'fixed') return
liveMode.value = nextMode
if (nextMode === 'fixed' && !selectedLiveGroup.value && liveSelectableGroups.value.length > 0) {
selectedLiveGroup.value = liveSelectableGroups.value[0]
}
}
function setSelectedLiveGroup(nextValue) {
const next = normalizedGroupCode(nextValue || '')
selectedLiveGroup.value = next
}
function rowClass(code) {
const key = groupKey(code)
if (key === 'u') return 'row-group-empty'
return 'row-group-' + key
}
function displayName(item) {
return language.value === 'ar' ? item.nameAr : item.nameEn
}
function secondaryName(item) {
return language.value === 'ar' ? item.nameEn : item.nameAr
}
function playerImage(player) {
if (player?.imageData) return player.imageData
const name = player?.nameAr || player?.nameEn || 'Player'
return `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=F0F2F8&color=1B1F40&bold=true&rounded=true&size=180`
}
function showToast(message, kind = 'success') {
toast.message = message
toast.kind = kind
toast.show = true
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => {
toast.show = false
}, 2500)
}
function clearAdminSession(notify = false) {
adminToken.value = ''
localStorage.removeItem('shooting_admin_token')
if (notify) showToast(t('messages.unauthorized'), 'error')
}
async function api(path, options = {}) {
const response = await fetch(`${API_BASE}${path}`, {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...(options.admin && adminToken.value ? { Authorization: `Bearer ${adminToken.value}` } : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
})
let payload = null
try {
payload = await response.json()
} catch {
payload = null
}
if (!response.ok) {
if (options.admin && response.status === 401) {
clearAdminSession(true)
}
throw new Error(payload?.message || `${response.status} ${response.statusText}`)
}
return payload
}
async function fetchState(showLoader = true) {
if (fetchingState) return
fetchingState = true
if (showLoader) loading.value = true
try {
const wantsAdminState = Boolean(adminToken.value)
state.value = await api(wantsAdminState ? '/admin/state' : '/state', { admin: wantsAdminState })
} catch (error) {
if (adminToken.value === '') {
try {
state.value = await api('/state')
} catch {}
}
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
} finally {
fetchingState = false
if (showLoader) loading.value = false
}
}
async function adminLogin() {
loginError.value = ''
try {
const payload = await api('/admin/login', {
method: 'POST',
body: { username: login.username, password: login.password },
})
adminToken.value = payload.token
localStorage.setItem('shooting_admin_token', payload.token)
await fetchState(false)
showToast(t('messages.saved'))
} catch (error) {
loginError.value = error.message
}
}
async function adminLogout() {
try {
if (adminToken.value) {
await api('/admin/logout', { method: 'POST', admin: true })
}
} catch {}
clearAdminSession(false)
await fetchState(false)
}
2026-04-03 09:55:36 +04:00
async function updateAdminSettings(payload) {
2026-04-01 11:47:03 +04:00
try {
state.value = await api('/admin/settings', {
method: 'PUT',
admin: true,
2026-04-03 09:55:36 +04:00
body: payload,
2026-04-01 11:47:03 +04:00
})
showToast(t('messages.saved'))
} catch (error) {
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
}
}
2026-04-03 09:55:36 +04:00
async function updateViewProofSetting(enabled) {
await updateAdminSettings({ viewProofInView: Boolean(enabled) })
}
async function updateLiveModeSetting(patch) {
if (!patch || typeof patch !== 'object') return
await updateAdminSettings({ liveMode: patch })
}
2026-04-01 11:47:03 +04:00
async function createPlayer() {
if (!newPlayer.nameAr || !newPlayer.nameEn) {
showToast(t('messages.mustProvideNames'), 'error')
return
}
try {
state.value = await api('/admin/players', {
method: 'POST',
admin: true,
body: {
nameAr: newPlayer.nameAr,
nameEn: newPlayer.nameEn,
groupCode: normalizedGroupCode(newPlayer.groupCode),
},
})
newPlayer.nameAr = ''
newPlayer.nameEn = ''
newPlayer.groupCode = ''
showToast(t('messages.saved'))
} catch (error) {
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
}
}
async function updatePlayer(playerId, body) {
try {
state.value = await api(`/admin/players/${playerId}`, {
method: 'PUT',
admin: true,
body,
})
showToast(t('messages.saved'))
} catch (error) {
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
}
}
async function updatePlayerField(player, field, event) {
const value = String(event.target.value || '').trim()
if (value === player[field]) return
if ((field === 'nameAr' || field === 'nameEn') && value === '') {
event.target.value = player[field]
showToast(t('messages.mustProvideNames'), 'error')
return
}
await updatePlayer(player.id, { [field]: value })
}
function onUpdatePlayerFieldPayload({ player, field, event }) {
updatePlayerField(player, field, event)
}
async function updatePlayerGroup(player, event) {
const next = normalizedGroupCode(event.target.value)
const current = normalizedGroupCode(player.groupCode)
if (next === current) return
await updatePlayer(player.id, { groupCode: next })
}
function onUpdatePlayerGroupPayload({ player, event }) {
updatePlayerGroup(player, event)
}
async function convertRowPlayerName({ player, direction }) {
if (!player || !direction) return
if (direction === 'ar_to_en') {
const source = String(player.nameAr || '').trim()
if (!source) {
showToast(t('messages.noNameToConvert'), 'error')
return
}
await updatePlayer(player.id, { nameEn: convertNameAuto('ar_to_en', source) })
return
}
if (direction === 'en_to_ar') {
const source = String(player.nameEn || '').trim()
if (!source) {
showToast(t('messages.noNameToConvert'), 'error')
return
}
await updatePlayer(player.id, { nameAr: convertNameAuto('en_to_ar', source) })
}
}
async function deletePlayer(playerId) {
const ok = window.confirm(t('messages.confirmDelete'))
if (!ok) return
try {
state.value = await api(`/admin/players/${playerId}`, {
method: 'DELETE',
admin: true,
})
showToast(t('messages.saved'))
} catch (error) {
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
}
}
function openImageUploader(playerId) {
if (mode.value !== 'admin' || !adminToken.value) return
uploadingPlayerId.value = playerId
imageInput.value?.click()
}
async function onImageSelected(event) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file || !uploadingPlayerId.value) return
try {
avatarCropModal.sourceImage = await compressImage(file, 1800, 0.9)
avatarCropModal.open = true
} catch (error) {
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
}
}
function closeAvatarCropModal() {
avatarCropModal.open = false
avatarCropModal.sourceImage = ''
uploadingPlayerId.value = null
}
async function onAvatarCropConfirm(imageData) {
if (!uploadingPlayerId.value || !imageData) {
closeAvatarCropModal()
return
}
try {
await updatePlayer(uploadingPlayerId.value, { imageData })
} finally {
closeAvatarCropModal()
}
}
async function removePlayerImage(playerId) {
await updatePlayer(playerId, { clearImage: true })
}
function scoreProofFor(stage, playerId) {
return state.value.scoreProofs?.[stage]?.[String(playerId)] || ''
}
function hasScoreProof(stage, playerId) {
return scoreProofFor(stage, playerId) !== ''
}
function openScoreProofUploader(stage, playerId) {
if (mode.value !== 'admin' || !adminToken.value) return
uploadingScoreProof.stage = stage
uploadingScoreProof.playerId = playerId
scoreProofInput.value?.click()
}
function onOpenProofFromPayload(payload) {
if (!payload || !payload.stage || !payload.playerId) return
openProofPreview(payload.stage, payload.playerId)
}
function openProofPreview(stage, playerId) {
const image = scoreProofFor(stage, playerId)
if (!image) return
proofModal.open = true
proofModal.image = image
proofModal.title = `${t('table.verification')} · ${stage}`
}
function closeProofModal() {
proofModal.open = false
proofModal.image = ''
proofModal.title = ''
}
async function onScoreProofSelected(event) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file || !uploadingScoreProof.stage || !uploadingScoreProof.playerId) return
try {
const imageData = await compressImage(file, 1280, 0.86)
state.value = await api(`/admin/scores/${uploadingScoreProof.stage}/${uploadingScoreProof.playerId}/proof`, {
method: 'PUT',
admin: true,
body: { imageData },
})
delete scoreAdviceCache[scoreAdviceKey(uploadingScoreProof.stage, uploadingScoreProof.playerId)]
showToast(t('messages.saved'))
} catch (error) {
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
} finally {
uploadingScoreProof.stage = ''
uploadingScoreProof.playerId = null
}
}
async function removeScoreProof(stage, playerId) {
try {
state.value = await api(`/admin/scores/${stage}/${playerId}/proof`, {
method: 'DELETE',
admin: true,
})
delete scoreAdviceCache[scoreAdviceKey(stage, playerId)]
showToast(t('messages.saved'))
} catch (error) {
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
}
}
async function requestScoreAdvice(stage, playerId) {
await openScoreAdviceModal(stage, playerId)
}
async function fetchScoreAdvice(stage, playerId) {
const image = scoreProofFor(stage, playerId)
if (!image) {
throw new Error(t('messages.noProofForAi'))
}
const advice = await api(`/admin/scores/${stage}/${playerId}/advice`, {
method: 'POST',
admin: true,
})
scoreAdviceCache[scoreAdviceKey(stage, playerId)] = advice
return { advice, image }
}
async function openScoreAdviceModal(stage, playerId) {
const image = scoreProofFor(stage, playerId)
if (!image) {
showToast(t('messages.noProofForAi'), 'error')
return
}
const current = Number(scoreFor(stage, playerId) || 0)
scoreAdviceModal.open = true
scoreAdviceModal.loading = true
scoreAdviceModal.error = ''
scoreAdviceModal.image = image
scoreAdviceModal.advice = scoreAdviceCache[scoreAdviceKey(stage, playerId)] || null
scoreAdviceModal.currentScore = current
scoreAdviceModal.stage = stage
scoreAdviceModal.playerId = playerId
try {
const result = await fetchScoreAdvice(stage, playerId)
scoreAdviceModal.image = result.image
scoreAdviceModal.advice = result.advice
} catch (error) {
scoreAdviceModal.error = `${t('messages.errorPrefix')}: ${error.message}`
} finally {
scoreAdviceModal.loading = false
}
}
function scoreAdviceKey(stage, playerId) {
return `${stage}:${playerId}`
}
function closeScoreAdviceModal() {
scoreAdviceModal.open = false
scoreAdviceModal.loading = false
scoreAdviceModal.error = ''
scoreAdviceModal.image = ''
scoreAdviceModal.advice = null
scoreAdviceModal.currentScore = 0
scoreAdviceModal.stage = ''
scoreAdviceModal.playerId = 0
}
async function refreshScoreAdvice() {
if (!scoreAdviceModal.stage || !scoreAdviceModal.playerId) return
await openScoreAdviceModal(scoreAdviceModal.stage, scoreAdviceModal.playerId)
}
async function applySuggestedScore() {
if (!scoreAdviceModal.advice) return
const stage = scoreAdviceModal.stage
const playerId = scoreAdviceModal.playerId
2026-04-29 00:45:48 +04:00
const score = clampScoreValue(scoreAdviceModal.advice.advisedScore)
2026-04-01 11:47:03 +04:00
await updateScoreValue(stage, playerId, score)
scoreAdviceModal.currentScore = score
closeScoreAdviceModal()
}
function compressImage(file, maxSize, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(new Error('Failed to read image'))
reader.onload = () => {
const image = new Image()
image.onerror = () => reject(new Error('Invalid image file'))
image.onload = () => {
let width = image.width
let height = image.height
if (width > height && width > maxSize) {
height = Math.round((height * maxSize) / width)
width = maxSize
} else if (height > maxSize) {
width = Math.round((width * maxSize) / height)
height = maxSize
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('Failed to process image'))
return
}
ctx.drawImage(image, 0, 0, width, height)
resolve(canvas.toDataURL('image/jpeg', quality))
}
image.src = reader.result
}
reader.readAsDataURL(file)
})
}
function scoreFor(stage, playerId) {
return state.value.scores?.[stage]?.[String(playerId)] ?? 0
}
2026-04-03 09:55:36 +04:00
function totalScoreByStages(stages, playerId) {
return stages.reduce((sum, stage) => sum + Number(scoreFor(stage, playerId) || 0), 0)
}
function preliminaryTotalScore(playerId) {
return totalScoreByStages(PRELIM_ROUND_STAGES, playerId)
}
function finalTotalScore(playerId) {
return totalScoreByStages(FINAL_ROUND_STAGES, playerId)
}
function proofStageByFamily(stages, playerId) {
for (const stage of stages) {
if (hasScoreProof(stage, playerId)) return stage
}
return ''
}
function preliminaryProofStage(playerId) {
return proofStageByFamily([...PRELIM_ROUND_STAGES].reverse(), playerId)
}
function finalProofStage(playerId) {
return proofStageByFamily([...FINAL_ROUND_STAGES].reverse(), playerId)
}
2026-04-01 11:47:03 +04:00
function scoreDraftKey(stage, playerId) {
return `${stage}:${playerId}`
}
2026-04-29 00:45:48 +04:00
function clampScoreValue(value) {
let score = Number(value)
if (!Number.isFinite(score)) score = 0
score = Math.trunc(score)
if (score < 0) return 0
if (score > 100) return 100
return score
}
2026-04-01 11:47:03 +04:00
function scoreInputValue(stage, playerId) {
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
if (draft) return draft.value
return String(scoreFor(stage, playerId))
}
2026-04-29 00:45:48 +04:00
function scoreInputInvalid(stage, playerId) {
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
return Boolean(draft?.invalid)
}
function parseDraftScoreValue(value) {
let score = Number(value === '' ? 0 : value)
if (!Number.isFinite(score)) score = 0
return Math.trunc(score)
}
2026-04-01 11:47:03 +04:00
function onScoreFocus(stage, playerId) {
const key = scoreDraftKey(stage, playerId)
if (!scoreDrafts[key]) {
2026-04-29 00:45:48 +04:00
scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false, invalid: false }
2026-04-01 11:47:03 +04:00
}
}
function onScoreInput(stage, playerId, event) {
const key = scoreDraftKey(stage, playerId)
const raw = String(event?.target?.value ?? '')
2026-04-29 00:45:48 +04:00
const digits = raw.replace(/[^\d]/g, '').slice(0, 3)
const parsed = parseDraftScoreValue(digits)
const invalid = digits !== '' && (parsed < 0 || parsed > 100)
const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false, invalid: false }
scoreDrafts[key] = { ...current, value: digits, invalid }
if (event?.target && event.target.value !== digits) event.target.value = digits
2026-04-01 11:47:03 +04:00
}
async function onScoreCommit(stage, playerId) {
const key = scoreDraftKey(stage, playerId)
const draft = scoreDrafts[key]
if (!draft || draft.committing) return
2026-04-29 00:45:48 +04:00
const score = parseDraftScoreValue(draft.value)
const invalid = score < 0 || score > 100
2026-04-01 11:47:03 +04:00
2026-04-29 00:45:48 +04:00
if (invalid) {
scoreDrafts[key] = { ...draft, invalid: true }
2026-04-01 11:47:03 +04:00
showToast(t('messages.invalidScore'), 'error')
return
}
if (score === scoreFor(stage, playerId)) {
delete scoreDrafts[key]
return
}
2026-04-29 00:45:48 +04:00
scoreDrafts[key] = { ...draft, invalid: false, committing: true }
2026-04-01 11:47:03 +04:00
await updateScoreValue(stage, playerId, score, key)
}
async function updateScoreValue(stage, playerId, score, draftKey = '') {
2026-04-29 00:45:48 +04:00
if (score < 0 || score > 100) {
2026-04-01 11:47:03 +04:00
showToast(t('messages.invalidScore'), 'error')
return
}
try {
state.value = await api(`/admin/scores/${stage}/${playerId}`, {
method: 'PUT',
admin: true,
body: { score },
})
if (draftKey) {
delete scoreDrafts[draftKey]
}
} catch (error) {
if (draftKey && scoreDrafts[draftKey]) {
scoreDrafts[draftKey] = { ...scoreDrafts[draftKey], committing: false }
}
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
}
}
function openResetStageModal(stage) {
resetModal.open = true
resetModal.stage = stage
}
function closeResetStageModal() {
resetModal.open = false
resetModal.stage = ''
}
async function confirmResetStage(resetProofs) {
const stage = resetModal.stage
if (!stage) return
2026-04-03 09:55:36 +04:00
const targets = resetStageTargets(stage)
2026-04-01 11:47:03 +04:00
try {
state.value = await api(`/admin/scores/${stage}/reset`, {
method: 'POST',
admin: true,
body: { resetProofs: Boolean(resetProofs) },
})
for (const key of Object.keys(scoreDrafts)) {
2026-04-03 09:55:36 +04:00
if (targets.some((target) => key.startsWith(`${target}:`))) delete scoreDrafts[key]
2026-04-01 11:47:03 +04:00
}
if (resetProofs) {
for (const key of Object.keys(scoreAdviceCache)) {
2026-04-03 09:55:36 +04:00
if (targets.some((target) => key.startsWith(`${target}:`))) delete scoreAdviceCache[key]
2026-04-01 11:47:03 +04:00
}
}
closeResetStageModal()
showToast(t('messages.saved'))
} catch (error) {
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
}
}
2026-04-03 09:55:36 +04:00
function resetStageTargets(stage) {
if (stage === 'preliminary') return PRELIM_ROUND_STAGES
if (stage === 'final') return FINAL_ROUND_STAGES
return [stage]
}
2026-04-01 11:47:03 +04:00
function isFinalist(playerId) {
return finalists.value.some((row) => row.playerId === playerId)
}
function startLiveRotation() {
stopLiveRotation()
if (liveRotationGroups.value.length === 0) return
liveGroupIndex.value = 0
liveTick.value += 1
if (liveRotationGroups.value.length <= 1) return
liveTimer = setInterval(() => {
liveGroupIndex.value = (liveGroupIndex.value + 1) % liveRotationGroups.value.length
liveTick.value += 1
}, 5000)
}
function stopLiveRotation() {
if (liveTimer) {
clearInterval(liveTimer)
liveTimer = null
}
}
2026-04-03 09:55:36 +04:00
function startLiveMonitorRotation() {
stopLiveMonitorRotation()
const canRotateGroups =
liveSettings.value.activeView === 'group_live' && liveSettings.value.groupDisplayMode === 'rotate' && liveMonitorGroupRotationCodes.value.length > 1
const canRotateFinal =
liveSettings.value.activeView === 'final_groups' && liveSettings.value.finalDisplayMode === 'rotate' && finalGroup1.value.length > 0 && finalGroup2.value.length > 0
if (!canRotateGroups && !canRotateFinal) return
liveMonitorGroupIndex.value = 0
liveMonitorFinalIndex.value = 0
const intervalMs = Math.max(3, Number(liveSettings.value.rotationIntervalSec) || 5) * 1000
liveMonitorTimer = setInterval(() => {
if (canRotateGroups) {
liveMonitorGroupIndex.value = (liveMonitorGroupIndex.value + 1) % liveMonitorGroupRotationCodes.value.length
}
if (canRotateFinal) {
liveMonitorFinalIndex.value = (liveMonitorFinalIndex.value + 1) % 2
}
}, intervalMs)
}
function stopLiveMonitorRotation() {
if (liveMonitorTimer) {
clearInterval(liveMonitorTimer)
liveMonitorTimer = null
}
}
2026-04-01 11:47:03 +04:00
function buildStreamURL() {
const base = API_BASE.endsWith('/') ? API_BASE.slice(0, -1) : API_BASE
return `${base}/events`
}
function connectStateStream() {
if (stream) {
stream.close()
stream = null
}
if (streamRetryTimer) {
clearTimeout(streamRetryTimer)
streamRetryTimer = null
}
stream = new EventSource(buildStreamURL())
stream.onopen = () => {
streamConnected = true
}
stream.addEventListener('state', () => {
fetchState(false)
})
stream.onerror = () => {
streamConnected = false
if (stream) {
stream.close()
stream = null
}
if (!streamRetryTimer) {
streamRetryTimer = setTimeout(() => {
streamRetryTimer = null
connectStateStream()
}, STREAM_RETRY_MS)
}
}
}
function startRealtimeSync() {
stopRealtimeSync()
connectStateStream()
syncTimer = setInterval(() => {
if (!streamConnected) fetchState(false)
}, FALLBACK_SYNC_MS)
}
function stopRealtimeSync() {
streamConnected = false
if (streamRetryTimer) {
clearTimeout(streamRetryTimer)
streamRetryTimer = null
}
if (stream) {
stream.close()
stream = null
}
if (syncTimer) {
clearInterval(syncTimer)
syncTimer = null
}
}
function podiumName(entry) {
if (!entry || entry.score === 0) return t('labels.waiting')
return displayName(entry)
}
function podiumImage(entry) {
if (!entry || entry.score === 0) {
return 'https://ui-avatars.com/api/?name=?&background=F0F2F8&color=1B1F40&rounded=true&size=200'
}
return playerImage(entry)
}
function podiumHasResult(entry) {
return Boolean(entry && entry.score > 0)
}
2026-04-03 09:55:36 +04:00
function podiumScoreMain(entry) {
2026-04-01 11:47:03 +04:00
if (!podiumHasResult(entry)) return '-'
2026-04-03 09:55:36 +04:00
return `${t('table.score')}: ${entry.score}`
2026-04-01 11:47:03 +04:00
}
2026-04-03 09:55:36 +04:00
function podiumTieSubtitle(entry) {
if (!podiumHasResult(entry)) return ''
if (!finalTie.value.required) return ''
return `${t('table.tieScore')}: ${entry.tieBreak}`
2026-04-01 11:47:03 +04:00
}
</script>