Compare commits

...
3 Commits
Author SHA1 Message Date
Legrems 13b14abb12 chore: add i18n 2026-09-16 22:41:59 +02:00
Legrems f68c4e50e8 feat: use django-objects-actions 2026-09-16 19:07:40 +02:00
Legrems 4392122cfc fix: polylan sync 2026-09-16 18:42:37 +02:00
50 changed files with 1075 additions and 454 deletions
+12 -2
View File
@@ -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()
+13 -5
View File
@@ -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):
+9 -1
View File
@@ -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)
+15 -10
View File
@@ -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):
+2 -1
View File
@@ -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",
+56
View File
@@ -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/"
+20 -7
View File
@@ -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>
+13 -8
View File
@@ -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>
+19 -14
View File
@@ -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">
+45 -40
View File
@@ -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>
+21 -16
View File
@@ -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>
+79 -78
View File
@@ -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 &mdash; {{ 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>
+10 -3
View File
@@ -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>
+35 -33
View File
@@ -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;
} }
+28 -25
View File
@@ -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;
} }
+31 -29
View File
@@ -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>
+18 -16
View File
@@ -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>
+43 -41
View File
@@ -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>
+22 -20
View File
@@ -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>
+16 -14
View File
@@ -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>
+2
View File
@@ -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)
+2
View File
@@ -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)
+9
View File
@@ -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 },
})
+366
View File
@@ -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)"
}
}
}
+2
View File
@@ -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)
+2
View File
@@ -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)
+2
View File
@@ -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)
+2
View File
@@ -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>"
), ),
) )
+23 -2
View File
@@ -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)
+6 -1
View File
@@ -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,
) )
) )
+1 -3
View File
@@ -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 %}
+1 -3
View File
@@ -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 %}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 %}
+1
View File
@@ -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]
Generated
+11
View File
@@ -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" },