354 lines
12 KiB
Vue
354 lines
12 KiB
Vue
<template>
|
|
<div class="form-control w-full">
|
|
<label class="label">
|
|
<span class="label-text font-medium">Upload Solution Files</span>
|
|
<span class="label-text-alt text-xs">Images or GIFs only</span>
|
|
</label>
|
|
|
|
<div
|
|
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center hover:border-primary transition-colors duration-300"
|
|
:class="{ 'border-primary bg-primary/5': isDragOver }"
|
|
@drop="handleDrop"
|
|
@dragover.prevent="isDragOver = true"
|
|
@dragleave="isDragOver = false"
|
|
@dragenter.prevent
|
|
>
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
multiple
|
|
accept="image/*,.gif"
|
|
class="hidden"
|
|
@change="handleFileSelect"
|
|
>
|
|
|
|
<div v-if="files.length === 0" class="space-y-4">
|
|
<div class="mx-auto w-12 h-12 text-base-content/40 flex items-center justify-center">
|
|
<i class="mdi mdi-cloud-upload text-5xl"></i>
|
|
</div>
|
|
<div>
|
|
<p class="text-base-content/70 mb-2">Drop your files here or</p>
|
|
<button
|
|
type="button"
|
|
@click="fileInput?.click()"
|
|
class="btn btn-primary btn-sm"
|
|
>
|
|
Choose Files
|
|
</button>
|
|
</div>
|
|
<p class="text-xs text-base-content/50">
|
|
Supported formats: JPG, PNG, GIF (max 256MB each)
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else class="space-y-4">
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<div
|
|
v-for="(file, index) in files"
|
|
:key="index"
|
|
class="relative group"
|
|
>
|
|
<div class="aspect-square rounded-lg overflow-hidden bg-base-200">
|
|
<img
|
|
:src="file.preview"
|
|
:alt="file.file.name"
|
|
class="w-full h-full object-cover"
|
|
>
|
|
</div>
|
|
|
|
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center">
|
|
<button
|
|
@click="removeFile(index)"
|
|
class="btn btn-error btn-sm btn-circle"
|
|
>
|
|
<i class="mdi mdi-close"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mt-2">
|
|
<p class="text-xs font-medium truncate">{{ file.file.name }}</p>
|
|
<p class="text-xs text-base-content/60">
|
|
{{ formatFileSize(file.file.size) }} • {{ file.type.toUpperCase() }}
|
|
</p>
|
|
|
|
<!-- OCR Status and Results -->
|
|
<div v-if="file.ocrProcessing" class="mt-1 flex items-center gap-1">
|
|
<span class="loading loading-spinner loading-xs"></span>
|
|
<span class="text-xs text-info">Extracting puzzle data...</span>
|
|
</div>
|
|
|
|
<div v-else-if="file.ocrError" class="mt-1">
|
|
<p class="text-xs text-error">{{ file.ocrError }}</p>
|
|
</div>
|
|
|
|
<div v-else-if="file.ocrData" class="mt-1 space-y-1">
|
|
<div class="text-xs flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-medium text-success">✓ OCR Complete</span>
|
|
<span
|
|
v-if="file.ocrData.confidence"
|
|
class="badge badge-xs"
|
|
:class="getConfidenceBadgeClass(file.ocrData.confidence.overall)"
|
|
:title="`Overall confidence: ${Math.round(file.ocrData.confidence.overall * 100)}%`"
|
|
>
|
|
{{ Math.round(file.ocrData.confidence.overall * 100) }}%
|
|
</span>
|
|
</div>
|
|
<button
|
|
@click="retryOCR(file)"
|
|
class="btn btn-xs btn-ghost"
|
|
title="Retry OCR"
|
|
>
|
|
<i class="mdi mdi-refresh"></i>
|
|
</button>
|
|
</div>
|
|
<div class="text-xs space-y-1 bg-base-200 p-2 rounded">
|
|
<div v-if="file.ocrData.puzzle">
|
|
<strong>Puzzle:</strong> {{ file.ocrData.puzzle }}
|
|
<span
|
|
v-if="file.ocrData.confidence?.puzzle"
|
|
class="ml-2 opacity-60"
|
|
:title="`Puzzle confidence: ${Math.round(file.ocrData.confidence.puzzle * 100)}%`"
|
|
>
|
|
({{ Math.round(file.ocrData.confidence.puzzle * 100) }}%)
|
|
</span>
|
|
</div>
|
|
<div v-if="file.ocrData.cost">
|
|
<strong>Cost:</strong> {{ file.ocrData.cost }}
|
|
<span
|
|
v-if="file.ocrData.confidence?.cost"
|
|
class="ml-2 opacity-60"
|
|
:title="`Cost confidence: ${Math.round(file.ocrData.confidence.cost * 100)}%`"
|
|
>
|
|
({{ Math.round(file.ocrData.confidence.cost * 100) }}%)
|
|
</span>
|
|
</div>
|
|
<div v-if="file.ocrData.cycles">
|
|
<strong>Cycles:</strong> {{ file.ocrData.cycles }}
|
|
<span
|
|
v-if="file.ocrData.confidence?.cycles"
|
|
class="ml-2 opacity-60"
|
|
:title="`Cycles confidence: ${Math.round(file.ocrData.confidence.cycles * 100)}%`"
|
|
>
|
|
({{ Math.round(file.ocrData.confidence.cycles * 100) }}%)
|
|
</span>
|
|
</div>
|
|
<div v-if="file.ocrData.area">
|
|
<strong>Area:</strong> {{ file.ocrData.area }}
|
|
<span
|
|
v-if="file.ocrData.confidence?.area"
|
|
class="ml-2 opacity-60"
|
|
:title="`Area confidence: ${Math.round(file.ocrData.confidence.area * 100)}%`"
|
|
>
|
|
({{ Math.round(file.ocrData.confidence.area * 100) }}%)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Manual OCR trigger for non-auto detected files -->
|
|
<div v-else-if="!file.ocrProcessing && !file.ocrError && !file.ocrData" class="mt-1">
|
|
<button
|
|
@click="processOCR(file)"
|
|
class="btn btn-xs btn-outline"
|
|
>
|
|
<i class="mdi mdi-text-recognition"></i>
|
|
Extract Puzzle Data
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-center">
|
|
<button
|
|
type="button"
|
|
@click="fileInput?.click()"
|
|
class="btn btn-outline btn-sm"
|
|
>
|
|
Add More Files
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="error" class="label">
|
|
<span class="label-text-alt text-error">{{ error }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch, nextTick } from 'vue'
|
|
import { ocrService } from '../services/ocrService'
|
|
import type { SubmissionFile, SteamCollectionItem } from '@/types'
|
|
|
|
interface Props {
|
|
modelValue: SubmissionFile[]
|
|
puzzles?: SteamCollectionItem[]
|
|
}
|
|
|
|
interface Emits {
|
|
'update:modelValue': [files: SubmissionFile[]]
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
const emit = defineEmits<Emits>()
|
|
|
|
const fileInput = ref<HTMLInputElement>()
|
|
const isDragOver = ref(false)
|
|
const error = ref('')
|
|
const files = ref<SubmissionFile[]>([])
|
|
|
|
// Watch for external changes to modelValue
|
|
watch(() => props.modelValue, (newFiles) => {
|
|
files.value = newFiles
|
|
}, { immediate: true })
|
|
|
|
// Watch for internal changes and emit
|
|
watch(files, (newFiles) => {
|
|
emit('update:modelValue', newFiles)
|
|
}, { deep: true })
|
|
|
|
// Watch for puzzle changes and update OCR service
|
|
watch(() => props.puzzles, (newPuzzles) => {
|
|
if (newPuzzles && newPuzzles.length > 0) {
|
|
const puzzleNames = newPuzzles.map(puzzle => puzzle.title)
|
|
ocrService.setAvailablePuzzleNames(puzzleNames)
|
|
}
|
|
}, { immediate: true })
|
|
|
|
const handleFileSelect = (event: Event) => {
|
|
const target = event.target as HTMLInputElement
|
|
if (target.files) {
|
|
processFiles(Array.from(target.files))
|
|
}
|
|
}
|
|
|
|
const handleDrop = (event: DragEvent) => {
|
|
event.preventDefault()
|
|
isDragOver.value = false
|
|
|
|
if (event.dataTransfer?.files) {
|
|
processFiles(Array.from(event.dataTransfer.files))
|
|
}
|
|
}
|
|
|
|
const processFiles = async (newFiles: File[]) => {
|
|
error.value = ''
|
|
|
|
for (const file of newFiles) {
|
|
if (!isValidFile(file)) {
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const preview = await createPreview(file)
|
|
const fileType = file.type.startsWith('image/gif') ? 'gif' : 'image'
|
|
|
|
const submissionFile: SubmissionFile = {
|
|
file,
|
|
preview,
|
|
type: fileType,
|
|
ocrProcessing: false,
|
|
ocrError: undefined,
|
|
ocrData: undefined
|
|
}
|
|
|
|
files.value.push(submissionFile)
|
|
|
|
// Start OCR processing for Opus Magnum images (with delay to ensure reactivity)
|
|
if (isOpusMagnumImage(file)) {
|
|
nextTick(() => {
|
|
processOCR(submissionFile)
|
|
})
|
|
}
|
|
} catch (err) {
|
|
error.value = `Failed to process ${file.name}`
|
|
}
|
|
}
|
|
}
|
|
|
|
const isValidFile = (file: File): boolean => {
|
|
// Check file type
|
|
if (!file.type.startsWith('image/')) {
|
|
error.value = `${file.name} is not a valid image file`
|
|
return false
|
|
}
|
|
|
|
// Check file size (256MB limit)
|
|
if (file.size > 256 * 1024 * 1024) {
|
|
error.value = `${file.name} is too large (max 256MB)`
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
const createPreview = (file: File): Promise<string> => {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader()
|
|
reader.onload = (e) => resolve(e.target?.result as string)
|
|
reader.onerror = reject
|
|
reader.readAsDataURL(file)
|
|
})
|
|
}
|
|
|
|
const removeFile = (index: number) => {
|
|
files.value.splice(index, 1)
|
|
}
|
|
|
|
const formatFileSize = (bytes: number): string => {
|
|
if (bytes === 0) return '0 Bytes'
|
|
|
|
const k = 1024
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
}
|
|
|
|
const isOpusMagnumImage = (file: File): boolean => {
|
|
// Basic heuristic - could be enhanced with actual image analysis
|
|
return file.type.startsWith('image/') && file.size > 50000 // > 50KB likely screenshot
|
|
}
|
|
|
|
const processOCR = async (submissionFile: SubmissionFile) => {
|
|
// Find the file in the reactive array to ensure proper reactivity
|
|
const fileIndex = files.value.findIndex(f => f.file === submissionFile.file)
|
|
if (fileIndex === -1) return
|
|
|
|
// Update the reactive array directly
|
|
files.value[fileIndex].ocrProcessing = true
|
|
files.value[fileIndex].ocrError = undefined
|
|
files.value[fileIndex].ocrData = undefined
|
|
|
|
try {
|
|
console.log('Starting OCR processing for:', submissionFile.file.name)
|
|
await ocrService.initialize()
|
|
const ocrData = await ocrService.extractOpusMagnumData(submissionFile.file)
|
|
console.log('OCR completed:', ocrData)
|
|
|
|
// Force reactivity update
|
|
await nextTick()
|
|
files.value[fileIndex].ocrData = ocrData
|
|
await nextTick()
|
|
} catch (error) {
|
|
console.error('OCR processing failed:', error)
|
|
files.value[fileIndex].ocrError = 'Failed to extract puzzle data'
|
|
} finally {
|
|
files.value[fileIndex].ocrProcessing = false
|
|
}
|
|
}
|
|
|
|
const retryOCR = (submissionFile: SubmissionFile) => {
|
|
processOCR(submissionFile)
|
|
}
|
|
|
|
const getConfidenceBadgeClass = (confidence: number): string => {
|
|
if (confidence >= 0.8) return 'badge-success'
|
|
if (confidence >= 0.6) return 'badge-warning'
|
|
return 'badge-error'
|
|
}
|
|
</script>
|