Compare commits

...
2 Commits
Author SHA1 Message Date
Legrems 99a868a6be feat: switch to spa 2026-09-17 00:37:28 +02:00
Legrems 15590e5162 feat: "spa", but more like single entry-point 2026-09-16 23:25:16 +02:00
40 changed files with 491 additions and 369 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ dist-ssr
# Vite build output (generated locally via `pnpm build`, served by whitenoise) # Vite build output (generated locally via `pnpm build`, served by whitenoise)
static_source/vite/assets static_source/vite/assets
static_source/vite/manifest.json static_source/vite/manifest.json
static_source/vite/vite.svg static_source/vite/icon.png
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
# vue-tsc emits a compiled copy of vite.config.ts here (tsconfig.node.json, # vue-tsc emits a compiled copy of vite.config.ts here (tsconfig.node.json,
+2 -2
View File
@@ -7,5 +7,5 @@ from events.models import Event
@login_required @login_required
def market_home(request: HttpRequest, event_path: str): def market_home(request: HttpRequest, event_path: str):
event = get_object_or_404(Event, path=event_path) get_object_or_404(Event, path=event_path)
return render(request, "market.html", {"event": event}) return render(request, "app.html")
+2 -1
View File
@@ -20,7 +20,8 @@
"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" "vue-i18n": "^11.4.12",
"vue-router": "4"
}, },
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "^0.97.2", "@hey-api/openapi-ts": "^0.97.2",
+13
View File
@@ -38,6 +38,9 @@ importers:
vue-i18n: vue-i18n:
specifier: ^11.4.12 specifier: ^11.4.12
version: 11.4.12(vue@3.5.22(typescript@5.9.3)) version: 11.4.12(vue@3.5.22(typescript@5.9.3))
vue-router:
specifier: '4'
version: 4.6.4(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
@@ -1075,6 +1078,11 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.0.0 vue: ^3.0.0
vue-router@4.6.4:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies:
vue: ^3.5.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
@@ -1962,6 +1970,11 @@ snapshots:
'@vue/devtools-api': 6.6.4 '@vue/devtools-api': 6.6.4
vue: 3.5.22(typescript@5.9.3) vue: 3.5.22(typescript@5.9.3)
vue-router@4.6.4(vue@3.5.22(typescript@5.9.3)):
dependencies:
'@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
+12 -1
View File
@@ -1,4 +1,4 @@
from ninja import NinjaAPI from ninja import NinjaAPI, Schema
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.http import HttpRequest from django.http import HttpRequest
@@ -62,6 +62,17 @@ def health_check(request):
return {"status": "healthy", "service": "polylan-submitter-api"} return {"status": "healthy", "service": "polylan-submitter-api"}
class ConfigOut(Schema):
market_enabled: bool
# Site-wide config the SPA needs but that isn't tied to any one event
# (e.g. whether to render the market card at all on the home page).
@api.get("/config", response=ConfigOut)
def get_config(request):
return {"market_enabled": settings.MARKET_ENABLED}
# Cache management endpoint # Cache management endpoint
@api.post("/cache/clear") @api.post("/cache/clear")
def clear_cache(request: HttpRequest, event_path: str | None = None): def clear_cache(request: HttpRequest, event_path: str | None = None):
+6 -26
View File
@@ -43,42 +43,22 @@ def home(request: HttpRequest):
@login_required @login_required
def event_home(request: HttpRequest, event_path: str): def event_home(request: HttpRequest, event_path: str):
event = get_object_or_404(Event, path=event_path) get_object_or_404(Event, path=event_path)
return render(request, "app.html")
return render(
request,
"home.html",
{
"event": event,
"market_enabled": settings.MARKET_ENABLED,
},
)
@login_required @login_required
@require_game_enabled(OPUS_MAGNUM_APP_ID) @require_game_enabled(OPUS_MAGNUM_APP_ID)
def opus_magnum_home(request: HttpRequest, event_path: str): def opus_magnum_home(request: HttpRequest, event_path: str):
from opus_magnum.models import SteamCollection get_object_or_404(Event, path=event_path)
return render(request, "app.html")
event = get_object_or_404(Event, path=event_path)
return render(
request,
"opus-magnum.html",
{
"event": event,
"collection": SteamCollection.objects.filter(
is_active=True, event=event
).last(),
},
)
@login_required @login_required
@require_game_enabled(NOITA_APP_ID) @require_game_enabled(NOITA_APP_ID)
def noita_home(request: HttpRequest, event_path: str): def noita_home(request: HttpRequest, event_path: str):
event = get_object_or_404(Event, path=event_path) get_object_or_404(Event, path=event_path)
return render(request, "noita.html", {"event": event}) return render(request, "app.html")
urlpatterns = [ urlpatterns = [
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { RouterView } from "vue-router";
import AppHeader from "@/layout/AppHeader.vue";
import AppFooter from "@/layout/AppFooter.vue";
import { useUserStore } from "@/stores/user";
const userStore = useUserStore();
onMounted(() => {
userStore.loadUserData();
});
</script>
<template>
<div class="min-h-screen flex flex-col">
<AppHeader />
<main class="flex-1 flex flex-col">
<RouterView />
</main>
<AppFooter />
</div>
</template>
+23 -22
View File
@@ -1,25 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from "vue"; import { ref, onMounted, computed } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { gamesApiListEventGames } from "./api"; import { gamesApiListEventGames, polylanSubmitterApiGetConfig } from "./api";
import type { GamesApiListEventGamesResponse } from "./api/types.gen"; import type { GamesApiListEventGamesResponse } from "./api/types.gen";
interface Props { interface Props {
eventPath: string; eventPath: string;
marketEnabled?: string | boolean;
} }
const props = withDefaults(defineProps<Props>(), { const props = defineProps<Props>();
marketEnabled: true,
});
const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
useTitle(computed(() => t("home.title"))); 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());
const marketEnabled = ref(false);
const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => { const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => {
if (game.image) { if (game.image) {
@@ -35,31 +35,32 @@ const onImageError = (gameId: number) => {
imageErrors.value.add(gameId); imageErrors.value.add(gameId);
}; };
const navigate = (path: string) => { const goToMarket = () => {
window.location.href = `/${props.eventPath}${path}`; router.push(`/${props.eventPath}/market`);
}; };
const isMarketEnabled = () => { const goToGame = (game: GamesApiListEventGamesResponse[number]) => {
if (typeof props.marketEnabled === 'string') { const path = game.custom_path ? `/${game.path}` : `/submissions/${game.path}`;
return props.marketEnabled === 'true'; router.push(`/${props.eventPath}${path}`);
}
return Boolean(props.marketEnabled);
}; };
onMounted(async () => { onMounted(async () => {
// Fetch games enabled for this event const [gamesResponse, configResponse] = await Promise.all([
const response = await gamesApiListEventGames({ gamesApiListEventGames({ path: { event_path: props.eventPath } }),
path: { event_path: props.eventPath }, polylanSubmitterApiGetConfig(),
}); ]);
if (response.data) { if (gamesResponse.data) {
games.value = response.data; games.value = gamesResponse.data;
}
if (configResponse.data) {
marketEnabled.value = configResponse.data.market_enabled;
} }
loading.value = false; loading.value = false;
}); });
</script> </script>
<template> <template>
<div class="min-h-screen bg-base-300 flex items-center justify-center px-4"> <div class="flex-1 bg-base-300 flex items-center justify-center px-4">
<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">
@@ -77,7 +78,7 @@ onMounted(async () => {
<!-- Cards Grid --> <!-- Cards Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8"> <div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Market Card --> <!-- Market Card -->
<div v-if="isMarketEnabled()" @click="navigate('/market')" <div v-if="marketEnabled" @click="goToMarket"
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden"> class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
<figure class="relative h-60 bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center"> <figure class="relative h-60 bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
<i class="mdi mdi-chart-box text-6xl text-white opacity-80"></i> <i class="mdi mdi-chart-box text-6xl text-white opacity-80"></i>
@@ -97,7 +98,7 @@ onMounted(async () => {
<!-- Game Cards --> <!-- Game Cards -->
<div v-for="game in games" :key="game.id" <div v-for="game in games" :key="game.id"
@click="navigate(game.custom_path ? `/${game.path}` : `/submissions/${game.path}`)" @click="goToGame(game)"
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden"> class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
<figure class="relative h-60 bg-base-300 overflow-hidden"> <figure class="relative h-60 bg-base-300 overflow-hidden">
<img v-if="getHeaderImage(game) && !imageErrors.has(game.id)" :src="getHeaderImage(game)!" :alt="game.name" <img v-if="getHeaderImage(game) && !imageErrors.has(game.id)" :src="getHeaderImage(game)!" :alt="game.name"
@@ -111,7 +112,7 @@ onMounted(async () => {
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl">{{ game.name }}</h2> <h2 class="card-title text-2xl">{{ game.name }}</h2>
<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 class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i> <i class="mdi mdi-arrow-right mr-2"></i>
{{ t("home.submitResults") }} {{ t("home.submitResults") }}
</button> </button>
+1 -28
View File
@@ -21,10 +21,6 @@ useTitle(computed(() => t("market.pageTitle")));
const marketStore = useMarketStore(); const marketStore = useMarketStore();
const { markets, userInfo, isLoading } = storeToRefs(marketStore); const { markets, userInfo, isLoading } = storeToRefs(marketStore);
const goHome = () => {
window.location.href = `/${props.eventPath}/`;
};
const reloadPage = async () => { const reloadPage = async () => {
await marketStore.refreshPage(props.eventPath); await marketStore.refreshPage(props.eventPath);
}; };
@@ -35,30 +31,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="min-h-screen bg-base-200"> <div class="flex-1 bg-base-200">
<!-- Header -->
<div class="navbar bg-base-100 shadow-lg">
<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">
<i class="mdi mdi-arrow-left"></i>
{{ t("common.back") }}
</button>
<h1 class="text-xl font-bold">{{ t("market.header") }}</h1>
<div class="flex-1"></div>
<div class="flex items-center gap-4">
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
<div class="text-sm">
<span class="font-medium">{{ userInfo.username }}</span>
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">{{ t("common.admin") }}</span>
</div>
</div>
<div v-else class="text-sm text-base-content/70">{{ t("common.notLoggedIn") }}</div>
<a href="/api/docs" class="btn btn-xs">{{ t("common.apiDocs") }}</a>
<a href="/admin" class="btn btn-xs btn-warning">{{ t("common.adminPanel") }}</a>
</div>
</div>
</div>
<!-- Main Content --> <!-- Main Content -->
<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 -->
+1 -19
View File
@@ -155,10 +155,6 @@ const submitRun = async () => {
} }
}; };
const goHome = () => {
window.location.href = `/${props.eventPath}/`;
};
const clearCache = async () => { const clearCache = async () => {
try { try {
await noitaStore.clearCache(props.eventPath); await noitaStore.clearCache(props.eventPath);
@@ -175,21 +171,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="min-h-screen bg-base-200"> <div class="flex-1 bg-base-200">
<!-- Header -->
<div class="navbar bg-base-100 shadow-lg">
<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">
<i class="mdi mdi-arrow-left"></i>
{{ t("common.back") }}
</button>
<h1 class="text-xl font-bold">{{ t("noita.header") }}</h1>
<div class="flex-1"></div>
<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">{{ t("common.adminPanel") }}</a>
</div>
</div>
<!-- Main Content --> <!-- Main Content -->
<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">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
+3 -33
View File
@@ -20,9 +20,6 @@ import { storeToRefs } from "pinia";
const props = defineProps<{ const props = defineProps<{
eventPath: string; eventPath: string;
collectionTitle: string;
collectionUrl: string;
collectionDescription: string;
}>(); }>();
provideEventPath(props.eventPath); provideEventPath(props.eventPath);
@@ -139,37 +136,10 @@ const findPuzzleByName = (ocrPuzzleName: string) => {
const reloadPage = () => { const reloadPage = () => {
window.location.reload(); window.location.reload();
}; };
const goHome = () => {
window.location.href = `/${props.eventPath}/`;
};
</script> </script>
<template> <template>
<div class="min-h-screen bg-base-200"> <div class="flex-1 bg-base-200">
<!-- Header -->
<div class="navbar bg-base-100 shadow-lg">
<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">
<i class="mdi mdi-arrow-left"></i>
{{ t("common.back") }}
</button>
<h1 class="text-xl font-bold">{{ t("opusMagnum.header") }}</h1>
<div class="flex-1"></div>
<div class="flex items-center gap-4">
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
<div class="text-sm">
<span class="font-medium">{{ userInfo.username }}</span>
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">{{ t("common.admin") }}</span>
</div>
</div>
<div v-else class="text-sm text-base-content/70">{{ t("common.notLoggedIn") }}</div>
<a href="/api/docs" class="btn btn-xs">{{ t("common.apiDocs") }}</a>
<a href="/admin" class="btn btn-xs btn-warning">{{ t("common.adminPanel") }}</a>
</div>
</div>
</div>
<!-- Main Content --> <!-- Main Content -->
<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">
<!-- Loading State --> <!-- Loading State -->
@@ -218,9 +188,9 @@ const goHome = () => {
<div class="mb-8"> <div class="mb-8">
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-lg">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2> <h2 class="card-title text-2xl">{{ collection?.title }}</h2>
<p class="text-base-content/70"> <p class="text-base-content/70">
{{ props.collectionDescription }} {{ collection?.description }}
</p> </p>
<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">
+52 -47
View File
@@ -1,35 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from "vue"; import { ref, onMounted, onUnmounted, computed } from "vue";
import { useRouter } from "vue-router";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; 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 { usePageHeaderStore } from "@/stores/pageHeader";
import { gamesApiListEventGames, submissionsApiListAvailableScopes, submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api";
import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket"; import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket";
import { provideEventPath } from "@/composables/useEventPath"; import { provideEventPath } from "@/composables/useEventPath";
interface Props { interface Props {
eventPath: string; eventPath: string;
gamePath: string;
scopeId: string; scopeId: string;
scopeName?: string;
gameName?: string;
gamePath?: string;
gameImage?: string;
gameSteamId?: string;
} }
const props = withDefaults(defineProps<Props>(), { const props = defineProps<Props>();
scopeName: "Match",
gameName: "Game",
gamePath: "",
gameImage: "",
gameSteamId: "",
});
provideEventPath(props.eventPath); provideEventPath(props.eventPath);
const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
useTitle(computed(() => t("submissions.pageTitle", { game: props.gameName, scope: props.scopeName }))); const pageHeaderStore = usePageHeaderStore();
const scopeName = ref("Match");
const gameName = ref("Game");
const gameImage = ref("");
const gameSteamId = ref("");
useTitle(computed(() => t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value })));
const userStore = useUserStore(); const userStore = useUserStore();
const { userInfo } = storeToRefs(userStore); const { userInfo } = storeToRefs(userStore);
@@ -333,11 +333,11 @@ const processCropOcr = async (cropId: string) => {
}; };
const getGameHeaderImage = () => { const getGameHeaderImage = () => {
if (props.gameImage) { if (gameImage.value) {
return props.gameImage; return gameImage.value;
} }
if (props.gameSteamId) { if (gameSteamId.value) {
return `https://cdn.akamai.steamstatic.com/steam/apps/${props.gameSteamId}/header.jpg`; return `https://cdn.akamai.steamstatic.com/steam/apps/${gameSteamId.value}/header.jpg`;
} }
return null; return null;
}; };
@@ -430,10 +430,6 @@ const submitSubmission = async () => {
} }
}; };
const goHome = () => {
window.location.href = `/${props.eventPath}/submissions/${props.gamePath}`;
};
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes"; if (bytes === 0) return "0 Bytes";
const k = 1024; const k = 1024;
@@ -506,10 +502,34 @@ const handlePasteOnUploadArea = (event: ClipboardEvent) => {
} }
}; };
onMounted(() => { onMounted(async () => {
userStore.loadUserData(); userStore.loadUserData();
loadUserSubmissions(); loadUserSubmissions();
const [gamesResponse, scopesResponse] = await Promise.all([
gamesApiListEventGames({ path: { event_path: props.eventPath } }),
submissionsApiListAvailableScopes({
path: { event_path: props.eventPath, game_path: props.gamePath },
}),
]);
const game = gamesResponse.data?.find((g) => g.path === props.gamePath);
if (game) {
gameName.value = game.name;
gameImage.value = game.image ?? "";
gameSteamId.value = game.steam_app_id ? String(game.steam_app_id) : "";
}
const scope = scopesResponse.data?.find((s) => s.id === props.scopeId);
if (!scope) {
// Not in the list of scopes this user may access - bounce back to
// the picker rather than rendering a page for data we can't show.
router.replace({ name: "submissions-select", params: { eventPath: props.eventPath, gamePath: props.gamePath } });
return;
}
scopeName.value = scope.name;
pageHeaderStore.setTitle(t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value }));
// Add paste listener (only processes if hovering upload area) // Add paste listener (only processes if hovering upload area)
window.addEventListener('paste', handlePasteOnUploadArea); window.addEventListener('paste', handlePasteOnUploadArea);
}); });
@@ -520,32 +540,17 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="min-h-screen bg-base-200"> <div class="flex-1 bg-base-200">
<!-- Header -->
<div class="navbar bg-base-100 shadow-lg">
<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">
<i class="mdi mdi-arrow-left"></i>
{{ t("common.back") }}
</button>
<h1 class="text-xl font-bold">{{ t("submissions.pageTitle", { game: gameName, scope: scopeName }) }}</h1>
<div class="flex-1"></div>
<!-- WebSocket Status Indicator -->
<div class="flex items-center gap-2">
<div :class="['badge', wsConnected ? 'badge-success' : 'badge-warning']">
<i :class="['mdi mr-1', wsConnected ? 'mdi-wifi' : 'mdi-wifi-off']"></i>
{{ wsConnected ? t("submissions.live") : t("submissions.offline") }}
</div>
</div>
<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">{{ t("common.adminPanel") }}</a>
</div>
</div>
<!-- Main Content --> <!-- Main Content -->
<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">
<!-- WebSocket Status Indicator -->
<div class="flex justify-end mb-4">
<div :class="['badge', wsConnected ? 'badge-success' : 'badge-warning']">
<i :class="['mdi mr-1', wsConnected ? 'mdi-wifi' : 'mdi-wifi-off']"></i>
{{ wsConnected ? t("submissions.live") : t("submissions.offline") }}
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Right Column: Upload --> <!-- Right Column: Upload -->
<div class="lg:col-span-1 flex flex-col gap-8 order-1 lg:order-2"> <div class="lg:col-span-1 flex flex-col gap-8 order-1 lg:order-2">
+49 -27
View File
@@ -1,40 +1,69 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { gamesApiListEventGames, submissionsApiListAvailableScopes } from "@/api";
interface ScopeOption { import type { ScopeOut } from "@/api/types.gen";
id: string;
name: string;
}
interface Props { interface Props {
eventPath: string; eventPath: string;
gamePath: string; gamePath: string;
gameName?: string;
scopes?: ScopeOption[];
} }
const props = withDefaults(defineProps<Props>(), { const props = defineProps<Props>();
gameName: "Game",
scopes: () => [],
});
const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
useTitle(computed(() => t("submissionsSelect.title"))); useTitle(computed(() => t("submissionsSelect.title")));
const goToScope = (scopeId: string) => { const gameName = ref("");
window.location.href = `/${props.eventPath}/submissions/${props.gamePath}/${scopeId}`; const scopes = ref<ScopeOut[]>([]);
const loading = ref(true);
const scopeRoute = (scope: ScopeOut) => ({
name: "submissions",
params: { eventPath: props.eventPath, gamePath: props.gamePath, scopeId: scope.id },
});
const goToScope = (scope: ScopeOut) => {
router.push(scopeRoute(scope));
}; };
const goHome = () => { onMounted(async () => {
window.location.href = `/${props.eventPath}`; const [gamesResponse, scopesResponse] = await Promise.all([
}; gamesApiListEventGames({ path: { event_path: props.eventPath } }),
submissionsApiListAvailableScopes({
path: { event_path: props.eventPath, game_path: props.gamePath },
}),
]);
gameName.value = gamesResponse.data?.find((game) => game.path === props.gamePath)?.name ?? "";
const availableScopes = scopesResponse.data ?? [];
// Mirrors the server-side behavior this replaced: skip the picker
// straight to the only option, or bounce out if there's nothing to pick.
if (availableScopes.length === 1) {
router.replace(scopeRoute(availableScopes[0]));
return;
}
if (availableScopes.length === 0) {
router.replace({ name: "home", params: { eventPath: props.eventPath } });
return;
}
scopes.value = availableScopes;
loading.value = false;
});
</script> </script>
<template> <template>
<div class="min-h-screen bg-base-200 flex items-center justify-center p-4"> <div class="flex-1 bg-base-200 flex items-center justify-center p-4">
<div class="card bg-base-100 shadow-lg w-full max-w-md"> <div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else class="card bg-base-100 shadow-lg w-full max-w-md">
<div class="card-body"> <div class="card-body">
<h2 class="card-title">{{ t("submissionsSelect.title") }}</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">
@@ -42,19 +71,12 @@ const goHome = () => {
</p> </p>
<div class="flex flex-col gap-2 mt-2"> <div class="flex flex-col gap-2 mt-2">
<button v-for="scope in scopes" :key="scope.id" @click="goToScope(scope.id)" <button v-for="scope in scopes" :key="scope.id" @click="goToScope(scope)"
class="btn btn-outline justify-start"> class="btn btn-outline justify-start">
<i class="mdi mdi-sword-cross mr-2"></i> <i class="mdi mdi-sword-cross mr-2"></i>
{{ scope.name }} {{ scope.name }}
</button> </button>
</div> </div>
<div class="card-actions mt-4">
<button @click="goHome" class="btn btn-ghost btn-sm">
<i class="mdi mdi-arrow-left"></i>
{{ t("common.back") }}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, gamesApiListEventGames, gamesApiListGames, marketApiCloseMarket, marketApiCreateBet, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr } from './sdk.gen'; export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, gamesApiListEventGames, gamesApiListGames, marketApiCloseMarket, marketApiCreateBet, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, polylanSubmitterApiClearCache, polylanSubmitterApiGetConfig, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListAvailableScopes, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr } from './sdk.gen';
export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, GameOut, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, ImageCropIn, ImageCropOut, Input, LeaderboardEntryOut, LeaderboardOut, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponse, MarketApiResolveMarketResponses, MarketListSchema, MarketOptionSchema, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponse, NoitaApiSubmitLogFileResponses, NoitaSubmissionOut, ObjectivResultOut, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponse, OpusMagnumApiValidateSubmissionResponses, PagedSubmissionOut, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, PuzzlePointsFactorOut, PuzzleResponseIn, PuzzleResponseOut, PuzzleResponseRankingOut, PuzzleResultsOut, PuzzleSubmissionsOut, PuzzleSubmissionWithRankOut, RankingSchema, ResolveMarketSchema, ResultsOut, ScopeOut, SteamCollectionItemOut, SteamCollectionOut, SubmissionFileOut, SubmissionIn, SubmissionOut, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponse, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponse, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiGetSubmissionResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponse, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponse, SubmissionsApiProcessFileOcrResponses, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen'; export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, ConfigOut, GameOut, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, ImageCropIn, ImageCropOut, Input, LeaderboardEntryOut, LeaderboardOut, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponse, MarketApiResolveMarketResponses, MarketListSchema, MarketOptionSchema, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponse, NoitaApiSubmitLogFileResponses, NoitaSubmissionOut, ObjectivResultOut, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponse, OpusMagnumApiValidateSubmissionResponses, PagedSubmissionOut, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponse, PolylanSubmitterApiGetConfigResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, PuzzlePointsFactorOut, PuzzleResponseIn, PuzzleResponseOut, PuzzleResponseRankingOut, PuzzleResultsOut, PuzzleSubmissionsOut, PuzzleSubmissionWithRankOut, RankingSchema, ResolveMarketSchema, ResultsOut, ScopeOut, SteamCollectionItemOut, SteamCollectionOut, SubmissionFileOut, SubmissionIn, SubmissionOut, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponse, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponse, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesError, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponse, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponse, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponse, SubmissionsApiProcessFileOcrResponses, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen';
+17 -1
View File
@@ -2,7 +2,7 @@
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client'; import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen'; import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/** /**
@@ -25,6 +25,11 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
*/ */
export const polylanSubmitterApiHealthCheck = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiHealthCheckData, ThrowOnError>) => (options?.client ?? client).get<PolylanSubmitterApiHealthCheckResponses, unknown, ThrowOnError>({ url: '/api/health', ...options }); export const polylanSubmitterApiHealthCheck = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiHealthCheckData, ThrowOnError>) => (options?.client ?? client).get<PolylanSubmitterApiHealthCheckResponses, unknown, ThrowOnError>({ url: '/api/health', ...options });
/**
* Get Config
*/
export const polylanSubmitterApiGetConfig = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiGetConfigData, ThrowOnError>) => (options?.client ?? client).get<PolylanSubmitterApiGetConfigResponses, unknown, ThrowOnError>({ url: '/api/config', ...options });
/** /**
* Clear Cache * Clear Cache
* *
@@ -201,6 +206,17 @@ export const gamesApiListEventGames = <ThrowOnError extends boolean = false>(opt
*/ */
export const submissionsApiListMyScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListMyScopesData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListMyScopesResponses, SubmissionsApiListMyScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/scopes/mine', ...options }); export const submissionsApiListMyScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListMyScopesData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListMyScopesResponses, SubmissionsApiListMyScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/scopes/mine', ...options });
/**
* List Available Scopes
*
* Scopes the current user may submit to for this game - superusers see
* every active scope, everyone else only the ones assigned to them. Used
* by the submissions-select and submissions-detail pages, both to list
* scopes and (for the detail page) to confirm the requested scope_id is
* one the current user is actually allowed to see.
*/
export const submissionsApiListAvailableScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListAvailableScopesData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListAvailableScopesResponses, SubmissionsApiListAvailableScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/available-scopes', ...options });
/** /**
* Create Submission * Create Submission
*/ */
+66
View File
@@ -4,6 +4,16 @@ export type ClientOptions = {
baseUrl: string; baseUrl: string;
}; };
/**
* ConfigOut
*/
export type ConfigOut = {
/**
* Market Enabled
*/
market_enabled: boolean;
};
/** /**
* UserInfoOut * UserInfoOut
* *
@@ -1125,6 +1135,22 @@ export type PolylanSubmitterApiHealthCheckResponses = {
200: unknown; 200: unknown;
}; };
export type PolylanSubmitterApiGetConfigData = {
body?: never;
path?: never;
query?: never;
url: '/api/config';
};
export type PolylanSubmitterApiGetConfigResponses = {
/**
* OK
*/
200: ConfigOut;
};
export type PolylanSubmitterApiGetConfigResponse = PolylanSubmitterApiGetConfigResponses[keyof PolylanSubmitterApiGetConfigResponses];
export type PolylanSubmitterApiClearCacheData = { export type PolylanSubmitterApiClearCacheData = {
body?: never; body?: never;
path?: never; path?: never;
@@ -1668,6 +1694,46 @@ export type SubmissionsApiListMyScopesResponses = {
export type SubmissionsApiListMyScopesResponse = SubmissionsApiListMyScopesResponses[keyof SubmissionsApiListMyScopesResponses]; export type SubmissionsApiListMyScopesResponse = SubmissionsApiListMyScopesResponses[keyof SubmissionsApiListMyScopesResponses];
export type SubmissionsApiListAvailableScopesData = {
body?: never;
path: {
/**
* Event Path
*/
event_path: string;
/**
* Game Path
*/
game_path: string;
};
query?: never;
url: '/api/events/{event_path}/submissions/{game_path}/available-scopes';
};
export type SubmissionsApiListAvailableScopesErrors = {
/**
* Response
*
* Forbidden
*/
403: {
[key: string]: unknown;
};
};
export type SubmissionsApiListAvailableScopesError = SubmissionsApiListAvailableScopesErrors[keyof SubmissionsApiListAvailableScopesErrors];
export type SubmissionsApiListAvailableScopesResponses = {
/**
* Response
*
* OK
*/
200: Array<ScopeOut>;
};
export type SubmissionsApiListAvailableScopesResponse = SubmissionsApiListAvailableScopesResponses[keyof SubmissionsApiListAvailableScopesResponses];
export type SubmissionsApiCreateSubmissionData = { export type SubmissionsApiCreateSubmissionData = {
/** /**
* MultiPartBodyParams * MultiPartBodyParams
-11
View File
@@ -1,11 +0,0 @@
import { createApp } from 'vue'
import Home from '@/Home.vue'
import { i18n } from '@/i18n'
import '@/style.css'
// const app = createApp(App)
const selector = "#app"
const mountData = document.querySelector<HTMLElement>(selector)
const app = createApp(Home, { ...mountData?.dataset })
app.use(i18n)
app.mount(selector)
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const year = new Date().getFullYear();
</script>
<template>
<footer class="text-center text-sm text-base-content/50 py-6">
&copy; {{ year }} {{ t("common.appName") }}
</footer>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { storeToRefs } from "pinia";
import { useUserStore } from "@/stores/user";
import { usePageHeaderStore } from "@/stores/pageHeader";
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const userStore = useUserStore();
const { userInfo } = storeToRefs(userStore);
const pageHeaderStore = usePageHeaderStore();
const { title: dynamicTitle } = storeToRefs(pageHeaderStore);
const title = computed(() => {
if (dynamicTitle.value) return dynamicTitle.value;
const key = route.meta.titleKey;
return key ? t(key) : t("common.appName");
});
const goBack = () => {
const backRoute = route.meta.getBackRoute?.(route);
if (backRoute) {
router.push(backRoute);
}
};
</script>
<template>
<div class="navbar bg-base-100 shadow-lg">
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
<button v-if="route.meta.getBackRoute" @click="goBack" class="btn btn-primary btn-sm">
<i class="mdi mdi-arrow-left"></i>
{{ t("common.back") }}
</button>
<h1 class="text-xl font-bold">{{ title }}</h1>
<div class="flex-1"></div>
<div v-if="userInfo.isAuthenticated" class="flex items-center gap-2">
<div class="text-sm">
<span class="font-medium">{{ userInfo.username }}</span>
<span v-if="userInfo.isStaff" class="badge badge-warning badge-xs ml-1">{{ t("common.admin") }}</span>
</div>
</div>
<div v-else class="text-sm text-base-content/70">{{ t("common.notLoggedIn") }}</div>
<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">{{ t("common.adminPanel") }}</a>
</div>
</div>
</template>
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from "vue";
import App from "@/App.vue";
import { router } from "@/router";
import { pinia } from "@/stores";
import { i18n } from "@/i18n";
import "@/style.css";
const app = createApp(App);
app.use(pinia);
app.use(router);
app.use(i18n);
app.mount("#app");
-12
View File
@@ -1,12 +0,0 @@
import { createApp } from 'vue'
import Market from '@/Market.vue'
import { pinia } from '@/stores'
import { i18n } from '@/i18n'
import '@/style.css'
const selector = "#app"
const mountData = document.querySelector<HTMLElement>(selector)
const app = createApp(Market, { ...mountData?.dataset })
app.use(pinia)
app.use(i18n)
app.mount(selector)
-12
View File
@@ -1,12 +0,0 @@
import { createApp } from 'vue'
import Noita from '@/Noita.vue'
import { pinia } from '@/stores'
import { i18n } from '@/i18n'
import '@/style.css'
const selector = "#app"
const mountData = document.querySelector<HTMLElement>(selector)
const app = createApp(Noita, { ...mountData?.dataset })
app.use(pinia)
app.use(i18n)
app.mount(selector)
-13
View File
@@ -1,13 +0,0 @@
import { createApp } from 'vue'
import OpusMagnum from '@/OpusMagnum.vue'
import { pinia } from '@/stores'
import { i18n } from '@/i18n'
import '@/style.css'
// const app = createApp(App)
const selector = "#app"
const mountData = document.querySelector<HTMLElement>(selector)
const app = createApp(OpusMagnum, { ...mountData?.dataset })
app.use(pinia)
app.use(i18n)
app.mount(selector)
+83
View File
@@ -0,0 +1,83 @@
import { createRouter, createWebHistory, type RouteLocationNormalizedLoaded, type RouteLocationRaw } from "vue-router";
import Home from "@/Home.vue";
import Market from "@/Market.vue";
import Noita from "@/Noita.vue";
import OpusMagnum from "@/OpusMagnum.vue";
import Submissions from "@/Submissions.vue";
import SubmissionsSelect from "@/SubmissionsSelect.vue";
import { usePageHeaderStore } from "@/stores/pageHeader";
declare module "vue-router" {
interface RouteMeta {
// Static label for AppHeader's title bar. Pages whose title depends on
// fetched data (e.g. submissions) set one at runtime via
// usePageHeaderStore() instead and leave this out.
titleKey?: string;
getBackRoute?: (route: RouteLocationNormalizedLoaded) => RouteLocationRaw;
}
}
const eventHome = (route: RouteLocationNormalizedLoaded): RouteLocationRaw => ({
name: "home",
params: { eventPath: route.params.eventPath },
});
export const router = createRouter({
history: createWebHistory("/"),
routes: [
{
path: "/:eventPath/",
name: "home",
component: Home,
props: true,
},
{
path: "/:eventPath/market",
name: "market",
component: Market,
props: true,
meta: { titleKey: "market.header", getBackRoute: eventHome },
},
{
path: "/:eventPath/opus-magnum",
name: "opus-magnum",
component: OpusMagnum,
props: true,
meta: { titleKey: "opusMagnum.header", getBackRoute: eventHome },
},
{
path: "/:eventPath/noita",
name: "noita",
component: Noita,
props: true,
meta: { titleKey: "noita.header", getBackRoute: eventHome },
},
{
path: "/:eventPath/submissions/:gamePath",
name: "submissions-select",
component: SubmissionsSelect,
props: true,
meta: { titleKey: "submissionsSelect.title", getBackRoute: eventHome },
},
{
path: "/:eventPath/submissions/:gamePath/:scopeId",
name: "submissions",
component: Submissions,
props: true,
meta: {
// No static titleKey: Submissions.vue sets a dynamic
// "{game} Submissions — {scope}" title once it has fetched both.
getBackRoute: (route) => ({
name: "submissions-select",
params: { eventPath: route.params.eventPath, gamePath: route.params.gamePath },
}),
},
},
],
});
// Every route change starts from a clean header title; pages that need a
// dynamic one (see submissions above) set it again once their data lands.
router.beforeEach(() => {
usePageHeaderStore().clear();
});
@@ -0,0 +1,20 @@
import { defineStore } from "pinia";
import { ref } from "vue";
// Lets a routed page hand AppHeader a title built from data only that page
// has fetched (e.g. "Noita Submissions — Semifinal", assembled from an API
// call keyed by the scopeId route param). Cleared on every navigation so a
// stale title never lingers while the next page is still loading.
export const usePageHeaderStore = defineStore("pageHeader", () => {
const title = ref("");
const setTitle = (value: string) => {
title.value = value;
};
const clear = () => {
title.value = "";
};
return { title, setTitle, clear };
});
-12
View File
@@ -1,12 +0,0 @@
import { createApp } from 'vue'
import Submissions from '@/Submissions.vue'
import { pinia } from '@/stores'
import { i18n } from '@/i18n'
import '@/style.css'
const selector = "#app"
const mountData = document.querySelector<HTMLElement>(selector)
const app = createApp(Submissions, { ...mountData?.dataset })
app.use(pinia)
app.use(i18n)
app.mount(selector)
@@ -1,12 +0,0 @@
import { createApp } from 'vue'
import SubmissionsSelect from '@/SubmissionsSelect.vue'
import { i18n } from '@/i18n'
import '@/style.css'
const selector = "#app"
const mountData = document.querySelector<HTMLElement>(selector)
const scopesEl = document.getElementById('scopes-data')
const scopes = scopesEl ? JSON.parse(scopesEl.textContent || '[]') : []
const app = createApp(SubmissionsSelect, { ...mountData?.dataset, scopes })
app.use(i18n)
app.mount(selector)
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+32
View File
@@ -31,6 +31,38 @@ def list_my_scopes(request: HttpRequest, event_path: str, game_path: str):
) )
@router.get(f"{PREFIX}/{{game_path}}/available-scopes", response={200: list[ScopeOut], 403: dict})
def list_available_scopes(request: HttpRequest, event_path: str, game_path: str):
"""Scopes the current user may submit to for this game - superusers see
every active scope, everyone else only the ones assigned to them. Used
by the submissions-select and submissions-detail pages, both to list
scopes and (for the detail page) to confirm the requested scope_id is
one the current user is actually allowed to see."""
if not request.user.is_authenticated:
return 403, {"detail": "Authentication required"}
game = get_object_or_404(
Game.objects.enabled_for_event(request.event), path=game_path
)
scopes = (
Scope.objects.filter(
event_game__event=request.event,
event_game__game=game,
is_active=True,
)
if request.user.is_superuser
else Scope.objects.filter(
event_game__event=request.event,
event_game__game=game,
assigned_users=request.user,
is_active=True,
)
)
return list(scopes.order_by("name"))
@router.post(f"{PREFIX}/submission/create", response={200: SubmissionOut, 403: dict}) @router.post(f"{PREFIX}/submission/create", response={200: SubmissionOut, 403: dict})
def create_submission( def create_submission(
request: HttpRequest, request: HttpRequest,
+2 -21
View File
@@ -39,18 +39,7 @@ def submission_home(request: HttpRequest, event_path: str, game_path: str):
if not scopes: if not scopes:
raise Http404 raise Http404
return render( return render(request, "app.html")
request,
"submissions_select.html",
{
"event": event,
"game": game,
"scopes_data": [
{"id": str(scope.id), "name": scope.name}
for scope in scopes.order_by("name")
],
},
)
@login_required @login_required
@@ -73,12 +62,4 @@ def submission_view(
): ):
raise Http404 raise Http404
return render( return render(request, "app.html")
request,
"submissions.html",
{
"event": event,
"scope": scope,
"game": scope.event_game.game,
},
)
@@ -3,9 +3,9 @@
{% load django_vite %} {% load django_vite %}
{% block head %} {% block head %}
{% vite_asset 'src/noita.ts' %} {% vite_asset 'src/main.ts' %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div id="app" data-event-path="{{ event.path }}"></div> <div id="app"></div>
{% endblock %} {% endblock %}
+2 -1
View File
@@ -1,9 +1,10 @@
<!doctype html> <!doctype html>
{% load django_vite %} {% load django_vite %}
{% load static %}
<html lang="{{ LANGUAGE_CODE }}" 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/png" href="{% static 'favicon.ico' %}" />
<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 %}PolyLAN Submitter{% endblock %}</title> <title>{% block title %}PolyLAN Submitter{% endblock %}</title>
{% if debug %}{% vite_hmr_client %}{% endif %} {% if debug %}{% vite_hmr_client %}{% endif %}
-11
View File
@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% load django_vite %}
{% block head %}
{% vite_asset 'src/home.ts' %}
{% endblock %}
{% block content %}
<div id="app" data-event-path="{{ event.path }}" data-market-enabled="{{ market_enabled|lower }}"></div>
{% endblock %}
-11
View File
@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% load django_vite %}
{% block head %}
{% vite_asset 'src/market.ts' %}
{% endblock %}
{% block content %}
<div id="app" data-event-path="{{ event.path }}"></div>
{% endblock %}
@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% load django_vite %}
{% block head %}
{% vite_asset 'src/opus-magnum.ts' %}
{% endblock %}
{% block content %}
<div id="app" data-event-path="{{ event.path }}" data-collection-title="{{ collection.title }}" data-collection-url="{{ collection.url }}" data-collection-description="{{ collection.description }}"></div>
{% endblock %}
@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% load django_vite %}
{% block head %}
{% vite_asset 'src/submissions.ts' %}
{% endblock %}
{% block content %}
<div id="app" data-event-path="{{ event.path }}" data-scope-id="{{ scope.id }}" data-scope-name="{{ scope.name }}" data-game-name="{{ game.name }}" data-game-image="{{ game.image |default:'' }}" data-game-steam-id="{{ game.steam_app_id|default:'' }}" data-game-path="{{ game.path }}"></div>
{% endblock %}
@@ -1,12 +0,0 @@
{% extends "base.html" %}
{% load django_vite %}
{% block head %}
{% vite_asset 'src/submissions_select.ts' %}
{% endblock %}
{% block content %}
{{ scopes_data|json_script:"scopes-data" }}
<div id="app" data-event-path="{{ event.path }}" data-game-path="{{ game.path }}" data-game-name="{{ game.name }}"></div>
{% endblock %}
+2 -6
View File
@@ -29,12 +29,8 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
input: input:
{ {
opus_magnum: resolve('./src/opus-magnum.ts'), main: resolve('./src/main.ts'),
home: resolve('./src/home.ts'), error: resolve('./src/error.ts'),
noita: resolve('./src/noita.ts'),
market: resolve('./src/market.ts'),
submissions: resolve('./src/submissions.ts'),
submissions_select: resolve('./src/submissions_select.ts'),
} }
} }
}, },