feat(websocket): frontend websocket

This commit is contained in:
2026-05-25 13:01:22 +02:00
parent b64161b371
commit d7bdb85fb0
9 changed files with 343 additions and 76 deletions
+145 -36
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
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;
@@ -39,6 +40,8 @@ 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;
@@ -337,10 +340,28 @@ const getFileIcon = (file: File | null | { type?: string }) => {
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) {
uploadedFiles.value = Array.from(input.files);
addFiles(Array.from(input.files));
}
};
@@ -362,10 +383,11 @@ const handleDrop = (event: DragEvent) => {
isDragover.value = false;
if (event.dataTransfer?.files) {
uploadedFiles.value = Array.from(event.dataTransfer.files);
addFiles(Array.from(event.dataTransfer.files));
}
};
const removeFile = (index: number) => {
uploadedFiles.value.splice(index, 1);
};
@@ -444,9 +466,44 @@ const loadUserSubmissions = async () => {
}
};
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>
@@ -461,6 +518,15 @@ onMounted(() => {
</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>
@@ -496,7 +562,7 @@ onMounted(() => {
</h2>
<!-- Upload Area -->
<div @dragover="handleDragover" @dragleave="handleDragleave" @drop="handleDrop" :class="[
<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'
]">
@@ -507,12 +573,23 @@ onMounted(() => {
isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50'
]"></i>
<div>
<p class="font-semibold">Click to upload or drag and drop</p>
<p class="text-sm text-base-content/70">Any file type supported</p>
<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>
@@ -809,41 +886,73 @@ onMounted(() => {
</span>
</div>
<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>
<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 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>
<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 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>
<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 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>
<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-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>