This commit is contained in:
2026-04-03 09:55:36 +04:00
parent 2465bc2ec3
commit 27143319e3
22 changed files with 2542 additions and 173 deletions

View File

@@ -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>

View File

@@ -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',

View File

@@ -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>

View 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>

View File

@@ -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 },

View 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>

View 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>

View File

@@ -107,7 +107,6 @@
/>
</div>
</div>
<div class="mono">#{{ player.id }}</div>
</div>
<div class="panel-actions compact name-convert-row">

View File

@@ -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>

View File

@@ -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: [],

View File

@@ -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;
}

View 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
}