feat(submissions): base submissions frontend + api

This commit is contained in:
2026-05-25 01:20:47 +02:00
parent 6a58a0289e
commit 9f9cb0a483
21 changed files with 735 additions and 94 deletions
+2 -2
View File
@@ -91,7 +91,7 @@ onMounted(async () => {
<div v-for="game in games" :key="game.id" @click="navigate(game.path)"
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">
<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"
@error="onImageError(game.id)" class="w-full h-full object-cover" />
<div v-else
class="w-full h-full bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center text-white">
@@ -102,7 +102,7 @@ onMounted(async () => {
<div class="card-body">
<h2 class="card-title text-2xl">{{ game.name }}</h2>
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary">
<button @click="navigate(game.path)" class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i>
Submit results
</button>
+1 -1
View File
@@ -175,7 +175,7 @@ onMounted(() => {
<h1 class="text-xl font-bold">Noita Submitter</h1>
<div class="flex-1"></div>
<a href="/api/docs" class="btn btn-xs">API docs</a>
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
<a href="/admin" v-if="userInfo.isStaff" class="btn btn-xs btn-warning">Admin panel</a>
</div>
</div>
+396
View File
@@ -0,0 +1,396 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useUserStore } from "@/stores/user";
interface Props {
gamePath?: string;
gameName?: string;
gameImage?: string;
gameSteamId?: string;
}
const props = withDefaults(defineProps<Props>(), {
gamePath: "",
gameName: "Game",
gameImage: "",
gameSteamId: "",
});
const userStore = useUserStore();
const { userInfo } = storeToRefs(userStore);
const uploadedFiles = ref<File[]>([]);
const isDragover = ref(false);
const isUploading = ref(false);
const notes = ref("");
const selectedFile = ref<File | null>(null);
const filePreviewUrl = ref<string>("");
const isModalOpen = ref(false);
const openFilePreview = async (file: File) => {
selectedFile.value = file;
const fileType = file.type;
if (fileType.startsWith("image/")) {
filePreviewUrl.value = URL.createObjectURL(file);
} else if (fileType === "application/pdf") {
filePreviewUrl.value = URL.createObjectURL(file);
} else if (fileType.startsWith("text/")) {
const text = await file.text();
filePreviewUrl.value = text;
} else {
filePreviewUrl.value = "";
}
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
if (filePreviewUrl.value.startsWith("blob:")) {
URL.revokeObjectURL(filePreviewUrl.value);
}
selectedFile.value = null;
filePreviewUrl.value = "";
};
const getGameHeaderImage = () => {
if (props.gameImage) {
return props.gameImage;
}
if (props.gameSteamId) {
return `https://cdn.akamai.steamstatic.com/steam/apps/${props.gameSteamId}/header.jpg`;
}
return null;
};
const getFileIcon = (file: File | null) => {
if (!file) return "mdi-file";
const type = file.type;
if (type.startsWith("image/")) return "mdi-image";
if (type === "application/pdf") return "mdi-file-pdf";
if (type.startsWith("text/")) return "mdi-file-document";
if (type.includes("video")) return "mdi-video";
return "mdi-file";
};
const handleFileUpload = (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files) {
uploadedFiles.value = Array.from(input.files);
}
};
const handleDragover = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
isDragover.value = true;
};
const handleDragleave = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
isDragover.value = false;
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
isDragover.value = false;
if (event.dataTransfer?.files) {
uploadedFiles.value = Array.from(event.dataTransfer.files);
}
};
const removeFile = (index: number) => {
uploadedFiles.value.splice(index, 1);
};
const submitSubmission = async () => {
if (uploadedFiles.value.length === 0) return;
isUploading.value = true;
try {
const formData = new FormData();
// Add files
uploadedFiles.value.forEach((file) => {
formData.append("files", file);
});
// Add form fields
formData.append("notes", notes.value);
// Build URL with query parameters
const url = new URL("/api/submissions/create", window.location.origin);
url.searchParams.append("game_path", props.gamePath);
const response = await fetch(url.toString(), {
method: "POST",
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
const errorMsg = Array.isArray(errorData.detail)
? errorData.detail.map((e: any) => e.msg).join(", ")
: errorData.detail || "Failed to submit";
throw new Error(errorMsg);
}
uploadedFiles.value = [];
notes.value = "";
alert("Submission successful!");
} catch (error) {
console.error("Error submitting:", error);
alert(`Error submitting: ${error instanceof Error ? error.message : "Please try again."}`);
} finally {
isUploading.value = false;
}
};
const goHome = () => {
window.location.href = "/";
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
};
onMounted(() => {
userStore.loadUserData();
});
</script>
<template>
<div class="min-h-screen 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>
Back
</button>
<h1 class="text-xl font-bold">{{ gameName }} Submissions</h1>
<div class="flex-1"></div>
<a href="/api/docs" class="btn btn-xs">API docs</a>
<a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
</div>
</div>
<!-- Main Content -->
<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">
<!-- Right Column: Upload -->
<div class="lg:col-span-1">
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl mb-6">
<i class="mdi mdi-cloud-upload text-primary mr-2"></i>
Submit Results
</h2>
<!-- Upload Area -->
<div @dragover="handleDragover" @dragleave="handleDragleave" @drop="handleDrop" :class="[
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer bg-base-200/50 mb-6',
isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary'
]">
<input type="file" multiple @change="handleFileUpload" class="hidden" id="file-upload" />
<label for="file-upload" class="cursor-pointer flex flex-col items-center gap-3">
<i :class="[
'mdi text-4xl',
isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50'
]"></i>
<div>
<p class="font-semibold">Click to upload or drag and drop</p>
<p class="text-sm text-base-content/70">Any file type supported</p>
</div>
</label>
</div>
<!-- Uploaded Files List -->
<div v-if="uploadedFiles.length > 0" class="mb-6">
<p class="font-semibold mb-3">Selected Files:</p>
<div class="space-y-2">
<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"
@click="openFilePreview(file)">
<i :class="['mdi text-primary', getFileIcon(file)]"></i>
<div class="flex-1 min-w-0">
<p class="font-medium truncate group-hover:text-primary">{{ file.name }}</p>
<p class="text-xs text-base-content/70">{{ formatFileSize(file.size) }}</p>
</div>
<button @click.stop="removeFile(index)" class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100">
<i class="mdi mdi-close"></i>
</button>
</div>
</div>
</div>
<!-- Notes Section -->
<div class="mb-6">
<div class="form-control">
<div class="flex-1">
<label class="flex label">
<span class="label-text font-medium">Notes (Optional)</span>
<span class="label-text-alt">0/500</span>
</label>
<textarea class="flex textarea textarea-bordered h-24 w-full resize-none"
placeholder="Add any notes special for this upload" maxlength="500"></textarea>
</div>
</div>
</div>
<!-- Submit Button -->
<div class="flex gap-3">
<label for="file-upload" class="btn btn-outline flex-1">
<i class="mdi mdi-folder-open mr-2"></i>
Choose Files
</label>
<button @click="submitSubmission" :disabled="uploadedFiles.length === 0 || isUploading"
:class="['btn btn-primary flex-1', { 'loading': isUploading }]">
<i v-if="!isUploading" class="mdi mdi-send mr-2"></i>
{{ isUploading ? 'Submitting...' : 'Submit' }}
</button>
</div>
<p class="text-xs text-base-content/70 text-center mt-4">
Maximum file size: 256 MB per file
</p>
</div>
</div>
</div>
<!-- Left Column: Info -->
<div class="lg:col-span-1 flex flex-col gap-8">
<!-- Game Card -->
<div class="card bg-base-100 shadow-lg">
<figure v-if="getGameHeaderImage()" class="relative h-48 bg-base-300 overflow-hidden">
<img :src="getGameHeaderImage()!" :alt="gameName" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-black/30"></div>
</figure>
<figure v-else class="relative h-48 bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center">
<i class="mdi mdi-gamepad-variant text-6xl text-white opacity-30"></i>
</figure>
<div class="card-body">
<h2 class="card-title text-2xl">{{ gameName }}</h2>
<p class="text-base-content/70">Submit your best results for this game</p>
</div>
</div>
<!-- Guidelines Card -->
<div class="card bg-base-100 shadow-lg sticky top-8">
<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">
<i class="mdi mdi-information-outline text-3xl"></i>
Submission Guidelines
</h2>
</div>
<div class="card-body p-8">
<div class="space-y-6">
<div>
<h3 class="font-bold text-lg mb-2">
<i class="mdi mdi-file-upload mr-2 text-primary"></i>
File Upload
</h3>
<p class="text-base-content/70 text-sm">
Upload your results as screenshots, logs, or any other file format. You can upload multiple
files at once.
</p>
</div>
<div class="divider"></div>
<div>
<h3 class="font-bold text-lg mb-2">
<i class="mdi mdi-note-text mr-2 text-primary"></i>
Add Notes
</h3>
<p class="text-base-content/70 text-sm">
Include any additional context about your submission. This helps reviewers understand your
results.
</p>
</div>
<div class="divider"></div>
<div>
<h3 class="font-bold text-lg mb-2">
<i class="mdi mdi-check-circle mr-2 text-primary"></i>
Verification
</h3>
<p class="text-base-content/70 text-sm">
Your submission will be reviewed and verified. You'll receive confirmation once it's been
processed.
</p>
</div>
<div class="divider"></div>
<div class="bg-base-200 p-4 rounded-lg">
<p class="text-base-content/70 text-sm">
<i class="mdi mdi-lightbulb-on mr-2 text-warning"></i>
Pro tip: Include clear screenshots or documentation with your submission for faster verification.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- File Preview Modal -->
<div :class="['modal', { 'modal-open': isModalOpen }]">
<div class="modal-box w-11/12 max-w-4xl">
<h3 class="font-bold text-lg mb-4">
<i :class="['mdi mr-2', getFileIcon(selectedFile)]"></i>
{{ selectedFile?.name }}
</h3>
<!-- Image Preview -->
<div v-if="selectedFile && selectedFile.type.startsWith('image/')" class="mb-4">
<img :src="filePreviewUrl" :alt="selectedFile.name" class="max-w-full max-h-[60vh] mx-auto rounded-lg" />
</div>
<!-- PDF Preview -->
<div v-else-if="selectedFile && selectedFile.type === 'application/pdf'" class="mb-4">
<iframe :src="filePreviewUrl" class="w-full h-[60vh] rounded-lg border border-base-300"></iframe>
</div>
<!-- Text Preview -->
<div v-else-if="selectedFile && selectedFile.type.startsWith('text/')" class="mb-4">
<div class="bg-base-200 p-4 rounded-lg max-h-[60vh] overflow-y-auto">
<pre class="text-sm whitespace-pre-wrap break-words font-mono">{{ filePreviewUrl }}</pre>
</div>
</div>
<!-- Unsupported Preview -->
<div v-else class="mb-4">
<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>
<p class="text-base-content/70">
Preview not available for {{ selectedFile?.type || 'this file type' }}
</p>
<p class="text-sm text-base-content/50">
File size: {{ selectedFile ? formatFileSize(selectedFile.size) : '' }}
</p>
</div>
</div>
<div class="modal-action">
<button @click="closeModal" class="btn">Close</button>
</div>
</div>
<div class="modal-backdrop" @click="closeModal"></div>
</div>
</div>
</template>
+2 -2
View File
@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts
export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, gamesApiListGames, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck } from './sdk.gen';
export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, GameOut, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, Input, LeaderboardEntryOut, LeaderboardOut, 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, ResultsOut, SteamCollectionItemOut, SteamCollectionOut, SubmissionFileOut, SubmissionIn, SubmissionOut, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen';
export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, gamesApiListGames, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateSubmission, submissionsApiGetSubmission, submissionsApiListSubmissionsByGame } from './sdk.gen';
export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, GameOut, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, Input, LeaderboardEntryOut, LeaderboardOut, 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, ResultsOut, SteamCollectionItemOut, SteamCollectionOut, SubmissionFileOut, SubmissionIn, SubmissionOut, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionResponse, SubmissionsApiCreateSubmissionResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiGetSubmissionResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen';
+24 -1
View File
@@ -2,7 +2,7 @@
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, GamesApiListGamesData, GamesApiListGamesResponses, 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 } from './types.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, GamesApiListGamesData, GamesApiListGamesResponses, 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, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/**
@@ -190,3 +190,26 @@ export const noitaApiSubmitLogFile = <ThrowOnError extends boolean = false>(opti
* List Games
*/
export const gamesApiListGames = <ThrowOnError extends boolean = false>(options?: Options<GamesApiListGamesData, ThrowOnError>) => (options?.client ?? client).get<GamesApiListGamesResponses, unknown, ThrowOnError>({ url: '/api/games/', ...options });
/**
* Create Submission
*/
export const submissionsApiCreateSubmission = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiCreateSubmissionData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiCreateSubmissionResponses, unknown, ThrowOnError>({
...formDataBodySerializer,
url: '/api/submissions/create',
...options,
headers: {
'Content-Type': null,
...options.headers
}
});
/**
* Get Submission
*/
export const submissionsApiGetSubmission = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiGetSubmissionData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiGetSubmissionResponses, unknown, ThrowOnError>({ url: '/api/submissions/{submission_id}', ...options });
/**
* List Submissions By Game
*/
export const submissionsApiListSubmissionsByGame = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListSubmissionsByGameData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListSubmissionsByGameResponses, unknown, ThrowOnError>({ url: '/api/submissions/game/{game_id}', ...options });
+84 -77
View File
@@ -341,120 +341,54 @@ export type PuzzleResponseOut = {
/**
* SubmissionFileOut
*
* Schema for submission file output
*/
export type SubmissionFileOut = {
/**
* File Url
* Id
*/
file_url: string | null;
/**
* ID
*/
id: number;
id: string;
/**
* Original Filename
*
* Original filename as uploaded by user
*/
original_filename: string;
/**
* File Size
*
* File size in bytes
*/
file_size: number;
/**
* Content Type
*
* MIME type of the file
*/
content_type: string;
/**
* Ocr Processed
*
* Whether OCR has been processed for this file
* File Url
*/
ocr_processed?: boolean;
file_url?: string | null;
/**
* Ocr Raw Data
*
* Raw OCR data as JSON
* Ocr Status
*/
ocr_raw_data?: {
[key: string]: unknown;
} | null;
/**
* Ocr Error
*
* OCR processing error message
*/
ocr_error: string;
/**
* Created At
*/
created_at: string;
ocr_status: string;
};
/**
* SubmissionOut
*
* Schema for submission output
*/
export type SubmissionOut = {
/**
* Responses
*/
responses: Array<PuzzleResponseOut>;
/**
* Total Responses
*/
total_responses: number;
/**
* Needs Validation
*/
needs_validation: boolean;
/**
* Id
*/
id?: string;
id: string;
/**
* User
*
* User who made the submission (null for anonymous)
* Game Id
*/
user_id?: number | null;
game_id?: number | null;
/**
* Notes
*
* Optional notes about the submission
*/
notes?: string | null;
/**
* Is Validated
*
* Whether this submission has been manually validated
* Files
*/
is_validated?: boolean;
/**
* Validated By
*
* Admin user who validated this submission
*/
validated_by_id?: number | null;
/**
* Validated At
*
* When this submission was validated
*/
validated_at?: string | null;
/**
* Manual Validation Requested
*
* Whether the user specifically requested manual validation
*/
manual_validation_requested?: boolean;
files?: Array<SubmissionFileOut>;
/**
* Created At
*/
@@ -1381,3 +1315,76 @@ export type GamesApiListGamesResponses = {
};
export type GamesApiListGamesResponse = GamesApiListGamesResponses[keyof GamesApiListGamesResponses];
export type SubmissionsApiCreateSubmissionData = {
/**
* FileParams
*/
body: {
/**
* Files
*/
files: Array<Blob | File>;
};
path?: never;
query: {
/**
* Game Id
*/
game_id: number;
/**
* Notes
*/
notes?: string | null;
};
url: '/api/submissions/create';
};
export type SubmissionsApiCreateSubmissionResponses = {
/**
* OK
*/
200: SubmissionOut;
};
export type SubmissionsApiCreateSubmissionResponse = SubmissionsApiCreateSubmissionResponses[keyof SubmissionsApiCreateSubmissionResponses];
export type SubmissionsApiGetSubmissionData = {
body?: never;
path: {
/**
* Submission Id
*/
submission_id: string;
};
query?: never;
url: '/api/submissions/{submission_id}';
};
export type SubmissionsApiGetSubmissionResponses = {
/**
* OK
*/
200: SubmissionOut;
};
export type SubmissionsApiGetSubmissionResponse = SubmissionsApiGetSubmissionResponses[keyof SubmissionsApiGetSubmissionResponses];
export type SubmissionsApiListSubmissionsByGameData = {
body?: never;
path: {
/**
* Game Id
*/
game_id: number;
};
query?: never;
url: '/api/submissions/game/{game_id}';
};
export type SubmissionsApiListSubmissionsByGameResponses = {
/**
* OK
*/
200: unknown;
};
+43
View File
@@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
interface UserInfo {
username: string
isAuthenticated: boolean
isStaff: boolean
}
export const useUserStore = defineStore('user', () => {
const userInfo = ref<UserInfo>({
username: '',
isAuthenticated: false,
isStaff: false,
})
const isLoading = ref(false)
const error = ref<string>('')
const loadUserData = async () => {
isLoading.value = true
try {
const response = await fetch('/api/user')
if (response.ok) {
const user = await response.json()
userInfo.value.isAuthenticated = user.is_authenticated || false
userInfo.value.username = user.username || ''
userInfo.value.isStaff = user.is_staff || false
}
} catch (err) {
error.value = 'Failed to fetch user info'
console.error('Error fetching user info:', err)
} finally {
isLoading.value = false
}
}
return {
userInfo,
isLoading,
error,
loadUserData,
}
})
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import Submissions from '@/Submissions.vue'
import { pinia } from '@/stores'
import '@/style.css'
const selector = "#app"
const mountData = document.querySelector<HTMLElement>(selector)
const app = createApp(Submissions, { ...mountData?.dataset })
app.use(pinia)
app.mount(selector)