Compare commits
3
Commits
9ec9a54649
...
13b14abb12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13b14abb12
|
||
|
|
f68c4e50e8
|
||
|
|
4392122cfc
|
@@ -1,4 +1,9 @@
|
|||||||
from django.contrib import admin, messages
|
from django.contrib import admin, messages
|
||||||
|
from django_object_actions import (
|
||||||
|
DjangoObjectActions,
|
||||||
|
action,
|
||||||
|
takes_instance_or_queryset,
|
||||||
|
)
|
||||||
|
|
||||||
from submissions.tasks import sync_event_game_scopes_task
|
from submissions.tasks import sync_event_game_scopes_task
|
||||||
|
|
||||||
@@ -19,17 +24,22 @@ class GameAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(EventGame)
|
@admin.register(EventGame)
|
||||||
class EventGameAdmin(admin.ModelAdmin):
|
class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||||
list_display = ["game", "event", "enabled", "has_apibot_token"]
|
list_display = ["game", "event", "enabled", "has_apibot_token"]
|
||||||
list_filter = ["event", "enabled"]
|
list_filter = ["event", "enabled"]
|
||||||
search_fields = ["game__name", "event__name"]
|
search_fields = ["game__name", "event__name"]
|
||||||
actions = ["sync_scopes"]
|
actions = ["sync_scopes"]
|
||||||
|
change_actions = ["sync_scopes"]
|
||||||
|
|
||||||
@admin.display(description="AAndT token", boolean=True)
|
@admin.display(description="AAndT token", boolean=True)
|
||||||
def has_apibot_token(self, obj):
|
def has_apibot_token(self, obj):
|
||||||
return bool(obj.apibot_token)
|
return bool(obj.apibot_token)
|
||||||
|
|
||||||
@admin.action(description="Sync scopes from AAndT for selected EventGames")
|
@action(
|
||||||
|
label="Sync scopes",
|
||||||
|
description="Sync scopes from AAndT for selected EventGames",
|
||||||
|
)
|
||||||
|
@takes_instance_or_queryset
|
||||||
def sync_scopes(self, request, queryset):
|
def sync_scopes(self, request, queryset):
|
||||||
tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="")
|
tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="")
|
||||||
skipped = queryset.count() - tokened.count()
|
skipped = queryset.count() - tokened.count()
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django_object_actions import (
|
||||||
|
DjangoObjectActions,
|
||||||
|
action,
|
||||||
|
takes_instance_or_queryset,
|
||||||
|
)
|
||||||
from market.models import Market, MarketOption, UserBet, UserPointChange
|
from market.models import Market, MarketOption, UserBet, UserPointChange
|
||||||
|
|
||||||
|
|
||||||
@@ -9,7 +14,7 @@ class MarketOptionInline(admin.TabularInline):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Market)
|
@admin.register(Market)
|
||||||
class MarketAdmin(admin.ModelAdmin):
|
class MarketAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||||
list_display = ["title", "status", "end_date", "created_by", "created_at"]
|
list_display = ["title", "status", "end_date", "created_by", "created_at"]
|
||||||
list_filter = ["status", "created_at"]
|
list_filter = ["status", "created_at"]
|
||||||
search_fields = ["uuid", "title"]
|
search_fields = ["uuid", "title"]
|
||||||
@@ -39,22 +44,25 @@ class MarketAdmin(admin.ModelAdmin):
|
|||||||
obj.created_by = request.user
|
obj.created_by = request.user
|
||||||
super().save_model(request, obj, form, change)
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
@admin.action(description="Publish selected draft markets")
|
actions = ["publish_markets", "close_markets"]
|
||||||
|
change_actions = ["publish_markets", "close_markets"]
|
||||||
|
|
||||||
|
@action(label="Publish", description="Publish selected draft markets")
|
||||||
|
@takes_instance_or_queryset
|
||||||
def publish_markets(self, request, queryset):
|
def publish_markets(self, request, queryset):
|
||||||
updated = queryset.filter(status=Market.Status.DRAFT).update(
|
updated = queryset.filter(status=Market.Status.DRAFT).update(
|
||||||
status=Market.Status.OPEN
|
status=Market.Status.OPEN
|
||||||
)
|
)
|
||||||
self.message_user(request, f"Published {updated} market(s).")
|
self.message_user(request, f"Published {updated} market(s).")
|
||||||
|
|
||||||
@admin.action(description="Close selected markets")
|
@action(label="Close", description="Close selected markets")
|
||||||
|
@takes_instance_or_queryset
|
||||||
def close_markets(self, request, queryset):
|
def close_markets(self, request, queryset):
|
||||||
updated = queryset.filter(status=Market.Status.OPEN).update(
|
updated = queryset.filter(status=Market.Status.OPEN).update(
|
||||||
status=Market.Status.CLOSED
|
status=Market.Status.CLOSED
|
||||||
)
|
)
|
||||||
self.message_user(request, f"Closed {updated} market(s).")
|
self.message_user(request, f"Closed {updated} market(s).")
|
||||||
|
|
||||||
actions = ["publish_markets", "close_markets"]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(MarketOption)
|
@admin.register(MarketOption)
|
||||||
class MarketOptionAdmin(admin.ModelAdmin):
|
class MarketOptionAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django_object_actions import (
|
||||||
|
DjangoObjectActions,
|
||||||
|
action,
|
||||||
|
takes_instance_or_queryset,
|
||||||
|
)
|
||||||
|
|
||||||
from noita.services.objectives import parse_objectives_and_store
|
from noita.services.objectives import parse_objectives_and_store
|
||||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LogfileSubmission)
|
@admin.register(LogfileSubmission)
|
||||||
class LogfileSubmissionAdmin(admin.ModelAdmin):
|
class LogfileSubmissionAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
"id",
|
"id",
|
||||||
"user",
|
"user",
|
||||||
@@ -26,7 +31,10 @@ class LogfileSubmissionAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
actions = ["validate_submission"]
|
actions = ["validate_submission"]
|
||||||
|
change_actions = ["validate_submission"]
|
||||||
|
|
||||||
|
@action(label="Validate", description="Validate selected submissions")
|
||||||
|
@takes_instance_or_queryset
|
||||||
def validate_submission(self, request, queryset):
|
def validate_submission(self, request, queryset):
|
||||||
for logfile in queryset:
|
for logfile in queryset:
|
||||||
parse_objectives_and_store(logfile)
|
parse_objectives_and_store(logfile)
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django_object_actions import (
|
||||||
|
DjangoObjectActions,
|
||||||
|
action,
|
||||||
|
takes_instance_or_queryset,
|
||||||
|
)
|
||||||
from opus_magnum.models import (
|
from opus_magnum.models import (
|
||||||
SteamAPIKey,
|
SteamAPIKey,
|
||||||
SteamCollection,
|
SteamCollection,
|
||||||
@@ -185,7 +190,7 @@ class PuzzleResponseInline(admin.TabularInline):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Submission)
|
@admin.register(Submission)
|
||||||
class SubmissionAdmin(admin.ModelAdmin):
|
class SubmissionAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
"id",
|
"id",
|
||||||
"user",
|
"user",
|
||||||
@@ -238,9 +243,11 @@ class SubmissionAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
actions = ["mark_as_validated"]
|
actions = ["mark_as_validated"]
|
||||||
|
change_actions = ["mark_as_validated"]
|
||||||
|
|
||||||
|
@action(label="Validate", description="Mark selected submissions as validated")
|
||||||
|
@takes_instance_or_queryset
|
||||||
def mark_as_validated(self, request, queryset):
|
def mark_as_validated(self, request, queryset):
|
||||||
"""Mark selected submissions as validated"""
|
|
||||||
updated = 0
|
updated = 0
|
||||||
for submission in queryset:
|
for submission in queryset:
|
||||||
if not submission.is_validated:
|
if not submission.is_validated:
|
||||||
@@ -254,11 +261,9 @@ class SubmissionAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
self.message_user(request, f"{updated} submissions marked as validated.")
|
self.message_user(request, f"{updated} submissions marked as validated.")
|
||||||
|
|
||||||
mark_as_validated.short_description = "Mark selected submissions as validated"
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PuzzleResponse)
|
@admin.register(PuzzleResponse)
|
||||||
class PuzzleResponseAdmin(admin.ModelAdmin):
|
class PuzzleResponseAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
"puzzle_name",
|
"puzzle_name",
|
||||||
"submission",
|
"submission",
|
||||||
@@ -314,22 +319,22 @@ class PuzzleResponseAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
actions = ["mark_for_validation", "clear_validation_flag"]
|
actions = ["mark_for_validation", "clear_validation_flag"]
|
||||||
|
change_actions = ["mark_for_validation", "clear_validation_flag"]
|
||||||
|
|
||||||
|
@action(label="Mark for validation", description="Mark as needing validation")
|
||||||
|
@takes_instance_or_queryset
|
||||||
def mark_for_validation(self, request, queryset):
|
def mark_for_validation(self, request, queryset):
|
||||||
"""Mark selected responses as needing validation"""
|
|
||||||
updated = queryset.update(needs_manual_validation=True)
|
updated = queryset.update(needs_manual_validation=True)
|
||||||
self.message_user(request, f"{updated} responses marked for validation.")
|
self.message_user(request, f"{updated} responses marked for validation.")
|
||||||
|
|
||||||
|
@action(label="Clear validation flag", description="Clear validation flag")
|
||||||
|
@takes_instance_or_queryset
|
||||||
def clear_validation_flag(self, request, queryset):
|
def clear_validation_flag(self, request, queryset):
|
||||||
"""Clear validation flag for selected responses"""
|
|
||||||
updated = queryset.update(needs_manual_validation=False)
|
updated = queryset.update(needs_manual_validation=False)
|
||||||
self.message_user(
|
self.message_user(
|
||||||
request, f"{updated} responses cleared from validation queue."
|
request, f"{updated} responses cleared from validation queue."
|
||||||
)
|
)
|
||||||
|
|
||||||
mark_for_validation.short_description = "Mark as needing validation"
|
|
||||||
clear_validation_flag.short_description = "Clear validation flag"
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SubmissionFile)
|
@admin.register(SubmissionFile)
|
||||||
class SubmissionFileAdmin(admin.ModelAdmin):
|
class SubmissionFileAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
"tesseract.js": "^5.1.1",
|
"tesseract.js": "^5.1.1",
|
||||||
"vue": "^3.5.22"
|
"vue": "^3.5.22",
|
||||||
|
"vue-i18n": "^11.4.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "^0.97.2",
|
"@hey-api/openapi-ts": "^0.97.2",
|
||||||
|
|||||||
Generated
+56
@@ -35,6 +35,9 @@ importers:
|
|||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.22
|
specifier: ^3.5.22
|
||||||
version: 3.5.22(typescript@5.9.3)
|
version: 3.5.22(typescript@5.9.3)
|
||||||
|
vue-i18n:
|
||||||
|
specifier: ^11.4.12
|
||||||
|
version: 11.4.12(vue@3.5.22(typescript@5.9.3))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@hey-api/openapi-ts':
|
'@hey-api/openapi-ts':
|
||||||
specifier: ^0.97.2
|
specifier: ^0.97.2
|
||||||
@@ -264,6 +267,22 @@ packages:
|
|||||||
'@hey-api/types@0.1.4':
|
'@hey-api/types@0.1.4':
|
||||||
resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==}
|
resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==}
|
||||||
|
|
||||||
|
'@intlify/core-base@11.4.12':
|
||||||
|
resolution: {integrity: sha512-tvYUWgMJmk+UvLFa7ck8uRQ9m19tTI9B2e01KCAjBV7+HN2zFLnnORvMO85hM1P8DIw4MTvot1ZSBUdoR+F6jA==}
|
||||||
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
|
'@intlify/devtools-types@11.4.12':
|
||||||
|
resolution: {integrity: sha512-Ctkcs8DQItaEL9HHdkyasFFcZFP59iBvZmrjiA7b72JFq/JlI6c6GPQHN7oEwbYTo8rndPMqNY7SX71aPm4MkA==}
|
||||||
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
|
'@intlify/message-compiler@11.4.12':
|
||||||
|
resolution: {integrity: sha512-kBczk9r44fdBYnkC5tXRWa/UaA+e9NglXE3cX7jPnDd2nHzoFHYrKGfIbn8JFZpMA9omexZZo2WxOpTvlHdjsw==}
|
||||||
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
|
'@intlify/shared@11.4.12':
|
||||||
|
resolution: {integrity: sha512-ojfehaXMFrGFcSnK3cJPaMxlrSXT8FwZUG9H4i+WKX4OFD4Yl1WKdTmfwoC0REnwIfTrnt1QhRtd8kwi/te6bQ==}
|
||||||
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
@@ -543,6 +562,9 @@ packages:
|
|||||||
'@vue/compiler-ssr@3.5.22':
|
'@vue/compiler-ssr@3.5.22':
|
||||||
resolution: {integrity: sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==}
|
resolution: {integrity: sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==}
|
||||||
|
|
||||||
|
'@vue/devtools-api@6.6.4':
|
||||||
|
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
|
||||||
|
|
||||||
'@vue/devtools-api@7.7.7':
|
'@vue/devtools-api@7.7.7':
|
||||||
resolution: {integrity: sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==}
|
resolution: {integrity: sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==}
|
||||||
|
|
||||||
@@ -1047,6 +1069,12 @@ packages:
|
|||||||
vscode-uri@3.1.0:
|
vscode-uri@3.1.0:
|
||||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||||
|
|
||||||
|
vue-i18n@11.4.12:
|
||||||
|
resolution: {integrity: sha512-yvArvvo3ErLKFNlQrZUGmRkxL/ctM5xFYtE9FxTKiE/PalWP1bnTopBgbSRGLluYEyYSNl1Pt8l2J7zVavbXww==}
|
||||||
|
engines: {node: '>= 22'}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.0.0
|
||||||
|
|
||||||
vue-tsc@3.1.2:
|
vue-tsc@3.1.2:
|
||||||
resolution: {integrity: sha512-3fd4DY0rFczs5f+VB3OhcLU83V6+3Puj2yLBe0Ak65k7ERk+STVNKaOAi0EBo6Lc15UiJB6LzU6Mxy4+h/pKew==}
|
resolution: {integrity: sha512-3fd4DY0rFczs5f+VB3OhcLU83V6+3Puj2yLBe0Ak65k7ERk+STVNKaOAi0EBo6Lc15UiJB6LzU6Mxy4+h/pKew==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1230,6 +1258,24 @@ snapshots:
|
|||||||
|
|
||||||
'@hey-api/types@0.1.4': {}
|
'@hey-api/types@0.1.4': {}
|
||||||
|
|
||||||
|
'@intlify/core-base@11.4.12':
|
||||||
|
dependencies:
|
||||||
|
'@intlify/devtools-types': 11.4.12
|
||||||
|
'@intlify/message-compiler': 11.4.12
|
||||||
|
'@intlify/shared': 11.4.12
|
||||||
|
|
||||||
|
'@intlify/devtools-types@11.4.12':
|
||||||
|
dependencies:
|
||||||
|
'@intlify/core-base': 11.4.12
|
||||||
|
'@intlify/shared': 11.4.12
|
||||||
|
|
||||||
|
'@intlify/message-compiler@11.4.12':
|
||||||
|
dependencies:
|
||||||
|
'@intlify/shared': 11.4.12
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
'@intlify/shared@11.4.12': {}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -1456,6 +1502,8 @@ snapshots:
|
|||||||
'@vue/compiler-dom': 3.5.22
|
'@vue/compiler-dom': 3.5.22
|
||||||
'@vue/shared': 3.5.22
|
'@vue/shared': 3.5.22
|
||||||
|
|
||||||
|
'@vue/devtools-api@6.6.4': {}
|
||||||
|
|
||||||
'@vue/devtools-api@7.7.7':
|
'@vue/devtools-api@7.7.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-kit': 7.7.7
|
'@vue/devtools-kit': 7.7.7
|
||||||
@@ -1906,6 +1954,14 @@ snapshots:
|
|||||||
|
|
||||||
vscode-uri@3.1.0: {}
|
vscode-uri@3.1.0: {}
|
||||||
|
|
||||||
|
vue-i18n@11.4.12(vue@3.5.22(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
'@intlify/core-base': 11.4.12
|
||||||
|
'@intlify/devtools-types': 11.4.12
|
||||||
|
'@intlify/shared': 11.4.12
|
||||||
|
'@vue/devtools-api': 6.6.4
|
||||||
|
vue: 3.5.22(typescript@5.9.3)
|
||||||
|
|
||||||
vue-tsc@3.1.2(typescript@5.9.3):
|
vue-tsc@3.1.2(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@volar/typescript': 2.4.23
|
'@volar/typescript': 2.4.23
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ INSTALLED_APPS = [
|
|||||||
"django_vite",
|
"django_vite",
|
||||||
"django_rq",
|
"django_rq",
|
||||||
"django_tasks_rq",
|
"django_tasks_rq",
|
||||||
|
"django_object_actions",
|
||||||
"accounts",
|
"accounts",
|
||||||
"events",
|
"events",
|
||||||
"animations",
|
"animations",
|
||||||
@@ -59,6 +60,7 @@ MIDDLEWARE = [
|
|||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.locale.LocaleMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
@@ -78,6 +80,7 @@ TEMPLATES = [
|
|||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"context_processors": [
|
"context_processors": [
|
||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
|
"django.template.context_processors.i18n",
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
],
|
],
|
||||||
@@ -123,6 +126,10 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
|
LANGUAGES = [
|
||||||
|
("en", "English"),
|
||||||
|
]
|
||||||
|
|
||||||
TIME_ZONE = "UTC"
|
TIME_ZONE = "UTC"
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
@@ -155,7 +162,7 @@ AANDT_API_BASE_URL = os.environ.get("AANDT_API_BASE_URL", "https://polylan.ch")
|
|||||||
|
|
||||||
# Base URL of this site, used to build submission links pushed back to the
|
# Base URL of this site, used to build submission links pushed back to the
|
||||||
# legacy AAndT match messages (see submissions.services.aandt_sync)
|
# legacy AAndT match messages (see submissions.services.aandt_sync)
|
||||||
SUBMITTER_BASE_URL = os.environ.get("SUBMITTER_BASE_URL", "http://127.0.0.1:7777")
|
SUBMITTER_BASE_URL = os.environ.get("SUBMITTER_BASE_URL", "http://localhost:7777")
|
||||||
|
|
||||||
# File Upload Settings
|
# File Upload Settings
|
||||||
MEDIA_URL = "/media/"
|
MEDIA_URL = "/media/"
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useTitle } from "@vueuse/core";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
code?: string;
|
code?: string;
|
||||||
title?: string;
|
|
||||||
message?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
code: "500",
|
code: "500",
|
||||||
title: "Something went wrong",
|
|
||||||
message: "An unexpected error occurred.",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const errorKey = computed(() => {
|
||||||
|
if (props.code === "404") return "errorPage.notFound";
|
||||||
|
if (props.code === "500") return "errorPage.serverError";
|
||||||
|
return "errorPage.generic";
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = computed(() => t(`${errorKey.value}.title`));
|
||||||
|
const message = computed(() => t(`${errorKey.value}.message`));
|
||||||
|
|
||||||
|
useTitle(title);
|
||||||
|
|
||||||
const goBack = () => window.history.back();
|
const goBack = () => window.history.back();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -24,11 +37,11 @@ const goBack = () => window.history.back();
|
|||||||
<div class="mt-8 flex items-center justify-center gap-4">
|
<div class="mt-8 flex items-center justify-center gap-4">
|
||||||
<a href="/" class="btn btn-primary">
|
<a href="/" class="btn btn-primary">
|
||||||
<i class="mdi mdi-home mr-2"></i>
|
<i class="mdi mdi-home mr-2"></i>
|
||||||
Go home
|
{{ t("errorPage.goHome") }}
|
||||||
</a>
|
</a>
|
||||||
<button @click="goBack" class="btn btn-ghost">
|
<button @click="goBack" class="btn btn-ghost">
|
||||||
<i class="mdi mdi-arrow-left mr-2"></i>
|
<i class="mdi mdi-arrow-left mr-2"></i>
|
||||||
Go back
|
{{ t("errorPage.goBack") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted, computed } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useTitle } from "@vueuse/core";
|
||||||
import { gamesApiListEventGames } from "./api";
|
import { gamesApiListEventGames } from "./api";
|
||||||
import type { GamesApiListEventGamesResponse } from "./api/types.gen";
|
import type { GamesApiListEventGamesResponse } from "./api/types.gen";
|
||||||
|
|
||||||
@@ -12,6 +14,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
marketEnabled: true,
|
marketEnabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
useTitle(computed(() => t("home.title")));
|
||||||
|
|
||||||
const games = ref<GamesApiListEventGamesResponse | undefined>();
|
const games = ref<GamesApiListEventGamesResponse | undefined>();
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const imageErrors = ref<Set<number>>(new Set());
|
const imageErrors = ref<Set<number>>(new Set());
|
||||||
@@ -58,9 +63,9 @@ onMounted(async () => {
|
|||||||
<div class="w-full max-w-6xl">
|
<div class="w-full max-w-6xl">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="text-center mb-12">
|
<div class="text-center mb-12">
|
||||||
<h1 class="text-5xl font-bold mb-4">PolyLAN Submitter</h1>
|
<h1 class="text-5xl font-bold mb-4">{{ t("home.title") }}</h1>
|
||||||
<p class="text-xl text-base-content/70">
|
<p class="text-xl text-base-content/70">
|
||||||
Choose a game and submit your best solutions
|
{{ t("home.subtitle") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,12 +84,12 @@ onMounted(async () => {
|
|||||||
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div>
|
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div>
|
||||||
</figure>
|
</figure>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl">Market</h2>
|
<h2 class="card-title text-2xl">{{ t("home.marketCard.title") }}</h2>
|
||||||
<p class="text-base-content/70">Place your bets and compete</p>
|
<p class="text-base-content/70">{{ t("home.marketCard.description") }}</p>
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
<button class="btn btn-primary">
|
<button class="btn btn-primary">
|
||||||
<i class="mdi mdi-arrow-right mr-2"></i>
|
<i class="mdi mdi-arrow-right mr-2"></i>
|
||||||
Place bets
|
{{ t("home.marketCard.cta") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +113,7 @@ onMounted(async () => {
|
|||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
<button @click="navigate(game.path)" class="btn btn-primary">
|
<button @click="navigate(game.path)" class="btn btn-primary">
|
||||||
<i class="mdi mdi-arrow-right mr-2"></i>
|
<i class="mdi mdi-arrow-right mr-2"></i>
|
||||||
Submit results
|
{{ t("home.submitResults") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +122,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="text-center mt-12 text-base-content/50">
|
<div class="text-center mt-12 text-base-content/50">
|
||||||
<p>Select a game above to begin submitting</p>
|
<p>{{ t("home.footer") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from "vue";
|
import { onMounted, computed } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useTitle } from "@vueuse/core";
|
||||||
import { useMarketStore } from "./stores/market";
|
import { useMarketStore } from "./stores/market";
|
||||||
import MarketCard from "./components/MarketCard.vue";
|
import MarketCard from "./components/MarketCard.vue";
|
||||||
import UserBets from "./components/UserBets.vue";
|
import UserBets from "./components/UserBets.vue";
|
||||||
@@ -13,6 +15,9 @@ interface Props {
|
|||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
provideEventPath(props.eventPath);
|
provideEventPath(props.eventPath);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
useTitle(computed(() => t("market.pageTitle")));
|
||||||
|
|
||||||
const marketStore = useMarketStore();
|
const marketStore = useMarketStore();
|
||||||
const { markets, userInfo, isLoading } = storeToRefs(marketStore);
|
const { markets, userInfo, isLoading } = storeToRefs(marketStore);
|
||||||
|
|
||||||
@@ -36,20 +41,20 @@ onMounted(() => {
|
|||||||
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
|
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
|
||||||
<button @click="goHome" class="btn btn-primary btn-sm">
|
<button @click="goHome" class="btn btn-primary btn-sm">
|
||||||
<i class="mdi mdi-arrow-left"></i>
|
<i class="mdi mdi-arrow-left"></i>
|
||||||
Back
|
{{ t("common.back") }}
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-xl font-bold">Market</h1>
|
<h1 class="text-xl font-bold">{{ t("market.header") }}</h1>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
|
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-medium">{{ userInfo.username }}</span>
|
<span class="font-medium">{{ userInfo.username }}</span>
|
||||||
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
|
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">{{ t("common.admin") }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
<div v-else class="text-sm text-base-content/70">{{ t("common.notLoggedIn") }}</div>
|
||||||
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
<a href="/api/docs" class="btn btn-xs">{{ t("common.apiDocs") }}</a>
|
||||||
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
<a href="/admin" class="btn btn-xs btn-warning">{{ t("common.adminPanel") }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,8 +63,8 @@ onMounted(() => {
|
|||||||
<div class="container min-w-3/4 mx-auto px-4 py-8">
|
<div class="container min-w-3/4 mx-auto px-4 py-8">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="text-3xl font-bold mb-2">Market</h2>
|
<h2 class="text-3xl font-bold mb-2">{{ t("market.header") }}</h2>
|
||||||
<p class="text-base-content/70">Place your bets on upcoming events</p>
|
<p class="text-base-content/70">{{ t("market.subtitle") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
@@ -75,11 +80,11 @@ onMounted(() => {
|
|||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 class="text-2xl font-bold flex items-center gap-2">
|
<h3 class="text-2xl font-bold flex items-center gap-2">
|
||||||
<i class="mdi mdi-heart text-error"></i>
|
<i class="mdi mdi-heart text-error"></i>
|
||||||
My Bets
|
{{ t("market.myBets") }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="text-lg font-semibold">
|
<div class="text-lg font-semibold">
|
||||||
<span class="text-primary">{{ userInfo.points }}</span>
|
<span class="text-primary">{{ userInfo.points }}</span>
|
||||||
<span class="text-base-content/60 ml-1">pts</span>
|
<span class="text-base-content/60 ml-1">{{ t("common.pts") }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UserBets :markets="markets" @refresh="reloadPage" />
|
<UserBets :markets="markets" @refresh="reloadPage" />
|
||||||
@@ -90,17 +95,17 @@ onMounted(() => {
|
|||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 class="text-2xl font-bold flex items-center gap-2">
|
<h3 class="text-2xl font-bold flex items-center gap-2">
|
||||||
<i class="mdi mdi-list"></i>
|
<i class="mdi mdi-list"></i>
|
||||||
All Markets
|
{{ t("market.allMarkets") }}
|
||||||
</h3>
|
</h3>
|
||||||
<a v-if="userInfo?.is_superuser" href="/admin/market/market/add/" class="btn btn-sm btn-primary">
|
<a v-if="userInfo?.is_superuser" href="/admin/market/market/add/" class="btn btn-sm btn-primary">
|
||||||
<i class="mdi mdi-plus"></i>
|
<i class="mdi mdi-plus"></i>
|
||||||
Create Market
|
{{ t("market.createMarket") }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="markets.length === 0" class="alert">
|
<div v-if="markets.length === 0" class="alert">
|
||||||
<i class="mdi mdi-information mr-2"></i>
|
<i class="mdi mdi-information mr-2"></i>
|
||||||
<span>No markets available</span>
|
<span>{{ t("market.noMarkets") }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useTitle } from "@vueuse/core";
|
||||||
import RankBadge from "@/components/RankBadge.vue";
|
import RankBadge from "@/components/RankBadge.vue";
|
||||||
import { useNoitaStore, type Objective } from "@/stores/noita";
|
import { useNoitaStore, type Objective } from "@/stores/noita";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +22,9 @@ interface Props {
|
|||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
useTitle(computed(() => t("noita.pageTitle")));
|
||||||
|
|
||||||
const noitaStore = useNoitaStore();
|
const noitaStore = useNoitaStore();
|
||||||
const { userInfo, objectives, leaderboard, isLoadingLeaderboard, isUploading } = storeToRefs(noitaStore);
|
const { userInfo, objectives, leaderboard, isLoadingLeaderboard, isUploading } = storeToRefs(noitaStore);
|
||||||
|
|
||||||
@@ -40,15 +45,15 @@ const formatDate = (dateString: string | null) => {
|
|||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
columnHelper.accessor("objectiv_id", {
|
columnHelper.accessor("objectiv_id", {
|
||||||
header: "Objective ID",
|
header: t("noita.columns.objectiveId"),
|
||||||
cell: (info) => info.getValue(),
|
cell: (info) => info.getValue(),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("total_points", {
|
columnHelper.accessor("total_points", {
|
||||||
header: "Your points",
|
header: t("noita.columns.yourPoints"),
|
||||||
cell: (info) => info.getValue() || 0,
|
cell: (info) => info.getValue() || 0,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("first_seen_at", {
|
columnHelper.accessor("first_seen_at", {
|
||||||
header: "First seen",
|
header: t("noita.columns.firstSeen"),
|
||||||
cell: (info) => formatDate(info.getValue()),
|
cell: (info) => formatDate(info.getValue()),
|
||||||
sortingFn: (rowA, rowB) => {
|
sortingFn: (rowA, rowB) => {
|
||||||
const dateA = dayjs(rowA.original.first_seen_at);
|
const dateA = dayjs(rowA.original.first_seen_at);
|
||||||
@@ -63,7 +68,7 @@ const columns = [
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("seed", {
|
columnHelper.accessor("seed", {
|
||||||
header: "Seed",
|
header: t("noita.columns.seed"),
|
||||||
cell: (info) => info.getValue(),
|
cell: (info) => info.getValue(),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
@@ -143,10 +148,10 @@ const submitRun = async () => {
|
|||||||
try {
|
try {
|
||||||
await noitaStore.submitRun(props.eventPath, uploadedFiles.value);
|
await noitaStore.submitRun(props.eventPath, uploadedFiles.value);
|
||||||
uploadedFiles.value = [];
|
uploadedFiles.value = [];
|
||||||
alert("Run submitted successfully!");
|
alert(t("noita.runSubmittedSuccess"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error submitting run:", error);
|
console.error("Error submitting run:", error);
|
||||||
alert("Error submitting run. Please try again.");
|
alert(t("noita.runSubmitError"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,10 +162,10 @@ const goHome = () => {
|
|||||||
const clearCache = async () => {
|
const clearCache = async () => {
|
||||||
try {
|
try {
|
||||||
await noitaStore.clearCache(props.eventPath);
|
await noitaStore.clearCache(props.eventPath);
|
||||||
alert("Cache cleared successfully!");
|
alert(t("common.cacheClearedSuccess"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error clearing cache:", error);
|
console.error("Error clearing cache:", error);
|
||||||
alert("Error clearing cache. Please try again.");
|
alert(t("common.cacheClearError"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,12 +181,12 @@ onMounted(() => {
|
|||||||
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
|
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
|
||||||
<button @click="goHome" class="btn btn-primary btn-sm">
|
<button @click="goHome" class="btn btn-primary btn-sm">
|
||||||
<i class="mdi mdi-arrow-left"></i>
|
<i class="mdi mdi-arrow-left"></i>
|
||||||
Back
|
{{ t("common.back") }}
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-xl font-bold">Noita Submitter</h1>
|
<h1 class="text-xl font-bold">{{ t("noita.header") }}</h1>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
<a href="/api/docs" class="btn btn-xs">{{ t("common.apiDocs") }}</a>
|
||||||
<a href="/admin" v-if="userInfo.isStaff" class="btn btn-xs btn-warning">Admin panel</a>
|
<a href="/admin" v-if="userInfo.isStaff" class="btn btn-xs btn-warning">{{ t("common.adminPanel") }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -194,12 +199,12 @@ onMounted(() => {
|
|||||||
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-8 text-white rounded-t-2xl">
|
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-8 text-white rounded-t-2xl">
|
||||||
<h2 class="text-3xl font-bold">
|
<h2 class="text-3xl font-bold">
|
||||||
<i class="mdi mdi-trophy text-3xl"></i>
|
<i class="mdi mdi-trophy text-3xl"></i>
|
||||||
Your Ranking
|
{{ t("common.yourRanking") }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-8">
|
<div class="card-body p-8">
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<p class="text-base text-base-content/70">Player</p>
|
<p class="text-base text-base-content/70">{{ t("common.player") }}</p>
|
||||||
<p class="text-4xl font-bold mt-2">{{ userInfo.username }}</p>
|
<p class="text-4xl font-bold mt-2">{{ userInfo.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,38 +216,38 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div v-else class="space-y-6">
|
<div v-else class="space-y-6">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-base text-base-content/70 mb-3">Current Rank</p>
|
<p class="text-base text-base-content/70 mb-3">{{ t("common.currentRank") }}</p>
|
||||||
<RankBadge :rank="userInfo.rank" />
|
<RankBadge :rank="userInfo.rank" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-base text-base-content/70 mb-2">Total Score</p>
|
<p class="text-base text-base-content/70 mb-2">{{ t("noita.totalScore") }}</p>
|
||||||
<p class="text-3xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
|
<p class="text-3xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-base text-base-content/70 mb-2">Objectives Completed</p>
|
<p class="text-base text-base-content/70 mb-2">{{ t("noita.objectivesCompleted") }}</p>
|
||||||
<p class="text-3xl font-bold">{{ userInfo.runsSubmitted }}</p>
|
<p class="text-3xl font-bold">{{ userInfo.runsSubmitted }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-base text-base-content/70 mb-2">Deaths</p>
|
<p class="text-base text-base-content/70 mb-2">{{ t("noita.deaths") }}</p>
|
||||||
<p class="text-3xl font-bold text-error">{{ userInfo.deathsCount }}</p>
|
<p class="text-3xl font-bold text-error">{{ userInfo.deathsCount }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Leaderboard Table -->
|
<!-- Leaderboard Table -->
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h3 class="font-bold text-lg mb-3">Global Leaderboard</h3>
|
<h3 class="font-bold text-lg mb-3">{{ t("noita.globalLeaderboard") }}</h3>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table table-sm w-full">
|
<table class="table table-sm w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Rank</th>
|
<th>{{ t("common.rank") }}</th>
|
||||||
<th>Player</th>
|
<th>{{ t("common.player") }}</th>
|
||||||
<th class="text-right">Score</th>
|
<th class="text-right">{{ t("noita.score") }}</th>
|
||||||
<th class="text-right">Objectives</th>
|
<th class="text-right">{{ t("noita.objectivesColumn") }}</th>
|
||||||
<th class="text-right">Deaths</th>
|
<th class="text-right">{{ t("noita.deaths") }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -254,10 +259,10 @@ onMounted(() => {
|
|||||||
<td class="text-sm">
|
<td class="text-sm">
|
||||||
{{ entry.username }}
|
{{ entry.username }}
|
||||||
<span v-if="entry.username === userInfo.username" class="badge badge-primary badge-sm ml-1">
|
<span v-if="entry.username === userInfo.username" class="badge badge-primary badge-sm ml-1">
|
||||||
You
|
{{ t("common.you") }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="entry.is_staff" class="badge badge-warning badge-sm ml-1">
|
<span v-if="entry.is_staff" class="badge badge-warning badge-sm ml-1">
|
||||||
admin
|
{{ t("common.staffBadge") }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right text-sm font-bold text-primary">{{ entry.total_score.toLocaleString() }}
|
<td class="text-right text-sm font-bold text-primary">{{ entry.total_score.toLocaleString() }}
|
||||||
@@ -272,7 +277,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-3">
|
<button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-3">
|
||||||
<i class="mdi mdi-cache-clear mr-1"></i>
|
<i class="mdi mdi-cache-clear mr-1"></i>
|
||||||
Clear Cache
|
{{ t("common.clearCache") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,7 +289,7 @@ onMounted(() => {
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl mb-6">
|
<h2 class="card-title text-2xl mb-6">
|
||||||
<i class="mdi mdi-cloud-upload text-purple-500 mr-2"></i>
|
<i class="mdi mdi-cloud-upload text-purple-500 mr-2"></i>
|
||||||
Submit Your Run
|
{{ t("noita.submitYourRun") }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Upload Area -->
|
<!-- Upload Area -->
|
||||||
@@ -298,15 +303,15 @@ onMounted(() => {
|
|||||||
<i
|
<i
|
||||||
:class="['mdi text-4xl', isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50']"></i>
|
:class="['mdi text-4xl', isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50']"></i>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">Click to upload or drag and drop</p>
|
<p class="font-semibold">{{ t("noita.uploadPrompt") }}</p>
|
||||||
<p class="text-sm text-base-content/70">The log file <code>polylan_mod_log.txt</code></p>
|
<p class="text-sm text-base-content/70">{{ t("noita.logFileLabel") }} <code>polylan_mod_log.txt</code></p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uploaded Files List -->
|
<!-- Uploaded Files List -->
|
||||||
<div v-if="uploadedFiles.length > 0" class="mb-6">
|
<div v-if="uploadedFiles.length > 0" class="mb-6">
|
||||||
<p class="font-semibold mb-3">Selected Files:</p>
|
<p class="font-semibold mb-3">{{ t("common.selectedFiles") }}</p>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div v-for="(file, index) in uploadedFiles" :key="index"
|
<div v-for="(file, index) in uploadedFiles" :key="index"
|
||||||
class="flex items-center gap-3 bg-base-200 p-3 rounded-lg">
|
class="flex items-center gap-3 bg-base-200 p-3 rounded-lg">
|
||||||
@@ -326,17 +331,17 @@ onMounted(() => {
|
|||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<label for="file-upload" class="btn btn-outline flex-1">
|
<label for="file-upload" class="btn btn-outline flex-1">
|
||||||
<i class="mdi mdi-folder-open mr-2"></i>
|
<i class="mdi mdi-folder-open mr-2"></i>
|
||||||
Choose Files
|
{{ t("common.chooseFiles") }}
|
||||||
</label>
|
</label>
|
||||||
<button @click="submitRun" :disabled="uploadedFiles.length === 0 || isUploading"
|
<button @click="submitRun" :disabled="uploadedFiles.length === 0 || isUploading"
|
||||||
:class="['btn btn-primary flex-1', { 'loading': isUploading }]">
|
:class="['btn btn-primary flex-1', { 'loading': isUploading }]">
|
||||||
<i v-if="!isUploading" class="mdi mdi-send mr-2"></i>
|
<i v-if="!isUploading" class="mdi mdi-send mr-2"></i>
|
||||||
{{ isUploading ? 'Submitting...' : 'Submit Run' }}
|
{{ isUploading ? t("common.submitting") : t("noita.submitRun") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-base-content/70 text-center mt-4">
|
<p class="text-xs text-base-content/70 text-center mt-4">
|
||||||
Maximum file size: 256 MB per file
|
{{ t("common.maxFileSize") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,12 +351,12 @@ onMounted(() => {
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl mb-6">
|
<h2 class="card-title text-2xl mb-6">
|
||||||
<i class="mdi mdi-view-list text-purple-500 mr-2"></i>
|
<i class="mdi mdi-view-list text-purple-500 mr-2"></i>
|
||||||
Objectives
|
{{ t("noita.objectives") }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div v-if="objectives.length === 0" class="text-center py-8">
|
<div v-if="objectives.length === 0" class="text-center py-8">
|
||||||
<p class="text-base-content/70 mb-2">No objectives completed yet</p>
|
<p class="text-base-content/70 mb-2">{{ t("noita.noObjectivesYet") }}</p>
|
||||||
<p class="text-sm text-base-content/50">Submit your runs to unlock objectives!</p>
|
<p class="text-sm text-base-content/50">{{ t("noita.noObjectivesHint") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="objectives.length > 0" class="space-y-4">
|
<div v-if="objectives.length > 0" class="space-y-4">
|
||||||
@@ -361,11 +366,11 @@ onMounted(() => {
|
|||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
table.getColumn('objectiv_id')?.setFilterValue(target.value);
|
table.getColumn('objectiv_id')?.setFilterValue(target.value);
|
||||||
}
|
}
|
||||||
" type="text" placeholder="Search objectives..." class="input input-bordered w-full" />
|
" type="text" :placeholder="t('noita.searchPlaceholder')" class="input input-bordered w-full" />
|
||||||
|
|
||||||
<!-- Results Summary -->
|
<!-- Results Summary -->
|
||||||
<div class="text-sm text-base-content/70">
|
<div class="text-sm text-base-content/70">
|
||||||
Showing {{ filteredObjectives.length }} of {{ objectives.length }} objectives
|
{{ t("noita.showingResults", { shown: filteredObjectives.length, total: objectives.length }) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Objectives Table -->
|
<!-- Objectives Table -->
|
||||||
@@ -429,7 +434,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- No Results -->
|
<!-- No Results -->
|
||||||
<div v-if="filteredObjectives.length === 0" class="text-center py-8">
|
<div v-if="filteredObjectives.length === 0" class="text-center py-8">
|
||||||
<p class="text-base-content/70">No objectives match your search</p>
|
<p class="text-base-content/70">{{ t("noita.noObjectivesMatch") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from "vue";
|
import { ref, onMounted, computed } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useTitle } from "@vueuse/core";
|
||||||
import PuzzleCard from "@/components/PuzzleCard.vue";
|
import PuzzleCard from "@/components/PuzzleCard.vue";
|
||||||
import SubmissionForm from "@/components/SubmissionForm.vue";
|
import SubmissionForm from "@/components/SubmissionForm.vue";
|
||||||
import AdminPanel from "@/components/AdminPanel.vue";
|
import AdminPanel from "@/components/AdminPanel.vue";
|
||||||
@@ -26,6 +28,9 @@ const props = defineProps<{
|
|||||||
provideEventPath(props.eventPath);
|
provideEventPath(props.eventPath);
|
||||||
apiService.setEventPath(props.eventPath);
|
apiService.setEventPath(props.eventPath);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
useTitle(computed(() => t("opusMagnum.pageTitle")));
|
||||||
|
|
||||||
const puzzlesStore = usePuzzlesStore();
|
const puzzlesStore = usePuzzlesStore();
|
||||||
const submissionsStore = useSubmissionsStore();
|
const submissionsStore = useSubmissionsStore();
|
||||||
|
|
||||||
@@ -147,20 +152,20 @@ const goHome = () => {
|
|||||||
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
|
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
|
||||||
<button @click="goHome" class="btn btn-primary btn-sm">
|
<button @click="goHome" class="btn btn-primary btn-sm">
|
||||||
<i class="mdi mdi-arrow-left"></i>
|
<i class="mdi mdi-arrow-left"></i>
|
||||||
Back
|
{{ t("common.back") }}
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
<h1 class="text-xl font-bold">{{ t("opusMagnum.header") }}</h1>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
|
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-medium">{{ userInfo.username }}</span>
|
<span class="font-medium">{{ userInfo.username }}</span>
|
||||||
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
|
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">{{ t("common.admin") }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
<div v-else class="text-sm text-base-content/70">{{ t("common.notLoggedIn") }}</div>
|
||||||
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
<a href="/api/docs" class="btn btn-xs">{{ t("common.apiDocs") }}</a>
|
||||||
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
<a href="/admin" class="btn btn-xs btn-warning">{{ t("common.adminPanel") }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +177,7 @@ const goHome = () => {
|
|||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="mb-6 text-base-content/70">
|
<p class="mb-6 text-base-content/70">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
Auto reload page in {{ remaining }} seconds ...
|
{{ t("opusMagnum.autoReload", { seconds: remaining }) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +185,7 @@ const goHome = () => {
|
|||||||
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
|
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
<p class="mt-4 text-base-content/70">{{ t("opusMagnum.loadingPuzzles") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -188,12 +193,12 @@ const goHome = () => {
|
|||||||
<div v-else-if="error" class="alert alert-error max-w-2xl mx-auto">
|
<div v-else-if="error" class="alert alert-error max-w-2xl mx-auto">
|
||||||
<i class="mdi mdi-alert-circle text-xl"></i>
|
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold">Error Loading Data</h3>
|
<h3 class="font-bold">{{ t("opusMagnum.errorLoadingData") }}</h3>
|
||||||
<div class="text-sm">{{ error }}</div>
|
<div class="text-sm">{{ error }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="reloadPage" class="btn btn-sm btn-outline">
|
<button @click="reloadPage" class="btn btn-sm btn-outline">
|
||||||
<i class="mdi mdi-refresh mr-1"></i>
|
<i class="mdi mdi-refresh mr-1"></i>
|
||||||
Retry
|
{{ t("common.retry") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -220,14 +225,14 @@ const goHome = () => {
|
|||||||
<div class="flex flex-wrap gap-4 mt-4">
|
<div class="flex flex-wrap gap-4 mt-4">
|
||||||
<button @click="openSubmissionModal" class="btn btn-primary" :disabled="isTournamentClosed">
|
<button @click="openSubmissionModal" class="btn btn-primary" :disabled="isTournamentClosed">
|
||||||
<i class="mdi mdi-plus mr-2"></i>
|
<i class="mdi mdi-plus mr-2"></i>
|
||||||
Submit Solution
|
{{ t("common.submitSolution") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isTournamentClosed" class="alert alert-warning mt-4">
|
<div v-if="isTournamentClosed" class="alert alert-warning mt-4">
|
||||||
<i class="mdi mdi-alert-circle text-xl"></i>
|
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold">Tournament Closed</h3>
|
<h3 class="font-bold">{{ t("opusMagnum.tournamentClosed") }}</h3>
|
||||||
<div class="text-sm">This tournament is no longer accepting new submissions.</div>
|
<div class="text-sm">{{ t("opusMagnum.tournamentClosedMessage") }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,9 +255,9 @@ const goHome = () => {
|
|||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
|
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
|
||||||
<div class="text-6xl mb-4">🧩</div>
|
<div class="text-6xl mb-4">🧩</div>
|
||||||
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3>
|
<h3 class="text-xl font-bold mb-2">{{ t("opusMagnum.noPuzzlesTitle") }}</h3>
|
||||||
<p class="text-base-content/70">
|
<p class="text-base-content/70">
|
||||||
Check back later for new puzzle collections!
|
{{ t("opusMagnum.noPuzzlesMessage") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -263,7 +268,7 @@ const goHome = () => {
|
|||||||
<div v-if="isSubmissionModalOpen" class="modal modal-open">
|
<div v-if="isSubmissionModalOpen" class="modal modal-open">
|
||||||
<div class="modal-box max-w-6xl">
|
<div class="modal-box max-w-6xl">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 class="font-bold text-lg">Submit Solution</h3>
|
<h3 class="font-bold text-lg">{{ t("common.submitSolution") }}</h3>
|
||||||
<button @click="closeSubmissionModal" class="btn btn-sm btn-circle btn-ghost">
|
<button @click="closeSubmissionModal" class="btn btn-sm btn-circle btn-ghost">
|
||||||
<i class="mdi mdi-close"></i>
|
<i class="mdi mdi-close"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from "vue";
|
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useTitle } from "@vueuse/core";
|
||||||
import { useUserStore } from "@/stores/user";
|
import { useUserStore } from "@/stores/user";
|
||||||
import { submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api";
|
import { submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api";
|
||||||
import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket";
|
import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket";
|
||||||
@@ -26,6 +28,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
|
|
||||||
provideEventPath(props.eventPath);
|
provideEventPath(props.eventPath);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
useTitle(computed(() => t("submissions.pageTitle", { game: props.gameName, scope: props.scopeName })));
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const { userInfo } = storeToRefs(userStore);
|
const { userInfo } = storeToRefs(userStore);
|
||||||
|
|
||||||
@@ -138,12 +143,12 @@ const copyOCRText = async () => {
|
|||||||
if (selectedSubmissionFile.value?.ocr_raw_data) {
|
if (selectedSubmissionFile.value?.ocr_raw_data) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(selectedSubmissionFile.value.ocr_raw_data);
|
await navigator.clipboard.writeText(selectedSubmissionFile.value.ocr_raw_data);
|
||||||
copyFeedback.value = "Copied!";
|
copyFeedback.value = t("common.copied");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copyFeedback.value = "";
|
copyFeedback.value = "";
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
copyFeedback.value = "Failed to copy";
|
copyFeedback.value = t("submissions.copyFailed");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copyFeedback.value = "";
|
copyFeedback.value = "";
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@@ -266,14 +271,14 @@ const submitCrop = async () => {
|
|||||||
redrawCropCanvas();
|
redrawCropCanvas();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating crop:", error);
|
console.error("Error creating crop:", error);
|
||||||
alert(`Error creating crop: ${error instanceof Error ? error.message : "Please try again."}`);
|
alert(t("submissions.createCropError", { message: error instanceof Error ? error.message : t("common.pleaseTryAgain") }));
|
||||||
} finally {
|
} finally {
|
||||||
isCreatingCrop.value = false;
|
isCreatingCrop.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCrop = async (cropId: string) => {
|
const deleteCrop = async (cropId: string) => {
|
||||||
if (!confirm("Delete this crop?")) return;
|
if (!confirm(t("submissions.deleteCropConfirm"))) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await submissionsApiDeleteImageCrop({
|
await submissionsApiDeleteImageCrop({
|
||||||
@@ -285,7 +290,7 @@ const deleteCrop = async (cropId: string) => {
|
|||||||
redrawCropCanvas();
|
redrawCropCanvas();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting crop:", error);
|
console.error("Error deleting crop:", error);
|
||||||
alert(`Error deleting crop: ${error instanceof Error ? error.message : "Please try again."}`);
|
alert(t("submissions.deleteCropError", { message: error instanceof Error ? error.message : t("common.pleaseTryAgain") }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,7 +310,7 @@ const processFileOcr = async () => {
|
|||||||
selectedSubmissionFile.value.ocr_status = "processing";
|
selectedSubmissionFile.value.ocr_status = "processing";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing OCR:", error);
|
console.error("Error processing OCR:", error);
|
||||||
alert(`Error processing OCR: ${error instanceof Error ? error.message : "Please try again."}`);
|
alert(t("submissions.processOcrError", { message: error instanceof Error ? error.message : t("common.pleaseTryAgain") }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -323,7 +328,7 @@ const processCropOcr = async (cropId: string) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing crop OCR:", error);
|
console.error("Error processing crop OCR:", error);
|
||||||
alert(`Error processing OCR: ${error instanceof Error ? error.message : "Please try again."}`);
|
alert(t("submissions.processOcrError", { message: error instanceof Error ? error.message : t("common.pleaseTryAgain") }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -352,7 +357,7 @@ const addFiles = (filesToAdd: File[]) => {
|
|||||||
|
|
||||||
if (totalFiles > MAX_FILES) {
|
if (totalFiles > MAX_FILES) {
|
||||||
const canAdd = MAX_FILES - uploadedFiles.value.length;
|
const canAdd = MAX_FILES - uploadedFiles.value.length;
|
||||||
uploadLimitExceeded.value = `Maximum ${MAX_FILES} files allowed. You can add ${canAdd} more file${canAdd !== 1 ? 's' : ''}.`;
|
uploadLimitExceeded.value = t("submissions.uploadLimitExceeded", { max: MAX_FILES, count: canAdd, unit: t("common.fileWord", canAdd) });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uploadLimitExceeded.value = "";
|
uploadLimitExceeded.value = "";
|
||||||
}, 5000);
|
}, 5000);
|
||||||
@@ -415,11 +420,11 @@ const submitSubmission = async () => {
|
|||||||
|
|
||||||
uploadedFiles.value = [];
|
uploadedFiles.value = [];
|
||||||
notes.value = "";
|
notes.value = "";
|
||||||
alert("Submission successful!");
|
alert(t("submissions.submitSuccess"));
|
||||||
await loadUserSubmissions();
|
await loadUserSubmissions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error submitting:", error);
|
console.error("Error submitting:", error);
|
||||||
alert(`Error submitting: ${error instanceof Error ? error.message : "Please try again."}`);
|
alert(t("submissions.submitError", { message: error instanceof Error ? error.message : t("common.pleaseTryAgain") }));
|
||||||
} finally {
|
} finally {
|
||||||
isUploading.value = false;
|
isUploading.value = false;
|
||||||
}
|
}
|
||||||
@@ -521,21 +526,21 @@ onUnmounted(() => {
|
|||||||
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
|
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
|
||||||
<button @click="goHome" class="btn btn-primary btn-sm">
|
<button @click="goHome" class="btn btn-primary btn-sm">
|
||||||
<i class="mdi mdi-arrow-left"></i>
|
<i class="mdi mdi-arrow-left"></i>
|
||||||
Back
|
{{ t("common.back") }}
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-xl font-bold">{{ gameName }} Submissions — {{ scopeName }}</h1>
|
<h1 class="text-xl font-bold">{{ t("submissions.pageTitle", { game: gameName, scope: scopeName }) }}</h1>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
<!-- WebSocket Status Indicator -->
|
<!-- WebSocket Status Indicator -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div :class="['badge', wsConnected ? 'badge-success' : 'badge-warning']">
|
<div :class="['badge', wsConnected ? 'badge-success' : 'badge-warning']">
|
||||||
<i :class="['mdi mr-1', wsConnected ? 'mdi-wifi' : 'mdi-wifi-off']"></i>
|
<i :class="['mdi mr-1', wsConnected ? 'mdi-wifi' : 'mdi-wifi-off']"></i>
|
||||||
{{ wsConnected ? 'Live' : 'Offline' }}
|
{{ wsConnected ? t("submissions.live") : t("submissions.offline") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
<a href="/api/docs" class="btn btn-xs">{{ t("common.apiDocs") }}</a>
|
||||||
<a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
<a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">{{ t("common.adminPanel") }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -556,7 +561,7 @@ onUnmounted(() => {
|
|||||||
</figure>
|
</figure>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl">{{ gameName }}</h2>
|
<h2 class="card-title text-2xl">{{ gameName }}</h2>
|
||||||
<p class="text-base-content/70">Submitting for match: <span class="font-semibold">{{ scopeName }}</span>
|
<p class="text-base-content/70">{{ t("submissions.submittingForMatch") }} <span class="font-semibold">{{ scopeName }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -566,7 +571,7 @@ onUnmounted(() => {
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl mb-6">
|
<h2 class="card-title text-2xl mb-6">
|
||||||
<i class="mdi mdi-cloud-upload text-primary mr-2"></i>
|
<i class="mdi mdi-cloud-upload text-primary mr-2"></i>
|
||||||
Submit Results
|
{{ t("submissions.submitResultsTitle") }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Upload Area -->
|
<!-- Upload Area -->
|
||||||
@@ -582,8 +587,8 @@ onUnmounted(() => {
|
|||||||
isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50'
|
isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50'
|
||||||
]"></i>
|
]"></i>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">Click to upload, drag and drop, or paste</p>
|
<p class="font-semibold">{{ t("submissions.uploadPrompt") }}</p>
|
||||||
<p class="text-sm text-base-content/70">Any file type supported • Images from clipboard welcome</p>
|
<p class="text-sm text-base-content/70">{{ t("submissions.uploadHint") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -596,12 +601,12 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- File Count -->
|
<!-- File Count -->
|
||||||
<div v-if="uploadedFiles.length > 0" class="mb-3 text-sm text-base-content/70">
|
<div v-if="uploadedFiles.length > 0" class="mb-3 text-sm text-base-content/70">
|
||||||
<span class="font-medium">{{ uploadedFiles.length }}/{{ MAX_FILES }}</span> files selected
|
<span class="font-medium">{{ uploadedFiles.length }}/{{ MAX_FILES }}</span> {{ t("submissions.filesSelectedSuffix") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uploaded Files List -->
|
<!-- Uploaded Files List -->
|
||||||
<div v-if="uploadedFiles.length > 0" class="mb-6">
|
<div v-if="uploadedFiles.length > 0" class="mb-6">
|
||||||
<p class="font-semibold mb-3">Selected Files:</p>
|
<p class="font-semibold mb-3">{{ t("common.selectedFiles") }}</p>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div v-for="(file, index) in uploadedFiles" :key="index"
|
<div v-for="(file, index) in uploadedFiles" :key="index"
|
||||||
class="flex items-center gap-3 bg-base-200 p-3 rounded-lg cursor-pointer hover:bg-base-300 transition-colors group"
|
class="flex items-center gap-3 bg-base-200 p-3 rounded-lg cursor-pointer hover:bg-base-300 transition-colors group"
|
||||||
@@ -624,12 +629,12 @@ onUnmounted(() => {
|
|||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label class="flex label">
|
<label class="flex label">
|
||||||
<span class="label-text font-medium">Notes (Optional)</span>
|
<span class="label-text font-medium">{{ t("common.notesOptional") }}</span>
|
||||||
<span class="label-text-alt">0/500</span>
|
<span class="label-text-alt">0/500</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<textarea v-model="notes" class="flex textarea textarea-bordered h-24 w-full resize-none"
|
<textarea v-model="notes" class="flex textarea textarea-bordered h-24 w-full resize-none"
|
||||||
placeholder="Add any notes special for this upload" maxlength="500"></textarea>
|
:placeholder="t('submissions.notesPlaceholder')" maxlength="500"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -638,17 +643,17 @@ onUnmounted(() => {
|
|||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<label for="file-upload" class="btn btn-outline flex-1">
|
<label for="file-upload" class="btn btn-outline flex-1">
|
||||||
<i class="mdi mdi-folder-open mr-2"></i>
|
<i class="mdi mdi-folder-open mr-2"></i>
|
||||||
Choose Files
|
{{ t("common.chooseFiles") }}
|
||||||
</label>
|
</label>
|
||||||
<button @click="submitSubmission" :disabled="uploadedFiles.length === 0 || isUploading"
|
<button @click="submitSubmission" :disabled="uploadedFiles.length === 0 || isUploading"
|
||||||
:class="['btn btn-primary flex-1', { 'loading': isUploading }]">
|
:class="['btn btn-primary flex-1', { 'loading': isUploading }]">
|
||||||
<i v-if="!isUploading" class="mdi mdi-send mr-2"></i>
|
<i v-if="!isUploading" class="mdi mdi-send mr-2"></i>
|
||||||
{{ isUploading ? 'Submitting...' : 'Submit' }}
|
{{ isUploading ? t("common.submitting") : t("common.submit") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-base-content/70 text-center mt-4">
|
<p class="text-xs text-base-content/70 text-center mt-4">
|
||||||
Maximum file size: 256 MB per file
|
{{ t("common.maxFileSize") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -661,7 +666,7 @@ onUnmounted(() => {
|
|||||||
<div class="bg-gradient-to-br from-blue-600 to-blue-400 p-8 text-white rounded-t-2xl">
|
<div class="bg-gradient-to-br from-blue-600 to-blue-400 p-8 text-white rounded-t-2xl">
|
||||||
<h2 class="text-3xl font-bold">
|
<h2 class="text-3xl font-bold">
|
||||||
<i class="mdi mdi-information-outline text-3xl"></i>
|
<i class="mdi mdi-information-outline text-3xl"></i>
|
||||||
Submission Guidelines
|
{{ t("submissions.guidelines.title") }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-8">
|
<div class="card-body p-8">
|
||||||
@@ -669,11 +674,10 @@ onUnmounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold text-lg mb-2">
|
<h3 class="font-bold text-lg mb-2">
|
||||||
<i class="mdi mdi-file-upload mr-2 text-primary"></i>
|
<i class="mdi mdi-file-upload mr-2 text-primary"></i>
|
||||||
File Upload
|
{{ t("submissions.guidelines.fileUpload.title") }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-base-content/70 text-sm">
|
<p class="text-base-content/70 text-sm">
|
||||||
Upload your results as screenshots, logs, or any other file format. You can upload multiple
|
{{ t("submissions.guidelines.fileUpload.body") }}
|
||||||
files at once.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -682,11 +686,10 @@ onUnmounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold text-lg mb-2">
|
<h3 class="font-bold text-lg mb-2">
|
||||||
<i class="mdi mdi-note-text mr-2 text-primary"></i>
|
<i class="mdi mdi-note-text mr-2 text-primary"></i>
|
||||||
Add Notes
|
{{ t("submissions.guidelines.notes.title") }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-base-content/70 text-sm">
|
<p class="text-base-content/70 text-sm">
|
||||||
Include any additional context about your submission. This helps reviewers understand your
|
{{ t("submissions.guidelines.notes.body") }}
|
||||||
results.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -695,11 +698,10 @@ onUnmounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold text-lg mb-2">
|
<h3 class="font-bold text-lg mb-2">
|
||||||
<i class="mdi mdi-check-circle mr-2 text-primary"></i>
|
<i class="mdi mdi-check-circle mr-2 text-primary"></i>
|
||||||
Verification
|
{{ t("submissions.guidelines.verification.title") }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-base-content/70 text-sm">
|
<p class="text-base-content/70 text-sm">
|
||||||
Your submission will be reviewed and verified. You'll receive confirmation once it's been
|
{{ t("submissions.guidelines.verification.body") }}
|
||||||
processed.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -715,7 +717,7 @@ onUnmounted(() => {
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl mb-6">
|
<h2 class="card-title text-2xl mb-6">
|
||||||
<i class="mdi mdi-history text-primary mr-2"></i>
|
<i class="mdi mdi-history text-primary mr-2"></i>
|
||||||
Your Submissions
|
{{ t("submissions.yourSubmissions") }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div v-if="isLoadingSubmissions" class="flex justify-center py-8">
|
<div v-if="isLoadingSubmissions" class="flex justify-center py-8">
|
||||||
@@ -723,19 +725,19 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="userSubmissions.length === 0" class="text-center py-8">
|
<div v-else-if="userSubmissions.length === 0" class="text-center py-8">
|
||||||
<p class="text-base-content/70 mb-2">No submissions yet</p>
|
<p class="text-base-content/70 mb-2">{{ t("common.noSubmissionsYet") }}</p>
|
||||||
<p class="text-sm text-base-content/50">Upload your first submission above to get started</p>
|
<p class="text-sm text-base-content/50">{{ t("submissions.noSubmissionsHint") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<div v-else class="overflow-x-auto">
|
||||||
<table class="table w-full">
|
<table class="table w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>{{ t("common.date") }}</th>
|
||||||
<th>Files</th>
|
<th>{{ t("common.files") }}</th>
|
||||||
<th>File Status</th>
|
<th>{{ t("submissions.fileStatus") }}</th>
|
||||||
<th>OCR Status</th>
|
<th>{{ t("submissions.ocrStatus") }}</th>
|
||||||
<th>Notes</th>
|
<th>{{ t("common.notes") }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -811,16 +813,16 @@ onUnmounted(() => {
|
|||||||
<div class="bg-base-200 p-8 rounded-lg text-center">
|
<div class="bg-base-200 p-8 rounded-lg text-center">
|
||||||
<i class="mdi mdi-file-question text-6xl text-base-content/30 mb-4"></i>
|
<i class="mdi mdi-file-question text-6xl text-base-content/30 mb-4"></i>
|
||||||
<p class="text-base-content/70">
|
<p class="text-base-content/70">
|
||||||
Preview not available for {{ selectedFile?.type || 'this file type' }}
|
{{ t("submissions.previewNotAvailableFor", { type: selectedFile?.type || t("submissions.thisFileType") }) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-base-content/50">
|
<p class="text-sm text-base-content/50">
|
||||||
File size: {{ selectedFile ? formatFileSize(selectedFile.size) : '' }}
|
{{ selectedFile ? t("submissions.fileSizeLabel", { size: formatFileSize(selectedFile.size) }) : '' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button @click="closeModal" class="btn">Close</button>
|
<button @click="closeModal" class="btn">{{ t("common.close") }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -839,7 +841,7 @@ onUnmounted(() => {
|
|||||||
<div class="gap-4 h-[80vh] w-full overflow-hidden flex flex-col">
|
<div class="gap-4 h-[80vh] w-full overflow-hidden flex flex-col">
|
||||||
<!-- File Display -->
|
<!-- File Display -->
|
||||||
<div class="flex flex-col overflow-hidden flex-[1.5]">
|
<div class="flex flex-col overflow-hidden flex-[1.5]">
|
||||||
<p class="text-sm text-base-content/70 mb-2">File Preview</p>
|
<p class="text-sm text-base-content/70 mb-2">{{ t("submissions.filePreview") }}</p>
|
||||||
<div class="flex-1 overflow-auto border border-base-300 rounded-lg">
|
<div class="flex-1 overflow-auto border border-base-300 rounded-lg">
|
||||||
<!-- Image Preview with Crop Drawing (Admin Only) -->
|
<!-- Image Preview with Crop Drawing (Admin Only) -->
|
||||||
<div v-if="selectedSubmissionFile?.content_type.startsWith('image/')"
|
<div v-if="selectedSubmissionFile?.content_type.startsWith('image/')"
|
||||||
@@ -860,7 +862,7 @@ onUnmounted(() => {
|
|||||||
<!-- Text Preview -->
|
<!-- Text Preview -->
|
||||||
<div v-else-if="selectedSubmissionFile?.content_type.startsWith('text/')" class="p-4">
|
<div v-else-if="selectedSubmissionFile?.content_type.startsWith('text/')" class="p-4">
|
||||||
<div class="bg-base-200 p-4 rounded font-mono text-sm whitespace-pre-wrap break-words">
|
<div class="bg-base-200 p-4 rounded font-mono text-sm whitespace-pre-wrap break-words">
|
||||||
<p class="text-base-content/70">Text content preview not available. Download file to view.</p>
|
<p class="text-base-content/70">{{ t("submissions.textPreviewUnavailable") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -868,7 +870,7 @@ onUnmounted(() => {
|
|||||||
<div v-else class="flex items-center justify-center h-full">
|
<div v-else class="flex items-center justify-center h-full">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<i class="mdi mdi-file-question text-6xl text-base-content/30 mb-4"></i>
|
<i class="mdi mdi-file-question text-6xl text-base-content/30 mb-4"></i>
|
||||||
<p class="text-base-content/70">Preview not available</p>
|
<p class="text-base-content/70">{{ t("submissions.previewNotAvailable") }}</p>
|
||||||
<p class="text-sm text-base-content/50">{{ selectedSubmissionFile?.content_type }}</p>
|
<p class="text-sm text-base-content/50">{{ selectedSubmissionFile?.content_type }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -880,7 +882,7 @@ onUnmounted(() => {
|
|||||||
<!-- OCR Data -->
|
<!-- OCR Data -->
|
||||||
<div class="flex flex-col overflow-hidden flex-1">
|
<div class="flex flex-col overflow-hidden flex-1">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<p class="text-sm text-base-content/70 mb-2">OCR Data</p>
|
<p class="text-sm text-base-content/70 mb-2">{{ t("submissions.ocrData") }}</p>
|
||||||
<span :class="getOCRStatusColor(selectedSubmissionFile?.ocr_status)">
|
<span :class="getOCRStatusColor(selectedSubmissionFile?.ocr_status)">
|
||||||
{{ selectedSubmissionFile?.ocr_status }}
|
{{ selectedSubmissionFile?.ocr_status }}
|
||||||
</span>
|
</span>
|
||||||
@@ -892,10 +894,10 @@ onUnmounted(() => {
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<p class="text-xs font-semibold text-base-content/70">File - Extracted Text:</p>
|
<p class="text-xs font-semibold text-base-content/70">{{ t("submissions.extractedText") }}</p>
|
||||||
<button @click="copyOCRText" class="btn btn-xs btn-ghost gap-1">
|
<button @click="copyOCRText" class="btn btn-xs btn-ghost gap-1">
|
||||||
<i class="mdi mdi-content-copy"></i>
|
<i class="mdi mdi-content-copy"></i>
|
||||||
{{ copyFeedback || "Copy" }}
|
{{ copyFeedback || t("common.copy") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -909,30 +911,29 @@ onUnmounted(() => {
|
|||||||
class="flex items-center justify-center py-8">
|
class="flex items-center justify-center py-8">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
<p class="text-base-content/70 mt-4">OCR is being processed...</p>
|
<p class="text-base-content/70 mt-4">{{ t("submissions.ocrProcessing") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="selectedSubmissionFile?.ocr_status === 'error'"
|
<div v-else-if="selectedSubmissionFile?.ocr_status === 'error'"
|
||||||
class="flex items-center justify-center py-8">
|
class="flex items-center justify-center py-8">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<i class="mdi mdi-alert-circle text-4xl text-error mb-2"></i>
|
<i class="mdi mdi-alert-circle text-4xl text-error mb-2"></i>
|
||||||
<p class="text-base-content/70">OCR processing failed</p>
|
<p class="text-base-content/70">{{ t("submissions.ocrFailed") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!selectedSubmissionFile?.ocr_raw_data" class="flex items-center justify-center py-8">
|
<div v-else-if="!selectedSubmissionFile?.ocr_raw_data" class="flex items-center justify-center py-8">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<i class="mdi mdi-clock-outline text-4xl text-base-content/30 mb-2"></i>
|
<i class="mdi mdi-clock-outline text-4xl text-base-content/30 mb-2"></i>
|
||||||
<p class="text-base-content/70">Waiting for OCR processing...</p>
|
<p class="text-base-content/70">{{ t("submissions.ocrWaiting") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Crops OCR Data -->
|
<!-- Crops OCR Data -->
|
||||||
<div v-if="selectedSubmissionFile?.crops?.length" class="space-y-3 pt-4 border-t border-base-300">
|
<div v-if="selectedSubmissionFile?.crops?.length" class="space-y-3 pt-4 border-t border-base-300">
|
||||||
<p class="text-xs font-semibold text-base-content/70">Crop OCR Results ({{
|
<p class="text-xs font-semibold text-base-content/70">{{ t("submissions.cropOcrResults", { count: selectedSubmissionFile.crops.length }) }}</p>
|
||||||
selectedSubmissionFile.crops.length }}):</p>
|
|
||||||
<div v-for="crop in selectedSubmissionFile.crops" :key="crop.id" class="space-y-2">
|
<div v-for="crop in selectedSubmissionFile.crops" :key="crop.id" class="space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-xs font-medium">{{ crop.label || `Crop (${crop.x1}, ${crop.y1})` }}</p>
|
<p class="text-xs font-medium">{{ crop.label || t("submissions.cropLabelFallback", { x: crop.x1, y: crop.y1 }) }}</p>
|
||||||
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span>
|
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data"
|
<div v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data"
|
||||||
@@ -942,15 +943,15 @@ onUnmounted(() => {
|
|||||||
<div v-else-if="crop.ocr_status === 'processing'"
|
<div v-else-if="crop.ocr_status === 'processing'"
|
||||||
class="text-xs text-base-content/70 text-center py-2">
|
class="text-xs text-base-content/70 text-center py-2">
|
||||||
<span class="loading loading-spinner loading-sm inline-block"></span>
|
<span class="loading loading-spinner loading-sm inline-block"></span>
|
||||||
Processing...
|
{{ t("common.processing") }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="crop.ocr_status === 'error'" class="text-xs text-error text-center py-2">
|
<div v-else-if="crop.ocr_status === 'error'" class="text-xs text-error text-center py-2">
|
||||||
<i class="mdi mdi-alert-circle mr-1"></i>
|
<i class="mdi mdi-alert-circle mr-1"></i>
|
||||||
Processing failed
|
{{ t("submissions.cropProcessingFailed") }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-xs text-base-content/50 text-center py-2">
|
<div v-else class="text-xs text-base-content/50 text-center py-2">
|
||||||
<i class="mdi mdi-clock-outline mr-1"></i>
|
<i class="mdi mdi-clock-outline mr-1"></i>
|
||||||
Pending
|
{{ t("common.pending") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -960,13 +961,13 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Crops Data -->
|
<!-- Crops Data -->
|
||||||
<div v-if="userInfo.isStaff" class="flex flex-col overflow-hidden flex-1">
|
<div v-if="userInfo.isStaff" class="flex flex-col overflow-hidden flex-1">
|
||||||
<p class="text-sm text-base-content/70 mb-2">Crops Data</p>
|
<p class="text-sm text-base-content/70 mb-2">{{ t("submissions.cropsData") }}</p>
|
||||||
<div class="flex-1 overflow-auto border border-base-300 rounded-lg p-4 bg-base-100 flex flex-col gap-4">
|
<div class="flex-1 overflow-auto border border-base-300 rounded-lg p-4 bg-base-100 flex flex-col gap-4">
|
||||||
<!-- Crop Drawing Hints -->
|
<!-- Crop Drawing Hints -->
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-base-content/50 text-center mb-2">
|
<p class="text-xs text-base-content/50 text-center mb-2">
|
||||||
<i class="mdi mdi-information-outline"></i>
|
<i class="mdi mdi-information-outline"></i>
|
||||||
Drag on image to create crop (blue) or existing crops (green)
|
{{ t("submissions.cropDrawHint") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -975,40 +976,40 @@ onUnmounted(() => {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-sm">Crop Label (optional)</span>
|
<span class="label-text text-sm">{{ t("submissions.cropLabelOptional") }}</span>
|
||||||
</label>
|
</label>
|
||||||
<input v-model="cropLabel" type="text" placeholder="e.g., Top-left corner"
|
<input v-model="cropLabel" type="text" :placeholder="t('submissions.cropLabelPlaceholder')"
|
||||||
class="input input-sm input-bordered" />
|
class="input input-sm input-bordered" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button @click="submitCrop" :disabled="isCreatingCrop" class="btn btn-sm btn-primary flex-1">
|
<button @click="submitCrop" :disabled="isCreatingCrop" class="btn btn-sm btn-primary flex-1">
|
||||||
<i v-if="!isCreatingCrop" class="mdi mdi-content-save"></i>
|
<i v-if="!isCreatingCrop" class="mdi mdi-content-save"></i>
|
||||||
{{ isCreatingCrop ? "Creating..." : "Save Crop" }}
|
{{ isCreatingCrop ? t("submissions.creatingCrop") : t("submissions.saveCrop") }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="cancelCrop" class="btn btn-sm btn-ghost flex-1">Cancel</button>
|
<button @click="cancelCrop" class="btn btn-sm btn-ghost flex-1">{{ t("common.cancel") }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Crops List -->
|
<!-- Crops List -->
|
||||||
<div v-if="selectedSubmissionFile?.crops?.length" class="flex-1 overflow-y-auto">
|
<div v-if="selectedSubmissionFile?.crops?.length" class="flex-1 overflow-y-auto">
|
||||||
<p class="text-xs font-semibold text-base-content/70 mb-2">Crops ({{
|
<p class="text-xs font-semibold text-base-content/70 mb-2">{{ t("submissions.cropsCount", { count:
|
||||||
selectedSubmissionFile.crops.length }}):</p>
|
selectedSubmissionFile.crops.length }) }}</p>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div v-for="crop in selectedSubmissionFile.crops" :key="crop.id"
|
<div v-for="crop in selectedSubmissionFile.crops" :key="crop.id"
|
||||||
class="flex items-center justify-between p-1 rounded border border-green-200 text-xs">
|
class="flex items-center justify-between p-1 rounded border border-green-200 text-xs">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="font-medium truncate">{{ crop.label || `Crop (${crop.x1}, ${crop.y1})` }}</p>
|
<p class="font-medium truncate">{{ crop.label || t("submissions.cropLabelFallback", { x: crop.x1, y: crop.y1 }) }}</p>
|
||||||
<p class="text-xs text-base-content/50">
|
<p class="text-xs text-base-content/50">
|
||||||
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span>
|
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1 flex-shrink-0">
|
<div class="flex gap-1 flex-shrink-0">
|
||||||
<button @click="processCropOcr(crop.id)" class="btn btn-xs btn-ghost" title="Process OCR">
|
<button @click="processCropOcr(crop.id)" class="btn btn-xs btn-ghost" :title="t('submissions.processOcr')">
|
||||||
<i class="mdi mdi-lightning-bolt"></i>
|
<i class="mdi mdi-lightning-bolt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button @click="deleteCrop(crop.id)" class="btn btn-xs btn-ghost text-error"
|
<button @click="deleteCrop(crop.id)" class="btn btn-xs btn-ghost text-error"
|
||||||
title="Delete crop">
|
:title="t('submissions.deleteCropTooltip')">
|
||||||
<i class="mdi mdi-delete"></i>
|
<i class="mdi mdi-delete"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1016,7 +1017,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-center text-xs text-base-content/50 py-4">
|
<div v-else class="text-center text-xs text-base-content/50 py-4">
|
||||||
No crops yet
|
{{ t("submissions.noCropsYet") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1026,14 +1027,14 @@ onUnmounted(() => {
|
|||||||
<div class="modal-action mt-4">
|
<div class="modal-action mt-4">
|
||||||
<button v-if="userInfo.isStaff" @click="processFileOcr" class="btn btn-warning gap-1">
|
<button v-if="userInfo.isStaff" @click="processFileOcr" class="btn btn-warning gap-1">
|
||||||
<i class="mdi mdi-lightning-bolt"></i>
|
<i class="mdi mdi-lightning-bolt"></i>
|
||||||
Process OCR
|
{{ t("submissions.processOcr") }}
|
||||||
</button>
|
</button>
|
||||||
<a v-if="selectedSubmissionFile?.file_url" :href="selectedSubmissionFile.file_url" download
|
<a v-if="selectedSubmissionFile?.file_url" :href="selectedSubmissionFile.file_url" download
|
||||||
class="btn btn-outline">
|
class="btn btn-outline">
|
||||||
<i class="mdi mdi-download mr-2"></i>
|
<i class="mdi mdi-download mr-2"></i>
|
||||||
Download
|
{{ t("common.download") }}
|
||||||
</a>
|
</a>
|
||||||
<button @click="closeSubmissionFileModal" class="btn">Close</button>
|
<button @click="closeSubmissionFileModal" class="btn">{{ t("common.close") }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useTitle } from "@vueuse/core";
|
||||||
|
|
||||||
interface ScopeOption {
|
interface ScopeOption {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -16,6 +20,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
scopes: () => [],
|
scopes: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
useTitle(computed(() => t("submissionsSelect.title")));
|
||||||
|
|
||||||
const goToScope = (scopeId: string) => {
|
const goToScope = (scopeId: string) => {
|
||||||
window.location.href = `/${props.eventPath}/submissions/${props.gamePath}/${scopeId}`;
|
window.location.href = `/${props.eventPath}/submissions/${props.gamePath}/${scopeId}`;
|
||||||
};
|
};
|
||||||
@@ -29,9 +36,9 @@ const goHome = () => {
|
|||||||
<div class="min-h-screen bg-base-200 flex items-center justify-center p-4">
|
<div class="min-h-screen bg-base-200 flex items-center justify-center p-4">
|
||||||
<div class="card bg-base-100 shadow-lg w-full max-w-md">
|
<div class="card bg-base-100 shadow-lg w-full max-w-md">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title">Select your match</h2>
|
<h2 class="card-title">{{ t("submissionsSelect.title") }}</h2>
|
||||||
<p class="text-base-content/70 mb-2">
|
<p class="text-base-content/70 mb-2">
|
||||||
You're assigned to multiple matches for {{ gameName }}. Pick which one you want to submit for.
|
{{ t("submissionsSelect.description", { game: gameName }) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 mt-2">
|
<div class="flex flex-col gap-2 mt-2">
|
||||||
@@ -45,7 +52,7 @@ const goHome = () => {
|
|||||||
<div class="card-actions mt-4">
|
<div class="card-actions mt-4">
|
||||||
<button @click="goHome" class="btn btn-ghost btn-sm">
|
<button @click="goHome" class="btn btn-ghost btn-sm">
|
||||||
<i class="mdi mdi-arrow-left"></i>
|
<i class="mdi mdi-arrow-left"></i>
|
||||||
Back
|
{{ t("common.back") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,31 +3,31 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
<i class="mdi mdi-shield-account text-2xl text-warning"></i>
|
<i class="mdi mdi-shield-account text-2xl text-warning"></i>
|
||||||
Admin Panel
|
{{ t("components.adminPanel.title") }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6">
|
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">Total Submissions</div>
|
<div class="stat-title">{{ t("components.adminPanel.totalSubmissions") }}</div>
|
||||||
<div class="stat-value text-primary">
|
<div class="stat-value text-primary">
|
||||||
{{ stats.total_submissions }}
|
{{ stats.total_submissions }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">Total Responses</div>
|
<div class="stat-title">{{ t("components.adminPanel.totalResponses") }}</div>
|
||||||
<div class="stat-value text-secondary">
|
<div class="stat-value text-secondary">
|
||||||
{{ stats.total_responses }}
|
{{ stats.total_responses }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">Need Validation</div>
|
<div class="stat-title">{{ t("components.adminPanel.needValidation") }}</div>
|
||||||
<div class="stat-value text-warning">
|
<div class="stat-value text-warning">
|
||||||
{{ stats.needs_validation }}
|
{{ stats.needs_validation }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">Validation Rate</div>
|
<div class="stat-title">{{ t("components.adminPanel.validationRate") }}</div>
|
||||||
<div class="stat-value text-success">
|
<div class="stat-value text-success">
|
||||||
{{ Math.round(stats.validation_rate * 100) }}%
|
{{ Math.round(stats.validation_rate * 100) }}%
|
||||||
</div>
|
</div>
|
||||||
@@ -36,21 +36,21 @@
|
|||||||
|
|
||||||
<button class="btn btn-sm btn-primary" @click="autoValidationResponse">
|
<button class="btn btn-sm btn-primary" @click="autoValidationResponse">
|
||||||
<i class="mdi mdi-check-circle mr-1"></i>
|
<i class="mdi mdi-check-circle mr-1"></i>
|
||||||
Auto validation for all responses
|
{{ t("components.adminPanel.autoValidateAll") }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Responses Needing Validation -->
|
<!-- Responses Needing Validation -->
|
||||||
<div v-if="responsesNeedingValidation.length > 0">
|
<div v-if="responsesNeedingValidation.length > 0">
|
||||||
<h3 class="text-lg font-bold mb-4">Responses Needing Validation</h3>
|
<h3 class="text-lg font-bold mb-4">{{ t("components.adminPanel.responsesNeedingValidation") }}</h3>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table table-zebra">
|
<table class="table table-zebra">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Puzzle</th>
|
<th>{{ t("common.puzzle") }}</th>
|
||||||
<th>OCR Data</th>
|
<th>{{ t("submissions.ocrData") }}</th>
|
||||||
<th>Confidence</th>
|
<th>{{ t("components.adminPanel.confidence") }}</th>
|
||||||
<th>Actions</th>
|
<th>{{ t("common.actions") }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -60,12 +60,12 @@
|
|||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
<div class="font-bold">{{ response.puzzle_name }}</div>
|
<div class="font-bold">{{ response.puzzle_name }}</div>
|
||||||
<div class="text-sm opacity-50">ID: {{ response.id }}</div>
|
<div class="text-sm opacity-50">{{ t("common.idLabel", { id: response.id }) }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="text-sm space-y-1">
|
<div class="text-sm space-y-1">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span>Cost: {{ response.cost || "-" }}</span>
|
<span>{{ t("components.adminPanel.costLabel", { value: response.cost || "-" }) }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="response.ocr_confidence_cost"
|
v-if="response.ocr_confidence_cost"
|
||||||
class="badge badge-xs"
|
class="badge badge-xs"
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span>Cycles: {{ response.cycles || "-" }}</span>
|
<span>{{ t("components.adminPanel.cyclesLabel", { value: response.cycles || "-" }) }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="response.ocr_confidence_cycles"
|
v-if="response.ocr_confidence_cycles"
|
||||||
class="badge badge-xs"
|
class="badge badge-xs"
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span>Area: {{ response.area || "-" }}</span>
|
<span>{{ t("components.adminPanel.areaLabel", { value: response.area || "-" }) }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="response.ocr_confidence_area"
|
v-if="response.ocr_confidence_area"
|
||||||
class="badge badge-xs"
|
class="badge badge-xs"
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
class="btn btn-sm btn-primary mr-2"
|
class="btn btn-sm btn-primary mr-2"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-check-circle mr-1"></i>
|
<i class="mdi mdi-check-circle mr-1"></i>
|
||||||
Validate
|
{{ t("components.adminPanel.validate") }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="response.id"
|
v-if="response.id"
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
class="btn btn-sm btn-warning"
|
class="btn btn-sm btn-warning"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-check-circle mr-1"></i>
|
<i class="mdi mdi-check-circle mr-1"></i>
|
||||||
Auto Validation
|
{{ t("components.adminPanel.autoValidation") }}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -134,9 +134,9 @@
|
|||||||
|
|
||||||
<div v-else class="text-center py-8">
|
<div v-else class="text-center py-8">
|
||||||
<i class="mdi mdi-check-all text-6xl text-success opacity-50"></i>
|
<i class="mdi mdi-check-all text-6xl text-success opacity-50"></i>
|
||||||
<p class="text-lg font-medium mt-2">All responses validated!</p>
|
<p class="text-lg font-medium mt-2">{{ t("components.adminPanel.allValidated") }}</p>
|
||||||
<p class="text-sm opacity-70">
|
<p class="text-sm opacity-70">
|
||||||
No responses currently need manual validation.
|
{{ t("components.adminPanel.noValidationNeeded") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
<!-- Validation Modal -->
|
<!-- Validation Modal -->
|
||||||
<div v-if="validationModal.show" class="modal modal-open">
|
<div v-if="validationModal.show" class="modal modal-open">
|
||||||
<div class="modal-box w-11/12 max-w-5xl">
|
<div class="modal-box w-11/12 max-w-5xl">
|
||||||
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
|
<h3 class="font-bold text-lg mb-4">{{ t("components.adminPanel.validateResponseTitle") }}</h3>
|
||||||
|
|
||||||
<div v-for="file in validationModal.response?.files ?? []">
|
<div v-for="file in validationModal.response?.files ?? []">
|
||||||
<img :src="file.file_url" />
|
<img :src="file.file_url" />
|
||||||
@@ -158,20 +158,20 @@
|
|||||||
<div class="font-bold">
|
<div class="font-bold">
|
||||||
{{ validationModal.response.puzzle_name }}
|
{{ validationModal.response.puzzle_name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm">Review and correct the OCR data below</div>
|
<div class="text-sm">{{ t("components.adminPanel.reviewHint") }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-4 gap-4">
|
<div class="grid grid-cols-4 gap-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Puzzle</span>
|
<span class="label-text">{{ t("common.puzzle") }}</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
v-model="validationModal.data.puzzle"
|
v-model="validationModal.data.puzzle"
|
||||||
class="select select-bordered select-sm w-full"
|
class="select select-bordered select-sm w-full"
|
||||||
>
|
>
|
||||||
<option value="">Select puzzle...</option>
|
<option value="">{{ t("common.selectPuzzlePlaceholder") }}</option>
|
||||||
<option
|
<option
|
||||||
v-for="puzzle in puzzlesStore.puzzles"
|
v-for="puzzle in puzzlesStore.puzzles"
|
||||||
:key="puzzle.id"
|
:key="puzzle.id"
|
||||||
@@ -184,42 +184,42 @@
|
|||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Cost</span>
|
<span class="label-text">{{ t("common.cost") }}</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-model="validationModal.data.validated_cost"
|
v-model="validationModal.data.validated_cost"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
validationModal.response.cost?.toString() || 'Enter cost'
|
validationModal.response.cost?.toString() || t('components.adminPanel.enterCost')
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Cycles</span>
|
<span class="label-text">{{ t("common.cycles") }}</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-model="validationModal.data.validated_cycles"
|
v-model="validationModal.data.validated_cycles"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
validationModal.response.cycles?.toString() || 'Enter cycles'
|
validationModal.response.cycles?.toString() || t('components.adminPanel.enterCycles')
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Area</span>
|
<span class="label-text">{{ t("common.area") }}</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-model="validationModal.data.validated_area"
|
v-model="validationModal.data.validated_area"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
validationModal.response.area?.toString() || 'Enter area'
|
validationModal.response.area?.toString() || t('components.adminPanel.enterArea')
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button @click="closeValidationModal" class="btn btn-ghost">
|
<button @click="closeValidationModal" class="btn btn-ghost">
|
||||||
Cancel
|
{{ t("common.cancel") }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="submitValidation"
|
@click="submitValidation"
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
v-if="isValidating"
|
v-if="isValidating"
|
||||||
class="loading loading-spinner loading-sm"
|
class="loading loading-spinner loading-sm"
|
||||||
></span>
|
></span>
|
||||||
{{ isValidating ? "Validating..." : "Validate" }}
|
{{ isValidating ? t("components.adminPanel.validating") : t("components.adminPanel.validate") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -253,10 +253,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import { apiService } from "@/services/apiService";
|
import { apiService } from "@/services/apiService";
|
||||||
import type { PuzzleResponse } from "@/types";
|
import type { PuzzleResponse } from "@/types";
|
||||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||||
const puzzlesStore = usePuzzlesStore();
|
const puzzlesStore = usePuzzlesStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
// Reactive data
|
// Reactive data
|
||||||
const stats = ref({
|
const stats = ref({
|
||||||
@@ -384,7 +386,7 @@ const submitValidation = async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
alert(`Validation failed: ${response.error}`);
|
alert(t("components.adminPanel.validationFailed", { error: response.error }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +404,7 @@ const submitValidation = async () => {
|
|||||||
closeValidationModal();
|
closeValidationModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Validation error:", error);
|
console.error("Validation error:", error);
|
||||||
alert("Validation failed");
|
alert(t("components.adminPanel.validationFailedGeneric"));
|
||||||
} finally {
|
} finally {
|
||||||
isValidating.value = false;
|
isValidating.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="form-control w-full">
|
<div class="form-control w-full">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-medium">Upload Solution Files</span>
|
<span class="label-text font-medium">{{ t("components.fileUpload.title") }}</span>
|
||||||
<span class="label-text-alt text-xs">Images or GIFs only</span>
|
<span class="label-text-alt text-xs">{{ t("components.fileUpload.hint") }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -29,17 +29,17 @@
|
|||||||
<i class="mdi mdi-cloud-upload text-5xl"></i>
|
<i class="mdi mdi-cloud-upload text-5xl"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-base-content/70 mb-2">Drop your files here or</p>
|
<p class="text-base-content/70 mb-2">{{ t("components.fileUpload.dropHint") }}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="fileInput?.click()"
|
@click="fileInput?.click()"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
>
|
>
|
||||||
Choose Files
|
{{ t("common.chooseFiles") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-base-content/50">
|
<p class="text-xs text-base-content/50">
|
||||||
Supported formats: JPG, PNG, GIF (max 256MB each)
|
{{ t("components.fileUpload.supportedFormats") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
class="mt-1 flex items-center gap-1"
|
class="mt-1 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
<span class="text-xs text-info">Extracting puzzle data...</span>
|
<span class="text-xs text-info">{{ t("components.fileUpload.extracting") }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="file.ocrError" class="mt-1">
|
<div v-else-if="file.ocrError" class="mt-1">
|
||||||
@@ -92,14 +92,14 @@
|
|||||||
<div v-else-if="file.ocrData" class="mt-1 space-y-1">
|
<div v-else-if="file.ocrData" class="mt-1 space-y-1">
|
||||||
<div class="text-xs flex items-center justify-between">
|
<div class="text-xs flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium text-success">✓ OCR Complete</span>
|
<span class="font-medium text-success">{{ t("components.fileUpload.ocrComplete") }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="file.ocrData.confidence"
|
v-if="file.ocrData.confidence"
|
||||||
class="badge badge-xs"
|
class="badge badge-xs"
|
||||||
:class="
|
:class="
|
||||||
getConfidenceBadgeClass(file.ocrData.confidence.overall)
|
getConfidenceBadgeClass(file.ocrData.confidence.overall)
|
||||||
"
|
"
|
||||||
:title="`Overall confidence: ${Math.round(file.ocrData.confidence.overall * 100)}%`"
|
:title="t('components.fileUpload.overallConfidence', { pct: Math.round(file.ocrData.confidence.overall * 100) })"
|
||||||
>
|
>
|
||||||
{{ Math.round(file.ocrData.confidence.overall * 100) }}%
|
{{ Math.round(file.ocrData.confidence.overall * 100) }}%
|
||||||
</span>
|
</span>
|
||||||
@@ -107,48 +107,48 @@
|
|||||||
<button
|
<button
|
||||||
@click="processOCR(file)"
|
@click="processOCR(file)"
|
||||||
class="btn btn-xs btn-ghost"
|
class="btn btn-xs btn-ghost"
|
||||||
title="Retry OCR"
|
:title="t('components.fileUpload.retryOcr')"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-refresh"></i>
|
<i class="mdi mdi-refresh"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs space-y-1 bg-base-200 p-2 rounded">
|
<div class="text-xs space-y-1 bg-base-200 p-2 rounded">
|
||||||
<div v-if="file.ocrData.puzzle">
|
<div v-if="file.ocrData.puzzle">
|
||||||
<strong>Puzzle:</strong> {{ file.ocrData.puzzle }}
|
<strong>{{ t("components.fileUpload.puzzleLabel") }}</strong> {{ file.ocrData.puzzle }}
|
||||||
<span
|
<span
|
||||||
v-if="file.ocrData.confidence?.puzzle"
|
v-if="file.ocrData.confidence?.puzzle"
|
||||||
class="ml-2 opacity-60"
|
class="ml-2 opacity-60"
|
||||||
:title="`Puzzle confidence: ${Math.round(file.ocrData.confidence.puzzle * 100)}%`"
|
:title="t('components.fileUpload.puzzleConfidence', { pct: Math.round(file.ocrData.confidence.puzzle * 100) })"
|
||||||
>
|
>
|
||||||
({{ Math.round(file.ocrData.confidence.puzzle * 100) }}%)
|
({{ Math.round(file.ocrData.confidence.puzzle * 100) }}%)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="file.ocrData.cost">
|
<div v-if="file.ocrData.cost">
|
||||||
<strong>Cost:</strong> {{ file.ocrData.cost }}
|
<strong>{{ t("components.fileUpload.costLabel") }}</strong> {{ file.ocrData.cost }}
|
||||||
<span
|
<span
|
||||||
v-if="file.ocrData.confidence?.cost"
|
v-if="file.ocrData.confidence?.cost"
|
||||||
class="ml-2 opacity-60"
|
class="ml-2 opacity-60"
|
||||||
:title="`Cost confidence: ${Math.round(file.ocrData.confidence.cost * 100)}%`"
|
:title="t('components.fileUpload.costConfidence', { pct: Math.round(file.ocrData.confidence.cost * 100) })"
|
||||||
>
|
>
|
||||||
({{ Math.round(file.ocrData.confidence.cost * 100) }}%)
|
({{ Math.round(file.ocrData.confidence.cost * 100) }}%)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="file.ocrData.cycles">
|
<div v-if="file.ocrData.cycles">
|
||||||
<strong>Cycles:</strong> {{ file.ocrData.cycles }}
|
<strong>{{ t("components.fileUpload.cyclesLabel") }}</strong> {{ file.ocrData.cycles }}
|
||||||
<span
|
<span
|
||||||
v-if="file.ocrData.confidence?.cycles"
|
v-if="file.ocrData.confidence?.cycles"
|
||||||
class="ml-2 opacity-60"
|
class="ml-2 opacity-60"
|
||||||
:title="`Cycles confidence: ${Math.round(file.ocrData.confidence.cycles * 100)}%`"
|
:title="t('components.fileUpload.cyclesConfidence', { pct: Math.round(file.ocrData.confidence.cycles * 100) })"
|
||||||
>
|
>
|
||||||
({{ Math.round(file.ocrData.confidence.cycles * 100) }}%)
|
({{ Math.round(file.ocrData.confidence.cycles * 100) }}%)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="file.ocrData.area">
|
<div v-if="file.ocrData.area">
|
||||||
<strong>Area:</strong> {{ file.ocrData.area }}
|
<strong>{{ t("components.fileUpload.areaLabel") }}</strong> {{ file.ocrData.area }}
|
||||||
<span
|
<span
|
||||||
v-if="file.ocrData.confidence?.area"
|
v-if="file.ocrData.confidence?.area"
|
||||||
class="ml-2 opacity-60"
|
class="ml-2 opacity-60"
|
||||||
:title="`Area confidence: ${Math.round(file.ocrData.confidence.area * 100)}%`"
|
:title="t('components.fileUpload.areaConfidence', { pct: Math.round(file.ocrData.confidence.area * 100) })"
|
||||||
>
|
>
|
||||||
({{ Math.round(file.ocrData.confidence.area * 100) }}%)
|
({{ Math.round(file.ocrData.confidence.area * 100) }}%)
|
||||||
</span>
|
</span>
|
||||||
@@ -161,9 +161,9 @@
|
|||||||
<div class="alert alert-warning alert-sm">
|
<div class="alert alert-warning alert-sm">
|
||||||
<i class="mdi mdi-alert-circle text-lg"></i>
|
<i class="mdi mdi-alert-circle text-lg"></i>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-medium">Low OCR Confidence</div>
|
<div class="font-medium">{{ t("components.fileUpload.lowConfidence") }}</div>
|
||||||
<div class="text-xs">
|
<div class="text-xs">
|
||||||
Please select the correct puzzle manually
|
{{ t("components.fileUpload.selectManually") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
class="select select-bordered select-sm w-full"
|
class="select select-bordered select-sm w-full"
|
||||||
@change="onManualPuzzleSelection(file)"
|
@change="onManualPuzzleSelection(file)"
|
||||||
>
|
>
|
||||||
<option value="">Select puzzle...</option>
|
<option value="">{{ t("common.selectPuzzlePlaceholder") }}</option>
|
||||||
<option
|
<option
|
||||||
v-for="puzzle in puzzlesStore.puzzles"
|
v-for="puzzle in puzzlesStore.puzzles"
|
||||||
:key="puzzle.id"
|
:key="puzzle.id"
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
class="btn btn-xs btn-outline"
|
class="btn btn-xs btn-outline"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-text-recognition"></i>
|
<i class="mdi mdi-text-recognition"></i>
|
||||||
Extract Puzzle Data
|
{{ t("components.fileUpload.extractPuzzleData") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,7 +210,7 @@
|
|||||||
@click="fileInput?.click()"
|
@click="fileInput?.click()"
|
||||||
class="btn btn-outline btn-sm"
|
class="btn btn-outline btn-sm"
|
||||||
>
|
>
|
||||||
Add More Files
|
{{ t("components.fileUpload.addMoreFiles") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,11 +224,14 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick } from "vue";
|
import { ref, watch, nextTick } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import { ocrService } from "@/services/ocrService";
|
import { ocrService } from "@/services/ocrService";
|
||||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||||
import { useUploadsStore } from "@/stores/uploads";
|
import { useUploadsStore } from "@/stores/uploads";
|
||||||
import type { SubmissionFile } from "@/types";
|
import type { SubmissionFile } from "@/types";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
// Pinia store
|
// Pinia store
|
||||||
const puzzlesStore = usePuzzlesStore();
|
const puzzlesStore = usePuzzlesStore();
|
||||||
const { submissionFiles, processOCR } = useUploadsStore();
|
const { submissionFiles, processOCR } = useUploadsStore();
|
||||||
@@ -295,7 +298,7 @@ const processFiles = async (newFiles: File[]) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = `Failed to process ${file.name}`;
|
error.value = t("components.fileUpload.processFailed", { name: file.name });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -303,13 +306,13 @@ const processFiles = async (newFiles: File[]) => {
|
|||||||
const isValidFile = (file: File): boolean => {
|
const isValidFile = (file: File): boolean => {
|
||||||
// Check file type
|
// Check file type
|
||||||
if (!file.type.startsWith("image/")) {
|
if (!file.type.startsWith("image/")) {
|
||||||
error.value = `${file.name} is not a valid image file`;
|
error.value = t("components.fileUpload.invalidFileType", { name: file.name });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check file size (256MB limit)
|
// Check file size (256MB limit)
|
||||||
if (file.size > 256 * 1024 * 1024) {
|
if (file.size > 256 * 1024 * 1024) {
|
||||||
error.value = `${file.name} is too large (max 256MB)`;
|
error.value = t("components.fileUpload.fileTooLarge", { name: file.name });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import { marketApiCreateBet, marketApiCloseMarket, marketApiResolveMarket } from "../api";
|
import { marketApiCreateBet, marketApiCloseMarket, marketApiResolveMarket } from "../api";
|
||||||
import { useMarketStore } from "../stores/market";
|
import { useMarketStore } from "../stores/market";
|
||||||
import { useEventPath } from "../composables/useEventPath";
|
import { useEventPath } from "../composables/useEventPath";
|
||||||
@@ -15,6 +16,7 @@ const emit = defineEmits<{
|
|||||||
refresh: [];
|
refresh: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const eventPath = useEventPath();
|
const eventPath = useEventPath();
|
||||||
const marketStore = useMarketStore();
|
const marketStore = useMarketStore();
|
||||||
const { userInfo, userBets } = storeToRefs(marketStore);
|
const { userInfo, userBets } = storeToRefs(marketStore);
|
||||||
@@ -33,7 +35,7 @@ const timeRemaining = computed(() => {
|
|||||||
const now = new Date().getTime();
|
const now = new Date().getTime();
|
||||||
const diff = endDate - now;
|
const diff = endDate - now;
|
||||||
|
|
||||||
if (diff <= 0) return "Ended";
|
if (diff <= 0) return t("components.marketCard.ended");
|
||||||
|
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||||
@@ -88,7 +90,7 @@ const closeMarket = async () => {
|
|||||||
});
|
});
|
||||||
emit("refresh");
|
emit("refresh");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = "Error closing market";
|
error.value = t("components.marketCard.errorClosing");
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -96,7 +98,7 @@ const closeMarket = async () => {
|
|||||||
|
|
||||||
const resolveMarket = async () => {
|
const resolveMarket = async () => {
|
||||||
if (!selectedWinningOption.value) {
|
if (!selectedWinningOption.value) {
|
||||||
error.value = "Please select a winning option";
|
error.value = t("components.marketCard.selectWinnerRequired");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ const resolveMarket = async () => {
|
|||||||
} else if (typeof err === 'string') {
|
} else if (typeof err === 'string') {
|
||||||
error.value = err;
|
error.value = err;
|
||||||
} else {
|
} else {
|
||||||
error.value = "Error resolving market";
|
error.value = t("components.marketCard.errorResolving");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
resolveLoading.value = false;
|
resolveLoading.value = false;
|
||||||
@@ -155,11 +157,11 @@ const placeBet = async () => {
|
|||||||
} else if (typeof err === 'string') {
|
} else if (typeof err === 'string') {
|
||||||
error.value = err;
|
error.value = err;
|
||||||
} else {
|
} else {
|
||||||
error.value = "Failed to place bet";
|
error.value = t("components.marketCard.failedToPlaceBet");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = "Error placing bet";
|
error.value = t("components.marketCard.errorPlacingBet");
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -193,7 +195,7 @@ const updateExistingBet = () => {
|
|||||||
<div class="flex flex-col items-end gap-2">
|
<div class="flex flex-col items-end gap-2">
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<div :class="['badge', statusColor, 'text-white']">
|
<div :class="['badge', statusColor, 'text-white']">
|
||||||
{{ market.status }}
|
{{ t(`common.marketStatus.${market.status}`) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="market.multiplier > 1" class="badge badge-accent text-white font-bold">
|
<div v-if="market.multiplier > 1" class="badge badge-accent text-white font-bold">
|
||||||
{{ market.multiplier }}x
|
{{ market.multiplier }}x
|
||||||
@@ -201,7 +203,7 @@ const updateExistingBet = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="market.status === 'open' || market.status === 'closed'" class="text-sm text-base-content/60 text-right">
|
<div v-if="market.status === 'open' || market.status === 'closed'" class="text-sm text-base-content/60 text-right">
|
||||||
<div>{{ timeRemaining }}</div>
|
<div>{{ timeRemaining }}</div>
|
||||||
<div class="text-xs">until close</div>
|
<div class="text-xs">{{ t("components.marketCard.untilClose") }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,10 +216,10 @@ const updateExistingBet = () => {
|
|||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button v-if="market.status === 'open'" @click="closeMarket" :disabled="loading" class="btn btn-sm btn-warning">
|
<button v-if="market.status === 'open'" @click="closeMarket" :disabled="loading" class="btn btn-sm btn-warning">
|
||||||
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
||||||
<span v-else>Close Market</span>
|
<span v-else>{{ t("components.marketCard.closeMarket") }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="market.status === 'closed'" @click="showResolveModal = true" class="btn btn-sm btn-success">
|
<button v-if="market.status === 'closed'" @click="showResolveModal = true" class="btn btn-sm btn-success">
|
||||||
Resolve Market
|
{{ t("components.marketCard.resolveMarketButton") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,7 +227,7 @@ const updateExistingBet = () => {
|
|||||||
<!-- Resolve Modal -->
|
<!-- Resolve Modal -->
|
||||||
<dialog v-if="showResolveModal" class="modal modal-open">
|
<dialog v-if="showResolveModal" class="modal modal-open">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 class="font-bold text-lg mb-4">Resolve Market - Select Winner</h3>
|
<h3 class="font-bold text-lg mb-4">{{ t("components.marketCard.resolveModalTitle") }}</h3>
|
||||||
|
|
||||||
<div class="space-y-2 mb-4">
|
<div class="space-y-2 mb-4">
|
||||||
<div v-for="option in market.options" :key="option.uuid" class="form-control">
|
<div v-for="option in market.options" :key="option.uuid" class="form-control">
|
||||||
@@ -243,11 +245,11 @@ const updateExistingBet = () => {
|
|||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button @click="showResolveModal = false" class="btn" :disabled="resolveLoading">
|
<button @click="showResolveModal = false" class="btn" :disabled="resolveLoading">
|
||||||
Cancel
|
{{ t("common.cancel") }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="resolveMarket" :disabled="!selectedWinningOption || resolveLoading" class="btn btn-primary">
|
<button @click="resolveMarket" :disabled="!selectedWinningOption || resolveLoading" class="btn btn-primary">
|
||||||
<span v-if="resolveLoading" class="loading loading-spinner loading-sm"></span>
|
<span v-if="resolveLoading" class="loading loading-spinner loading-sm"></span>
|
||||||
<span v-else>Resolve & Distribute Points</span>
|
<span v-else>{{ t("components.marketCard.resolveAndDistribute") }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,15 +267,15 @@ const updateExistingBet = () => {
|
|||||||
<div class="flex flex-col gap-2 flex-1">
|
<div class="flex flex-col gap-2 flex-1">
|
||||||
<span class="label-text font-medium">{{ option.text }}</span>
|
<span class="label-text font-medium">{{ option.text }}</span>
|
||||||
<div class="text-xs text-base-content/60">
|
<div class="text-xs text-base-content/60">
|
||||||
<div>Pool: {{ option.total_bets }} pts</div>
|
<div>{{ t("components.marketCard.pool", { n: option.total_bets }) }}</div>
|
||||||
<div v-if="option.total_bets > 0">Multiplier: {{ getMultiplier(option).toFixed(2) }}x</div>
|
<div v-if="option.total_bets > 0">{{ t("components.marketCard.multiplierLabel", { x: getMultiplier(option).toFixed(2) }) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="existingBet && existingBet.option.uuid === option.uuid" class="flex flex-col gap-1">
|
<div v-if="existingBet && existingBet.option.uuid === option.uuid" class="flex flex-col gap-1">
|
||||||
<span class="badge badge-primary badge-sm w-fit">
|
<span class="badge badge-primary badge-sm w-fit">
|
||||||
Your bet: {{ existingBet.amount }} pts
|
{{ t("components.marketCard.yourBet", { n: existingBet.amount }) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="option.total_bets > 0" class="badge badge-success badge-sm w-fit">
|
<span v-if="option.total_bets > 0" class="badge badge-success badge-sm w-fit">
|
||||||
Potential: {{ getPotentialGain(option) }} pts
|
{{ t("components.marketCard.potential", { n: getPotentialGain(option) }) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,12 +291,12 @@ const updateExistingBet = () => {
|
|||||||
<div class="form-control gap-3">
|
<div class="form-control gap-3">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">
|
<span class="label-text">
|
||||||
<span v-if="existingBet">Increase bet (current: {{ existingBet.amount }} pts)</span>
|
<span v-if="existingBet">{{ t("components.marketCard.increaseBetLabel", { n: existingBet.amount }) }}</span>
|
||||||
<span v-else>Points to bet</span>
|
<span v-else>{{ t("components.marketCard.pointsToBet") }}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<input v-model.number="betAmount" type="number"
|
<input v-model.number="betAmount" type="number"
|
||||||
:placeholder="existingBet ? `Enter amount to increase by` : 'Enter points'" class="input input-bordered"
|
:placeholder="existingBet ? t('components.marketCard.increaseByPlaceholder') : t('components.marketCard.enterPointsPlaceholder')" class="input input-bordered"
|
||||||
min="1" :disabled="loading" />
|
min="1" :disabled="loading" />
|
||||||
|
|
||||||
<div v-if="error" class="alert alert-error">
|
<div v-if="error" class="alert alert-error">
|
||||||
@@ -304,8 +306,8 @@ const updateExistingBet = () => {
|
|||||||
|
|
||||||
<button @click="placeBet" :disabled="!canBet || loading" class="btn btn-primary">
|
<button @click="placeBet" :disabled="!canBet || loading" class="btn btn-primary">
|
||||||
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
||||||
<span v-else-if="existingBet">Increase Bet</span>
|
<span v-else-if="existingBet">{{ t("components.marketCard.increaseBet") }}</span>
|
||||||
<span v-else>Place Bet</span>
|
<span v-else>{{ t("components.marketCard.placeBet") }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,12 +315,12 @@ const updateExistingBet = () => {
|
|||||||
<!-- Show existing bet (market closed/resolved) -->
|
<!-- Show existing bet (market closed/resolved) -->
|
||||||
<div v-else-if="market.status !== 'open' && existingBet" class="card-body py-4 bg-base-200">
|
<div v-else-if="market.status !== 'open' && existingBet" class="card-body py-4 bg-base-200">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<div class="font-semibold">Your Bet</div>
|
<div class="font-semibold">{{ t("components.marketCard.yourBetTitle") }}</div>
|
||||||
<div class="text-base-content/70 mt-1">
|
<div class="text-base-content/70 mt-1">
|
||||||
Option: <span class="font-semibold">{{ existingBet.option.text }}</span>
|
{{ t("components.marketCard.optionLabel", { text: existingBet.option.text }) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-base-content/70">
|
<div class="text-base-content/70">
|
||||||
Amount: <span class="font-semibold">{{ existingBet.amount }} pts</span>
|
{{ t("components.marketCard.amountLabel", { n: existingBet.amount }) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,12 +330,12 @@ const updateExistingBet = () => {
|
|||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<div class="font-semibold"
|
<div class="font-semibold"
|
||||||
:class="existingBet && existingBet.option.uuid === market.winning_option.uuid ? 'text-success' : 'text-error'">
|
:class="existingBet && existingBet.option.uuid === market.winning_option.uuid ? 'text-success' : 'text-error'">
|
||||||
<span v-if="existingBet && existingBet.option.uuid === market.winning_option.uuid">✓ You Won!</span>
|
<span v-if="existingBet && existingBet.option.uuid === market.winning_option.uuid">{{ t("components.marketCard.won") }}</span>
|
||||||
<span v-else-if="existingBet">✗ You Lost</span>
|
<span v-else-if="existingBet">{{ t("components.marketCard.lost") }}</span>
|
||||||
<span v-else>Resolved</span>
|
<span v-else>{{ t("components.marketCard.resolved") }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-base-content/70 mt-1">
|
<div class="text-base-content/70 mt-1">
|
||||||
Winner: <span class="font-semibold">{{ market.winning_option.text }}</span>
|
{{ t("components.marketCard.winnerLabel", { text: market.winning_option.text }) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,14 +8,14 @@
|
|||||||
{{ puzzle.title }}
|
{{ puzzle.title }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-base-content/70 mb-2">
|
<p class="text-sm text-base-content/70 mb-2">
|
||||||
by {{ puzzle.author_name }}
|
{{ t("components.puzzleCard.byAuthor", { author: puzzle.author_name }) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<div class="badge badge-primary badge-sm">
|
<div class="badge badge-primary badge-sm">
|
||||||
{{ puzzle.steam_item_id }}
|
{{ puzzle.steam_item_id }}
|
||||||
</div>
|
</div>
|
||||||
<div class="badge badge-ghost badge-sm">ID: {{ puzzle.id }}</div>
|
<div class="badge badge-ghost badge-sm">{{ t("common.idLabel", { id: puzzle.id }) }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="puzzle.description" class="text-sm text-base-content/80 mb-4">
|
<p v-if="puzzle.description" class="text-sm text-base-content/80 mb-4">
|
||||||
@@ -24,19 +24,19 @@
|
|||||||
|
|
||||||
<!-- Points Factor Coefficients -->
|
<!-- Points Factor Coefficients -->
|
||||||
<div v-if="puzzle.points_factor" class="bg-base-200 p-3 rounded-lg mb-4">
|
<div v-if="puzzle.points_factor" class="bg-base-200 p-3 rounded-lg mb-4">
|
||||||
<p class="text-xs text-base-content/70 font-semibold mb-2">Points Coefficients</p>
|
<p class="text-xs text-base-content/70 font-semibold mb-2">{{ t("common.pointsCoefficients") }}</p>
|
||||||
<div class="grid grid-cols-3 gap-2">
|
<div class="grid grid-cols-3 gap-2">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cost }}</span>
|
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cost }}</span>
|
||||||
<p class="text-xs text-base-content/70">Cost</p>
|
<p class="text-xs text-base-content/70">{{ t("common.cost") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cycles }}</span>
|
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cycles }}</span>
|
||||||
<p class="text-xs text-base-content/70">Cycles</p>
|
<p class="text-xs text-base-content/70">{{ t("common.cycles") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.area }}</span>
|
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.area }}</span>
|
||||||
<p class="text-xs text-base-content/70">Area</p>
|
<p class="text-xs text-base-content/70">{{ t("common.area") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,13 +46,13 @@
|
|||||||
{{ tag }}
|
{{ tag }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="puzzle.tags.length > 3" class="badge badge-outline badge-xs">
|
<span v-if="puzzle.tags.length > 3" class="badge badge-outline badge-xs">
|
||||||
+{{ puzzle.tags.length - 3 }} more
|
{{ t("components.puzzleCard.moreTags", { n: puzzle.tags.length - 3 }) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-end gap-2">
|
<div class="flex flex-col items-end gap-2">
|
||||||
<div class="tooltip" data-tip="View on Steam Workshop">
|
<div class="tooltip" :data-tip="t('components.puzzleCard.viewOnSteam')">
|
||||||
<a :href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`" target="_blank"
|
<a :href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`" target="_blank"
|
||||||
class="btn btn-ghost btn-sm btn-square">
|
class="btn btn-ghost btn-sm btn-square">
|
||||||
<i class="mdi mdi-steam text-lg"></i>
|
<i class="mdi mdi-steam text-lg"></i>
|
||||||
@@ -64,17 +64,17 @@
|
|||||||
<!-- Responses Table -->
|
<!-- Responses Table -->
|
||||||
<div v-if="responses && responses.length > 0" class="mt-1">
|
<div v-if="responses && responses.length > 0" class="mt-1">
|
||||||
<div class="divider">
|
<div class="divider">
|
||||||
<span class="text-sm font-medium">Solutions ({{ responses.length }})</span>
|
<span class="text-sm font-medium">{{ t("components.puzzleCard.solutionsCount", { n: responses.length }) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<table class="table table-xs">
|
<table class="table table-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Cost</th>
|
<th>{{ t("common.cost") }}</th>
|
||||||
<th>Cycles</th>
|
<th>{{ t("common.cycles") }}</th>
|
||||||
<th>Area</th>
|
<th>{{ t("common.area") }}</th>
|
||||||
<th>Files</th>
|
<th>{{ t("common.files") }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
">
|
">
|
||||||
<i class="mdi mdi-information-outline text-xs"></i>
|
<i class="mdi mdi-information-outline text-xs"></i>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="response.needs_manual_validation" class="tooltip" data-tip="Needs manual validation">
|
<div v-if="response.needs_manual_validation" class="tooltip" :data-tip="t('components.puzzleCard.needsManualValidation')">
|
||||||
<i class="mdi mdi-alert-circle text-xs text-warning"></i>
|
<i class="mdi mdi-alert-circle text-xs text-warning"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,9 +124,9 @@
|
|||||||
class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg hover:border-primary transition-colors duration-300 cursor-pointer"
|
class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg hover:border-primary transition-colors duration-300 cursor-pointer"
|
||||||
@click="openSubmissionModal">
|
@click="openSubmissionModal">
|
||||||
<i class="mdi mdi-upload text-2xl text-base-content/40"></i>
|
<i class="mdi mdi-upload text-2xl text-base-content/40"></i>
|
||||||
<p class="text-sm text-base-content/60 mt-2">No solutions yet</p>
|
<p class="text-sm text-base-content/60 mt-2">{{ t("components.puzzleCard.noSolutionsYet") }}</p>
|
||||||
<p class="text-xs text-base-content/40">
|
<p class="text-xs text-base-content/40">
|
||||||
Upload solutions using the submit button
|
{{ t("components.puzzleCard.uploadHint") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,6 +134,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import type { SteamCollectionItem, PuzzleResponse } from "@/types";
|
import type { SteamCollectionItem, PuzzleResponse } from "@/types";
|
||||||
import { useSubmissionsStore } from "@/stores/submissions";
|
import { useSubmissionsStore } from "@/stores/submissions";
|
||||||
|
|
||||||
@@ -144,5 +145,6 @@ interface Props {
|
|||||||
|
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const { openSubmissionModal } = useSubmissionsStore();
|
const { openSubmissionModal } = useSubmissionsStore();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import { apiService } from "@/services/apiService";
|
import { apiService } from "@/services/apiService";
|
||||||
import type { TournamentPuzzleResults } from "@/types";
|
import type { TournamentPuzzleResults } from "@/types";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const resultsData = ref<TournamentPuzzleResults | null>(null);
|
const resultsData = ref<TournamentPuzzleResults | null>(null);
|
||||||
const error = ref<string>("");
|
const error = ref<string>("");
|
||||||
@@ -24,7 +26,7 @@ const fetchResults = async () => {
|
|||||||
console.error("Error fetching results:", response.error);
|
console.error("Error fetching results:", response.error);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : "Failed to fetch results";
|
error.value = err instanceof Error ? err.message : t("common.fetchResultsError");
|
||||||
console.error("Error fetching results:", err);
|
console.error("Error fetching results:", err);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
@@ -68,7 +70,7 @@ onMounted(() => {
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl flex items-center gap-2">
|
<h2 class="card-title text-2xl flex items-center gap-2">
|
||||||
<i class="mdi mdi-table text-blue-500 text-3xl"></i>
|
<i class="mdi mdi-table text-blue-500 text-3xl"></i>
|
||||||
Results by Puzzle
|
{{ t("components.puzzleResults.title") }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div v-if="isLoading" class="flex justify-center py-12">
|
<div v-if="isLoading" class="flex justify-center py-12">
|
||||||
@@ -81,7 +83,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!resultsData || resultsData.results.length === 0" class="text-center py-8">
|
<div v-else-if="!resultsData || resultsData.results.length === 0" class="text-center py-8">
|
||||||
<p class="text-base-content/70">No results available yet.</p>
|
<p class="text-base-content/70">{{ t("common.noResultsYet") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-8">
|
<div v-else class="space-y-8">
|
||||||
@@ -90,19 +92,19 @@ onMounted(() => {
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h3 class="text-xl font-bold mb-3">{{ puzzle.puzzle_title }}</h3>
|
<h3 class="text-xl font-bold mb-3">{{ puzzle.puzzle_title }}</h3>
|
||||||
<div class="bg-base-200 p-3 rounded-lg mb-4" v-if="puzzle.points_factor">
|
<div class="bg-base-200 p-3 rounded-lg mb-4" v-if="puzzle.points_factor">
|
||||||
<p class="text-xs text-base-content/70 font-semibold mb-2">Points Coefficients</p>
|
<p class="text-xs text-base-content/70 font-semibold mb-2">{{ t("common.pointsCoefficients") }}</p>
|
||||||
<div class="grid grid-cols-3 gap-2">
|
<div class="grid grid-cols-3 gap-2">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cost }}</span>
|
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cost }}</span>
|
||||||
<p class="text-xs text-base-content/70">Cost</p>
|
<p class="text-xs text-base-content/70">{{ t("common.cost") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cycles }}</span>
|
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cycles }}</span>
|
||||||
<p class="text-xs text-base-content/70">Cycles</p>
|
<p class="text-xs text-base-content/70">{{ t("common.cycles") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.area }}</span>
|
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.area }}</span>
|
||||||
<p class="text-xs text-base-content/70">Area</p>
|
<p class="text-xs text-base-content/70">{{ t("common.area") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,7 +112,7 @@ onMounted(() => {
|
|||||||
<!-- Winners GIF Display (all with same top points) -->
|
<!-- Winners GIF Display (all with same top points) -->
|
||||||
<div v-if="puzzle.submissions.length > 0 && getWinnersForPuzzle(puzzle).length > 0"
|
<div v-if="puzzle.submissions.length > 0 && getWinnersForPuzzle(puzzle).length > 0"
|
||||||
class="bg-base-200 p-4 rounded-lg">
|
class="bg-base-200 p-4 rounded-lg">
|
||||||
<p class="text-xs text-base-content/70 font-semibold mb-3 text-center">🏆 Winning Solutions</p>
|
<p class="text-xs text-base-content/70 font-semibold mb-3 text-center">{{ t("components.puzzleResults.winningSolutions") }}</p>
|
||||||
<div class="flex flex-wrap justify-center gap-6">
|
<div class="flex flex-wrap justify-center gap-6">
|
||||||
<template v-for="submission in getWinnersForPuzzle(puzzle)"
|
<template v-for="submission in getWinnersForPuzzle(puzzle)"
|
||||||
:key="`${puzzle.puzzle_id}-${submission.user_id}`">
|
:key="`${puzzle.puzzle_id}-${submission.user_id}`">
|
||||||
@@ -133,14 +135,14 @@ onMounted(() => {
|
|||||||
<table class="table table-sm table-zebra">
|
<table class="table table-sm table-zebra">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-12 text-center">Pos</th>
|
<th class="w-12 text-center">{{ t("components.puzzleResults.columns.pos") }}</th>
|
||||||
<th>User</th>
|
<th>{{ t("components.puzzleResults.columns.user") }}</th>
|
||||||
<th class="text-right">Cost</th>
|
<th class="text-right">{{ t("common.cost") }}</th>
|
||||||
<th class="text-right">Cycles</th>
|
<th class="text-right">{{ t("common.cycles") }}</th>
|
||||||
<th class="text-right">Area</th>
|
<th class="text-right">{{ t("common.area") }}</th>
|
||||||
<th class="text-right font-bold">Total Pts</th>
|
<th class="text-right font-bold">{{ t("components.puzzleResults.columns.totalPts") }}</th>
|
||||||
<th class="text-right font-bold">Total Coef</th>
|
<th class="text-right font-bold">{{ t("components.puzzleResults.columns.totalCoef") }}</th>
|
||||||
<th class="text-center">GIF</th>
|
<th class="text-center">{{ t("components.puzzleResults.columns.gif") }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -167,7 +169,7 @@ onMounted(() => {
|
|||||||
@click="openImageModal(submission.files[0].file_url, submission.files[0].original_filename)"
|
@click="openImageModal(submission.files[0].file_url, submission.files[0].original_filename)"
|
||||||
class="btn btn-xs btn-primary gap-1">
|
class="btn btn-xs btn-primary gap-1">
|
||||||
<i class="mdi mdi-image"></i>
|
<i class="mdi mdi-image"></i>
|
||||||
View
|
{{ t("common.view") }}
|
||||||
</button>
|
</button>
|
||||||
<span v-else class="text-base-content/50">—</span>
|
<span v-else class="text-base-content/50">—</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -177,7 +179,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="p-4 bg-base-200 rounded-lg text-center text-base-content/70">
|
<div v-else class="p-4 bg-base-200 rounded-lg text-center text-base-content/70">
|
||||||
No submissions yet
|
{{ t("common.noSubmissionsYet") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,9 +203,9 @@ onMounted(() => {
|
|||||||
<div class="modal-action mt-4">
|
<div class="modal-action mt-4">
|
||||||
<a :href="selectedImageUrl" target="_blank" class="btn btn-primary btn-sm">
|
<a :href="selectedImageUrl" target="_blank" class="btn btn-primary btn-sm">
|
||||||
<i class="mdi mdi-download"></i>
|
<i class="mdi mdi-download"></i>
|
||||||
Download
|
{{ t("common.download") }}
|
||||||
</a>
|
</a>
|
||||||
<button @click="closeImageModal" class="btn btn-sm">Close</button>
|
<button @click="closeImageModal" class="btn btn-sm">{{ t("common.close") }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-backdrop" @click="closeImageModal"></div>
|
<div class="modal-backdrop" @click="closeImageModal"></div>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
rank: number | null;
|
rank: number | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -20,6 +24,6 @@ defineProps<{
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-2xl text-base-content/50">
|
<div v-else class="text-2xl text-base-content/50">
|
||||||
No rank yet
|
{{ t("common.noRankYet") }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import RankBadge from "./RankBadge.vue";
|
import RankBadge from "./RankBadge.vue";
|
||||||
import { animationsApiResults, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo } from "@/api";
|
import { animationsApiResults, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo } from "@/api";
|
||||||
import { useEventPath } from "@/composables/useEventPath";
|
import { useEventPath } from "@/composables/useEventPath";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const eventPath = useEventPath();
|
const eventPath = useEventPath();
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -126,15 +128,15 @@ const clearCache = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.error) {
|
if (!response.error) {
|
||||||
alert("Cache cleared successfully!");
|
alert(t("common.cacheClearedSuccess"));
|
||||||
await fetchResults();
|
await fetchResults();
|
||||||
} else {
|
} else {
|
||||||
const detail = (response.error as { detail?: string })?.detail;
|
const detail = (response.error as { detail?: string })?.detail;
|
||||||
alert(`Error clearing cache: ${detail || "Unknown error"}`);
|
alert(t("components.results.clearCacheError", { detail: detail || t("common.unknownError") }));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error clearing cache:", error);
|
console.error("Error clearing cache:", error);
|
||||||
alert("Error clearing cache. Please try again.");
|
alert(t("common.cacheClearError"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,12 +180,12 @@ onMounted(() => {
|
|||||||
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-6 text-white rounded-t-2xl">
|
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-6 text-white rounded-t-2xl">
|
||||||
<h3 class="text-3xl font-bold">
|
<h3 class="text-3xl font-bold">
|
||||||
<i class="mdi mdi-trophy text-3xl"></i>
|
<i class="mdi mdi-trophy text-3xl"></i>
|
||||||
Your Ranking
|
{{ t("common.yourRanking") }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="text-center mb-6">
|
<div class="text-center mb-6">
|
||||||
<p class="text-sm text-base-content/70">Player</p>
|
<p class="text-sm text-base-content/70">{{ t("common.player") }}</p>
|
||||||
<p class="text-3xl font-bold">{{ userInfo.username }}</p>
|
<p class="text-3xl font-bold">{{ userInfo.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,26 +197,26 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70 mb-1">Current Rank</p>
|
<p class="text-sm text-base-content/70 mb-1">{{ t("common.currentRank") }}</p>
|
||||||
<p v-if="userInfo.rank !== null" class="text-4xl font-bold text-primary">
|
<p v-if="userInfo.rank !== null" class="text-4xl font-bold text-primary">
|
||||||
<RankBadge :rank="userInfo.rank" />
|
<RankBadge :rank="userInfo.rank" />
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-2xl text-base-content/50">No rank yet</p>
|
<p v-else class="text-2xl text-base-content/50">{{ t("common.noRankYet") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70 mb-1">Total Points</p>
|
<p class="text-sm text-base-content/70 mb-1">{{ t("components.results.totalPoints") }}</p>
|
||||||
<p class="text-2xl font-bold">{{ userInfo.totalPoints.toLocaleString() }}</p>
|
<p class="text-2xl font-bold">{{ userInfo.totalPoints.toLocaleString() }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70 mb-1">Puzzles Solved</p>
|
<p class="text-sm text-base-content/70 mb-1">{{ t("components.results.puzzlesSolved") }}</p>
|
||||||
<p class="text-2xl font-bold">{{ userInfo.puzzlesSolved }}</p>
|
<p class="text-2xl font-bold">{{ userInfo.puzzlesSolved }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-6">
|
<button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-6">
|
||||||
<i class="mdi mdi-cache-clear mr-1"></i>
|
<i class="mdi mdi-cache-clear mr-1"></i>
|
||||||
Clear Cache
|
{{ t("common.clearCache") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,7 +229,7 @@ onMounted(() => {
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl mb-6">
|
<h2 class="card-title text-2xl mb-6">
|
||||||
<i class="mdi mdi-trophy text-yellow-500 mr-2"></i>
|
<i class="mdi mdi-trophy text-yellow-500 mr-2"></i>
|
||||||
General Results
|
{{ t("components.results.generalResults") }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div v-if="isLoading" class="flex justify-center py-8">
|
<div v-if="isLoading" class="flex justify-center py-8">
|
||||||
@@ -235,7 +237,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!resultsData" class="text-center py-8">
|
<div v-else-if="!resultsData" class="text-center py-8">
|
||||||
<p class="text-base-content/70">No results available yet</p>
|
<p class="text-base-content/70">{{ t("common.noResultsYet") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-6">
|
<div v-else class="space-y-6">
|
||||||
@@ -246,31 +248,31 @@ onMounted(() => {
|
|||||||
selectedTab === 'overall' ? 'tab-active' : '',
|
selectedTab === 'overall' ? 'tab-active' : '',
|
||||||
]">
|
]">
|
||||||
<i class="mdi mdi-chart-line mr-2"></i>
|
<i class="mdi mdi-chart-line mr-2"></i>
|
||||||
Overall Ranking
|
{{ t("components.results.tabs.overall") }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="selectedTab = 'byPuzzle'" :class="[
|
<button @click="selectedTab = 'byPuzzle'" :class="[
|
||||||
'tab',
|
'tab',
|
||||||
selectedTab === 'byPuzzle' ? 'tab-active' : '',
|
selectedTab === 'byPuzzle' ? 'tab-active' : '',
|
||||||
]">
|
]">
|
||||||
<i class="mdi mdi-puzzle mr-2"></i>
|
<i class="mdi mdi-puzzle mr-2"></i>
|
||||||
By Puzzle
|
{{ t("components.results.tabs.byPuzzle") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Overall Ranking -->
|
<!-- Overall Ranking -->
|
||||||
<div v-show="selectedTab === 'overall'" class="space-y-4">
|
<div v-show="selectedTab === 'overall'" class="space-y-4">
|
||||||
<div v-if="getOverallRanking().length === 0" class="text-center py-8">
|
<div v-if="getOverallRanking().length === 0" class="text-center py-8">
|
||||||
<p class="text-base-content/70">No submissions yet</p>
|
<p class="text-base-content/70">{{ t("common.noSubmissionsYet") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<div v-else class="overflow-x-auto">
|
||||||
<table class="table table-zebra w-full">
|
<table class="table table-zebra w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Rank</th>
|
<th>{{ t("common.rank") }}</th>
|
||||||
<th>Player</th>
|
<th>{{ t("common.player") }}</th>
|
||||||
<th class="text-right">Puzzles Solved</th>
|
<th class="text-right">{{ t("components.results.puzzlesSolved") }}</th>
|
||||||
<th class="text-right">Total Points</th>
|
<th class="text-right">{{ t("components.results.totalPoints") }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -281,7 +283,7 @@ onMounted(() => {
|
|||||||
<td class="font-medium">
|
<td class="font-medium">
|
||||||
{{ ranking.user.username }}
|
{{ ranking.user.username }}
|
||||||
<span v-if="ranking.user.is_staff" class="badge badge-warning">
|
<span v-if="ranking.user.is_staff" class="badge badge-warning">
|
||||||
admin
|
{{ t("common.staffBadge") }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">{{ ranking.puzzlesSolved }}</td>
|
<td class="text-right">{{ ranking.puzzlesSolved }}</td>
|
||||||
@@ -302,35 +304,35 @@ onMounted(() => {
|
|||||||
:class="['mdi mr-2', expandedPuzzleId === puzzle.id ? 'mdi-chevron-down' : 'mdi-chevron-right']"></i>
|
:class="['mdi mr-2', expandedPuzzleId === puzzle.id ? 'mdi-chevron-down' : 'mdi-chevron-right']"></i>
|
||||||
{{ puzzle.title }}
|
{{ puzzle.title }}
|
||||||
<span class="ml-auto badge badge-sm">
|
<span class="ml-auto badge badge-sm">
|
||||||
{{ getPuzzleRanking(puzzle.id).length }} submissions
|
{{ t("components.results.submissionsCount", { n: getPuzzleRanking(puzzle.id).length }) }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Expanded Details -->
|
<!-- Expanded Details -->
|
||||||
<div v-if="expandedPuzzleId === puzzle.id" class="card-body">
|
<div v-if="expandedPuzzleId === puzzle.id" class="card-body">
|
||||||
<div v-if="getPuzzleRanking(puzzle.id).length === 0" class="text-center py-8">
|
<div v-if="getPuzzleRanking(puzzle.id).length === 0" class="text-center py-8">
|
||||||
<p class="text-base-content/70 text-lg">No submissions yet</p>
|
<p class="text-base-content/70 text-lg">{{ t("common.noSubmissionsYet") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-6">
|
<div v-else class="space-y-6">
|
||||||
<!-- Points Factor Info -->
|
<!-- Points Factor Info -->
|
||||||
<div v-if="puzzle.points_factor" class="bg-base-200 p-4 rounded-lg">
|
<div v-if="puzzle.points_factor" class="bg-base-200 p-4 rounded-lg">
|
||||||
<p class="text-sm text-base-content/70 mb-3 font-semibold">Points Coefficients</p>
|
<p class="text-sm text-base-content/70 mb-3 font-semibold">{{ t("common.pointsCoefficients") }}</p>
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="text-2xl font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cost
|
<span class="text-2xl font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cost
|
||||||
}}</span>
|
}}</span>
|
||||||
<p class="text-xs text-base-content/70">Cost</p>
|
<p class="text-xs text-base-content/70">{{ t("common.cost") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="text-2xl font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cycles
|
<span class="text-2xl font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cycles
|
||||||
}}</span>
|
}}</span>
|
||||||
<p class="text-xs text-base-content/70">Cycles</p>
|
<p class="text-xs text-base-content/70">{{ t("common.cycles") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="text-2xl font-bold text-primary"><small>x</small>{{ puzzle.points_factor.area
|
<span class="text-2xl font-bold text-primary"><small>x</small>{{ puzzle.points_factor.area
|
||||||
}}</span>
|
}}</span>
|
||||||
<p class="text-xs text-base-content/70">Area</p>
|
<p class="text-xs text-base-content/70">{{ t("common.area") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -341,41 +343,41 @@ onMounted(() => {
|
|||||||
class="card bg-base-200">
|
class="card bg-base-200">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="text-xs text-base-content/70 font-bold">
|
<div class="text-xs text-base-content/70 font-bold">
|
||||||
{{ index === 0 ? '🏆 1st Place' : index === 1 ? '🥈 2nd Place' : '🥉 3rd Place' }}
|
{{ index === 0 ? t("components.results.podium.first") : index === 1 ? t("components.results.podium.second") : t("components.results.podium.third") }}
|
||||||
</div>
|
</div>
|
||||||
<h4 class="font-bold text-lg">
|
<h4 class="font-bold text-lg">
|
||||||
{{ response.user.username }}
|
{{ response.user.username }}
|
||||||
<span v-if="response.user.is_staff" class="badge badge-warning">
|
<span v-if="response.user.is_staff" class="badge badge-warning">
|
||||||
admin
|
{{ t("common.staffBadge") }}
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
<div class="divider my-2"></div>
|
<div class="divider my-2"></div>
|
||||||
<div class="space-y-2 text-sm">
|
<div class="space-y-2 text-sm">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span>Cost<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
|
<span>{{ t("common.cost") }}<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
|
||||||
(x{{ puzzle.points_factor.cost }})
|
(x{{ puzzle.points_factor.cost }})
|
||||||
</span></span>
|
</span></span>
|
||||||
<span class="badge badge-sm">{{ response.cost || 'N/A' }}</span>
|
<span class="badge badge-sm">{{ response.cost || 'N/A' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span>Cycles<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
|
<span>{{ t("common.cycles") }}<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
|
||||||
(x{{ puzzle.points_factor.cycles }})
|
(x{{ puzzle.points_factor.cycles }})
|
||||||
</span></span>
|
</span></span>
|
||||||
<span class="badge badge-sm">{{ response.cycles || 'N/A' }}</span>
|
<span class="badge badge-sm">{{ response.cycles || 'N/A' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span>Area<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
|
<span>{{ t("common.area") }}<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
|
||||||
(x{{ puzzle.points_factor.area }})
|
(x{{ puzzle.points_factor.area }})
|
||||||
</span></span>
|
</span></span>
|
||||||
<span class="badge badge-sm">{{ response.area || 'N/A' }}</span>
|
<span class="badge badge-sm">{{ response.area || 'N/A' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between pt-2 border-t">
|
<div class="flex justify-between pt-2 border-t">
|
||||||
<span>Total (with coef.)</span>
|
<span>{{ t("components.results.totalWithCoef") }}</span>
|
||||||
<span class="badge badge-sm">{{ response.points || 'N/A' }}</span>
|
<span class="badge badge-sm">{{ response.points || 'N/A' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between pt-2 border-t">
|
<div class="flex justify-between pt-2 border-t">
|
||||||
<span class="font-bold">Points</span>
|
<span class="font-bold">{{ t("common.points") }}</span>
|
||||||
<span class="badge badge-primary">{{ response.rank_points }} pts</span>
|
<span class="badge badge-primary">{{ response.rank_points }} {{ t("common.pts") }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -387,25 +389,25 @@ onMounted(() => {
|
|||||||
<table class="table table-zebra w-full table-sm">
|
<table class="table table-zebra w-full table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-12">Rank</th>
|
<th class="w-12">{{ t("common.rank") }}</th>
|
||||||
<th>Player</th>
|
<th>{{ t("common.player") }}</th>
|
||||||
<th class="text-center">
|
<th class="text-center">
|
||||||
Cost
|
{{ t("common.cost") }}
|
||||||
<span v-if="puzzle.points_factor" class="text-xs text-base-content/60 block">(x{{
|
<span v-if="puzzle.points_factor" class="text-xs text-base-content/60 block">(x{{
|
||||||
puzzle.points_factor.cost }})</span>
|
puzzle.points_factor.cost }})</span>
|
||||||
</th>
|
</th>
|
||||||
<th class="text-center">
|
<th class="text-center">
|
||||||
Cycles
|
{{ t("common.cycles") }}
|
||||||
<span v-if="puzzle.points_factor" class="text-xs text-base-content/60 block">(x{{
|
<span v-if="puzzle.points_factor" class="text-xs text-base-content/60 block">(x{{
|
||||||
puzzle.points_factor.cycles }})</span>
|
puzzle.points_factor.cycles }})</span>
|
||||||
</th>
|
</th>
|
||||||
<th class="text-center">
|
<th class="text-center">
|
||||||
Area
|
{{ t("common.area") }}
|
||||||
<span v-if="puzzle.points_factor" class="text-xs text-base-content/60 block">(x{{
|
<span v-if="puzzle.points_factor" class="text-xs text-base-content/60 block">(x{{
|
||||||
puzzle.points_factor.area }})</span>
|
puzzle.points_factor.area }})</span>
|
||||||
</th>
|
</th>
|
||||||
<th class="text-center">Total (with coef.)</th>
|
<th class="text-center">{{ t("components.results.totalWithCoef") }}</th>
|
||||||
<th class="text-right">Points</th>
|
<th class="text-right">{{ t("common.points") }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-xl mb-6">
|
<h2 class="card-title text-xl mb-6">
|
||||||
<i class="mdi mdi-check-circle text-2xl text-primary"></i>
|
<i class="mdi mdi-check-circle text-2xl text-primary"></i>
|
||||||
Submit Solution
|
{{ t("common.submitSolution") }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<i class="mdi mdi-information-outline text-xl"></i>
|
<i class="mdi mdi-information-outline text-xl"></i>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="font-bold">
|
<h4 class="font-bold">
|
||||||
Detected Puzzles ({{ Object.keys(responsesByPuzzle).length }})
|
{{ t("components.submissionForm.detectedPuzzles", { n: Object.keys(responsesByPuzzle).length }) }}
|
||||||
</h4>
|
</h4>
|
||||||
<div class="text-sm space-y-1 mt-1">
|
<div class="text-sm space-y-1 mt-1">
|
||||||
<div
|
<div
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
>
|
>
|
||||||
<span>{{ puzzleName }}</span>
|
<span>{{ puzzleName }}</span>
|
||||||
<span class="badge badge-ghost badge-sm ml-2"
|
<span class="badge badge-ghost badge-sm ml-2"
|
||||||
>{{ data.files.length }} file(s)</span
|
>{{ t("common.fileCount", data.files.length) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,11 +42,9 @@
|
|||||||
>
|
>
|
||||||
<i class="mdi mdi-alert-circle text-xl"></i>
|
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-bold">Manual Puzzle Selection Required</div>
|
<div class="font-bold">{{ t("components.submissionForm.manualSelectionRequired") }}</div>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
{{ submissionFilesNeedingManualSelection.length }} file(s) have
|
{{ t("components.submissionForm.manualSelectionBody", { n: submissionFilesNeedingManualSelection.length }) }}
|
||||||
low OCR confidence for puzzle names. Please select the correct
|
|
||||||
puzzle for each file before submitting.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -54,7 +52,7 @@
|
|||||||
@click="processLowConfidenceOCRFiles"
|
@click="processLowConfidenceOCRFiles"
|
||||||
>
|
>
|
||||||
<span class="mdi mdi-reload text-2xl"></span>
|
<span class="mdi mdi-reload text-2xl"></span>
|
||||||
Retry OCR on low confidence puzzle
|
{{ t("components.submissionForm.retryOcr") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,13 +61,13 @@
|
|||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label class="flex label">
|
<label class="flex label">
|
||||||
<span class="label-text font-medium">Notes (Optional)</span>
|
<span class="label-text font-medium">{{ t("common.notesOptional") }}</span>
|
||||||
<span class="label-text-alt">{{ notesLength }}/500</span>
|
<span class="label-text-alt">{{ notesLength }}/500</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="notes"
|
v-model="notes"
|
||||||
class="flex textarea textarea-bordered h-24 w-full resize-none"
|
class="flex textarea textarea-bordered h-24 w-full resize-none"
|
||||||
placeholder="Add any notes about your solution, approach, or interesting findings..."
|
:placeholder="t('components.submissionForm.notesPlaceholder')"
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,16 +84,12 @@
|
|||||||
/>
|
/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="label-text font-medium"
|
<span class="label-text font-medium"
|
||||||
>Request manual validation</span
|
>{{ t("components.submissionForm.requestManualValidation") }}</span
|
||||||
>
|
>
|
||||||
<div class="label-text-alt text-xs opacity-70 mt-1">
|
<div class="label-text-alt text-xs opacity-70 mt-1">
|
||||||
Check this if you want an admin to manually review your
|
{{ t("components.submissionForm.manualValidationHint") }}
|
||||||
submission, even if OCR confidence is high.
|
|
||||||
<br />
|
<br />
|
||||||
<em
|
<em>{{ t("components.submissionForm.manualValidationNote") }}</em>
|
||||||
>Note: This will be automatically checked if any OCR
|
|
||||||
confidence is below 80%.</em
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -108,12 +102,11 @@
|
|||||||
v-if="isSubmitting"
|
v-if="isSubmitting"
|
||||||
class="loading loading-spinner loading-sm"
|
class="loading loading-spinner loading-sm"
|
||||||
></span>
|
></span>
|
||||||
<span v-if="isSubmitting">Submitting...</span>
|
<span v-if="isSubmitting">{{ t("common.submitting") }}</span>
|
||||||
<span v-else-if="submissionFilesNeedingManualSelection.length > 0">
|
<span v-else-if="submissionFilesNeedingManualSelection.length > 0">
|
||||||
Select Puzzles ({{ submissionFilesNeedingManualSelection.length }}
|
{{ t("components.submissionForm.selectPuzzlesRemaining", { n: submissionFilesNeedingManualSelection.length }) }}
|
||||||
remaining)
|
|
||||||
</span>
|
</span>
|
||||||
<span v-else>Submit Solution</span>
|
<span v-else>{{ t("common.submitSolution") }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -123,6 +116,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import FileUpload from "@/components/FileUpload.vue";
|
import FileUpload from "@/components/FileUpload.vue";
|
||||||
import type { SteamCollectionItem, SubmissionFile } from "@/types";
|
import type { SteamCollectionItem, SubmissionFile } from "@/types";
|
||||||
import { useUploadsStore } from "@/stores/uploads";
|
import { useUploadsStore } from "@/stores/uploads";
|
||||||
@@ -136,6 +130,7 @@ interface Props {
|
|||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const uploadsStore = useUploadsStore();
|
const uploadsStore = useUploadsStore();
|
||||||
const {
|
const {
|
||||||
submissionFiles,
|
submissionFiles,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import { apiService } from "@/services/apiService";
|
import { apiService } from "@/services/apiService";
|
||||||
import type { TournamentPuzzleResults } from "@/types";
|
import type { TournamentPuzzleResults } from "@/types";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const resultsData = ref<TournamentPuzzleResults | null>(null);
|
const resultsData = ref<TournamentPuzzleResults | null>(null);
|
||||||
const error = ref<string>("");
|
const error = ref<string>("");
|
||||||
@@ -19,7 +21,7 @@ const fetchResults = async () => {
|
|||||||
console.error("Error fetching results:", response.error);
|
console.error("Error fetching results:", response.error);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : "Failed to fetch results";
|
error.value = err instanceof Error ? err.message : t("common.fetchResultsError");
|
||||||
console.error("Error fetching results:", err);
|
console.error("Error fetching results:", err);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
@@ -60,7 +62,7 @@ onMounted(() => {
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl flex items-center gap-2">
|
<h2 class="card-title text-2xl flex items-center gap-2">
|
||||||
<i class="mdi mdi-podium text-yellow-500 text-3xl"></i>
|
<i class="mdi mdi-podium text-yellow-500 text-3xl"></i>
|
||||||
Top 10 Users
|
{{ t("components.topUsersLeaderboard.title") }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div v-if="isLoading" class="flex justify-center py-12">
|
<div v-if="isLoading" class="flex justify-center py-12">
|
||||||
@@ -73,7 +75,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="getTop10Users().length === 0" class="text-center py-8">
|
<div v-else-if="getTop10Users().length === 0" class="text-center py-8">
|
||||||
<p class="text-base-content/70">No results available yet.</p>
|
<p class="text-base-content/70">{{ t("common.noResultsYet") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||||
@@ -87,7 +89,7 @@ onMounted(() => {
|
|||||||
{{ index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `#${index + 1}` }}
|
{{ index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `#${index + 1}` }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold text-sm mb-2 truncate">{{ user.username }}</div>
|
<div class="font-semibold text-sm mb-2 truncate">{{ user.username }}</div>
|
||||||
<div class="text-2xl font-bold text-primary">{{ user.total_points }} pts</div>
|
<div class="text-2xl font-bold text-primary">{{ user.total_points }} {{ t("common.pts") }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import { useMarketStore } from "../stores/market";
|
import { useMarketStore } from "../stores/market";
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
refresh: [];
|
refresh: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const marketStore = useMarketStore();
|
const marketStore = useMarketStore();
|
||||||
const { userBets } = storeToRefs(marketStore);
|
const { userBets } = storeToRefs(marketStore);
|
||||||
const loading = computed(() => marketStore.isLoading);
|
const loading = computed(() => marketStore.isLoading);
|
||||||
@@ -49,7 +51,7 @@ const totalWinnings = computed(() => {
|
|||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-else-if="userBets.length === 0" class="alert">
|
<div v-else-if="userBets.length === 0" class="alert">
|
||||||
<i class="mdi mdi-information mr-2"></i>
|
<i class="mdi mdi-information mr-2"></i>
|
||||||
<span>You haven't placed any bets yet</span>
|
<span>{{ t("components.userBets.empty") }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
@@ -57,27 +59,27 @@ const totalWinnings = computed(() => {
|
|||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div class="stat bg-base-100 rounded-lg shadow">
|
<div class="stat bg-base-100 rounded-lg shadow">
|
||||||
<div class="stat-title text-sm">Total Bets</div>
|
<div class="stat-title text-sm">{{ t("components.userBets.totalBets") }}</div>
|
||||||
<div class="stat-value text-2xl">{{ userBets.length }}</div>
|
<div class="stat-value text-2xl">{{ userBets.length }}</div>
|
||||||
<div class="stat-desc text-xs">{{ totalBetAmount }} points</div>
|
<div class="stat-desc text-xs">{{ totalBetAmount }} {{ t("common.pointsUnit") }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat bg-base-100 rounded-lg shadow">
|
<div class="stat bg-base-100 rounded-lg shadow">
|
||||||
<div class="stat-title text-sm">Active</div>
|
<div class="stat-title text-sm">{{ t("components.userBets.active") }}</div>
|
||||||
<div class="stat-value text-2xl text-info">{{ openBets.length }}</div>
|
<div class="stat-value text-2xl text-info">{{ openBets.length }}</div>
|
||||||
<div class="stat-desc text-xs">Waiting for result</div>
|
<div class="stat-desc text-xs">{{ t("components.userBets.waitingForResult") }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat bg-base-100 rounded-lg shadow">
|
<div class="stat bg-base-100 rounded-lg shadow">
|
||||||
<div class="stat-title text-sm">Won</div>
|
<div class="stat-title text-sm">{{ t("components.userBets.won") }}</div>
|
||||||
<div class="stat-value text-2xl text-success">{{ winningBets.length }}</div>
|
<div class="stat-value text-2xl text-success">{{ winningBets.length }}</div>
|
||||||
<div class="stat-desc text-xs text-success">+{{ totalWinnings }} points</div>
|
<div class="stat-desc text-xs text-success">{{ t("components.userBets.wonPointsLabel", { n: totalWinnings }) }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat bg-base-100 rounded-lg shadow">
|
<div class="stat bg-base-100 rounded-lg shadow">
|
||||||
<div class="stat-title text-sm">Lost</div>
|
<div class="stat-title text-sm">{{ t("components.userBets.lost") }}</div>
|
||||||
<div class="stat-value text-2xl text-error">{{ losingBets.length }}</div>
|
<div class="stat-value text-2xl text-error">{{ losingBets.length }}</div>
|
||||||
<div class="stat-desc text-xs">Better luck next time</div>
|
<div class="stat-desc text-xs">{{ t("components.userBets.betterLuck") }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@ const totalWinnings = computed(() => {
|
|||||||
<div v-if="openBets.length > 0">
|
<div v-if="openBets.length > 0">
|
||||||
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
<i class="mdi mdi-progress-clock text-info"></i>
|
<i class="mdi mdi-progress-clock text-info"></i>
|
||||||
Active Bets ({{ openBets.length }})
|
{{ t("components.userBets.activeBets", { n: openBets.length }) }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div
|
<div
|
||||||
@@ -100,13 +102,13 @@ const totalWinnings = computed(() => {
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="font-semibold text-lg">{{ bet.market?.title }}</h4>
|
<h4 class="font-semibold text-lg">{{ bet.market?.title }}</h4>
|
||||||
<p class="text-sm text-base-content/70">
|
<p class="text-sm text-base-content/70">
|
||||||
Bet on: <span class="font-medium">{{ bet.option.text }}</span>
|
{{ t("components.userBets.betOn", { option: bet.option.text }) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="text-lg font-bold">{{ bet.amount }} pts</div>
|
<div class="text-lg font-bold">{{ bet.amount }} {{ t("common.pts") }}</div>
|
||||||
<div class="text-xs text-base-content/60 mt-1">
|
<div class="text-xs text-base-content/60 mt-1">
|
||||||
Status: <span class="badge badge-info badge-sm">{{ bet.market?.status }}</span>
|
{{ t("components.userBets.statusLabel") }} <span class="badge badge-info badge-sm">{{ bet.market?.status ? t(`common.marketStatus.${bet.market.status}`) : '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +122,7 @@ const totalWinnings = computed(() => {
|
|||||||
<button @click="isWonBetsExpanded = !isWonBetsExpanded" class="text-xl font-bold mb-4 flex items-center gap-2 cursor-pointer hover:opacity-70 transition-opacity">
|
<button @click="isWonBetsExpanded = !isWonBetsExpanded" class="text-xl font-bold mb-4 flex items-center gap-2 cursor-pointer hover:opacity-70 transition-opacity">
|
||||||
<i :class="['mdi', isWonBetsExpanded ? 'mdi-chevron-down' : 'mdi-chevron-right']"></i>
|
<i :class="['mdi', isWonBetsExpanded ? 'mdi-chevron-down' : 'mdi-chevron-right']"></i>
|
||||||
<i class="mdi mdi-check-circle text-success"></i>
|
<i class="mdi mdi-check-circle text-success"></i>
|
||||||
Won Bets ({{ winningBets.length }})
|
{{ t("components.userBets.wonBets", { n: winningBets.length }) }}
|
||||||
</button>
|
</button>
|
||||||
<div v-if="isWonBetsExpanded" class="space-y-4">
|
<div v-if="isWonBetsExpanded" class="space-y-4">
|
||||||
<div
|
<div
|
||||||
@@ -133,11 +135,11 @@ const totalWinnings = computed(() => {
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="font-semibold text-lg">{{ bet.market?.title }}</h4>
|
<h4 class="font-semibold text-lg">{{ bet.market?.title }}</h4>
|
||||||
<p class="text-sm text-base-content/70">
|
<p class="text-sm text-base-content/70">
|
||||||
Correct! You bet on: <span class="font-medium text-success">{{ bet.option.text }}</span>
|
{{ t("components.userBets.correctBetOn", { option: bet.option.text }) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="text-lg font-bold text-success">+{{ bet.amount }} pts</div>
|
<div class="text-lg font-bold text-success">{{ t("components.userBets.wonPointsLabel", { n: bet.amount }) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,7 +151,7 @@ const totalWinnings = computed(() => {
|
|||||||
<div v-if="losingBets.length > 0">
|
<div v-if="losingBets.length > 0">
|
||||||
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
<i class="mdi mdi-close-circle text-error"></i>
|
<i class="mdi mdi-close-circle text-error"></i>
|
||||||
Lost Bets ({{ losingBets.length }})
|
{{ t("components.userBets.lostBets", { n: losingBets.length }) }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div
|
<div
|
||||||
@@ -162,14 +164,14 @@ const totalWinnings = computed(() => {
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="font-semibold text-lg">{{ bet.market?.title }}</h4>
|
<h4 class="font-semibold text-lg">{{ bet.market?.title }}</h4>
|
||||||
<p class="text-sm text-base-content/70">
|
<p class="text-sm text-base-content/70">
|
||||||
You bet on: <span class="font-medium">{{ bet.option.text }}</span>
|
{{ t("components.userBets.youBetOn", { option: bet.option.text }) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-base-content/60 mt-1">
|
<p class="text-sm text-base-content/60 mt-1">
|
||||||
Winner: <span class="font-medium">{{ bet.market?.winning_option?.text }}</span>
|
{{ t("components.userBets.winnerLabel", { option: bet.market?.winning_option?.text }) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="text-lg font-bold text-error">-{{ bet.amount }} pts</div>
|
<div class="text-lg font-bold text-error">-{{ bet.amount }} {{ t("common.pts") }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from "vue";
|
import { ref, onMounted, computed } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import { apiService } from "@/services/apiService";
|
import { apiService } from "@/services/apiService";
|
||||||
import type { TournamentSubmissions, WinnerResponse, PuzzleSubmissions } from "@/types";
|
import type { TournamentSubmissions, WinnerResponse, PuzzleSubmissions } from "@/types";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const submissionsData = ref<TournamentSubmissions | null>(null);
|
const submissionsData = ref<TournamentSubmissions | null>(null);
|
||||||
const error = ref<string>("");
|
const error = ref<string>("");
|
||||||
@@ -24,7 +26,7 @@ const fetchSubmissions = async () => {
|
|||||||
console.error("Error fetching submissions:", response.error);
|
console.error("Error fetching submissions:", response.error);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : "Failed to fetch submissions";
|
error.value = err instanceof Error ? err.message : t("components.winners.fetchError");
|
||||||
console.error("Error fetching submissions:", err);
|
console.error("Error fetching submissions:", err);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
@@ -90,7 +92,7 @@ onMounted(() => {
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl flex items-center gap-2">
|
<h2 class="card-title text-2xl flex items-center gap-2">
|
||||||
<i class="mdi mdi-trophy text-yellow-500 text-3xl"></i>
|
<i class="mdi mdi-trophy text-yellow-500 text-3xl"></i>
|
||||||
Top Submissions
|
{{ t("components.winners.title") }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div v-if="isLoading" class="flex justify-center py-12">
|
<div v-if="isLoading" class="flex justify-center py-12">
|
||||||
@@ -103,21 +105,21 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="flattenedRows.length === 0" class="text-center py-8">
|
<div v-else-if="flattenedRows.length === 0" class="text-center py-8">
|
||||||
<p class="text-base-content/70">No results available yet.</p>
|
<p class="text-base-content/70">{{ t("common.noResultsYet") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<div v-else class="overflow-x-auto">
|
||||||
<table class="table table-zebra">
|
<table class="table table-zebra">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Puzzle</th>
|
<th>{{ t("components.winners.columns.puzzle") }}</th>
|
||||||
<th>User</th>
|
<th>{{ t("components.winners.columns.user") }}</th>
|
||||||
<th class="text-right">Cost</th>
|
<th class="text-right">{{ t("common.cost") }}</th>
|
||||||
<th class="text-right">Cycles</th>
|
<th class="text-right">{{ t("common.cycles") }}</th>
|
||||||
<th class="text-right">Area</th>
|
<th class="text-right">{{ t("common.area") }}</th>
|
||||||
<th class="text-right">Total Pts</th>
|
<th class="text-right">{{ t("components.winners.columns.totalPts") }}</th>
|
||||||
<th class="text-right">Total Coef</th>
|
<th class="text-right">{{ t("components.winners.columns.totalCoef") }}</th>
|
||||||
<th class="text-center">GIF</th>
|
<th class="text-center">{{ t("components.winners.columns.gif") }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -133,7 +135,7 @@ onMounted(() => {
|
|||||||
<button v-if="row.files.length > 0" @click="openImageModal(row.files[0].url, row.files[0].name)"
|
<button v-if="row.files.length > 0" @click="openImageModal(row.files[0].url, row.files[0].name)"
|
||||||
class="btn btn-xs btn-primary gap-1">
|
class="btn btn-xs btn-primary gap-1">
|
||||||
<i class="mdi mdi-image"></i>
|
<i class="mdi mdi-image"></i>
|
||||||
View
|
{{ t("common.view") }}
|
||||||
</button>
|
</button>
|
||||||
<span v-else class="text-base-content/50">—</span>
|
<span v-else class="text-base-content/50">—</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -161,9 +163,9 @@ onMounted(() => {
|
|||||||
<div class="modal-action mt-4">
|
<div class="modal-action mt-4">
|
||||||
<a :href="selectedImageUrl" target="_blank" class="btn btn-primary btn-sm">
|
<a :href="selectedImageUrl" target="_blank" class="btn btn-primary btn-sm">
|
||||||
<i class="mdi mdi-download"></i>
|
<i class="mdi mdi-download"></i>
|
||||||
Download
|
{{ t("common.download") }}
|
||||||
</a>
|
</a>
|
||||||
<button @click="closeImageModal" class="btn btn-sm">Close</button>
|
<button @click="closeImageModal" class="btn btn-sm">{{ t("common.close") }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-backdrop" @click="closeImageModal"></div>
|
<div class="modal-backdrop" @click="closeImageModal"></div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import ErrorPage from '@/ErrorPage.vue'
|
import ErrorPage from '@/ErrorPage.vue'
|
||||||
|
import { i18n } from '@/i18n'
|
||||||
import '@/style.css'
|
import '@/style.css'
|
||||||
|
|
||||||
const selector = "#app"
|
const selector = "#app"
|
||||||
const mountData = document.querySelector<HTMLElement>(selector)
|
const mountData = document.querySelector<HTMLElement>(selector)
|
||||||
const app = createApp(ErrorPage, { ...mountData?.dataset })
|
const app = createApp(ErrorPage, { ...mountData?.dataset })
|
||||||
|
app.use(i18n)
|
||||||
app.mount(selector)
|
app.mount(selector)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import Home from '@/Home.vue'
|
import Home from '@/Home.vue'
|
||||||
|
import { i18n } from '@/i18n'
|
||||||
import '@/style.css'
|
import '@/style.css'
|
||||||
|
|
||||||
// const app = createApp(App)
|
// const app = createApp(App)
|
||||||
const selector = "#app"
|
const selector = "#app"
|
||||||
const mountData = document.querySelector<HTMLElement>(selector)
|
const mountData = document.querySelector<HTMLElement>(selector)
|
||||||
const app = createApp(Home, { ...mountData?.dataset })
|
const app = createApp(Home, { ...mountData?.dataset })
|
||||||
|
app.use(i18n)
|
||||||
app.mount(selector)
|
app.mount(selector)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import en from '@/locales/en.json'
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: document.documentElement.lang || 'en',
|
||||||
|
fallbackLocale: 'en',
|
||||||
|
messages: { en },
|
||||||
|
})
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"appName": "PolyLAN Submitter",
|
||||||
|
"back": "Back",
|
||||||
|
"apiDocs": "API docs",
|
||||||
|
"adminPanel": "Admin panel",
|
||||||
|
"admin": "Admin",
|
||||||
|
"staffBadge": "admin",
|
||||||
|
"notLoggedIn": "Not logged in",
|
||||||
|
"close": "Close",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"download": "Download",
|
||||||
|
"retry": "Retry",
|
||||||
|
"view": "View",
|
||||||
|
"player": "Player",
|
||||||
|
"rank": "Rank",
|
||||||
|
"you": "You",
|
||||||
|
"pts": "pts",
|
||||||
|
"points": "Points",
|
||||||
|
"pointsUnit": "points",
|
||||||
|
"cost": "Cost",
|
||||||
|
"cycles": "Cycles",
|
||||||
|
"area": "Area",
|
||||||
|
"puzzle": "Puzzle",
|
||||||
|
"files": "Files",
|
||||||
|
"date": "Date",
|
||||||
|
"notes": "Notes",
|
||||||
|
"actions": "Actions",
|
||||||
|
"noRankYet": "No rank yet",
|
||||||
|
"noResultsYet": "No results available yet.",
|
||||||
|
"noSubmissionsYet": "No submissions yet",
|
||||||
|
"yourRanking": "Your Ranking",
|
||||||
|
"currentRank": "Current Rank",
|
||||||
|
"clearCache": "Clear Cache",
|
||||||
|
"cacheClearedSuccess": "Cache cleared successfully!",
|
||||||
|
"cacheClearError": "Error clearing cache. Please try again.",
|
||||||
|
"pleaseTryAgain": "Please try again.",
|
||||||
|
"submitSolution": "Submit Solution",
|
||||||
|
"selectedFiles": "Selected Files:",
|
||||||
|
"chooseFiles": "Choose Files",
|
||||||
|
"submitting": "Submitting...",
|
||||||
|
"submit": "Submit",
|
||||||
|
"maxFileSize": "Maximum file size: 256 MB per file",
|
||||||
|
"notesOptional": "Notes (Optional)",
|
||||||
|
"selectPuzzlePlaceholder": "Select puzzle...",
|
||||||
|
"pointsCoefficients": "Points Coefficients",
|
||||||
|
"idLabel": "ID: {id}",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"processing": "Processing...",
|
||||||
|
"pending": "Pending",
|
||||||
|
"unknownError": "Unknown error",
|
||||||
|
"fetchResultsError": "Failed to fetch results",
|
||||||
|
"fileWord": "file | files",
|
||||||
|
"fileCount": "{n} file | {n} files",
|
||||||
|
"marketStatus": {
|
||||||
|
"draft": "draft",
|
||||||
|
"open": "open",
|
||||||
|
"closed": "closed",
|
||||||
|
"resolved": "resolved"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "PolyLAN Submitter",
|
||||||
|
"subtitle": "Choose a game and submit your best solutions",
|
||||||
|
"marketCard": {
|
||||||
|
"title": "Market",
|
||||||
|
"description": "Place your bets and compete",
|
||||||
|
"cta": "Place bets"
|
||||||
|
},
|
||||||
|
"submitResults": "Submit results",
|
||||||
|
"footer": "Select a game above to begin submitting"
|
||||||
|
},
|
||||||
|
"market": {
|
||||||
|
"pageTitle": "Market",
|
||||||
|
"header": "Market",
|
||||||
|
"subtitle": "Place your bets on upcoming events",
|
||||||
|
"myBets": "My Bets",
|
||||||
|
"allMarkets": "All Markets",
|
||||||
|
"createMarket": "Create Market",
|
||||||
|
"noMarkets": "No markets available"
|
||||||
|
},
|
||||||
|
"noita": {
|
||||||
|
"pageTitle": "Noita Submitter",
|
||||||
|
"header": "Noita Submitter",
|
||||||
|
"totalScore": "Total Score",
|
||||||
|
"objectivesCompleted": "Objectives Completed",
|
||||||
|
"deaths": "Deaths",
|
||||||
|
"globalLeaderboard": "Global Leaderboard",
|
||||||
|
"score": "Score",
|
||||||
|
"objectivesColumn": "Objectives",
|
||||||
|
"objectives": "Objectives",
|
||||||
|
"submitYourRun": "Submit Your Run",
|
||||||
|
"uploadPrompt": "Click to upload or drag and drop",
|
||||||
|
"logFileLabel": "The log file",
|
||||||
|
"submitRun": "Submit Run",
|
||||||
|
"noObjectivesYet": "No objectives completed yet",
|
||||||
|
"noObjectivesHint": "Submit your runs to unlock objectives!",
|
||||||
|
"searchPlaceholder": "Search objectives...",
|
||||||
|
"showingResults": "Showing {shown} of {total} objectives",
|
||||||
|
"noObjectivesMatch": "No objectives match your search",
|
||||||
|
"runSubmittedSuccess": "Run submitted successfully!",
|
||||||
|
"runSubmitError": "Error submitting run. Please try again.",
|
||||||
|
"columns": {
|
||||||
|
"objectiveId": "Objective ID",
|
||||||
|
"yourPoints": "Your points",
|
||||||
|
"firstSeen": "First seen",
|
||||||
|
"seed": "Seed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"opusMagnum": {
|
||||||
|
"pageTitle": "Opus Magnum Puzzle Submitter",
|
||||||
|
"header": "Opus Magnum Puzzle Submitter",
|
||||||
|
"autoReload": "Auto reload page in {seconds} seconds ...",
|
||||||
|
"loadingPuzzles": "Loading puzzles...",
|
||||||
|
"errorLoadingData": "Error Loading Data",
|
||||||
|
"tournamentClosed": "Tournament Closed",
|
||||||
|
"tournamentClosedMessage": "This tournament is no longer accepting new submissions.",
|
||||||
|
"noPuzzlesTitle": "No Puzzles Available",
|
||||||
|
"noPuzzlesMessage": "Check back later for new puzzle collections!"
|
||||||
|
},
|
||||||
|
"submissions": {
|
||||||
|
"pageTitle": "{game} Submissions — {scope}",
|
||||||
|
"live": "Live",
|
||||||
|
"offline": "Offline",
|
||||||
|
"submittingForMatch": "Submitting for match:",
|
||||||
|
"submitResultsTitle": "Submit Results",
|
||||||
|
"uploadPrompt": "Click to upload, drag and drop, or paste",
|
||||||
|
"uploadHint": "Any file type supported • Images from clipboard welcome",
|
||||||
|
"uploadLimitExceeded": "Maximum {max} files allowed. You can add {count} more {unit}.",
|
||||||
|
"filesSelectedSuffix": "files selected",
|
||||||
|
"notesPlaceholder": "Add any notes special for this upload",
|
||||||
|
"thisFileType": "this file type",
|
||||||
|
"copyFailed": "Failed to copy",
|
||||||
|
"guidelines": {
|
||||||
|
"title": "Submission Guidelines",
|
||||||
|
"fileUpload": {
|
||||||
|
"title": "File Upload",
|
||||||
|
"body": "Upload your results as screenshots, logs, or any other file format. You can upload multiple files at once."
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"title": "Add Notes",
|
||||||
|
"body": "Include any additional context about your submission. This helps reviewers understand your results."
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"title": "Verification",
|
||||||
|
"body": "Your submission will be reviewed and verified. You'll receive confirmation once it's been processed."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"yourSubmissions": "Your Submissions",
|
||||||
|
"noSubmissionsHint": "Upload your first submission above to get started",
|
||||||
|
"fileStatus": "File Status",
|
||||||
|
"ocrStatus": "OCR Status",
|
||||||
|
"previewNotAvailableFor": "Preview not available for {type}",
|
||||||
|
"fileSizeLabel": "File size: {size}",
|
||||||
|
"processOcr": "Process OCR",
|
||||||
|
"filePreview": "File Preview",
|
||||||
|
"textPreviewUnavailable": "Text content preview not available. Download file to view.",
|
||||||
|
"previewNotAvailable": "Preview not available",
|
||||||
|
"ocrData": "OCR Data",
|
||||||
|
"extractedText": "File - Extracted Text:",
|
||||||
|
"ocrProcessing": "OCR is being processed...",
|
||||||
|
"ocrFailed": "OCR processing failed",
|
||||||
|
"ocrWaiting": "Waiting for OCR processing...",
|
||||||
|
"cropOcrResults": "Crop OCR Results ({count}):",
|
||||||
|
"cropLabelFallback": "Crop ({x}, {y})",
|
||||||
|
"cropProcessingFailed": "Processing failed",
|
||||||
|
"cropsData": "Crops Data",
|
||||||
|
"cropDrawHint": "Drag on image to create crop (blue) or existing crops (green)",
|
||||||
|
"cropLabelOptional": "Crop Label (optional)",
|
||||||
|
"cropLabelPlaceholder": "e.g., Top-left corner",
|
||||||
|
"saveCrop": "Save Crop",
|
||||||
|
"creatingCrop": "Creating...",
|
||||||
|
"cropsCount": "Crops ({count}):",
|
||||||
|
"deleteCropTooltip": "Delete crop",
|
||||||
|
"noCropsYet": "No crops yet",
|
||||||
|
"deleteCropConfirm": "Delete this crop?",
|
||||||
|
"submitSuccess": "Submission successful!",
|
||||||
|
"submitError": "Error submitting: {message}",
|
||||||
|
"createCropError": "Error creating crop: {message}",
|
||||||
|
"deleteCropError": "Error deleting crop: {message}",
|
||||||
|
"processOcrError": "Error processing OCR: {message}"
|
||||||
|
},
|
||||||
|
"submissionsSelect": {
|
||||||
|
"title": "Select your match",
|
||||||
|
"description": "You're assigned to multiple matches for {game}. Pick which one you want to submit for."
|
||||||
|
},
|
||||||
|
"errorPage": {
|
||||||
|
"notFound": {
|
||||||
|
"title": "Page not found",
|
||||||
|
"message": "The page you're looking for doesn't exist or has been moved."
|
||||||
|
},
|
||||||
|
"serverError": {
|
||||||
|
"title": "Server error",
|
||||||
|
"message": "Something went wrong on our end. Please try again later."
|
||||||
|
},
|
||||||
|
"generic": {
|
||||||
|
"title": "Something went wrong",
|
||||||
|
"message": "An unexpected error occurred."
|
||||||
|
},
|
||||||
|
"goHome": "Go home",
|
||||||
|
"goBack": "Go back"
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"adminPanel": {
|
||||||
|
"title": "Admin Panel",
|
||||||
|
"totalSubmissions": "Total Submissions",
|
||||||
|
"totalResponses": "Total Responses",
|
||||||
|
"needValidation": "Need Validation",
|
||||||
|
"validationRate": "Validation Rate",
|
||||||
|
"autoValidateAll": "Auto validation for all responses",
|
||||||
|
"responsesNeedingValidation": "Responses Needing Validation",
|
||||||
|
"confidence": "Confidence",
|
||||||
|
"costLabel": "Cost: {value}",
|
||||||
|
"cyclesLabel": "Cycles: {value}",
|
||||||
|
"areaLabel": "Area: {value}",
|
||||||
|
"validate": "Validate",
|
||||||
|
"autoValidation": "Auto Validation",
|
||||||
|
"allValidated": "All responses validated!",
|
||||||
|
"noValidationNeeded": "No responses currently need manual validation.",
|
||||||
|
"validateResponseTitle": "Validate Response",
|
||||||
|
"reviewHint": "Review and correct the OCR data below",
|
||||||
|
"enterCost": "Enter cost",
|
||||||
|
"enterCycles": "Enter cycles",
|
||||||
|
"enterArea": "Enter area",
|
||||||
|
"validating": "Validating...",
|
||||||
|
"validationFailed": "Validation failed: {error}",
|
||||||
|
"validationFailedGeneric": "Validation failed"
|
||||||
|
},
|
||||||
|
"fileUpload": {
|
||||||
|
"title": "Upload Solution Files",
|
||||||
|
"hint": "Images or GIFs only",
|
||||||
|
"dropHint": "Drop your files here or",
|
||||||
|
"supportedFormats": "Supported formats: JPG, PNG, GIF (max 256MB each)",
|
||||||
|
"extracting": "Extracting puzzle data...",
|
||||||
|
"ocrComplete": "✓ OCR Complete",
|
||||||
|
"overallConfidence": "Overall confidence: {pct}%",
|
||||||
|
"retryOcr": "Retry OCR",
|
||||||
|
"puzzleLabel": "Puzzle:",
|
||||||
|
"puzzleConfidence": "Puzzle confidence: {pct}%",
|
||||||
|
"costLabel": "Cost:",
|
||||||
|
"costConfidence": "Cost confidence: {pct}%",
|
||||||
|
"cyclesLabel": "Cycles:",
|
||||||
|
"cyclesConfidence": "Cycles confidence: {pct}%",
|
||||||
|
"areaLabel": "Area:",
|
||||||
|
"areaConfidence": "Area confidence: {pct}%",
|
||||||
|
"lowConfidence": "Low OCR Confidence",
|
||||||
|
"selectManually": "Please select the correct puzzle manually",
|
||||||
|
"extractPuzzleData": "Extract Puzzle Data",
|
||||||
|
"addMoreFiles": "Add More Files",
|
||||||
|
"invalidFileType": "{name} is not a valid image file",
|
||||||
|
"fileTooLarge": "{name} is too large (max 256MB)",
|
||||||
|
"processFailed": "Failed to process {name}"
|
||||||
|
},
|
||||||
|
"marketCard": {
|
||||||
|
"untilClose": "until close",
|
||||||
|
"closeMarket": "Close Market",
|
||||||
|
"resolveMarketButton": "Resolve Market",
|
||||||
|
"resolveModalTitle": "Resolve Market - Select Winner",
|
||||||
|
"resolveAndDistribute": "Resolve & Distribute Points",
|
||||||
|
"pool": "Pool: {n} pts",
|
||||||
|
"multiplierLabel": "Multiplier: {x}x",
|
||||||
|
"yourBet": "Your bet: {n} pts",
|
||||||
|
"potential": "Potential: {n} pts",
|
||||||
|
"increaseBetLabel": "Increase bet (current: {n} pts)",
|
||||||
|
"pointsToBet": "Points to bet",
|
||||||
|
"increaseByPlaceholder": "Enter amount to increase by",
|
||||||
|
"enterPointsPlaceholder": "Enter points",
|
||||||
|
"increaseBet": "Increase Bet",
|
||||||
|
"placeBet": "Place Bet",
|
||||||
|
"yourBetTitle": "Your Bet",
|
||||||
|
"optionLabel": "Option: {text}",
|
||||||
|
"amountLabel": "Amount: {n} pts",
|
||||||
|
"won": "✓ You Won!",
|
||||||
|
"lost": "✗ You Lost",
|
||||||
|
"resolved": "Resolved",
|
||||||
|
"winnerLabel": "Winner: {text}",
|
||||||
|
"errorClosing": "Error closing market",
|
||||||
|
"selectWinnerRequired": "Please select a winning option",
|
||||||
|
"errorResolving": "Error resolving market",
|
||||||
|
"failedToPlaceBet": "Failed to place bet",
|
||||||
|
"errorPlacingBet": "Error placing bet",
|
||||||
|
"ended": "Ended"
|
||||||
|
},
|
||||||
|
"puzzleCard": {
|
||||||
|
"byAuthor": "by {author}",
|
||||||
|
"moreTags": "+{n} more",
|
||||||
|
"viewOnSteam": "View on Steam Workshop",
|
||||||
|
"solutionsCount": "Solutions ({n})",
|
||||||
|
"needsManualValidation": "Needs manual validation",
|
||||||
|
"noSolutionsYet": "No solutions yet",
|
||||||
|
"uploadHint": "Upload solutions using the submit button"
|
||||||
|
},
|
||||||
|
"puzzleResults": {
|
||||||
|
"title": "Results by Puzzle",
|
||||||
|
"winningSolutions": "🏆 Winning Solutions",
|
||||||
|
"columns": {
|
||||||
|
"pos": "Pos",
|
||||||
|
"user": "User",
|
||||||
|
"totalPts": "Total Pts",
|
||||||
|
"totalCoef": "Total Coef",
|
||||||
|
"gif": "GIF"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"totalPoints": "Total Points",
|
||||||
|
"puzzlesSolved": "Puzzles Solved",
|
||||||
|
"generalResults": "General Results",
|
||||||
|
"clearCacheError": "Error clearing cache: {detail}",
|
||||||
|
"tabs": {
|
||||||
|
"overall": "Overall Ranking",
|
||||||
|
"byPuzzle": "By Puzzle"
|
||||||
|
},
|
||||||
|
"submissionsCount": "{n} submissions",
|
||||||
|
"podium": {
|
||||||
|
"first": "🏆 1st Place",
|
||||||
|
"second": "🥈 2nd Place",
|
||||||
|
"third": "🥉 3rd Place"
|
||||||
|
},
|
||||||
|
"totalWithCoef": "Total (with coef.)"
|
||||||
|
},
|
||||||
|
"topUsersLeaderboard": {
|
||||||
|
"title": "Top 10 Users"
|
||||||
|
},
|
||||||
|
"userBets": {
|
||||||
|
"empty": "You haven't placed any bets yet",
|
||||||
|
"totalBets": "Total Bets",
|
||||||
|
"active": "Active",
|
||||||
|
"waitingForResult": "Waiting for result",
|
||||||
|
"won": "Won",
|
||||||
|
"wonPointsLabel": "+{n} points",
|
||||||
|
"lost": "Lost",
|
||||||
|
"betterLuck": "Better luck next time",
|
||||||
|
"activeBets": "Active Bets ({n})",
|
||||||
|
"betOn": "Bet on: {option}",
|
||||||
|
"statusLabel": "Status:",
|
||||||
|
"wonBets": "Won Bets ({n})",
|
||||||
|
"correctBetOn": "Correct! You bet on: {option}",
|
||||||
|
"lostBets": "Lost Bets ({n})",
|
||||||
|
"youBetOn": "You bet on: {option}",
|
||||||
|
"winnerLabel": "Winner: {option}"
|
||||||
|
},
|
||||||
|
"winners": {
|
||||||
|
"title": "Top Submissions",
|
||||||
|
"fetchError": "Failed to fetch submissions",
|
||||||
|
"columns": {
|
||||||
|
"puzzle": "Puzzle",
|
||||||
|
"user": "User",
|
||||||
|
"totalPts": "Total Pts",
|
||||||
|
"totalCoef": "Total Coef",
|
||||||
|
"gif": "GIF"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submissionForm": {
|
||||||
|
"detectedPuzzles": "Detected Puzzles ({n})",
|
||||||
|
"manualSelectionRequired": "Manual Puzzle Selection Required",
|
||||||
|
"manualSelectionBody": "{n} file(s) have low OCR confidence for puzzle names. Please select the correct puzzle for each file before submitting.",
|
||||||
|
"retryOcr": "Retry OCR on low confidence puzzle",
|
||||||
|
"notesPlaceholder": "Add any notes about your solution, approach, or interesting findings...",
|
||||||
|
"requestManualValidation": "Request manual validation",
|
||||||
|
"manualValidationHint": "Check this if you want an admin to manually review your submission, even if OCR confidence is high.",
|
||||||
|
"manualValidationNote": "Note: This will be automatically checked if any OCR confidence is below 80%.",
|
||||||
|
"selectPuzzlesRemaining": "Select Puzzles ({n} remaining)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import Market from '@/Market.vue'
|
import Market from '@/Market.vue'
|
||||||
import { pinia } from '@/stores'
|
import { pinia } from '@/stores'
|
||||||
|
import { i18n } from '@/i18n'
|
||||||
import '@/style.css'
|
import '@/style.css'
|
||||||
|
|
||||||
const selector = "#app"
|
const selector = "#app"
|
||||||
const mountData = document.querySelector<HTMLElement>(selector)
|
const mountData = document.querySelector<HTMLElement>(selector)
|
||||||
const app = createApp(Market, { ...mountData?.dataset })
|
const app = createApp(Market, { ...mountData?.dataset })
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
app.use(i18n)
|
||||||
app.mount(selector)
|
app.mount(selector)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import Noita from '@/Noita.vue'
|
import Noita from '@/Noita.vue'
|
||||||
import { pinia } from '@/stores'
|
import { pinia } from '@/stores'
|
||||||
|
import { i18n } from '@/i18n'
|
||||||
import '@/style.css'
|
import '@/style.css'
|
||||||
|
|
||||||
const selector = "#app"
|
const selector = "#app"
|
||||||
const mountData = document.querySelector<HTMLElement>(selector)
|
const mountData = document.querySelector<HTMLElement>(selector)
|
||||||
const app = createApp(Noita, { ...mountData?.dataset })
|
const app = createApp(Noita, { ...mountData?.dataset })
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
app.use(i18n)
|
||||||
app.mount(selector)
|
app.mount(selector)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import OpusMagnum from '@/OpusMagnum.vue'
|
import OpusMagnum from '@/OpusMagnum.vue'
|
||||||
import { pinia } from '@/stores'
|
import { pinia } from '@/stores'
|
||||||
|
import { i18n } from '@/i18n'
|
||||||
import '@/style.css'
|
import '@/style.css'
|
||||||
|
|
||||||
// const app = createApp(App)
|
// const app = createApp(App)
|
||||||
@@ -8,4 +9,5 @@ const selector = "#app"
|
|||||||
const mountData = document.querySelector<HTMLElement>(selector)
|
const mountData = document.querySelector<HTMLElement>(selector)
|
||||||
const app = createApp(OpusMagnum, { ...mountData?.dataset })
|
const app = createApp(OpusMagnum, { ...mountData?.dataset })
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
app.use(i18n)
|
||||||
app.mount(selector)
|
app.mount(selector)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import Submissions from '@/Submissions.vue'
|
import Submissions from '@/Submissions.vue'
|
||||||
import { pinia } from '@/stores'
|
import { pinia } from '@/stores'
|
||||||
|
import { i18n } from '@/i18n'
|
||||||
import '@/style.css'
|
import '@/style.css'
|
||||||
|
|
||||||
const selector = "#app"
|
const selector = "#app"
|
||||||
const mountData = document.querySelector<HTMLElement>(selector)
|
const mountData = document.querySelector<HTMLElement>(selector)
|
||||||
const app = createApp(Submissions, { ...mountData?.dataset })
|
const app = createApp(Submissions, { ...mountData?.dataset })
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
app.use(i18n)
|
||||||
app.mount(selector)
|
app.mount(selector)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import SubmissionsSelect from '@/SubmissionsSelect.vue'
|
import SubmissionsSelect from '@/SubmissionsSelect.vue'
|
||||||
|
import { i18n } from '@/i18n'
|
||||||
import '@/style.css'
|
import '@/style.css'
|
||||||
|
|
||||||
const selector = "#app"
|
const selector = "#app"
|
||||||
@@ -7,4 +8,5 @@ const mountData = document.querySelector<HTMLElement>(selector)
|
|||||||
const scopesEl = document.getElementById('scopes-data')
|
const scopesEl = document.getElementById('scopes-data')
|
||||||
const scopes = scopesEl ? JSON.parse(scopesEl.textContent || '[]') : []
|
const scopes = scopesEl ? JSON.parse(scopesEl.textContent || '[]') : []
|
||||||
const app = createApp(SubmissionsSelect, { ...mountData?.dataset, scopes })
|
const app = createApp(SubmissionsSelect, { ...mountData?.dataset, scopes })
|
||||||
|
app.use(i18n)
|
||||||
app.mount(selector)
|
app.mount(selector)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class Match:
|
|||||||
rounds_count (int | Unset):
|
rounds_count (int | Unset):
|
||||||
use_points (bool | Unset):
|
use_points (bool | Unset):
|
||||||
points_are_positions (bool | Unset):
|
points_are_positions (bool | Unset):
|
||||||
multiround_modes (MatchMultiroundModes | Unset):
|
multiround_modes (MatchMultiroundModes | None | Unset):
|
||||||
multiround_modes_display (None | str | Unset):
|
multiround_modes_display (None | str | Unset):
|
||||||
creation_date (str | Unset): Example: 2026-08-01 10:00:00+00:00.
|
creation_date (str | Unset): Example: 2026-08-01 10:00:00+00:00.
|
||||||
playing_date (str | Unset):
|
playing_date (str | Unset):
|
||||||
@@ -49,7 +49,7 @@ class Match:
|
|||||||
rounds_count: int | Unset = UNSET
|
rounds_count: int | Unset = UNSET
|
||||||
use_points: bool | Unset = UNSET
|
use_points: bool | Unset = UNSET
|
||||||
points_are_positions: bool | Unset = UNSET
|
points_are_positions: bool | Unset = UNSET
|
||||||
multiround_modes: MatchMultiroundModes | Unset = UNSET
|
multiround_modes: MatchMultiroundModes | None | Unset = UNSET
|
||||||
multiround_modes_display: None | str | Unset = UNSET
|
multiround_modes_display: None | str | Unset = UNSET
|
||||||
creation_date: str | Unset = UNSET
|
creation_date: str | Unset = UNSET
|
||||||
playing_date: str | Unset = UNSET
|
playing_date: str | Unset = UNSET
|
||||||
@@ -78,9 +78,13 @@ class Match:
|
|||||||
|
|
||||||
points_are_positions = self.points_are_positions
|
points_are_positions = self.points_are_positions
|
||||||
|
|
||||||
multiround_modes: str | Unset = UNSET
|
multiround_modes: None | str | Unset
|
||||||
if not isinstance(self.multiround_modes, Unset):
|
if isinstance(self.multiround_modes, Unset):
|
||||||
|
multiround_modes = UNSET
|
||||||
|
elif isinstance(self.multiround_modes, MatchMultiroundModes):
|
||||||
multiround_modes = self.multiround_modes.value
|
multiround_modes = self.multiround_modes.value
|
||||||
|
else:
|
||||||
|
multiround_modes = self.multiround_modes
|
||||||
|
|
||||||
multiround_modes_display: None | str | Unset
|
multiround_modes_display: None | str | Unset
|
||||||
if isinstance(self.multiround_modes_display, Unset):
|
if isinstance(self.multiround_modes_display, Unset):
|
||||||
@@ -172,12 +176,24 @@ class Match:
|
|||||||
|
|
||||||
points_are_positions = d.pop("points_are_positions", UNSET)
|
points_are_positions = d.pop("points_are_positions", UNSET)
|
||||||
|
|
||||||
_multiround_modes = d.pop("multiround_modes", UNSET)
|
def _parse_multiround_modes(
|
||||||
multiround_modes: MatchMultiroundModes | Unset
|
data: object,
|
||||||
if isinstance(_multiround_modes, Unset):
|
) -> MatchMultiroundModes | None | Unset:
|
||||||
multiround_modes = UNSET
|
if data is None:
|
||||||
else:
|
return data
|
||||||
multiround_modes = MatchMultiroundModes(_multiround_modes)
|
if isinstance(data, Unset):
|
||||||
|
return data
|
||||||
|
try:
|
||||||
|
if not isinstance(data, str):
|
||||||
|
raise TypeError()
|
||||||
|
multiround_modes_type_1 = MatchMultiroundModes(data)
|
||||||
|
|
||||||
|
return multiround_modes_type_1
|
||||||
|
except (TypeError, ValueError, AttributeError, KeyError):
|
||||||
|
pass
|
||||||
|
return cast(MatchMultiroundModes | None | Unset, data)
|
||||||
|
|
||||||
|
multiround_modes = _parse_multiround_modes(d.pop("multiround_modes", UNSET))
|
||||||
|
|
||||||
def _parse_multiround_modes_display(data: object) -> None | str | Unset:
|
def _parse_multiround_modes_display(data: object) -> None | str | Unset:
|
||||||
if data is None:
|
if data is None:
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/MatchRoundResult"
|
$ref: "#/components/schemas/MatchRoundResult"
|
||||||
|
|
||||||
|
MatchMultiroundModes:
|
||||||
|
type: string
|
||||||
|
enum: [PA, PS, AP]
|
||||||
|
|
||||||
Match:
|
Match:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -146,9 +150,9 @@ components:
|
|||||||
points_are_positions:
|
points_are_positions:
|
||||||
type: boolean
|
type: boolean
|
||||||
multiround_modes:
|
multiround_modes:
|
||||||
type: string
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/MatchMultiroundModes"
|
||||||
nullable: true
|
nullable: true
|
||||||
enum: [PA, PS, AP]
|
|
||||||
multiround_modes_display:
|
multiround_modes_display:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ from games.models import EventGame
|
|||||||
from ..models import Scope
|
from ..models import Scope
|
||||||
from .aandt_client import AuthenticatedClient
|
from .aandt_client import AuthenticatedClient
|
||||||
from .aandt_client.api.default import (
|
from .aandt_client.api.default import (
|
||||||
|
aandt_api_infos,
|
||||||
aandt_api_match_messages_set,
|
aandt_api_match_messages_set,
|
||||||
aandt_api_matches,
|
aandt_api_matches,
|
||||||
aandt_api_members,
|
aandt_api_members,
|
||||||
)
|
)
|
||||||
from .aandt_client.models import (
|
from .aandt_client.models import (
|
||||||
|
AandtApiInfosResponse200,
|
||||||
AandtApiMatchesResponse200,
|
AandtApiMatchesResponse200,
|
||||||
AandtApiMatchMessagesSetBody,
|
AandtApiMatchMessagesSetBody,
|
||||||
AandtApiMembersResponse200,
|
AandtApiMembersResponse200,
|
||||||
@@ -33,6 +35,11 @@ from .aandt_client.types import Unset
|
|||||||
# uvx openapi-python-client generate --path aandt_openapi.yaml --meta none \
|
# uvx openapi-python-client generate --path aandt_openapi.yaml --meta none \
|
||||||
# --output-path aandt_client --overwrite
|
# --output-path aandt_client --overwrite
|
||||||
|
|
||||||
|
# AAndTInfosStatus value for "matches in progress" (see the / endpoint). Compared
|
||||||
|
# as a plain string rather than the generated enum, whose member names
|
||||||
|
# (VALUE_0, VALUE_1, ...) aren't stable across regenerations.
|
||||||
|
AANDT_STATUS_IN_PROGRESS = "7inprog"
|
||||||
|
|
||||||
|
|
||||||
class ScopeSyncError(Exception):
|
class ScopeSyncError(Exception):
|
||||||
"""Raised when an EventGame cannot be synced against the AAndT API."""
|
"""Raised when an EventGame cannot be synced against the AAndT API."""
|
||||||
@@ -43,6 +50,7 @@ class ScopeSyncResult:
|
|||||||
created: int = 0
|
created: int = 0
|
||||||
updated: int = 0
|
updated: int = 0
|
||||||
warnings: list[str] = field(default_factory=list)
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
_T = TypeVar("_T")
|
||||||
@@ -109,11 +117,16 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
|
|||||||
submission page's URL pushed back as the match's player-facing message, so
|
submission page's URL pushed back as the match's player-facing message, so
|
||||||
participants browsing the legacy site get a direct link here.
|
participants browsing the legacy site get a direct link here.
|
||||||
|
|
||||||
|
Scopes whose match is no longer returned by the API (deleted, or from a
|
||||||
|
previous AAndT run reusing the same token) are deactivated.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event_game: The EventGame to sync; must have `apibot_token` set.
|
event_game: The EventGame to sync; must have `apibot_token` set.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A summary of created/updated scopes and any unresolved participants.
|
A summary of created/updated scopes and any unresolved participants,
|
||||||
|
plus the AAndT's current status (see `AANDT_STATUS_IN_PROGRESS`), so
|
||||||
|
callers can decide whether to keep polling.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ScopeSyncError: When the EventGame has no token, or the API call fails.
|
ScopeSyncError: When the EventGame has no token, or the API call fails.
|
||||||
@@ -124,6 +137,7 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
|
|||||||
client = _client(event_game)
|
client = _client(event_game)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
infos_response = aandt_api_infos.sync_detailed(client=client)
|
||||||
members_response = aandt_api_members.sync_detailed(client=client)
|
members_response = aandt_api_members.sync_detailed(client=client)
|
||||||
matches_response = aandt_api_matches.sync_detailed(client=client)
|
matches_response = aandt_api_matches.sync_detailed(client=client)
|
||||||
except (httpx.HTTPError, ValueError) as exc:
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
@@ -134,32 +148,52 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
|
|||||||
# resync this copy and regenerate.
|
# resync this copy and regenerate.
|
||||||
raise ScopeSyncError(f"Failed to reach AAndT API: {exc}") from exc
|
raise ScopeSyncError(f"Failed to reach AAndT API: {exc}") from exc
|
||||||
|
|
||||||
|
infos_parsed = infos_response.parsed
|
||||||
members_parsed = members_response.parsed
|
members_parsed = members_response.parsed
|
||||||
matches_parsed = matches_response.parsed
|
matches_parsed = matches_response.parsed
|
||||||
|
|
||||||
if not isinstance(members_parsed, AandtApiMembersResponse200) or not isinstance(
|
if (
|
||||||
matches_parsed, AandtApiMatchesResponse200
|
not isinstance(infos_parsed, AandtApiInfosResponse200)
|
||||||
|
or not isinstance(members_parsed, AandtApiMembersResponse200)
|
||||||
|
or not isinstance(matches_parsed, AandtApiMatchesResponse200)
|
||||||
):
|
):
|
||||||
raise ScopeSyncError(
|
raise ScopeSyncError(
|
||||||
"AAndT API returned "
|
"AAndT API returned "
|
||||||
f"{members_response.status_code.value}/{matches_response.status_code.value}"
|
f"{infos_response.status_code.value}/{members_response.status_code.value}"
|
||||||
|
f"/{matches_response.status_code.value}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
aandt_infos = _unwrap(infos_parsed.aandt)
|
||||||
|
status = _unwrap(aandt_infos.status) if aandt_infos is not None else None
|
||||||
|
|
||||||
members = _unwrap(members_parsed.members, [])
|
members = _unwrap(members_parsed.members, [])
|
||||||
matches = _unwrap(matches_parsed.matches, [])
|
matches = _unwrap(matches_parsed.matches, [])
|
||||||
|
|
||||||
userids_by_member_id = _userids_by_member_id(members)
|
userids_by_member_id = _userids_by_member_id(members)
|
||||||
result = ScopeSyncResult()
|
result = ScopeSyncResult(status=status.value if status is not None else None)
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
synced_match_ids: set[int] = set()
|
||||||
|
|
||||||
for match in matches:
|
for match in matches:
|
||||||
created, warnings = _sync_match(event_game, client, match, userids_by_member_id, now)
|
match_id = _unwrap(match.id)
|
||||||
|
created, warnings = _sync_match(
|
||||||
|
event_game, client, match, userids_by_member_id, now
|
||||||
|
)
|
||||||
if created is None:
|
if created is None:
|
||||||
continue
|
continue
|
||||||
|
synced_match_ids.add(match_id)
|
||||||
result.created += created
|
result.created += created
|
||||||
result.updated += not created
|
result.updated += not created
|
||||||
result.warnings.extend(warnings)
|
result.warnings.extend(warnings)
|
||||||
|
|
||||||
|
(
|
||||||
|
Scope.objects.filter(
|
||||||
|
event_game=event_game, is_active=True, external_id__isnull=False
|
||||||
|
)
|
||||||
|
.exclude(external_id__in=synced_match_ids)
|
||||||
|
.update(is_active=False)
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -210,11 +244,13 @@ def _sync_match(
|
|||||||
return created, warnings
|
return created, warnings
|
||||||
|
|
||||||
|
|
||||||
def _push_submission_link(client: AuthenticatedClient, match_id: int, scope: Scope) -> list[str]:
|
def _push_submission_link(
|
||||||
|
client: AuthenticatedClient, match_id: int, scope: Scope
|
||||||
|
) -> list[str]:
|
||||||
body = AandtApiMatchMessagesSetBody(
|
body = AandtApiMatchMessagesSetBody(
|
||||||
players=(
|
players=(
|
||||||
"<h3>Submit your result</h3>"
|
"<h2>Submit your result</h2>"
|
||||||
f'<a href="{_submission_url(scope)}" target="_blank" rel="noopener">'
|
f'<a href="{_submission_url(scope)}" target="_blank" rel="noopener" class="btn btn-info">'
|
||||||
"Submit your proof here</a>"
|
"Submit your proof here</a>"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.tasks import task
|
from django.tasks import task
|
||||||
|
from django.utils import timezone
|
||||||
from games.models import EventGame
|
from games.models import EventGame
|
||||||
from submissions.models import ImageCrop, SubmissionFile, OCRStatus
|
from submissions.models import ImageCrop, SubmissionFile, OCRStatus
|
||||||
from submissions.services.aandt_sync import sync_event_game_scopes
|
from submissions.services.aandt_sync import (
|
||||||
|
AANDT_STATUS_IN_PROGRESS,
|
||||||
|
sync_event_game_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import pytesseract
|
import pytesseract
|
||||||
|
|
||||||
|
# How often to repoll an EventGame's AAndT while it's still "in progress".
|
||||||
|
SCOPE_SYNC_REPOLL_INTERVAL = timedelta(seconds=15)
|
||||||
|
|
||||||
|
|
||||||
def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
|
def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
|
||||||
if obj.ocr_status != OCRStatus.TO_PROCESS:
|
if obj.ocr_status != OCRStatus.TO_PROCESS:
|
||||||
@@ -86,9 +95,21 @@ def sync_all_event_games_task():
|
|||||||
|
|
||||||
@task
|
@task
|
||||||
def sync_event_game_scopes_task(event_game_id: int):
|
def sync_event_game_scopes_task(event_game_id: int):
|
||||||
|
"""Sync an EventGame's Scopes, repolling every minute while matches are in progress.
|
||||||
|
|
||||||
|
Each run re-checks the AAndT's status (from the `/` endpoint). While it's
|
||||||
|
still "in progress" (`7inprog`), the task re-enqueues itself with a delay
|
||||||
|
instead of relying on an external scheduler; once the status moves on, it
|
||||||
|
simply doesn't reschedule and the loop stops.
|
||||||
|
"""
|
||||||
event_game = EventGame.objects.filter(id=event_game_id).first()
|
event_game = EventGame.objects.filter(id=event_game_id).first()
|
||||||
|
|
||||||
if not event_game:
|
if not event_game:
|
||||||
return
|
return
|
||||||
|
|
||||||
sync_event_game_scopes(event_game)
|
result = sync_event_game_scopes(event_game)
|
||||||
|
|
||||||
|
if result.status == AANDT_STATUS_IN_PROGRESS:
|
||||||
|
sync_event_game_scopes_task.using(
|
||||||
|
run_after=timezone.now() + SCOPE_SYNC_REPOLL_INTERVAL
|
||||||
|
).enqueue(event_game_id)
|
||||||
|
|||||||
@@ -14,12 +14,17 @@ def submission_home(request: HttpRequest, event_path: str, game_path: str):
|
|||||||
game = get_object_or_404(Game, path=game_path)
|
game = get_object_or_404(Game, path=game_path)
|
||||||
|
|
||||||
scopes = (
|
scopes = (
|
||||||
Scope.objects.filter(event_game__event=event, event_game__game=game)
|
Scope.objects.filter(
|
||||||
|
event_game__event=event,
|
||||||
|
event_game__game=game,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
if request.user.is_superuser
|
if request.user.is_superuser
|
||||||
else Scope.objects.filter(
|
else Scope.objects.filter(
|
||||||
event_game__event=event,
|
event_game__event=event,
|
||||||
event_game__game=game,
|
event_game__game=game,
|
||||||
assigned_users=request.user,
|
assigned_users=request.user,
|
||||||
|
is_active=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
{% load django_vite %}
|
{% load django_vite %}
|
||||||
|
|
||||||
{% block title %}Page Not Found{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% vite_asset 'src/error.ts' %}
|
{% vite_asset 'src/error.ts' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="app" data-code="404" data-title="Page not found" data-message="The page you're looking for doesn't exist or has been moved."></div>
|
<div id="app" data-code="404"></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
{% load django_vite %}
|
{% load django_vite %}
|
||||||
|
|
||||||
{% block title %}Server Error{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% vite_asset 'src/error.ts' %}
|
{% vite_asset 'src/error.ts' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="app" data-code="500" data-title="Server error" data-message="Something went wrong on our end. Please try again later."></div>
|
<div id="app" data-code="500"></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
{% load django_vite %}
|
{% load django_vite %}
|
||||||
<html lang="en" data-theme="dim">
|
<html lang="{{ LANGUAGE_CODE }}" data-theme="dim">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{% block title %}{% endblock %}</title>
|
<title>{% block title %}PolyLAN Submitter{% endblock %}</title>
|
||||||
{% if debug %}{% vite_hmr_client %}{% endif %}
|
{% if debug %}{% vite_hmr_client %}{% endif %}
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
{% load django_vite %}
|
{% load django_vite %}
|
||||||
|
|
||||||
{% block title %}Opus Magnum Puzzle Submitter{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% vite_asset 'src/home.ts' %}
|
{% vite_asset 'src/home.ts' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
{% load django_vite %}
|
{% load django_vite %}
|
||||||
|
|
||||||
{% block title %}Market - PolyLAN Submitter{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% vite_asset 'src/market.ts' %}
|
{% vite_asset 'src/market.ts' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
{% load django_vite %}
|
{% load django_vite %}
|
||||||
|
|
||||||
{% block title %}Noita Submitter{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% vite_asset 'src/noita.ts' %}
|
{% vite_asset 'src/noita.ts' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
{% load django_vite %}
|
{% load django_vite %}
|
||||||
|
|
||||||
{% block title %}Opus Magnum Submitter{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% vite_asset 'src/opus-magnum.ts' %}
|
{% vite_asset 'src/opus-magnum.ts' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
{% load django_vite %}
|
{% load django_vite %}
|
||||||
|
|
||||||
{% block title %}{{ scope.name }} Submissions{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% vite_asset 'src/submissions.ts' %}
|
{% vite_asset 'src/submissions.ts' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
{% load django_vite %}
|
{% load django_vite %}
|
||||||
|
|
||||||
{% block title %}Select a match{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% vite_asset 'src/submissions_select.ts' %}
|
{% vite_asset 'src/submissions_select.ts' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ dependencies = [
|
|||||||
"django-rq",
|
"django-rq",
|
||||||
"django-tasks-rq",
|
"django-tasks-rq",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
"django-object-actions>=5.1.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -677,6 +677,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-object-actions"
|
||||||
|
version = "5.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c4/2f/99e8fa3d117becebfbe46d54d0b85d81475b6375b85507907a7aaf1d5d2b/django_object_actions-5.1.2.tar.gz", hash = "sha256:a11ac544e5beb6b309a50e184a2086e1e18f6469704d9609bbe083a8972a2245", size = 19048, upload-time = "2026-05-28T05:48:29.88Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/23/c5743780fc68433f244fba86cd64b513b9d6a9491714ac700a4654b5e00f/django_object_actions-5.1.2-py3-none-any.whl", hash = "sha256:366dcd5ccfb3ef6a16ce4c147be8df2a35b52d637eb36fb9c558e28419ae4038", size = 16900, upload-time = "2026-05-28T05:48:28.56Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-redis"
|
name = "django-redis"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -1626,6 +1635,7 @@ dependencies = [
|
|||||||
{ name = "django", version = "5.2.14", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
{ name = "django", version = "5.2.14", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||||
{ name = "django", version = "6.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
{ name = "django", version = "6.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||||
{ name = "django-channels" },
|
{ name = "django-channels" },
|
||||||
|
{ name = "django-object-actions" },
|
||||||
{ name = "django-redis" },
|
{ name = "django-redis" },
|
||||||
{ name = "django-rq" },
|
{ name = "django-rq" },
|
||||||
{ name = "django-shinobi" },
|
{ name = "django-shinobi" },
|
||||||
@@ -1666,6 +1676,7 @@ requires-dist = [
|
|||||||
{ name = "daphne" },
|
{ name = "daphne" },
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
{ name = "django-channels" },
|
{ name = "django-channels" },
|
||||||
|
{ name = "django-object-actions", specifier = ">=5.1.2" },
|
||||||
{ name = "django-redis" },
|
{ name = "django-redis" },
|
||||||
{ name = "django-rq" },
|
{ name = "django-rq" },
|
||||||
{ name = "django-shinobi" },
|
{ name = "django-shinobi" },
|
||||||
|
|||||||
Reference in New Issue
Block a user