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( application = ProtocolTypeRouter(
{ {
"http": django_asgi_app, "http": django_asgi_app,
"websocket": AuthMiddlewareStack( "websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
URLRouter(websocket_urlpatterns)
),
} }
) )
@@ -173,6 +173,7 @@ CHANNEL_LAYERS = {
"BACKEND": "channels_redis.core.RedisChannelLayer", "BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": { "CONFIG": {
"hosts": [("127.0.0.1", 6379)], "hosts": [("127.0.0.1", 6379)],
"serializer_format": "cjson",
}, },
}, },
} }
+145 -36
View File
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, onMounted, onUnmounted } from "vue";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api"; import { submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api";
import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket";
interface Props { interface Props {
gamePath?: string; gamePath?: string;
@@ -39,6 +40,8 @@ const cropStartY = ref(0);
const currentCrop = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null); const currentCrop = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
const cropLabel = ref(""); const cropLabel = ref("");
const isCreatingCrop = ref(false); const isCreatingCrop = ref(false);
const uploadLimitExceeded = ref<string>("");
const MAX_FILES = 5;
const openFilePreview = async (file: File) => { const openFilePreview = async (file: File) => {
selectedFile.value = file; selectedFile.value = file;
@@ -337,10 +340,28 @@ const getFileIcon = (file: File | null | { type?: string }) => {
return "mdi-file"; 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 handleFileUpload = (event: Event) => {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
if (input.files) { 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; isDragover.value = false;
if (event.dataTransfer?.files) { if (event.dataTransfer?.files) {
uploadedFiles.value = Array.from(event.dataTransfer.files); addFiles(Array.from(event.dataTransfer.files));
} }
}; };
const removeFile = (index: number) => { const removeFile = (index: number) => {
uploadedFiles.value.splice(index, 1); 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(() => { onMounted(() => {
userStore.loadUserData(); userStore.loadUserData();
loadUserSubmissions(); loadUserSubmissions();
// Add paste listener (only processes if hovering upload area)
window.addEventListener('paste', handlePasteOnUploadArea);
});
onUnmounted(() => {
window.removeEventListener('paste', handlePasteOnUploadArea);
}); });
</script> </script>
@@ -461,6 +518,15 @@ onMounted(() => {
</button> </button>
<h1 class="text-xl font-bold">{{ gameName }} Submissions</h1> <h1 class="text-xl font-bold">{{ gameName }} Submissions</h1>
<div class="flex-1"></div> <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 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> <a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
</div> </div>
@@ -496,7 +562,7 @@ onMounted(() => {
</h2> </h2>
<!-- Upload Area --> <!-- 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', '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' 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' isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50'
]"></i> ]"></i>
<div> <div>
<p class="font-semibold">Click to upload or drag and drop</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</p> <p class="text-sm text-base-content/70">Any file type supported • Images from clipboard welcome</p>
</div> </div>
</label> </label>
</div> </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 --> <!-- Uploaded Files List -->
<div v-if="uploadedFiles.length > 0" class="mb-6"> <div v-if="uploadedFiles.length > 0" class="mb-6">
<p class="font-semibold mb-3">Selected Files:</p> <p class="font-semibold mb-3">Selected Files:</p>
@@ -809,41 +886,73 @@ onMounted(() => {
</span> </span>
</div> </div>
<div class="flex-1 overflow-auto border border-base-300 rounded-lg p-4 bg-base-200"> <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-4">
<div class="space-y-3"> <!-- File OCR Data -->
<div> <div v-if="selectedSubmissionFile?.ocr_status === 'processed' && selectedSubmissionFile.ocr_raw_data">
<div class="flex items-center justify-between mb-2"> <div class="space-y-3">
<p class="text-xs font-semibold text-base-content/70">Extracted Text:</p> <div>
<button @click="copyOCRText" class="btn btn-xs btn-ghost gap-1"> <div class="flex items-center justify-between mb-2">
<i class="mdi mdi-content-copy"></i> <p class="text-xs font-semibold text-base-content/70">File - Extracted Text:</p>
{{ copyFeedback || "Copy" }} <button @click="copyOCRText" class="btn btn-xs btn-ghost gap-1">
</button> <i class="mdi mdi-content-copy"></i>
</div> {{ copyFeedback || "Copy" }}
<div </button>
class="bg-base-100 p-3 rounded text-xs whitespace-pre-wrap break-words font-mono overflow-y-auto h-full"> </div>
{{ selectedSubmissionFile.ocr_raw_data }} <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> </div>
</div> <div v-else-if="selectedSubmissionFile?.ocr_status === 'processing'"
<div v-else-if="selectedSubmissionFile?.ocr_status === 'processing'" class="flex items-center justify-center py-8">
class="flex items-center justify-center h-full"> <div class="text-center">
<div class="text-center"> <span class="loading loading-spinner loading-lg"></span>
<span class="loading loading-spinner loading-lg"></span> <p class="text-base-content/70 mt-4">OCR is being processed...</p>
<p class="text-base-content/70 mt-4">OCR is being processed...</p> </div>
</div> </div>
</div> <div v-else-if="selectedSubmissionFile?.ocr_status === 'error'"
<div v-else-if="selectedSubmissionFile?.ocr_status === 'error'" class="flex items-center justify-center py-8">
class="flex items-center justify-center h-full"> <div class="text-center">
<div class="text-center"> <i class="mdi mdi-alert-circle text-4xl text-error mb-2"></i>
<i class="mdi mdi-alert-circle text-4xl text-error mb-2"></i> <p class="text-base-content/70">OCR processing failed</p>
<p class="text-base-content/70">OCR processing failed</p> </div>
</div> </div>
</div> <div v-else-if="!selectedSubmissionFile?.ocr_raw_data"
<div v-else class="flex items-center justify-center h-full"> class="flex items-center justify-center py-8">
<div class="text-center"> <div class="text-center">
<i class="mdi mdi-clock-outline text-4xl text-base-content/30 mb-2"></i> <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> <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> </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.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): class SubmissionsConfig(AppConfig):
@@ -6,4 +19,6 @@ class SubmissionsConfig(AppConfig):
name = "submissions" name = "submissions"
def ready(self): def ready(self):
registry.register_serializer("cjson", CJSONSerializer)
import submissions.signals # noqa import submissions.signals # noqa
@@ -1,11 +1,7 @@
import json import json
from channels.generic.websocket import AsyncWebsocketConsumer from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from submissions.models import Submission, SubmissionFile, ImageCrop
from submissions.schemas import SubmissionOut, SubmissionFileOut, ImageCropOut
class SubmissionsConsumer(AsyncWebsocketConsumer): class SubmissionsConsumer(AsyncWebsocketConsumer):
async def connect(self): async def connect(self):
+3 -10
View File
@@ -1,7 +1,6 @@
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
from ninja import Schema from ninja import Schema
from pydantic import BaseModel, ConfigDict
class ImageCropIn(Schema): class ImageCropIn(Schema):
@@ -12,9 +11,7 @@ class ImageCropIn(Schema):
label: str | None = None label: str | None = None
class ImageCropOut(BaseModel): class ImageCropOut(Schema):
model_config = ConfigDict(from_attributes=True)
id: UUID id: UUID
x1: int x1: int
y1: int y1: int
@@ -26,9 +23,7 @@ class ImageCropOut(BaseModel):
created_at: datetime created_at: datetime
class SubmissionFileOut(BaseModel): class SubmissionFileOut(Schema):
model_config = ConfigDict(from_attributes=True)
id: UUID id: UUID
original_filename: str original_filename: str
content_type: str content_type: str
@@ -39,9 +34,7 @@ class SubmissionFileOut(BaseModel):
crops: list[ImageCropOut] = [] crops: list[ImageCropOut] = []
class SubmissionOut(BaseModel): class SubmissionOut(Schema):
model_config = ConfigDict(from_attributes=True)
id: UUID id: UUID
game_id: int | None = None game_id: int | None = None
notes: str | 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.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
@@ -17,15 +16,14 @@ def broadcast_submission_update(sender, instance, created, **kwargs):
return return
user_group_name = f"submissions_user_{instance.user.id}" user_group_name = f"submissions_user_{instance.user.id}"
schema = SubmissionOut.model_validate(instance) submission_data = SubmissionOut.from_orm(instance).dict()
submission_data = schema.model_dump(mode="json")
async_to_sync(channel_layer.group_send)( async_to_sync(channel_layer.group_send)(
user_group_name, user_group_name,
{ {
"type": "submission.updated", "type": "submission.updated",
"submission": submission_data, "submission": submission_data,
} },
) )
@@ -36,15 +34,14 @@ def broadcast_submission_file_update(sender, instance, created, **kwargs):
return return
user_group_name = f"submissions_user_{submission.user.id}" user_group_name = f"submissions_user_{submission.user.id}"
schema = SubmissionFileOut.model_validate(instance) file_data = SubmissionFileOut.from_orm(instance).dict()
file_data = schema.model_dump(mode="json")
async_to_sync(channel_layer.group_send)( async_to_sync(channel_layer.group_send)(
user_group_name, user_group_name,
{ {
"type": "submission.file.updated", "type": "submission.file.updated",
"file": file_data, "file": file_data,
} },
) )
@@ -55,13 +52,12 @@ def broadcast_image_crop_update(sender, instance, created, **kwargs):
return return
user_group_name = f"submissions_user_{submission.user.id}" user_group_name = f"submissions_user_{submission.user.id}"
schema = ImageCropOut.model_validate(instance) crop_data = ImageCropOut.from_orm(instance).dict()
crop_data = schema.model_dump(mode="json")
async_to_sync(channel_layer.group_send)( async_to_sync(channel_layer.group_send)(
user_group_name, user_group_name,
{ {
"type": "submission.crop.updated", "type": "submission.crop.updated",
"crop": crop_data, "crop": crop_data,
} },
) )
+16 -13
View File
@@ -5,25 +5,28 @@ from PIL import Image
import pytesseract import pytesseract
def _process_ocr_file(file) -> bool: def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
if file.ocr_status != OCRStatus.TO_PROCESS: if obj.ocr_status != OCRStatus.TO_PROCESS:
return False return False
file.ocr_status = OCRStatus.PROCESSING obj.ocr_status = OCRStatus.PROCESSING
file.save(update_fields=["ocr_status"]) obj.save(update_fields=["ocr_status"])
try: try:
file.ocr_raw_data = pytesseract.image_to_string(Image.open(file.file)) image = Image.open(file)
except Exception: if crop:
file.ocr_status = OCRStatus.ERROR image = image.crop((crop.x1, crop.y1, crop.x2, crop.y2))
file.save(update_fields=["ocr_status"])
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: finally:
file.ocr_status = OCRStatus.PROCESSED obj.save(update_fields=["ocr_status", "ocr_raw_data"])
file.save(update_fields=["ocr_status", "ocr_raw_data"])
return True return True
@@ -42,7 +45,7 @@ def process_submission_file(file_id: str):
if not file: if not file:
return return
_process_ocr_file(file) _process_ocr_file(file, file.file)
process_crops.enqueue(file_id) process_crops.enqueue(file_id)
@@ -65,4 +68,4 @@ def process_crop(crop_id: str):
if not crop: if not crop:
return return
_process_ocr_file(crop) _process_ocr_file(crop, crop.submission_file.file, crop=crop)