feat(websocket): frontend websocket
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user