1955 lines
62 KiB
Vue
1955 lines
62 KiB
Vue
<template>
|
|
<div class="app-shell" :class="['lang-' + language, 'mode-' + mode]">
|
|
<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"
|
|
:is-fullscreen="isFullscreen"
|
|
:server-time="state.serverTime ? formatServerTime(state.serverTime) : ''"
|
|
@change-mode="mode = $event"
|
|
@change-language="setLanguage"
|
|
@toggle-fullscreen="toggleFullscreen"
|
|
/>
|
|
|
|
<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"
|
|
:final-total-score="finalTotalScore"
|
|
:preliminary-proof-stage="preliminaryProofStage"
|
|
:final-proof-stage="finalProofStage"
|
|
:podium-ordered="podiumOrdered"
|
|
:podium-image="podiumImage"
|
|
:podium-name="podiumName"
|
|
:podium-has-result="podiumHasResult"
|
|
:podium-score-main="podiumScoreMain"
|
|
:podium-tie-subtitle="podiumTieSubtitle"
|
|
: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"
|
|
/>
|
|
|
|
<LiveModePanel
|
|
v-else-if="mode === 'live'"
|
|
:t="t"
|
|
:live-settings="liveSettings"
|
|
:position-boards="positionBoards"
|
|
: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-overall-rows="finalRows"
|
|
: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"
|
|
/>
|
|
|
|
<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"
|
|
:live-settings="liveSettings"
|
|
:stage-control="stageControl"
|
|
:stage-options="stageOptions"
|
|
:current-stage="currentStage"
|
|
:last-stage="lastStage"
|
|
:active-scoring-stage="currentScoringStageKey"
|
|
:active-scoring-group="currentScoringGroupKey"
|
|
: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"
|
|
:preliminary-score-rows="adminPreliminaryRows"
|
|
:prelim-tie-score-rows="adminPrelimTieRows"
|
|
:final-score-rows="adminFinalRows"
|
|
:final-tie-score-rows="adminFinalTieRows"
|
|
:final-rows="finalRows"
|
|
:preliminary-total-score="preliminaryTotalScore"
|
|
:final-total-score="finalTotalScore"
|
|
:prelim-tie="prelimTie"
|
|
:final-tie="finalTie"
|
|
:position-boards="positionBoards"
|
|
:score-filters="scoreFilters"
|
|
:display-name="displayName"
|
|
:secondary-name="secondaryName"
|
|
:score-input-value="scoreInputValue"
|
|
:score-input-invalid="scoreInputInvalid"
|
|
: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"
|
|
@update-live-setting="updateLiveModeSetting"
|
|
@update-stage-control="updateStageControl"
|
|
@start-stage="startCurrentStage"
|
|
@end-stage="endCurrentStage"
|
|
@navigate-current-stage="navigateToCurrentStage"
|
|
@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"
|
|
@save-position-board="savePositionBoard"
|
|
@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'
|
|
import LiveModePanel from './components/LiveModePanel.vue'
|
|
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
|
|
const PRELIM_ROUND_STAGES = ['prelim_r1', 'prelim_r2', 'prelim_r3']
|
|
const FINAL_ROUND_STAGES = ['final_r1', 'final_r2']
|
|
const POSITION_BOARDS = ['preliminary', 'final', 'prelim_tiebreak', 'final_tiebreak']
|
|
|
|
const loading = ref(true)
|
|
const mode = ref('live')
|
|
const viewTab = ref('groups')
|
|
const adminTab = ref('players')
|
|
const language = ref(localStorage.getItem('shooting_lang') === 'en' ? 'en' : 'ar')
|
|
const isFullscreen = ref(false)
|
|
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: '',
|
|
final_common: '',
|
|
prelim_tiebreak: '',
|
|
final_tiebreak: '',
|
|
})
|
|
const stageControl = reactive({
|
|
phase: 'preliminary',
|
|
group: '',
|
|
round: 1,
|
|
})
|
|
|
|
const liveGroupIndex = ref(0)
|
|
const liveMode = ref('rotate')
|
|
const selectedLiveGroup = ref('')
|
|
const liveTick = ref(0)
|
|
let liveTimer = null
|
|
|
|
const liveMonitorGroupIndex = ref(0)
|
|
const liveMonitorFinalIndex = ref(0)
|
|
let liveMonitorTimer = null
|
|
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 positionBoards = computed(() => {
|
|
const raw = state.value.positionBoards || {}
|
|
const out = {}
|
|
for (const board of POSITION_BOARDS) {
|
|
out[board] = raw[board] || {}
|
|
}
|
|
return out
|
|
})
|
|
const canViewProofs = computed(() => Boolean(state.value.settings?.viewProofInView))
|
|
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,
|
|
}
|
|
})
|
|
const currentStage = computed(() => state.value.current_stage || null)
|
|
const lastStage = computed(() => state.value.last_stage || null)
|
|
const currentScoringStageKey = computed(() => mapStageSessionToScoreStage(currentStage.value))
|
|
const currentScoringGroupKey = computed(() => mapStageSessionToScoreGroup(currentStage.value))
|
|
|
|
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') },
|
|
{ id: 'liveMode', label: t('tabs.liveModeAdmin') },
|
|
{ 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 []
|
|
const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
|
return sortRowsByPreliminaryPosition(rows)
|
|
})
|
|
|
|
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 []
|
|
const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
|
return sortRowsByPreliminaryPosition(rows)
|
|
})
|
|
const liveMonitorFinalGroup = computed(() => {
|
|
if (liveSettings.value.finalDisplayMode === 'fixed') {
|
|
return liveSettings.value.finalFixedGroup === 2 ? 2 : 1
|
|
}
|
|
return liveMonitorFinalIndex.value % 2 === 0 ? 1 : 2
|
|
})
|
|
const finalBoardGroupedRows = computed(() => {
|
|
const sorted = sortRowsByBoardPosition('final', adminFinalRows.value)
|
|
const grouped = {}
|
|
for (const row of sorted) {
|
|
const key = positionSlotFor('final', row.playerId)?.groupKey || String(row.finalGroup || 1)
|
|
if (!grouped[key]) grouped[key] = []
|
|
grouped[key].push(row)
|
|
}
|
|
return grouped
|
|
})
|
|
const liveMonitorFinalRows = computed(() => {
|
|
const key = String(liveMonitorFinalGroup.value === 2 ? 2 : 1)
|
|
return finalBoardGroupedRows.value[key] || []
|
|
})
|
|
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 || [])
|
|
const rows = preliminaryRows.value.filter((row) => ids.has(row.playerId))
|
|
return sortRowsByBoardPosition('prelim_tiebreak', rows)
|
|
})
|
|
|
|
const finalTieRows = computed(() => {
|
|
const ids = new Set(finalTie.value.playerIds || [])
|
|
const rows = finalRows.value.filter((row) => ids.has(row.playerId))
|
|
return sortRowsByBoardPosition('final_tiebreak', rows)
|
|
})
|
|
|
|
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,
|
|
}
|
|
})
|
|
return sortRowsByBoardPosition('preliminary', 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)
|
|
return sortRowsByBoardPosition('prelim_tiebreak', 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,
|
|
}
|
|
})
|
|
|
|
return sortRowsByBoardPosition('final', 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)
|
|
|
|
return sortRowsByBoardPosition('final_tiebreak', rows)
|
|
})
|
|
|
|
const stageOptions = computed(() => {
|
|
const preliminaryGroups = collectUniqueStrings([
|
|
...assignableGroups.value,
|
|
...activeGroupCodes.value,
|
|
])
|
|
const finalGroups = collectUniqueStrings([
|
|
...Object.keys(finalBoardGroupedRows.value || {}),
|
|
'1',
|
|
'2',
|
|
]).filter((value) => value === '1' || value === '2')
|
|
|
|
const prelimTieGroups = collectUniqueStrings((adminPrelimTieRows.value || []).map((row) => String(row._boardGroupKey || row.scoreGroup || '1')))
|
|
const finalTieGroups = collectUniqueStrings((adminFinalTieRows.value || []).map((row) => String(row._boardGroupKey || row.scoreGroup || '1')))
|
|
|
|
return {
|
|
preliminary: preliminaryGroups,
|
|
prelim_tiebreak: prelimTieGroups.length > 0 ? prelimTieGroups : ['1'],
|
|
final: finalGroups.length > 0 ? finalGroups : ['1', '2'],
|
|
final_tiebreak: finalTieGroups.length > 0 ? finalTieGroups : ['1'],
|
|
}
|
|
})
|
|
|
|
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()
|
|
},
|
|
)
|
|
|
|
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()
|
|
},
|
|
)
|
|
|
|
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 },
|
|
)
|
|
|
|
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 },
|
|
)
|
|
|
|
watch(
|
|
() => `${stageControl.phase}|${JSON.stringify(stageOptions.value)}`,
|
|
() => {
|
|
applyStageControlDefaults()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
onMounted(async () => {
|
|
onFullscreenChange()
|
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
|
document.addEventListener('webkitfullscreenchange', onFullscreenChange)
|
|
document.addEventListener('mozfullscreenchange', onFullscreenChange)
|
|
document.addEventListener('MSFullscreenChange', onFullscreenChange)
|
|
document.documentElement.lang = language.value
|
|
document.documentElement.dir = language.value === 'ar' ? 'rtl' : 'ltr'
|
|
await fetchState()
|
|
startRealtimeSync()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
|
document.removeEventListener('webkitfullscreenchange', onFullscreenChange)
|
|
document.removeEventListener('mozfullscreenchange', onFullscreenChange)
|
|
document.removeEventListener('MSFullscreenChange', onFullscreenChange)
|
|
stopLiveRotation()
|
|
stopLiveMonitorRotation()
|
|
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
|
|
}
|
|
|
|
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.
|
|
}
|
|
}
|
|
|
|
function normalizeLiveActiveView(value) {
|
|
const normalized = String(value || '').trim().toLowerCase()
|
|
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'final_overall', 'podium'])
|
|
return allowed.has(normalized) ? normalized : 'group_live'
|
|
}
|
|
|
|
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})`
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
function positionSlotFor(board, playerId) {
|
|
const boardMap = positionBoards.value?.[board] || {}
|
|
return boardMap[String(playerId)] || boardMap[playerId] || null
|
|
}
|
|
|
|
function normalizePositionGroup(board, raw, fallback = '') {
|
|
if (board === 'preliminary') {
|
|
const normalized = normalizedGroupCode(raw || '')
|
|
if (normalized) return normalized
|
|
if (String(raw || '').trim().toUpperCase() === 'UNASSIGNED') return 'UNASSIGNED'
|
|
return fallback || 'UNASSIGNED'
|
|
}
|
|
const parsed = Number.parseInt(String(raw || ''), 10)
|
|
if (Number.isFinite(parsed) && parsed > 0) return String(parsed)
|
|
return fallback || '1'
|
|
}
|
|
|
|
function normalizePositionValue(raw, fallback = 1) {
|
|
const parsed = Number(raw)
|
|
if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed)
|
|
return fallback
|
|
}
|
|
|
|
function positionGroupSortValue(board, groupKey) {
|
|
if (board === 'preliminary') {
|
|
if (groupKey === 'UNASSIGNED') return Number.MAX_SAFE_INTEGER - 1
|
|
const idx = assignableGroups.value.indexOf(groupKey)
|
|
if (idx >= 0) return idx
|
|
return Number.MAX_SAFE_INTEGER
|
|
}
|
|
return Number.parseInt(String(groupKey || '0'), 10) || Number.MAX_SAFE_INTEGER
|
|
}
|
|
|
|
function sortRowsByPreliminaryPosition(rows) {
|
|
return sortRowsByBoardPosition('preliminary', rows)
|
|
}
|
|
|
|
function sortRowsByBoardPosition(board, rows) {
|
|
const list = Array.isArray(rows) ? rows.slice() : []
|
|
if (list.length <= 1) return list
|
|
|
|
const fallbackRows = list.slice()
|
|
if (board === 'preliminary') {
|
|
fallbackRows.sort(compareRowsByGroupThenId)
|
|
} else if (board === 'final') {
|
|
fallbackRows.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 Number(a.playerId || a.id || 0) - Number(b.playerId || b.id || 0)
|
|
})
|
|
} else {
|
|
fallbackRows.sort(compareRowsByScoreGroupThenId)
|
|
}
|
|
|
|
const fallbackMap = new Map()
|
|
const fallbackCountByGroup = new Map()
|
|
for (let index = 0; index < fallbackRows.length; index += 1) {
|
|
const row = fallbackRows[index]
|
|
const playerId = Number(row.playerId || row.id || 0)
|
|
let fallbackGroup = '1'
|
|
if (board === 'preliminary') {
|
|
fallbackGroup = normalizePositionGroup(board, row.groupCode, 'UNASSIGNED')
|
|
} else if (board === 'final') {
|
|
fallbackGroup = normalizePositionGroup(board, row.finalGroup, '1')
|
|
} else if (board === 'prelim_tiebreak' || board === 'final_tiebreak') {
|
|
const scoreGroup = Number.parseInt(String(row.scoreGroup || ''), 10)
|
|
const finalGroup = Number.parseInt(String(row.finalGroup || ''), 10)
|
|
if (Number.isFinite(scoreGroup) && scoreGroup > 0) {
|
|
fallbackGroup = String(scoreGroup)
|
|
} else if (Number.isFinite(finalGroup) && finalGroup > 0) {
|
|
fallbackGroup = String(finalGroup)
|
|
} else {
|
|
fallbackGroup = '1'
|
|
}
|
|
} else {
|
|
fallbackGroup = '1'
|
|
}
|
|
const nextPos = (fallbackCountByGroup.get(fallbackGroup) || 0) + 1
|
|
fallbackCountByGroup.set(fallbackGroup, nextPos)
|
|
fallbackMap.set(playerId, { groupKey: fallbackGroup, position: nextPos, index })
|
|
}
|
|
|
|
return list
|
|
.map((row) => {
|
|
const playerId = Number(row.playerId || row.id || 0)
|
|
const fallback = fallbackMap.get(playerId) || { groupKey: '1', position: Number.MAX_SAFE_INTEGER, index: Number.MAX_SAFE_INTEGER }
|
|
const slot = positionSlotFor(board, playerId)
|
|
const groupKey = normalizePositionGroup(board, slot?.groupKey, fallback.groupKey)
|
|
const position = normalizePositionValue(slot?.position, fallback.position)
|
|
return { row, groupKey, position, fallbackIndex: fallback.index }
|
|
})
|
|
.sort((a, b) => {
|
|
const groupOrderA = positionGroupSortValue(board, a.groupKey)
|
|
const groupOrderB = positionGroupSortValue(board, b.groupKey)
|
|
if (groupOrderA !== groupOrderB) return groupOrderA - groupOrderB
|
|
if (a.groupKey !== b.groupKey) return String(a.groupKey).localeCompare(String(b.groupKey))
|
|
if (a.position !== b.position) return a.position - b.position
|
|
return a.fallbackIndex - b.fallbackIndex
|
|
})
|
|
.map((entry) => ({
|
|
...entry.row,
|
|
_boardGroupKey: entry.groupKey,
|
|
_boardPosition: entry.position,
|
|
}))
|
|
}
|
|
|
|
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 savePositionBoard(payload) {
|
|
const board = String(payload?.board || '').trim().toLowerCase()
|
|
if (!POSITION_BOARDS.includes(board)) return
|
|
const slots = Array.isArray(payload?.slots) ? payload.slots : []
|
|
|
|
try {
|
|
state.value = await api(`/admin/positions/${board}`, {
|
|
method: 'PUT',
|
|
admin: true,
|
|
body: { slots },
|
|
})
|
|
} catch (error) {
|
|
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
async function updateAdminSettings(payload) {
|
|
try {
|
|
state.value = await api('/admin/settings', {
|
|
method: 'PUT',
|
|
admin: true,
|
|
body: payload,
|
|
})
|
|
showToast(t('messages.saved'))
|
|
} catch (error) {
|
|
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
|
}
|
|
}
|
|
|
|
async function updateViewProofSetting(enabled) {
|
|
await updateAdminSettings({ viewProofInView: Boolean(enabled) })
|
|
}
|
|
|
|
async function updateLiveModeSetting(patch) {
|
|
if (!patch || typeof patch !== 'object') return
|
|
await updateAdminSettings({ liveMode: patch })
|
|
}
|
|
|
|
function collectUniqueStrings(values) {
|
|
const set = new Set()
|
|
for (const raw of values || []) {
|
|
const value = String(raw || '').trim()
|
|
if (!value) continue
|
|
set.add(value)
|
|
}
|
|
return [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
}
|
|
|
|
function stageRoundMax(phase) {
|
|
if (phase === 'preliminary') return 3
|
|
if (phase === 'final') return 2
|
|
return 1
|
|
}
|
|
|
|
function mapStageSessionToScoreStage(stage) {
|
|
if (!stage || typeof stage !== 'object') return ''
|
|
const phase = String(stage.phase || '').trim().toLowerCase()
|
|
const round = Number(stage.round || 1)
|
|
if (phase === 'preliminary') return `prelim_r${Math.min(3, Math.max(1, Math.floor(round || 1)))}`
|
|
if (phase === 'final') return `final_r${Math.min(2, Math.max(1, Math.floor(round || 1)))}`
|
|
if (phase === 'prelim_tiebreak') return 'prelim_tiebreak'
|
|
if (phase === 'final_tiebreak') return 'final_tiebreak'
|
|
return ''
|
|
}
|
|
|
|
function mapStageSessionToScoreGroup(stage) {
|
|
if (!stage || typeof stage !== 'object') return ''
|
|
const phase = String(stage.phase || '').trim().toLowerCase()
|
|
const rawGroup = String(stage.group || '').trim()
|
|
if (!rawGroup) return ''
|
|
if (phase === 'preliminary') {
|
|
if (rawGroup.toUpperCase() === 'UNASSIGNED') return 'UNASSIGNED'
|
|
return normalizedGroupCode(rawGroup)
|
|
}
|
|
const parsed = Number.parseInt(rawGroup, 10)
|
|
if (Number.isFinite(parsed) && parsed > 0) return String(parsed)
|
|
return rawGroup
|
|
}
|
|
|
|
function mapStagePhaseToAdminTab(phase) {
|
|
const normalized = String(phase || '').trim().toLowerCase()
|
|
if (normalized === 'preliminary') return 'preliminary'
|
|
if (normalized === 'prelim_tiebreak') return 'prelimTie'
|
|
if (normalized === 'final') return 'final'
|
|
if (normalized === 'final_tiebreak') return 'finalTie'
|
|
return ''
|
|
}
|
|
|
|
function applyStageControlDefaults() {
|
|
const phase = String(stageControl.phase || 'preliminary').trim().toLowerCase()
|
|
if (!['preliminary', 'prelim_tiebreak', 'final', 'final_tiebreak'].includes(phase)) {
|
|
stageControl.phase = 'preliminary'
|
|
} else {
|
|
stageControl.phase = phase
|
|
}
|
|
|
|
const maxRound = stageRoundMax(stageControl.phase)
|
|
const round = Number(stageControl.round || 1)
|
|
stageControl.round = Math.min(maxRound, Math.max(1, Number.isFinite(round) ? Math.floor(round) : 1))
|
|
|
|
const options = stageOptions.value?.[stageControl.phase] || []
|
|
if (options.length === 0) {
|
|
stageControl.group = ''
|
|
return
|
|
}
|
|
const current = String(stageControl.group || '').trim()
|
|
if (!options.includes(current)) {
|
|
stageControl.group = options[0]
|
|
}
|
|
}
|
|
|
|
function updateStageControl(patch) {
|
|
if (!patch || typeof patch !== 'object') return
|
|
if (Object.prototype.hasOwnProperty.call(patch, 'phase')) {
|
|
stageControl.phase = String(patch.phase || 'preliminary').trim().toLowerCase()
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(patch, 'group')) {
|
|
stageControl.group = String(patch.group || '').trim()
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(patch, 'round')) {
|
|
stageControl.round = Number(patch.round || 1)
|
|
}
|
|
applyStageControlDefaults()
|
|
}
|
|
|
|
async function startCurrentStage() {
|
|
applyStageControlDefaults()
|
|
if (!stageControl.group) {
|
|
showToast(t('messages.noGroupForPhase'), 'error')
|
|
return
|
|
}
|
|
try {
|
|
state.value = await api('/admin/stage/start', {
|
|
method: 'POST',
|
|
admin: true,
|
|
body: {
|
|
phase: stageControl.phase,
|
|
group: stageControl.group,
|
|
round: stageControl.round,
|
|
},
|
|
})
|
|
showToast(t('messages.saved'))
|
|
} catch (error) {
|
|
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
|
}
|
|
}
|
|
|
|
async function endCurrentStage() {
|
|
try {
|
|
state.value = await api('/admin/stage/end', {
|
|
method: 'POST',
|
|
admin: true,
|
|
})
|
|
showToast(t('messages.saved'))
|
|
} catch (error) {
|
|
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
|
}
|
|
}
|
|
|
|
function navigateToCurrentStage() {
|
|
const active = currentStage.value
|
|
if (!active) {
|
|
showToast(t('labels.noCurrentStage'), 'error')
|
|
return
|
|
}
|
|
const nextTab = mapStagePhaseToAdminTab(active.phase)
|
|
if (nextTab) {
|
|
adminTab.value = nextTab
|
|
}
|
|
}
|
|
|
|
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
|
|
const score = clampScoreValue(scoreAdviceModal.advice.advisedScore)
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
function scoreDraftKey(stage, playerId) {
|
|
return `${stage}:${playerId}`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function scoreInputValue(stage, playerId) {
|
|
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
|
|
if (draft) return draft.value
|
|
return String(scoreFor(stage, playerId))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
function onScoreFocus(stage, playerId) {
|
|
const key = scoreDraftKey(stage, playerId)
|
|
if (!scoreDrafts[key]) {
|
|
scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false, invalid: false }
|
|
}
|
|
}
|
|
|
|
function onScoreInput(stage, playerId, event) {
|
|
const key = scoreDraftKey(stage, playerId)
|
|
const raw = String(event?.target?.value ?? '')
|
|
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
|
|
}
|
|
|
|
async function onScoreCommit(stage, playerId) {
|
|
const key = scoreDraftKey(stage, playerId)
|
|
const draft = scoreDrafts[key]
|
|
if (!draft || draft.committing) return
|
|
|
|
const score = parseDraftScoreValue(draft.value)
|
|
const invalid = score < 0 || score > 100
|
|
|
|
if (invalid) {
|
|
scoreDrafts[key] = { ...draft, invalid: true }
|
|
showToast(t('messages.invalidScore'), 'error')
|
|
return
|
|
}
|
|
|
|
if (score === scoreFor(stage, playerId)) {
|
|
delete scoreDrafts[key]
|
|
return
|
|
}
|
|
|
|
scoreDrafts[key] = { ...draft, invalid: false, committing: true }
|
|
await updateScoreValue(stage, playerId, score, key)
|
|
}
|
|
|
|
async function updateScoreValue(stage, playerId, score, draftKey = '') {
|
|
if (score < 0 || score > 100) {
|
|
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
|
|
const targets = resetStageTargets(stage)
|
|
|
|
try {
|
|
state.value = await api(`/admin/scores/${stage}/reset`, {
|
|
method: 'POST',
|
|
admin: true,
|
|
body: { resetProofs: Boolean(resetProofs) },
|
|
})
|
|
for (const key of Object.keys(scoreDrafts)) {
|
|
if (targets.some((target) => key.startsWith(`${target}:`))) delete scoreDrafts[key]
|
|
}
|
|
if (resetProofs) {
|
|
for (const key of Object.keys(scoreAdviceCache)) {
|
|
if (targets.some((target) => key.startsWith(`${target}:`))) delete scoreAdviceCache[key]
|
|
}
|
|
}
|
|
closeResetStageModal()
|
|
showToast(t('messages.saved'))
|
|
} catch (error) {
|
|
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
|
}
|
|
}
|
|
|
|
function resetStageTargets(stage) {
|
|
if (stage === 'preliminary') return PRELIM_ROUND_STAGES
|
|
if (stage === 'final') return FINAL_ROUND_STAGES
|
|
return [stage]
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
function podiumScoreMain(entry) {
|
|
if (!podiumHasResult(entry)) return '-'
|
|
return `${t('table.score')}: ${entry.score}`
|
|
}
|
|
|
|
function podiumTieSubtitle(entry) {
|
|
if (!podiumHasResult(entry)) return ''
|
|
if (!finalTie.value.required) return ''
|
|
return `${t('table.tieScore')}: ${entry.tieBreak}`
|
|
}
|
|
</script>
|