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>