final
This commit is contained in:
@@ -47,12 +47,15 @@
|
||||
:final-group2="finalGroup2"
|
||||
:final-rows="finalRows"
|
||||
:final-tie="finalTie"
|
||||
:score-for="scoreFor"
|
||||
:final-total-score="finalTotalScore"
|
||||
:preliminary-proof-stage="preliminaryProofStage"
|
||||
:final-proof-stage="finalProofStage"
|
||||
:podium-ordered="podiumOrdered"
|
||||
:podium-image="podiumImage"
|
||||
:podium-name="podiumName"
|
||||
:podium-has-result="podiumHasResult"
|
||||
:podium-score-display="podiumScoreDisplay"
|
||||
:podium-score-main="podiumScoreMain"
|
||||
:podium-tie-subtitle="podiumTieSubtitle"
|
||||
:can-view-proofs="canViewProofs"
|
||||
:has-score-proof="hasScoreProof"
|
||||
:score-proof-for="scoreProofFor"
|
||||
@@ -62,6 +65,32 @@
|
||||
@open-proof="onOpenProofFromPayload"
|
||||
/>
|
||||
|
||||
<LiveModePanel
|
||||
v-else-if="mode === 'live'"
|
||||
:t="t"
|
||||
:live-settings="liveSettings"
|
||||
:live-group-code="liveMonitorGroupCode"
|
||||
:live-group-members="liveMonitorMembers"
|
||||
:prelim-tie-rows="prelimTieRows"
|
||||
:preliminary-rows="preliminaryRows"
|
||||
:finalists="finalists"
|
||||
:live-final-group="liveMonitorFinalGroup"
|
||||
:live-final-rows="liveMonitorFinalRows"
|
||||
:final-tie-rows="finalTieRows"
|
||||
:podium-ordered="podiumOrdered"
|
||||
:player-image="playerImage"
|
||||
:display-name="displayName"
|
||||
:secondary-name="secondaryName"
|
||||
:score-for="scoreFor"
|
||||
:prelim-total="preliminaryTotalScore"
|
||||
:final-total="finalTotalScore"
|
||||
:podium-image="podiumImage"
|
||||
:podium-name="podiumName"
|
||||
:podium-has-result="podiumHasResult"
|
||||
:podium-score-main="podiumScoreMain"
|
||||
:podium-tie-subtitle="podiumTieSubtitle"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<AdminLoginPanel
|
||||
v-if="!adminToken"
|
||||
@@ -80,6 +109,7 @@
|
||||
:admin-tabs="adminTabs"
|
||||
:admin-tab="adminTab"
|
||||
:view-proof-in-view="canViewProofs"
|
||||
:live-settings="liveSettings"
|
||||
:group-setup-input="groupSetupInput"
|
||||
:admin-group-cards="adminGroupCards"
|
||||
:new-player="newPlayer"
|
||||
@@ -90,12 +120,13 @@
|
||||
: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"
|
||||
:preliminary-score-rows="adminPreliminaryRows"
|
||||
:prelim-tie-score-rows="adminPrelimTieRows"
|
||||
:final-score-rows="adminFinalRows"
|
||||
:final-tie-score-rows="adminFinalTieRows"
|
||||
:final-rows="finalRows"
|
||||
:preliminary-total-score="preliminaryTotalScore"
|
||||
:final-total-score="finalTotalScore"
|
||||
:prelim-tie="prelimTie"
|
||||
:final-tie="finalTie"
|
||||
:score-filters="scoreFilters"
|
||||
@@ -114,6 +145,7 @@
|
||||
@refresh="fetchState()"
|
||||
@logout="adminLogout"
|
||||
@toggle-view-proof="updateViewProofSetting"
|
||||
@update-live-setting="updateLiveModeSetting"
|
||||
@change-admin-tab="adminTab = $event"
|
||||
@update:group-setup-input="groupSetupInput = $event"
|
||||
@save-group-setup="saveGroupSetup"
|
||||
@@ -168,6 +200,7 @@
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import AppHeader from './components/AppHeader.vue'
|
||||
import ViewModePanel from './components/ViewModePanel.vue'
|
||||
import LiveModePanel from './components/LiveModePanel.vue'
|
||||
import AdminLoginPanel from './components/AdminLoginPanel.vue'
|
||||
import AdminPanel from './components/AdminPanel.vue'
|
||||
import ProofModal from './components/ProofModal.vue'
|
||||
@@ -181,9 +214,11 @@ import { convertNameAuto } from './utils/nameTransliteration'
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
|
||||
const FALLBACK_SYNC_MS = 15000
|
||||
const STREAM_RETRY_MS = 1500
|
||||
const PRELIM_ROUND_STAGES = ['prelim_r1', 'prelim_r2', 'prelim_r3']
|
||||
const FINAL_ROUND_STAGES = ['final_r1', 'final_r2']
|
||||
|
||||
const loading = ref(true)
|
||||
const mode = ref('view')
|
||||
const mode = ref('live')
|
||||
const viewTab = ref('groups')
|
||||
const adminTab = ref('players')
|
||||
const language = ref(localStorage.getItem('shooting_lang') === 'en' ? 'en' : 'ar')
|
||||
@@ -204,8 +239,8 @@ const playerFilter = ref('')
|
||||
const playerSort = ref('id')
|
||||
const scoreFilters = reactive({
|
||||
preliminary: '',
|
||||
final_common: '',
|
||||
prelim_tiebreak: '',
|
||||
final: '',
|
||||
final_tiebreak: '',
|
||||
})
|
||||
|
||||
@@ -214,6 +249,10 @@ const liveMode = ref('rotate')
|
||||
const selectedLiveGroup = ref('')
|
||||
const liveTick = ref(0)
|
||||
let liveTimer = null
|
||||
|
||||
const liveMonitorGroupIndex = ref(0)
|
||||
const liveMonitorFinalIndex = ref(0)
|
||||
let liveMonitorTimer = null
|
||||
let syncTimer = null
|
||||
let stream = null
|
||||
let streamRetryTimer = null
|
||||
@@ -256,6 +295,27 @@ 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 liveSettings = computed(() => {
|
||||
const raw = state.value.settings?.liveMode || {}
|
||||
const groupDisplayMode = raw.groupDisplayMode === 'fixed' ? 'fixed' : 'rotate'
|
||||
const finalDisplayMode = raw.finalDisplayMode === 'fixed' ? 'fixed' : 'rotate'
|
||||
const activeView = normalizeLiveActiveView(raw.activeView || 'group_live')
|
||||
return {
|
||||
activeView,
|
||||
showGroupLive: raw.showGroupLive !== false,
|
||||
groupDisplayMode,
|
||||
groupFixedCode: normalizedGroupCode(raw.groupFixedCode || ''),
|
||||
showPrelimTie: raw.showPrelimTie !== false,
|
||||
showPrelimOverall: raw.showPrelimOverall !== false,
|
||||
showFinalGroups: raw.showFinalGroups !== false,
|
||||
finalDisplayMode,
|
||||
finalFixedGroup: Number(raw.finalFixedGroup) === 2 ? 2 : 1,
|
||||
showFinalTie: raw.showFinalTie !== false,
|
||||
showPodium: raw.showPodium !== false,
|
||||
rotationIntervalSec: Number(raw.rotationIntervalSec) > 0 ? Number(raw.rotationIntervalSec) : 5,
|
||||
rotationPlayerCount: Number(raw.rotationPlayerCount) > 0 ? Number(raw.rotationPlayerCount) : 12,
|
||||
}
|
||||
})
|
||||
|
||||
const viewTabs = computed(() => [
|
||||
{ id: 'groups', label: t('tabs.groups') },
|
||||
@@ -267,6 +327,7 @@ const viewTabs = computed(() => [
|
||||
|
||||
const adminTabs = computed(() => [
|
||||
{ id: 'players', label: t('tabs.players') },
|
||||
{ id: 'liveMode', label: t('tabs.liveModeAdmin') },
|
||||
{ id: 'preliminary', label: t('tabs.preliminary') },
|
||||
{ id: 'prelimTie', label: t('tabs.prelimTie') },
|
||||
{ id: 'final', label: t('tabs.final') },
|
||||
@@ -346,6 +407,28 @@ const liveMembers = computed(() => {
|
||||
if (!code) return []
|
||||
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
||||
})
|
||||
|
||||
const liveMonitorGroupRotationCodes = computed(() => activeGroupCodes.value)
|
||||
const liveMonitorGroupCode = computed(() => {
|
||||
if (liveSettings.value.groupDisplayMode === 'fixed') {
|
||||
const fixed = normalizedGroupCode(liveSettings.value.groupFixedCode)
|
||||
if (fixed) return fixed
|
||||
return liveMonitorGroupRotationCodes.value[0] || ''
|
||||
}
|
||||
return liveMonitorGroupRotationCodes.value[liveMonitorGroupIndex.value] || ''
|
||||
})
|
||||
const liveMonitorMembers = computed(() => {
|
||||
const code = normalizedGroupCode(liveMonitorGroupCode.value)
|
||||
if (!code) return []
|
||||
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
||||
})
|
||||
const liveMonitorFinalGroup = computed(() => {
|
||||
if (liveSettings.value.finalDisplayMode === 'fixed') {
|
||||
return liveSettings.value.finalFixedGroup === 2 ? 2 : 1
|
||||
}
|
||||
return liveMonitorFinalIndex.value % 2 === 0 ? 1 : 2
|
||||
})
|
||||
const liveMonitorFinalRows = computed(() => (liveMonitorFinalGroup.value === 2 ? finalGroup2.value : finalGroup1.value))
|
||||
const podiumOrdered = computed(() => [podiumRows.value[1] || null, podiumRows.value[0] || null, podiumRows.value[2] || null])
|
||||
|
||||
const prelimTieRows = computed(() => {
|
||||
@@ -358,6 +441,123 @@ const finalTieRows = computed(() => {
|
||||
return finalRows.value.filter((row) => ids.has(row.playerId))
|
||||
})
|
||||
|
||||
const playerByID = computed(() => {
|
||||
const map = new Map()
|
||||
for (const player of playersSorted.value) {
|
||||
map.set(player.id, player)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const preliminaryRowByID = computed(() => {
|
||||
const map = new Map()
|
||||
for (const row of preliminaryRows.value) {
|
||||
map.set(row.playerId, row)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const finalRowByID = computed(() => {
|
||||
const map = new Map()
|
||||
for (const row of finalRows.value) {
|
||||
map.set(row.playerId, row)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const adminPreliminaryRows = computed(() => {
|
||||
const rows = playersSorted.value.map((player) => {
|
||||
const derived = preliminaryRowByID.value.get(player.id)
|
||||
return {
|
||||
playerId: player.id,
|
||||
nameAr: player.nameAr,
|
||||
nameEn: player.nameEn,
|
||||
groupCode: player.groupCode,
|
||||
imageData: player.imageData,
|
||||
score: derived?.score ?? preliminaryTotalScore(player.id),
|
||||
tieBreak: derived?.tieBreak ?? 0,
|
||||
rank: derived?.rank ?? 0,
|
||||
}
|
||||
})
|
||||
rows.sort(compareRowsByGroupThenId)
|
||||
return rows
|
||||
})
|
||||
|
||||
const adminPrelimTieRows = computed(() => {
|
||||
const rows = (prelimTie.value.playerIds || [])
|
||||
.map((playerId) => {
|
||||
const player = playerByID.value.get(playerId)
|
||||
if (!player) return null
|
||||
const derived = preliminaryRowByID.value.get(playerId)
|
||||
return {
|
||||
playerId,
|
||||
nameAr: player.nameAr,
|
||||
nameEn: player.nameEn,
|
||||
groupCode: player.groupCode,
|
||||
imageData: player.imageData,
|
||||
score: derived?.score ?? preliminaryTotalScore(playerId),
|
||||
tieBreak: derived?.tieBreak ?? 0,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
rows.sort(compareRowsByScoreGroupThenId)
|
||||
return rows
|
||||
})
|
||||
|
||||
const adminFinalRows = computed(() => {
|
||||
const rows = [...finalGroup1.value, ...finalGroup2.value].map((row) => {
|
||||
const player = playerByID.value.get(row.playerId)
|
||||
const ranked = finalRowByID.value.get(row.playerId)
|
||||
return {
|
||||
playerId: row.playerId,
|
||||
nameAr: player?.nameAr || row.nameAr,
|
||||
nameEn: player?.nameEn || row.nameEn,
|
||||
groupCode: player?.groupCode || row.groupCode,
|
||||
imageData: player?.imageData || row.imageData,
|
||||
finalGroup: row.finalGroup,
|
||||
seed: row.seed,
|
||||
score: ranked?.score ?? row.score ?? 0,
|
||||
tieBreak: ranked?.tieBreak ?? row.tieBreak ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const groupA = Number(a.finalGroup || 0)
|
||||
const groupB = Number(b.finalGroup || 0)
|
||||
if (groupA !== groupB) return groupA - groupB
|
||||
const seedA = Number(a.seed || 0)
|
||||
const seedB = Number(b.seed || 0)
|
||||
if (seedA !== seedB) return seedA - seedB
|
||||
return a.playerId - b.playerId
|
||||
})
|
||||
|
||||
return rows
|
||||
})
|
||||
|
||||
const adminFinalTieRows = computed(() => {
|
||||
const rows = (finalTie.value.playerIds || [])
|
||||
.map((playerId) => {
|
||||
const player = playerByID.value.get(playerId)
|
||||
if (!player) return null
|
||||
const derived = finalRowByID.value.get(playerId)
|
||||
return {
|
||||
playerId,
|
||||
nameAr: player.nameAr,
|
||||
nameEn: player.nameEn,
|
||||
groupCode: player.groupCode,
|
||||
imageData: player.imageData,
|
||||
finalGroup: derived?.finalGroup ?? 0,
|
||||
seed: derived?.seed ?? 0,
|
||||
score: derived?.score ?? finalTotalScore(playerId),
|
||||
tieBreak: derived?.tieBreak ?? 0,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
rows.sort(compareRowsByScoreGroupThenId)
|
||||
return rows
|
||||
})
|
||||
|
||||
watch(language, (value) => {
|
||||
localStorage.setItem('shooting_lang', value)
|
||||
document.documentElement.lang = value
|
||||
@@ -375,6 +575,18 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() =>
|
||||
`${mode.value}|${liveSettings.value.activeView}|${liveSettings.value.groupDisplayMode}|${liveSettings.value.finalDisplayMode}|${liveSettings.value.rotationIntervalSec}|${liveMonitorGroupRotationCodes.value.join('|')}|${finalGroup1.value.length}|${finalGroup2.value.length}`,
|
||||
() => {
|
||||
if (mode.value === 'live') {
|
||||
startLiveMonitorRotation()
|
||||
return
|
||||
}
|
||||
stopLiveMonitorRotation()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => liveSelectableGroups.value.join('|'),
|
||||
() => {
|
||||
@@ -403,6 +615,30 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => liveMonitorGroupRotationCodes.value.join('|'),
|
||||
() => {
|
||||
if (liveMonitorGroupRotationCodes.value.length === 0) {
|
||||
liveMonitorGroupIndex.value = 0
|
||||
return
|
||||
}
|
||||
if (liveMonitorGroupIndex.value >= liveMonitorGroupRotationCodes.value.length) {
|
||||
liveMonitorGroupIndex.value = 0
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => `${finalGroup1.value.length}|${finalGroup2.value.length}`,
|
||||
() => {
|
||||
if (liveMonitorFinalIndex.value < 0) {
|
||||
liveMonitorFinalIndex.value = 0
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
document.documentElement.lang = language.value
|
||||
document.documentElement.dir = language.value === 'ar' ? 'rtl' : 'ltr'
|
||||
@@ -412,6 +648,7 @@ onMounted(async () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopLiveRotation()
|
||||
stopLiveMonitorRotation()
|
||||
stopRealtimeSync()
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
})
|
||||
@@ -425,6 +662,12 @@ function setLanguage(next) {
|
||||
if (next === 'ar' || next === 'en') language.value = next
|
||||
}
|
||||
|
||||
function normalizeLiveActiveView(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'podium'])
|
||||
return allowed.has(normalized) ? normalized : 'group_live'
|
||||
}
|
||||
|
||||
function formatServerTime(iso) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(language.value === 'ar' ? 'ar-AE' : 'en-US', {
|
||||
@@ -448,6 +691,39 @@ function groupOptionLabel(code) {
|
||||
return `${normalized} (${count})`
|
||||
}
|
||||
|
||||
function groupOrderValue(code) {
|
||||
const normalized = normalizedGroupCode(code)
|
||||
if (!normalized) return Number.MAX_SAFE_INTEGER - 1
|
||||
const idx = assignableGroups.value.indexOf(normalized)
|
||||
if (idx >= 0) return idx
|
||||
return Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
function compareRowsByGroupThenId(a, b) {
|
||||
const orderA = groupOrderValue(a.groupCode)
|
||||
const orderB = groupOrderValue(b.groupCode)
|
||||
if (orderA !== orderB) return orderA - orderB
|
||||
|
||||
const groupA = normalizedGroupCode(a.groupCode)
|
||||
const groupB = normalizedGroupCode(b.groupCode)
|
||||
if (groupA !== groupB) {
|
||||
if (!groupA) return 1
|
||||
if (!groupB) return -1
|
||||
return groupA.localeCompare(groupB)
|
||||
}
|
||||
|
||||
const idA = Number(a.playerId || a.id || 0)
|
||||
const idB = Number(b.playerId || b.id || 0)
|
||||
return idA - idB
|
||||
}
|
||||
|
||||
function compareRowsByScoreGroupThenId(a, b) {
|
||||
const scoreA = Number(a.score || 0)
|
||||
const scoreB = Number(b.score || 0)
|
||||
if (scoreA !== scoreB) return scoreB - scoreA
|
||||
return compareRowsByGroupThenId(a, b)
|
||||
}
|
||||
|
||||
function saveGroupSetup() {
|
||||
const parsed = parseGroupList(groupSetupInput.value)
|
||||
primaryGroups.value = parsed.length > 0 ? parsed : DEFAULT_PRIMARY_GROUPS
|
||||
@@ -628,12 +904,12 @@ async function adminLogout() {
|
||||
await fetchState(false)
|
||||
}
|
||||
|
||||
async function updateViewProofSetting(enabled) {
|
||||
async function updateAdminSettings(payload) {
|
||||
try {
|
||||
state.value = await api('/admin/settings', {
|
||||
method: 'PUT',
|
||||
admin: true,
|
||||
body: { viewProofInView: Boolean(enabled) },
|
||||
body: payload,
|
||||
})
|
||||
showToast(t('messages.saved'))
|
||||
} catch (error) {
|
||||
@@ -641,6 +917,15 @@ async function updateViewProofSetting(enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateViewProofSetting(enabled) {
|
||||
await updateAdminSettings({ viewProofInView: Boolean(enabled) })
|
||||
}
|
||||
|
||||
async function updateLiveModeSetting(patch) {
|
||||
if (!patch || typeof patch !== 'object') return
|
||||
await updateAdminSettings({ liveMode: patch })
|
||||
}
|
||||
|
||||
async function createPlayer() {
|
||||
if (!newPlayer.nameAr || !newPlayer.nameEn) {
|
||||
showToast(t('messages.mustProvideNames'), 'error')
|
||||
@@ -971,6 +1256,33 @@ function scoreFor(stage, playerId) {
|
||||
return state.value.scores?.[stage]?.[String(playerId)] ?? 0
|
||||
}
|
||||
|
||||
function totalScoreByStages(stages, playerId) {
|
||||
return stages.reduce((sum, stage) => sum + Number(scoreFor(stage, playerId) || 0), 0)
|
||||
}
|
||||
|
||||
function preliminaryTotalScore(playerId) {
|
||||
return totalScoreByStages(PRELIM_ROUND_STAGES, playerId)
|
||||
}
|
||||
|
||||
function finalTotalScore(playerId) {
|
||||
return totalScoreByStages(FINAL_ROUND_STAGES, playerId)
|
||||
}
|
||||
|
||||
function proofStageByFamily(stages, playerId) {
|
||||
for (const stage of stages) {
|
||||
if (hasScoreProof(stage, playerId)) return stage
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function preliminaryProofStage(playerId) {
|
||||
return proofStageByFamily([...PRELIM_ROUND_STAGES].reverse(), playerId)
|
||||
}
|
||||
|
||||
function finalProofStage(playerId) {
|
||||
return proofStageByFamily([...FINAL_ROUND_STAGES].reverse(), playerId)
|
||||
}
|
||||
|
||||
function scoreDraftKey(stage, playerId) {
|
||||
return `${stage}:${playerId}`
|
||||
}
|
||||
@@ -1056,6 +1368,7 @@ function closeResetStageModal() {
|
||||
async function confirmResetStage(resetProofs) {
|
||||
const stage = resetModal.stage
|
||||
if (!stage) return
|
||||
const targets = resetStageTargets(stage)
|
||||
|
||||
try {
|
||||
state.value = await api(`/admin/scores/${stage}/reset`, {
|
||||
@@ -1064,11 +1377,11 @@ async function confirmResetStage(resetProofs) {
|
||||
body: { resetProofs: Boolean(resetProofs) },
|
||||
})
|
||||
for (const key of Object.keys(scoreDrafts)) {
|
||||
if (key.startsWith(`${stage}:`)) delete scoreDrafts[key]
|
||||
if (targets.some((target) => key.startsWith(`${target}:`))) delete scoreDrafts[key]
|
||||
}
|
||||
if (resetProofs) {
|
||||
for (const key of Object.keys(scoreAdviceCache)) {
|
||||
if (key.startsWith(`${stage}:`)) delete scoreAdviceCache[key]
|
||||
if (targets.some((target) => key.startsWith(`${target}:`))) delete scoreAdviceCache[key]
|
||||
}
|
||||
}
|
||||
closeResetStageModal()
|
||||
@@ -1078,6 +1391,12 @@ async function confirmResetStage(resetProofs) {
|
||||
}
|
||||
}
|
||||
|
||||
function resetStageTargets(stage) {
|
||||
if (stage === 'preliminary') return PRELIM_ROUND_STAGES
|
||||
if (stage === 'final') return FINAL_ROUND_STAGES
|
||||
return [stage]
|
||||
}
|
||||
|
||||
function isFinalist(playerId) {
|
||||
return finalists.value.some((row) => row.playerId === playerId)
|
||||
}
|
||||
@@ -1104,6 +1423,36 @@ function stopLiveRotation() {
|
||||
}
|
||||
}
|
||||
|
||||
function startLiveMonitorRotation() {
|
||||
stopLiveMonitorRotation()
|
||||
|
||||
const canRotateGroups =
|
||||
liveSettings.value.activeView === 'group_live' && liveSettings.value.groupDisplayMode === 'rotate' && liveMonitorGroupRotationCodes.value.length > 1
|
||||
const canRotateFinal =
|
||||
liveSettings.value.activeView === 'final_groups' && liveSettings.value.finalDisplayMode === 'rotate' && finalGroup1.value.length > 0 && finalGroup2.value.length > 0
|
||||
if (!canRotateGroups && !canRotateFinal) return
|
||||
|
||||
liveMonitorGroupIndex.value = 0
|
||||
liveMonitorFinalIndex.value = 0
|
||||
|
||||
const intervalMs = Math.max(3, Number(liveSettings.value.rotationIntervalSec) || 5) * 1000
|
||||
liveMonitorTimer = setInterval(() => {
|
||||
if (canRotateGroups) {
|
||||
liveMonitorGroupIndex.value = (liveMonitorGroupIndex.value + 1) % liveMonitorGroupRotationCodes.value.length
|
||||
}
|
||||
if (canRotateFinal) {
|
||||
liveMonitorFinalIndex.value = (liveMonitorFinalIndex.value + 1) % 2
|
||||
}
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
function stopLiveMonitorRotation() {
|
||||
if (liveMonitorTimer) {
|
||||
clearInterval(liveMonitorTimer)
|
||||
liveMonitorTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function buildStreamURL() {
|
||||
const base = API_BASE.endsWith('/') ? API_BASE.slice(0, -1) : API_BASE
|
||||
return `${base}/events`
|
||||
@@ -1181,15 +1530,14 @@ function podiumHasResult(entry) {
|
||||
return Boolean(entry && entry.score > 0)
|
||||
}
|
||||
|
||||
function podiumScoreDisplay(entry) {
|
||||
function podiumScoreMain(entry) {
|
||||
if (!podiumHasResult(entry)) return '-'
|
||||
return podiumScoreText(entry)
|
||||
return `${t('table.score')}: ${entry.score}`
|
||||
}
|
||||
|
||||
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}`
|
||||
function podiumTieSubtitle(entry) {
|
||||
if (!podiumHasResult(entry)) return ''
|
||||
if (!finalTie.value.required) return ''
|
||||
return `${t('table.tieScore')}: ${entry.tieBreak}`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -54,6 +54,14 @@
|
||||
@delete-player="$emit('delete-player', $event)"
|
||||
/>
|
||||
|
||||
<section v-show="adminTab === 'liveMode'" class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>{{ t('liveMode') }}</h2>
|
||||
<p>{{ t('labels.liveModeVisibility') }}</p>
|
||||
</div>
|
||||
<LiveSettingsCard :t="t" :live-settings="liveSettings" :assignable-groups="assignableGroups" @update-live-setting="$emit('update-live-setting', $event)" />
|
||||
</section>
|
||||
|
||||
<section v-show="adminTab === 'preliminary'" class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>{{ t('sections.preliminaryAdminTitle') }}</h2>
|
||||
@@ -64,14 +72,28 @@
|
||||
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'preliminary')">{{ t('actions.resetScores') }}</button>
|
||||
</div>
|
||||
|
||||
<ScoreStageEditor
|
||||
<div class="stage-filter-bar">
|
||||
<input
|
||||
class="name-input"
|
||||
:value="scoreFilters.preliminary"
|
||||
:placeholder="t('actions.searchPlayer')"
|
||||
@input="$emit('update-score-filter', { stage: 'preliminary', value: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MultiRoundScoreEditor
|
||||
:t="t"
|
||||
stage="preliminary"
|
||||
:rows="preliminaryRows"
|
||||
:rows="preliminaryScoreRows"
|
||||
:round-stages="[
|
||||
{ stage: 'prelim_r1', label: t('table.round1') },
|
||||
{ stage: 'prelim_r2', label: t('table.round2') },
|
||||
{ stage: 'prelim_r3', label: t('table.round3') },
|
||||
]"
|
||||
:total-score="preliminaryTotalScore"
|
||||
:show-filter="false"
|
||||
:filter-text="scoreFilters.preliminary"
|
||||
:show-group="true"
|
||||
:show-rank="true"
|
||||
:input-label="t('table.score')"
|
||||
:show-rank="false"
|
||||
:player-image="playerImage"
|
||||
:display-name="displayName"
|
||||
:secondary-name="secondaryName"
|
||||
@@ -82,10 +104,7 @@
|
||||
: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"
|
||||
@update:filter="$emit('update-score-filter', { stage: 'preliminary', value: $event })"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -111,7 +130,8 @@
|
||||
<ScoreStageEditor
|
||||
:t="t"
|
||||
stage="prelim_tiebreak"
|
||||
:rows="prelimTieRows"
|
||||
color-mode="score"
|
||||
:rows="prelimTieScoreRows"
|
||||
:filter-text="scoreFilters.prelim_tiebreak"
|
||||
:show-score-before-input="true"
|
||||
:input-label="t('table.tieScore')"
|
||||
@@ -146,67 +166,36 @@
|
||||
<div class="stage-filter-bar">
|
||||
<input
|
||||
class="name-input"
|
||||
:value="scoreFilters.final"
|
||||
:value="scoreFilters.final_common"
|
||||
:placeholder="t('actions.searchPlayer')"
|
||||
@input="$emit('update-score-filter', { stage: 'final', value: $event.target.value })"
|
||||
@input="$emit('update-score-filter', { stage: 'final_common', value: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="two-column">
|
||||
<div>
|
||||
<h3 class="sub-heading">{{ t('labels.finalGroup1') }}</h3>
|
||||
<ScoreStageEditor
|
||||
:t="t"
|
||||
stage="final"
|
||||
:rows="finalGroup1"
|
||||
:show-filter="false"
|
||||
:filter-text="scoreFilters.final"
|
||||
:show-seed="true"
|
||||
:input-label="t('table.score')"
|
||||
:player-image="playerImage"
|
||||
: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"
|
||||
@update:filter="$emit('update-score-filter', { stage: 'final', value: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="sub-heading">{{ t('labels.finalGroup2') }}</h3>
|
||||
<ScoreStageEditor
|
||||
:t="t"
|
||||
stage="final"
|
||||
:rows="finalGroup2"
|
||||
:show-filter="false"
|
||||
:filter-text="scoreFilters.final"
|
||||
:show-seed="true"
|
||||
:input-label="t('table.score')"
|
||||
:player-image="playerImage"
|
||||
: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"
|
||||
@update:filter="$emit('update-score-filter', { stage: 'final', value: $event })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MultiRoundScoreEditor
|
||||
:t="t"
|
||||
:rows="finalScoreRows"
|
||||
:round-stages="[
|
||||
{ stage: 'final_r1', label: t('table.round1') },
|
||||
{ stage: 'final_r2', label: t('table.round2') },
|
||||
]"
|
||||
:total-score="finalTotalScore"
|
||||
:show-filter="false"
|
||||
:filter-text="scoreFilters.final_common"
|
||||
:show-group="true"
|
||||
:show-seed="true"
|
||||
:player-image="playerImage"
|
||||
: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"
|
||||
:open-proof-preview="openProofPreview"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-show="adminTab === 'finalTie'" class="panel">
|
||||
@@ -226,7 +215,8 @@
|
||||
<ScoreStageEditor
|
||||
:t="t"
|
||||
stage="final_tiebreak"
|
||||
:rows="finalTieRows"
|
||||
color-mode="score"
|
||||
:rows="finalTieScoreRows"
|
||||
:filter-text="scoreFilters.final_tiebreak"
|
||||
:show-score-before-input="true"
|
||||
:input-label="t('table.tieScore')"
|
||||
@@ -291,12 +281,15 @@
|
||||
<script setup>
|
||||
import PlayersManagementTab from './admin/PlayersManagementTab.vue'
|
||||
import ScoreStageEditor from './admin/ScoreStageEditor.vue'
|
||||
import LiveSettingsCard from './admin/LiveSettingsCard.vue'
|
||||
import MultiRoundScoreEditor from './admin/MultiRoundScoreEditor.vue'
|
||||
|
||||
defineProps({
|
||||
t: { type: Function, required: true },
|
||||
adminTabs: { type: Array, required: true },
|
||||
adminTab: { type: String, required: true },
|
||||
viewProofInView: { type: Boolean, default: false },
|
||||
liveSettings: { type: Object, required: true },
|
||||
groupSetupInput: { type: String, default: '' },
|
||||
adminGroupCards: { type: Array, required: true },
|
||||
newPlayer: { type: Object, required: true },
|
||||
@@ -307,12 +300,13 @@ defineProps({
|
||||
playerImage: { type: Function, required: true },
|
||||
groupOptionLabel: { type: Function, required: true },
|
||||
normalizedGroupCode: { type: Function, required: true },
|
||||
preliminaryRows: { type: Array, required: true },
|
||||
prelimTieRows: { type: Array, required: true },
|
||||
finalGroup1: { type: Array, required: true },
|
||||
finalGroup2: { type: Array, required: true },
|
||||
finalTieRows: { type: Array, required: true },
|
||||
preliminaryScoreRows: { type: Array, required: true },
|
||||
prelimTieScoreRows: { type: Array, required: true },
|
||||
finalScoreRows: { type: Array, required: true },
|
||||
finalTieScoreRows: { type: Array, required: true },
|
||||
finalRows: { type: Array, required: true },
|
||||
preliminaryTotalScore: { type: Function, required: true },
|
||||
finalTotalScore: { type: Function, required: true },
|
||||
prelimTie: { type: Object, required: true },
|
||||
finalTie: { type: Object, required: true },
|
||||
scoreFilters: { type: Object, required: true },
|
||||
@@ -334,6 +328,7 @@ defineEmits([
|
||||
'refresh',
|
||||
'logout',
|
||||
'toggle-view-proof',
|
||||
'update-live-setting',
|
||||
'change-admin-tab',
|
||||
'update:group-setup-input',
|
||||
'save-group-setup',
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
<template>
|
||||
<header class="masthead">
|
||||
<div class="masthead-main">
|
||||
<div>
|
||||
<h1 class="masthead-title">{{ competitionTitle }}</h1>
|
||||
<div class="masthead-brand">
|
||||
<picture>
|
||||
<source srcset="/logo.svg" type="image/svg+xml" />
|
||||
<img class="masthead-logo" src="/logo.png" :alt="competitionTitle" />
|
||||
</picture>
|
||||
<p class="masthead-subtitle">{{ t('subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="masthead-controls">
|
||||
<div class="controls-grid">
|
||||
<div class="control-box">
|
||||
<p class="control-label">{{ t('labels.mode') }}</p>
|
||||
<div class="language-switcher mode-switcher">
|
||||
<button class="lang-btn" :class="{ active: mode === 'view' }" @click="$emit('change-mode', 'view')">{{ t('viewMode') }}</button>
|
||||
<button class="lang-btn" :class="{ active: mode === 'admin' }" @click="$emit('change-mode', 'admin')">{{ t('adminMode') }}</button>
|
||||
</div>
|
||||
<div ref="controlsRoot" class="controls-popup-anchor">
|
||||
<div class="control-toggle-row">
|
||||
<button class="control-toggle-btn" type="button" :aria-expanded="controlsOpen ? 'true' : 'false'" @click="toggleControls">
|
||||
<span>{{ optionsLabel }}</span>
|
||||
<span class="control-toggle-meta">{{ modeShort }} · {{ languageShort }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="control-box">
|
||||
<p class="control-label">{{ t('labels.language') }}</p>
|
||||
<div class="language-switcher">
|
||||
<button class="lang-btn" :class="{ active: language === 'ar' }" @click="$emit('change-language', 'ar')">العربية</button>
|
||||
<button class="lang-btn" :class="{ active: language === 'en' }" @click="$emit('change-language', 'en')">English</button>
|
||||
<Transition name="controls-reveal">
|
||||
<div v-if="controlsOpen" class="controls-grid controls-popover">
|
||||
<div class="control-box">
|
||||
<p class="control-label">{{ t('labels.mode') }}</p>
|
||||
<div class="language-switcher mode-switcher">
|
||||
<button class="lang-btn" :class="{ active: mode === 'view' }" @click="changeMode('view')">{{ t('viewMode') }}</button>
|
||||
<button class="lang-btn" :class="{ active: mode === 'live' }" @click="changeMode('live')">{{ t('liveMode') }}</button>
|
||||
<button class="lang-btn" :class="{ active: mode === 'admin' }" @click="changeMode('admin')">{{ t('adminMode') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-box">
|
||||
<p class="control-label">{{ t('labels.language') }}</p>
|
||||
<div class="language-switcher">
|
||||
<button class="lang-btn" :class="{ active: language === 'ar' }" @click="changeLanguage('ar')">العربية</button>
|
||||
<button class="lang-btn" :class="{ active: language === 'en' }" @click="changeLanguage('en')">English</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<div class="live-badge">{{ mode === 'view' ? '● ' + t('labels.liveTracker') : t('adminPanel') }}</div>
|
||||
<div class="live-badge">{{ modeStatusLabel }}</div>
|
||||
<div class="server-time" v-if="serverTime">{{ t('labels.lastSync') }}: {{ serverTime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,7 +49,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
t: { type: Function, required: true },
|
||||
competitionTitle: { type: String, required: true },
|
||||
mode: { type: String, required: true },
|
||||
@@ -42,6 +59,55 @@ defineProps({
|
||||
serverTime: { type: String, default: '' },
|
||||
})
|
||||
|
||||
defineEmits(['change-mode', 'change-language'])
|
||||
</script>
|
||||
const emit = defineEmits(['change-mode', 'change-language'])
|
||||
|
||||
const controlsOpen = ref(false)
|
||||
const controlsRoot = ref(null)
|
||||
|
||||
const optionsLabel = computed(() => (props.language === 'ar' ? 'خيارات' : 'Options'))
|
||||
const modeShort = computed(() => {
|
||||
if (props.mode === 'view') return props.language === 'ar' ? 'عرض' : 'View'
|
||||
if (props.mode === 'live') return props.language === 'ar' ? 'حي' : 'Live'
|
||||
return props.language === 'ar' ? 'إدارة' : 'Admin'
|
||||
})
|
||||
const languageShort = computed(() => (props.language === 'ar' ? 'AR' : 'EN'))
|
||||
const modeStatusLabel = computed(() => {
|
||||
if (props.mode === 'view') return '● ' + props.t('labels.liveTracker')
|
||||
if (props.mode === 'live') return '● ' + props.t('liveMode')
|
||||
return props.t('adminPanel')
|
||||
})
|
||||
|
||||
function toggleControls() {
|
||||
controlsOpen.value = !controlsOpen.value
|
||||
}
|
||||
|
||||
function changeMode(nextMode) {
|
||||
emit('change-mode', nextMode)
|
||||
controlsOpen.value = false
|
||||
}
|
||||
|
||||
function changeLanguage(nextLanguage) {
|
||||
emit('change-language', nextLanguage)
|
||||
controlsOpen.value = false
|
||||
}
|
||||
|
||||
function onGlobalPointerDown(event) {
|
||||
if (!controlsOpen.value) return
|
||||
const root = controlsRoot.value
|
||||
if (root && !root.contains(event.target)) controlsOpen.value = false
|
||||
}
|
||||
|
||||
function onGlobalKeyDown(event) {
|
||||
if (event.key === 'Escape') controlsOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('pointerdown', onGlobalPointerDown)
|
||||
window.addEventListener('keydown', onGlobalKeyDown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pointerdown', onGlobalPointerDown)
|
||||
window.removeEventListener('keydown', onGlobalKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
475
frontend/src/components/LiveModePanel.vue
Normal file
475
frontend/src/components/LiveModePanel.vue
Normal file
@@ -0,0 +1,475 @@
|
||||
<template>
|
||||
<section class="live-mode-shell">
|
||||
<div class="live-mode-overlay">
|
||||
<header class="live-mode-header">
|
||||
<h2>{{ t('sections.liveModeTitle') }}</h2>
|
||||
<p>{{ t('sections.liveModeSubtitle') }}</p>
|
||||
</header>
|
||||
|
||||
<div class="live-screen-head live-head-selection">
|
||||
<span class="live-chip strong">{{ activeViewLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="live-mode-grid single">
|
||||
<article v-if="activeView === 'group_live'" class="live-screen-card wide">
|
||||
<div class="live-screen-head">
|
||||
<h3>{{ t('sections.liveTitle') }}</h3>
|
||||
<div class="live-chip-row">
|
||||
<span class="live-chip">{{ liveSettings.groupDisplayMode === 'fixed' ? t('actions.liveModeFixed') : t('actions.liveModeRotate') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="live-swap" mode="out-in">
|
||||
<div :key="'live-group-swap-' + (liveGroupCode || 'u')" class="live-swap-block">
|
||||
<div class="live-focus-banner" :class="'focus-' + groupClassKey">
|
||||
<span class="live-focus-label">{{ t('labels.group') }}</span>
|
||||
<strong class="live-focus-title">{{ liveGroupCode || t('labels.unassigned') }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="score-table live-score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('table.rank') }}</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th>{{ t('table.round1') }}</th>
|
||||
<th>{{ t('table.round2') }}</th>
|
||||
<th>{{ t('table.round3') }}</th>
|
||||
<th>{{ t('table.total') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in rankedLiveGroupMembers" :key="'live-group-' + entry.player.id">
|
||||
<td class="mono rank">{{ entry.rank }}</td>
|
||||
<td>
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(entry.player)" :alt="entry.player.nameAr" class="competitor-image" />
|
||||
<div>
|
||||
<p class="name-main">{{ displayName(entry.player) }}</p>
|
||||
<p class="name-sub">{{ secondaryName(entry.player) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono">{{ scoreFor('prelim_r1', entry.player.id) }}</td>
|
||||
<td class="mono">{{ scoreFor('prelim_r2', entry.player.id) }}</td>
|
||||
<td class="mono">{{ scoreFor('prelim_r3', entry.player.id) }}</td>
|
||||
<td class="mono strong">{{ entry.total }}</td>
|
||||
</tr>
|
||||
<tr v-if="liveGroupMembers.length === 0"><td colspan="6" class="muted center">{{ t('labels.emptyLive') }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</article>
|
||||
|
||||
<article v-else-if="activeView === 'prelim_tie'" class="live-screen-card wide">
|
||||
<div class="live-screen-head">
|
||||
<h3>{{ t('sections.prelimTieTitle') }}</h3>
|
||||
<div class="live-chip-row">
|
||||
<span class="live-chip">{{ t('table.tieScore') }}</span>
|
||||
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="live-swap" mode="out-in">
|
||||
<div :key="'prelim-tie-page-' + listPageIndex" class="live-swap-block">
|
||||
<div class="table-wrap">
|
||||
<table class="score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th>{{ t('table.tieScore') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in pagedPrelimTieRows" :key="'live-pre-tie-' + row.playerId">
|
||||
<td class="mono">{{ listPageStart + index + 1 }}</td>
|
||||
<td>
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||
<div>
|
||||
<p class="name-main">{{ displayName(row) }}</p>
|
||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono strong">{{ row.tieBreak }}</td>
|
||||
</tr>
|
||||
<tr v-if="prelimTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noPrelimTie') }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</article>
|
||||
|
||||
<article v-else-if="activeView === 'prelim_overall'" class="live-screen-card wide">
|
||||
<div class="live-screen-head">
|
||||
<h3>{{ t('sections.overallTitle') }}</h3>
|
||||
<div class="live-chip-row">
|
||||
<span class="live-chip strong">{{ t('labels.top12') }}: {{ finalists.length }}</span>
|
||||
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="live-swap" mode="out-in">
|
||||
<div :key="'prelim-overall-page-' + listPageIndex" class="live-swap-block">
|
||||
<div class="table-wrap">
|
||||
<table class="score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('table.rank') }}</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th>{{ t('table.total') }}</th>
|
||||
<th>{{ t('table.tieScore') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in pagedPreliminaryRows"
|
||||
:key="'live-pre-overall-' + row.playerId"
|
||||
:class="{ 'qualified-row': isFinalist(row.playerId) }"
|
||||
>
|
||||
<td class="mono rank">{{ row.rank }}</td>
|
||||
<td>
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||
<div>
|
||||
<p class="name-main">{{ displayName(row) }}</p>
|
||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono strong">{{ row.score }}</td>
|
||||
<td class="mono">{{ row.tieBreak }}</td>
|
||||
</tr>
|
||||
<tr v-if="preliminaryRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</article>
|
||||
|
||||
<article v-else-if="activeView === 'final_groups'" class="live-screen-card wide">
|
||||
<div class="live-screen-head">
|
||||
<h3>{{ t('sections.finalTitle') }}</h3>
|
||||
<div class="live-chip-row">
|
||||
<span class="live-chip">{{ liveSettings.finalDisplayMode === 'fixed' ? t('actions.liveModeFixed') : t('actions.liveModeRotate') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="live-swap" mode="out-in">
|
||||
<div :key="'live-final-swap-' + liveFinalGroup" class="live-swap-block">
|
||||
<div class="live-focus-banner focus-final">
|
||||
<span class="live-focus-label">{{ t('tabs.final') }}</span>
|
||||
<strong class="live-focus-title">{{ currentFinalGroupLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="score-table live-score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('table.rank') }}</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th>{{ t('table.round1') }}</th>
|
||||
<th>{{ t('table.round2') }}</th>
|
||||
<th>{{ t('table.total') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in rankedLiveFinalRows" :key="'live-final-group-' + entry.row.playerId">
|
||||
<td class="mono rank">{{ entry.rank }}</td>
|
||||
<td>
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
|
||||
<div>
|
||||
<p class="name-main">{{ displayName(entry.row) }}</p>
|
||||
<p class="name-sub">{{ secondaryName(entry.row) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono">{{ scoreFor('final_r1', entry.row.playerId) }}</td>
|
||||
<td class="mono">{{ scoreFor('final_r2', entry.row.playerId) }}</td>
|
||||
<td class="mono strong">{{ entry.total }}</td>
|
||||
</tr>
|
||||
<tr v-if="liveFinalRows.length === 0"><td colspan="5" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</article>
|
||||
|
||||
<article v-else-if="activeView === 'final_tie'" class="live-screen-card wide">
|
||||
<div class="live-screen-head">
|
||||
<h3>{{ t('sections.finalTieTitle') }}</h3>
|
||||
<div class="live-chip-row">
|
||||
<span class="live-chip">{{ t('table.tieScore') }}</span>
|
||||
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="live-swap" mode="out-in">
|
||||
<div :key="'final-tie-page-' + listPageIndex" class="live-swap-block">
|
||||
<div class="table-wrap">
|
||||
<table class="score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th>{{ t('table.tieScore') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in pagedFinalTieRows" :key="'live-final-tie-' + row.playerId">
|
||||
<td class="mono">{{ listPageStart + index + 1 }}</td>
|
||||
<td>
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||
<div>
|
||||
<p class="name-main">{{ displayName(row) }}</p>
|
||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono strong">{{ row.tieBreak }}</td>
|
||||
</tr>
|
||||
<tr v-if="finalTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noFinalTie') }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</article>
|
||||
|
||||
<article v-else class="live-screen-card podium-live-card wide">
|
||||
<div class="live-screen-head">
|
||||
<h3>{{ t('sections.podiumTitle') }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="podium-wrapper live-podium">
|
||||
<article class="podium-col pos-2">
|
||||
<div class="podium-avatar-wrap">
|
||||
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
|
||||
</div>
|
||||
<div class="podium-medal-icon">🥈</div>
|
||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
|
||||
<div class="podium-score-wrap">
|
||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
|
||||
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="podium-col pos-1">
|
||||
<div class="podium-avatar-wrap">
|
||||
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
|
||||
</div>
|
||||
<div class="podium-medal-icon">🥇</div>
|
||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
|
||||
<div class="podium-score-wrap">
|
||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
|
||||
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="podium-col pos-3">
|
||||
<div class="podium-avatar-wrap">
|
||||
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
|
||||
</div>
|
||||
<div class="podium-medal-icon">🥉</div>
|
||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
|
||||
<div class="podium-score-wrap">
|
||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
|
||||
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
t: { type: Function, required: true },
|
||||
liveSettings: { type: Object, required: true },
|
||||
liveGroupCode: { type: String, default: '' },
|
||||
liveGroupMembers: { type: Array, required: true },
|
||||
prelimTieRows: { type: Array, required: true },
|
||||
preliminaryRows: { type: Array, required: true },
|
||||
finalists: { type: Array, required: true },
|
||||
liveFinalGroup: { type: Number, required: true },
|
||||
liveFinalRows: { type: Array, required: true },
|
||||
finalTieRows: { type: Array, required: true },
|
||||
podiumOrdered: { type: Array, required: true },
|
||||
playerImage: { type: Function, required: true },
|
||||
displayName: { type: Function, required: true },
|
||||
secondaryName: { type: Function, required: true },
|
||||
scoreFor: { type: Function, required: true },
|
||||
prelimTotal: { type: Function, required: true },
|
||||
finalTotal: { type: Function, required: true },
|
||||
podiumImage: { type: Function, required: true },
|
||||
podiumName: { type: Function, required: true },
|
||||
podiumHasResult: { type: Function, required: true },
|
||||
podiumScoreMain: { type: Function, required: true },
|
||||
podiumTieSubtitle: { type: Function, required: true },
|
||||
})
|
||||
|
||||
const finalistIds = computed(() => new Set((props.finalists || []).map((row) => row.playerId)))
|
||||
const activeView = computed(() => props.liveSettings.activeView || 'group_live')
|
||||
const listPageIndex = ref(0)
|
||||
let listRotationTimer = null
|
||||
|
||||
const rotationIntervalSec = computed(() => {
|
||||
const raw = Number(props.liveSettings.rotationIntervalSec || 5)
|
||||
if (!Number.isFinite(raw)) return 5
|
||||
return Math.max(3, raw)
|
||||
})
|
||||
|
||||
const rotationPlayerCount = computed(() => {
|
||||
const raw = Number(props.liveSettings.rotationPlayerCount || 12)
|
||||
if (!Number.isFinite(raw)) return 12
|
||||
if (raw < 3) return 3
|
||||
if (raw > 40) return 40
|
||||
return Math.floor(raw)
|
||||
})
|
||||
|
||||
const listRowsForActiveView = computed(() => {
|
||||
if (activeView.value === 'prelim_tie') return props.prelimTieRows || []
|
||||
if (activeView.value === 'prelim_overall') return props.preliminaryRows || []
|
||||
if (activeView.value === 'final_tie') return props.finalTieRows || []
|
||||
return []
|
||||
})
|
||||
|
||||
const listPageCount = computed(() => {
|
||||
const total = listRowsForActiveView.value.length
|
||||
if (total <= 0) return 1
|
||||
return Math.max(1, Math.ceil(total / rotationPlayerCount.value))
|
||||
})
|
||||
|
||||
const listPageStart = computed(() => listPageIndex.value * rotationPlayerCount.value)
|
||||
const currentListPageDisplay = computed(() => `${listPageIndex.value + 1} / ${listPageCount.value}`)
|
||||
|
||||
const pagedPrelimTieRows = computed(() => paginateRows(props.prelimTieRows || []))
|
||||
const pagedPreliminaryRows = computed(() => paginateRows(props.preliminaryRows || []))
|
||||
const pagedFinalTieRows = computed(() => paginateRows(props.finalTieRows || []))
|
||||
const rankedLiveGroupMembers = computed(() => {
|
||||
const rows = (props.liveGroupMembers || []).map((player) => ({
|
||||
player,
|
||||
total: Number(props.prelimTotal(player.id) || 0),
|
||||
}))
|
||||
rows.sort((a, b) => {
|
||||
if (a.total !== b.total) return b.total - a.total
|
||||
return a.player.id - b.player.id
|
||||
})
|
||||
|
||||
let prevTotal = null
|
||||
let prevRank = 0
|
||||
return rows.map((entry, idx) => {
|
||||
const rank = prevTotal === entry.total ? prevRank : idx + 1
|
||||
prevTotal = entry.total
|
||||
prevRank = rank
|
||||
return { ...entry, rank }
|
||||
})
|
||||
})
|
||||
|
||||
const activeViewLabel = computed(() => {
|
||||
const map = {
|
||||
group_live: props.t('labels.liveViewGroup'),
|
||||
prelim_tie: props.t('labels.liveViewPrelimTie'),
|
||||
prelim_overall: props.t('labels.liveViewPrelimOverall'),
|
||||
final_groups: props.t('labels.liveViewFinalGroups'),
|
||||
final_tie: props.t('labels.liveViewFinalTie'),
|
||||
podium: props.t('labels.liveViewPodium'),
|
||||
}
|
||||
return map[activeView.value] || map.group_live
|
||||
})
|
||||
|
||||
const currentFinalGroupLabel = computed(() => {
|
||||
if (props.liveFinalGroup === 2) return props.t('labels.finalGroup2')
|
||||
return props.t('labels.finalGroup1')
|
||||
})
|
||||
const groupClassKey = computed(() => {
|
||||
const code = String(props.liveGroupCode || '').trim().toUpperCase()
|
||||
if (code.startsWith('A')) return 'a'
|
||||
if (code.startsWith('B')) return 'b'
|
||||
if (code.startsWith('C')) return 'c'
|
||||
if (code.startsWith('D')) return 'd'
|
||||
return 'u'
|
||||
})
|
||||
const rankedLiveFinalRows = computed(() => {
|
||||
const rows = (props.liveFinalRows || []).map((row) => ({
|
||||
row,
|
||||
total: Number(props.finalTotal(row.playerId) || 0),
|
||||
}))
|
||||
|
||||
rows.sort((a, b) => {
|
||||
if (a.total !== b.total) return b.total - a.total
|
||||
const seedA = Number(a.row.seed || 0)
|
||||
const seedB = Number(b.row.seed || 0)
|
||||
if (seedA !== seedB) return seedA - seedB
|
||||
return a.row.playerId - b.row.playerId
|
||||
})
|
||||
|
||||
let prevTotal = null
|
||||
let prevRank = 0
|
||||
return rows.map((entry, idx) => {
|
||||
const rank = prevTotal === entry.total ? prevRank : idx + 1
|
||||
prevTotal = entry.total
|
||||
prevRank = rank
|
||||
return { ...entry, rank }
|
||||
})
|
||||
})
|
||||
|
||||
function isFinalist(playerId) {
|
||||
return finalistIds.value.has(playerId)
|
||||
}
|
||||
|
||||
watch(
|
||||
() =>
|
||||
`${activeView.value}|${rotationIntervalSec.value}|${rotationPlayerCount.value}|${props.prelimTieRows.length}|${props.preliminaryRows.length}|${props.finalTieRows.length}`,
|
||||
() => {
|
||||
restartListRotation()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopListRotation()
|
||||
})
|
||||
|
||||
function paginateRows(rows) {
|
||||
const start = listPageIndex.value * rotationPlayerCount.value
|
||||
return rows.slice(start, start + rotationPlayerCount.value)
|
||||
}
|
||||
|
||||
function restartListRotation() {
|
||||
stopListRotation()
|
||||
listPageIndex.value = 0
|
||||
|
||||
const autoRotateView = activeView.value === 'prelim_tie' || activeView.value === 'prelim_overall' || activeView.value === 'final_tie'
|
||||
if (!autoRotateView) return
|
||||
if (listPageCount.value <= 1) return
|
||||
|
||||
listRotationTimer = setInterval(() => {
|
||||
if (listPageCount.value <= 1) {
|
||||
listPageIndex.value = 0
|
||||
return
|
||||
}
|
||||
listPageIndex.value = (listPageIndex.value + 1) % listPageCount.value
|
||||
}, rotationIntervalSec.value * 1000)
|
||||
}
|
||||
|
||||
function stopListRotation() {
|
||||
if (listRotationTimer) {
|
||||
clearInterval(listRotationTimer)
|
||||
listRotationTimer = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -137,11 +137,11 @@
|
||||
<td class="mono strong">
|
||||
<span>{{ row.score }}</span>
|
||||
<button
|
||||
v-if="canViewProofs && hasScoreProof('preliminary', row.playerId)"
|
||||
v-if="canViewProofs && preliminaryProofStage(row.playerId)"
|
||||
class="proof-mini"
|
||||
@click="$emit('open-proof', { stage: 'preliminary', playerId: row.playerId })"
|
||||
@click="$emit('open-proof', { stage: preliminaryProofStage(row.playerId), playerId: row.playerId })"
|
||||
>
|
||||
<img :src="scoreProofFor('preliminary', row.playerId)" :alt="t('table.verification')" />
|
||||
<img :src="scoreProofFor(preliminaryProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
|
||||
</button>
|
||||
</td>
|
||||
<td class="mono">
|
||||
@@ -237,13 +237,13 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono strong">
|
||||
<span>{{ scoreFor('final', row.playerId) }}</span>
|
||||
<span>{{ finalTotalScore(row.playerId) }}</span>
|
||||
<button
|
||||
v-if="canViewProofs && hasScoreProof('final', row.playerId)"
|
||||
v-if="canViewProofs && finalProofStage(row.playerId)"
|
||||
class="proof-mini"
|
||||
@click="$emit('open-proof', { stage: 'final', playerId: row.playerId })"
|
||||
@click="$emit('open-proof', { stage: finalProofStage(row.playerId), playerId: row.playerId })"
|
||||
>
|
||||
<img :src="scoreProofFor('final', row.playerId)" :alt="t('table.verification')" />
|
||||
<img :src="scoreProofFor(finalProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -277,13 +277,13 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono strong">
|
||||
<span>{{ scoreFor('final', row.playerId) }}</span>
|
||||
<span>{{ row.score }}</span>
|
||||
<button
|
||||
v-if="canViewProofs && hasScoreProof('final', row.playerId)"
|
||||
v-if="canViewProofs && finalProofStage(row.playerId)"
|
||||
class="proof-mini"
|
||||
@click="$emit('open-proof', { stage: 'final', playerId: row.playerId })"
|
||||
@click="$emit('open-proof', { stage: finalProofStage(row.playerId), playerId: row.playerId })"
|
||||
>
|
||||
<img :src="scoreProofFor('final', row.playerId)" :alt="t('table.verification')" />
|
||||
<img :src="scoreProofFor(finalProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -320,11 +320,11 @@
|
||||
<td class="mono strong">
|
||||
<span>{{ row.score }}</span>
|
||||
<button
|
||||
v-if="canViewProofs && hasScoreProof('final', row.playerId)"
|
||||
v-if="canViewProofs && finalProofStage(row.playerId)"
|
||||
class="proof-mini"
|
||||
@click="$emit('open-proof', { stage: 'final', playerId: row.playerId })"
|
||||
@click="$emit('open-proof', { stage: finalProofStage(row.playerId), playerId: row.playerId })"
|
||||
>
|
||||
<img :src="scoreProofFor('final', row.playerId)" :alt="t('table.verification')" />
|
||||
<img :src="scoreProofFor(finalProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
|
||||
</button>
|
||||
</td>
|
||||
<td class="mono">
|
||||
@@ -361,7 +361,10 @@
|
||||
</div>
|
||||
<div class="podium-medal-icon">🥈</div>
|
||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
|
||||
<p class="podium-score">{{ podiumScoreDisplay(podiumOrdered[0]) }}</p>
|
||||
<div class="podium-score-wrap">
|
||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
|
||||
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="podium-col pos-1">
|
||||
@@ -370,7 +373,10 @@
|
||||
</div>
|
||||
<div class="podium-medal-icon">🥇</div>
|
||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
|
||||
<p class="podium-score">{{ podiumScoreDisplay(podiumOrdered[1]) }}</p>
|
||||
<div class="podium-score-wrap">
|
||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
|
||||
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="podium-col pos-3">
|
||||
@@ -379,7 +385,10 @@
|
||||
</div>
|
||||
<div class="podium-medal-icon">🥉</div>
|
||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
|
||||
<p class="podium-score">{{ podiumScoreDisplay(podiumOrdered[2]) }}</p>
|
||||
<div class="podium-score-wrap">
|
||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
|
||||
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -413,12 +422,15 @@ const props = defineProps({
|
||||
finalGroup2: { type: Array, required: true },
|
||||
finalRows: { type: Array, required: true },
|
||||
finalTie: { type: Object, required: true },
|
||||
scoreFor: { type: Function, required: true },
|
||||
finalTotalScore: { type: Function, required: true },
|
||||
preliminaryProofStage: { type: Function, required: true },
|
||||
finalProofStage: { type: Function, required: true },
|
||||
podiumOrdered: { type: Array, required: true },
|
||||
podiumImage: { type: Function, required: true },
|
||||
podiumName: { type: Function, required: true },
|
||||
podiumHasResult: { type: Function, required: true },
|
||||
podiumScoreDisplay: { type: Function, required: true },
|
||||
podiumScoreMain: { type: Function, required: true },
|
||||
podiumTieSubtitle: { type: Function, required: true },
|
||||
canViewProofs: { type: Boolean, default: false },
|
||||
hasScoreProof: { type: Function, required: true },
|
||||
scoreProofFor: { type: Function, required: true },
|
||||
|
||||
95
frontend/src/components/admin/LiveSettingsCard.vue
Normal file
95
frontend/src/components/admin/LiveSettingsCard.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<section class="live-settings-card">
|
||||
<div class="live-settings-head">
|
||||
<h3>{{ t('labels.liveModeVisibility') }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="live-settings-row">
|
||||
<label class="control-label">{{ t('labels.activeLiveView') }}</label>
|
||||
<select class="name-input" :value="liveSettings.activeView" @change="emitPatch({ activeView: $event.target.value })">
|
||||
<option value="group_live">{{ t('labels.liveViewGroup') }}</option>
|
||||
<option value="prelim_tie">{{ t('labels.liveViewPrelimTie') }}</option>
|
||||
<option value="prelim_overall">{{ t('labels.liveViewPrelimOverall') }}</option>
|
||||
<option value="final_groups">{{ t('labels.liveViewFinalGroups') }}</option>
|
||||
<option value="final_tie">{{ t('labels.liveViewFinalTie') }}</option>
|
||||
<option value="podium">{{ t('labels.liveViewPodium') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="live-settings-row">
|
||||
<label class="control-label">{{ t('labels.groupDisplayMode') }}</label>
|
||||
<div class="inline-settings">
|
||||
<select class="name-input" :value="liveSettings.groupDisplayMode" @change="emitPatch({ groupDisplayMode: $event.target.value })">
|
||||
<option value="rotate">{{ t('actions.liveModeRotate') }}</option>
|
||||
<option value="fixed">{{ t('actions.liveModeFixed') }}</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="liveSettings.groupDisplayMode === 'fixed'"
|
||||
class="name-input"
|
||||
:value="liveSettings.groupFixedCode || ''"
|
||||
@change="emitPatch({ groupFixedCode: $event.target.value })"
|
||||
>
|
||||
<option value="">{{ t('labels.unassigned') }}</option>
|
||||
<option v-for="group in assignableGroups" :key="'live-fixed-group-' + group" :value="group">{{ group }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="live-settings-row">
|
||||
<label class="control-label">{{ t('labels.finalDisplayMode') }}</label>
|
||||
<div class="inline-settings">
|
||||
<select class="name-input" :value="liveSettings.finalDisplayMode" @change="emitPatch({ finalDisplayMode: $event.target.value })">
|
||||
<option value="rotate">{{ t('actions.liveModeRotate') }}</option>
|
||||
<option value="fixed">{{ t('actions.liveModeFixed') }}</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="liveSettings.finalDisplayMode === 'fixed'"
|
||||
class="name-input"
|
||||
:value="String(liveSettings.finalFixedGroup || 1)"
|
||||
@change="emitPatch({ finalFixedGroup: Number($event.target.value) || 1 })"
|
||||
>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="live-settings-row">
|
||||
<label class="control-label">{{ t('labels.rotationIntervalSec') }}</label>
|
||||
<input
|
||||
class="name-input"
|
||||
type="number"
|
||||
min="3"
|
||||
max="30"
|
||||
:value="liveSettings.rotationIntervalSec || 5"
|
||||
@change="emitPatch({ rotationIntervalSec: Number($event.target.value) || 5 })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="live-settings-row">
|
||||
<label class="control-label">{{ t('labels.rotationPlayerCount') }}</label>
|
||||
<input
|
||||
class="name-input"
|
||||
type="number"
|
||||
min="3"
|
||||
max="40"
|
||||
:value="liveSettings.rotationPlayerCount || 12"
|
||||
@change="emitPatch({ rotationPlayerCount: Number($event.target.value) || 12 })"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
t: { type: Function, required: true },
|
||||
liveSettings: { type: Object, required: true },
|
||||
assignableGroups: { type: Array, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update-live-setting'])
|
||||
|
||||
function emitPatch(patch) {
|
||||
emit('update-live-setting', patch)
|
||||
}
|
||||
</script>
|
||||
206
frontend/src/components/admin/MultiRoundScoreEditor.vue
Normal file
206
frontend/src/components/admin/MultiRoundScoreEditor.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="stage-filter-bar" v-if="showFilter">
|
||||
<input
|
||||
class="name-input"
|
||||
:value="filterText"
|
||||
:placeholder="t('actions.searchPlayer')"
|
||||
@input="$emit('update:filter', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap desktop-score-table">
|
||||
<table class="score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th v-if="showGroup">{{ t('table.group') }}</th>
|
||||
<th v-if="showRank">{{ t('table.rank') }}</th>
|
||||
<th v-if="showSeed">{{ t('table.seed') }}</th>
|
||||
<th v-for="round in roundStages" :key="'head-' + round.stage">{{ round.label }}</th>
|
||||
<th>{{ t('table.total') }}</th>
|
||||
<th>{{ t('table.verification') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in filteredRows" :key="'multi-row-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row)">
|
||||
<td class="mono">{{ index + 1 }}</td>
|
||||
<td>
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||
<div>
|
||||
<p class="name-main">{{ displayName(row) }}</p>
|
||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="showGroup" class="mono">{{ groupCell(row) }}</td>
|
||||
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
|
||||
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
|
||||
<td v-for="round in roundStages" :key="'cell-' + round.stage + '-' + row.playerId" class="multi-round-cell">
|
||||
<input
|
||||
class="score-input score-input-compact"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min="0"
|
||||
max="9999"
|
||||
:value="scoreInputValue(round.stage, row.playerId)"
|
||||
@focus="onScoreFocus(round.stage, row.playerId)"
|
||||
@input="onScoreInput(round.stage, row.playerId, $event)"
|
||||
@blur="onScoreCommit(round.stage, row.playerId)"
|
||||
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
|
||||
/>
|
||||
</td>
|
||||
<td class="mono strong">{{ totalScore(row.playerId) }}</td>
|
||||
<td class="verify-round-cell">
|
||||
<div class="verify-round-list">
|
||||
<div v-for="round in roundStages" :key="'verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
|
||||
<span class="verify-round-label">{{ round.label }}</span>
|
||||
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
|
||||
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||
</button>
|
||||
<img
|
||||
v-if="hasScoreProof(round.stage, row.playerId)"
|
||||
class="proof-thumb"
|
||||
:src="scoreProofFor(round.stage, row.playerId)"
|
||||
:alt="t('table.verification')"
|
||||
@click="openProofPreview(round.stage, row.playerId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filteredRows.length === 0">
|
||||
<td :colspan="columnCount" class="muted center">{{ t('labels.noPlayers') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mobile-score-cards">
|
||||
<article v-for="(row, index) in filteredRows" :key="'multi-mobile-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row)">
|
||||
<div class="score-card-head">
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||
<div>
|
||||
<p class="name-main">{{ displayName(row) }}</p>
|
||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mono">#{{ index + 1 }}</div>
|
||||
</div>
|
||||
|
||||
<div class="score-card-meta">
|
||||
<span v-if="showGroup">{{ t('table.group') }}: {{ groupCell(row) }}</span>
|
||||
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
|
||||
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mobile-round-grid">
|
||||
<label v-for="round in roundStages" :key="'mobile-round-' + round.stage + '-' + row.playerId" class="mobile-round-item">
|
||||
<span class="score-label">{{ round.label }}</span>
|
||||
<input
|
||||
class="score-input"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min="0"
|
||||
max="9999"
|
||||
:value="scoreInputValue(round.stage, row.playerId)"
|
||||
@focus="onScoreFocus(round.stage, row.playerId)"
|
||||
@input="onScoreInput(round.stage, row.playerId, $event)"
|
||||
@blur="onScoreCommit(round.stage, row.playerId)"
|
||||
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mobile-total-line">
|
||||
<span class="muted">{{ t('table.total') }}</span>
|
||||
<strong class="mono">{{ totalScore(row.playerId) }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="mobile-verify-block">
|
||||
<p class="mobile-verify-title">{{ t('table.verification') }}</p>
|
||||
<div class="verify-round-list mobile">
|
||||
<div v-for="round in roundStages" :key="'mobile-verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
|
||||
<span class="verify-round-label">{{ round.label }}</span>
|
||||
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
|
||||
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||
</button>
|
||||
<img
|
||||
v-if="hasScoreProof(round.stage, row.playerId)"
|
||||
class="proof-thumb"
|
||||
:src="scoreProofFor(round.stage, row.playerId)"
|
||||
:alt="t('table.verification')"
|
||||
@click="openProofPreview(round.stage, row.playerId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { scoreGroupStyle } from '../../utils/scoreGroupTheme'
|
||||
|
||||
const props = defineProps({
|
||||
t: { type: Function, required: true },
|
||||
rows: { type: Array, required: true },
|
||||
roundStages: { type: Array, required: true },
|
||||
totalScore: { type: Function, required: true },
|
||||
showFilter: { type: Boolean, default: true },
|
||||
filterText: { type: String, default: '' },
|
||||
showGroup: { type: Boolean, default: false },
|
||||
showRank: { type: Boolean, default: false },
|
||||
showSeed: { type: Boolean, default: false },
|
||||
playerImage: { type: Function, required: true },
|
||||
displayName: { type: Function, required: true },
|
||||
secondaryName: { type: Function, required: true },
|
||||
scoreInputValue: { type: Function, required: true },
|
||||
onScoreFocus: { type: Function, required: true },
|
||||
onScoreInput: { type: Function, required: true },
|
||||
onScoreCommit: { type: Function, required: true },
|
||||
hasScoreProof: { type: Function, required: true },
|
||||
scoreProofFor: { type: Function, required: true },
|
||||
openScoreProofUploader: { type: Function, required: true },
|
||||
openProofPreview: { type: Function, required: true },
|
||||
})
|
||||
|
||||
defineEmits(['update:filter'])
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const query = String(props.filterText || '').trim().toLowerCase()
|
||||
if (!query) return props.rows
|
||||
return props.rows.filter((row) => {
|
||||
const ar = String(row.nameAr || '')
|
||||
const en = String(row.nameEn || '').toLowerCase()
|
||||
return ar.includes(query) || en.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const columnCount = computed(() => {
|
||||
let count = 4 + props.roundStages.length
|
||||
if (props.showGroup) count += 1
|
||||
if (props.showRank) count += 1
|
||||
if (props.showSeed) count += 1
|
||||
return count
|
||||
})
|
||||
|
||||
function groupCell(row) {
|
||||
if (row.finalGroup === 1 || row.finalGroup === 2) {
|
||||
return row.finalGroup
|
||||
}
|
||||
return row.groupCode || props.t('labels.unassigned')
|
||||
}
|
||||
|
||||
function scoreGroupStyleFor(row) {
|
||||
return scoreGroupStyle(row)
|
||||
}
|
||||
</script>
|
||||
@@ -107,7 +107,6 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mono">#{{ player.id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions compact name-convert-row">
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in filteredRows" :key="'desk-' + stage + '-' + row.playerId">
|
||||
<td class="mono">{{ row.playerId }}</td>
|
||||
<tr v-for="(row, index) in filteredRows" :key="'desk-' + stage + '-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row)">
|
||||
<td class="mono">{{ index + 1 }}</td>
|
||||
<td>
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||
@@ -87,7 +87,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mobile-score-cards">
|
||||
<article v-for="row in filteredRows" :key="'mob-' + stage + '-' + row.playerId" class="score-card">
|
||||
<article v-for="(row, index) in filteredRows" :key="'mob-' + stage + '-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row)">
|
||||
<div class="score-card-head">
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||
@@ -96,7 +96,7 @@
|
||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mono">#{{ row.playerId }}</div>
|
||||
<div class="mono">#{{ index + 1 }}</div>
|
||||
</div>
|
||||
|
||||
<div class="score-card-meta">
|
||||
@@ -152,6 +152,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { scoreGroupStyle, scoreValueStyle } from '../../utils/scoreGroupTheme'
|
||||
|
||||
const props = defineProps({
|
||||
t: { type: Function, required: true },
|
||||
@@ -162,6 +163,7 @@ const props = defineProps({
|
||||
showGroup: { type: Boolean, default: false },
|
||||
showRank: { type: Boolean, default: false },
|
||||
showSeed: { type: Boolean, default: false },
|
||||
colorMode: { type: String, default: 'group' },
|
||||
showScoreBeforeInput: { type: Boolean, default: false },
|
||||
inputLabel: { type: String, required: true },
|
||||
playerImage: { type: Function, required: true },
|
||||
@@ -199,4 +201,11 @@ const columnCount = computed(() => {
|
||||
if (props.showSeed) count += 1
|
||||
return count
|
||||
})
|
||||
|
||||
function scoreGroupStyleFor(row) {
|
||||
if (props.colorMode === 'score') {
|
||||
return scoreValueStyle(row?.score)
|
||||
}
|
||||
return scoreGroupStyle(row)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,7 @@ export const I18N = {
|
||||
subtitle: 'فئة المسدس · مسافات: 15م ← 20م ← 25م',
|
||||
viewMode: 'وضع العرض',
|
||||
adminMode: 'لوحة الإدارة',
|
||||
liveMode: 'الوضع الحي',
|
||||
adminLogin: 'تسجيل دخول الإدارة',
|
||||
adminLoginDesc: 'أدخل بيانات الإدارة للتحكم الكامل في البطولة.',
|
||||
adminPanel: 'لوحة التحكم الإدارية',
|
||||
@@ -30,6 +31,26 @@ export const I18N = {
|
||||
tieSlots: 'عدد المقاعد المتاحة من كسر التعادل',
|
||||
waiting: 'بانتظار النتيجة',
|
||||
viewProofInView: 'إظهار صور إثبات النتيجة في وضع العرض',
|
||||
liveModeVisibility: 'إعدادات شاشة الوضع الحي',
|
||||
activeLiveView: 'العرض النشط في الوضع الحي',
|
||||
rotationIntervalSec: 'مدة التدوير (ثانية)',
|
||||
rotationPlayerCount: 'عدد اللاعبين لكل صفحة تدوير',
|
||||
liveViewGroup: 'شاشة المجموعات الحية',
|
||||
liveViewPrelimTie: 'كسر تعادل التمهيدي',
|
||||
liveViewPrelimOverall: 'الترتيب العام للتمهيدي',
|
||||
liveViewFinalGroups: 'شاشة مجموعات النهائي',
|
||||
liveViewFinalTie: 'كسر تعادل النهائي',
|
||||
liveViewPodium: 'منصة التتويج',
|
||||
showGroupLive: 'إظهار شاشة المجموعات الحية',
|
||||
showPrelimTie: 'إظهار كسر تعادل التمهيدي',
|
||||
showPrelimOverall: 'إظهار ترتيب التمهيدي العام',
|
||||
showFinalGroups: 'إظهار مجموعات النهائي',
|
||||
showFinalTie: 'إظهار كسر تعادل النهائي',
|
||||
showPodium: 'إظهار منصة التتويج',
|
||||
groupDisplayMode: 'عرض المجموعات',
|
||||
finalDisplayMode: 'عرض مجموعات النهائي',
|
||||
fixedGroup: 'مجموعة ثابتة',
|
||||
fixedFinalGroup: 'المجموعة النهائية الثابتة',
|
||||
sortBy: 'الترتيب',
|
||||
zoom: 'التكبير',
|
||||
currentScore: 'النتيجة الحالية',
|
||||
@@ -62,12 +83,20 @@ export const I18N = {
|
||||
profileCropTitle: 'تعديل صورة اللاعب',
|
||||
profileCropSubtitle: 'حرّك وكبّر الصورة لتناسب الإطار الدائري قبل الحفظ.',
|
||||
aiAdvisorTitle: 'مساعد الذكاء الاصطناعي للنتيجة',
|
||||
liveModeTitle: 'شاشة البطولة المباشرة',
|
||||
liveModeSubtitle: 'عرض مخصص للشاشات أثناء الفعالية مع تحديث لحظي.',
|
||||
prelimRoundsTitle: 'الجولات التمهيدية',
|
||||
finalRoundsTitle: 'جولات النهائي',
|
||||
},
|
||||
table: {
|
||||
competitor: 'اللاعب',
|
||||
group: 'المجموعة',
|
||||
rank: 'الترتيب',
|
||||
score: 'النتيجة',
|
||||
total: 'المجموع',
|
||||
round1: 'جولة 1',
|
||||
round2: 'جولة 2',
|
||||
round3: 'جولة 3',
|
||||
tieScore: 'نتيجة كسر التعادل',
|
||||
verification: 'التحقق',
|
||||
status: 'الحالة',
|
||||
@@ -108,6 +137,8 @@ export const I18N = {
|
||||
applySuggestedScore: 'اعتماد النتيجة المقترحة',
|
||||
applyCrop: 'تطبيق القص',
|
||||
resetPosition: 'إعادة الضبط',
|
||||
liveModeRotate: 'تدوير تلقائي',
|
||||
liveModeFixed: 'ثابت',
|
||||
},
|
||||
auth: {
|
||||
username: 'اسم المستخدم',
|
||||
@@ -123,6 +154,7 @@ export const I18N = {
|
||||
preliminary: 'التمهيدي',
|
||||
prelimTie: 'كسر تعادل التأهل',
|
||||
finalTie: 'كسر تعادل التتويج',
|
||||
liveModeAdmin: 'إعدادات الوضع الحي',
|
||||
},
|
||||
messages: {
|
||||
saved: 'تم الحفظ بنجاح.',
|
||||
@@ -148,6 +180,7 @@ export const I18N = {
|
||||
subtitle: 'Pistol class · Distances: 15m ← 20m ← 25m',
|
||||
viewMode: 'View Mode',
|
||||
adminMode: 'Admin Panel',
|
||||
liveMode: 'Live Mode',
|
||||
adminLogin: 'Admin Login',
|
||||
adminLoginDesc: 'Enter admin credentials for full tournament control.',
|
||||
adminPanel: 'Admin Control Panel',
|
||||
@@ -174,6 +207,26 @@ export const I18N = {
|
||||
tieSlots: 'Tie-break slots',
|
||||
waiting: 'Waiting',
|
||||
viewProofInView: 'Allow proof images in view mode',
|
||||
liveModeVisibility: 'Live mode screen controls',
|
||||
activeLiveView: 'Active live view',
|
||||
rotationIntervalSec: 'Rotation interval (sec)',
|
||||
rotationPlayerCount: 'Players per rotation page',
|
||||
liveViewGroup: 'Live group board',
|
||||
liveViewPrelimTie: 'Preliminary tie-break',
|
||||
liveViewPrelimOverall: 'Preliminary overall',
|
||||
liveViewFinalGroups: 'Final groups board',
|
||||
liveViewFinalTie: 'Final tie-break',
|
||||
liveViewPodium: 'Podium',
|
||||
showGroupLive: 'Show live group board',
|
||||
showPrelimTie: 'Show preliminary tie-break',
|
||||
showPrelimOverall: 'Show preliminary overall ranking',
|
||||
showFinalGroups: 'Show final groups board',
|
||||
showFinalTie: 'Show final tie-break',
|
||||
showPodium: 'Show podium',
|
||||
groupDisplayMode: 'Group board mode',
|
||||
finalDisplayMode: 'Final board mode',
|
||||
fixedGroup: 'Fixed group',
|
||||
fixedFinalGroup: 'Fixed final group',
|
||||
sortBy: 'Sort by',
|
||||
zoom: 'Zoom',
|
||||
currentScore: 'Current score',
|
||||
@@ -206,12 +259,20 @@ export const I18N = {
|
||||
profileCropTitle: 'Adjust Player Photo',
|
||||
profileCropSubtitle: 'Move and zoom to fit the circular avatar before saving.',
|
||||
aiAdvisorTitle: 'AI Score Advisor',
|
||||
liveModeTitle: 'Event Live Screen',
|
||||
liveModeSubtitle: 'Monitor-ready display with real-time updates.',
|
||||
prelimRoundsTitle: 'Preliminary Rounds',
|
||||
finalRoundsTitle: 'Final Rounds',
|
||||
},
|
||||
table: {
|
||||
competitor: 'Player',
|
||||
group: 'Group',
|
||||
rank: 'Rank',
|
||||
score: 'Score',
|
||||
total: 'Total',
|
||||
round1: 'Round 1',
|
||||
round2: 'Round 2',
|
||||
round3: 'Round 3',
|
||||
tieScore: 'Tie-Break Score',
|
||||
verification: 'Verification',
|
||||
status: 'Status',
|
||||
@@ -252,6 +313,8 @@ export const I18N = {
|
||||
applySuggestedScore: 'Apply suggested score',
|
||||
applyCrop: 'Apply crop',
|
||||
resetPosition: 'Reset position',
|
||||
liveModeRotate: 'Auto rotate',
|
||||
liveModeFixed: 'Fixed',
|
||||
},
|
||||
auth: {
|
||||
username: 'Username',
|
||||
@@ -267,6 +330,7 @@ export const I18N = {
|
||||
preliminary: 'Preliminary',
|
||||
prelimTie: 'Prelim Tie-Break',
|
||||
finalTie: 'Final Tie-Break',
|
||||
liveModeAdmin: 'Live Mode Settings',
|
||||
},
|
||||
messages: {
|
||||
saved: 'Saved successfully.',
|
||||
@@ -294,13 +358,33 @@ export function createInitialState() {
|
||||
competition: { titleAr: '', titleEn: '' },
|
||||
players: [],
|
||||
scores: {
|
||||
preliminary: {},
|
||||
prelim_r1: {},
|
||||
prelim_r2: {},
|
||||
prelim_r3: {},
|
||||
prelim_tiebreak: {},
|
||||
final: {},
|
||||
final_r1: {},
|
||||
final_r2: {},
|
||||
final_tiebreak: {},
|
||||
},
|
||||
scoreProofs: {},
|
||||
settings: { viewProofInView: false },
|
||||
settings: {
|
||||
viewProofInView: false,
|
||||
liveMode: {
|
||||
activeView: 'group_live',
|
||||
showGroupLive: true,
|
||||
groupDisplayMode: 'rotate',
|
||||
groupFixedCode: '',
|
||||
showPrelimTie: true,
|
||||
showPrelimOverall: true,
|
||||
showFinalGroups: true,
|
||||
finalDisplayMode: 'rotate',
|
||||
finalFixedGroup: 1,
|
||||
showFinalTie: true,
|
||||
showPodium: true,
|
||||
rotationIntervalSec: 5,
|
||||
rotationPlayerCount: 12,
|
||||
},
|
||||
},
|
||||
derived: {
|
||||
preliminaryRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } },
|
||||
finalists: [],
|
||||
|
||||
@@ -71,6 +71,18 @@ body {
|
||||
padding: 24px 28px 26px;
|
||||
}
|
||||
|
||||
.masthead-brand {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.masthead-logo {
|
||||
display: block;
|
||||
width: min(420px, 68vw);
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.masthead-title {
|
||||
margin: 0;
|
||||
font-size: clamp(30px, 5vw, 44px);
|
||||
@@ -97,6 +109,65 @@ body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.controls-popup-anchor {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.control-toggle-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.control-toggle-btn {
|
||||
border: 1px solid #cfd6e7;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%);
|
||||
border-radius: 12px;
|
||||
padding: 6px 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #445478;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(27, 31, 64, 0.08);
|
||||
}
|
||||
|
||||
.control-toggle-btn:hover {
|
||||
background: linear-gradient(180deg, #f0f6ff 0%, #e6f0ff 100%);
|
||||
}
|
||||
|
||||
.control-toggle-meta {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
color: #6c7a97;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.controls-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
inset-inline-end: 0;
|
||||
min-width: 270px;
|
||||
z-index: 30;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
box-shadow: 0 14px 28px rgba(27, 31, 64, 0.12);
|
||||
}
|
||||
|
||||
.controls-reveal-enter-active,
|
||||
.controls-reveal-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.controls-reveal-enter-from,
|
||||
.controls-reveal-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.98);
|
||||
}
|
||||
|
||||
.control-box {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -256,6 +327,35 @@ body {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.live-settings-card {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.live-settings-head h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.live-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.live-settings-row {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.inline-settings {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -537,6 +637,15 @@ body {
|
||||
background: rgba(154, 166, 194, 0.08);
|
||||
}
|
||||
|
||||
.score-group-row {
|
||||
background: linear-gradient(90deg, var(--score-group-soft, rgba(127, 140, 168, 0.08)) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
}
|
||||
|
||||
.score-group-row td:first-child {
|
||||
box-shadow: inset 4px 0 0 var(--score-group-accent, #7f8ca8);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.qualified-row {
|
||||
background: linear-gradient(90deg, rgba(0, 140, 168, 0.09), rgba(0, 140, 168, 0.02));
|
||||
}
|
||||
@@ -1014,6 +1123,330 @@ select.name-input {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.live-mode-shell {
|
||||
position: relative;
|
||||
min-height: 72vh;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background-image: url('/bg_live.png');
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
box-shadow: 0 16px 38px rgba(7, 19, 42, 0.24);
|
||||
}
|
||||
|
||||
.live-mode-overlay {
|
||||
min-height: 72vh;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, rgba(7, 18, 43, 0.72) 0%, rgba(9, 27, 56, 0.6) 45%, rgba(12, 30, 68, 0.66) 100%);
|
||||
}
|
||||
|
||||
.live-mode-header h2 {
|
||||
margin: 0;
|
||||
color: #f8d78b;
|
||||
font-size: clamp(22px, 3vw, 34px);
|
||||
}
|
||||
|
||||
.live-mode-header p {
|
||||
margin: 6px 0 12px;
|
||||
color: rgba(240, 248, 255, 0.84);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.live-mode-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.live-mode-grid.single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.live-screen-card {
|
||||
border: 1px solid rgba(200, 223, 255, 0.28);
|
||||
border-radius: 14px;
|
||||
background: rgba(8, 16, 38, 0.62);
|
||||
box-shadow: 0 8px 18px rgba(7, 19, 42, 0.18);
|
||||
backdrop-filter: blur(3px);
|
||||
padding: 10px;
|
||||
animation: fade-up 0.32s ease both;
|
||||
}
|
||||
|
||||
.live-screen-card.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.live-swap-block {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.live-focus-banner {
|
||||
border: 1px solid rgba(168, 205, 242, 0.4);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(16, 32, 72, 0.86) 0%, rgba(11, 27, 60, 0.84) 100%);
|
||||
padding: 10px 14px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 18px rgba(7, 19, 42, 0.28);
|
||||
}
|
||||
|
||||
.live-focus-label {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.9px;
|
||||
color: rgba(201, 224, 248, 0.82);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.live-focus-title {
|
||||
font-family: "Bebas Neue", "Cairo", sans-serif;
|
||||
font-size: clamp(34px, 6vw, 58px);
|
||||
line-height: 0.95;
|
||||
letter-spacing: 1.2px;
|
||||
color: #f6d68a;
|
||||
text-shadow: 0 3px 10px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.live-focus-banner.focus-a .live-focus-title {
|
||||
color: #98c4ff;
|
||||
}
|
||||
|
||||
.live-focus-banner.focus-b .live-focus-title {
|
||||
color: #8de6ff;
|
||||
}
|
||||
|
||||
.live-focus-banner.focus-c .live-focus-title {
|
||||
color: #ffad9d;
|
||||
}
|
||||
|
||||
.live-focus-banner.focus-d .live-focus-title {
|
||||
color: #ffd98f;
|
||||
}
|
||||
|
||||
.live-focus-banner.focus-u .live-focus-title {
|
||||
color: #d9e3f2;
|
||||
}
|
||||
|
||||
.live-focus-banner.focus-final .live-focus-title {
|
||||
color: #f9e3a1;
|
||||
}
|
||||
|
||||
.live-screen-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.live-head-selection {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.live-screen-head h3 {
|
||||
margin: 0;
|
||||
color: #eaf4ff;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.live-chip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.live-chip {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(130, 187, 230, 0.38);
|
||||
background: rgba(27, 39, 75, 0.72);
|
||||
color: #cae9ff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.live-chip.strong {
|
||||
border-color: rgba(255, 214, 124, 0.4);
|
||||
color: #ffd67c;
|
||||
}
|
||||
|
||||
.live-swap-enter-active,
|
||||
.live-swap-leave-active {
|
||||
transition: opacity 0.34s ease, transform 0.34s ease, filter 0.34s ease;
|
||||
}
|
||||
|
||||
.live-swap-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.985);
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.live-swap-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.99);
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
.multi-round-cell {
|
||||
min-width: 98px;
|
||||
}
|
||||
|
||||
.score-input-compact {
|
||||
width: 84px;
|
||||
min-height: 36px;
|
||||
padding: 7px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mobile-round-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mobile-round-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mobile-total-line {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.verify-round-cell {
|
||||
min-width: 210px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.verify-round-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.verify-round-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.verify-round-label {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mobile-verify-block {
|
||||
margin-top: 10px;
|
||||
border-top: 1px dashed #d8e1f2;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.mobile-verify-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.verify-round-list.mobile .verify-round-item {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.verify-round-list.mobile .btn,
|
||||
.verify-round-list.mobile .proof-thumb {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.verify-round-list.mobile .proof-thumb {
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
|
||||
.live-score-table thead th {
|
||||
background: linear-gradient(90deg, #11254a 0%, #124665 100%);
|
||||
}
|
||||
|
||||
.live-screen-card .score-table td {
|
||||
color: #f2f8ff;
|
||||
border-top-color: rgba(158, 186, 216, 0.22);
|
||||
}
|
||||
|
||||
.live-screen-card .name-sub,
|
||||
.live-screen-card .muted {
|
||||
color: rgba(213, 232, 255, 0.72);
|
||||
}
|
||||
|
||||
.live-screen-card .table-wrap {
|
||||
border-color: rgba(158, 186, 216, 0.28);
|
||||
background: rgba(11, 24, 49, 0.65);
|
||||
}
|
||||
|
||||
.live-screen-card .qualified-row {
|
||||
background: linear-gradient(90deg, rgba(16, 160, 194, 0.24), rgba(16, 160, 194, 0.06));
|
||||
}
|
||||
|
||||
.live-podium {
|
||||
margin-top: 60px;
|
||||
height: 390px;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col {
|
||||
background: linear-gradient(180deg, rgba(12, 23, 52, 0.95) 0%, rgba(9, 19, 43, 0.95) 100%);
|
||||
border: 1px solid rgba(177, 205, 236, 0.26);
|
||||
box-shadow: 0 16px 30px rgba(2, 10, 25, 0.45);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-1 {
|
||||
border-top: 8px solid #e7c45f;
|
||||
background: linear-gradient(180deg, rgba(48, 38, 15, 0.95) 0%, rgba(18, 24, 48, 0.96) 100%);
|
||||
height: 330px;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-2 {
|
||||
border-top: 8px solid #b8c3d7;
|
||||
background: linear-gradient(180deg, rgba(37, 44, 62, 0.95) 0%, rgba(12, 20, 43, 0.96) 100%);
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-3 {
|
||||
border-top: 8px solid #cf8b45;
|
||||
background: linear-gradient(180deg, rgba(59, 36, 23, 0.95) 0%, rgba(14, 22, 45, 0.96) 100%);
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-name {
|
||||
color: #f2f8ff;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-name.empty {
|
||||
color: rgba(204, 220, 240, 0.78);
|
||||
}
|
||||
|
||||
.podium-live-card .podium-score {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ffd888;
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-medal-icon {
|
||||
filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
|
||||
.podium-panel {
|
||||
background: #f0f2f8;
|
||||
}
|
||||
@@ -1137,17 +1570,47 @@ select.name-input {
|
||||
}
|
||||
|
||||
.podium-score {
|
||||
margin-top: auto;
|
||||
margin-bottom: 25px;
|
||||
margin: 0;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
padding: 8px 20px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--red);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
width: fit-content;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.podium-score-wrap {
|
||||
margin-top: auto;
|
||||
margin-bottom: 12px;
|
||||
width: calc(100% - 16px);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.podium-score-sub {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-score-sub {
|
||||
color: rgba(220, 234, 252, 0.86);
|
||||
}
|
||||
|
||||
.toast {
|
||||
@@ -1259,6 +1722,22 @@ select.name-input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.score-group-card {
|
||||
border-color: var(--score-group-border, var(--border));
|
||||
background: linear-gradient(140deg, var(--score-group-soft, rgba(127, 140, 168, 0.08)) 0%, #ffffff 72%);
|
||||
box-shadow: 0 8px 18px rgba(27, 31, 64, 0.08);
|
||||
}
|
||||
|
||||
.score-group-card .score-card-head {
|
||||
border-bottom: 1px dashed var(--score-group-border, #d2daec);
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.score-group-card .score-card-meta {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.score-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1605,12 +2084,37 @@ select.name-input {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.masthead-logo {
|
||||
width: min(360px, 86vw);
|
||||
}
|
||||
|
||||
.masthead-controls {
|
||||
justify-items: start;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.control-toggle-row {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.controls-popup-anchor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controls-popover {
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.control-toggle-btn {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
grid-auto-columns: minmax(126px, 1fr);
|
||||
}
|
||||
@@ -1649,6 +2153,11 @@ select.name-input {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.live-settings-grid,
|
||||
.inline-settings {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.view-group-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1661,6 +2170,29 @@ select.name-input {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.live-mode-shell {
|
||||
border-radius: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.live-mode-overlay {
|
||||
padding: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.live-mode-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.live-screen-card.wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.live-screen-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
59
frontend/src/utils/scoreGroupTheme.js
Normal file
59
frontend/src/utils/scoreGroupTheme.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { normalizedGroupCode } from './groups'
|
||||
|
||||
const PRESET = {
|
||||
A: { accent: '#2f4d9a', soft: 'rgba(47, 77, 154, 0.10)', border: 'rgba(47, 77, 154, 0.34)' },
|
||||
B: { accent: '#0d8fa5', soft: 'rgba(13, 143, 165, 0.10)', border: 'rgba(13, 143, 165, 0.34)' },
|
||||
C: { accent: '#d54a3f', soft: 'rgba(213, 74, 63, 0.10)', border: 'rgba(213, 74, 63, 0.34)' },
|
||||
D: { accent: '#c2891f', soft: 'rgba(194, 137, 31, 0.12)', border: 'rgba(194, 137, 31, 0.36)' },
|
||||
F1: { accent: '#5b4fc9', soft: 'rgba(91, 79, 201, 0.11)', border: 'rgba(91, 79, 201, 0.36)' },
|
||||
F2: { accent: '#0f9f63', soft: 'rgba(15, 159, 99, 0.11)', border: 'rgba(15, 159, 99, 0.36)' },
|
||||
U: { accent: '#7f8ca8', soft: 'rgba(127, 140, 168, 0.11)', border: 'rgba(127, 140, 168, 0.34)' },
|
||||
}
|
||||
|
||||
export function scoreGroupToken(row) {
|
||||
const finalGroup = Number(row?.finalGroup || 0)
|
||||
if (finalGroup === 1) return 'F1'
|
||||
if (finalGroup === 2) return 'F2'
|
||||
const code = normalizedGroupCode(row?.groupCode || '')
|
||||
if (code) return code
|
||||
return 'U'
|
||||
}
|
||||
|
||||
export function scoreGroupStyle(row) {
|
||||
const token = scoreGroupToken(row)
|
||||
const preset = PRESET[token]
|
||||
if (preset) {
|
||||
return {
|
||||
'--score-group-accent': preset.accent,
|
||||
'--score-group-soft': preset.soft,
|
||||
'--score-group-border': preset.border,
|
||||
}
|
||||
}
|
||||
|
||||
const hue = hashToHue(token)
|
||||
return {
|
||||
'--score-group-accent': `hsl(${hue} 70% 38%)`,
|
||||
'--score-group-soft': `hsla(${hue}, 72%, 46%, 0.10)`,
|
||||
'--score-group-border': `hsla(${hue}, 70%, 38%, 0.34)`,
|
||||
}
|
||||
}
|
||||
|
||||
export function scoreValueStyle(scoreValue) {
|
||||
const score = Number(scoreValue || 0)
|
||||
const normalized = Number.isFinite(score) ? score : 0
|
||||
const hue = hashToHue(`score:${normalized}`)
|
||||
return {
|
||||
'--score-group-accent': `hsl(${hue} 70% 38%)`,
|
||||
'--score-group-soft': `hsla(${hue}, 72%, 46%, 0.10)`,
|
||||
'--score-group-border': `hsla(${hue}, 70%, 38%, 0.34)`,
|
||||
}
|
||||
}
|
||||
|
||||
function hashToHue(value) {
|
||||
const input = String(value || 'U')
|
||||
let hash = 0
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash * 31 + input.charCodeAt(i)) % 360
|
||||
}
|
||||
return (hash + 360) % 360
|
||||
}
|
||||
Reference in New Issue
Block a user