This commit is contained in:
2026-05-18 13:22:11 +04:00
parent 8cda54548f
commit 5863574a78
24 changed files with 3060 additions and 467 deletions

View File

@@ -1,5 +1,5 @@
<template>
<div class="app-shell" :class="'lang-' + language">
<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" />
@@ -71,6 +71,7 @@
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"
@@ -78,6 +79,7 @@
:finalists="finalists"
:live-final-group="liveMonitorFinalGroup"
:live-final-rows="liveMonitorFinalRows"
:final-overall-rows="finalRows"
:final-tie-rows="finalTieRows"
:podium-ordered="podiumOrdered"
:player-image="playerImage"
@@ -112,6 +114,12 @@
: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"
@@ -131,6 +139,7 @@
:final-total-score="finalTotalScore"
:prelim-tie="prelimTie"
:final-tie="finalTie"
:position-boards="positionBoards"
:score-filters="scoreFilters"
:display-name="displayName"
:secondary-name="secondaryName"
@@ -149,6 +158,10 @@
@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"
@@ -165,6 +178,7 @@
@remove-player-image="removePlayerImage"
@delete-player="deletePlayer"
@request-reset-stage="openResetStageModal"
@save-position-board="savePositionBoard"
@update-score-filter="updateScoreFilter"
/>
</template>
@@ -219,6 +233,7 @@ 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')
@@ -247,6 +262,11 @@ const scoreFilters = reactive({
prelim_tiebreak: '',
final_tiebreak: '',
})
const stageControl = reactive({
phase: 'preliminary',
group: '',
round: 1,
})
const liveGroupIndex = ref(0)
const liveMode = ref('rotate')
@@ -298,6 +318,14 @@ 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 || {}
@@ -320,6 +348,10 @@ const liveSettings = computed(() => {
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') },
@@ -409,7 +441,8 @@ const liveGroupCode = computed(() => {
const liveMembers = computed(() => {
const code = normalizedGroupCode(liveGroupCode.value)
if (!code) return []
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
return sortRowsByPreliminaryPosition(rows)
})
const liveMonitorGroupRotationCodes = computed(() => activeGroupCodes.value)
@@ -424,7 +457,8 @@ const liveMonitorGroupCode = computed(() => {
const liveMonitorMembers = computed(() => {
const code = normalizedGroupCode(liveMonitorGroupCode.value)
if (!code) return []
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
return sortRowsByPreliminaryPosition(rows)
})
const liveMonitorFinalGroup = computed(() => {
if (liveSettings.value.finalDisplayMode === 'fixed') {
@@ -432,17 +466,32 @@ const liveMonitorFinalGroup = computed(() => {
}
return liveMonitorFinalIndex.value % 2 === 0 ? 1 : 2
})
const liveMonitorFinalRows = computed(() => (liveMonitorFinalGroup.value === 2 ? finalGroup2.value : finalGroup1.value))
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 || [])
return preliminaryRows.value.filter((row) => ids.has(row.playerId))
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 || [])
return finalRows.value.filter((row) => ids.has(row.playerId))
const rows = finalRows.value.filter((row) => ids.has(row.playerId))
return sortRowsByBoardPosition('final_tiebreak', rows)
})
const playerByID = computed(() => {
@@ -483,8 +532,7 @@ const adminPreliminaryRows = computed(() => {
rank: derived?.rank ?? 0,
}
})
rows.sort(compareRowsByGroupThenId)
return rows
return sortRowsByBoardPosition('preliminary', rows)
})
const adminPrelimTieRows = computed(() => {
@@ -504,8 +552,7 @@ const adminPrelimTieRows = computed(() => {
}
})
.filter(Boolean)
rows.sort(compareRowsByScoreGroupThenId)
return rows
return sortRowsByBoardPosition('prelim_tiebreak', rows)
})
const adminFinalRows = computed(() => {
@@ -525,17 +572,7 @@ const adminFinalRows = computed(() => {
}
})
rows.sort((a, b) => {
const groupA = Number(a.finalGroup || 0)
const groupB = Number(b.finalGroup || 0)
if (groupA !== groupB) return groupA - groupB
const seedA = Number(a.seed || 0)
const seedB = Number(b.seed || 0)
if (seedA !== seedB) return seedA - seedB
return a.playerId - b.playerId
})
return rows
return sortRowsByBoardPosition('final', rows)
})
const adminFinalTieRows = computed(() => {
@@ -558,8 +595,29 @@ const adminFinalTieRows = computed(() => {
})
.filter(Boolean)
rows.sort(compareRowsByScoreGroupThenId)
return rows
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) => {
@@ -643,6 +701,14 @@ watch(
{ immediate: true },
)
watch(
() => `${stageControl.phase}|${JSON.stringify(stageOptions.value)}`,
() => {
applyStageControlDefaults()
},
{ immediate: true },
)
onMounted(async () => {
onFullscreenChange()
document.addEventListener('fullscreenchange', onFullscreenChange)
@@ -728,7 +794,7 @@ async function toggleFullscreen() {
function normalizeLiveActiveView(value) {
const normalized = String(value || '').trim().toLowerCase()
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'podium'])
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'final_overall', 'podium'])
return allowed.has(normalized) ? normalized : 'group_live'
}
@@ -788,6 +854,116 @@ function compareRowsByScoreGroupThenId(a, b) {
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
@@ -829,6 +1005,22 @@ function updateScoreFilter({ stage, value }) {
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')
@@ -990,6 +1182,139 @@ async function updateLiveModeSetting(patch) {
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')