feat(submissions): crop management

This commit is contained in:
2026-05-25 02:34:23 +02:00
parent a769d1a0b3
commit 0fc1fb255f
6 changed files with 518 additions and 78 deletions
+305 -56
View File
@@ -2,7 +2,7 @@
import { ref, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useUserStore } from "@/stores/user";
import { submissionsApiCreateSubmission, submissionsApiListUserSubmissions } from "@/api";
import { submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop } from "@/api";
interface Props {
gamePath?: string;
@@ -32,8 +32,13 @@ const userSubmissions = ref<any[]>([]);
const isLoadingSubmissions = ref(false);
const selectedSubmissionFile = ref<any>(null);
const isSubmissionFileModalOpen = ref(false);
const imageAspectRatio = ref<"portrait" | "landscape">("landscape");
const copyFeedback = ref<string>("");
const isDrawingCrop = ref(false);
const cropStartX = ref(0);
const cropStartY = ref(0);
const currentCrop = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
const cropLabel = ref("");
const isCreatingCrop = ref(false);
const openFilePreview = async (file: File) => {
selectedFile.value = file;
@@ -65,12 +70,50 @@ const closeModal = () => {
const openSubmissionFileModal = (file: any) => {
selectedSubmissionFile.value = file;
isSubmissionFileModalOpen.value = true;
currentCrop.value = null;
cropLabel.value = "";
// Check image dimensions for aspect ratio
if (file?.content_type?.startsWith("image/")) {
const img = new Image();
img.onload = () => {
imageAspectRatio.value = img.height > img.width ? "portrait" : "landscape";
// Initialize canvas for crop drawing
setTimeout(() => {
const canvas = document.getElementById("cropCanvas") as HTMLCanvasElement;
if (canvas && userInfo.value.isStaff) {
const container = canvas.parentElement;
if (container) {
// Set canvas resolution to image size for accurate crop coordinates
canvas.width = img.width;
canvas.height = img.height;
// Calculate scaling to fit container while maintaining aspect ratio
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
const imgAspect = img.width / img.height;
const containerAspect = containerWidth / containerHeight;
let displayWidth, displayHeight;
if (imgAspect > containerAspect) {
displayWidth = containerWidth;
displayHeight = containerWidth / imgAspect;
} else {
displayHeight = containerHeight;
displayWidth = containerHeight * imgAspect;
}
// Apply CSS scaling to display the image at correct size
canvas.style.width = displayWidth + "px";
canvas.style.height = displayHeight + "px";
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.drawImage(img, 0, 0);
redrawCropCanvas();
}
}
}
}, 0);
};
img.src = file.file_url;
}
@@ -98,6 +141,150 @@ const copyOCRText = async () => {
}
};
const getCanvasCoordinates = (canvas: HTMLCanvasElement, clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = (clientX - rect.left) * scaleX;
const y = (clientY - rect.top) * scaleY;
return { x, y };
};
const startCropDraw = (e: MouseEvent) => {
const canvas = e.currentTarget as HTMLCanvasElement;
const coords = getCanvasCoordinates(canvas, e.clientX, e.clientY);
cropStartX.value = coords.x;
cropStartY.value = coords.y;
isDrawingCrop.value = true;
};
const drawCrop = (e: MouseEvent) => {
if (!isDrawingCrop.value) return;
const canvas = e.currentTarget as HTMLCanvasElement;
const coords = getCanvasCoordinates(canvas, e.clientX, e.clientY);
currentCrop.value = {
x1: Math.min(cropStartX.value, coords.x),
y1: Math.min(cropStartY.value, coords.y),
x2: Math.max(cropStartX.value, coords.x),
y2: Math.max(cropStartY.value, coords.y),
};
redrawCropCanvas();
};
const endCropDraw = () => {
isDrawingCrop.value = false;
};
const redrawCropCanvas = () => {
const canvas = document.getElementById("cropCanvas") as HTMLCanvasElement;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
if (currentCrop.value) {
ctx.fillStyle = "rgba(59, 130, 246, 0.1)";
ctx.fillRect(
currentCrop.value.x1,
currentCrop.value.y1,
currentCrop.value.x2 - currentCrop.value.x1,
currentCrop.value.y2 - currentCrop.value.y1
);
ctx.strokeStyle = "#3b82f6";
ctx.lineWidth = 2;
ctx.strokeRect(
currentCrop.value.x1,
currentCrop.value.y1,
currentCrop.value.x2 - currentCrop.value.x1,
currentCrop.value.y2 - currentCrop.value.y1
);
}
selectedSubmissionFile.value?.crops?.forEach((crop: any) => {
ctx.fillStyle = "rgba(34, 197, 94, 0.1)";
ctx.fillRect(
crop.x1,
crop.y1,
crop.x2 - crop.x1,
crop.y2 - crop.y1
);
ctx.strokeStyle = "#22c55e";
ctx.lineWidth = 1;
ctx.strokeRect(
crop.x1,
crop.y1,
crop.x2 - crop.x1,
crop.y2 - crop.y1
);
});
};
img.src = selectedSubmissionFile.value?.file_url;
};
const submitCrop = async () => {
if (!currentCrop.value || !selectedSubmissionFile.value) return;
isCreatingCrop.value = true;
try {
const { data: newCrop } = await submissionsApiCreateImageCrop({
path: { submission_file_id: selectedSubmissionFile.value.id },
body: {
x1: Math.round(currentCrop.value.x1),
y1: Math.round(currentCrop.value.y1),
x2: Math.round(currentCrop.value.x2),
y2: Math.round(currentCrop.value.y2),
label: cropLabel.value || null,
},
});
// Add the new crop to the list immediately
if (newCrop) {
selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops || [];
selectedSubmissionFile.value.crops.push(newCrop);
}
currentCrop.value = null;
cropLabel.value = "";
redrawCropCanvas();
} catch (error) {
console.error("Error creating crop:", error);
alert(`Error creating crop: ${error instanceof Error ? error.message : "Please try again."}`);
} finally {
isCreatingCrop.value = false;
}
};
const deleteCrop = async (cropId: string) => {
if (!confirm("Delete this crop?")) return;
try {
await submissionsApiDeleteImageCrop({
path: { crop_id: cropId },
});
selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops.filter(
(c: any) => c.id !== cropId
);
redrawCropCanvas();
} catch (error) {
console.error("Error deleting crop:", error);
alert(`Error deleting crop: ${error instanceof Error ? error.message : "Please try again."}`);
}
};
const cancelCrop = () => {
currentCrop.value = null;
cropLabel.value = "";
redrawCropCanvas();
};
const getGameHeaderImage = () => {
if (props.gameImage) {
return props.gameImage;
@@ -533,22 +720,26 @@ onMounted(() => {
<!-- Submission File Modal -->
<div :class="['modal', { 'modal-open': isSubmissionFileModalOpen }]">
<div class="modal-box w-11/12 max-w-6xl max-h-[90vh] overflow-hidden">
<div class="modal-box w-11/12 max-w-7xl max-h-[95vh] overflow-hidden flex flex-col">
<h3 class="font-bold text-lg mb-4">
<i :class="['mdi mr-2', getFileIcon({ type: selectedSubmissionFile?.content_type })]"></i>
{{ selectedSubmissionFile?.original_filename }}
</h3>
<!-- Responsive Layout: Portrait images side-by-side (left/right), landscape images and other files stacked (top/bottom) -->
<div :class="['gap-4 h-[70vh]', imageAspectRatio === 'portrait' ? 'grid grid-cols-1 lg:grid-cols-2' : 'flex flex-col']">
<!-- Layout: File Preview on top, OCR and Crops side by side below -->
<div class="gap-4 h-[80vh] w-full overflow-hidden flex flex-col">
<!-- File Display -->
<div class="flex flex-col overflow-hidden" :class="imageAspectRatio === 'portrait' ? 'h-full' : 'flex-1'">
<div class="flex flex-col overflow-hidden flex-[1.5]">
<p class="text-sm text-base-content/70 mb-2">File Preview</p>
<div class="flex-1 overflow-auto border border-base-300 rounded-lg">
<!-- Image Preview -->
<!-- Image Preview with Crop Drawing (Admin Only) -->
<div v-if="selectedSubmissionFile?.content_type.startsWith('image/')"
class="flex items-center justify-center h-full p-4">
<img :src="selectedSubmissionFile.file_url" :alt="selectedSubmissionFile.original_filename"
<canvas v-if="userInfo.isStaff" id="cropCanvas" @mousedown="startCropDraw" @mousemove="drawCrop"
@mouseup="endCropDraw" @mouseleave="endCropDraw"
class="border border-dashed border-blue-400 cursor-crosshair"
style="max-height: 100%; max-width: 100%;"></canvas>
<img v-else :src="selectedSubmissionFile.file_url" :alt="selectedSubmissionFile.original_filename"
class="max-w-full max-h-full object-contain rounded" />
</div>
@@ -573,60 +764,113 @@ onMounted(() => {
</div>
</div>
</div>
<div class="mt-2">
<a v-if="selectedSubmissionFile?.file_url" :href="selectedSubmissionFile.file_url" download
class="btn btn-sm btn-outline w-full">
<i class="mdi mdi-download mr-2"></i>
Download File
</a>
</div>
</div>
<!-- OCR Data -->
<div class="flex flex-col overflow-hidden" :class="imageAspectRatio === 'portrait' ? 'h-full' : 'flex-1'">
<p class="text-sm text-base-content/70 mb-2">OCR Data</p>
<div class="flex-1 overflow-auto border border-base-300 rounded-lg p-4 bg-base-200">
<div v-if="selectedSubmissionFile?.ocr_status === 'processed' && selectedSubmissionFile.ocr_raw_data">
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-base-content/70 mb-1">Status:</p>
<span :class="getOCRStatusColor(selectedSubmissionFile.ocr_status)">{{
selectedSubmissionFile.ocr_status }}</span>
<!-- OCR Data and Crops Data Row -->
<div class="gap-4 w-full overflow-hidden flex flex-row flex-1">
<!-- OCR Data -->
<div class="flex flex-col overflow-hidden flex-1">
<p class="text-sm text-base-content/70 mb-2">OCR Data</p>
<div class="flex-1 overflow-auto border border-base-300 rounded-lg p-4 bg-base-200">
<div v-if="selectedSubmissionFile?.ocr_status === 'processed' && selectedSubmissionFile.ocr_raw_data">
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-base-content/70 mb-1">Status:</p>
<span :class="getOCRStatusColor(selectedSubmissionFile.ocr_status)">{{
selectedSubmissionFile.ocr_status }}</span>
</div>
<div class="divider my-2"></div>
<div>
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-semibold text-base-content/70">Extracted Text:</p>
<button @click="copyOCRText" class="btn btn-xs btn-ghost gap-1">
<i class="mdi mdi-content-copy"></i>
{{ copyFeedback || "Copy" }}
</button>
</div>
<div
class="bg-base-100 p-3 rounded text-xs whitespace-pre-wrap break-words font-mono overflow-y-auto h-full">
{{ selectedSubmissionFile.ocr_raw_data }}
</div>
</div>
</div>
<div class="divider my-2"></div>
<div>
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-semibold text-base-content/70">Extracted Text:</p>
<button @click="copyOCRText" class="btn btn-xs btn-ghost gap-1">
<i class="mdi mdi-content-copy"></i>
{{ copyFeedback || "Copy" }}
</div>
<div v-else-if="selectedSubmissionFile?.ocr_status === 'processing'"
class="flex items-center justify-center h-full">
<div class="text-center">
<span class="loading loading-spinner loading-lg"></span>
<p class="text-base-content/70 mt-4">OCR is being processed...</p>
</div>
</div>
<div v-else-if="selectedSubmissionFile?.ocr_status === 'error'"
class="flex items-center justify-center h-full">
<div class="text-center">
<i class="mdi mdi-alert-circle text-4xl text-error mb-2"></i>
<p class="text-base-content/70">OCR processing failed</p>
</div>
</div>
<div v-else class="flex items-center justify-center h-full">
<div class="text-center">
<i class="mdi mdi-clock-outline text-4xl text-base-content/30 mb-2"></i>
<p class="text-base-content/70">Waiting for OCR processing...</p>
</div>
</div>
</div>
</div>
<!-- Crops Data -->
<div v-if="userInfo.isStaff" class="flex flex-col overflow-hidden flex-1">
<p class="text-sm text-base-content/70 mb-2">Crops Data</p>
<div class="flex-1 overflow-auto border border-base-300 rounded-lg p-4 bg-base-100 flex flex-col gap-4">
<!-- Crop Drawing Hints -->
<div>
<p class="text-xs text-base-content/50 text-center mb-2">
<i class="mdi mdi-information-outline"></i>
Drag on image to create crop (blue) or existing crops (green)
</p>
</div>
<!-- Crop Form -->
<div v-if="currentCrop" class="p-2 rounded border border-blue-200">
<div class="space-y-2">
<div class="form-control">
<label class="label">
<span class="label-text text-sm">Crop Label (optional)</span>
</label>
<input v-model="cropLabel" type="text" placeholder="e.g., Top-left corner"
class="input input-sm input-bordered" />
</div>
<div class="flex gap-2">
<button @click="submitCrop" :disabled="isCreatingCrop" class="btn btn-sm btn-primary flex-1">
<i v-if="!isCreatingCrop" class="mdi mdi-content-save"></i>
{{ isCreatingCrop ? "Creating..." : "Save Crop" }}
</button>
<button @click="cancelCrop" class="btn btn-sm btn-ghost flex-1">Cancel</button>
</div>
</div>
</div>
<!-- Crops List -->
<div v-if="selectedSubmissionFile?.crops?.length" class="flex-1 overflow-y-auto">
<p class="text-xs font-semibold text-base-content/70 mb-2">Crops ({{
selectedSubmissionFile.crops.length }}):</p>
<div class="space-y-1">
<div v-for="crop in selectedSubmissionFile.crops" :key="crop.id"
class="flex items-center justify-between p-1 rounded border border-green-200 text-xs">
<div class="flex-1 min-w-0">
<p class="font-medium truncate">{{ crop.label || `Crop (${crop.x1}, ${crop.y1})` }}</p>
<p class="text-xs text-base-content/50">
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span>
</p>
</div>
<button @click="deleteCrop(crop.id)" class="btn btn-xs btn-ghost text-error flex-shrink-0">
<i class="mdi mdi-delete"></i>
</button>
</div>
<div
class="bg-base-100 p-3 rounded text-xs whitespace-pre-wrap break-words font-mono overflow-y-auto h-full">
{{ selectedSubmissionFile.ocr_raw_data }}
</div>
</div>
</div>
</div>
<div v-else-if="selectedSubmissionFile?.ocr_status === 'processing'"
class="flex items-center justify-center h-full">
<div class="text-center">
<span class="loading loading-spinner loading-lg"></span>
<p class="text-base-content/70 mt-4">OCR is being processed...</p>
</div>
</div>
<div v-else-if="selectedSubmissionFile?.ocr_status === 'error'"
class="flex items-center justify-center h-full">
<div class="text-center">
<i class="mdi mdi-alert-circle text-4xl text-error mb-2"></i>
<p class="text-base-content/70">OCR processing failed</p>
</div>
</div>
<div v-else class="flex items-center justify-center h-full">
<div class="text-center">
<i class="mdi mdi-clock-outline text-4xl text-base-content/30 mb-2"></i>
<p class="text-base-content/70">Waiting for OCR processing...</p>
<div v-else class="text-center text-xs text-base-content/50 py-4">
No crops yet
</div>
</div>
</div>
@@ -634,6 +878,11 @@ onMounted(() => {
</div>
<div class="modal-action mt-4">
<a v-if="selectedSubmissionFile?.file_url" :href="selectedSubmissionFile.file_url" download
class="btn btn-outline">
<i class="mdi mdi-download mr-2"></i>
Download
</a>
<button @click="closeSubmissionFileModal" class="btn">Close</button>
</div>
</div>
+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, submissionsApiCreateSubmission, submissionsApiGetSubmission, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions } 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, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiListUserSubmissionsResponses, 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, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions } from './sdk.gen';
export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, GameOut, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, ImageCropIn, ImageCropOut, 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, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionResponse, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponse, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiGetSubmissionResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiListUserSubmissionsResponses, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen';
+22 -5
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, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsResponses } 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, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/**
@@ -196,7 +196,7 @@ export const gamesApiListGames = <ThrowOnError extends boolean = false>(options?
*/
export const submissionsApiCreateSubmission = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiCreateSubmissionData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiCreateSubmissionResponses, unknown, ThrowOnError>({
...formDataBodySerializer,
url: '/api/submissions/create',
url: '/api/submissions/submission/create',
...options,
headers: {
'Content-Type': null,
@@ -207,14 +207,31 @@ export const submissionsApiCreateSubmission = <ThrowOnError extends boolean = fa
/**
* List User Submissions
*/
export const submissionsApiListUserSubmissions = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListUserSubmissionsData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListUserSubmissionsResponses, unknown, ThrowOnError>({ url: '/api/submissions/user', ...options });
export const submissionsApiListUserSubmissions = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListUserSubmissionsData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListUserSubmissionsResponses, unknown, ThrowOnError>({ url: '/api/submissions/submission/by/user', ...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', ...options });
export const submissionsApiListSubmissionsByGame = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListSubmissionsByGameData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListSubmissionsByGameResponses, unknown, ThrowOnError>({ url: '/api/submissions/submission/by/game', ...options });
/**
* 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 });
export const submissionsApiGetSubmission = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiGetSubmissionData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiGetSubmissionResponses, unknown, ThrowOnError>({ url: '/api/submissions/submission/{submission_id}', ...options });
/**
* Create Image Crop
*/
export const submissionsApiCreateImageCrop = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiCreateImageCropData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiCreateImageCropResponses, unknown, ThrowOnError>({
url: '/api/submissions/crops/s/{submission_file_id}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Delete Image Crop
*/
export const submissionsApiDeleteImageCrop = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiDeleteImageCropData, ThrowOnError>) => (options.client ?? client).delete<SubmissionsApiDeleteImageCropResponses, unknown, ThrowOnError>({ url: '/api/submissions/crops/{crop_id}', ...options });
+122 -4
View File
@@ -371,6 +371,10 @@ export type SubmissionFileOut = {
* Ocr Raw Data
*/
ocr_raw_data: string;
/**
* Crops
*/
crops?: Array<ImageCropOut>;
};
/**
@@ -912,6 +916,74 @@ export type GameOut = {
image?: string | null;
};
/**
* ImageCropOut
*/
export type ImageCropOut = {
/**
* Id
*/
id: string;
/**
* X1
*/
x1: number;
/**
* Y1
*/
y1: number;
/**
* X2
*/
x2: number;
/**
* Y2
*/
y2: number;
/**
* Label
*/
label: string;
/**
* Ocr Status
*/
ocr_status: string;
/**
* Ocr Raw Data
*/
ocr_raw_data: string;
/**
* Created At
*/
created_at: string;
};
/**
* ImageCropIn
*/
export type ImageCropIn = {
/**
* X1
*/
x1: number;
/**
* Y1
*/
y1: number;
/**
* X2
*/
x2: number;
/**
* Y2
*/
y2: number;
/**
* Label
*/
label?: string | null;
};
export type PolylanSubmitterApiHealthCheckData = {
body?: never;
path?: never;
@@ -1341,7 +1413,7 @@ export type SubmissionsApiCreateSubmissionData = {
*/
game_path: string;
};
url: '/api/submissions/create';
url: '/api/submissions/submission/create';
};
export type SubmissionsApiCreateSubmissionResponses = {
@@ -1362,7 +1434,7 @@ export type SubmissionsApiListUserSubmissionsData = {
*/
game_path: string;
};
url: '/api/submissions/user';
url: '/api/submissions/submission/by/user';
};
export type SubmissionsApiListUserSubmissionsResponses = {
@@ -1385,7 +1457,7 @@ export type SubmissionsApiListSubmissionsByGameData = {
*/
game_path: string;
};
url: '/api/submissions/game';
url: '/api/submissions/submission/by/game';
};
export type SubmissionsApiListSubmissionsByGameResponses = {
@@ -1408,7 +1480,7 @@ export type SubmissionsApiGetSubmissionData = {
submission_id: string;
};
query?: never;
url: '/api/submissions/{submission_id}';
url: '/api/submissions/submission/{submission_id}';
};
export type SubmissionsApiGetSubmissionResponses = {
@@ -1419,3 +1491,49 @@ export type SubmissionsApiGetSubmissionResponses = {
};
export type SubmissionsApiGetSubmissionResponse = SubmissionsApiGetSubmissionResponses[keyof SubmissionsApiGetSubmissionResponses];
export type SubmissionsApiCreateImageCropData = {
body: ImageCropIn;
path: {
/**
* Submission File Id
*/
submission_file_id: string;
};
query?: never;
url: '/api/submissions/crops/s/{submission_file_id}';
};
export type SubmissionsApiCreateImageCropResponses = {
/**
* OK
*/
200: ImageCropOut;
};
export type SubmissionsApiCreateImageCropResponse = SubmissionsApiCreateImageCropResponses[keyof SubmissionsApiCreateImageCropResponses];
export type SubmissionsApiDeleteImageCropData = {
body?: never;
path: {
/**
* Crop Id
*/
crop_id: string;
};
query?: never;
url: '/api/submissions/crops/{crop_id}';
};
export type SubmissionsApiDeleteImageCropResponses = {
/**
* Response
*
* OK
*/
200: {
[key: string]: unknown;
};
};
export type SubmissionsApiDeleteImageCropResponse = SubmissionsApiDeleteImageCropResponses[keyof SubmissionsApiDeleteImageCropResponses];
+43 -7
View File
@@ -1,17 +1,18 @@
from typing import List, Optional
from uuid import UUID
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
from .models import Submission, SubmissionFile, ImageCrop, OCRStatus
from .schemas import SubmissionOut, ImageCropIn, ImageCropOut
router = Router()
@router.post("/create", response=SubmissionOut)
@router.post("/submission/create", response=SubmissionOut)
def create_submission(
request: HttpRequest,
game_path: str,
@@ -37,7 +38,7 @@ def create_submission(
return submission
@router.get("/user", response=list[SubmissionOut])
@router.get("/submission/by/user", response=list[SubmissionOut])
def list_user_submissions(request: HttpRequest, game_path: str):
game = get_object_or_404(Game, path=game_path)
@@ -50,7 +51,7 @@ def list_user_submissions(request: HttpRequest, game_path: str):
return submissions
@router.get("/game", response=list[SubmissionOut])
@router.get("/submission/by/game", response=list[SubmissionOut])
def list_submissions_by_game(request: HttpRequest, game_path: str):
game = get_object_or_404(Game, path=game_path)
@@ -60,11 +61,46 @@ def list_submissions_by_game(request: HttpRequest, game_path: str):
return Submission.objects.filter(game=game)
@router.get("/{submission_id}", response=SubmissionOut)
def get_submission(request: HttpRequest, submission_id: str):
@router.get("/submission/{submission_id}", response=SubmissionOut)
def get_submission(request: HttpRequest, submission_id: UUID):
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.post("/crops/s/{submission_file_id}", response=ImageCropOut)
def create_image_crop(
request: HttpRequest,
submission_file_id: UUID,
data: ImageCropIn,
):
if not request.user.is_staff:
return 403, {"detail": "Admin access required"}
submission_file = get_object_or_404(SubmissionFile, id=submission_file_id)
crop = ImageCrop.objects.create(
submission_file=submission_file,
x1=data.x1,
y1=data.y1,
x2=data.x2,
y2=data.y2,
label=data.label or "",
ocr_status=OCRStatus.TO_PROCESS,
)
return crop
@router.delete("/crops/{crop_id}", response=dict)
def delete_image_crop(request: HttpRequest, crop_id: UUID):
if not request.user.is_staff:
return 403, {"detail": "Admin access required"}
crop = get_object_or_404(ImageCrop, id=crop_id)
crop.delete()
return {"status": "deleted"}
+24 -4
View File
@@ -1,23 +1,43 @@
from typing import Optional
from datetime import datetime
from uuid import UUID
from ninja import Schema
class ImageCropIn(Schema):
x1: int
y1: int
x2: int
y2: int
label: str | None = None
class ImageCropOut(Schema):
id: UUID
x1: int
y1: int
x2: int
y2: int
label: str
ocr_status: str
ocr_raw_data: str
created_at: datetime
class SubmissionFileOut(Schema):
id: UUID
original_filename: str
content_type: str
file_size: int
file_url: Optional[str] = None
file_url: str | None = None
ocr_status: str
ocr_raw_data: str
crops: list[ImageCropOut] = []
class SubmissionOut(Schema):
id: UUID
game_id: Optional[int] = None
notes: Optional[str] = None
game_id: int | None = None
notes: str | None = None
files: list[SubmissionFileOut] = []
created_at: datetime
updated_at: datetime