1196 lines
36 KiB
Vue
1196 lines
36 KiB
Vue
|
|
<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"
|
||
|
|
:server-time="state.serverTime ? formatServerTime(state.serverTime) : ''"
|
||
|
|
@change-mode="mode = $event"
|
||
|
|
@change-language="setLanguage"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<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"
|
||
|
|
:score-for="scoreFor"
|
||
|
|
:podium-ordered="podiumOrdered"
|
||
|
|
:podium-image="podiumImage"
|
||
|
|
:podium-name="podiumName"
|
||
|
|
:podium-has-result="podiumHasResult"
|
||
|
|
:podium-score-display="podiumScoreDisplay"
|
||
|
|
: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"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<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"
|
||
|
|
: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-rows="preliminaryRows"
|
||
|
|
:prelim-tie-rows="prelimTieRows"
|
||
|
|
:final-group1="finalGroup1"
|
||
|
|
:final-group2="finalGroup2"
|
||
|
|
:final-tie-rows="finalTieRows"
|
||
|
|
:final-rows="finalRows"
|
||
|
|
:prelim-tie="prelimTie"
|
||
|
|
:final-tie="finalTie"
|
||
|
|
:score-filters="scoreFilters"
|
||
|
|
:display-name="displayName"
|
||
|
|
:secondary-name="secondaryName"
|
||
|
|
:score-input-value="scoreInputValue"
|
||
|
|
: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"
|
||
|
|
@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'
|
||
|
|
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 loading = ref(true)
|
||
|
|
const mode = ref('view')
|
||
|
|
const viewTab = ref('groups')
|
||
|
|
const adminTab = ref('players')
|
||
|
|
const language = ref(localStorage.getItem('shooting_lang') === 'en' ? 'en' : 'ar')
|
||
|
|
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: '',
|
||
|
|
prelim_tiebreak: '',
|
||
|
|
final: '',
|
||
|
|
final_tiebreak: '',
|
||
|
|
})
|
||
|
|
|
||
|
|
const liveGroupIndex = ref(0)
|
||
|
|
const liveMode = ref('rotate')
|
||
|
|
const selectedLiveGroup = ref('')
|
||
|
|
const liveTick = ref(0)
|
||
|
|
let liveTimer = 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 canViewProofs = computed(() => Boolean(state.value.settings?.viewProofInView))
|
||
|
|
|
||
|
|
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: '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)
|
||
|
|
})
|
||
|
|
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))
|
||
|
|
})
|
||
|
|
|
||
|
|
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(
|
||
|
|
() => 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 },
|
||
|
|
)
|
||
|
|
|
||
|
|
onMounted(async () => {
|
||
|
|
document.documentElement.lang = language.value
|
||
|
|
document.documentElement.dir = language.value === 'ar' ? 'rtl' : 'ltr'
|
||
|
|
await fetchState()
|
||
|
|
startRealtimeSync()
|
||
|
|
})
|
||
|
|
|
||
|
|
onBeforeUnmount(() => {
|
||
|
|
stopLiveRotation()
|
||
|
|
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 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 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)
|
||
|
|
}
|
||
|
|
|
||
|
|
async function updateViewProofSetting(enabled) {
|
||
|
|
try {
|
||
|
|
state.value = await api('/admin/settings', {
|
||
|
|
method: 'PUT',
|
||
|
|
admin: true,
|
||
|
|
body: { viewProofInView: Boolean(enabled) },
|
||
|
|
})
|
||
|
|
showToast(t('messages.saved'))
|
||
|
|
} catch (error) {
|
||
|
|
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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 = Number(scoreAdviceModal.advice.advisedScore || 0)
|
||
|
|
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 scoreDraftKey(stage, playerId) {
|
||
|
|
return `${stage}:${playerId}`
|
||
|
|
}
|
||
|
|
|
||
|
|
function scoreInputValue(stage, playerId) {
|
||
|
|
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
|
||
|
|
if (draft) return draft.value
|
||
|
|
return String(scoreFor(stage, playerId))
|
||
|
|
}
|
||
|
|
|
||
|
|
function onScoreFocus(stage, playerId) {
|
||
|
|
const key = scoreDraftKey(stage, playerId)
|
||
|
|
if (!scoreDrafts[key]) {
|
||
|
|
scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function onScoreInput(stage, playerId, event) {
|
||
|
|
const key = scoreDraftKey(stage, playerId)
|
||
|
|
const raw = String(event?.target?.value ?? '')
|
||
|
|
const cleaned = raw.replace(/[^\d]/g, '').slice(0, 4)
|
||
|
|
const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false }
|
||
|
|
scoreDrafts[key] = { ...current, value: cleaned }
|
||
|
|
if (event?.target && event.target.value !== cleaned) event.target.value = cleaned
|
||
|
|
}
|
||
|
|
|
||
|
|
async function onScoreCommit(stage, playerId) {
|
||
|
|
const key = scoreDraftKey(stage, playerId)
|
||
|
|
const draft = scoreDrafts[key]
|
||
|
|
if (!draft || draft.committing) return
|
||
|
|
|
||
|
|
let score = Number(draft.value === '' ? 0 : draft.value)
|
||
|
|
if (!Number.isFinite(score)) score = 0
|
||
|
|
score = Math.trunc(score)
|
||
|
|
|
||
|
|
if (score < 0 || score > 9999) {
|
||
|
|
showToast(t('messages.invalidScore'), 'error')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (score === scoreFor(stage, playerId)) {
|
||
|
|
delete scoreDrafts[key]
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
scoreDrafts[key] = { ...draft, committing: true }
|
||
|
|
await updateScoreValue(stage, playerId, score, key)
|
||
|
|
}
|
||
|
|
|
||
|
|
async function updateScoreValue(stage, playerId, score, draftKey = '') {
|
||
|
|
if (score < 0 || score > 9999) {
|
||
|
|
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
|
||
|
|
|
||
|
|
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 (key.startsWith(`${stage}:`)) delete scoreDrafts[key]
|
||
|
|
}
|
||
|
|
if (resetProofs) {
|
||
|
|
for (const key of Object.keys(scoreAdviceCache)) {
|
||
|
|
if (key.startsWith(`${stage}:`)) delete scoreAdviceCache[key]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
closeResetStageModal()
|
||
|
|
showToast(t('messages.saved'))
|
||
|
|
} catch (error) {
|
||
|
|
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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 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 podiumScoreDisplay(entry) {
|
||
|
|
if (!podiumHasResult(entry)) return '-'
|
||
|
|
return podiumScoreText(entry)
|
||
|
|
}
|
||
|
|
|
||
|
|
function podiumScoreText(entry) {
|
||
|
|
if (!entry || entry.score === 0) return '—'
|
||
|
|
const scoreText = `${t('table.score')}: ${entry.score}`
|
||
|
|
if (!finalTie.value.required) return scoreText
|
||
|
|
return `${scoreText} · ${t('table.tieScore')}: ${entry.tieBreak}`
|
||
|
|
}
|
||
|
|
</script>
|