feat(submissions): base submissions frontend + api
This commit is contained in:
@@ -2,12 +2,13 @@ from ninja import NinjaAPI
|
|||||||
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
|
||||||
from opus_magnum.api import router as submissions_router
|
from opus_magnum.api import router as opus_magnum_router
|
||||||
from opus_magnum.schemas import UserInfoOut
|
from opus_magnum.schemas import UserInfoOut
|
||||||
from animations.api import router as results_router
|
from animations.api import router as results_router
|
||||||
from noita.api import router as noita_router
|
from noita.api import router as noita_router
|
||||||
from games.api import router as games_router
|
from games.api import router as games_router
|
||||||
from market.api import router as market_router
|
from market.api import router as market_router
|
||||||
|
from submissions.api import router as submissions_router
|
||||||
|
|
||||||
# Create the main API instance
|
# Create the main API instance
|
||||||
api = NinjaAPI(
|
api = NinjaAPI(
|
||||||
@@ -33,11 +34,12 @@ The Noita Submission API allows clients to upload the result of the log file of
|
|||||||
# Add authentication for protected endpoints
|
# Add authentication for protected endpoints
|
||||||
# api.auth = django_auth # Uncomment if you want global auth
|
# api.auth = django_auth # Uncomment if you want global auth
|
||||||
|
|
||||||
# Include the opus_magnum router
|
# Include the routers
|
||||||
api.add_router("/opus-magnum/", submissions_router, tags=["opus-magnum"])
|
api.add_router("/opus-magnum/", opus_magnum_router, tags=["opus-magnum"])
|
||||||
api.add_router("/results/", results_router, tags=["results"])
|
api.add_router("/results/", results_router, tags=["results"])
|
||||||
api.add_router("/noita/", noita_router, tags=["noita"])
|
api.add_router("/noita/", noita_router, tags=["noita"])
|
||||||
api.add_router("/games/", games_router, tags=["games"])
|
api.add_router("/games/", games_router, tags=["games"])
|
||||||
|
api.add_router("/submissions/", submissions_router, tags=["submissions"])
|
||||||
|
|
||||||
if settings.MARKET_ENABLED:
|
if settings.MARKET_ENABLED:
|
||||||
api.add_router("/market/", market_router)
|
api.add_router("/market/", market_router)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from django.conf.urls.static import static
|
|||||||
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
||||||
from games.decorators import require_game_enabled
|
from games.decorators import require_game_enabled
|
||||||
from market.views import market_home
|
from market.views import market_home
|
||||||
|
from submissions.views import submission_home
|
||||||
from .api import api
|
from .api import api
|
||||||
|
|
||||||
NOITA_APP_ID = 881100
|
NOITA_APP_ID = 881100
|
||||||
@@ -72,6 +73,7 @@ urlpatterns = [
|
|||||||
path("market", market_home, name="market.home"),
|
path("market", market_home, name="market.home"),
|
||||||
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
||||||
path("noita", noita_home, name="noita.home"),
|
path("noita", noita_home, name="noita.home"),
|
||||||
|
path("submissions/<str:game>", submission_home, name="submissions.home"),
|
||||||
path("", home, name="home"),
|
path("", home, name="home"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ onMounted(async () => {
|
|||||||
<div v-for="game in games" :key="game.id" @click="navigate(game.path)"
|
<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">
|
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"
|
||||||
@error="onImageError(game.id)" class="w-full h-full object-cover" />
|
@error="onImageError(game.id)" class="w-full h-full object-cover" />
|
||||||
<div v-else
|
<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">
|
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">
|
<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 class="btn btn-primary">
|
<button @click="navigate(game.path)" class="btn btn-primary">
|
||||||
<i class="mdi mdi-arrow-right mr-2"></i>
|
<i class="mdi mdi-arrow-right mr-2"></i>
|
||||||
Submit results
|
Submit results
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ onMounted(() => {
|
|||||||
<h1 class="text-xl font-bold">Noita Submitter</h1>
|
<h1 class="text-xl font-bold">Noita Submitter</h1>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
<a href="/api/docs" class="btn btn-xs">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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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, gamesApiListGames, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck } from './sdk.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, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.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';
|
||||||
|
|||||||
@@ -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, 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> & {
|
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
|
* List Games
|
||||||
*/
|
*/
|
||||||
export const gamesApiListGames = <ThrowOnError extends boolean = false>(options?: Options<GamesApiListGamesData, ThrowOnError>) => (options?.client ?? client).get<GamesApiListGamesResponses, unknown, ThrowOnError>({ url: '/api/games/', ...options });
|
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 });
|
||||||
|
|||||||
@@ -341,120 +341,54 @@ export type PuzzleResponseOut = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* SubmissionFileOut
|
* SubmissionFileOut
|
||||||
*
|
|
||||||
* Schema for submission file output
|
|
||||||
*/
|
*/
|
||||||
export type SubmissionFileOut = {
|
export type SubmissionFileOut = {
|
||||||
/**
|
/**
|
||||||
* File Url
|
* Id
|
||||||
*/
|
*/
|
||||||
file_url: string | null;
|
id: string;
|
||||||
/**
|
|
||||||
* ID
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
/**
|
/**
|
||||||
* Original Filename
|
* Original Filename
|
||||||
*
|
|
||||||
* Original filename as uploaded by user
|
|
||||||
*/
|
*/
|
||||||
original_filename: string;
|
original_filename: string;
|
||||||
/**
|
/**
|
||||||
* File Size
|
* File Size
|
||||||
*
|
|
||||||
* File size in bytes
|
|
||||||
*/
|
*/
|
||||||
file_size: number;
|
file_size: number;
|
||||||
/**
|
/**
|
||||||
* Content Type
|
* Content Type
|
||||||
*
|
|
||||||
* MIME type of the file
|
|
||||||
*/
|
*/
|
||||||
content_type: string;
|
content_type: string;
|
||||||
/**
|
/**
|
||||||
* Ocr Processed
|
* File Url
|
||||||
*
|
|
||||||
* Whether OCR has been processed for this file
|
|
||||||
*/
|
*/
|
||||||
ocr_processed?: boolean;
|
file_url?: string | null;
|
||||||
/**
|
/**
|
||||||
* Ocr Raw Data
|
* Ocr Status
|
||||||
*
|
|
||||||
* Raw OCR data as JSON
|
|
||||||
*/
|
*/
|
||||||
ocr_raw_data?: {
|
ocr_status: string;
|
||||||
[key: string]: unknown;
|
|
||||||
} | null;
|
|
||||||
/**
|
|
||||||
* Ocr Error
|
|
||||||
*
|
|
||||||
* OCR processing error message
|
|
||||||
*/
|
|
||||||
ocr_error: string;
|
|
||||||
/**
|
|
||||||
* Created At
|
|
||||||
*/
|
|
||||||
created_at: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SubmissionOut
|
* SubmissionOut
|
||||||
*
|
|
||||||
* Schema for submission output
|
|
||||||
*/
|
*/
|
||||||
export type SubmissionOut = {
|
export type SubmissionOut = {
|
||||||
/**
|
|
||||||
* Responses
|
|
||||||
*/
|
|
||||||
responses: Array<PuzzleResponseOut>;
|
|
||||||
/**
|
|
||||||
* Total Responses
|
|
||||||
*/
|
|
||||||
total_responses: number;
|
|
||||||
/**
|
|
||||||
* Needs Validation
|
|
||||||
*/
|
|
||||||
needs_validation: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Id
|
* Id
|
||||||
*/
|
*/
|
||||||
id?: string;
|
id: string;
|
||||||
/**
|
/**
|
||||||
* User
|
* Game Id
|
||||||
*
|
|
||||||
* User who made the submission (null for anonymous)
|
|
||||||
*/
|
*/
|
||||||
user_id?: number | null;
|
game_id?: number | null;
|
||||||
/**
|
/**
|
||||||
* Notes
|
* Notes
|
||||||
*
|
|
||||||
* Optional notes about the submission
|
|
||||||
*/
|
*/
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
/**
|
/**
|
||||||
* Is Validated
|
* Files
|
||||||
*
|
|
||||||
* Whether this submission has been manually validated
|
|
||||||
*/
|
*/
|
||||||
is_validated?: boolean;
|
files?: Array<SubmissionFileOut>;
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
/**
|
/**
|
||||||
* Created At
|
* Created At
|
||||||
*/
|
*/
|
||||||
@@ -1381,3 +1315,76 @@ export type GamesApiListGamesResponses = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type GamesApiListGamesResponse = GamesApiListGamesResponses[keyof 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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
@@ -39,14 +39,14 @@ class SubmissionFileInline(admin.TabularInline):
|
|||||||
|
|
||||||
@admin.register(Submission)
|
@admin.register(Submission)
|
||||||
class SubmissionAdmin(admin.ModelAdmin):
|
class SubmissionAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id_short", "user", "notes_preview", "created_at"]
|
list_display = ["id_short", "game", "user", "notes_preview", "created_at"]
|
||||||
list_filter = ["created_at", "updated_at"]
|
list_filter = ["game", "created_at", "updated_at"]
|
||||||
search_fields = ["id", "user__email", "notes"]
|
search_fields = ["id", "user__email", "notes", "game__name"]
|
||||||
readonly_fields = ["id", "created_at", "updated_at"]
|
readonly_fields = ["id", "created_at", "updated_at"]
|
||||||
inlines = [SubmissionFileInline]
|
inlines = [SubmissionFileInline]
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
return super().get_queryset(request).select_related("user")
|
return super().get_queryset(request).select_related("user", "game")
|
||||||
|
|
||||||
@admin.display(description="ID")
|
@admin.display(description="ID")
|
||||||
def id_short(self, obj):
|
def id_short(self, obj):
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from ninja import Router, File, Form
|
||||||
|
from ninja.files import UploadedFile
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from games.models import Game
|
||||||
|
from .models import Submission, SubmissionFile
|
||||||
|
from .schemas import SubmissionOut
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create", response=SubmissionOut)
|
||||||
|
def create_submission(
|
||||||
|
request: HttpRequest,
|
||||||
|
game_path: str,
|
||||||
|
files: List[UploadedFile] = File(...),
|
||||||
|
notes: Optional[str] = Form(None),
|
||||||
|
):
|
||||||
|
game = get_object_or_404(Game, path=game_path, enabled=True)
|
||||||
|
|
||||||
|
submission = Submission.objects.create(
|
||||||
|
game=game,
|
||||||
|
user=request.user if request.user.is_authenticated else None,
|
||||||
|
notes=notes or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
SubmissionFile.objects.create(
|
||||||
|
submission=submission,
|
||||||
|
file=file,
|
||||||
|
original_filename=file.name,
|
||||||
|
content_type=file.content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
return submission
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{submission_id}", response=SubmissionOut)
|
||||||
|
def get_submission(request: HttpRequest, submission_id: str):
|
||||||
|
submission = get_object_or_404(Submission, id=submission_id)
|
||||||
|
|
||||||
|
if submission.user != request.user and not request.user.is_staff:
|
||||||
|
return 403, {"detail": "Not authorized"}
|
||||||
|
|
||||||
|
return submission
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/game/{game_path}")
|
||||||
|
def list_submissions_by_game(request: HttpRequest, game_path: str):
|
||||||
|
game = get_object_or_404(Game, path=game_path)
|
||||||
|
|
||||||
|
if not request.user.is_staff:
|
||||||
|
return 403, {"detail": "Admin access required"}
|
||||||
|
|
||||||
|
submissions = Submission.objects.filter(game=game)
|
||||||
|
return [SubmissionOut.from_orm(s) for s in submissions]
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-24 22:57
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0004_game_image_alter_game_steam_app_id"),
|
||||||
|
("submissions", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="submission",
|
||||||
|
name="game",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="submissions",
|
||||||
|
to="games.game",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="submission",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="User who made the submission (null for anonymous)",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="user_submissions",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from accounts.models import CustomUser
|
from accounts.models import CustomUser
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
|
||||||
class OCRStatus(models.TextChoices):
|
class OCRStatus(models.TextChoices):
|
||||||
@@ -33,13 +34,21 @@ class BaseModel(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Submission(BaseModel):
|
class Submission(BaseModel):
|
||||||
|
game = models.ForeignKey(
|
||||||
|
Game,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="submissions",
|
||||||
|
)
|
||||||
|
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
CustomUser,
|
CustomUser,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="User who made the submission (null for anonymous)",
|
help_text="User who made the submission (null for anonymous)",
|
||||||
related_name="submissions",
|
related_name="user_submissions",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Submission metadata
|
# Submission metadata
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionFileOut(Schema):
|
||||||
|
id: str
|
||||||
|
original_filename: str
|
||||||
|
file_size: int
|
||||||
|
content_type: str
|
||||||
|
file_url: Optional[str] = None
|
||||||
|
ocr_status: str
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionOut(Schema):
|
||||||
|
id: str
|
||||||
|
game_id: Optional[int] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
files: list[SubmissionFileOut] = []
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
@@ -1 +1,17 @@
|
|||||||
# Create your views here.
|
from django.http import HttpRequest
|
||||||
|
from django.shortcuts import render, get_object_or_404
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def submission_home(request: HttpRequest, game: str):
|
||||||
|
game_obj = get_object_or_404(Game, path=f"/submissions/{game}", enabled=True)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"submissions.html",
|
||||||
|
{
|
||||||
|
"game": game_obj,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% load django_vite %}
|
||||||
|
|
||||||
|
{% block title %}{{ game.name }} Submissions{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% vite_asset 'src/submissions.ts' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div id="app" data-game-path="{{ game.path }}" data-game-name="{{ game.name }}" data-game-image="{{ game.image |default:'' }}" data-game-steam-id="{{ game.steam_app_id|default:'' }}"></div>
|
||||||
|
{% endblock %}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"root":["./src/home.ts","./src/market.ts","./src/noita.ts","./src/opus-magnum.ts","./src/api/client.gen.ts","./src/api/index.ts","./src/api/sdk.gen.ts","./src/api/types.gen.ts","./src/api/client/client.gen.ts","./src/api/client/index.ts","./src/api/client/types.gen.ts","./src/api/client/utils.gen.ts","./src/api/core/auth.gen.ts","./src/api/core/bodySerializer.gen.ts","./src/api/core/params.gen.ts","./src/api/core/pathSerializer.gen.ts","./src/api/core/queryKeySerializer.gen.ts","./src/api/core/serverSentEvents.gen.ts","./src/api/core/types.gen.ts","./src/api/core/utils.gen.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/stores/index.ts","./src/stores/market.ts","./src/stores/noita.ts","./src/stores/puzzles.ts","./src/stores/submissions.ts","./src/stores/uploads.ts","./src/types/index.ts","./src/Home.vue","./src/Market.vue","./src/Noita.vue","./src/OpusMagnum.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/MarketCard.vue","./src/components/PuzzleCard.vue","./src/components/PuzzleResults.vue","./src/components/RankBadge.vue","./src/components/Results.vue","./src/components/SubmissionForm.vue","./src/components/TopUsersLeaderboard.vue","./src/components/UserBets.vue","./src/components/Winners.vue"],"version":"5.9.3"}
|
{"root":["./src/home.ts","./src/market.ts","./src/noita.ts","./src/opus-magnum.ts","./src/submissions.ts","./src/api/client.gen.ts","./src/api/index.ts","./src/api/sdk.gen.ts","./src/api/types.gen.ts","./src/api/client/client.gen.ts","./src/api/client/index.ts","./src/api/client/types.gen.ts","./src/api/client/utils.gen.ts","./src/api/core/auth.gen.ts","./src/api/core/bodySerializer.gen.ts","./src/api/core/params.gen.ts","./src/api/core/pathSerializer.gen.ts","./src/api/core/queryKeySerializer.gen.ts","./src/api/core/serverSentEvents.gen.ts","./src/api/core/types.gen.ts","./src/api/core/utils.gen.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/stores/index.ts","./src/stores/market.ts","./src/stores/noita.ts","./src/stores/puzzles.ts","./src/stores/submissions.ts","./src/stores/uploads.ts","./src/stores/user.ts","./src/types/index.ts","./src/Home.vue","./src/Market.vue","./src/Noita.vue","./src/OpusMagnum.vue","./src/Submissions.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/MarketCard.vue","./src/components/PuzzleCard.vue","./src/components/PuzzleResults.vue","./src/components/RankBadge.vue","./src/components/Results.vue","./src/components/SubmissionForm.vue","./src/components/TopUsersLeaderboard.vue","./src/components/UserBets.vue","./src/components/Winners.vue"],"errors":true,"version":"5.9.3"}
|
||||||
@@ -24,6 +24,7 @@ export default defineConfig({
|
|||||||
home: resolve('./src/home.ts'),
|
home: resolve('./src/home.ts'),
|
||||||
noita: resolve('./src/noita.ts'),
|
noita: resolve('./src/noita.ts'),
|
||||||
market: resolve('./src/market.ts'),
|
market: resolve('./src/market.ts'),
|
||||||
|
submissions: resolve('./src/submissions.ts'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default defineConfig({
|
|||||||
home: resolve('./src/home.ts'),
|
home: resolve('./src/home.ts'),
|
||||||
noita: resolve('./src/noita.ts'),
|
noita: resolve('./src/noita.ts'),
|
||||||
market: resolve('./src/market.ts'),
|
market: resolve('./src/market.ts'),
|
||||||
|
submissions: resolve('./src/submissions.ts'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user