Files
polylan-submitter/polylan_submitter/src/components/FileUpload.vue
T

369 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="submissionFiles.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-2 gap-4">
<div
v-for="(file, index) in submissionFiles"
: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/80 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-lg 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="processOCR(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 Puzzle Selection (when OCR confidence is low) -->
<div v-if="file.needsManualPuzzleSelection" class="mt-2">
<div class="alert alert-warning alert-sm">
<i class="mdi mdi-alert-circle text-lg"></i>
<div class="flex-1">
<div class="font-medium">Low OCR Confidence</div>
<div class="text-xs">
Please select the correct puzzle manually
</div>
</div>
</div>
<div class="mt-2">
<select
v-model="file.manualPuzzleSelection"
class="select select-bordered select-sm w-full"
@change="onManualPuzzleSelection(file)"
>
<option value="">Select puzzle...</option>
<option
v-for="puzzle in puzzlesStore.puzzles"
:key="puzzle.id"
:value="puzzle.title"
>
{{ puzzle.title }}
</option>
</select>
</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 { usePuzzlesStore } from "@/stores/puzzles";
import { useUploadsStore } from "@/stores/uploads";
import type { SubmissionFile } from "@/types";
// Pinia store
const puzzlesStore = usePuzzlesStore();
const { submissionFiles, processOCR } = useUploadsStore();
const fileInput = ref<HTMLInputElement>();
const isDragOver = ref(false);
const error = ref("");
// Watch for puzzle changes and update OCR service
watch(
() => puzzlesStore.puzzles,
(newPuzzles) => {
if (newPuzzles && newPuzzles.length > 0) {
ocrService.setAvailablePuzzleNames(puzzlesStore.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,
file_url: "",
preview,
type: fileType,
ocrProcessing: false,
ocrError: undefined,
ocrData: undefined,
};
submissionFiles.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) => {
submissionFiles.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 getConfidenceBadgeClass = (confidence: number): string => {
if (confidence >= 0.8) return "badge-success";
if (confidence >= 0.6) return "badge-warning";
return "badge-error";
};
const onManualPuzzleSelection = (submissionFile: SubmissionFile) => {
// Find the file in the reactive array
const fileIndex = submissionFiles.findIndex(
(f) => f.file === submissionFile.file,
);
if (fileIndex === -1) return;
// Clear the manual selection requirement once user has selected
if (submissionFiles[fileIndex].manualPuzzleSelection) {
submissionFiles[fileIndex].needsManualPuzzleSelection = false;
console.log(
`Manual puzzle selection: ${submissionFile.file.name} -> ${submissionFiles[fileIndex].manualPuzzleSelection}`,
);
}
};
</script>