use api in front
This commit is contained in:
+92
-40
@@ -2,14 +2,16 @@
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import PuzzleCard from './components/PuzzleCard.vue'
|
||||
import SubmissionForm from './components/SubmissionForm.vue'
|
||||
import { puzzleHelpers, submissionHelpers, errorHelpers } from './services/apiService'
|
||||
import type { SteamCollection, SteamCollectionItem, Submission, PuzzleResponse } from './types'
|
||||
|
||||
// Mock data - replace with actual API calls later
|
||||
// API data
|
||||
const collections = ref<SteamCollection[]>([])
|
||||
const puzzles = ref<SteamCollectionItem[]>([])
|
||||
const submissions = ref<Submission[]>([])
|
||||
const isLoading = ref(true)
|
||||
const showSubmissionModal = ref(false)
|
||||
const error = ref<string>('')
|
||||
|
||||
// Mock data for development
|
||||
const mockCollections: SteamCollection[] = [
|
||||
@@ -105,31 +107,83 @@ const responsesByPuzzle = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Simulate API loading
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
collections.value = mockCollections
|
||||
puzzles.value = mockPuzzles
|
||||
isLoading.value = false
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
// Load puzzles from API
|
||||
const loadedPuzzles = await puzzleHelpers.loadPuzzles()
|
||||
puzzles.value = loadedPuzzles
|
||||
|
||||
// Create mock collection from loaded puzzles for display
|
||||
if (loadedPuzzles.length > 0) {
|
||||
collections.value = [{
|
||||
id: 1,
|
||||
steam_id: '3479142989',
|
||||
title: 'PolyLAN 41',
|
||||
description: 'Puzzle collection for PolyLAN 41 fil rouge',
|
||||
author_name: 'Flame Legrems',
|
||||
total_items: loadedPuzzles.length,
|
||||
unique_visitors: 31,
|
||||
current_favorites: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}]
|
||||
}
|
||||
|
||||
// Load existing submissions
|
||||
const loadedSubmissions = await submissionHelpers.loadSubmissions()
|
||||
submissions.value = loadedSubmissions
|
||||
|
||||
} catch (err) {
|
||||
error.value = errorHelpers.getErrorMessage(err)
|
||||
console.error('Failed to load data:', err)
|
||||
} finally {
|
||||
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 handleSubmission = async (submissionData: {
|
||||
files: any[],
|
||||
notes?: string
|
||||
}) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
// Create submission via API
|
||||
const response = await submissionHelpers.createFromFiles(
|
||||
submissionData.files,
|
||||
puzzles.value,
|
||||
submissionData.notes
|
||||
)
|
||||
|
||||
if (response.error) {
|
||||
error.value = response.error
|
||||
alert(`Submission failed: ${response.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
// Add to local submissions list
|
||||
submissions.value.unshift(response.data)
|
||||
|
||||
// Show success message
|
||||
const puzzleNames = response.data.responses.map(r => r.puzzle_name).join(', ')
|
||||
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
|
||||
|
||||
// Close modal
|
||||
showSubmissionModal.value = false
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = errorHelpers.getErrorMessage(err)
|
||||
error.value = errorMessage
|
||||
alert(`Submission failed: ${errorMessage}`)
|
||||
console.error('Submission error:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openSubmissionModal = () => {
|
||||
@@ -142,22 +196,7 @@ const closeSubmissionModal = () => {
|
||||
|
||||
// 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
|
||||
return puzzleHelpers.findPuzzleByName(puzzles.value, ocrPuzzleName)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -181,6 +220,19 @@ const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null =>
|
||||
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="alert alert-error max-w-2xl mx-auto">
|
||||
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||
<div>
|
||||
<h3 class="font-bold">Error Loading Data</h3>
|
||||
<div class="text-sm">{{ error }}</div>
|
||||
</div>
|
||||
<button @click="window.location.reload()" class="btn btn-sm btn-outline">
|
||||
<i class="mdi mdi-refresh mr-1"></i>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div v-else class="space-y-8">
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<i class="mdi mdi-shield-account text-2xl text-warning"></i>
|
||||
Admin Panel
|
||||
</h2>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Total Submissions</div>
|
||||
<div class="stat-value text-primary">{{ stats.total_submissions }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Total Responses</div>
|
||||
<div class="stat-value text-secondary">{{ stats.total_responses }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Need Validation</div>
|
||||
<div class="stat-value text-warning">{{ stats.needs_validation }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Validation Rate</div>
|
||||
<div class="stat-value text-success">{{ Math.round(stats.validation_rate * 100) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Responses Needing Validation -->
|
||||
<div v-if="responsesNeedingValidation.length > 0">
|
||||
<h3 class="text-lg font-bold mb-4">Responses Needing Validation</h3>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Puzzle</th>
|
||||
<th>OCR Data</th>
|
||||
<th>Confidence</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="response in responsesNeedingValidation" :key="response.id">
|
||||
<td>
|
||||
<div class="font-bold">{{ response.puzzle_name }}</div>
|
||||
<div class="text-sm opacity-50">ID: {{ response.id }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm space-y-1">
|
||||
<div>Cost: {{ response.cost || '-' }}</div>
|
||||
<div>Cycles: {{ response.cycles || '-' }}</div>
|
||||
<div>Area: {{ response.area || '-' }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge badge-warning badge-sm">
|
||||
{{ response.ocr_confidence_score ? Math.round(response.ocr_confidence_score * 100) + '%' : 'Low' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
@click="openValidationModal(response)"
|
||||
class="btn btn-sm btn-primary"
|
||||
>
|
||||
<i class="mdi mdi-check-circle mr-1"></i>
|
||||
Validate
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-8">
|
||||
<i class="mdi mdi-check-all text-6xl text-success opacity-50"></i>
|
||||
<p class="text-lg font-medium mt-2">All responses validated!</p>
|
||||
<p class="text-sm opacity-70">No responses currently need manual validation.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation Modal -->
|
||||
<div v-if="validationModal.show" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
|
||||
|
||||
<div v-if="validationModal.response" class="space-y-4">
|
||||
<div class="alert alert-info">
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
<div>
|
||||
<div class="font-bold">{{ validationModal.response.puzzle_name }}</div>
|
||||
<div class="text-sm">Review and correct the OCR data below</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Cost</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="validationModal.data.validated_cost"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="validationModal.response.cost || 'Enter cost'"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Cycles</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="validationModal.data.validated_cycles"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="validationModal.response.cycles || 'Enter cycles'"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Area</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="validationModal.data.validated_area"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="validationModal.response.area || 'Enter area'"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button @click="closeValidationModal" class="btn btn-ghost">Cancel</button>
|
||||
<button
|
||||
@click="submitValidation"
|
||||
class="btn btn-primary"
|
||||
:disabled="isValidating"
|
||||
>
|
||||
<span v-if="isValidating" class="loading loading-spinner loading-sm"></span>
|
||||
{{ isValidating ? 'Validating...' : 'Validate' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" @click="closeValidationModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiService } from '../services/apiService'
|
||||
import type { PuzzleResponse } from '../types'
|
||||
|
||||
// Reactive data
|
||||
const stats = ref({
|
||||
total_submissions: 0,
|
||||
total_responses: 0,
|
||||
needs_validation: 0,
|
||||
validated_submissions: 0,
|
||||
validation_rate: 0
|
||||
})
|
||||
|
||||
const responsesNeedingValidation = ref<PuzzleResponse[]>([])
|
||||
const isLoading = ref(false)
|
||||
const isValidating = ref(false)
|
||||
|
||||
const validationModal = ref({
|
||||
show: false,
|
||||
response: null as PuzzleResponse | null,
|
||||
data: {
|
||||
validated_cost: '',
|
||||
validated_cycles: '',
|
||||
validated_area: ''
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
// Load stats
|
||||
const statsResponse = await apiService.getStats()
|
||||
if (statsResponse.data) {
|
||||
stats.value = statsResponse.data
|
||||
}
|
||||
|
||||
// Load responses needing validation
|
||||
const responsesResponse = await apiService.getResponsesNeedingValidation()
|
||||
if (responsesResponse.data) {
|
||||
responsesNeedingValidation.value = responsesResponse.data
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load admin data:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openValidationModal = (response: PuzzleResponse) => {
|
||||
validationModal.value.response = response
|
||||
validationModal.value.data = {
|
||||
validated_cost: response.cost || '',
|
||||
validated_cycles: response.cycles || '',
|
||||
validated_area: response.area || ''
|
||||
}
|
||||
validationModal.value.show = true
|
||||
}
|
||||
|
||||
const closeValidationModal = () => {
|
||||
validationModal.value.show = false
|
||||
validationModal.value.response = null
|
||||
validationModal.value.data = {
|
||||
validated_cost: '',
|
||||
validated_cycles: '',
|
||||
validated_area: ''
|
||||
}
|
||||
}
|
||||
|
||||
const submitValidation = async () => {
|
||||
if (!validationModal.value.response?.id) return
|
||||
|
||||
try {
|
||||
isValidating.value = true
|
||||
|
||||
const response = await apiService.validateResponse(
|
||||
validationModal.value.response.id,
|
||||
validationModal.value.data
|
||||
)
|
||||
|
||||
if (response.error) {
|
||||
alert(`Validation failed: ${response.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove from validation list
|
||||
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
||||
r => r.id !== validationModal.value.response?.id
|
||||
)
|
||||
|
||||
// Update stats
|
||||
stats.value.needs_validation = Math.max(0, stats.value.needs_validation - 1)
|
||||
|
||||
closeValidationModal()
|
||||
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error)
|
||||
alert('Validation failed')
|
||||
} finally {
|
||||
isValidating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
// Expose refresh method
|
||||
defineExpose({
|
||||
refresh: loadData
|
||||
})
|
||||
</script>
|
||||
@@ -143,10 +143,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { ocrService, type OpusMagnumData } from '../services/ocrService'
|
||||
import type { SubmissionFile } from '@/types'
|
||||
import type { SubmissionFile, SteamCollectionItem } from '@/types'
|
||||
|
||||
interface Props {
|
||||
modelValue: SubmissionFile[]
|
||||
puzzles?: SteamCollectionItem[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -171,6 +172,14 @@ 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) {
|
||||
|
||||
@@ -61,29 +61,32 @@
|
||||
<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 v-if="response.final_cost || response.cost" class="badge badge-success badge-xs">
|
||||
{{ response.final_cost || 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 v-if="response.final_cycles || response.cycles" class="badge badge-info badge-xs">
|
||||
{{ response.final_cycles || 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 v-if="response.final_area || response.area" class="badge badge-warning badge-xs">
|
||||
{{ response.final_area || 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(', ')">
|
||||
<span class="badge badge-ghost badge-xs">{{ response.files?.length || 0 }}</span>
|
||||
<div v-if="response.files?.length" class="tooltip" :data-tip="response.files.map(f => f.original_filename || f.file?.name).join(', ')">
|
||||
<i class="mdi mdi-information-outline text-xs"></i>
|
||||
</div>
|
||||
<div v-if="response.needs_manual_validation" class="tooltip" data-tip="Needs manual validation">
|
||||
<i class="mdi mdi-alert-circle text-xs text-warning"></i>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<FileUpload v-model="submissionFiles" />
|
||||
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-control">
|
||||
@@ -57,7 +57,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import FileUpload from './FileUpload.vue'
|
||||
import type { SteamCollectionItem, SubmissionFile, Submission, PuzzleResponse } from '@/types'
|
||||
import type { SteamCollectionItem, SubmissionFile } from '@/types'
|
||||
|
||||
interface Props {
|
||||
puzzles: SteamCollectionItem[]
|
||||
@@ -65,7 +65,7 @@ interface Props {
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
submit: [submission: Submission]
|
||||
submit: [submissionData: { files: SubmissionFile[], notes?: string }]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -108,31 +108,11 @@ const handleSubmit = async () => {
|
||||
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,
|
||||
// Emit the files and notes for the parent to handle API submission
|
||||
emit('submit', {
|
||||
files: submissionFiles.value,
|
||||
notes: notes.value.trim() || undefined
|
||||
}
|
||||
|
||||
emit('submit', submission)
|
||||
})
|
||||
|
||||
// Reset form
|
||||
submissionFiles.value = []
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import type {
|
||||
SteamCollectionItem,
|
||||
Submission,
|
||||
PuzzleResponse,
|
||||
SubmissionFile
|
||||
} from '../types'
|
||||
|
||||
// API Configuration
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
// API Response Types
|
||||
interface ApiResponse<T> {
|
||||
data?: T
|
||||
error?: string
|
||||
status: number
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
count: number
|
||||
}
|
||||
|
||||
interface SubmissionStats {
|
||||
total_submissions: number
|
||||
total_responses: number
|
||||
needs_validation: number
|
||||
validated_submissions: number
|
||||
validation_rate: number
|
||||
}
|
||||
|
||||
// API Service Class
|
||||
export class ApiService {
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: data.detail || `HTTP ${response.status}`,
|
||||
status: response.status
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
status: response.status
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
status: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadRequest<T>(
|
||||
endpoint: string,
|
||||
formData: FormData
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: data.detail || `HTTP ${response.status}`,
|
||||
status: response.status
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
status: response.status
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
status: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Puzzle endpoints
|
||||
async getPuzzles(): Promise<ApiResponse<SteamCollectionItem[]>> {
|
||||
return this.request<SteamCollectionItem[]>('/submissions/puzzles')
|
||||
}
|
||||
|
||||
// Submission endpoints
|
||||
async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> {
|
||||
return this.request<PaginatedResponse<Submission>>(
|
||||
`/submissions/submissions?limit=${limit}&offset=${offset}`
|
||||
)
|
||||
}
|
||||
|
||||
async getSubmission(id: string): Promise<ApiResponse<Submission>> {
|
||||
return this.request<Submission>(`/submissions/submissions/${id}`)
|
||||
}
|
||||
|
||||
async createSubmission(
|
||||
submissionData: {
|
||||
notes?: string
|
||||
responses: Array<{
|
||||
puzzle_id: number
|
||||
puzzle_name: string
|
||||
cost?: string
|
||||
cycles?: string
|
||||
area?: string
|
||||
needs_manual_validation?: boolean
|
||||
ocr_confidence_score?: number
|
||||
}>
|
||||
},
|
||||
files: File[]
|
||||
): Promise<ApiResponse<Submission>> {
|
||||
const formData = new FormData()
|
||||
|
||||
// Add JSON data
|
||||
formData.append('data', JSON.stringify(submissionData))
|
||||
|
||||
// Add files
|
||||
files.forEach((file, index) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
return this.uploadRequest<Submission>('/submissions/submissions', formData)
|
||||
}
|
||||
|
||||
// Admin endpoints (require staff permissions)
|
||||
async validateResponse(
|
||||
responseId: number,
|
||||
validationData: {
|
||||
validated_cost?: string
|
||||
validated_cycles?: string
|
||||
validated_area?: string
|
||||
}
|
||||
): Promise<ApiResponse<PuzzleResponse>> {
|
||||
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(validationData),
|
||||
})
|
||||
}
|
||||
|
||||
async getResponsesNeedingValidation(): Promise<ApiResponse<PuzzleResponse[]>> {
|
||||
return this.request<PuzzleResponse[]>('/submissions/responses/needs-validation')
|
||||
}
|
||||
|
||||
async validateSubmission(submissionId: string): Promise<ApiResponse<Submission>> {
|
||||
return this.request<Submission>(`/submissions/submissions/${submissionId}/validate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async deleteSubmission(submissionId: string): Promise<ApiResponse<{ detail: string }>> {
|
||||
return this.request<{ detail: string }>(`/submissions/submissions/${submissionId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// Statistics endpoint
|
||||
async getStats(): Promise<ApiResponse<SubmissionStats>> {
|
||||
return this.request<SubmissionStats>('/submissions/stats')
|
||||
}
|
||||
|
||||
// Health check
|
||||
async healthCheck(): Promise<ApiResponse<{ status: string; service: string }>> {
|
||||
return this.request<{ status: string; service: string }>('/health')
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const apiService = new ApiService()
|
||||
|
||||
// Helper functions for common operations
|
||||
export const puzzleHelpers = {
|
||||
async loadPuzzles(): Promise<SteamCollectionItem[]> {
|
||||
const response = await apiService.getPuzzles()
|
||||
if (response.error) {
|
||||
console.error('Failed to load puzzles:', response.error)
|
||||
return []
|
||||
}
|
||||
return response.data || []
|
||||
},
|
||||
|
||||
findPuzzleByName(puzzles: SteamCollectionItem[], name: string): SteamCollectionItem | null {
|
||||
if (!name) return null
|
||||
|
||||
// Try exact match first
|
||||
let match = puzzles.find(p =>
|
||||
p.title.toLowerCase() === name.toLowerCase()
|
||||
)
|
||||
|
||||
if (!match) {
|
||||
// Try partial match
|
||||
match = puzzles.find(p =>
|
||||
p.title.toLowerCase().includes(name.toLowerCase()) ||
|
||||
name.toLowerCase().includes(p.title.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
return match || null
|
||||
}
|
||||
}
|
||||
|
||||
export const submissionHelpers = {
|
||||
async createFromFiles(
|
||||
files: SubmissionFile[],
|
||||
puzzles: SteamCollectionItem[],
|
||||
notes?: string
|
||||
): Promise<ApiResponse<Submission>> {
|
||||
// Group files by detected puzzle
|
||||
const responsesByPuzzle: Record<string, {
|
||||
puzzle: SteamCollectionItem | null,
|
||||
files: SubmissionFile[]
|
||||
}> = {}
|
||||
|
||||
files.forEach(file => {
|
||||
if (file.ocrData?.puzzle) {
|
||||
const puzzleName = file.ocrData.puzzle
|
||||
if (!responsesByPuzzle[puzzleName]) {
|
||||
responsesByPuzzle[puzzleName] = {
|
||||
puzzle: puzzleHelpers.findPuzzleByName(puzzles, puzzleName),
|
||||
files: []
|
||||
}
|
||||
}
|
||||
responsesByPuzzle[puzzleName].files.push(file)
|
||||
}
|
||||
})
|
||||
|
||||
// Create responses array
|
||||
const responses = Object.entries(responsesByPuzzle)
|
||||
.filter(([_, data]) => data.puzzle) // Only include matched puzzles
|
||||
.map(([puzzleName, data]) => {
|
||||
// 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
|
||||
)
|
||||
|
||||
// Check if manual validation is needed
|
||||
const needsValidation = !fileWithOCR?.ocrData ||
|
||||
!fileWithOCR.ocrData.cost ||
|
||||
!fileWithOCR.ocrData.cycles ||
|
||||
!fileWithOCR.ocrData.area
|
||||
|
||||
return {
|
||||
puzzle_id: data.puzzle!.id,
|
||||
puzzle_name: puzzleName,
|
||||
cost: fileWithOCR?.ocrData?.cost,
|
||||
cycles: fileWithOCR?.ocrData?.cycles,
|
||||
area: fileWithOCR?.ocrData?.area,
|
||||
needs_manual_validation: needsValidation,
|
||||
ocr_confidence_score: needsValidation ? 0.5 : 0.9 // Rough estimate
|
||||
}
|
||||
})
|
||||
|
||||
if (responses.length === 0) {
|
||||
return {
|
||||
error: 'No valid puzzle responses found',
|
||||
status: 400
|
||||
}
|
||||
}
|
||||
|
||||
// Extract actual File objects for upload
|
||||
const fileObjects = files.map(f => f.file)
|
||||
|
||||
return apiService.createSubmission({ notes, responses }, fileObjects)
|
||||
},
|
||||
|
||||
async loadSubmissions(limit = 20, offset = 0): Promise<Submission[]> {
|
||||
const response = await apiService.getSubmissions(limit, offset)
|
||||
if (response.error) {
|
||||
console.error('Failed to load submissions:', response.error)
|
||||
return []
|
||||
}
|
||||
return response.data?.items || []
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling utilities
|
||||
export const errorHelpers = {
|
||||
getErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'string') return error
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'object' && error !== null && 'detail' in error) {
|
||||
return String((error as any).detail)
|
||||
}
|
||||
return 'An unknown error occurred'
|
||||
},
|
||||
|
||||
isNetworkError(error: unknown): boolean {
|
||||
return typeof error === 'string' && error.includes('Network')
|
||||
},
|
||||
|
||||
isValidationError(status: number): boolean {
|
||||
return status === 400
|
||||
},
|
||||
|
||||
isAuthError(status: number): boolean {
|
||||
return status === 401 || status === 403
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export interface OCRRegion {
|
||||
|
||||
export class OpusMagnumOCRService {
|
||||
private worker: Tesseract.Worker | null = null;
|
||||
private availablePuzzleNames: string[] = [];
|
||||
|
||||
// Regions based on main.py coordinates (adjusted for web usage)
|
||||
private readonly regions: Record<string, OCRRegion> = {
|
||||
@@ -35,6 +36,13 @@ export class OpusMagnumOCRService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the list of available puzzle names for better OCR matching
|
||||
*/
|
||||
setAvailablePuzzleNames(puzzleNames: string[]): void {
|
||||
this.availablePuzzleNames = puzzleNames;
|
||||
}
|
||||
|
||||
async extractOpusMagnumData(imageFile: File): Promise<OpusMagnumData> {
|
||||
if (!this.worker) {
|
||||
await this.initialize();
|
||||
@@ -123,8 +131,8 @@ export class OpusMagnumOCRService {
|
||||
// Ensure only digits remain
|
||||
cleanText = cleanText.replace(/[^0-9]/g, '');
|
||||
} else if (key === 'puzzle') {
|
||||
// Post-process puzzle names
|
||||
cleanText = this.processPuzzleName(cleanText);
|
||||
// Post-process puzzle names with fuzzy matching
|
||||
cleanText = this.findBestPuzzleMatch(cleanText);
|
||||
}
|
||||
|
||||
results[key as keyof OpusMagnumData] = cleanText;
|
||||
@@ -171,39 +179,71 @@ export class OpusMagnumOCRService {
|
||||
}
|
||||
}
|
||||
|
||||
private processPuzzleName(rawText: string): string {
|
||||
let processed = rawText.trim();
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings
|
||||
*/
|
||||
private levenshteinDistance(str1: string, str2: string): number {
|
||||
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
||||
|
||||
// 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)}`;
|
||||
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
|
||||
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
|
||||
|
||||
for (let j = 1; j <= str2.length; j++) {
|
||||
for (let i = 1; i <= str1.length; i++) {
|
||||
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
||||
matrix[j][i] = Math.min(
|
||||
matrix[j][i - 1] + 1, // deletion
|
||||
matrix[j - 1][i] + 1, // insertion
|
||||
matrix[j - 1][i - 1] + indicator // substitution
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
return matrix[str2.length][str1.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching puzzle name from available options
|
||||
*/
|
||||
private findBestPuzzleMatch(ocrText: string): string {
|
||||
if (!this.availablePuzzleNames.length) {
|
||||
return ocrText.trim();
|
||||
}
|
||||
|
||||
const cleanedOcr = ocrText.trim();
|
||||
|
||||
// First try exact match (case insensitive)
|
||||
const exactMatch = this.availablePuzzleNames.find(
|
||||
name => name.toLowerCase() === cleanedOcr.toLowerCase()
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
// Then try fuzzy matching
|
||||
let bestMatch = cleanedOcr;
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (const puzzleName of this.availablePuzzleNames) {
|
||||
// Calculate similarity scores
|
||||
const distance = this.levenshteinDistance(
|
||||
cleanedOcr.toLowerCase(),
|
||||
puzzleName.toLowerCase()
|
||||
);
|
||||
|
||||
// Normalize by length to get a similarity ratio
|
||||
const maxLength = Math.max(cleanedOcr.length, puzzleName.length);
|
||||
const similarity = 1 - (distance / maxLength);
|
||||
|
||||
// Consider it a good match if similarity is above 70%
|
||||
if (similarity > 0.7 && distance < bestScore) {
|
||||
bestScore = distance;
|
||||
bestMatch = puzzleName;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
|
||||
async terminate(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.terminate();
|
||||
|
||||
@@ -42,18 +42,34 @@ export interface SubmissionFile {
|
||||
|
||||
export interface PuzzleResponse {
|
||||
id?: number
|
||||
puzzle_id: number
|
||||
puzzle: number | SteamCollectionItem
|
||||
puzzle_name: string
|
||||
cost?: string
|
||||
cycles?: string
|
||||
area?: string
|
||||
files: SubmissionFile[]
|
||||
}
|
||||
|
||||
export interface Submission {
|
||||
id?: number
|
||||
responses: PuzzleResponse[]
|
||||
notes?: string
|
||||
needs_manual_validation?: boolean
|
||||
ocr_confidence_score?: number
|
||||
validated_cost?: string
|
||||
validated_cycles?: string
|
||||
validated_area?: string
|
||||
final_cost?: string
|
||||
final_cycles?: string
|
||||
final_area?: string
|
||||
files?: SubmissionFile[]
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface Submission {
|
||||
id?: string
|
||||
user?: number | null
|
||||
responses: PuzzleResponse[]
|
||||
notes?: string
|
||||
is_validated?: boolean
|
||||
validated_by?: number | null
|
||||
validated_at?: string | null
|
||||
total_responses?: number
|
||||
needs_validation?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user