From 0fc1fb255fd652762eafcffb4c47942d1b856cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Mon, 25 May 2026 02:34:23 +0200 Subject: [PATCH] feat(submissions): crop management --- polylan_submitter/src/Submissions.vue | 361 +++++++++++++++++++---- polylan_submitter/src/api/index.ts | 4 +- polylan_submitter/src/api/sdk.gen.ts | 27 +- polylan_submitter/src/api/types.gen.ts | 126 +++++++- polylan_submitter/submissions/api.py | 50 +++- polylan_submitter/submissions/schemas.py | 28 +- 6 files changed, 518 insertions(+), 78 deletions(-) diff --git a/polylan_submitter/src/Submissions.vue b/polylan_submitter/src/Submissions.vue index ad05e10..6b12157 100644 --- a/polylan_submitter/src/Submissions.vue +++ b/polylan_submitter/src/Submissions.vue @@ -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([]); const isLoadingSubmissions = ref(false); const selectedSubmissionFile = ref(null); const isSubmissionFileModalOpen = ref(false); -const imageAspectRatio = ref<"portrait" | "landscape">("landscape"); const copyFeedback = ref(""); +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(() => {
-