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 { ref, onMounted } from "vue";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { submissionsApiCreateSubmission, submissionsApiListUserSubmissions } from "@/api"; import { submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop } from "@/api";
interface Props { interface Props {
gamePath?: string; gamePath?: string;
@@ -32,8 +32,13 @@ const userSubmissions = ref<any[]>([]);
const isLoadingSubmissions = ref(false); const isLoadingSubmissions = ref(false);
const selectedSubmissionFile = ref<any>(null); const selectedSubmissionFile = ref<any>(null);
const isSubmissionFileModalOpen = ref(false); const isSubmissionFileModalOpen = ref(false);
const imageAspectRatio = ref<"portrait" | "landscape">("landscape");
const copyFeedback = ref<string>(""); 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) => { const openFilePreview = async (file: File) => {
selectedFile.value = file; selectedFile.value = file;
@@ -65,12 +70,50 @@ const closeModal = () => {
const openSubmissionFileModal = (file: any) => { const openSubmissionFileModal = (file: any) => {
selectedSubmissionFile.value = file; selectedSubmissionFile.value = file;
isSubmissionFileModalOpen.value = true; isSubmissionFileModalOpen.value = true;
currentCrop.value = null;
cropLabel.value = "";
// Check image dimensions for aspect ratio // Check image dimensions for aspect ratio
if (file?.content_type?.startsWith("image/")) { if (file?.content_type?.startsWith("image/")) {
const img = new Image(); const img = new Image();
img.onload = () => { 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; 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 = () => { const getGameHeaderImage = () => {
if (props.gameImage) { if (props.gameImage) {
return props.gameImage; return props.gameImage;
@@ -533,22 +720,26 @@ onMounted(() => {
<!-- Submission File Modal --> <!-- Submission File Modal -->
<div :class="['modal', { 'modal-open': isSubmissionFileModalOpen }]"> <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"> <h3 class="font-bold text-lg mb-4">
<i :class="['mdi mr-2', getFileIcon({ type: selectedSubmissionFile?.content_type })]"></i> <i :class="['mdi mr-2', getFileIcon({ type: selectedSubmissionFile?.content_type })]"></i>
{{ selectedSubmissionFile?.original_filename }} {{ selectedSubmissionFile?.original_filename }}
</h3> </h3>
<!-- Responsive Layout: Portrait images side-by-side (left/right), landscape images and other files stacked (top/bottom) --> <!-- Layout: File Preview on top, OCR and Crops side by side below -->
<div :class="['gap-4 h-[70vh]', imageAspectRatio === 'portrait' ? 'grid grid-cols-1 lg:grid-cols-2' : 'flex flex-col']"> <div class="gap-4 h-[80vh] w-full overflow-hidden flex flex-col">
<!-- File Display --> <!-- File Display -->
<div class="flex flex-col overflow-hidden" :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> <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"> <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/')" <div v-if="selectedSubmissionFile?.content_type.startsWith('image/')"
class="flex items-center justify-center h-full p-4"> 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" /> class="max-w-full max-h-full object-contain rounded" />
</div> </div>
@@ -573,60 +764,113 @@ onMounted(() => {
</div> </div>
</div> </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> </div>
<!-- OCR Data --> <!-- OCR Data and Crops Data Row -->
<div class="flex flex-col overflow-hidden" :class="imageAspectRatio === 'portrait' ? 'h-full' : 'flex-1'"> <div class="gap-4 w-full overflow-hidden flex flex-row flex-1">
<p class="text-sm text-base-content/70 mb-2">OCR Data</p> <!-- OCR Data -->
<div class="flex-1 overflow-auto border border-base-300 rounded-lg p-4 bg-base-200"> <div class="flex flex-col overflow-hidden flex-1">
<div v-if="selectedSubmissionFile?.ocr_status === 'processed' && selectedSubmissionFile.ocr_raw_data"> <p class="text-sm text-base-content/70 mb-2">OCR Data</p>
<div class="space-y-3"> <div class="flex-1 overflow-auto border border-base-300 rounded-lg p-4 bg-base-200">
<div> <div v-if="selectedSubmissionFile?.ocr_status === 'processed' && selectedSubmissionFile.ocr_raw_data">
<p class="text-xs font-semibold text-base-content/70 mb-1">Status:</p> <div class="space-y-3">
<span :class="getOCRStatusColor(selectedSubmissionFile.ocr_status)">{{ <div>
selectedSubmissionFile.ocr_status }}</span> <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>
<div class="divider my-2"></div> </div>
<div> <div v-else-if="selectedSubmissionFile?.ocr_status === 'processing'"
<div class="flex items-center justify-between mb-2"> class="flex items-center justify-center h-full">
<p class="text-xs font-semibold text-base-content/70">Extracted Text:</p> <div class="text-center">
<button @click="copyOCRText" class="btn btn-xs btn-ghost gap-1"> <span class="loading loading-spinner loading-lg"></span>
<i class="mdi mdi-content-copy"></i> <p class="text-base-content/70 mt-4">OCR is being processed...</p>
{{ copyFeedback || "Copy" }} </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> </button>
</div> </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>
</div> <div v-else class="text-center text-xs text-base-content/50 py-4">
<div v-else-if="selectedSubmissionFile?.ocr_status === 'processing'" No crops yet
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>
</div> </div>
@@ -634,6 +878,11 @@ onMounted(() => {
</div> </div>
<div class="modal-action mt-4"> <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> <button @click="closeSubmissionFileModal" class="btn">Close</button>
</div> </div>
</div> </div>
+2 -2
View File
@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, 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 { 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, 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 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 { 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, 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> & { 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>({ export const submissionsApiCreateSubmission = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiCreateSubmissionData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiCreateSubmissionResponses, unknown, ThrowOnError>({
...formDataBodySerializer, ...formDataBodySerializer,
url: '/api/submissions/create', url: '/api/submissions/submission/create',
...options, ...options,
headers: { headers: {
'Content-Type': null, 'Content-Type': null,
@@ -207,14 +207,31 @@ export const submissionsApiCreateSubmission = <ThrowOnError extends boolean = fa
/** /**
* List User Submissions * 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 * 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 * 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
*/ */
ocr_raw_data: string; ocr_raw_data: string;
/**
* Crops
*/
crops?: Array<ImageCropOut>;
}; };
/** /**
@@ -912,6 +916,74 @@ export type GameOut = {
image?: string | null; 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 = { export type PolylanSubmitterApiHealthCheckData = {
body?: never; body?: never;
path?: never; path?: never;
@@ -1341,7 +1413,7 @@ export type SubmissionsApiCreateSubmissionData = {
*/ */
game_path: string; game_path: string;
}; };
url: '/api/submissions/create'; url: '/api/submissions/submission/create';
}; };
export type SubmissionsApiCreateSubmissionResponses = { export type SubmissionsApiCreateSubmissionResponses = {
@@ -1362,7 +1434,7 @@ export type SubmissionsApiListUserSubmissionsData = {
*/ */
game_path: string; game_path: string;
}; };
url: '/api/submissions/user'; url: '/api/submissions/submission/by/user';
}; };
export type SubmissionsApiListUserSubmissionsResponses = { export type SubmissionsApiListUserSubmissionsResponses = {
@@ -1385,7 +1457,7 @@ export type SubmissionsApiListSubmissionsByGameData = {
*/ */
game_path: string; game_path: string;
}; };
url: '/api/submissions/game'; url: '/api/submissions/submission/by/game';
}; };
export type SubmissionsApiListSubmissionsByGameResponses = { export type SubmissionsApiListSubmissionsByGameResponses = {
@@ -1408,7 +1480,7 @@ export type SubmissionsApiGetSubmissionData = {
submission_id: string; submission_id: string;
}; };
query?: never; query?: never;
url: '/api/submissions/{submission_id}'; url: '/api/submissions/submission/{submission_id}';
}; };
export type SubmissionsApiGetSubmissionResponses = { export type SubmissionsApiGetSubmissionResponses = {
@@ -1419,3 +1491,49 @@ export type SubmissionsApiGetSubmissionResponses = {
}; };
export type SubmissionsApiGetSubmissionResponse = SubmissionsApiGetSubmissionResponses[keyof 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 typing import List, Optional
from uuid import UUID
from django.http import HttpRequest from django.http import HttpRequest
from ninja import Router, File, Form from ninja import Router, File, Form
from ninja.files import UploadedFile from ninja.files import UploadedFile
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from games.models import Game from games.models import Game
from .models import Submission, SubmissionFile from .models import Submission, SubmissionFile, ImageCrop, OCRStatus
from .schemas import SubmissionOut from .schemas import SubmissionOut, ImageCropIn, ImageCropOut
router = Router() router = Router()
@router.post("/create", response=SubmissionOut) @router.post("/submission/create", response=SubmissionOut)
def create_submission( def create_submission(
request: HttpRequest, request: HttpRequest,
game_path: str, game_path: str,
@@ -37,7 +38,7 @@ def create_submission(
return 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): def list_user_submissions(request: HttpRequest, game_path: str):
game = get_object_or_404(Game, path=game_path) game = get_object_or_404(Game, path=game_path)
@@ -50,7 +51,7 @@ def list_user_submissions(request: HttpRequest, game_path: str):
return submissions 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): def list_submissions_by_game(request: HttpRequest, game_path: str):
game = get_object_or_404(Game, path=game_path) 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) return Submission.objects.filter(game=game)
@router.get("/{submission_id}", response=SubmissionOut) @router.get("/submission/{submission_id}", response=SubmissionOut)
def get_submission(request: HttpRequest, submission_id: str): def get_submission(request: HttpRequest, submission_id: UUID):
submission = get_object_or_404(Submission, id=submission_id) submission = get_object_or_404(Submission, id=submission_id)
if submission.user != request.user and not request.user.is_staff: if submission.user != request.user and not request.user.is_staff:
return 403, {"detail": "Not authorized"} return 403, {"detail": "Not authorized"}
return submission 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 datetime import datetime
from uuid import UUID from uuid import UUID
from ninja import Schema 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): class SubmissionFileOut(Schema):
id: UUID id: UUID
original_filename: str original_filename: str
content_type: str content_type: str
file_size: int file_size: int
file_url: Optional[str] = None file_url: str | None = None
ocr_status: str ocr_status: str
ocr_raw_data: str ocr_raw_data: str
crops: list[ImageCropOut] = []
class SubmissionOut(Schema): class SubmissionOut(Schema):
id: UUID id: UUID
game_id: Optional[int] = None game_id: int | None = None
notes: Optional[str] = None notes: str | None = None
files: list[SubmissionFileOut] = [] files: list[SubmissionFileOut] = []
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime