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>
@@ -0,0 +1,156 @@
import { ref, computed, onBeforeUnmount } from 'vue'
import { useWebSocket } from '@vueuse/core'
interface SubmissionUpdate {
id: string
game_id?: number
notes?: string
files?: SubmissionFileUpdate[]
created_at: string
updated_at: string
}
interface SubmissionFileUpdate {
id: string
original_filename: string
content_type: string
file_size: number
file_url?: string
ocr_status: string
ocr_raw_data: string
crops?: ImageCropUpdate[]
}
interface ImageCropUpdate {
id: string
x1: number
y1: number
x2: number
y2: number
label: string
ocr_status: string
ocr_raw_data: string
created_at: string
}
type WebSocketData = SubmissionUpdate | SubmissionFileUpdate | ImageCropUpdate
export function useSubmissionsWebSocket(userSubmissions: any) {
const isConnected = ref(false)
const reconnectAttempts = ref(0)
const maxReconnectAttempts = 10
const reconnectDelay = ref(3000)
const wsUrl = computed(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${window.location.host}/ws/submissions/`
})
const {
status,
open,
close,
} = useWebSocket(wsUrl.value, {
onConnected: () => {
isConnected.value = true
reconnectAttempts.value = 0
reconnectDelay.value = 3000
console.log('✓ Connected to submissions WebSocket')
},
onDisconnected: () => {
isConnected.value = false
console.log('✗ Disconnected from submissions WebSocket, attempting to reconnect...')
attemptReconnect()
},
onError: (event) => {
console.error('WebSocket error:', event)
},
onMessage: (_ws, event) => {
try {
const messageData = event.data as string
const data = JSON.parse(messageData) as WebSocketData
if ('files' in data) {
handleSubmissionUpdate(data as SubmissionUpdate)
} else if ('original_filename' in data) {
handleFileUpdate(data as SubmissionFileUpdate)
} else if ('x1' in data) {
handleCropUpdate(data as ImageCropUpdate)
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
},
immediate: true,
})
const attemptReconnect = () => {
if (reconnectAttempts.value >= maxReconnectAttempts) {
console.error('Max reconnection attempts reached')
return
}
reconnectAttempts.value++
const delay = reconnectDelay.value * Math.pow(1.5, reconnectAttempts.value - 1)
console.log(`Reconnecting (attempt ${reconnectAttempts.value}/${maxReconnectAttempts}) in ${Math.round(delay / 1000)}s...`)
setTimeout(() => {
open()
}, delay)
}
const handleSubmissionUpdate = (submission: SubmissionUpdate) => {
const existingIndex = userSubmissions.value.findIndex((s: any) => s.id === submission.id)
if (existingIndex >= 0) {
// Merge into existing object to preserve references
Object.assign(userSubmissions.value[existingIndex], submission)
} else {
userSubmissions.value.unshift(submission)
}
console.log('Updated submission:', submission.id)
}
const handleFileUpdate = (file: SubmissionFileUpdate) => {
userSubmissions.value.forEach((submission: any) => {
if (!submission.files) return
const fileIndex = submission.files.findIndex((f: any) => f.id === file.id)
if (fileIndex >= 0) {
// Merge into existing object to preserve references
Object.assign(submission.files[fileIndex], file)
console.log('Updated file:', file.id)
}
})
}
const handleCropUpdate = (crop: ImageCropUpdate) => {
userSubmissions.value.forEach((submission: any) => {
if (!submission.files) return
submission.files.forEach((file: any) => {
if (!file.crops) return
const cropIndex = file.crops.findIndex((c: any) => c.id === crop.id)
if (cropIndex >= 0) {
// Merge into existing object to preserve references
Object.assign(file.crops[cropIndex], crop)
console.log('Updated crop:', crop.id)
}
})
})
}
onBeforeUnmount(() => {
close()
})
return {
isConnected,
status,
open,
close,
}
}