working ocr in ts
This commit is contained in:
+256
-1
@@ -1,6 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import PuzzleCard from './components/PuzzleCard.vue'
|
||||
import SubmissionForm from './components/SubmissionForm.vue'
|
||||
import type { SteamCollection, SteamCollectionItem, Submission, PuzzleResponse } from './types'
|
||||
|
||||
// Mock data - replace with actual API calls later
|
||||
const collections = ref<SteamCollection[]>([])
|
||||
const puzzles = ref<SteamCollectionItem[]>([])
|
||||
const submissions = ref<Submission[]>([])
|
||||
const isLoading = ref(true)
|
||||
const showSubmissionModal = ref(false)
|
||||
|
||||
// Mock data for development
|
||||
const mockCollections: SteamCollection[] = [
|
||||
{
|
||||
id: 1,
|
||||
steam_id: '3479142989',
|
||||
title: 'PolyLAN 41',
|
||||
description: 'Puzzle for PolyLAN 41 fil rouge',
|
||||
author_name: 'Flame Legrems',
|
||||
total_items: 10,
|
||||
unique_visitors: 31,
|
||||
current_favorites: 1,
|
||||
created_at: '2025-05-29T11:19:24Z',
|
||||
updated_at: '2025-05-30T22:15:09Z'
|
||||
}
|
||||
]
|
||||
|
||||
const mockPuzzles: SteamCollectionItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
steam_item_id: '3479143948',
|
||||
title: 'P41-FLOC',
|
||||
author_name: 'Flame Legrems',
|
||||
description: 'A challenging puzzle involving complex molecular arrangements',
|
||||
tags: ['puzzle', 'chemistry', 'advanced'],
|
||||
order_index: 0,
|
||||
collection: 1,
|
||||
created_at: '2025-05-29T11:19:24Z',
|
||||
updated_at: '2025-05-30T22:15:09Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
steam_item_id: '3479143084',
|
||||
title: 'P41-40',
|
||||
author_name: 'Flame Legrems',
|
||||
description: 'Test your optimization skills with this intricate design challenge',
|
||||
tags: ['optimization', 'design'],
|
||||
order_index: 1,
|
||||
collection: 1,
|
||||
created_at: '2025-05-29T11:19:24Z',
|
||||
updated_at: '2025-05-30T22:15:09Z'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
steam_item_id: '3479143304',
|
||||
title: 'P41-39',
|
||||
author_name: 'Flame Legrems',
|
||||
description: 'A puzzle focusing on efficient resource management',
|
||||
tags: ['efficiency', 'resources'],
|
||||
order_index: 2,
|
||||
collection: 1,
|
||||
created_at: '2025-05-29T11:19:24Z',
|
||||
updated_at: '2025-05-30T22:15:09Z'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
steam_item_id: '3479143433',
|
||||
title: 'P41-38',
|
||||
author_name: 'Flame Legrems',
|
||||
description: 'Master the art of precise timing in this temporal challenge',
|
||||
tags: ['timing', 'precision'],
|
||||
order_index: 3,
|
||||
collection: 1,
|
||||
created_at: '2025-05-29T11:19:24Z',
|
||||
updated_at: '2025-05-30T22:15:09Z'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
steam_item_id: '3479143537',
|
||||
title: 'P41-37',
|
||||
author_name: 'Flame Legrems',
|
||||
description: 'Explore innovative solutions in this creative puzzle',
|
||||
tags: ['creative', 'innovation'],
|
||||
order_index: 4,
|
||||
collection: 1,
|
||||
created_at: '2025-05-29T11:19:24Z',
|
||||
updated_at: '2025-05-30T22:15:09Z'
|
||||
}
|
||||
]
|
||||
|
||||
// Computed property to get responses grouped by puzzle
|
||||
const responsesByPuzzle = computed(() => {
|
||||
const grouped: Record<number, PuzzleResponse[]> = {}
|
||||
submissions.value.forEach(submission => {
|
||||
submission.responses.forEach(response => {
|
||||
if (!grouped[response.puzzle_id]) {
|
||||
grouped[response.puzzle_id] = []
|
||||
}
|
||||
grouped[response.puzzle_id].push(response)
|
||||
})
|
||||
})
|
||||
return grouped
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Simulate API loading
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
collections.value = mockCollections
|
||||
puzzles.value = mockPuzzles
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
const handleSubmission = (submission: Submission) => {
|
||||
console.log('Submission received:', submission)
|
||||
|
||||
// Add submission to the list
|
||||
submissions.value.push({
|
||||
...submission,
|
||||
id: Date.now(), // Simple ID generation for demo
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
// Show success message
|
||||
const puzzleNames = submission.responses.map(r => r.puzzle_name).join(', ')
|
||||
alert(`Solutions submitted for puzzles: ${puzzleNames}`)
|
||||
|
||||
// Close modal
|
||||
showSubmissionModal.value = false
|
||||
}
|
||||
|
||||
const openSubmissionModal = () => {
|
||||
showSubmissionModal.value = true
|
||||
}
|
||||
|
||||
const closeSubmissionModal = () => {
|
||||
showSubmissionModal.value = false
|
||||
}
|
||||
|
||||
// Function to match puzzle name from OCR to actual puzzle
|
||||
const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null => {
|
||||
if (!ocrPuzzleName) return null
|
||||
|
||||
// Try exact match first
|
||||
let match = puzzles.value.find(p =>
|
||||
p.title.toLowerCase() === ocrPuzzleName.toLowerCase()
|
||||
)
|
||||
|
||||
if (!match) {
|
||||
// Try partial match
|
||||
match = puzzles.value.find(p =>
|
||||
p.title.toLowerCase().includes(ocrPuzzleName.toLowerCase()) ||
|
||||
ocrPuzzleName.toLowerCase().includes(p.title.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
return match || null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
HELLO
|
||||
<div class="min-h-screen bg-base-200">
|
||||
<!-- Header -->
|
||||
<div class="navbar bg-base-100 shadow-lg">
|
||||
<div class="container mx-auto">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<button
|
||||
@click="openSubmissionModal"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<i class="mdi mdi-plus mr-2"></i>
|
||||
Submit Solution
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
|
||||
<div class="text-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div v-else class="space-y-8">
|
||||
<!-- Collection Info -->
|
||||
<div v-if="collections.length > 0" class="mb-8">
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl">{{ collections[0].title }}</h2>
|
||||
<p class="text-base-content/70">{{ collections[0].description }}</p>
|
||||
<div class="flex flex-wrap gap-4 mt-4">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Total Puzzles</div>
|
||||
<div class="stat-value text-primary">{{ collections[0].total_items }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Author</div>
|
||||
<div class="stat-value text-sm">{{ collections[0].author_name }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Visitors</div>
|
||||
<div class="stat-value text-sm">{{ collections[0].unique_visitors }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Puzzles Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<PuzzleCard
|
||||
v-for="puzzle in puzzles"
|
||||
:key="puzzle.id"
|
||||
:puzzle="puzzle"
|
||||
:responses="responsesByPuzzle[puzzle.id] || []"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="puzzles.length === 0" class="text-center py-12">
|
||||
<div class="text-6xl mb-4">🧩</div>
|
||||
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3>
|
||||
<p class="text-base-content/70">Check back later for new puzzle collections!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submission Modal -->
|
||||
<div v-if="showSubmissionModal" class="modal modal-open">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-bold text-lg">Submit Solution</h3>
|
||||
<button
|
||||
@click="closeSubmissionModal"
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SubmissionForm
|
||||
:puzzles="puzzles"
|
||||
:find-puzzle-by-name="findPuzzleByName"
|
||||
@submit="handleSubmission"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
<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 10MB 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">
|
||||
<span class="font-medium text-success">✓ OCR Complete</span>
|
||||
<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 }}
|
||||
</div>
|
||||
<div v-if="file.ocrData.cost">
|
||||
<strong>Cost:</strong> {{ file.ocrData.cost }}
|
||||
</div>
|
||||
<div v-if="file.ocrData.cycles">
|
||||
<strong>Cycles:</strong> {{ file.ocrData.cycles }}
|
||||
</div>
|
||||
<div v-if="file.ocrData.area">
|
||||
<strong>Area:</strong> {{ file.ocrData.area }}
|
||||
</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, type OpusMagnumData } from '../services/ocrService'
|
||||
import type { SubmissionFile } from '@/types'
|
||||
|
||||
interface Props {
|
||||
modelValue: SubmissionFile[]
|
||||
}
|
||||
|
||||
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 })
|
||||
|
||||
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 (10MB limit)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
error.value = `${file.name} is too large (max 10MB)`
|
||||
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)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="card-title text-lg font-bold">{{ puzzle.title }}</h3>
|
||||
<p class="text-sm text-base-content/70 mb-2">by {{ puzzle.author_name }}</p>
|
||||
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="badge badge-primary badge-sm">{{ puzzle.steam_item_id }}</div>
|
||||
<div class="badge badge-ghost badge-sm">Order: {{ puzzle.order_index + 1 }}</div>
|
||||
</div>
|
||||
|
||||
<p v-if="puzzle.description" class="text-sm text-base-content/80 mb-4 line-clamp-2">
|
||||
{{ puzzle.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="puzzle.tags && puzzle.tags.length > 0" class="flex flex-wrap gap-1 mb-4">
|
||||
<span
|
||||
v-for="tag in puzzle.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
class="badge badge-outline badge-xs"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
<span v-if="puzzle.tags.length > 3" class="badge badge-outline badge-xs">
|
||||
+{{ puzzle.tags.length - 3 }} more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<div class="tooltip" data-tip="View on Steam Workshop">
|
||||
<a
|
||||
:href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`"
|
||||
target="_blank"
|
||||
class="btn btn-ghost btn-sm btn-square"
|
||||
>
|
||||
<i class="mdi mdi-steam text-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Responses Table -->
|
||||
<div v-if="responses && responses.length > 0" class="mt-6">
|
||||
<div class="divider">
|
||||
<span class="text-sm font-medium">Solutions ({{ responses.length }})</span>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cost</th>
|
||||
<th>Cycles</th>
|
||||
<th>Area</th>
|
||||
<th>Files</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="response in responses" :key="response.id" class="hover">
|
||||
<td>
|
||||
<span v-if="response.cost" class="badge badge-success badge-xs">
|
||||
{{ response.cost }}
|
||||
</span>
|
||||
<span v-else class="text-base-content/50">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="response.cycles" class="badge badge-info badge-xs">
|
||||
{{ response.cycles }}
|
||||
</span>
|
||||
<span v-else class="text-base-content/50">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="response.area" class="badge badge-warning badge-xs">
|
||||
{{ response.area }}
|
||||
</span>
|
||||
<span v-else class="text-base-content/50">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="badge badge-ghost badge-xs">{{ response.files.length }}</span>
|
||||
<div class="tooltip" :data-tip="response.files.map(f => f.file.name).join(', ')">
|
||||
<i class="mdi mdi-information-outline text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No responses state -->
|
||||
<div v-else class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg">
|
||||
<i class="mdi mdi-upload text-2xl text-base-content/40"></i>
|
||||
<p class="text-sm text-base-content/60 mt-2">No solutions yet</p>
|
||||
<p class="text-xs text-base-content/40">Upload solutions using the submit button</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SteamCollectionItem, PuzzleResponse } from '@/types'
|
||||
|
||||
interface Props {
|
||||
puzzle: SteamCollectionItem
|
||||
responses?: PuzzleResponse[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
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 formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-xl mb-6">
|
||||
<i class="mdi mdi-check-circle text-2xl text-primary"></i>
|
||||
Submit Solution
|
||||
</h2>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<!-- Detected Puzzles Summary -->
|
||||
<div v-if="Object.keys(responsesByPuzzle).length > 0" class="alert alert-info">
|
||||
<i class="mdi mdi-information-outline text-xl"></i>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold">Detected Puzzles ({{ Object.keys(responsesByPuzzle).length }})</h4>
|
||||
<div class="text-sm space-y-1 mt-1">
|
||||
<div v-for="(data, puzzleName) in responsesByPuzzle" :key="puzzleName" class="flex justify-between">
|
||||
<span>{{ puzzleName }}</span>
|
||||
<span class="badge badge-ghost badge-sm">{{ data.files.length }} file(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<FileUpload v-model="submissionFiles" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Notes (Optional)</span>
|
||||
<span class="label-text-alt">{{ notesLength }}/500</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="notes"
|
||||
class="textarea textarea-bordered h-24 resize-none"
|
||||
placeholder="Add any notes about your solution, approach, or interesting findings..."
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="card-actions justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
|
||||
{{ isSubmitting ? 'Submitting...' : 'Submit Solution' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import FileUpload from './FileUpload.vue'
|
||||
import type { SteamCollectionItem, SubmissionFile, Submission, PuzzleResponse } from '@/types'
|
||||
|
||||
interface Props {
|
||||
puzzles: SteamCollectionItem[]
|
||||
findPuzzleByName: (name: string) => SteamCollectionItem | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
submit: [submission: Submission]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const submissionFiles = ref<SubmissionFile[]>([])
|
||||
const notes = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const notesLength = computed(() => notes.value.length)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return submissionFiles.value.length > 0 &&
|
||||
!isSubmitting.value
|
||||
})
|
||||
|
||||
// Group files by detected puzzle
|
||||
const responsesByPuzzle = computed(() => {
|
||||
const grouped: Record<string, { puzzle: SteamCollectionItem | null, files: SubmissionFile[] }> = {}
|
||||
|
||||
submissionFiles.value.forEach(file => {
|
||||
if (file.ocrData?.puzzle) {
|
||||
const puzzleName = file.ocrData.puzzle
|
||||
if (!grouped[puzzleName]) {
|
||||
grouped[puzzleName] = {
|
||||
puzzle: props.findPuzzleByName(puzzleName),
|
||||
files: []
|
||||
}
|
||||
}
|
||||
grouped[puzzleName].files.push(file)
|
||||
}
|
||||
})
|
||||
|
||||
return grouped
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
const responses: PuzzleResponse[] = []
|
||||
|
||||
// Create responses for each detected puzzle
|
||||
Object.entries(responsesByPuzzle.value).forEach(([puzzleName, data]) => {
|
||||
if (data.puzzle) {
|
||||
// Get OCR data from the first file with complete data
|
||||
const fileWithOCR = data.files.find(f => f.ocrData?.cost || f.ocrData?.cycles || f.ocrData?.area)
|
||||
|
||||
responses.push({
|
||||
puzzle_id: data.puzzle.id,
|
||||
puzzle_name: puzzleName,
|
||||
cost: fileWithOCR?.ocrData?.cost,
|
||||
cycles: fileWithOCR?.ocrData?.cycles,
|
||||
area: fileWithOCR?.ocrData?.area,
|
||||
files: data.files
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const submission: Submission = {
|
||||
responses,
|
||||
notes: notes.value.trim() || undefined
|
||||
}
|
||||
|
||||
emit('submit', submission)
|
||||
|
||||
// Reset form
|
||||
submissionFiles.value = []
|
||||
notes.value = ''
|
||||
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error)
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from '@/App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { createWorker } from 'tesseract.js';
|
||||
|
||||
export interface OpusMagnumData {
|
||||
puzzle: string;
|
||||
cost: string;
|
||||
cycles: string;
|
||||
area: string;
|
||||
}
|
||||
|
||||
export interface OCRRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export class OpusMagnumOCRService {
|
||||
private worker: Tesseract.Worker | null = null;
|
||||
|
||||
// Regions based on main.py coordinates (adjusted for web usage)
|
||||
private readonly regions: Record<string, OCRRegion> = {
|
||||
puzzle: { x: 15, y: 600, width: 330, height: 28 },
|
||||
cost: { x: 412, y: 603, width: 65, height: 22 },
|
||||
cycles: { x: 577, y: 603, width: 65, height: 22 },
|
||||
area: { x: 739, y: 603, width: 65, height: 22 }
|
||||
};
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.worker) return;
|
||||
|
||||
this.worker = await createWorker('eng');
|
||||
await this.worker.setParameters({
|
||||
tessedit_ocr_engine_mode: '3',
|
||||
tessedit_pageseg_mode: '7'
|
||||
});
|
||||
}
|
||||
|
||||
async extractOpusMagnumData(imageFile: File): Promise<OpusMagnumData> {
|
||||
if (!this.worker) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
// Convert file to image element for canvas processing
|
||||
const imageUrl = URL.createObjectURL(imageFile);
|
||||
const img = new Image();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
img.onload = async () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Extract text from each region
|
||||
const results: Partial<OpusMagnumData> = {};
|
||||
|
||||
for (const [key, region] of Object.entries(this.regions)) {
|
||||
const regionCanvas = document.createElement('canvas');
|
||||
const regionCtx = regionCanvas.getContext('2d')!;
|
||||
|
||||
regionCanvas.width = region.width;
|
||||
regionCanvas.height = region.height;
|
||||
|
||||
// Extract region from main image
|
||||
regionCtx.drawImage(
|
||||
canvas,
|
||||
region.x, region.y, region.width, region.height,
|
||||
0, 0, region.width, region.height
|
||||
);
|
||||
|
||||
// Convert to grayscale and invert (similar to main.py processing)
|
||||
const imageData = regionCtx.getImageData(0, 0, region.width, region.height);
|
||||
this.preprocessImage(imageData);
|
||||
regionCtx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Configure OCR based on content type
|
||||
let config: any = {};
|
||||
if (key === 'cost') {
|
||||
// Cost field has digits + 'G' for gold (content type: 'digits_with_6')
|
||||
await this.worker!.setParameters({
|
||||
tessedit_char_whitelist: '0123456789G'
|
||||
});
|
||||
} else if (key === 'cycles' || key === 'area') {
|
||||
// Pure digits (content type: 'digits')
|
||||
await this.worker!.setParameters({
|
||||
tessedit_char_whitelist: '0123456789'
|
||||
});
|
||||
} else if (key === 'puzzle') {
|
||||
// Puzzle name - allow alphanumeric, spaces, and dashes
|
||||
await this.worker!.setParameters({
|
||||
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 -'
|
||||
});
|
||||
} else {
|
||||
// Default - allow all characters
|
||||
await this.worker!.setParameters({
|
||||
tessedit_char_whitelist: ''
|
||||
});
|
||||
}
|
||||
|
||||
// Perform OCR on the region
|
||||
const { data: { text } } = await this.worker!.recognize(regionCanvas);
|
||||
let cleanText = text.trim();
|
||||
|
||||
// Post-process based on field type
|
||||
if (key === 'cost') {
|
||||
// Handle common OCR misreadings where G is read as 6
|
||||
// If the text ends with 6 and looks like it should be G, remove it
|
||||
if (cleanText.endsWith('6') && cleanText.length > 1) {
|
||||
// Check if removing the last character gives a reasonable cost value
|
||||
const withoutLast = cleanText.slice(0, -1);
|
||||
if (/^\d+$/.test(withoutLast)) {
|
||||
cleanText = withoutLast;
|
||||
}
|
||||
}
|
||||
// Remove any trailing G characters
|
||||
cleanText = cleanText.replace(/G+$/g, '');
|
||||
// Ensure only digits remain
|
||||
cleanText = cleanText.replace(/[^0-9]/g, '');
|
||||
} else if (key === 'cycles' || key === 'area') {
|
||||
// Ensure only digits remain
|
||||
cleanText = cleanText.replace(/[^0-9]/g, '');
|
||||
} else if (key === 'puzzle') {
|
||||
// Post-process puzzle names
|
||||
cleanText = this.processPuzzleName(cleanText);
|
||||
}
|
||||
|
||||
results[key as keyof OpusMagnumData] = cleanText;
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
|
||||
resolve({
|
||||
puzzle: results.puzzle || '',
|
||||
cost: results.cost || '',
|
||||
cycles: results.cycles || '',
|
||||
area: results.area || ''
|
||||
});
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
private preprocessImage(imageData: ImageData): void {
|
||||
// Convert to grayscale and invert (similar to cv2.bitwise_not in main.py)
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
// Convert to grayscale
|
||||
const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
|
||||
|
||||
// Invert the grayscale value
|
||||
const inverted = 255 - gray;
|
||||
|
||||
data[i] = inverted; // Red
|
||||
data[i + 1] = inverted; // Green
|
||||
data[i + 2] = inverted; // Blue
|
||||
// Alpha channel (data[i + 3]) remains unchanged
|
||||
}
|
||||
}
|
||||
|
||||
private processPuzzleName(rawText: string): string {
|
||||
let processed = rawText.trim();
|
||||
|
||||
// If no dash is present but we have digits, try to insert one
|
||||
if (!processed.includes('-') && /\d/.test(processed)) {
|
||||
// Common pattern: "P4141" should become "P41-41"
|
||||
// Look for patterns like P[digits][digits] where the last part might be a separate number
|
||||
const match = processed.match(/^([A-Z]+\d+)(\d{1,3})$/);
|
||||
if (match) {
|
||||
processed = `${match[1]}-${match[2]}`;
|
||||
}
|
||||
// Handle cases like "4141" -> "41-41" (missing P prefix)
|
||||
else if (/^\d{3,4}$/.test(processed)) {
|
||||
const mid = Math.floor(processed.length / 2);
|
||||
processed = `P${processed.slice(0, mid)}-${processed.slice(mid)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up spacing around dashes
|
||||
processed = processed.replace(/\s*-\s*/g, '-');
|
||||
|
||||
// Ensure proper spacing
|
||||
processed = processed.replace(/([A-Z])(\d)/g, '$1$2');
|
||||
processed = processed.replace(/(\d)([A-Z])/g, '$1 $2');
|
||||
|
||||
// Add P prefix if missing and starts with digits
|
||||
if (/^\d/.test(processed) && !processed.startsWith('P')) {
|
||||
processed = 'P' + processed;
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
async terminate(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.terminate();
|
||||
this.worker = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility method to validate if an image looks like an Opus Magnum screenshot
|
||||
static isValidOpusMagnumImage(file: File): boolean {
|
||||
// Basic validation - could be enhanced with actual image analysis
|
||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
||||
return validTypes.includes(file.type);
|
||||
}
|
||||
|
||||
// Debug method to visualize OCR regions (similar to main.py debug rectangles)
|
||||
static drawDebugRegions(imageFile: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const imageUrl = URL.createObjectURL(imageFile);
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Draw debug rectangles
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
const service = new OpusMagnumOCRService();
|
||||
Object.values(service.regions).forEach(region => {
|
||||
ctx.strokeRect(region.x, region.y, region.width, region.height);
|
||||
});
|
||||
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
resolve(canvas.toDataURL());
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
reject(new Error('Failed to load image for debug'));
|
||||
};
|
||||
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for the application
|
||||
export const ocrService = new OpusMagnumOCRService();
|
||||
@@ -0,0 +1,39 @@
|
||||
@import '@mdi/font/css/materialdesignicons.css';
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "daisyui";
|
||||
@plugin "daisyui/theme" {
|
||||
name: "dim";
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: "dark";
|
||||
--color-base-100: oklch(30.857% 0.023 264.149);
|
||||
--color-base-200: oklch(28.036% 0.019 264.182);
|
||||
--color-base-300: oklch(26.346% 0.018 262.177);
|
||||
--color-base-content: oklch(82.901% 0.031 222.959);
|
||||
--color-primary: oklch(86.133% 0.141 139.549);
|
||||
--color-primary-content: oklch(17.226% 0.028 139.549);
|
||||
--color-secondary: oklch(73.375% 0.165 35.353);
|
||||
--color-secondary-content: oklch(14.675% 0.033 35.353);
|
||||
--color-accent: oklch(74.229% 0.133 311.379);
|
||||
--color-accent-content: oklch(14.845% 0.026 311.379);
|
||||
--color-neutral: oklch(24.731% 0.02 264.094);
|
||||
--color-neutral-content: oklch(82.901% 0.031 222.959);
|
||||
--color-info: oklch(86.078% 0.142 206.182);
|
||||
--color-info-content: oklch(17.215% 0.028 206.182);
|
||||
--color-success: oklch(86.171% 0.142 166.534);
|
||||
--color-success-content: oklch(17.234% 0.028 166.534);
|
||||
--color-warning: oklch(86.163% 0.142 94.818);
|
||||
--color-warning-content: oklch(17.232% 0.028 94.818);
|
||||
--color-error: oklch(82.418% 0.099 33.756);
|
||||
--color-error-content: oklch(16.483% 0.019 33.756);
|
||||
--radius-selector: 2rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.25rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface SteamCollection {
|
||||
id: number
|
||||
steam_id: string
|
||||
title: string
|
||||
description: string
|
||||
author_name: string
|
||||
total_items: number
|
||||
unique_visitors: number
|
||||
current_favorites: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SteamCollectionItem {
|
||||
id: number
|
||||
steam_item_id: string
|
||||
title: string
|
||||
author_name: string
|
||||
description: string
|
||||
tags: string[]
|
||||
order_index: number
|
||||
collection: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface OpusMagnumData {
|
||||
puzzle: string
|
||||
cost: string
|
||||
cycles: string
|
||||
area: string
|
||||
}
|
||||
|
||||
export interface SubmissionFile {
|
||||
file: File
|
||||
preview: string
|
||||
type: 'image' | 'gif'
|
||||
ocrData?: OpusMagnumData
|
||||
ocrProcessing?: boolean
|
||||
ocrError?: string
|
||||
}
|
||||
|
||||
export interface PuzzleResponse {
|
||||
id?: number
|
||||
puzzle_id: number
|
||||
puzzle_name: string
|
||||
cost?: string
|
||||
cycles?: string
|
||||
area?: string
|
||||
files: SubmissionFile[]
|
||||
}
|
||||
|
||||
export interface Submission {
|
||||
id?: number
|
||||
responses: PuzzleResponse[]
|
||||
notes?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Reference in New Issue
Block a user