Files
polylan-submitter/polylan_submitter/src/Submissions.vue
T

1105 lines
44 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core";
import { useQueryClient } from "@tanstack/vue-query";
import { useUser } from "@/composables/useUser";
import { eventGamesQuery } from "@/queries";
import { usePageHeaderStore } from "@/stores/pageHeader";
import { submissionsApiListAvailableScopes, submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api";
import type { SubmissionOut } from "@/api";
import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket";
import { provideEventPath } from "@/composables/useEventPath";
interface Props {
eventPath: string;
gamePath: string;
scopeId: string;
}
const props = defineProps<Props>();
provideEventPath(props.eventPath);
const router = useRouter();
const { t } = useI18n();
const pageHeaderStore = usePageHeaderStore();
const scopeName = ref("Match");
const gameName = ref("Game");
const gameImage = ref("");
const gameSteamId = ref("");
useTitle(computed(() => t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value })));
const queryClient = useQueryClient();
const userInfo = useUser();
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<SubmissionOut[]>([]);
const isLoadingSubmissions = ref(false);
const selectedSubmissionFile = ref<any>(null);
const isSubmissionFileModalOpen = ref(false);
// Feedback shown on the copy button that was clicked, keyed by "file" or crop id
const copyFeedback = ref<{ key: string; message: string } | null>(null);
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 (text: string | undefined, key: string) => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
copyFeedback.value = { key, message: t("common.copied") };
} catch (err) {
copyFeedback.value = { key, message: t("submissions.copyFailed") };
}
setTimeout(() => {
if (copyFeedback.value?.key === key) copyFeedback.value = null;
}, 2000);
};
const copyLabel = (key: string) =>
copyFeedback.value?.key === key ? copyFeedback.value.message : t("common.copy");
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;
};
// Crops can also arrive over the websocket (e.g. drawn by another admin)
watch(
() => selectedSubmissionFile.value?.crops?.length,
() => redrawCropCanvas(),
);
const submitCrop = async () => {
if (!currentCrop.value || !selectedSubmissionFile.value) return;
isCreatingCrop.value = true;
try {
const { data: newCrop } = await submissionsApiCreateImageCrop({
path: { event_path: props.eventPath, 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, unless the websocket beat us to it
if (newCrop) {
selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops || [];
if (!selectedSubmissionFile.value.crops.some((c: any) => c.id === newCrop.id)) {
selectedSubmissionFile.value.crops.push(newCrop);
}
}
currentCrop.value = null;
cropLabel.value = "";
redrawCropCanvas();
} catch (error) {
console.error("Error creating crop:", error);
alert(t("submissions.createCropError", { message: error instanceof Error ? error.message : t("common.pleaseTryAgain") }));
} finally {
isCreatingCrop.value = false;
}
};
const deleteCrop = async (cropId: string) => {
if (!confirm(t("submissions.deleteCropConfirm"))) return;
try {
await submissionsApiDeleteImageCrop({
path: { event_path: props.eventPath, 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(t("submissions.deleteCropError", { message: error instanceof Error ? error.message : t("common.pleaseTryAgain") }));
}
};
const cancelCrop = () => {
currentCrop.value = null;
cropLabel.value = "";
redrawCropCanvas();
};
const processFileOcr = async () => {
if (!selectedSubmissionFile.value) return;
try {
await submissionsApiProcessFileOcr({
path: { event_path: props.eventPath, file_id: selectedSubmissionFile.value.id },
});
selectedSubmissionFile.value.ocr_status = "processing";
} catch (error) {
console.error("Error processing OCR:", error);
alert(t("submissions.processOcrError", { message: error instanceof Error ? error.message : t("common.pleaseTryAgain") }));
}
};
const processCropOcr = async (cropId: string) => {
try {
await submissionsApiProcessCropOcr({
path: { event_path: props.eventPath, 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(t("submissions.processOcrError", { message: error instanceof Error ? error.message : t("common.pleaseTryAgain") }));
}
};
const getGameHeaderImage = () => {
if (gameImage.value) {
return gameImage.value;
}
if (gameSteamId.value) {
return `https://cdn.akamai.steamstatic.com/steam/apps/${gameSteamId.value}/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 = t("submissions.uploadLimitExceeded", { max: MAX_FILES, count: canAdd, unit: t("common.fileWord", canAdd) });
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);
};
// Thumbnails of the selected images, revoked once the file is removed/submitted
const thumbnailUrls = new Map<File, string>();
const thumbnails = computed(() =>
uploadedFiles.value.map((file) => {
if (!file.type.startsWith("image/")) return null;
if (!thumbnailUrls.has(file)) thumbnailUrls.set(file, URL.createObjectURL(file));
return thumbnailUrls.get(file)!;
}),
);
const revokeThumbnails = (keep: File[] = []) => {
thumbnailUrls.forEach((url, file) => {
if (keep.includes(file)) return;
URL.revokeObjectURL(url);
thumbnailUrls.delete(file);
});
};
watch(uploadedFiles, (files) => revokeThumbnails(files), { deep: true });
const submitSubmission = async () => {
if (uploadedFiles.value.length === 0) return;
isUploading.value = true;
try {
await submissionsApiCreateSubmission({
path: { event_path: props.eventPath },
query: { scope_id: props.scopeId },
body: {
files: uploadedFiles.value,
notes: notes.value || null,
},
});
uploadedFiles.value = [];
notes.value = "";
alert(t("submissions.submitSuccess"));
await loadUserSubmissions();
} catch (error) {
console.error("Error submitting:", error);
alert(t("submissions.submitError", { message: error instanceof Error ? error.message : t("common.pleaseTryAgain") }));
} finally {
isUploading.value = false;
}
};
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 () => {
isLoadingSubmissions.value = true;
try {
const { data } = await submissionsApiListUserSubmissions({
path: { event_path: props.eventPath },
query: { scope_id: props.scopeId },
});
if (data && Array.isArray(data)) {
userSubmissions.value = data;
}
} catch (error) {
console.error("Error loading submissions:", error);
} finally {
isLoadingSubmissions.value = false;
}
};
// Match over (or user moved off it): back to the picker, which auto-enters
// the next match when there's only one, so players follow along without a refresh.
useSubmissionsWebSocket({
userSubmissions,
scopeId: props.scopeId,
onScopeUpdate: (scope) => {
if (scope.id !== props.scopeId || (scope.is_active && scope.assigned)) return;
router.replace({ name: "submissions-select", params: { eventPath: props.eventPath, gamePath: props.gamePath } });
},
});
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(async () => {
loadUserSubmissions();
const [games, scopesResponse] = await Promise.all([
queryClient.ensureQueryData(eventGamesQuery(props.eventPath)),
submissionsApiListAvailableScopes({
path: { event_path: props.eventPath, game_path: props.gamePath },
}),
]);
const game = games.find((g) => g.path === props.gamePath);
if (game) {
gameName.value = game.name;
gameImage.value = game.image ?? "";
gameSteamId.value = game.steam_app_id ? String(game.steam_app_id) : "";
}
const scope = scopesResponse.data?.find((s) => s.id === props.scopeId);
if (!scope) {
// Not in the list of scopes this user may access - bounce back to
// the picker rather than rendering a page for data we can't show.
router.replace({ name: "submissions-select", params: { eventPath: props.eventPath, gamePath: props.gamePath } });
return;
}
scopeName.value = scope.name;
// The picker auto-redirects back here when there's only one scope, so
// going "back" to it would loop - head to the event home instead.
if (scopesResponse.data?.length === 1) {
pageHeaderStore.setBackRoute({ name: "home", params: { eventPath: props.eventPath } });
}
pageHeaderStore.setTitle(t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value }));
// Add paste listener (only processes if hovering upload area)
window.addEventListener('paste', handlePasteOnUploadArea);
});
onUnmounted(() => {
window.removeEventListener('paste', handlePasteOnUploadArea);
revokeThumbnails();
});
</script>
<template>
<div class="flex-1 bg-base-200">
<!-- 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">{{ t("submissions.submittingForMatch") }} <span class="font-semibold">{{ scopeName }}</span>
</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>
{{ t("submissions.submitResultsTitle") }}
</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">{{ t("submissions.uploadPrompt") }}</p>
<p class="text-sm text-base-content/70">{{ t("submissions.uploadHint") }}</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> {{ t("submissions.filesSelectedSuffix") }}
</div>
<!-- Uploaded Files List -->
<div v-if="uploadedFiles.length > 0" class="mb-6">
<p class="font-semibold mb-3">{{ t("common.selectedFiles") }}</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div v-for="(file, index) in uploadedFiles" :key="index"
class="relative bg-base-200 rounded-lg overflow-hidden cursor-pointer hover:bg-base-300 transition-colors group"
@click="openFilePreview(file)">
<div class="aspect-video bg-base-300 flex items-center justify-center">
<img v-if="thumbnails[index]" :src="thumbnails[index]" :alt="file.name"
class="w-full h-full object-contain" />
<i v-else :class="['mdi text-5xl text-primary', getFileIcon(file)]"></i>
</div>
<div class="flex items-center gap-2 p-2">
<i :class="['mdi text-primary', getFileIcon(file)]"></i>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate group-hover:text-primary" :title="file.name">{{ file.name }}</p>
<p class="text-xs text-base-content/70">{{ formatFileSize(file.size) }}</p>
</div>
</div>
<button @click.stop="removeFile(index)"
class="btn btn-circle btn-xs absolute top-2 right-2 sm:opacity-0 sm: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">{{ t("common.notesOptional") }}</span>
<span class="label-text-alt">0/500</span>
</label>
<textarea v-model="notes" class="flex textarea textarea-bordered h-24 w-full resize-none"
:placeholder="t('submissions.notesPlaceholder')" 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>
{{ t("common.chooseFiles") }}
</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 ? t("common.submitting") : t("common.submit") }}
</button>
</div>
<p class="text-xs text-base-content/70 text-center mt-4">
{{ t("common.maxFileSize") }}
</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>
{{ t("submissions.guidelines.title") }}
</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>
{{ t("submissions.guidelines.fileUpload.title") }}
</h3>
<p class="text-base-content/70 text-sm">
{{ t("submissions.guidelines.fileUpload.body") }}
</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>
{{ t("submissions.guidelines.notes.title") }}
</h3>
<p class="text-base-content/70 text-sm">
{{ t("submissions.guidelines.notes.body") }}
</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>
{{ t("submissions.guidelines.verification.title") }}
</h3>
<p class="text-base-content/70 text-sm">
{{ t("submissions.guidelines.verification.body") }}
</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>
{{ userInfo.isStaff ? t("submissions.allSubmissions") : t("submissions.yourSubmissions") }}
</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">{{ t("common.noSubmissionsYet") }}</p>
<p class="text-sm text-base-content/50">{{ t("submissions.noSubmissionsHint") }}</p>
</div>
<div v-else class="overflow-x-auto">
<table class="table w-full">
<thead>
<tr>
<th>{{ t("common.date") }}</th>
<th v-if="userInfo.isStaff">{{ t("common.user") }}</th>
<th>{{ t("common.files") }}</th>
<th>{{ t("submissions.fileStatus") }}</th>
<th>{{ t("submissions.ocrStatus") }}</th>
<th>{{ t("common.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 v-if="userInfo.isStaff" class="text-sm">{{ submission.username ?? "-" }}</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">
{{ t("submissions.previewNotAvailableFor", { type: selectedFile?.type || t("submissions.thisFileType") }) }}
</p>
<p class="text-sm text-base-content/50">
{{ selectedFile ? t("submissions.fileSizeLabel", { size: formatFileSize(selectedFile.size) }) : '' }}
</p>
</div>
</div>
<div class="modal-action">
<button @click="closeModal" class="btn">{{ t("common.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">{{ t("submissions.filePreview") }}</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">{{ t("submissions.textPreviewUnavailable") }}</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">{{ t("submissions.previewNotAvailable") }}</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">{{ t("submissions.ocrData") }}</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">{{ t("submissions.extractedText") }}</p>
<button @click="copyOCRText(selectedSubmissionFile.ocr_raw_data, 'file')"
class="btn btn-xs btn-ghost gap-1">
<i class="mdi mdi-content-copy"></i>
{{ copyLabel("file") }}
</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">{{ t("submissions.ocrProcessing") }}</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">{{ t("submissions.ocrFailed") }}</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">{{ t("submissions.ocrWaiting") }}</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">{{ t("submissions.cropOcrResults", { count: 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 || t("submissions.cropLabelFallback", { x: crop.x1, y: crop.y1 }) }}</p>
<div class="flex items-center gap-1">
<button v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data"
@click="copyOCRText(crop.ocr_raw_data, crop.id)" class="btn btn-xs btn-ghost gap-1">
<i class="mdi mdi-content-copy"></i>
{{ copyLabel(crop.id) }}
</button>
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span>
</div>
</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>
{{ t("common.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>
{{ t("submissions.cropProcessingFailed") }}
</div>
<div v-else class="text-xs text-base-content/50 text-center py-2">
<i class="mdi mdi-clock-outline mr-1"></i>
{{ t("common.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">{{ t("submissions.cropsData") }}</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>
{{ t("submissions.cropDrawHint") }}
</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">{{ t("submissions.cropLabelOptional") }}</span>
</label>
<input v-model="cropLabel" type="text" :placeholder="t('submissions.cropLabelPlaceholder')"
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 ? t("submissions.creatingCrop") : t("submissions.saveCrop") }}
</button>
<button @click="cancelCrop" class="btn btn-sm btn-ghost flex-1">{{ t("common.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">{{ t("submissions.cropsCount", { count:
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 || t("submissions.cropLabelFallback", { x: crop.x1, y: 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="t('submissions.processOcr')">
<i class="mdi mdi-lightning-bolt"></i>
</button>
<button @click="deleteCrop(crop.id)" class="btn btn-xs btn-ghost text-error"
:title="t('submissions.deleteCropTooltip')">
<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">
{{ t("submissions.noCropsYet") }}
</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>
{{ t("submissions.processOcr") }}
</button>
<a v-if="selectedSubmissionFile?.file_url" :href="selectedSubmissionFile.file_url" download
class="btn btn-outline">
<i class="mdi mdi-download mr-2"></i>
{{ t("common.download") }}
</a>
<button @click="closeSubmissionFileModal" class="btn">{{ t("common.close") }}</button>
</div>
</div>
<div class="modal-backdrop" @click="closeSubmissionFileModal"></div>
</div>
</div>
</template>