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

1196 lines
36 KiB
Vue
Raw Normal View History

2026-04-01 11:47:03 +04:00
<template>
<div class="app-shell" :class="'lang-' + language">
<input ref="imageInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onImageSelected" />
<input ref="scoreProofInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onScoreProofSelected" />
<AppHeader
:t="t"
:competition-title="competitionTitle"
:mode="mode"
:language="language"
: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>