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
+1 -3
View File
@@ -22,8 +22,6 @@ from submissions.routing import websocket_urlpatterns
application = ProtocolTypeRouter(
{
"http": django_asgi_app,
"websocket": AuthMiddlewareStack(
URLRouter(websocket_urlpatterns)
),
"websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
}
)
@@ -173,6 +173,7 @@ CHANNEL_LAYERS = {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [("127.0.0.1", 6379)],
"serializer_format": "cjson",
},
},
}
+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,
}
}
+15
View File
@@ -1,4 +1,17 @@
from django.apps import AppConfig
from django.core.serializers.json import DjangoJSONEncoder
from channels_redis.serializers import JSONSerializer, registry
import json
class CJSONSerializer(JSONSerializer):
"""Override `JSONSerializer` to use `DjangoJSONEncoder`."""
def as_bytes(self, message, *args, **kwargs): # noqa: ANN001 ANN002 ANN003 ANN201
message = json.dumps(message, *args, **kwargs, cls=DjangoJSONEncoder)
return message.encode("utf-8")
class SubmissionsConfig(AppConfig):
@@ -6,4 +19,6 @@ class SubmissionsConfig(AppConfig):
name = "submissions"
def ready(self):
registry.register_serializer("cjson", CJSONSerializer)
import submissions.signals # noqa
@@ -1,11 +1,7 @@
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.contrib.auth.models import AnonymousUser
from submissions.models import Submission, SubmissionFile, ImageCrop
from submissions.schemas import SubmissionOut, SubmissionFileOut, ImageCropOut
class SubmissionsConsumer(AsyncWebsocketConsumer):
async def connect(self):
+3 -10
View File
@@ -1,7 +1,6 @@
from datetime import datetime
from uuid import UUID
from ninja import Schema
from pydantic import BaseModel, ConfigDict
class ImageCropIn(Schema):
@@ -12,9 +11,7 @@ class ImageCropIn(Schema):
label: str | None = None
class ImageCropOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
class ImageCropOut(Schema):
id: UUID
x1: int
y1: int
@@ -26,9 +23,7 @@ class ImageCropOut(BaseModel):
created_at: datetime
class SubmissionFileOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
class SubmissionFileOut(Schema):
id: UUID
original_filename: str
content_type: str
@@ -39,9 +34,7 @@ class SubmissionFileOut(BaseModel):
crops: list[ImageCropOut] = []
class SubmissionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
class SubmissionOut(Schema):
id: UUID
game_id: int | None = None
notes: str | None = None
+6 -10
View File
@@ -1,4 +1,3 @@
import json
from django.db.models.signals import post_save
from django.dispatch import receiver
from asgiref.sync import async_to_sync
@@ -17,15 +16,14 @@ def broadcast_submission_update(sender, instance, created, **kwargs):
return
user_group_name = f"submissions_user_{instance.user.id}"
schema = SubmissionOut.model_validate(instance)
submission_data = schema.model_dump(mode="json")
submission_data = SubmissionOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{
"type": "submission.updated",
"submission": submission_data,
}
},
)
@@ -36,15 +34,14 @@ def broadcast_submission_file_update(sender, instance, created, **kwargs):
return
user_group_name = f"submissions_user_{submission.user.id}"
schema = SubmissionFileOut.model_validate(instance)
file_data = schema.model_dump(mode="json")
file_data = SubmissionFileOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{
"type": "submission.file.updated",
"file": file_data,
}
},
)
@@ -55,13 +52,12 @@ def broadcast_image_crop_update(sender, instance, created, **kwargs):
return
user_group_name = f"submissions_user_{submission.user.id}"
schema = ImageCropOut.model_validate(instance)
crop_data = schema.model_dump(mode="json")
crop_data = ImageCropOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{
"type": "submission.crop.updated",
"crop": crop_data,
}
},
)
+16 -13
View File
@@ -5,25 +5,28 @@ from PIL import Image
import pytesseract
def _process_ocr_file(file) -> bool:
if file.ocr_status != OCRStatus.TO_PROCESS:
def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
if obj.ocr_status != OCRStatus.TO_PROCESS:
return False
file.ocr_status = OCRStatus.PROCESSING
file.save(update_fields=["ocr_status"])
obj.ocr_status = OCRStatus.PROCESSING
obj.save(update_fields=["ocr_status"])
try:
file.ocr_raw_data = pytesseract.image_to_string(Image.open(file.file))
image = Image.open(file)
except Exception:
file.ocr_status = OCRStatus.ERROR
file.save(update_fields=["ocr_status"])
if crop:
image = image.crop((crop.x1, crop.y1, crop.x2, crop.y2))
return False
obj.ocr_raw_data = pytesseract.image_to_string(image)
obj.ocr_status = OCRStatus.PROCESSED
except Exception as e:
obj.ocr_status = OCRStatus.ERROR
raise e
finally:
file.ocr_status = OCRStatus.PROCESSED
file.save(update_fields=["ocr_status", "ocr_raw_data"])
obj.save(update_fields=["ocr_status", "ocr_raw_data"])
return True
@@ -42,7 +45,7 @@ def process_submission_file(file_id: str):
if not file:
return
_process_ocr_file(file)
_process_ocr_file(file, file.file)
process_crops.enqueue(file_id)
@@ -65,4 +68,4 @@ def process_crop(crop_id: str):
if not crop:
return
_process_ocr_file(crop)
_process_ocr_file(crop, crop.submission_file.file, crop=crop)