1044 lines
40 KiB
Vue
1044 lines
40 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted } from "vue";
|
|
import { storeToRefs } from "pinia";
|
|
import { useUserStore } from "@/stores/user";
|
|
import { submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api";
|
|
import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket";
|
|
|
|
interface Props {
|
|
gamePath?: string;
|
|
gameName?: string;
|
|
gameImage?: string;
|
|
gameSteamId?: string;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
gamePath: "",
|
|
gameName: "Game",
|
|
gameImage: "",
|
|
gameSteamId: "",
|
|
});
|
|
|
|
const userStore = useUserStore();
|
|
const { userInfo } = storeToRefs(userStore);
|
|
|
|
const uploadedFiles = ref<File[]>([]);
|
|
const isDragover = ref(false);
|
|
const isUploading = ref(false);
|
|
const notes = ref("");
|
|
const selectedFile = ref<File | null>(null);
|
|
const filePreviewUrl = ref<string>("");
|
|
const isModalOpen = ref(false);
|
|
const userSubmissions = ref<any[]>([]);
|
|
const isLoadingSubmissions = ref(false);
|
|
const selectedSubmissionFile = ref<any>(null);
|
|
const isSubmissionFileModalOpen = ref(false);
|
|
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 uploadLimitExceeded = ref<string>("");
|
|
const MAX_FILES = 5;
|
|
|
|
const openFilePreview = async (file: File) => {
|
|
selectedFile.value = file;
|
|
|
|
const fileType = file.type;
|
|
if (fileType.startsWith("image/")) {
|
|
filePreviewUrl.value = URL.createObjectURL(file);
|
|
} else if (fileType === "application/pdf") {
|
|
filePreviewUrl.value = URL.createObjectURL(file);
|
|
} else if (fileType.startsWith("text/")) {
|
|
const text = await file.text();
|
|
filePreviewUrl.value = text;
|
|
} else {
|
|
filePreviewUrl.value = "";
|
|
}
|
|
|
|
isModalOpen.value = true;
|
|
};
|
|
|
|
const closeModal = () => {
|
|
isModalOpen.value = false;
|
|
if (filePreviewUrl.value.startsWith("blob:")) {
|
|
URL.revokeObjectURL(filePreviewUrl.value);
|
|
}
|
|
selectedFile.value = null;
|
|
filePreviewUrl.value = "";
|
|
};
|
|
|
|
const 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 = () => {
|
|
// 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;
|
|
}
|
|
};
|
|
|
|
const closeSubmissionFileModal = () => {
|
|
isSubmissionFileModalOpen.value = false;
|
|
selectedSubmissionFile.value = null;
|
|
};
|
|
|
|
const copyOCRText = async () => {
|
|
if (selectedSubmissionFile.value?.ocr_raw_data) {
|
|
try {
|
|
await navigator.clipboard.writeText(selectedSubmissionFile.value.ocr_raw_data);
|
|
copyFeedback.value = "Copied!";
|
|
setTimeout(() => {
|
|
copyFeedback.value = "";
|
|
}, 2000);
|
|
} catch (err) {
|
|
copyFeedback.value = "Failed to copy";
|
|
setTimeout(() => {
|
|
copyFeedback.value = "";
|
|
}, 2000);
|
|
}
|
|
}
|
|
};
|
|
|
|
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 processFileOcr = async () => {
|
|
if (!selectedSubmissionFile.value) return;
|
|
|
|
try {
|
|
await submissionsApiProcessFileOcr({
|
|
path: { file_id: selectedSubmissionFile.value.id },
|
|
});
|
|
selectedSubmissionFile.value.ocr_status = "processing";
|
|
} catch (error) {
|
|
console.error("Error processing OCR:", error);
|
|
alert(`Error processing OCR: ${error instanceof Error ? error.message : "Please try again."}`);
|
|
}
|
|
};
|
|
|
|
const processCropOcr = async (cropId: string) => {
|
|
try {
|
|
await submissionsApiProcessCropOcr({
|
|
path: { crop_id: cropId },
|
|
});
|
|
// Update the crop status in the list
|
|
if (selectedSubmissionFile.value?.crops) {
|
|
const crop = selectedSubmissionFile.value.crops.find((c: any) => c.id === cropId);
|
|
if (crop) {
|
|
crop.ocr_status = "processing";
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error processing crop OCR:", error);
|
|
alert(`Error processing OCR: ${error instanceof Error ? error.message : "Please try again."}`);
|
|
}
|
|
};
|
|
|
|
const getGameHeaderImage = () => {
|
|
if (props.gameImage) {
|
|
return props.gameImage;
|
|
}
|
|
if (props.gameSteamId) {
|
|
return `https://cdn.akamai.steamstatic.com/steam/apps/${props.gameSteamId}/header.jpg`;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getFileIcon = (file: File | null | { type?: string }) => {
|
|
if (!file) return "mdi-file";
|
|
const type = (file as any).type;
|
|
if (type?.startsWith("image/")) return "mdi-image";
|
|
if (type === "application/pdf") return "mdi-file-pdf";
|
|
if (type?.startsWith("text/")) return "mdi-file-document";
|
|
if (type?.includes("video")) return "mdi-video";
|
|
return "mdi-file";
|
|
};
|
|
|
|
const addFiles = (filesToAdd: File[]) => {
|
|
const totalFiles = uploadedFiles.value.length + filesToAdd.length;
|
|
|
|
if (totalFiles > MAX_FILES) {
|
|
const canAdd = MAX_FILES - uploadedFiles.value.length;
|
|
uploadLimitExceeded.value = `Maximum ${MAX_FILES} files allowed. You can add ${canAdd} more file${canAdd !== 1 ? 's' : ''}.`;
|
|
setTimeout(() => {
|
|
uploadLimitExceeded.value = "";
|
|
}, 5000);
|
|
|
|
// Add only the files that fit
|
|
uploadedFiles.value.push(...filesToAdd.slice(0, canAdd));
|
|
} else {
|
|
uploadedFiles.value.push(...filesToAdd);
|
|
uploadLimitExceeded.value = "";
|
|
}
|
|
};
|
|
|
|
const handleFileUpload = (event: Event) => {
|
|
const input = event.target as HTMLInputElement;
|
|
if (input.files) {
|
|
addFiles(Array.from(input.files));
|
|
}
|
|
};
|
|
|
|
const handleDragover = (event: DragEvent) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
isDragover.value = true;
|
|
};
|
|
|
|
const handleDragleave = (event: DragEvent) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
isDragover.value = false;
|
|
};
|
|
|
|
const handleDrop = (event: DragEvent) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
isDragover.value = false;
|
|
|
|
if (event.dataTransfer?.files) {
|
|
addFiles(Array.from(event.dataTransfer.files));
|
|
}
|
|
};
|
|
|
|
|
|
const removeFile = (index: number) => {
|
|
uploadedFiles.value.splice(index, 1);
|
|
};
|
|
|
|
const submitSubmission = async () => {
|
|
if (uploadedFiles.value.length === 0) return;
|
|
|
|
isUploading.value = true;
|
|
try {
|
|
await submissionsApiCreateSubmission({
|
|
query: { game_path: props.gamePath },
|
|
body: {
|
|
files: uploadedFiles.value,
|
|
notes: notes.value || null,
|
|
},
|
|
});
|
|
|
|
uploadedFiles.value = [];
|
|
notes.value = "";
|
|
alert("Submission successful!");
|
|
await loadUserSubmissions();
|
|
} catch (error) {
|
|
console.error("Error submitting:", error);
|
|
alert(`Error submitting: ${error instanceof Error ? error.message : "Please try again."}`);
|
|
} finally {
|
|
isUploading.value = false;
|
|
}
|
|
};
|
|
|
|
const goHome = () => {
|
|
window.location.href = "/";
|
|
};
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
if (bytes === 0) return "0 Bytes";
|
|
const k = 1024;
|
|
const sizes = ["Bytes", "KB", "MB", "GB"];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleString();
|
|
};
|
|
|
|
const getOCRStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case "processed":
|
|
return "badge badge-success";
|
|
case "processing":
|
|
return "badge badge-warning";
|
|
case "to_process":
|
|
return "badge badge-info";
|
|
case "error":
|
|
return "badge badge-error";
|
|
default:
|
|
return "badge";
|
|
}
|
|
};
|
|
|
|
const loadUserSubmissions = async () => {
|
|
if (!props.gamePath) return;
|
|
|
|
isLoadingSubmissions.value = true;
|
|
try {
|
|
const { data } = await submissionsApiListUserSubmissions({
|
|
query: { game_path: props.gamePath },
|
|
});
|
|
if (data && Array.isArray(data)) {
|
|
userSubmissions.value = data;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error loading submissions:", error);
|
|
} finally {
|
|
isLoadingSubmissions.value = false;
|
|
}
|
|
};
|
|
|
|
const { isConnected: wsConnected } = useSubmissionsWebSocket(userSubmissions);
|
|
const isUploadAreaHovered = ref(false);
|
|
|
|
const handlePasteOnUploadArea = (event: ClipboardEvent) => {
|
|
if (!isUploadAreaHovered.value) return;
|
|
|
|
event.preventDefault();
|
|
const clipboardItems = event.clipboardData?.items;
|
|
|
|
if (!clipboardItems) return;
|
|
|
|
const filesToAdd: File[] = [];
|
|
for (let i = 0; i < clipboardItems.length; i++) {
|
|
const item = clipboardItems[i];
|
|
|
|
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
|
const file = item.getAsFile();
|
|
if (file) {
|
|
filesToAdd.push(file);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (filesToAdd.length > 0) {
|
|
addFiles(filesToAdd);
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
userStore.loadUserData();
|
|
loadUserSubmissions();
|
|
|
|
// Add paste listener (only processes if hovering upload area)
|
|
window.addEventListener('paste', handlePasteOnUploadArea);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('paste', handlePasteOnUploadArea);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-screen bg-base-200">
|
|
<!-- Header -->
|
|
<div class="navbar bg-base-100 shadow-lg">
|
|
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
|
|
<button @click="goHome" class="btn btn-primary btn-sm">
|
|
<i class="mdi mdi-arrow-left"></i>
|
|
Back
|
|
</button>
|
|
<h1 class="text-xl font-bold">{{ gameName }} Submissions</h1>
|
|
<div class="flex-1"></div>
|
|
|
|
<!-- WebSocket Status Indicator -->
|
|
<div class="flex items-center gap-2">
|
|
<div :class="['badge', wsConnected ? 'badge-success' : 'badge-warning']">
|
|
<i :class="['mdi mr-1', wsConnected ? 'mdi-wifi' : 'mdi-wifi-off']"></i>
|
|
{{ wsConnected ? 'Live' : 'Offline' }}
|
|
</div>
|
|
</div>
|
|
|
|
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
|
<a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<div class="container min-w-3/4 mx-auto px-4 py-8">
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
<!-- Right Column: Upload -->
|
|
<div class="lg:col-span-1 flex flex-col gap-8 order-1 lg:order-2">
|
|
<!-- Game Card -->
|
|
<div class="card bg-base-100 shadow-lg">
|
|
<figure v-if="getGameHeaderImage()" class="relative h-48 bg-base-300 overflow-hidden">
|
|
<img :src="getGameHeaderImage()!" :alt="gameName" class="w-full h-full object-cover" />
|
|
<div class="absolute inset-0 bg-black/30"></div>
|
|
</figure>
|
|
<figure v-else
|
|
class="relative h-48 bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center">
|
|
<i class="mdi mdi-gamepad-variant text-6xl text-white opacity-30"></i>
|
|
</figure>
|
|
<div class="card-body">
|
|
<h2 class="card-title text-2xl">{{ gameName }}</h2>
|
|
<p class="text-base-content/70">Submit your best results for this game</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Upload Card -->
|
|
<div class="card bg-base-100 shadow-lg">
|
|
<div class="card-body">
|
|
<h2 class="card-title text-2xl mb-6">
|
|
<i class="mdi mdi-cloud-upload text-primary mr-2"></i>
|
|
Submit Results
|
|
</h2>
|
|
|
|
<!-- Upload Area -->
|
|
<div @dragover="handleDragover" @dragleave="handleDragleave" @drop="handleDrop" @mouseenter="isUploadAreaHovered = true" @mouseleave="isUploadAreaHovered = false" :class="[
|
|
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer bg-base-200/50 mb-6',
|
|
isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary'
|
|
]">
|
|
<input type="file" multiple @change="handleFileUpload" class="hidden" id="file-upload" />
|
|
<label for="file-upload" class="cursor-pointer flex flex-col items-center gap-3">
|
|
<i :class="[
|
|
'mdi text-4xl',
|
|
isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50'
|
|
]"></i>
|
|
<div>
|
|
<p class="font-semibold">Click to upload, drag and drop, or paste</p>
|
|
<p class="text-sm text-base-content/70">Any file type supported • Images from clipboard welcome</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- File Limit Warning -->
|
|
<div v-if="uploadLimitExceeded" class="alert alert-warning mb-4">
|
|
<i class="mdi mdi-alert-circle"></i>
|
|
<span>{{ uploadLimitExceeded }}</span>
|
|
</div>
|
|
|
|
<!-- File Count -->
|
|
<div v-if="uploadedFiles.length > 0" class="mb-3 text-sm text-base-content/70">
|
|
<span class="font-medium">{{ uploadedFiles.length }}/{{ MAX_FILES }}</span> files selected
|
|
</div>
|
|
|
|
<!-- Uploaded Files List -->
|
|
<div v-if="uploadedFiles.length > 0" class="mb-6">
|
|
<p class="font-semibold mb-3">Selected Files:</p>
|
|
<div class="space-y-2">
|
|
<div v-for="(file, index) in uploadedFiles" :key="index"
|
|
class="flex items-center gap-3 bg-base-200 p-3 rounded-lg cursor-pointer hover:bg-base-300 transition-colors group"
|
|
@click="openFilePreview(file)">
|
|
<i :class="['mdi text-primary', getFileIcon(file)]"></i>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="font-medium truncate group-hover:text-primary">{{ file.name }}</p>
|
|
<p class="text-xs text-base-content/70">{{ formatFileSize(file.size) }}</p>
|
|
</div>
|
|
<button @click.stop="removeFile(index)"
|
|
class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100">
|
|
<i class="mdi mdi-close"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Notes Section -->
|
|
<div class="mb-6">
|
|
<div class="form-control">
|
|
<div class="flex-1">
|
|
<label class="flex label">
|
|
<span class="label-text font-medium">Notes (Optional)</span>
|
|
<span class="label-text-alt">0/500</span>
|
|
</label>
|
|
|
|
<textarea class="flex textarea textarea-bordered h-24 w-full resize-none"
|
|
placeholder="Add any notes special for this upload" maxlength="500"></textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Submit Button -->
|
|
<div class="flex gap-3">
|
|
<label for="file-upload" class="btn btn-outline flex-1">
|
|
<i class="mdi mdi-folder-open mr-2"></i>
|
|
Choose Files
|
|
</label>
|
|
<button @click="submitSubmission" :disabled="uploadedFiles.length === 0 || isUploading"
|
|
:class="['btn btn-primary flex-1', { 'loading': isUploading }]">
|
|
<i v-if="!isUploading" class="mdi mdi-send mr-2"></i>
|
|
{{ isUploading ? 'Submitting...' : 'Submit' }}
|
|
</button>
|
|
</div>
|
|
|
|
<p class="text-xs text-base-content/70 text-center mt-4">
|
|
Maximum file size: 256 MB per file
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Left Column: Info -->
|
|
<div class="lg:col-span-1 order-2 lg:order-1">
|
|
<!-- Guidelines Card -->
|
|
<div class="card bg-base-100 shadow-lg sticky top-8">
|
|
<div class="bg-gradient-to-br from-blue-600 to-blue-400 p-8 text-white rounded-t-2xl">
|
|
<h2 class="text-3xl font-bold">
|
|
<i class="mdi mdi-information-outline text-3xl"></i>
|
|
Submission Guidelines
|
|
</h2>
|
|
</div>
|
|
<div class="card-body p-8">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<h3 class="font-bold text-lg mb-2">
|
|
<i class="mdi mdi-file-upload mr-2 text-primary"></i>
|
|
File Upload
|
|
</h3>
|
|
<p class="text-base-content/70 text-sm">
|
|
Upload your results as screenshots, logs, or any other file format. You can upload multiple
|
|
files at once.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<div>
|
|
<h3 class="font-bold text-lg mb-2">
|
|
<i class="mdi mdi-note-text mr-2 text-primary"></i>
|
|
Add Notes
|
|
</h3>
|
|
<p class="text-base-content/70 text-sm">
|
|
Include any additional context about your submission. This helps reviewers understand your
|
|
results.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<div>
|
|
<h3 class="font-bold text-lg mb-2">
|
|
<i class="mdi mdi-check-circle mr-2 text-primary"></i>
|
|
Verification
|
|
</h3>
|
|
<p class="text-base-content/70 text-sm">
|
|
Your submission will be reviewed and verified. You'll receive confirmation once it's been
|
|
processed.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<div class="bg-base-200 p-4 rounded-lg">
|
|
<p class="text-base-content/70 text-sm">
|
|
<i class="mdi mdi-lightbulb-on mr-2 text-warning"></i>
|
|
Pro tip: Include clear screenshots or documentation with your submission for faster verification.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- User Submissions Section -->
|
|
<div class="container min-w-3/4 mx-auto px-4 py-8">
|
|
<div class="card bg-base-100 shadow-lg">
|
|
<div class="card-body">
|
|
<h2 class="card-title text-2xl mb-6">
|
|
<i class="mdi mdi-history text-primary mr-2"></i>
|
|
Your Submissions
|
|
</h2>
|
|
|
|
<div v-if="isLoadingSubmissions" class="flex justify-center py-8">
|
|
<span class="loading loading-spinner loading-lg"></span>
|
|
</div>
|
|
|
|
<div v-else-if="userSubmissions.length === 0" class="text-center py-8">
|
|
<p class="text-base-content/70 mb-2">No submissions yet</p>
|
|
<p class="text-sm text-base-content/50">Upload your first submission above to get started</p>
|
|
</div>
|
|
|
|
<div v-else class="overflow-x-auto">
|
|
<table class="table w-full">
|
|
<thead>
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Files</th>
|
|
<th>File Status</th>
|
|
<th>OCR Status</th>
|
|
<th>Notes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="submission in userSubmissions" :key="submission.id" class="hover">
|
|
<td class="text-sm">{{ formatDate(submission.created_at) }}</td>
|
|
<td class="text-sm">
|
|
<div class="flex flex-col gap-1">
|
|
<div v-for="file in submission.files" :key="file.id" class="flex items-center gap-2">
|
|
<i :class="['mdi text-primary text-sm', getFileIcon({ type: file.content_type })]"></i>
|
|
<button @click="openSubmissionFileModal(file)"
|
|
class="link link-primary truncate max-w-xs text-left">
|
|
{{ file.original_filename }}
|
|
</button>
|
|
<span class="text-xs text-base-content/50">({{ formatFileSize(file.file_size) }})</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="text-sm">
|
|
<div class="flex flex-col gap-1">
|
|
<div v-for="file in submission.files" :key="file.id" class="text-xs">
|
|
{{ file.content_type }}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="text-sm">
|
|
<div class="flex flex-col gap-1">
|
|
<div v-for="file in submission.files" :key="file.id">
|
|
<span :class="getOCRStatusColor(file.ocr_status)">{{ file.ocr_status }}</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="text-sm text-base-content/70">
|
|
<span v-if="submission.notes" :title="submission.notes" class="truncate block max-w-xs">
|
|
{{ submission.notes }}
|
|
</span>
|
|
<span v-else class="text-base-content/50">-</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- File Preview Modal -->
|
|
<div :class="['modal', { 'modal-open': isModalOpen }]">
|
|
<div class="modal-box w-11/12 max-w-4xl">
|
|
<h3 class="font-bold text-lg mb-4">
|
|
<i :class="['mdi mr-2', getFileIcon(selectedFile)]"></i>
|
|
{{ selectedFile?.name }}
|
|
</h3>
|
|
|
|
<!-- Image Preview -->
|
|
<div v-if="selectedFile && selectedFile.type.startsWith('image/')" class="mb-4">
|
|
<img :src="filePreviewUrl" :alt="selectedFile.name" class="max-w-full max-h-[60vh] mx-auto rounded-lg" />
|
|
</div>
|
|
|
|
<!-- PDF Preview -->
|
|
<div v-else-if="selectedFile && selectedFile.type === 'application/pdf'" class="mb-4">
|
|
<iframe :src="filePreviewUrl" class="w-full h-[60vh] rounded-lg border border-base-300"></iframe>
|
|
</div>
|
|
|
|
<!-- Text Preview -->
|
|
<div v-else-if="selectedFile && selectedFile.type.startsWith('text/')" class="mb-4">
|
|
<div class="bg-base-200 p-4 rounded-lg max-h-[60vh] overflow-y-auto">
|
|
<pre class="text-sm whitespace-pre-wrap break-words font-mono">{{ filePreviewUrl }}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Unsupported Preview -->
|
|
<div v-else class="mb-4">
|
|
<div class="bg-base-200 p-8 rounded-lg text-center">
|
|
<i class="mdi mdi-file-question text-6xl text-base-content/30 mb-4"></i>
|
|
<p class="text-base-content/70">
|
|
Preview not available for {{ selectedFile?.type || 'this file type' }}
|
|
</p>
|
|
<p class="text-sm text-base-content/50">
|
|
File size: {{ selectedFile ? formatFileSize(selectedFile.size) : '' }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-action">
|
|
<button @click="closeModal" class="btn">Close</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-backdrop" @click="closeModal"></div>
|
|
</div>
|
|
|
|
<!-- Submission File Modal -->
|
|
<div :class="['modal', { 'modal-open': isSubmissionFileModalOpen }]">
|
|
<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>
|
|
|
|
<!-- 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 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 with Crop Drawing (Admin Only) -->
|
|
<div v-if="selectedSubmissionFile?.content_type.startsWith('image/')"
|
|
class="flex items-center justify-center h-full p-4">
|
|
<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>
|
|
|
|
<!-- PDF Preview -->
|
|
<div v-else-if="selectedSubmissionFile?.content_type === 'application/pdf'" class="h-full">
|
|
<iframe :src="selectedSubmissionFile.file_url" class="w-full h-full border-0 rounded"></iframe>
|
|
</div>
|
|
|
|
<!-- Text Preview -->
|
|
<div v-else-if="selectedSubmissionFile?.content_type.startsWith('text/')" class="p-4">
|
|
<div class="bg-base-200 p-4 rounded font-mono text-sm whitespace-pre-wrap break-words">
|
|
<p class="text-base-content/70">Text content preview not available. Download file to view.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Unsupported Preview -->
|
|
<div v-else class="flex items-center justify-center h-full">
|
|
<div class="text-center">
|
|
<i class="mdi mdi-file-question text-6xl text-base-content/30 mb-4"></i>
|
|
<p class="text-base-content/70">Preview not available</p>
|
|
<p class="text-sm text-base-content/50">{{ selectedSubmissionFile?.content_type }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<p class="text-sm text-base-content/70 mb-2">OCR Data</p>
|
|
<span :class="getOCRStatusColor(selectedSubmissionFile?.ocr_status)">
|
|
{{ selectedSubmissionFile?.ocr_status }}
|
|
</span>
|
|
</div>
|
|
<div class="flex-1 overflow-auto border border-base-300 rounded-lg p-4 bg-base-200">
|
|
<div class="space-y-4">
|
|
<!-- File OCR Data -->
|
|
<div v-if="selectedSubmissionFile?.ocr_status === 'processed' && selectedSubmissionFile.ocr_raw_data">
|
|
<div class="space-y-3">
|
|
<div>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<p class="text-xs font-semibold text-base-content/70">File - 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">
|
|
{{ selectedSubmissionFile.ocr_raw_data }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="selectedSubmissionFile?.ocr_status === 'processing'"
|
|
class="flex items-center justify-center py-8">
|
|
<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 py-8">
|
|
<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-if="!selectedSubmissionFile?.ocr_raw_data"
|
|
class="flex items-center justify-center py-8">
|
|
<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>
|
|
|
|
<!-- Crops OCR Data -->
|
|
<div v-if="selectedSubmissionFile?.crops?.length"
|
|
class="space-y-3 pt-4 border-t border-base-300">
|
|
<p class="text-xs font-semibold text-base-content/70">Crop OCR Results ({{ selectedSubmissionFile.crops.length }}):</p>
|
|
<div v-for="crop in selectedSubmissionFile.crops" :key="crop.id" class="space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<p class="text-xs font-medium">{{ crop.label || `Crop (${crop.x1}, ${crop.y1})` }}</p>
|
|
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span>
|
|
</div>
|
|
<div v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data"
|
|
class="bg-base-100 p-2 rounded text-xs whitespace-pre-wrap break-words font-mono max-h-32 overflow-y-auto">
|
|
{{ crop.ocr_raw_data }}
|
|
</div>
|
|
<div v-else-if="crop.ocr_status === 'processing'" class="text-xs text-base-content/70 text-center py-2">
|
|
<span class="loading loading-spinner loading-sm inline-block"></span>
|
|
Processing...
|
|
</div>
|
|
<div v-else-if="crop.ocr_status === 'error'" class="text-xs text-error text-center py-2">
|
|
<i class="mdi mdi-alert-circle mr-1"></i>
|
|
Processing failed
|
|
</div>
|
|
<div v-else class="text-xs text-base-content/50 text-center py-2">
|
|
<i class="mdi mdi-clock-outline mr-1"></i>
|
|
Pending
|
|
</div>
|
|
</div>
|
|
</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>
|
|
<div class="flex gap-1 flex-shrink-0">
|
|
<button @click="processCropOcr(crop.id)" class="btn btn-xs btn-ghost" title="Process OCR">
|
|
<i class="mdi mdi-lightning-bolt"></i>
|
|
</button>
|
|
<button @click="deleteCrop(crop.id)" class="btn btn-xs btn-ghost text-error"
|
|
title="Delete crop">
|
|
<i class="mdi mdi-delete"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-center text-xs text-base-content/50 py-4">
|
|
No crops yet
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-action mt-4">
|
|
<button v-if="userInfo.isStaff" @click="processFileOcr" class="btn btn-warning gap-1">
|
|
<i class="mdi mdi-lightning-bolt"></i>
|
|
Process OCR
|
|
</button>
|
|
<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>
|
|
|
|
<div class="modal-backdrop" @click="closeSubmissionFileModal"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|