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

BIN
frontend/public/bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
frontend/public/bg_live.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

63
frontend/public/logo.svg Normal file
View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 583.65 114.56">
<defs>
<style>
.cls-1 {
fill: #008ca8;
}
.cls-2 {
fill: #ec2f23;
}
.cls-3 {
fill: #1b1f40;
}
</style>
</defs>
<g>
<path class="cls-3" d="M545.23,98.25l4.31-7.15c.28-.46.13-1.06-.33-1.33-2.22-1.32-8.46-4.34-10.8-5.19-.64-.23-.88-.38-1.41.22-.65.73-4.89,7.9-4.78,8.41.1.46,8,4.3,11.13,5.65.69.3,1.5.04,1.9-.61Z"/>
<g>
<path class="cls-3" d="M392.94,85.7c5.81.93,14.01.79,19.19-2.28,3.16-1.87,4.61-5.35,5.87-8.63l1.32,4.87c3.19,8.82,8.21,15.31,18.58,12.74,10.58-2.63,13.61-18.14,12.73-27.42-.65-6.87-2.57-13.59-3.36-20.44l1.61-.11,3.36-9.01c.02-.45-.27-.62-.56-.87-2.28-1.94-6.47-3.24-8.09-6.06-1.38-2.41-.88-4.59-.39-7.13-.32-.06-.53.03-.81.16-1.84.87-5.37,7.2-5.99,9.22-.44,1.44-.52,2.45,0,3.88.32.91,1.51,2.23,1.64,2.78.19.82-.16,1.4-.15,2.08.29,10.93,4.43,22.34,6.47,33,.24,1.27,1.13,5.76.62,6.65-.87,1.53-4.98,2.6-6.65,2.73-13.69,1.08-17.23-22.88-17.26-24.5-.02-1.63,3.27-6.22,4.17-8.05.29-.6,1.45-2.94,1.31-3.43l-.52-.21-5.3,7.35c-.93-3.2-1.96-6.43-2.15-9.79-.69-.95-1.17.03-1.63.61-2.69,3.35-3.93,5.91-3.64,10.33.04.65.56,1.64.19,2.14-7.63,4.11-16.83,6.5-21.56,14.35-1.4,2.33-4.47,9.4-4.06,11.96.39,2.45,3.05,2.76,5.05,3.08ZM398.75,71.81c5.39-3.26,11.6-5.51,16.24-9.94.49,2.58,1.29,5.14,1.62,7.75.07.57.35,1.65,0,2.1-.33.43-3.52,2.14-4.2,2.44-7.89,3.39-16.94.64-17.15.36-.11-.47,2.94-2.38,3.48-2.71Z"/>
<path class="cls-3" d="M384.64,43.49l10.08,4.98c.43.21.94.06,1.19-.35l3.86-6.41c3.08.93,5.9,2.66,8.65,4.33.21.12.41.05.58-.09l5.38-7.11c.28-.37.19-.89-.2-1.14-1.87-1.21-7.19-4.06-9.28-4.89-1.71-.67-1.63-.51-2.65.83-1.28,1.68-2.21,3.59-3.39,5.34-.64.19-6.86-3.71-9.28-4.05-.37-.05-.74.13-.94.45l-4.34,6.88c-.27.43-.11,1,.35,1.23Z"/>
<path class="cls-1" d="M412.38,30.98c5.42-3.61,12.17-5.45,18.12-8.01,1.55-.67,4.04-1.64,5.19-2.83.46-.48,2.17-3.45,2.03-3.98l-.35-.22c-1.06.57-2.11,1.19-3.21,1.69-5.69,2.56-12.2,4.4-17.73,7.18-3.1,1.56-5.78,3.95-6.06,7.62.12.14,1.76-1.28,2-1.44Z"/>
<path class="cls-1" d="M544.19,36.97c.53-.43,2.67-2.92,2.87-2.97,1.09-.27,2.33,2.56,3.41.76,1.27-2.13.29-4.12-1.85-5.02,3.63-5.13-1.82-8.77-5.51-4-2.26,2.93-2.19,6,1.66,7.24.3.4-5.23,3.9-5.71,4.19-2.66,1.58-5.51,2.96-8.26,4.39-.68.35-2.27.92-2.73,1.27-.28.21-.32.27-.27.64,8.41-1.44,14.63-5.05,16.39-6.5ZM544.77,28.23c-.28-1.72,3.07-1.19,2.34.92-.62.13-2.23-.22-2.34-.92Z"/>
<path class="cls-1" d="M487.55,49.01c.45-.37,2.28-2.5,2.46-2.54.93-.23,2,2.19,2.92.65,1.09-1.82.25-3.53-1.58-4.3,3.11-4.39-1.56-7.51-4.72-3.42-1.93,2.51-1.88,5.14,1.42,6.2.26.35-4.47,3.34-4.89,3.58-2.28,1.35-4.72,2.53-7.07,3.75-.58.3-1.94.78-2.33,1.08-.24.18-.28.23-.23.55,7.19-1.23,12.52-4.32,14.03-5.56ZM488.04,41.53c-.24-1.47,2.63-1.02,2,.79-.53.12-1.91-.19-2-.79Z"/>
<path class="cls-2" d="M475.87,43.99c.16-.42,1.45-4.23.57-4.22l-2.7,2.54c-.95.9-2.32,1.27-3.56.88-2.75-.86-3.43-4.62-3.2-7.68.37-4.86,3.22-9.04,4.99-13.43-.05-.06-.59.33-.71.44-4.34,4.2-8.86,15.7-7.73,21.64,1.59,8.37,9.66,6.77,12.34-.16Z"/>
<path class="cls-3" d="M560.15,59.59c-.48-6.52-4.32-9.98-8.51-15.45-.96-.25-1.27.94-1.6,1.63-.78,1.57-2.91,6.79-2.81,8.35.16,2.1,4.24,6.97,5.41,9.28.31.61,1.54,3.25,1.24,3.73-.45.71-7.04,2.47-8.23,2.74-4.77,1.09-9.7,1.58-14.5,2.56-.26-.23,6.7-5.19,9.46-13.62,1.79-5.5-6.64-10.43-11.05-11.41-7.4-1.67-15.05,3.33-20.35,7.96l-4.23,4.17c.47-1.51,1.23-2.9,1.67-4.43,2.64-9.01-.34-20.1-.44-20.34.63-.09,1.27.71,1.67-.01.87-2.38,1.4-4.98,2.45-7.3.22-.5,1.13-1.55,1-1.95-5.81-3.68-12.82-6.43-10.61-13.11-.27-1.24-2.06,1.48-2.25,1.79-1.2,1.99-3.4,6.92-3.4,9.17,0,3.07,2.12,4.11,2.46,5.87.12.63-.18,1.95-.16,2.8.14,4.15,2.12,9.94,2.96,14.21,1.93,9.78,2.55,16.11-4.81,23.74-.72.75-9.19,3.71-10.2,2.78-2.26-6.39-10.51-19.18-18.41-12.73-3.52,2.87-6.25,8.73-7.48,13.04-2.5,8.83,4.32,10.97,11.65,11.28,2.5.12,4.52-.38,6.9-.52,1.11-.08,1.99-.09,1.23,1.22-.92,1.58-15.56,16.28-29.89,15.6-2.72-.13-10.67-1.83-14.21-2.81-.34-.09-.56.36-.27.57.75.54,10.15,7.16,16.92,8.41,1.27.23,3.27.21,4.65.21,0,0-1.44.38-1.44.7,0,.1.12.65,1.23.89.83.18,2.45.26,2.45.26,9.13-.07,15.98-8.55,19.69-13.13,2.55-3.14,4.57-6.66,5.78-10.36.14-.41.17-1.55.6-1.7.53-.18,26.49-2.3,34.5-1.32,9.25,1.15,24.69-1.36,32.76-6.16,4.68-2.78,8.59-11.22,8.2-16.59ZM465.63,71.52c-.88-1.54,1.33-3.08,2.77-3.09,1.67-.01,6.42,4.66,6.26,5.29-1.81-.05-8.13-.62-9.03-2.2ZM500.54,70.49c-.08-.31.13-.36.26-.53.7-.92,1.9-1.73,2.74-2.56,4.37-4.31,11.57-10.71,18.14-10.36,3.91.21,7.97,2.72,10.69,5.41.06.28-.23.36-.4.47-.84.56-2.8,1.31-3.79,1.7-8.75,3.39-18.36,4.8-27.64,5.89Z"/>
</g>
</g>
<g>
<path class="cls-2" d="M217.93,56.12c2.78-1.56,3.83-5.94,4.41-8.84l-.31-.22c-1.28,2.08-4,4.11-6.31,2.14-2.91-2.49-1.56-7.07-.57-10.19.99-3.12,2.59-5.94,3.94-8.89,0,0-12.84,14.54-7.4,24.01,1.32,2.29,3.82,3.34,6.23,1.98Z"/>
<path class="cls-1" d="M339.55,40.67c-.21.38-1.07,2.03-1.03,2.32.02.13.31.35.47.12,5.9-4.13,13.01-5.94,19.63-8.59,4.02-1.61,7.78-2.67,10.3-6.45.73-1.09,1.59-2.51,1.51-3.86l-2.7,1.79c-8.01,5.05-23.37,5.98-28.18,14.67Z"/>
<path class="cls-2" d="M251.93,48.45c2.1,1.09,3.59-.9,4.9-2.21,2.22,1.02,4.11.54,5.3-1.62,1.28-2.33.53-4.16-.14-6.51-.85-.84-1.13,1.52-1.14,2.06,0,1.09.59,2.4-.66,3.18-1.34.83-2.38-.6-2.52-1.85-.07-.63,0-1.46,0-2.12,0-.74-.76.64-.82.77-.47,1.06-.45,1.75-.67,2.78-.59,2.71-3.83,3.62-4,.25-.02-.42.22-1.76.17-1.92-.3-1.14-1.3,1.41-1.38,1.65-.51,1.6-.84,4.62.95,5.55Z"/>
<g>
<path class="cls-3" d="M371.43,67.19c-1.92-5.4-10.44-12.79-16.07-15.06-2.28-.92-4.29-.92-5.95,0-2.69,1.49-3.83,5.03-4.31,7.74-.25,1.55-.59,4.2,0,4.62l.2.14.24-.06c.68-.17,1.25-.53,1.8-.88.74-.46,1.37-.86,2.21-.86,2.34,0,8.55,5.01,11.36,7.7,1.77,1.69,3.56,3.77,5.08,5.88-.43.22-1.49.65-4.1,1.45l-.11.03c-3.7,1.13-7.91,2.17-11.53,2.85-.22.04-.46.09-.72.14-2.51.5-6.71,1.33-7.93-.74-.15-.26-.25-.58-.35-.91-.16-.55-.35-1.17-.87-1.63l-.15-.14h-.2c-.48,0-.73.14-1.62,3.9-1.22,5.13-.98,8.35.76,10.12,1.07,1.08,2.69,1.61,4.97,1.61,1.81,0,4.05-.31,7.26-1.01,3.87-.85,15.21-3.89,17.47-6.22.98-1.01,1.5-2.63,1.67-3.28,1.25-4.65,2.41-11.09.88-15.39Z"/>
<path class="cls-3" d="M334.01,41.61l-.26.06.26-.07c-1.94-8.37-3.96-17.03-3.69-24.32,0-.1.05-.23.1-.36.13-.34.33-.85-.03-1.44l-.12-.2-.23-.05c-.98-.2-1.59.44-2.05.91l-.1.11c-6.98,7.12-6.41,14.05-4.94,22.37.83,4.72,2,9.24,3.13,13.64,2.89,11.24,5.63,21.85,2.6,34.3-.08.31-.46,1.01-.77,1.57-.48.87-.75,1.38-.79,1.73-.05.49.17,1.06.68,1.18.04,0,.08.02.14.02.29,0,.61-.16,2.15-2.17,5.72-7.49,8.5-19.01,7.26-30.08-.61-5.44-2-11.42-3.34-17.2Z"/>
<path class="cls-3" d="M272.08,56.27l.25-.08-.25.08c2.16,6.63,4.2,12.89,3.73,20.06-.06.93-.34,2.15-.62,3.35-.33,1.43-.65,2.78-.63,3.76,0,.32.02.69.5,1.03l.39.27.31-.36c2.71-3.13,4.14-7.58,5.09-11.1,2.62-9.78,1.49-16.82-.91-26.14,0,0-.82-3.09-1.35-4.88-1.28-4.27-3.42-8.98-3.15-12.78.01-.17.09-.5.17-.84.25-1.09.54-2.32-.02-2.95-.13-.15-.43-.4-.96-.35-1.13.11-4.09,4.59-4.11,4.63-3.24,5.18-2.75,8.94-1.68,14.66.76,4.03,2.02,7.9,3.24,11.65Z"/>
<path class="cls-3" d="M287.73,37.28l10.4,5.14c.2.1.42.15.65.15.52,0,1-.27,1.27-.71l3.77-6.26c2.94.96,5.68,2.63,8.33,4.24l.09.06c.4.23.85.16,1.28-.24l5.55-7.34c.23-.3.32-.68.25-1.05-.07-.37-.28-.7-.6-.9-2.01-1.3-7.55-4.26-9.67-5.09-1.85-.73-2.16-.56-3.11.71l-.24.32c-.79,1.04-1.45,2.16-2.12,3.28-.39.65-.79,1.33-1.21,1.97-.54-.19-1.77-.83-2.8-1.36-2.2-1.13-4.93-2.54-6.44-2.76-.6-.09-1.19.19-1.51.71l-4.48,7.1c-.21.34-.27.75-.17,1.14.11.39.38.71.74.89Z"/>
<path class="cls-3" d="M269.89,96.65l.1.06c.41.23.85.15,1.28-.24l5.55-7.34c.23-.3.32-.68.25-1.05s-.28-.7-.6-.9c-2.01-1.3-7.55-4.26-9.67-5.09-1.85-.73-2.16-.56-3.11.71l-.24.32c-.78,1.03-1.45,2.15-2.1,3.25-.39.66-.8,1.35-1.23,2-.54-.19-1.77-.83-2.8-1.36-2.2-1.13-4.93-2.54-6.44-2.76-.6-.09-1.19.19-1.51.71l-4.48,7.1c-.21.34-.27.75-.17,1.14.11.39.38.71.74.89l10.4,5.14c.2.1.42.15.65.15.52,0,1-.27,1.27-.71l3.77-6.26c2.94.96,5.68,2.63,8.33,4.24Z"/>
<path class="cls-2" d="M321.83,98.85h0c-.22.09-.44.3-.81.67-.29.28-.69.67-.94.79-1.38.68-2.96.36-3.76-.76-1.41-1.97-.24-5.48.76-7.94.22-.53.57-1.17.92-1.81.43-.78.87-1.59,1.08-2.25.13-.4.23-.71.03-1.27l-.11-.29-.3-.05c-.64-.12-.95.33-1.11.56-.02.03-.04.06-.07.09-2.28,2.79-6.98,10.67-6.09,15.95.29,1.71,1.13,3.01,2.5,3.85.79.48,1.61.73,2.46.73.73,0,1.47-.19,2.2-.55,2.26-1.13,4.14-3.97,4.37-6.6.02-.2-.02-.78-.4-1.05-.21-.15-.48-.18-.73-.07Z"/>
<path class="cls-2" d="M311.94,13.76h0c-.15.07-.31.21-.58.47-.21.2-.49.47-.67.56-.97.48-2.09.26-2.66-.54-1-1.4-.17-3.88.54-5.62.15-.38.4-.83.65-1.28.3-.55.62-1.12.77-1.59.09-.28.16-.5.02-.9l-.07-.2-.21-.04c-.45-.08-.67.23-.79.4-.02.02-.03.05-.05.07-1.61,1.97-4.94,7.55-4.31,11.29.2,1.21.8,2.13,1.77,2.72.56.34,1.14.52,1.74.52.52,0,1.04-.13,1.56-.39,1.6-.8,2.93-2.81,3.09-4.67.01-.14-.01-.55-.28-.75-.15-.11-.34-.13-.51-.05Z"/>
<path class="cls-3" d="M324.39,61.48c-.46-4.68-6.51-11.83-8.36-13.91-.37-.4-.7-.72-1.03-.98l-.06-.04h-.07c-.37-.07-.58.22-.71.39l-.04.05c-1.1,1.4-3.82,6.09-4.01,7.99-.23,2.25,1.86,4.95,3.53,7.13.52.68,1.02,1.32,1.39,1.88.08.13.19.29.32.47.63.92,2.09,3.06,2.07,3.74,0,.12-.22.8-3.83,2.4-1.45.63-3.84,1.5-4.41,1.5-1.17-4.42-6.15-11.55-11.48-12.92-2.47-.64-4.66.05-6.51,2.05-2.31,2.49-4.2,6.39-5.2,9.22-1.55,4.38-2.29,7.83-.87,10.45.99,1.81,2.94,3.04,5.98,3.76,3.75.89,8.59.71,12.73-.47-.07.19-.18.42-.28.62-1.7,3.37-15.28,16.69-29.4,16.03-2.54-.12-9.74-2.12-13.6-3.2l-.94-.26c-.28-.08-.56.06-.69.32-.13.26-.05.57.18.74.97.7,1.98,1.4,3.02,2.08,3.88,2.52,9.54,5.71,14.49,6.61.93.17,2.79.1,3.98.1-.07.04-.71.2-.76.25-.1.1-.15.21-.14.33,0,.09.06.86,1.47,1.17.82.18,1.64.27,2.45.27,3.53,0,7.57-1.23,10.55-3.2,4.24-2.81,13.94-12.5,15.28-23.12,5.5-2.28,9.66-4.87,12.1-10,.77-1.6,3.23-7.14,2.82-11.46h0ZM298.91,73.94c-.72.07-2.88-.02-3.4-.08-1.46-.18-4.19-.96-4.43-1.88-.11-.4.28-1.4.66-1.7.5-.39,1.37-.39,2.47-.02,1.99.68,4.16,2.4,4.7,3.69Z"/>
<path class="cls-3" d="M267.79,59.46c-1.01-2.05-2.43-4.14-4.23-6.49-.09-.12-.19-.28-.28-.45-.33-.56-.79-1.32-1.46-1.29-.44.01-.8.33-1.1.96-.8,1.61-1.9,4.71-2.49,6.41l-.16.43c-.44,1.25-.95,2.67-.37,4.02.49,1.14,1.32,2.37,2.14,3.57.88,1.28,2.46,4.25,2.46,4.38,0,.01-.07.16-.57.35-.66.24-9.67,2.56-11.43-4.79-1.35-5.65-6.8-34.72-9.24-47.44-.47-2.44-1.6-4.55-2.34-3.71-.8.9-5.73,6.31-6.12,7.53-.09.31-.24.53-.15.89,0,.04.01.08.03.12,3.66,10.92,5.66,23.65,7.3,35.05,0,.01.01.03.01.04.31,2.12.64,4.16,1.04,6.11-.01.45-.2.9-.52,1.37-.55.73-1.52,1.46-2.73,2.05-1.05.51-2.21.86-3.09.96-.88.09-1.78.07-2.71-.08-2.87-.44-5.53-2.01-7.09-4.18-.17-.24-.35-.59-.53-.93-.25-.49-.52-1.01-.83-1.33l-.07-.07c-.51-.59-.94-.48-1.2-.32-.44.23-.72.92-1.05,1.81l-.08.24c-.8,2.04-2.18,6.03-2.33,8.09-.05.71.01,1.41.07,2.09l.04.35.16.15c1.84,1.56,3.33,2.93,4.6,4.51l.2.23c.88,1.08,1.06,1.34,1.09,1.38.2.71-.12,1.8-.92,3.18-.86,1.49-2.17,3.01-3.02,4l-.43.49c-13.12,15.57-24.05,12.43-28.72,11.08-.47-.13-.88-.24-1.22-.33l-.76-.17.12.77c.59,3.86,8.51,7.34,8.86,7.49l.17.05c.81.13,1.64.21,2.43.27-.11.17-.15.39-.13.61l.03.35.32.12c.44.17.9.31,1.38.4.68.13,1.38.2,2.16.2,1.13,0,2.38-.15,3.85-.45,11.24-2.32,18.68-13.46,21.06-23.48.07-.29.15-.77.27-1.46.12-.75.44-2.71.63-2.97.16-.2,1.13-.41,1.6-.52.36-.08.69-.15.95-.23,4.9-1.66,7.94-4.18,9.58-8.01,4.06,10.53,12.35,10.27,13.62,10.3,4.15.08,7.65-2.12,9.62-6.01.48-.96.9-1.86,1.28-2.74,2.61-6.15,2.5-10.42.32-14.91Z"/>
</g>
<path class="cls-1" d="M263.48,30.62c.13-.89-.04-.9-.76-.62-2.19.82-4.7,1.96-6.84,2.95-2.94,1.36-5.86,2.56-6.26,6.24.16.17,2.1-1.17,2.42-1.34,2.38-1.27,4.98-2.14,7.41-3.3,1.63-.78,3.73-1.94,4.02-3.92Z"/>
</g>
<g>
<path class="cls-3" d="M49.47,41.16c-.18.16-.38.22-.61.1-2.87-1.75-5.83-3.55-9.05-4.53l-4.04,6.71c-.26.43-.8.58-1.24.36l-10.55-5.21c-.48-.24-.65-.83-.36-1.28l4.54-7.2c.21-.34.61-.53,1-.48,2.53.36,9.03,4.43,9.7,4.23,1.23-1.82,2.21-3.83,3.55-5.59,1.07-1.41.98-1.58,2.77-.87,2.19.86,7.75,3.85,9.71,5.12.41.26.5.81.21,1.19l-5.63,7.44Z"/>
<g>
<path class="cls-1" d="M115.09,48.87c8.37-4.02,18.12-7.1,26.81-10.56,5.22-2.08,9.22-2.94,11.61-8.6.15-.36.88-2.04.68-2.27l-3.54,1.92c-10.25,4.41-21.04,7.42-31.32,11.77-3.89,1.65-9.13,3.8-11.8,7.15-1.55,1.94-2.79,4.69-3.22,7.14l.36.21c3.1-2.61,6.76-4.99,10.42-6.75Z"/>
<path class="cls-1" d="M191.25,86.23c-8.15,3.26-17.96,5.21-25.73,8.78-3.2,1.47-5.5,3.98-6.5,7.36.26.05.44-.06.66-.14,2.34-.85,4.92-2.43,7.33-3.39,8.27-3.3,17.66-5.33,25.68-9.19,3.11-1.5,6.29-4.34,7.44-7.66-1.15.52-2.2,1.2-3.33,1.76-1.8.88-3.7,1.74-5.56,2.49Z"/>
<path class="cls-2" d="M141.79,56.7c1.9.04,2.21-1.19,3.19-2.26.16-.17,1.2-1.24,1.29-1.26.78-.19,1.62.6,2.3.71,3.33.53,6.08-2.7,6.16-5.81.02-.66-.97-5.78-1.26-5.98-.57-.39-1.14,1.15-1.21,1.56-.25,1.51.44,4.42-.59,5.49-.99,1.02-2.4.7-3.25-.3-.96-1.14-.75-2.62-.85-3.98-.06-.9-.49-.32-.75.15-1.22,2.15-.69,6.93-3.99,7.11-3.16.17-2.45-3.89-2.23-5.91-.54-.54-1.47,1.85-1.61,2.23-1,2.84-1.5,8.16,2.8,8.25Z"/>
<path class="cls-1" d="M69.89,49.89c3.39-1.55,9.76-3.16,11.37-6.74.2-.44.72-1.93-.17-1.8-3.48,1.66-7.18,2.86-10.66,4.51-3.02,1.43-6.09,3.25-6.67,6.86l.3.18c1.98-.92,3.83-2.1,5.82-3.01Z"/>
<path class="cls-2" d="M69.76,42.61c-.02.4.24.41.49.29.49-.23,1.84-4.67,2-5.45,1.41-6.83-1.25-13.04-6.48-17.42-.51-.42-1.85-1.63-2.47-1.35-.85.39-2.28,3.33-2.13,4.27.22,1.36,2.26,2.29,3.2,3.09,4.58,3.93,6.88,7.95,5.96,14.19-.11.77-.53,1.64-.57,2.38Z"/>
<g>
<path class="cls-3" d="M194.12,38.09l3.92-8.92c.17-.48.16-.87-.03-1.19-.1-.17-.35-.31-2.33-1.16-.64-.28-1.25-.54-1.47-.66-4.53-2.45-6.82-5.24-6.81-8.29,0-.25.11-.67.22-1.08.23-.85.42-1.59-.01-1.89-.38-.27-.93.05-1.42.39-2.16,1.53-5.87,8.92-5.5,11.73.12.91.63,1.72,1.08,2.44.32.5.61.98.68,1.35.13.69.12,1.71.12,2.7,0,.85-.01,1.73.07,2.45.14,1.27.41,2.65.66,3.99.1.52.2,1.03.29,1.53.73,4.03,1.66,8.09,2.57,12.02,1.23,5.34,2.51,10.86,3.27,16.38-.26.38-2.31,1.21-2.93,1.32-6.58,1.18-9.6-5.39-10.98-11.11-2.08-8.64-3.48-17.89-4.84-26.83-.59-3.87-1.19-7.87-1.84-11.64l.17-3.44-.11-.1c-.15-.15-.34-.22-.57-.2-.25.02-1.01.09-3.12,3.05-4.42,6.19-4.12,8.32-3.1,15.38.54,3.77,1.36,7.69,2.15,11.48,1.08,5.21,2.2,10.6,2.65,15.71.15,1.75-.16,2.65-1.33,3.85-2.46,2.5-5.33,2.72-7.31,2.47-2.54-.33-5.15-1.65-7.16-3.62-.22-.21-.53-.63-.83-1.03-.44-.58-.85-1.13-1.16-1.35-.22-.15-.58-.4-1.03-.08l-.07.05-4.3,9.93-.03.12c-.03,1.1,1.7,2.68,3.96,4.63.74.63,1.37,1.18,1.7,1.55.19.76-1.79,3.84-2.93,5.24-5.59,6.92-16.72,13.68-26.31,13.23-2.8-.13-10.92-2.12-14.56-3.13-.35-.1-.58.36-.28.58,3.09,2.23,10.85,7.46,17.39,8.65,1.31.24,3.53.26,4.93.26l-.84.21-.69.17-.07.14c-.06.14-.07.29,0,.42.13.27.49.46,1.22.65.74.19,1.55.27,2.41.27,3.53,0,7.81-1.44,10.4-3.16,7.58-5.01,12.84-13.13,14.45-22.3,2.38-.09,6.55-.59,9.72-2.85,2.1-1.49,3.93-4.35,5.02-7.77l.98,3.14c2.23,5.21,6.28,8.5,10.83,8.8,4.27.28,8.26-2.2,10.97-6.78,3.65-6.18,4.95-12.61,3.98-19.64-.41-2.99-1.09-6.01-1.75-8.93-.65-2.88-1.32-5.84-1.73-8.78.4.16,1.08.36,1.63-.32Z"/>
<path class="cls-3" d="M72.15,88.67c1.62,0,3.24-.28,4.8-.83,3.76-1.34,6.73-4.15,8.38-7.93,4.34-9.96,2.27-14.86-4.37-22.74-.14-.17-.29-.4-.46-.65-.51-.78-1.14-1.75-2-1.61l-.11.04c-.51.27-2.95,5.9-2.97,5.96-.44,1.23-.88,2.66-.76,3.84.14,1.34,1.85,3.88,3.51,6.33.86,1.27,1.67,2.47,2.08,3.26l.1.18c.51.96.66,1.38.68,1.54-3.41,1.96-7.57,2.02-11.45.18-6.66-3.17-8.85-12.14-10.61-19.35-.39-1.61-.77-3.14-1.17-4.54.06-.25.62-.96.93-1.34.25-.31.46-.58.59-.78.65-1.05,1.34-2.33,1.68-3,.09-.17.19-.35.28-.53.49-.9,1.04-1.92,1.15-2.93l.1-.94-5.6,5.6-2.05-9.19-.11-.07c-.09-.06-.25-.13-.46-.09-.64.12-1.39,1.18-1.4,1.19-1.27,1.86-3.66,7.82-3.68,10.17,0,.46.09.91.19,1.36.07.35.15.68.17,1l-.41.15c-1.44.53-2.93,1.08-4.33,1.72l-.31.14c-5.44,2.47-12.21,5.54-15.63,10.65-1.37,2.04-3.89,7.89-4.26,10.23-.48,3.03.9,3.67,3.24,4.36,5.32,1.58,16.59.97,21.27-1.83,2.78-1.67,3.64-4.11,4.55-6.7.22-.62.44-1.26.7-1.89,1.09,5.27,3.89,13.81,10.81,17.37,2.21,1.14,4.57,1.71,6.92,1.71ZM33.63,68.58c.09-.06.17-.11.25-.17,2.4-1.68,5.2-3.12,7.92-4.51,2.74-1.4,5.57-2.86,8.04-4.57.31-.22.61-.5.91-.77.23-.22.45-.42.65-.56l2.11,8.77c-.48.47-3.29,1.78-4.2,2.11-4.94,1.79-10.34,1.96-16.51.51.09-.29.42-.52.85-.8Z"/>
<path class="cls-3" d="M106.79,87.85c3.39-.34,7.1-4.21,8.72-8.67,3.47,1.19,7.15,2.4,10.89,3.22l.33.07c2.85.63,5.89,1.23,6.85.8.48-.21.71-.84.9-1.35.04-.11.08-.21.12-.3.51-1.18,3.08-7.19,3.42-9.56.56-3.84-2.05-9.51-5.8-12.65-2.5-2.09-5.21-2.79-7.64-1.98-4.72,1.58-6.8,6.35-8.81,10.97-1.23,2.82-2.39,5.49-4.08,7.19-1.4,1.42-2.91,2.03-4.5,1.81-2.72-.37-6.25-2.79-7.12-5.45-3.05-9.37-4.66-28.07-7.84-47.56-.39-2.41-1.53-4.35-2.31-3.66-.89.79-5.65,6.21-6.03,7.42-.11.34-.27.57-.13.99,8.04,24.03,1.75,60.83,23.02,58.68ZM120.95,68.88c.71-1.13,1.64-1.57,2.85-1.36,2.43.43,5.04,3.37,5.7,4.44-1.33-.22-4.76-1.4-6.16-1.96l-.17-.07c-1.57-.61-2.06-.92-2.21-1.06Z"/>
<path class="cls-3" d="M96.14,94.72c-2.04-1.31-7.63-4.3-9.77-5.15-1.77-.7-1.99-.58-2.91.65l-.25.33c-.79,1.04-1.46,2.17-2.11,3.27-.43.72-.87,1.47-1.34,2.18-.47-.12-1.81-.82-3.02-1.44-2.21-1.14-4.97-2.57-6.47-2.78-.52-.07-1.04.17-1.33.63l-4.54,7.2c-.19.29-.24.65-.14.99.09.33.33.61.64.77l10.55,5.21c.18.09.37.13.57.13.44,0,.87-.22,1.11-.62l3.91-6.49c3.06.97,5.9,2.7,8.65,4.37l.09.05c.19.11.57.23.99-.14l5.67-7.49c.19-.26.27-.59.22-.9-.06-.32-.24-.6-.51-.77Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

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
}