This commit is contained in:
2026-04-01 11:47:03 +04:00
parent cb68451c1c
commit 2465bc2ec3
43 changed files with 8210 additions and 0 deletions

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shooting Event Tracker</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Cairo:wght@400;600;700;800&family=IBM+Plex+Mono:wght@500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "shooting-event-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173"
},
"dependencies": {
"vue": "^3.5.22"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^5.4.19"
},
"engines": {
"node": ">=20"
}
}

747
frontend/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,747 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
vue:
specifier: ^3.5.22
version: 3.5.31
devDependencies:
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.4(vite@5.4.21)(vue@3.5.31)
vite:
specifier: ^5.4.19
version: 5.4.21
packages:
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.2':
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.21.5':
resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.21.5':
resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.21.5':
resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.21.5':
resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.21.5':
resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.21.5':
resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.21.5':
resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.21.5':
resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.21.5':
resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.21.5':
resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.21.5':
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.21.5':
resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.21.5':
resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.21.5':
resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.21.5':
resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.21.5':
resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-x64@0.21.5':
resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-x64@0.21.5':
resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.21.5':
resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.21.5':
resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.21.5':
resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.21.5':
resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@rollup/rollup-android-arm-eabi@4.60.1':
resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.60.1':
resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.60.1':
resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.60.1':
resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.60.1':
resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.60.1':
resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.60.1':
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.60.1':
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.60.1':
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loong64-gnu@4.60.1':
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-loong64-musl@4.60.1':
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-ppc64-musl@4.60.1':
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.60.1':
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.60.1':
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.60.1':
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.60.1':
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
cpu: [x64]
os: [linux]
'@rollup/rollup-openbsd-x64@4.60.1':
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.60.1':
resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.60.1':
resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.60.1':
resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.60.1':
resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.60.1':
resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==}
cpu: [x64]
os: [win32]
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@vitejs/plugin-vue@5.2.4':
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
'@vue/compiler-core@3.5.31':
resolution: {integrity: sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==}
'@vue/compiler-dom@3.5.31':
resolution: {integrity: sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==}
'@vue/compiler-sfc@3.5.31':
resolution: {integrity: sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==}
'@vue/compiler-ssr@3.5.31':
resolution: {integrity: sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==}
'@vue/reactivity@3.5.31':
resolution: {integrity: sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==}
'@vue/runtime-core@3.5.31':
resolution: {integrity: sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==}
'@vue/runtime-dom@3.5.31':
resolution: {integrity: sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==}
'@vue/server-renderer@3.5.31':
resolution: {integrity: sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==}
peerDependencies:
vue: 3.5.31
'@vue/shared@3.5.31':
resolution: {integrity: sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
hasBin: true
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
rollup@4.60.1:
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@types/node': ^18.0.0 || >=20.0.0
less: '*'
lightningcss: ^1.21.0
sass: '*'
sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
vue@3.5.31:
resolution: {integrity: sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
snapshots:
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.29.2':
dependencies:
'@babel/types': 7.29.0
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@esbuild/aix-ppc64@0.21.5':
optional: true
'@esbuild/android-arm64@0.21.5':
optional: true
'@esbuild/android-arm@0.21.5':
optional: true
'@esbuild/android-x64@0.21.5':
optional: true
'@esbuild/darwin-arm64@0.21.5':
optional: true
'@esbuild/darwin-x64@0.21.5':
optional: true
'@esbuild/freebsd-arm64@0.21.5':
optional: true
'@esbuild/freebsd-x64@0.21.5':
optional: true
'@esbuild/linux-arm64@0.21.5':
optional: true
'@esbuild/linux-arm@0.21.5':
optional: true
'@esbuild/linux-ia32@0.21.5':
optional: true
'@esbuild/linux-loong64@0.21.5':
optional: true
'@esbuild/linux-mips64el@0.21.5':
optional: true
'@esbuild/linux-ppc64@0.21.5':
optional: true
'@esbuild/linux-riscv64@0.21.5':
optional: true
'@esbuild/linux-s390x@0.21.5':
optional: true
'@esbuild/linux-x64@0.21.5':
optional: true
'@esbuild/netbsd-x64@0.21.5':
optional: true
'@esbuild/openbsd-x64@0.21.5':
optional: true
'@esbuild/sunos-x64@0.21.5':
optional: true
'@esbuild/win32-arm64@0.21.5':
optional: true
'@esbuild/win32-ia32@0.21.5':
optional: true
'@esbuild/win32-x64@0.21.5':
optional: true
'@jridgewell/sourcemap-codec@1.5.5': {}
'@rollup/rollup-android-arm-eabi@4.60.1':
optional: true
'@rollup/rollup-android-arm64@4.60.1':
optional: true
'@rollup/rollup-darwin-arm64@4.60.1':
optional: true
'@rollup/rollup-darwin-x64@4.60.1':
optional: true
'@rollup/rollup-freebsd-arm64@4.60.1':
optional: true
'@rollup/rollup-freebsd-x64@4.60.1':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.60.1':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-arm64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-loong64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-x64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-x64-musl@4.60.1':
optional: true
'@rollup/rollup-openbsd-x64@4.60.1':
optional: true
'@rollup/rollup-openharmony-arm64@4.60.1':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.60.1':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.60.1':
optional: true
'@rollup/rollup-win32-x64-gnu@4.60.1':
optional: true
'@rollup/rollup-win32-x64-msvc@4.60.1':
optional: true
'@types/estree@1.0.8': {}
'@vitejs/plugin-vue@5.2.4(vite@5.4.21)(vue@3.5.31)':
dependencies:
vite: 5.4.21
vue: 3.5.31
'@vue/compiler-core@3.5.31':
dependencies:
'@babel/parser': 7.29.2
'@vue/shared': 3.5.31
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.31':
dependencies:
'@vue/compiler-core': 3.5.31
'@vue/shared': 3.5.31
'@vue/compiler-sfc@3.5.31':
dependencies:
'@babel/parser': 7.29.2
'@vue/compiler-core': 3.5.31
'@vue/compiler-dom': 3.5.31
'@vue/compiler-ssr': 3.5.31
'@vue/shared': 3.5.31
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.8
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.31':
dependencies:
'@vue/compiler-dom': 3.5.31
'@vue/shared': 3.5.31
'@vue/reactivity@3.5.31':
dependencies:
'@vue/shared': 3.5.31
'@vue/runtime-core@3.5.31':
dependencies:
'@vue/reactivity': 3.5.31
'@vue/shared': 3.5.31
'@vue/runtime-dom@3.5.31':
dependencies:
'@vue/reactivity': 3.5.31
'@vue/runtime-core': 3.5.31
'@vue/shared': 3.5.31
csstype: 3.2.3
'@vue/server-renderer@3.5.31(vue@3.5.31)':
dependencies:
'@vue/compiler-ssr': 3.5.31
'@vue/shared': 3.5.31
vue: 3.5.31
'@vue/shared@3.5.31': {}
csstype@3.2.3: {}
entities@7.0.1: {}
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
'@esbuild/android-arm': 0.21.5
'@esbuild/android-arm64': 0.21.5
'@esbuild/android-x64': 0.21.5
'@esbuild/darwin-arm64': 0.21.5
'@esbuild/darwin-x64': 0.21.5
'@esbuild/freebsd-arm64': 0.21.5
'@esbuild/freebsd-x64': 0.21.5
'@esbuild/linux-arm': 0.21.5
'@esbuild/linux-arm64': 0.21.5
'@esbuild/linux-ia32': 0.21.5
'@esbuild/linux-loong64': 0.21.5
'@esbuild/linux-mips64el': 0.21.5
'@esbuild/linux-ppc64': 0.21.5
'@esbuild/linux-riscv64': 0.21.5
'@esbuild/linux-s390x': 0.21.5
'@esbuild/linux-x64': 0.21.5
'@esbuild/netbsd-x64': 0.21.5
'@esbuild/openbsd-x64': 0.21.5
'@esbuild/sunos-x64': 0.21.5
'@esbuild/win32-arm64': 0.21.5
'@esbuild/win32-ia32': 0.21.5
'@esbuild/win32-x64': 0.21.5
estree-walker@2.0.2: {}
fsevents@2.3.3:
optional: true
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
nanoid@3.3.11: {}
picocolors@1.1.1: {}
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
rollup@4.60.1:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.60.1
'@rollup/rollup-android-arm64': 4.60.1
'@rollup/rollup-darwin-arm64': 4.60.1
'@rollup/rollup-darwin-x64': 4.60.1
'@rollup/rollup-freebsd-arm64': 4.60.1
'@rollup/rollup-freebsd-x64': 4.60.1
'@rollup/rollup-linux-arm-gnueabihf': 4.60.1
'@rollup/rollup-linux-arm-musleabihf': 4.60.1
'@rollup/rollup-linux-arm64-gnu': 4.60.1
'@rollup/rollup-linux-arm64-musl': 4.60.1
'@rollup/rollup-linux-loong64-gnu': 4.60.1
'@rollup/rollup-linux-loong64-musl': 4.60.1
'@rollup/rollup-linux-ppc64-gnu': 4.60.1
'@rollup/rollup-linux-ppc64-musl': 4.60.1
'@rollup/rollup-linux-riscv64-gnu': 4.60.1
'@rollup/rollup-linux-riscv64-musl': 4.60.1
'@rollup/rollup-linux-s390x-gnu': 4.60.1
'@rollup/rollup-linux-x64-gnu': 4.60.1
'@rollup/rollup-linux-x64-musl': 4.60.1
'@rollup/rollup-openbsd-x64': 4.60.1
'@rollup/rollup-openharmony-arm64': 4.60.1
'@rollup/rollup-win32-arm64-msvc': 4.60.1
'@rollup/rollup-win32-ia32-msvc': 4.60.1
'@rollup/rollup-win32-x64-gnu': 4.60.1
'@rollup/rollup-win32-x64-msvc': 4.60.1
fsevents: 2.3.3
source-map-js@1.2.1: {}
vite@5.4.21:
dependencies:
esbuild: 0.21.5
postcss: 8.5.8
rollup: 4.60.1
optionalDependencies:
fsevents: 2.3.3
vue@3.5.31:
dependencies:
'@vue/compiler-dom': 3.5.31
'@vue/compiler-sfc': 3.5.31
'@vue/runtime-dom': 3.5.31
'@vue/server-renderer': 3.5.31(vue@3.5.31)
'@vue/shared': 3.5.31

1195
frontend/src/App.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
<template>
<section class="panel admin-login-panel">
<div class="panel-heading">
<h2>{{ t('adminLogin') }}</h2>
<p>{{ t('adminLoginDesc') }}</p>
</div>
<div class="admin-login-grid">
<input :value="username" class="name-input" :placeholder="t('auth.username')" @input="$emit('update:username', $event.target.value)" />
<input
:value="password"
type="password"
class="name-input"
:placeholder="t('auth.password')"
@input="$emit('update:password', $event.target.value)"
@keyup.enter="$emit('submit')"
/>
<button class="btn btn-primary" @click="$emit('submit')">{{ t('actions.login') }}</button>
</div>
<p class="auth-error" v-if="error">{{ error }}</p>
</section>
</template>
<script setup>
defineProps({
t: { type: Function, required: true },
username: { type: String, default: '' },
password: { type: String, default: '' },
error: { type: String, default: '' },
})
defineEmits(['submit', 'update:username', 'update:password'])
</script>

View File

@@ -0,0 +1,355 @@
<template>
<div>
<section class="panel admin-head-panel">
<div class="admin-head">
<div>
<h2>{{ t('adminPanel') }}</h2>
<p class="muted">{{ t('adminPanelDesc') }}</p>
</div>
<div class="panel-actions">
<button class="btn btn-secondary" @click="$emit('refresh')">{{ t('actions.refresh') }}</button>
<button class="btn btn-danger" @click="$emit('logout')">{{ t('actions.logout') }}</button>
</div>
</div>
<div class="admin-settings-row">
<label class="switch-row">
<input type="checkbox" :checked="viewProofInView" @change="$emit('toggle-view-proof', $event.target.checked)" />
<span>{{ t('labels.viewProofInView') }}</span>
</label>
</div>
</section>
<nav class="tab-bar admin-tab-bar">
<button v-for="tab in adminTabs" :key="tab.id" class="tab-btn" :class="{ active: adminTab === tab.id }" @click="$emit('change-admin-tab', tab.id)">
{{ tab.label }}
</button>
</nav>
<PlayersManagementTab
v-show="adminTab === 'players'"
:t="t"
:group-setup-input="groupSetupInput"
:admin-group-cards="adminGroupCards"
:new-player="newPlayer"
:assignable-groups="assignableGroups"
:players-sorted="playersSorted"
:player-filter="playerFilter"
:player-sort="playerSort"
:player-image="playerImage"
:group-option-label="groupOptionLabel"
:normalized-group-code="normalizedGroupCode"
@update:group-setup-input="$emit('update:group-setup-input', $event)"
@save-group-setup="$emit('save-group-setup')"
@auto-group-even="$emit('auto-group-even')"
@update:new-player="$emit('update:new-player', $event)"
@create-player="$emit('create-player')"
@convert-new-name="$emit('convert-new-name', $event)"
@update:player-filter="$emit('update:player-filter', $event)"
@update:player-sort="$emit('update:player-sort', $event)"
@open-image-uploader="$emit('open-image-uploader', $event)"
@update-player-field="$emit('update-player-field', $event)"
@update-player-group="$emit('update-player-group', $event)"
@convert-row-name="$emit('convert-row-name', $event)"
@remove-player-image="$emit('remove-player-image', $event)"
@delete-player="$emit('delete-player', $event)"
/>
<section v-show="adminTab === 'preliminary'" class="panel">
<div class="panel-heading">
<h2>{{ t('sections.preliminaryAdminTitle') }}</h2>
<p>{{ t('sections.preliminaryAdminSubtitle') }}</p>
</div>
<div class="panel-actions">
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'preliminary')">{{ t('actions.resetScores') }}</button>
</div>
<ScoreStageEditor
:t="t"
stage="preliminary"
:rows="preliminaryRows"
:filter-text="scoreFilters.preliminary"
:show-group="true"
:show-rank="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: 'preliminary', value: $event })"
/>
</section>
<section v-show="adminTab === 'prelimTie'" class="panel">
<div class="panel-heading">
<h2>{{ t('sections.prelimTieTitle') }}</h2>
<p>{{ t('sections.prelimTieSubtitle') }}</p>
</div>
<div class="hint-box" v-if="prelimTie.required">
{{ t('labels.tieSlots') }}: {{ prelimTie.slots }}
</div>
<div class="hint-box danger" v-if="prelimTie.required && !prelimTie.resolved">
{{ t('messages.prelimTieUnresolved') }}
</div>
<div class="empty-state good" v-if="!prelimTie.required">{{ t('messages.noPrelimTie') }}</div>
<template v-if="prelimTie.required">
<div class="panel-actions">
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'prelim_tiebreak')">{{ t('actions.resetScores') }}</button>
</div>
<ScoreStageEditor
:t="t"
stage="prelim_tiebreak"
:rows="prelimTieRows"
:filter-text="scoreFilters.prelim_tiebreak"
:show-score-before-input="true"
:input-label="t('table.tieScore')"
: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: 'prelim_tiebreak', value: $event })"
/>
</template>
</section>
<section v-show="adminTab === 'final'" class="panel">
<div class="panel-heading">
<h2>{{ t('sections.finalAdminTitle') }}</h2>
<p>{{ t('sections.finalAdminSubtitle') }}</p>
</div>
<div class="panel-actions">
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'final')">{{ t('actions.resetScores') }}</button>
</div>
<div class="stage-filter-bar">
<input
class="name-input"
:value="scoreFilters.final"
:placeholder="t('actions.searchPlayer')"
@input="$emit('update-score-filter', { stage: 'final', 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>
</section>
<section v-show="adminTab === 'finalTie'" class="panel">
<div class="panel-heading">
<h2>{{ t('sections.finalTieTitle') }}</h2>
<p>{{ t('sections.finalTieSubtitle') }}</p>
</div>
<div class="hint-box danger" v-if="finalTie.required && !finalTie.resolved">{{ t('messages.finalTieUnresolved') }}</div>
<div class="empty-state good" v-if="!finalTie.required">{{ t('messages.noFinalTie') }}</div>
<template v-if="finalTie.required">
<div class="panel-actions">
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'final_tiebreak')">{{ t('actions.resetScores') }}</button>
</div>
<ScoreStageEditor
:t="t"
stage="final_tiebreak"
:rows="finalTieRows"
:filter-text="scoreFilters.final_tiebreak"
:show-score-before-input="true"
:input-label="t('table.tieScore')"
: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_tiebreak', value: $event })"
/>
</template>
<h3 class="sub-heading mt-32">{{ t('sections.finalRanking') }}</h3>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.rank') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.score') }}</th>
<th>{{ t('table.tieScore') }}</th>
<th>{{ t('table.medal') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in finalRows" :key="'a-fr-' + row.playerId" :class="{ 'podium-row': row.rank <= 3 }">
<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>
<td>
<span v-if="row.rank === 1">🥇</span>
<span v-else-if="row.rank === 2">🥈</span>
<span v-else-if="row.rank === 3">🥉</span>
<span v-else class="muted"></span>
</td>
</tr>
<tr v-if="finalRows.length === 0"><td colspan="5" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
<script setup>
import PlayersManagementTab from './admin/PlayersManagementTab.vue'
import ScoreStageEditor from './admin/ScoreStageEditor.vue'
defineProps({
t: { type: Function, required: true },
adminTabs: { type: Array, required: true },
adminTab: { type: String, required: true },
viewProofInView: { type: Boolean, default: false },
groupSetupInput: { type: String, default: '' },
adminGroupCards: { type: Array, required: true },
newPlayer: { type: Object, required: true },
assignableGroups: { type: Array, required: true },
playersSorted: { type: Array, required: true },
playerFilter: { type: String, default: '' },
playerSort: { type: String, default: 'id' },
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 },
finalRows: { type: Array, required: true },
prelimTie: { type: Object, required: true },
finalTie: { type: Object, required: true },
scoreFilters: { type: Object, 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 },
removeScoreProof: { type: Function, required: true },
openProofPreview: { type: Function, required: true },
requestScoreAdvice: { type: Function, required: true },
})
defineEmits([
'refresh',
'logout',
'toggle-view-proof',
'change-admin-tab',
'update:group-setup-input',
'save-group-setup',
'auto-group-even',
'update:new-player',
'create-player',
'convert-new-name',
'update:player-filter',
'update:player-sort',
'open-image-uploader',
'update-player-field',
'update-player-group',
'convert-row-name',
'remove-player-image',
'delete-player',
'request-reset-stage',
'update-score-filter',
])
</script>

View File

@@ -0,0 +1,47 @@
<template>
<header class="masthead">
<div class="masthead-main">
<div>
<h1 class="masthead-title">{{ competitionTitle }}</h1>
<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>
<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>
</div>
</div>
</div>
<div class="status-row">
<div class="live-badge">{{ mode === 'view' ? '● ' + t('labels.liveTracker') : t('adminPanel') }}</div>
<div class="server-time" v-if="serverTime">{{ t('labels.lastSync') }}: {{ serverTime }}</div>
</div>
</div>
</div>
</header>
</template>
<script setup>
defineProps({
t: { type: Function, required: true },
competitionTitle: { type: String, required: true },
mode: { type: String, required: true },
language: { type: String, required: true },
serverTime: { type: String, default: '' },
})
defineEmits(['change-mode', 'change-language'])
</script>

View File

@@ -0,0 +1,190 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
<div class="modal-card image-crop-modal">
<div class="modal-head">
<h3>{{ t('sections.profileCropTitle') }}</h3>
<button class="btn btn-outline btn-xs" @click="$emit('close')">×</button>
</div>
<p class="modal-text subtle">{{ t('sections.profileCropSubtitle') }}</p>
<div class="crop-canvas-wrap">
<canvas
ref="canvasRef"
class="crop-canvas"
width="340"
height="340"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
/>
<div class="crop-circle-guide" />
</div>
<div class="crop-controls">
<label>{{ t('labels.zoom') }}</label>
<input type="range" min="1" max="3" step="0.01" :value="zoom" @input="onZoomInput" />
</div>
<div class="modal-actions split">
<button class="btn btn-outline" @click="resetPosition">{{ t('actions.resetPosition') }}</button>
<button class="btn btn-secondary" @click="$emit('close')">{{ t('actions.cancel') }}</button>
<button class="btn btn-primary" @click="confirmCrop">{{ t('actions.applyCrop') }}</button>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, watch } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
sourceImage: { type: String, default: '' },
t: { type: Function, required: true },
})
const emit = defineEmits(['close', 'confirm'])
const canvasRef = ref(null)
const zoom = ref(1)
const image = new Image()
const state = reactive({
loaded: false,
width: 0,
height: 0,
offsetX: 0,
offsetY: 0,
dragging: false,
pointerId: null,
lastX: 0,
lastY: 0,
})
const CANVAS_SIZE = 340
const EXPORT_SIZE = 520
watch(
() => [props.open, props.sourceImage],
async ([open, src]) => {
if (!open || !src) return
await loadImage(src)
resetPosition()
drawPreview()
},
{ immediate: true },
)
watch(zoom, () => {
clampOffsets()
drawPreview()
})
onMounted(() => {
drawPreview()
})
function loadImage(src) {
return new Promise((resolve, reject) => {
image.onload = () => {
state.loaded = true
state.width = image.naturalWidth || image.width
state.height = image.naturalHeight || image.height
resolve()
}
image.onerror = () => reject(new Error('failed to load image'))
image.src = src
})
}
function onZoomInput(event) {
zoom.value = Number(event.target.value || 1)
}
function resetPosition() {
zoom.value = 1
state.offsetX = 0
state.offsetY = 0
drawPreview()
}
function getDrawMetrics(size) {
const baseScale = Math.max(size / state.width, size / state.height)
const scale = baseScale * zoom.value
const width = state.width * scale
const height = state.height * scale
const left = (size-width)/2 + state.offsetX
const top = (size-height)/2 + state.offsetY
return { width, height, left, top }
}
function clampOffsets() {
if (!state.loaded) return
const { width, height } = getDrawMetrics(CANVAS_SIZE)
const maxOffsetX = Math.max(0, (width - CANVAS_SIZE) / 2)
const maxOffsetY = Math.max(0, (height - CANVAS_SIZE) / 2)
if (state.offsetX > maxOffsetX) state.offsetX = maxOffsetX
if (state.offsetX < -maxOffsetX) state.offsetX = -maxOffsetX
if (state.offsetY > maxOffsetY) state.offsetY = maxOffsetY
if (state.offsetY < -maxOffsetY) state.offsetY = -maxOffsetY
}
function drawPreview() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE)
ctx.fillStyle = '#f0f2f8'
ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE)
if (!state.loaded) return
const { width, height, left, top } = getDrawMetrics(CANVAS_SIZE)
ctx.drawImage(image, left, top, width, height)
}
function onPointerDown(event) {
if (!state.loaded) return
state.dragging = true
state.pointerId = event.pointerId
state.lastX = event.clientX
state.lastY = event.clientY
event.target.setPointerCapture(event.pointerId)
}
function onPointerMove(event) {
if (!state.dragging || state.pointerId !== event.pointerId) return
const dx = event.clientX - state.lastX
const dy = event.clientY - state.lastY
state.lastX = event.clientX
state.lastY = event.clientY
state.offsetX += dx
state.offsetY += dy
clampOffsets()
drawPreview()
}
function onPointerUp(event) {
if (state.pointerId !== event.pointerId) return
state.dragging = false
state.pointerId = null
}
function confirmCrop() {
if (!state.loaded) return
const canvas = document.createElement('canvas')
canvas.width = EXPORT_SIZE
canvas.height = EXPORT_SIZE
const ctx = canvas.getContext('2d')
if (!ctx) return
const { width, height, left, top } = getDrawMetrics(EXPORT_SIZE)
ctx.fillStyle = '#f0f2f8'
ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE)
ctx.drawImage(image, left, top, width, height)
emit('confirm', canvas.toDataURL('image/jpeg', 0.9))
}
</script>

View File

@@ -0,0 +1,24 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
<div class="modal-card proof-modal-card">
<div class="modal-head">
<h3>{{ title }}</h3>
<button class="btn btn-outline btn-xs" @click="$emit('close')">×</button>
</div>
<div class="proof-modal-image-wrap">
<img class="proof-modal-image" :src="image" :alt="title" />
</div>
</div>
</div>
</template>
<script setup>
defineProps({
open: { type: Boolean, default: false },
image: { type: String, default: '' },
title: { type: String, default: 'Proof' },
})
defineEmits(['close'])
</script>

View File

@@ -0,0 +1,24 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="$emit('cancel')">
<div class="modal-card">
<h3 class="modal-title">{{ t('actions.resetScores') }}</h3>
<p class="modal-text">{{ t('messages.confirmReset') }}</p>
<p class="modal-text subtle">{{ t('messages.resetProofPrompt') }}</p>
<div class="modal-actions">
<button class="btn btn-outline" @click="$emit('confirm', false)">{{ t('actions.resetOnlyScores') }}</button>
<button class="btn btn-danger" @click="$emit('confirm', true)">{{ t('actions.resetScoresAndProofs') }}</button>
<button class="btn btn-secondary" @click="$emit('cancel')">{{ t('actions.cancel') }}</button>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
open: { type: Boolean, default: false },
t: { type: Function, required: true },
})
defineEmits(['cancel', 'confirm'])
</script>

View File

@@ -0,0 +1,61 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
<div class="modal-card ai-advice-modal compact">
<div class="modal-head">
<h3>{{ t('sections.aiAdvisorTitle') }}</h3>
<button class="btn btn-outline btn-xs" @click="$emit('close')">×</button>
</div>
<div class="ai-modal-body compact">
<div class="ai-image-wrap">
<img v-if="image" class="ai-proof-image" :src="image" :alt="t('table.verification')" />
</div>
<div class="ai-info-panel">
<template v-if="loading">
<div class="loading-state ai-loading-state">
<div class="spinner" />
<p>{{ t('messages.aiAnalyzing') }}</p>
</div>
</template>
<template v-else-if="error">
<div class="hint-box danger">{{ error }}</div>
</template>
<template v-else-if="advice">
<div class="ai-metric-grid">
<div class="ai-metric-card">
<p class="ai-label">{{ t('labels.currentScore') }}</p>
<p class="ai-value mono">{{ currentScore }}</p>
</div>
<div class="ai-metric-card highlight">
<p class="ai-label">{{ t('labels.aiSuggestedScore') }}</p>
<p class="ai-value mono">{{ advice.advisedScore }}</p>
</div>
</div>
<p class="ai-summary">{{ advice.reason }}</p>
</template>
</div>
</div>
<div class="modal-actions split">
<button class="btn btn-outline" :disabled="loading" @click="$emit('refresh')">{{ t('actions.reAnalyze') }}</button>
<button class="btn btn-secondary" @click="$emit('close')">{{ t('actions.cancel') }}</button>
<button class="btn btn-ai" :disabled="loading || !advice" @click="$emit('apply')">{{ t('actions.quickApplyAi') }}</button>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
open: { type: Boolean, default: false },
t: { type: Function, required: true },
advice: { type: Object, default: null },
currentScore: { type: Number, default: 0 },
image: { type: String, default: '' },
loading: { type: Boolean, default: false },
error: { type: String, default: '' },
})
defineEmits(['close', 'refresh', 'apply'])
</script>

View File

@@ -0,0 +1,465 @@
<template>
<div>
<nav class="tab-bar">
<button v-for="tab in viewTabs" :key="tab.id" class="tab-btn" :class="{ active: viewTab === tab.id }" @click="$emit('change-tab', tab.id)">
{{ tab.label }}
</button>
</nav>
<section v-show="viewTab === 'groups'" class="panel">
<div class="panel-heading">
<h2>{{ t('sections.groupsTitle') }}</h2>
<p>{{ t('sections.groupsSubtitle') }}</p>
</div>
<div class="summary-grid">
<article v-for="group in groupSummaries" :key="group.code" class="summary-card" :class="'group-' + group.key">
<h3>{{ t('labels.group') }} {{ group.code }}</h3>
<p class="summary-value">{{ group.count }}</p>
<p class="summary-status ok">{{ t('labels.players') }}</p>
</article>
</div>
<div v-if="groupedPlayers.length > 0" class="view-group-grid">
<article v-for="group in groupedPlayers" :key="'view-group-' + group.code" class="view-group-card" :class="'group-' + group.key">
<div class="view-group-card-head">
<h3>{{ groupLabel(group.code) }}</h3>
<span class="pm-count-badge">{{ group.players.length }}</span>
</div>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>#</th>
<th>{{ t('table.competitor') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="player in group.players" :key="'v-g-' + group.code + '-' + player.id" :class="rowClass(player.groupCode)">
<td class="mono">{{ player.id }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(player)" :alt="player.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(player) }}</p>
<p class="name-sub">{{ secondaryName(player) }}</p>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</article>
</div>
<div v-else class="table-wrap">
<table class="score-table">
<tbody>
<tr><td class="muted center">{{ t('labels.noPlayers') }}</td></tr>
</tbody>
</table>
</div>
</section>
<section v-show="viewTab === 'live'" class="panel live-panel">
<div class="live-corner">
<div class="live-top-controls">
<button class="btn btn-outline light" :class="{ active: liveMode === 'rotate' }" @click="$emit('change-live-mode', 'rotate')">
{{ t('actions.liveRotate') }}
</button>
<button class="btn btn-outline light" :class="{ active: liveMode === 'fixed' }" @click="$emit('change-live-mode', 'fixed')">
{{ t('actions.liveFixed') }}
</button>
<select
v-if="liveMode === 'fixed'"
class="group-select"
:value="selectedLiveGroup"
@change="$emit('change-live-group', $event.target.value)"
>
<option v-for="group in liveSelectableGroups" :key="'live-group-' + group" :value="group">{{ group }}</option>
</select>
</div>
</div>
<div class="live-title">{{ t('sections.liveTitle') }} · {{ liveGroupCode || t('labels.unassigned') }}</div>
<p class="live-subtitle">{{ t('sections.liveSubtitle') }}</p>
<div class="live-grid" v-if="liveMembers.length > 0">
<article class="live-card" v-for="member in liveMembers" :key="'live-' + member.id">
<img class="live-image" :src="playerImage(member)" :alt="member.nameAr" />
<div>
<p class="live-number">#{{ member.id }}</p>
<p class="live-name-primary">{{ displayName(member) }}</p>
<p class="live-name-secondary">{{ secondaryName(member) }}</p>
</div>
</article>
</div>
<div class="empty-state" v-else>{{ t('labels.emptyLive') }}</div>
<div class="live-progress" :key="'tick-' + liveTick" />
</section>
<section v-show="viewTab === 'overall'" class="panel">
<div class="panel-heading">
<h2>{{ t('sections.overallTitle') }}</h2>
<p>{{ t('sections.overallSubtitle') }}</p>
</div>
<div class="two-column">
<div>
<h3 class="sub-heading">{{ t('labels.allPlayers') }}</h3>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.rank') }}</th>
<th>#</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.group') }}</th>
<th>{{ t('table.score') }}</th>
<th>{{ t('table.tieScore') }}</th>
<th>{{ t('table.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in preliminaryRows" :key="'v-pr-' + row.playerId" :class="{ 'qualified-row': isFinalist(row.playerId) }">
<td class="mono rank">{{ row.rank }}</td>
<td class="mono">{{ row.playerId }}</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">{{ row.groupCode || t('labels.unassigned') }}</td>
<td class="mono strong">
<span>{{ row.score }}</span>
<button
v-if="canViewProofs && hasScoreProof('preliminary', row.playerId)"
class="proof-mini"
@click="$emit('open-proof', { stage: 'preliminary', playerId: row.playerId })"
>
<img :src="scoreProofFor('preliminary', row.playerId)" :alt="t('table.verification')" />
</button>
</td>
<td class="mono">
<span>{{ row.tieBreak }}</span>
<button
v-if="canViewProofs && hasScoreProof('prelim_tiebreak', row.playerId)"
class="proof-mini"
@click="$emit('open-proof', { stage: 'prelim_tiebreak', playerId: row.playerId })"
>
<img :src="scoreProofFor('prelim_tiebreak', row.playerId)" :alt="t('table.verification')" />
</button>
</td>
<td>
<span v-if="isFinalist(row.playerId)" class="badge success">{{ t('labels.finalist') }}</span>
<span v-else class="muted">{{ t('labels.notFinalist') }}</span>
</td>
</tr>
<tr v-if="preliminaryRows.length === 0"><td colspan="7" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
</tbody>
</table>
</div>
</div>
<div>
<h3 class="sub-heading">{{ t('labels.top12') }}</h3>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.seed') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.group') }}</th>
<th>{{ t('table.score') }}</th>
<th>{{ t('table.tieScore') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in finalists" :key="'v-top-' + row.playerId" class="qualified-row">
<td class="mono rank">{{ row.seed }}</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">{{ row.groupCode || t('labels.unassigned') }}</td>
<td class="mono strong">{{ row.score }}</td>
<td class="mono">{{ row.tieBreak }}</td>
</tr>
<tr v-if="finalists.length === 0"><td colspan="5" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="hint-box danger" v-if="prelimTie.required && !prelimTie.resolved">
{{ t('messages.prelimTieUnresolved') }}
</div>
</section>
<section v-show="viewTab === 'final'" class="panel">
<div class="panel-heading">
<h2>{{ t('sections.finalTitle') }}</h2>
<p>{{ t('sections.finalSubtitle') }}</p>
</div>
<div class="two-column">
<div>
<h3 class="sub-heading">{{ t('labels.finalGroup1') }}</h3>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.seed') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.score') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in finalGroup1" :key="'v-f1-' + row.playerId">
<td class="mono">{{ row.seed }}</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">
<span>{{ scoreFor('final', row.playerId) }}</span>
<button
v-if="canViewProofs && hasScoreProof('final', row.playerId)"
class="proof-mini"
@click="$emit('open-proof', { stage: 'final', playerId: row.playerId })"
>
<img :src="scoreProofFor('final', row.playerId)" :alt="t('table.verification')" />
</button>
</td>
</tr>
<tr v-if="finalGroup1.length === 0"><td colspan="3" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
</tbody>
</table>
</div>
</div>
<div>
<h3 class="sub-heading">{{ t('labels.finalGroup2') }}</h3>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.seed') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.score') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in finalGroup2" :key="'v-f2-' + row.playerId">
<td class="mono">{{ row.seed }}</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">
<span>{{ scoreFor('final', row.playerId) }}</span>
<button
v-if="canViewProofs && hasScoreProof('final', row.playerId)"
class="proof-mini"
@click="$emit('open-proof', { stage: 'final', playerId: row.playerId })"
>
<img :src="scoreProofFor('final', row.playerId)" :alt="t('table.verification')" />
</button>
</td>
</tr>
<tr v-if="finalGroup2.length === 0"><td colspan="3" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<h3 class="sub-heading mt-32">{{ t('sections.finalRanking') }}</h3>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.rank') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.score') }}</th>
<th>{{ t('table.tieScore') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in finalRows" :key="'v-fr-' + row.playerId" :class="{ 'podium-row': row.rank <= 3 }">
<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">
<span>{{ row.score }}</span>
<button
v-if="canViewProofs && hasScoreProof('final', row.playerId)"
class="proof-mini"
@click="$emit('open-proof', { stage: 'final', playerId: row.playerId })"
>
<img :src="scoreProofFor('final', row.playerId)" :alt="t('table.verification')" />
</button>
</td>
<td class="mono">
<span>{{ row.tieBreak }}</span>
<button
v-if="canViewProofs && hasScoreProof('final_tiebreak', row.playerId)"
class="proof-mini"
@click="$emit('open-proof', { stage: 'final_tiebreak', playerId: row.playerId })"
>
<img :src="scoreProofFor('final_tiebreak', row.playerId)" :alt="t('table.verification')" />
</button>
</td>
</tr>
<tr v-if="finalRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
</tbody>
</table>
</div>
<div class="hint-box danger" v-if="finalTie.required && !finalTie.resolved">
{{ t('messages.finalTieUnresolved') }}
</div>
</section>
<section v-show="viewTab === 'podium'" class="panel podium-panel">
<div class="panel-heading">
<h2>{{ t('sections.podiumTitle') }}</h2>
<p>{{ t('sections.podiumSubtitle') }}</p>
</div>
<div class="podium-wrapper">
<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>
<p class="podium-score">{{ podiumScoreDisplay(podiumOrdered[0]) }}</p>
</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>
<p class="podium-score">{{ podiumScoreDisplay(podiumOrdered[1]) }}</p>
</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>
<p class="podium-score">{{ podiumScoreDisplay(podiumOrdered[2]) }}</p>
</article>
</div>
</section>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
t: { type: Function, required: true },
viewTabs: { type: Array, required: true },
viewTab: { type: String, required: true },
groupSummaries: { type: Array, required: true },
playersSorted: { type: Array, required: true },
rowClass: { type: Function, required: true },
playerImage: { type: Function, required: true },
displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true },
liveMode: { type: String, required: true },
selectedLiveGroup: { type: String, default: '' },
liveSelectableGroups: { type: Array, required: true },
liveGroupCode: { type: String, default: '' },
liveMembers: { type: Array, required: true },
liveTick: { type: Number, required: true },
preliminaryRows: { type: Array, required: true },
finalists: { type: Array, required: true },
isFinalist: { type: Function, required: true },
prelimTie: { type: Object, required: true },
finalGroup1: { type: Array, required: true },
finalGroup2: { type: Array, required: true },
finalRows: { type: Array, required: true },
finalTie: { type: Object, required: true },
scoreFor: { 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 },
canViewProofs: { type: Boolean, default: false },
hasScoreProof: { type: Function, required: true },
scoreProofFor: { type: Function, required: true },
})
const groupedPlayers = computed(() => {
const map = new Map()
const unassigned = props.t('labels.unassigned')
for (const player of props.playersSorted) {
const code = (player.groupCode || '').trim() || unassigned
if (!map.has(code)) map.set(code, [])
map.get(code).push(player)
}
return [...map.entries()]
.sort((a, b) => {
if (a[0] === unassigned) return 1
if (b[0] === unassigned) return -1
return String(a[0]).localeCompare(String(b[0]), undefined, { numeric: true, sensitivity: 'base' })
})
.map(([code, players]) => ({
code,
key: resolveGroupKey(code, unassigned),
players: [...players].sort((p1, p2) => p1.id - p2.id),
}))
})
function resolveGroupKey(code, unassigned) {
if (code === unassigned) return 'u'
const normalized = String(code || '').trim().toUpperCase()
if (normalized.startsWith('A')) return 'a'
if (normalized.startsWith('B')) return 'b'
if (normalized.startsWith('C')) return 'c'
if (normalized.startsWith('D')) return 'd'
return 'u'
}
function groupLabel(code) {
const unassigned = props.t('labels.unassigned')
if (code === unassigned) return unassigned
return `${props.t('labels.group')} ${code}`
}
defineEmits(['change-tab', 'change-live-mode', 'change-live-group', 'open-proof'])
</script>

View File

@@ -0,0 +1,259 @@
<template>
<section class="panel">
<div class="panel-heading">
<h2>{{ t('sections.playersTitle') }}</h2>
<p>{{ t('sections.playersSubtitle') }}</p>
</div>
<div class="pm-config-card">
<div class="pm-config-top">
<div class="pm-config-input">
<input
:value="groupSetupInput"
class="name-input en"
dir="ltr"
:placeholder="t('sections.groupsConfigPlaceholder')"
@input="$emit('update:group-setup-input', $event.target.value)"
/>
<button class="btn btn-outline" @click="$emit('save-group-setup')">{{ t('actions.updateGroups') }}</button>
</div>
<button class="btn btn-primary" @click="$emit('auto-group-even')">{{ t('actions.randomEvenGroups') }}</button>
</div>
<div class="summary-grid admin-summary-grid">
<article v-for="card in adminGroupCards" :key="'admin-group-' + card.key" class="summary-card" :class="'group-' + card.key">
<h3>{{ card.label }}</h3>
<p class="summary-value">{{ card.count }}</p>
<p class="summary-status ok">{{ t('labels.players') }}</p>
</article>
</div>
</div>
<div class="pm-composer-card">
<div class="pm-composer-grid">
<input
:value="newPlayer.nameAr"
class="name-input ar"
:placeholder="t('table.arabicName')"
@input="$emit('update:new-player', { key: 'nameAr', value: $event.target.value })"
/>
<input
:value="newPlayer.nameEn"
class="name-input en"
dir="ltr"
:placeholder="t('table.englishName')"
@input="$emit('update:new-player', { key: 'nameEn', value: $event.target.value })"
/>
<select
:value="newPlayer.groupCode"
class="name-input"
@change="$emit('update:new-player', { key: 'groupCode', value: $event.target.value })"
>
<option value="">{{ groupOptionLabel('') }}</option>
<option v-for="group in assignableGroups" :key="'new-group-' + group" :value="group">{{ groupOptionLabel(group) }}</option>
</select>
<button class="btn btn-primary" @click="$emit('create-player')">{{ t('actions.addPlayer') }}</button>
</div>
<div class="pm-composer-actions">
<button class="btn btn-outline" @click="$emit('convert-new-name', 'ar_to_en')">{{ t('actions.convertArToEn') }}</button>
<button class="btn btn-outline" @click="$emit('convert-new-name', 'en_to_ar')">{{ t('actions.convertEnToAr') }}</button>
</div>
</div>
<div class="players-tools players-tools-elevated">
<input
class="name-input"
:value="playerFilter"
:placeholder="t('actions.searchPlayer')"
@input="$emit('update:player-filter', $event.target.value)"
/>
<div class="players-sort-box">
<label class="control-label">{{ t('labels.sortBy') }}</label>
<select class="name-input" :value="playerSort" @change="$emit('update:player-sort', $event.target.value)">
<option value="id">{{ t('actions.sortById') }}</option>
<option value="nameAr">{{ t('actions.sortByArabic') }}</option>
<option value="nameEn">{{ t('actions.sortByEnglish') }}</option>
<option value="group">{{ t('actions.sortByGroup') }}</option>
</select>
</div>
</div>
<div class="group-cards-grid">
<article v-for="group in groupedCards" :key="'group-card-' + group.code" class="group-player-card" :class="'group-' + group.key">
<header class="group-player-card-head">
<h3>{{ group.label }}</h3>
<span class="pm-count-badge mono">{{ group.players.length }}</span>
</header>
<div class="group-player-list" v-if="group.players.length > 0">
<div class="group-player-row" v-for="player in group.players" :key="'card-player-' + player.id">
<div class="group-player-top">
<div class="competitor-cell">
<img :src="playerImage(player)" :alt="player.nameAr" class="competitor-image clickable" @click="$emit('open-image-uploader', player.id)" />
<div class="name-edit-grid vertical">
<input
class="name-input ar"
:value="player.nameAr"
:placeholder="t('table.arabicName')"
@blur="$emit('update-player-field', { player, field: 'nameAr', event: $event })"
/>
<input
class="name-input en"
dir="ltr"
:value="player.nameEn"
:placeholder="t('table.englishName')"
@blur="$emit('update-player-field', { player, field: 'nameEn', event: $event })"
/>
</div>
</div>
<div class="mono">#{{ player.id }}</div>
</div>
<div class="panel-actions compact name-convert-row">
<button class="btn btn-outline btn-xs" @click="$emit('convert-row-name', { player, direction: 'ar_to_en' })">{{ t('actions.convertArToEn') }}</button>
<button class="btn btn-outline btn-xs" @click="$emit('convert-row-name', { player, direction: 'en_to_ar' })">{{ t('actions.convertEnToAr') }}</button>
</div>
<div class="group-player-actions">
<select
class="name-input"
:value="normalizedGroupCode(player.groupCode)"
@change="$emit('update-player-group', { player, event: $event })"
>
<option value="">{{ groupOptionLabel('') }}</option>
<option v-for="item in assignableGroups" :key="'player-group-' + player.id + '-' + item" :value="item">
{{ groupOptionLabel(item) }}
</option>
</select>
<button class="btn btn-outline" @click="$emit('remove-player-image', player.id)">{{ t('actions.removeImage') }}</button>
<button class="btn btn-danger" @click="$emit('delete-player', player.id)">{{ t('actions.delete') }}</button>
</div>
</div>
</div>
<div v-else class="empty-state">{{ t('labels.noPlayers') }}</div>
</article>
</div>
</section>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
t: { type: Function, required: true },
groupSetupInput: { type: String, default: '' },
adminGroupCards: { type: Array, required: true },
newPlayer: { type: Object, required: true },
assignableGroups: { type: Array, required: true },
playersSorted: { type: Array, required: true },
playerFilter: { type: String, default: '' },
playerSort: { type: String, default: 'id' },
playerImage: { type: Function, required: true },
groupOptionLabel: { type: Function, required: true },
normalizedGroupCode: { type: Function, required: true },
})
defineEmits([
'update:group-setup-input',
'save-group-setup',
'auto-group-even',
'update:new-player',
'create-player',
'convert-new-name',
'update:player-filter',
'update:player-sort',
'open-image-uploader',
'update-player-field',
'update-player-group',
'convert-row-name',
'remove-player-image',
'delete-player',
])
const filteredPlayers = computed(() => {
const query = String(props.playerFilter || '').trim().toLowerCase()
if (!query) return props.playersSorted
return props.playersSorted.filter((player) => {
const ar = String(player.nameAr || '')
const en = String(player.nameEn || '').toLowerCase()
return ar.includes(query) || en.includes(query)
})
})
const sortedPlayers = computed(() => {
const items = [...filteredPlayers.value]
items.sort((a, b) => comparePlayers(a, b, props.playerSort))
return items
})
const groupedCards = computed(() => {
const groups = []
const groupMap = new Map()
for (const raw of props.assignableGroups || []) {
const code = props.normalizedGroupCode(raw)
if (!code || groupMap.has(code)) continue
groupMap.set(code, [])
groups.push(code)
}
for (const player of sortedPlayers.value) {
const code = props.normalizedGroupCode(player.groupCode)
if (!code) continue
if (!groupMap.has(code)) {
groupMap.set(code, [])
groups.push(code)
}
groupMap.get(code).push(player)
}
const unassigned = sortedPlayers.value.filter((player) => !props.normalizedGroupCode(player.groupCode))
const cards = groups.map((code) => ({
code,
key: groupVisualKey(code),
label: `${props.t('labels.group')} ${code}`,
players: groupMap.get(code) || [],
}))
cards.push({
code: '',
key: 'u',
label: props.t('labels.unassigned'),
players: unassigned,
})
return cards
})
function comparePlayers(a, b, sort) {
if (sort === 'nameAr') {
return String(a.nameAr || '').localeCompare(String(b.nameAr || ''), 'ar') || a.id - b.id
}
if (sort === 'nameEn') {
return String(a.nameEn || '').localeCompare(String(b.nameEn || ''), 'en') || a.id - b.id
}
if (sort === 'group') {
const ga = props.normalizedGroupCode(a.groupCode)
const gb = props.normalizedGroupCode(b.groupCode)
if (ga !== gb) {
if (!ga) return 1
if (!gb) return -1
return ga.localeCompare(gb)
}
return a.id - b.id
}
return a.id - b.id
}
function groupVisualKey(code) {
const normalized = props.normalizedGroupCode(code)
if (normalized.startsWith('A')) return 'a'
if (normalized.startsWith('B')) return 'b'
if (normalized.startsWith('C')) return 'c'
if (normalized.startsWith('D')) return 'd'
return 'u'
}
</script>

View File

@@ -0,0 +1,202 @@
<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="showScoreBeforeInput">{{ t('table.score') }}</th>
<th>{{ inputLabel }}</th>
<th>{{ t('table.verification') }}</th>
<th v-if="showRank">{{ t('table.rank') }}</th>
<th v-if="showSeed">{{ t('table.seed') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filteredRows" :key="'desk-' + stage + '-' + row.playerId">
<td class="mono">{{ row.playerId }}</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">{{ row.groupCode || t('labels.unassigned') }}</td>
<td v-if="showScoreBeforeInput" class="mono strong">{{ row.score }}</td>
<td>
<input
class="score-input"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="9999"
:value="scoreInputValue(stage, row.playerId)"
@focus="onScoreFocus(stage, row.playerId)"
@input="onScoreInput(stage, row.playerId, $event)"
@blur="onScoreCommit(stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
/>
</td>
<td>
<div class="proof-actions">
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)">
{{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
</button>
<!-- <button
v-if="hasScoreProof(stage, row.playerId)"
class="btn btn-ai btn-xs"
@click="requestScoreAdvice(stage, row.playerId)"
>
{{ t('actions.aiAdvisor') }}
</button> -->
<button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
{{ t('actions.removeProof') }}
</button>
<img
v-if="hasScoreProof(stage, row.playerId)"
class="proof-thumb"
:src="scoreProofFor(stage, row.playerId)"
:alt="t('table.verification')"
@click="openProofPreview(stage, row.playerId)"
/>
</div>
</td>
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
<td v-if="showSeed" class="mono">{{ row.seed }}</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 in filteredRows" :key="'mob-' + stage + '-' + row.playerId" class="score-card">
<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">#{{ row.playerId }}</div>
</div>
<div class="score-card-meta">
<span v-if="showGroup">{{ t('table.group') }}: {{ row.groupCode || t('labels.unassigned') }}</span>
<span v-if="showScoreBeforeInput">{{ t('table.score') }}: {{ row.score }}</span>
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
</div>
<label class="score-label">{{ inputLabel }}</label>
<input
class="score-input"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="9999"
:value="scoreInputValue(stage, row.playerId)"
@focus="onScoreFocus(stage, row.playerId)"
@input="onScoreInput(stage, row.playerId, $event)"
@blur="onScoreCommit(stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
/>
<div class="proof-actions mobile-proof-actions">
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)">
{{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
</button>
<!-- <button
v-if="hasScoreProof(stage, row.playerId)"
class="btn btn-ai btn-xs"
@click="requestScoreAdvice(stage, row.playerId)"
>
{{ t('actions.aiAdvisor') }}
</button> -->
<button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
{{ t('actions.removeProof') }}
</button>
<img
v-if="hasScoreProof(stage, row.playerId)"
class="proof-thumb"
:src="scoreProofFor(stage, row.playerId)"
:alt="t('table.verification')"
@click="openProofPreview(stage, row.playerId)"
/>
</div>
</article>
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
t: { type: Function, required: true },
stage: { type: String, required: true },
rows: { type: Array, 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 },
showScoreBeforeInput: { type: Boolean, default: false },
inputLabel: { type: String, required: true },
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 },
removeScoreProof: { type: Function, required: true },
openProofPreview: { type: Function, required: true },
requestScoreAdvice: { 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
if (props.showGroup) count += 1
if (props.showScoreBeforeInput) count += 1
if (props.showRank) count += 1
if (props.showSeed) count += 1
return count
})
</script>

View File

@@ -0,0 +1,313 @@
export const I18N = {
ar: {
titleFallback: 'بطولة دويتوايلر للرماية',
subtitle: 'فئة المسدس · مسافات: 15م ← 20م ← 25م',
viewMode: 'وضع العرض',
adminMode: 'لوحة الإدارة',
adminLogin: 'تسجيل دخول الإدارة',
adminLoginDesc: 'أدخل بيانات الإدارة للتحكم الكامل في البطولة.',
adminPanel: 'لوحة التحكم الإدارية',
adminPanelDesc: 'إدارة اللاعبين، التوزيع، وإدخال النتائج لكل المراحل.',
defaultCredentials: 'بيانات الإدارة الافتراضية: datwyler / datwyler',
labels: {
liveTracker: 'LIVE TRACKER',
mode: 'الوضع',
language: 'اللغة',
lastSync: 'آخر مزامنة',
loading: 'جاري تحميل بيانات البطولة...',
players: 'لاعب',
group: 'المجموعة',
unassigned: 'غير معين',
emptyLive: 'لا يوجد لاعبين في هذه المجموعة',
allPlayers: 'جميع اللاعبين',
top12: 'أفضل 12 متأهل',
finalist: 'متأهل',
notFinalist: 'غير متأهل',
noPlayers: 'لا يوجد لاعبين بعد',
noFinalists: 'لا يوجد متأهلون بعد',
finalGroup1: 'المجموعة النهائية 1 (المراكز 1-6)',
finalGroup2: 'المجموعة النهائية 2 (المراكز 7-12)',
tieSlots: 'عدد المقاعد المتاحة من كسر التعادل',
waiting: 'بانتظار النتيجة',
viewProofInView: 'إظهار صور إثبات النتيجة في وضع العرض',
sortBy: 'الترتيب',
zoom: 'التكبير',
currentScore: 'النتيجة الحالية',
aiSuggestedScore: 'النتيجة المقترحة',
confidence: 'مستوى الثقة',
},
sections: {
groupsTitle: 'عرض اللاعبين والمجموعات',
groupsSubtitle: 'شاشة نظيفة للمتابعة المباشرة حسب المجموعة.',
liveTitle: 'العرض الحي للمجموعة',
liveSubtitle: 'تدوير تلقائي بين المجموعات المسجلة كل 5 ثوان.',
overallTitle: 'الترتيب العام للمرحلة التمهيدية',
overallSubtitle: 'يتم تمييز أفضل 12 متأهل للنهائي.',
finalTitle: 'المرحلة النهائية',
finalSubtitle: 'تقسيم المتأهلين إلى مجموعتين حسب الترتيب.',
finalRanking: 'الترتيب النهائي',
podiumTitle: 'منصة التتويج',
podiumSubtitle: 'المراكز الثلاثة الأولى بعد فك أي تعادل.',
playersTitle: 'إدارة اللاعبين',
playersSubtitle: 'إضافة/تعديل/حذف لاعب، وتعديل الاسم والصورة والمجموعة.',
groupsConfigPlaceholder: 'المجموعات الأساسية (مثال: A,B,C,D)',
preliminaryAdminTitle: 'إدخال نتائج المرحلة التمهيدية',
preliminaryAdminSubtitle: 'إدخال يدوي للنتيجة التمهيدية لكل لاعب.',
prelimTieTitle: 'إدخال كسر تعادل التأهل (أفضل 12)',
prelimTieSubtitle: 'يظهر فقط اللاعبين المتعادلين على حد التأهل.',
finalAdminTitle: 'إدخال نتائج المرحلة النهائية',
finalAdminSubtitle: 'إدخال نتائج النهائي للمجموعتين.',
finalTieTitle: 'إدخال كسر تعادل منصة التتويج',
finalTieSubtitle: 'يظهر فقط عند تعادل المراكز 1-3.',
profileCropTitle: 'تعديل صورة اللاعب',
profileCropSubtitle: 'حرّك وكبّر الصورة لتناسب الإطار الدائري قبل الحفظ.',
aiAdvisorTitle: 'مساعد الذكاء الاصطناعي للنتيجة',
},
table: {
competitor: 'اللاعب',
group: 'المجموعة',
rank: 'الترتيب',
score: 'النتيجة',
tieScore: 'نتيجة كسر التعادل',
verification: 'التحقق',
status: 'الحالة',
seed: 'التصنيف',
medal: 'الميدالية',
actions: 'الإجراءات',
arabicName: 'الاسم بالعربية',
englishName: 'الاسم بالإنجليزية',
},
actions: {
refresh: 'تحديث',
login: 'دخول',
logout: 'تسجيل خروج',
updateGroups: 'تحديث المجموعات',
liveRotate: 'دوران تلقائي',
liveFixed: 'مجموعة محددة',
uploadProof: 'رفع إثبات',
replaceProof: 'استبدال الإثبات',
removeProof: 'حذف الإثبات',
addPlayer: 'إضافة لاعب',
removeImage: 'حذف الصورة',
delete: 'حذف',
resetScores: 'تصفير نتائج المرحلة',
resetOnlyScores: 'تصفير النتائج فقط',
resetScoresAndProofs: 'تصفير النتائج وحذف الإثبات',
randomEvenGroups: 'توزيع عشوائي متوازن',
searchPlayer: 'بحث بالاسم العربي أو الإنجليزي',
cancel: 'إلغاء',
convertArToEn: 'تحويل عربي → إنجليزي',
convertEnToAr: 'تحويل إنجليزي → عربي',
sortById: 'حسب الرقم',
sortByArabic: 'حسب الاسم العربي',
sortByEnglish: 'حسب الاسم الإنجليزي',
sortByGroup: 'حسب المجموعة',
aiAdvisor: 'اقتراح AI',
quickApplyAi: 'تطبيق سريع AI',
reAnalyze: 'إعادة التحليل',
applySuggestedScore: 'اعتماد النتيجة المقترحة',
applyCrop: 'تطبيق القص',
resetPosition: 'إعادة الضبط',
},
auth: {
username: 'اسم المستخدم',
password: 'كلمة المرور',
},
tabs: {
groups: 'المجموعات',
live: 'عرض حي',
overall: 'الترتيب العام',
final: 'النهائي',
podium: 'التتويج',
players: 'اللاعبون',
preliminary: 'التمهيدي',
prelimTie: 'كسر تعادل التأهل',
finalTie: 'كسر تعادل التتويج',
},
messages: {
saved: 'تم الحفظ بنجاح.',
mustProvideNames: 'يرجى إدخال الاسم بالعربية والإنجليزية.',
noPrelimTie: 'لا يوجد تعادل على حد التأهل.',
noFinalTie: 'لا يوجد تعادل في المراكز 1-3.',
prelimTieUnresolved: 'تعادل التأهل غير محسوم. أدخل نتائج كسر التعادل لتحديد أفضل 12.',
finalTieUnresolved: 'تعادل منصة التتويج غير محسوم. أكمل إدخال كسر التعادل.',
confirmDelete: 'هل تريد حذف اللاعب؟',
confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟',
resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟',
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 9999.',
unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.',
errorPrefix: 'حدث خطأ',
noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.',
noNameToConvert: 'لا يوجد اسم للتحويل.',
aiAnalyzing: 'جاري تحليل الصورة بالذكاء الاصطناعي...',
noProofForAi: 'لا توجد صورة إثبات لتحليلها.',
},
},
en: {
titleFallback: 'Datwyler Shooting Event',
subtitle: 'Pistol class · Distances: 15m ← 20m ← 25m',
viewMode: 'View Mode',
adminMode: 'Admin Panel',
adminLogin: 'Admin Login',
adminLoginDesc: 'Enter admin credentials for full tournament control.',
adminPanel: 'Admin Control Panel',
adminPanelDesc: 'Manage players, assignments, and scoring for all stages.',
defaultCredentials: 'Default admin credentials: datwyler / datwyler',
labels: {
liveTracker: 'LIVE TRACKER',
mode: 'Mode',
language: 'Language',
lastSync: 'Last sync',
loading: 'Loading tournament data...',
players: 'Players',
group: 'Group',
unassigned: 'Unassigned',
emptyLive: 'No players in this group',
allPlayers: 'All players',
top12: 'Top 12 finalists',
finalist: 'Finalist',
notFinalist: 'Not finalist',
noPlayers: 'No players yet',
noFinalists: 'No finalists yet',
finalGroup1: 'Final Group 1 (Seeds 1-6)',
finalGroup2: 'Final Group 2 (Seeds 7-12)',
tieSlots: 'Tie-break slots',
waiting: 'Waiting',
viewProofInView: 'Allow proof images in view mode',
sortBy: 'Sort by',
zoom: 'Zoom',
currentScore: 'Current score',
aiSuggestedScore: 'AI suggested score',
confidence: 'Confidence',
},
sections: {
groupsTitle: 'Players & Groups Overview',
groupsSubtitle: 'Clean view screen for live grouping.',
liveTitle: 'Live Group Screen',
liveSubtitle: 'Automatic rotation through registered groups every 5 seconds.',
overallTitle: 'Preliminary Overall Ranking',
overallSubtitle: 'Top 12 finalists are highlighted.',
finalTitle: 'Final Stage',
finalSubtitle: 'Finalists are split into two groups by rank.',
finalRanking: 'Final Ranking',
podiumTitle: 'Podium',
podiumSubtitle: 'Top 3 after tie-break resolution.',
playersTitle: 'Players Management',
playersSubtitle: 'Add/update/remove players, names, images, and group assignment.',
groupsConfigPlaceholder: 'Primary groups (example: A,B,C,D)',
preliminaryAdminTitle: 'Preliminary Scoring',
preliminaryAdminSubtitle: 'Manually input each player preliminary score.',
prelimTieTitle: 'Qualification Tie-Break Scoring (Top 12)',
prelimTieSubtitle: 'Only players tied at qualification cutoff are shown.',
finalAdminTitle: 'Final Stage Scoring',
finalAdminSubtitle: 'Input final scores for both final groups.',
finalTieTitle: 'Podium Tie-Break Scoring',
finalTieSubtitle: 'Shown only when places 1-3 are tied.',
profileCropTitle: 'Adjust Player Photo',
profileCropSubtitle: 'Move and zoom to fit the circular avatar before saving.',
aiAdvisorTitle: 'AI Score Advisor',
},
table: {
competitor: 'Player',
group: 'Group',
rank: 'Rank',
score: 'Score',
tieScore: 'Tie-Break Score',
verification: 'Verification',
status: 'Status',
seed: 'Seed',
medal: 'Medal',
actions: 'Actions',
arabicName: 'Arabic Name',
englishName: 'English Name',
},
actions: {
refresh: 'Refresh',
login: 'Login',
logout: 'Logout',
updateGroups: 'Update Groups',
liveRotate: 'Auto Rotation',
liveFixed: 'Fixed Group',
uploadProof: 'Upload Proof',
replaceProof: 'Replace Proof',
removeProof: 'Remove Proof',
addPlayer: 'Add Player',
removeImage: 'Remove Image',
delete: 'Delete',
resetScores: 'Reset Stage Scores',
resetOnlyScores: 'Reset scores only',
resetScoresAndProofs: 'Reset scores and proofs',
randomEvenGroups: 'Random even grouping',
searchPlayer: 'Search by Arabic or English name',
cancel: 'Cancel',
convertArToEn: 'Convert Arabic → English',
convertEnToAr: 'Convert English → Arabic',
sortById: 'By ID',
sortByArabic: 'By Arabic name',
sortByEnglish: 'By English name',
sortByGroup: 'By group',
aiAdvisor: 'AI Advice',
quickApplyAi: 'Quick Apply AI',
reAnalyze: 'Re-analyze',
applySuggestedScore: 'Apply suggested score',
applyCrop: 'Apply crop',
resetPosition: 'Reset position',
},
auth: {
username: 'Username',
password: 'Password',
},
tabs: {
groups: 'Groups',
live: 'Live',
overall: 'Overall',
final: 'Final',
podium: 'Podium',
players: 'Players',
preliminary: 'Preliminary',
prelimTie: 'Prelim Tie-Break',
finalTie: 'Final Tie-Break',
},
messages: {
saved: 'Saved successfully.',
mustProvideNames: 'Arabic and English names are required.',
noPrelimTie: 'No tie at qualification cutoff.',
noFinalTie: 'No tie in places 1-3.',
prelimTieUnresolved: 'Qualification tie is unresolved. Enter tie-break scores to finalize top 12.',
finalTieUnresolved: 'Podium tie is unresolved. Complete tie-break scoring.',
confirmDelete: 'Delete this player?',
confirmReset: 'Reset this stage scores?',
resetProofPrompt: 'Also remove all proof images for this stage?',
invalidScore: 'Score must be between 0 and 9999.',
unauthorized: 'Admin session expired. Please login again.',
errorPrefix: 'Error',
noGroupsConfigured: 'No primary groups configured.',
noNameToConvert: 'No name available to convert.',
aiAnalyzing: 'Analyzing image with AI...',
noProofForAi: 'No proof image available to analyze.',
},
},
}
export function createInitialState() {
return {
competition: { titleAr: '', titleEn: '' },
players: [],
scores: {
preliminary: {},
prelim_tiebreak: {},
final: {},
final_tiebreak: {},
},
scoreProofs: {},
settings: { viewProofInView: false },
derived: {
preliminaryRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } },
finalists: [],
finalGroups: { group1: [], group2: [] },
finalRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } },
podium: [],
},
serverTime: '',
}
}

5
frontend/src/main.js Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

1749
frontend/src/style.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
export const DEFAULT_PRIMARY_GROUPS = ['A', 'B', 'C', 'D']
export function normalizedGroupCode(value) {
return String(value || '').trim().toUpperCase()
}
export function parseGroupList(raw) {
const items = String(raw || '')
.split(',')
.map((item) => normalizedGroupCode(item))
.filter(Boolean)
return [...new Set(items)]
}
export function loadPrimaryGroups(storage) {
const stored = storage.getItem('shooting_group_list')
const parsed = parseGroupList(stored)
return parsed.length > 0 ? parsed : DEFAULT_PRIMARY_GROUPS
}
export function groupKey(code) {
const normalized = normalizedGroupCode(code)
if (normalized.startsWith('A')) return 'a'
if (normalized.startsWith('B')) return 'b'
if (normalized.startsWith('C')) return 'c'
if (normalized.startsWith('D')) return 'd'
return 'u'
}

View File

@@ -0,0 +1,220 @@
const ARABIC_DIACRITICS = /[\u064B-\u065F\u0670\u0640]/g
const AR_TO_EN_WORD = {
محمد: 'Mohammad',
احمد: 'Ahmad',
محمود: 'Mahmoud',
عبدالرحمن: 'Abdulrahman',
عبدالله: 'Abdullah',
عبدالله: 'Abdullah',
عبدالاله: 'Abdulilah',
عمر: 'Omar',
علي: 'Ali',
خالد: 'Khaled',
خليل: 'Khalil',
يزن: 'Yazan',
يزيد: 'Yazeed',
معاذ: 'Moaz',
طارق: 'Tareq',
زيد: 'Zaid',
سامر: 'Samer',
سيف: 'Saif',
حسام: 'Hossam',
باسم: 'Bassem',
امجد: 'Amjad',
مأمون: 'Maamoun',
اياد: 'Eyad',
إياد: 'Eyad',
حمزة: 'Hamza',
حمزه: 'Hamza',
هيثم: 'Haitham',
وائل: 'Wael',
رائد: 'Raed',
فهد: 'Fahad',
فارس: 'Fares',
ناصر: 'Nasser',
جميل: 'Jameel',
}
const EN_TO_AR_WORD = {
mohammad: 'محمد',
muhammad: 'محمد',
ahmad: 'أحمد',
ahmed: 'أحمد',
mahmoud: 'محمود',
abdullah: 'عبدالله',
abdallah: 'عبدالله',
abdulrahman: 'عبدالرحمن',
omar: 'عمر',
ali: 'علي',
khaled: 'خالد',
khalil: 'خليل',
yazan: 'يزن',
yazeed: 'يزيد',
moaz: 'معاذ',
tareq: 'طارق',
tariq: 'طارق',
zaid: 'زيد',
zaidan: 'زيدان',
samer: 'سامر',
saif: 'سيف',
hossam: 'حسام',
bassem: 'باسم',
amjad: 'أمجد',
eyad: 'إياد',
hamza: 'حمزة',
haitham: 'هيثم',
wael: 'وائل',
raed: 'رائد',
fahad: 'فهد',
fares: 'فارس',
nasser: 'ناصر',
jameel: 'جميل',
}
const AR_TO_EN_CHAR = {
ا: 'a',
أ: 'a',
إ: 'i',
آ: 'aa',
ء: '',
ب: 'b',
ت: 't',
ث: 'th',
ج: 'j',
ح: 'h',
خ: 'kh',
د: 'd',
ذ: 'dh',
ر: 'r',
ز: 'z',
س: 's',
ش: 'sh',
ص: 's',
ض: 'd',
ط: 't',
ظ: 'z',
ع: 'a',
غ: 'gh',
ف: 'f',
ق: 'q',
ك: 'k',
ل: 'l',
م: 'm',
ن: 'n',
ه: 'h',
و: 'w',
ي: 'y',
ى: 'a',
ة: 'a',
ئ: 'e',
ؤ: 'o',
}
const EN_DIGRAPHS = [
['sh', 'ش'],
['kh', 'خ'],
['gh', 'غ'],
['th', 'ث'],
['dh', 'ذ'],
['ch', 'تش'],
['ph', 'ف'],
['aa', 'ا'],
['ee', 'ي'],
['oo', 'و'],
['ou', 'و'],
]
const EN_TO_AR_CHAR = {
a: 'ا',
b: 'ب',
c: 'ك',
d: 'د',
e: 'ي',
f: 'ف',
g: 'ج',
h: 'ه',
i: 'ي',
j: 'ج',
k: 'ك',
l: 'ل',
m: 'م',
n: 'ن',
o: 'و',
p: 'ب',
q: 'ق',
r: 'ر',
s: 'س',
t: 'ت',
u: 'و',
v: 'ف',
w: 'و',
x: 'كس',
y: 'ي',
z: 'ز',
}
function normalizeArabicWord(word) {
return String(word || '')
.replace(ARABIC_DIACRITICS, '')
.replace(/[\u0622\u0623\u0625]/g, 'ا')
.replace(/ة/g, 'ه')
.trim()
}
function titleCase(word) {
if (!word) return ''
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
}
function transliterateArabicWord(word) {
const normalized = normalizeArabicWord(word)
if (!normalized) return ''
if (AR_TO_EN_WORD[normalized]) return AR_TO_EN_WORD[normalized]
let out = ''
for (const ch of normalized) {
out += AR_TO_EN_CHAR[ch] ?? ch
}
return titleCase(out.replace(/aa+/g, 'a'))
}
function transliterateEnglishWord(word) {
const normalized = String(word || '').trim().toLowerCase()
if (!normalized) return ''
if (EN_TO_AR_WORD[normalized]) return EN_TO_AR_WORD[normalized]
let left = normalized
let out = ''
while (left.length > 0) {
let matched = false
for (const [latin, arabic] of EN_DIGRAPHS) {
if (left.startsWith(latin)) {
out += arabic
left = left.slice(latin.length)
matched = true
break
}
}
if (matched) continue
out += EN_TO_AR_CHAR[left[0]] ?? left[0]
left = left.slice(1)
}
return out
}
export function convertNameAuto(direction, value) {
const words = String(value || '')
.split(/\s+/)
.filter(Boolean)
if (words.length === 0) return ''
if (direction === 'ar_to_en') {
return words.map(transliterateArabicWord).filter(Boolean).join(' ')
}
if (direction === 'en_to_ar') {
return words.map(transliterateEnglishWord).filter(Boolean).join(' ')
}
return String(value || '')
}

14
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})