Compare commits
3 Commits
596731a8a7
...
9ee45463a8
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ee45463a8 | |||
| 8f88548a59 | |||
| 012b72527b |
@ -6,7 +6,19 @@ from submissions.schemas import UserInfoOut
|
||||
api = NinjaAPI(
|
||||
title="Opus Magnum Submission API",
|
||||
version="1.0.0",
|
||||
description="API for managing Opus Magnum puzzle submissions",
|
||||
description="""API for managing Opus Magnum puzzle submissions.
|
||||
|
||||
The Opus Magnum Submission API allows clients to upload, manage, validate, and review puzzle solution submissions for the Opus Magnum puzzle game community.
|
||||
It provides features for user authentication, puzzle listing, submission uploads, automated and manual OCR validation, and administrative workflows.
|
||||
""",
|
||||
openapi_extra={
|
||||
"info": {
|
||||
"contact": {
|
||||
"name": "Legrems",
|
||||
"email": "loic.gremaud@polylan.ch",
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Add authentication for protected endpoints
|
||||
@ -33,8 +45,7 @@ def api_info(request):
|
||||
"description": "API for managing puzzle submissions with OCR validation",
|
||||
"features": [
|
||||
"Multi-puzzle submissions",
|
||||
"File upload to S3",
|
||||
"OCR validation tracking",
|
||||
"OCR validation",
|
||||
"Manual validation workflow",
|
||||
"Admin validation tools",
|
||||
],
|
||||
|
||||
@ -1,151 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, defineProps } from 'vue'
|
||||
import PuzzleCard from '@/components/PuzzleCard.vue'
|
||||
import SubmissionForm from '@/components/SubmissionForm.vue'
|
||||
import AdminPanel from '@/components/AdminPanel.vue'
|
||||
import { apiService, errorHelpers } from '@/services/apiService'
|
||||
import { usePuzzlesStore } from '@/stores/puzzles'
|
||||
import { useSubmissionsStore } from '@/stores/submissions'
|
||||
import type { SteamCollection, PuzzleResponse, UserInfo } from '@/types'
|
||||
import { useCountdown } from '@vueuse/core'
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import PuzzleCard from "@/components/PuzzleCard.vue";
|
||||
import SubmissionForm from "@/components/SubmissionForm.vue";
|
||||
import AdminPanel from "@/components/AdminPanel.vue";
|
||||
import { apiService, errorHelpers } from "@/services/apiService";
|
||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||
import { useSubmissionsStore } from "@/stores/submissions";
|
||||
import type { PuzzleResponse, UserInfo } from "@/types";
|
||||
import { useCountdown } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const props = defineProps<{collectionTitle: string, collectionUrl: string, collectionDescription: string}>()
|
||||
const props = defineProps<{
|
||||
collectionTitle: string;
|
||||
collectionUrl: string;
|
||||
collectionDescription: string;
|
||||
}>();
|
||||
|
||||
// Pinia stores
|
||||
const puzzlesStore = usePuzzlesStore()
|
||||
const submissionsStore = useSubmissionsStore()
|
||||
const puzzlesStore = usePuzzlesStore();
|
||||
const submissionsStore = useSubmissionsStore();
|
||||
|
||||
const { submissions, isSubmissionModalOpen } = storeToRefs(submissionsStore);
|
||||
const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
|
||||
submissionsStore;
|
||||
|
||||
// Local state
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string>('')
|
||||
const userInfo = ref<UserInfo | null>(null);
|
||||
const isLoading = ref(true);
|
||||
const error = ref<string>("");
|
||||
|
||||
// Computed properties
|
||||
const isSuperuser = computed(() => {
|
||||
return userInfo.value?.is_superuser || false
|
||||
})
|
||||
return userInfo.value?.is_superuser || false;
|
||||
});
|
||||
|
||||
// Computed property to get responses grouped by puzzle
|
||||
const responsesByPuzzle = computed(() => {
|
||||
const grouped: Record<number, PuzzleResponse[]> = {}
|
||||
submissionsStore.submissions.forEach(submission => {
|
||||
submission.responses.forEach(response => {
|
||||
const grouped: Record<number, PuzzleResponse[]> = {};
|
||||
submissions.value.forEach((submission) => {
|
||||
submission.responses.forEach((response) => {
|
||||
// Handle both number and object types for puzzle field
|
||||
const puzzleId = typeof response.puzzle === 'number' ? response.puzzle : response.puzzle.id
|
||||
if (!grouped[puzzleId]) {
|
||||
grouped[puzzleId] = []
|
||||
if (!grouped[response.puzzle]) {
|
||||
grouped[response.puzzle] = [];
|
||||
}
|
||||
grouped[puzzleId].push(response)
|
||||
})
|
||||
})
|
||||
return grouped
|
||||
})
|
||||
grouped[response.puzzle].push(response);
|
||||
});
|
||||
});
|
||||
return grouped;
|
||||
});
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
console.log('Starting data load...')
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = "";
|
||||
|
||||
console.log("Starting data load...");
|
||||
|
||||
// Load user info
|
||||
console.log('Loading user info...')
|
||||
const userResponse = await apiService.getUserInfo()
|
||||
console.log("Loading user info...");
|
||||
const userResponse = await apiService.getUserInfo();
|
||||
if (userResponse.data) {
|
||||
userInfo.value = userResponse.data
|
||||
console.log('User info loaded:', userResponse.data)
|
||||
userInfo.value = userResponse.data;
|
||||
console.log("User info loaded:", userResponse.data);
|
||||
} else if (userResponse.error) {
|
||||
console.warn('User info error:', userResponse.error)
|
||||
console.warn("User info error:", userResponse.error);
|
||||
}
|
||||
|
||||
|
||||
// Load puzzles from API using store
|
||||
console.log('Loading puzzles...')
|
||||
await puzzlesStore.loadPuzzles()
|
||||
console.log('Puzzles loaded:', puzzlesStore.puzzles.length)
|
||||
|
||||
console.log("Loading puzzles...");
|
||||
await puzzlesStore.loadPuzzles();
|
||||
console.log("Puzzles loaded:", puzzlesStore.puzzles.length);
|
||||
|
||||
// Load existing submissions using store
|
||||
console.log('Loading submissions...')
|
||||
await submissionsStore.loadSubmissions()
|
||||
console.log('Submissions loaded:', submissionsStore.submissions.length)
|
||||
|
||||
console.log('Data load complete!')
|
||||
|
||||
console.log("Loading submissions...");
|
||||
await loadSubmissions();
|
||||
console.log("Submissions loaded:", submissions.value.length);
|
||||
|
||||
console.log("Data load complete!");
|
||||
} catch (err) {
|
||||
error.value = errorHelpers.getErrorMessage(err)
|
||||
console.error('Failed to load data:', err)
|
||||
error.value = errorHelpers.getErrorMessage(err);
|
||||
console.error("Failed to load data:", err);
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
console.log('Loading state set to false')
|
||||
isLoading.value = false;
|
||||
console.log("Loading state set to false");
|
||||
}
|
||||
|
||||
if (userInfo.value.is_superuser) {
|
||||
start()
|
||||
if (userInfo.value?.is_superuser) {
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
const { remaining, start } = useCountdown(60, {
|
||||
onComplete() {
|
||||
initialize()
|
||||
}
|
||||
})
|
||||
initialize();
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await initialize()
|
||||
})
|
||||
|
||||
const handleSubmission = async (submissionData: {
|
||||
files: any[],
|
||||
notes?: string,
|
||||
manualValidationRequested?: boolean
|
||||
}) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
// Create submission via store
|
||||
const submission = await submissionsStore.createSubmission(
|
||||
submissionData.files,
|
||||
submissionData.notes,
|
||||
submissionData.manualValidationRequested
|
||||
)
|
||||
|
||||
// Show success message
|
||||
if (submission) {
|
||||
const puzzleNames = submission.responses.map(r => r.puzzle_name).join(', ')
|
||||
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
|
||||
} else {
|
||||
alert('Submission created successfully!')
|
||||
}
|
||||
|
||||
// Close modal
|
||||
submissionsStore.closeSubmissionModal()
|
||||
|
||||
} 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 = () => {
|
||||
submissionsStore.openSubmissionModal()
|
||||
}
|
||||
|
||||
const closeSubmissionModal = () => {
|
||||
submissionsStore.closeSubmissionModal()
|
||||
}
|
||||
await initialize();
|
||||
});
|
||||
|
||||
// Function to match puzzle name from OCR to actual puzzle
|
||||
const findPuzzleByName = (ocrPuzzleName: string) => {
|
||||
return puzzlesStore.findPuzzleByName(ocrPuzzleName)
|
||||
}
|
||||
return puzzlesStore.findPuzzleByName(ocrPuzzleName);
|
||||
};
|
||||
|
||||
const reloadPage = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
window.location.reload();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -157,19 +118,25 @@ const reloadPage = () => {
|
||||
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
||||
</div>
|
||||
<div class="flex items-start justify-between">
|
||||
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="userInfo?.is_authenticated"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">{{ userInfo.username }}</span>
|
||||
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
|
||||
<span
|
||||
v-if="userInfo.is_superuser"
|
||||
class="badge badge-warning badge-xs ml-1"
|
||||
>Admin</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-base-content/70">
|
||||
Not logged in
|
||||
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<a href="/admin" class="btn btn-xs btn-warning">
|
||||
Admin django
|
||||
</a>
|
||||
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -187,13 +154,16 @@ const reloadPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
|
||||
<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>
|
||||
|
||||
|
||||
<!-- 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>
|
||||
@ -214,14 +184,13 @@ const reloadPage = () => {
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2>
|
||||
<p class="text-base-content/70">{{ props.collectionDescription }}</p>
|
||||
<p class="text-base-content/70">
|
||||
{{ props.collectionDescription }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 mt-4">
|
||||
<button
|
||||
@click="openSubmissionModal"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<i class="mdi mdi-plus mr-2"></i>
|
||||
Submit Solution
|
||||
<button @click="openSubmissionModal" class="btn btn-primary">
|
||||
<i class="mdi mdi-plus mr-2"></i>
|
||||
Submit Solution
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -247,28 +216,29 @@ const reloadPage = () => {
|
||||
<div v-if="puzzlesStore.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>
|
||||
<p class="text-base-content/70">
|
||||
Check back later for new puzzle collections!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submission Modal -->
|
||||
<div v-if="submissionsStore.isSubmissionModalOpen" class="modal modal-open">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<div v-if="isSubmissionModalOpen" class="modal modal-open">
|
||||
<div class="modal-box max-w-6xl">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-bold text-lg">Submit Solution</h3>
|
||||
<button
|
||||
<button
|
||||
@click="closeSubmissionModal"
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SubmissionForm
|
||||
|
||||
<SubmissionForm
|
||||
:puzzles="puzzlesStore.puzzles"
|
||||
:find-puzzle-by-name="findPuzzleByName"
|
||||
@submit="handleSubmission"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
||||
|
||||
@ -5,24 +5,32 @@
|
||||
<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 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 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 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 class="stat-value text-success">
|
||||
{{ Math.round(stats.validation_rate * 100) }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -30,11 +38,11 @@
|
||||
<i class="mdi mdi-check-circle mr-1"></i>
|
||||
Auto validation for all responses
|
||||
</button>
|
||||
|
||||
|
||||
<!-- 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>
|
||||
@ -46,39 +54,50 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="response in responsesNeedingValidation" :key="response.id">
|
||||
<tr
|
||||
v-for="response in responsesNeedingValidation"
|
||||
:key="response.id"
|
||||
>
|
||||
<td>
|
||||
<div class="font-bold">{{ response.puzzle_title }}</div>
|
||||
<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 class="flex justify-between items-center">
|
||||
<span>Cost: {{ response.cost || '-' }}</span>
|
||||
<span
|
||||
v-if="response.ocr_confidence_cost"
|
||||
<span>Cost: {{ response.cost || "-" }}</span>
|
||||
<span
|
||||
v-if="response.ocr_confidence_cost"
|
||||
class="badge badge-xs"
|
||||
:class="getConfidenceBadgeClass(response.ocr_confidence_cost)"
|
||||
:class="
|
||||
getConfidenceBadgeClass(response.ocr_confidence_cost)
|
||||
"
|
||||
>
|
||||
{{ Math.round(response.ocr_confidence_cost * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span>Cycles: {{ response.cycles || '-' }}</span>
|
||||
<span
|
||||
v-if="response.ocr_confidence_cycles"
|
||||
<span>Cycles: {{ response.cycles || "-" }}</span>
|
||||
<span
|
||||
v-if="response.ocr_confidence_cycles"
|
||||
class="badge badge-xs"
|
||||
:class="getConfidenceBadgeClass(response.ocr_confidence_cycles)"
|
||||
:class="
|
||||
getConfidenceBadgeClass(
|
||||
response.ocr_confidence_cycles,
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ Math.round(response.ocr_confidence_cycles * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span>Area: {{ response.area || '-' }}</span>
|
||||
<span
|
||||
v-if="response.ocr_confidence_area"
|
||||
<span>Area: {{ response.area || "-" }}</span>
|
||||
<span
|
||||
v-if="response.ocr_confidence_area"
|
||||
class="badge badge-xs"
|
||||
:class="getConfidenceBadgeClass(response.ocr_confidence_area)"
|
||||
:class="
|
||||
getConfidenceBadgeClass(response.ocr_confidence_area)
|
||||
"
|
||||
>
|
||||
{{ Math.round(response.ocr_confidence_area * 100) }}%
|
||||
</span>
|
||||
@ -91,63 +110,78 @@
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
@click="openValidationModal(response)"
|
||||
class="btn btn-sm btn-primary"
|
||||
<button
|
||||
@click="openValidationModal(response)"
|
||||
class="btn btn-sm btn-primary mr-2"
|
||||
>
|
||||
<i class="mdi mdi-check-circle mr-1"></i>
|
||||
Validate
|
||||
</button>
|
||||
<button
|
||||
v-if="response.id"
|
||||
@click="autoValidation(response.id)"
|
||||
class="btn btn-sm btn-warning"
|
||||
>
|
||||
<i class="mdi mdi-check-circle mr-1"></i>
|
||||
Auto Validation
|
||||
</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>
|
||||
<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 w-11/12 max-w-5xl">
|
||||
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
|
||||
|
||||
<div v-for="file in validationModal.response.files">
|
||||
<img :src="file.file_url">
|
||||
<div v-for="file in validationModal.response?.files ?? []">
|
||||
<img :src="file.file_url" />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mockup-code w-full">
|
||||
<pre><code>{{ validationModal}}</code></pre>
|
||||
</div>
|
||||
|
||||
<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_title }}</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-4 gap-4">
|
||||
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Puzzle</span>
|
||||
</label>
|
||||
<select
|
||||
<select
|
||||
v-model="validationModal.data.puzzle"
|
||||
class="select select-bordered select-sm w-full"
|
||||
>
|
||||
<option value="">Select puzzle...</option>
|
||||
<option
|
||||
v-for="puzzle in puzzlesStore.puzzles"
|
||||
:key="puzzle.id"
|
||||
<option
|
||||
v-for="puzzle in puzzlesStore.puzzles"
|
||||
:key="puzzle.id"
|
||||
:value="puzzle.id"
|
||||
>
|
||||
{{ puzzle.title }}
|
||||
{{ puzzle.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -156,48 +190,53 @@
|
||||
<label class="label">
|
||||
<span class="label-text">Cost</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="validationModal.data.validated_cost"
|
||||
type="text"
|
||||
<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"
|
||||
<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"
|
||||
<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"
|
||||
<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' }}
|
||||
<span
|
||||
v-if="isValidating"
|
||||
class="loading loading-spinner loading-sm"
|
||||
></span>
|
||||
{{ isValidating ? "Validating..." : "Validate" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -207,11 +246,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiService } from '@/services/apiService'
|
||||
import type { PuzzleResponse } from '@/types'
|
||||
import {usePuzzlesStore} from '@/stores/puzzles'
|
||||
const puzzlesStore = usePuzzlesStore()
|
||||
import { ref, onMounted } from "vue";
|
||||
import { apiService } from "@/services/apiService";
|
||||
import type { PuzzleResponse } from "@/types";
|
||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||
const puzzlesStore = usePuzzlesStore();
|
||||
|
||||
// Reactive data
|
||||
const stats = ref({
|
||||
@ -219,160 +258,178 @@ const stats = ref({
|
||||
total_responses: 0,
|
||||
needs_validation: 0,
|
||||
validated_submissions: 0,
|
||||
validation_rate: 0
|
||||
})
|
||||
validation_rate: 0,
|
||||
});
|
||||
|
||||
const responsesNeedingValidation = ref<PuzzleResponse[]>([])
|
||||
const isLoading = ref(false)
|
||||
const isValidating = ref(false)
|
||||
const responsesNeedingValidation = ref<PuzzleResponse[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const isValidating = ref(false);
|
||||
|
||||
const validationModal = ref({
|
||||
show: false,
|
||||
response: null as PuzzleResponse | null,
|
||||
data: {
|
||||
puzzle_title: '',
|
||||
validated_cost: '',
|
||||
validated_cycles: '',
|
||||
validated_area: ''
|
||||
}
|
||||
})
|
||||
puzzle: -1,
|
||||
validated_cost: "",
|
||||
validated_cycles: "",
|
||||
validated_area: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Methods
|
||||
const loadData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
// Load stats (skip if endpoint doesn't exist)
|
||||
try {
|
||||
const statsResponse = await apiService.getStats()
|
||||
const statsResponse = await apiService.getStats();
|
||||
if (statsResponse.data) {
|
||||
stats.value = statsResponse.data
|
||||
stats.value = statsResponse.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Stats endpoint not available:', error)
|
||||
console.warn("Stats endpoint not available:", error);
|
||||
// Set default stats
|
||||
stats.value = {
|
||||
total_submissions: 0,
|
||||
total_responses: 0,
|
||||
needs_validation: 0,
|
||||
validated_submissions: 0,
|
||||
validation_rate: 0
|
||||
}
|
||||
validation_rate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Load responses needing validation
|
||||
const responsesResponse = await apiService.getResponsesNeedingValidation()
|
||||
const responsesResponse = await apiService.getResponsesNeedingValidation();
|
||||
if (responsesResponse.data) {
|
||||
responsesNeedingValidation.value = responsesResponse.data
|
||||
responsesNeedingValidation.value = responsesResponse.data;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load admin data:', error)
|
||||
console.error("Failed to load admin data:", error);
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const autoValidationResponse = async () => {
|
||||
for (const response of Array.from(responsesNeedingValidation.value)) {
|
||||
const {data, error} = await apiService.autoValidateResponses(response.id)
|
||||
if (!response.id) {
|
||||
continue;
|
||||
}
|
||||
const { data, error } = await apiService.autoValidateResponses(response.id);
|
||||
|
||||
if (data && !data.needs_manual_validation) {
|
||||
// Remove from validation list
|
||||
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
||||
r => r.id !== response.id
|
||||
)
|
||||
stats.value.needs_validation -= 1
|
||||
|
||||
responsesNeedingValidation.value =
|
||||
responsesNeedingValidation.value.filter((r) => r.id !== response.id);
|
||||
stats.value.needs_validation -= 1;
|
||||
} else if (error) {
|
||||
break
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openValidationModal = (response: PuzzleResponse) => {
|
||||
validationModal.value.response = response
|
||||
validationModal.value.response = response;
|
||||
validationModal.value.data = {
|
||||
puzzle: response.puzzle || '',
|
||||
validated_cost: response.cost || '',
|
||||
validated_cycles: response.cycles || '',
|
||||
validated_area: response.area || ''
|
||||
}
|
||||
validationModal.value.show = true
|
||||
}
|
||||
puzzle: response.puzzle || -1,
|
||||
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.show = false;
|
||||
validationModal.value.response = null;
|
||||
validationModal.value.data = {
|
||||
puzzle: '',
|
||||
validated_cost: '',
|
||||
validated_cycles: '',
|
||||
validated_area: ''
|
||||
}
|
||||
}
|
||||
puzzle: -1,
|
||||
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
|
||||
}
|
||||
|
||||
const autoValidation = async (id: number) => {
|
||||
const { data } = await apiService.autoValidateResponses(id);
|
||||
console.log(data);
|
||||
|
||||
if (data && !data.needs_manual_validation) {
|
||||
// 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
|
||||
(r) => r.id !== id,
|
||||
);
|
||||
console.log(stats.value);
|
||||
stats.value.needs_validation -= 1;
|
||||
console.log(stats.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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()
|
||||
})
|
||||
loadData();
|
||||
});
|
||||
|
||||
// Helper functions for confidence display
|
||||
const getConfidenceBadgeClass = (confidence: number): string => {
|
||||
if (confidence >= 0.8) return 'badge-success'
|
||||
if (confidence >= 0.6) return 'badge-warning'
|
||||
return 'badge-error'
|
||||
}
|
||||
if (confidence >= 0.8) return "badge-success";
|
||||
if (confidence >= 0.6) return "badge-warning";
|
||||
return "badge-error";
|
||||
};
|
||||
|
||||
const getOverallConfidence = (response: PuzzleResponse): number => {
|
||||
const confidences = [
|
||||
response.ocr_confidence_cost,
|
||||
response.ocr_confidence_cycles,
|
||||
response.ocr_confidence_area
|
||||
].filter(conf => conf !== undefined && conf !== null) as number[]
|
||||
|
||||
if (confidences.length === 0) return 0
|
||||
|
||||
const average = confidences.reduce((sum, conf) => sum + conf, 0) / confidences.length
|
||||
return Math.round(average * 100)
|
||||
}
|
||||
response.ocr_confidence_area,
|
||||
].filter((conf) => conf !== undefined && conf !== null) as number[];
|
||||
|
||||
if (confidences.length === 0) return 0;
|
||||
|
||||
const average =
|
||||
confidences.reduce((sum, conf) => sum + conf, 0) / confidences.length;
|
||||
return Math.round(average * 100);
|
||||
};
|
||||
|
||||
// Expose refresh method
|
||||
defineExpose({
|
||||
refresh: loadData
|
||||
})
|
||||
refresh: loadData,
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
<span class="label-text font-medium">Upload Solution Files</span>
|
||||
<span class="label-text-alt text-xs">Images or GIFs only</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
|
||||
<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"
|
||||
@ -20,15 +20,17 @@
|
||||
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">
|
||||
/>
|
||||
|
||||
<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
|
||||
<button
|
||||
type="button"
|
||||
@click="fileInput?.click()"
|
||||
class="btn btn-primary btn-sm"
|
||||
@ -40,62 +42,70 @@
|
||||
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"
|
||||
<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"
|
||||
<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">
|
||||
|
||||
<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-sm btn-circle"
|
||||
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() }}
|
||||
{{ 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">
|
||||
<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"
|
||||
<span
|
||||
v-if="file.ocrData.confidence"
|
||||
class="badge badge-xs"
|
||||
:class="getConfidenceBadgeClass(file.ocrData.confidence.overall)"
|
||||
: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)"
|
||||
<button
|
||||
@click="processOCR(file)"
|
||||
class="btn btn-xs btn-ghost"
|
||||
title="Retry OCR"
|
||||
>
|
||||
@ -105,8 +115,8 @@
|
||||
<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"
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.puzzle"
|
||||
class="ml-2 opacity-60"
|
||||
:title="`Puzzle confidence: ${Math.round(file.ocrData.confidence.puzzle * 100)}%`"
|
||||
>
|
||||
@ -115,8 +125,8 @@
|
||||
</div>
|
||||
<div v-if="file.ocrData.cost">
|
||||
<strong>Cost:</strong> {{ file.ocrData.cost }}
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.cost"
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.cost"
|
||||
class="ml-2 opacity-60"
|
||||
:title="`Cost confidence: ${Math.round(file.ocrData.confidence.cost * 100)}%`"
|
||||
>
|
||||
@ -125,8 +135,8 @@
|
||||
</div>
|
||||
<div v-if="file.ocrData.cycles">
|
||||
<strong>Cycles:</strong> {{ file.ocrData.cycles }}
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.cycles"
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.cycles"
|
||||
class="ml-2 opacity-60"
|
||||
:title="`Cycles confidence: ${Math.round(file.ocrData.confidence.cycles * 100)}%`"
|
||||
>
|
||||
@ -135,8 +145,8 @@
|
||||
</div>
|
||||
<div v-if="file.ocrData.area">
|
||||
<strong>Area:</strong> {{ file.ocrData.area }}
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.area"
|
||||
<span
|
||||
v-if="file.ocrData.confidence?.area"
|
||||
class="ml-2 opacity-60"
|
||||
:title="`Area confidence: ${Math.round(file.ocrData.confidence.area * 100)}%`"
|
||||
>
|
||||
@ -145,26 +155,28 @@
|
||||
</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 class="text-xs">
|
||||
Please select the correct puzzle manually
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<select
|
||||
<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"
|
||||
<option
|
||||
v-for="puzzle in puzzlesStore.puzzles"
|
||||
:key="puzzle.id"
|
||||
:value="puzzle.title"
|
||||
>
|
||||
{{ puzzle.title }}
|
||||
@ -172,11 +184,16 @@
|
||||
</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)"
|
||||
<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>
|
||||
@ -186,9 +203,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
@click="fileInput?.click()"
|
||||
class="btn btn-outline btn-sm"
|
||||
@ -198,7 +215,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="error" class="label">
|
||||
<span class="label-text-alt text-error">{{ error }}</span>
|
||||
</div>
|
||||
@ -206,199 +223,146 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { ocrService } from '@/services/ocrService'
|
||||
import { usePuzzlesStore } from '@/stores/puzzles'
|
||||
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>()
|
||||
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 puzzlesStore = usePuzzlesStore();
|
||||
const { submissionFiles, processOCR } = useUploadsStore();
|
||||
|
||||
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 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 })
|
||||
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
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files) {
|
||||
processFiles(Array.from(target.files))
|
||||
processFiles(Array.from(target.files));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = false
|
||||
|
||||
event.preventDefault();
|
||||
isDragOver.value = false;
|
||||
|
||||
if (event.dataTransfer?.files) {
|
||||
processFiles(Array.from(event.dataTransfer.files))
|
||||
processFiles(Array.from(event.dataTransfer.files));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processFiles = async (newFiles: File[]) => {
|
||||
error.value = ''
|
||||
|
||||
error.value = "";
|
||||
|
||||
for (const file of newFiles) {
|
||||
if (!isValidFile(file)) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const preview = await createPreview(file)
|
||||
const fileType = file.type.startsWith('image/gif') ? 'gif' : 'image'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
files.value.push(submissionFile)
|
||||
|
||||
ocrData: undefined,
|
||||
};
|
||||
|
||||
submissionFiles.push(submissionFile);
|
||||
|
||||
// Start OCR processing for Opus Magnum images (with delay to ensure reactivity)
|
||||
if (isOpusMagnumImage(file)) {
|
||||
nextTick(() => {
|
||||
processOCR(submissionFile)
|
||||
})
|
||||
processOCR(submissionFile);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `Failed to process ${file.name}`
|
||||
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
|
||||
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
|
||||
error.value = `${file.name} is too large (max 256MB)`;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
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]
|
||||
}
|
||||
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
|
||||
|
||||
// Check if puzzle confidence is below 80% and needs manual selection
|
||||
if (ocrData.confidence.puzzle < 0.8) {
|
||||
files.value[fileIndex].needsManualPuzzleSelection = true
|
||||
console.log(`Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`)
|
||||
} else {
|
||||
files.value[fileIndex].needsManualPuzzleSelection = false
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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'
|
||||
}
|
||||
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 = files.value.findIndex(f => f.file === submissionFile.file)
|
||||
if (fileIndex === -1) return
|
||||
|
||||
const fileIndex = submissionFiles.findIndex(
|
||||
(f) => f.file === submissionFile.file,
|
||||
);
|
||||
if (fileIndex === -1) return;
|
||||
|
||||
// Clear the manual selection requirement once user has selected
|
||||
if (files.value[fileIndex].manualPuzzleSelection) {
|
||||
files.value[fileIndex].needsManualPuzzleSelection = false
|
||||
console.log(`Manual puzzle selection: ${submissionFile.file.name} -> ${files.value[fileIndex].manualPuzzleSelection}`)
|
||||
if (submissionFiles[fileIndex].manualPuzzleSelection) {
|
||||
submissionFiles[fileIndex].needsManualPuzzleSelection = false;
|
||||
console.log(
|
||||
`Manual puzzle selection: ${submissionFile.file.name} -> ${submissionFiles[fileIndex].manualPuzzleSelection}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -1,37 +1,54 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300">
|
||||
<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>
|
||||
|
||||
<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 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">
|
||||
|
||||
<p
|
||||
v-if="puzzle.description"
|
||||
class="text-sm text-base-content/80 mb-4"
|
||||
>
|
||||
{{ 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)"
|
||||
|
||||
<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">
|
||||
<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
|
||||
<a
|
||||
:href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`"
|
||||
target="_blank"
|
||||
class="btn btn-ghost btn-sm btn-square"
|
||||
@ -41,13 +58,15 @@
|
||||
</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>
|
||||
<span class="text-sm font-medium"
|
||||
>Solutions ({{ responses.length }})</span
|
||||
>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<table class="table table-xs">
|
||||
<thead>
|
||||
@ -59,32 +78,59 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="response in responses" :key="response.id" class="hover">
|
||||
<tr
|
||||
v-for="response in responses"
|
||||
:key="response.id"
|
||||
class="hover"
|
||||
>
|
||||
<td>
|
||||
<span v-if="response.final_cost || response.cost" class="badge badge-success badge-xs">
|
||||
<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.final_cycles || response.cycles" class="badge badge-info badge-xs">
|
||||
<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.final_area || response.area" class="badge badge-warning badge-xs">
|
||||
<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 || 0 }}</span>
|
||||
<div v-if="response.files?.length" class="tooltip" :data-tip="response.files.map(f => f.original_filename || 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">
|
||||
<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>
|
||||
@ -94,35 +140,33 @@
|
||||
</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">
|
||||
<div
|
||||
v-else
|
||||
class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg hover:border-primary transition-colors duration-300 cursor-pointer"
|
||||
@click="openSubmissionModal"
|
||||
>
|
||||
<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>
|
||||
<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'
|
||||
import type { SteamCollectionItem, PuzzleResponse } from "@/types";
|
||||
import { useSubmissionsStore } from "@/stores/submissions";
|
||||
|
||||
interface Props {
|
||||
puzzle: SteamCollectionItem
|
||||
responses?: PuzzleResponse[]
|
||||
puzzle: SteamCollectionItem;
|
||||
responses?: PuzzleResponse[];
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineProps<Props>();
|
||||
|
||||
// Utility functions removed - not used in template
|
||||
const { openSubmissionModal } = useSubmissionsStore();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -5,37 +5,60 @@
|
||||
<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 ml-2">{{ data.files.length }} file(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
|
||||
|
||||
<!-- Manual Selection Warning -->
|
||||
<div v-if="filesNeedingManualSelection.length > 0" class="alert alert-warning">
|
||||
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||
<div class="flex-1">
|
||||
<div class="font-bold">Manual Puzzle Selection Required</div>
|
||||
<div class="text-sm">
|
||||
{{ filesNeedingManualSelection.length }} file(s) have low OCR confidence for puzzle names.
|
||||
Please select the correct puzzle for each file before submitting.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<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 ml-2"
|
||||
>{{ data.files.length }} file(s)</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<FileUpload />
|
||||
|
||||
<!-- Manual Selection Warning -->
|
||||
<div
|
||||
v-if="submissionFilesNeedingManualSelection.length > 0"
|
||||
class="alert alert-warning"
|
||||
>
|
||||
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||
<div class="flex-1">
|
||||
<div class="font-bold">Manual Puzzle Selection Required</div>
|
||||
<div class="text-sm">
|
||||
{{ submissionFilesNeedingManualSelection.length }} file(s) have
|
||||
low OCR confidence for puzzle names. Please select the correct
|
||||
puzzle for each file before submitting.
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn mt-3 w-full"
|
||||
@click="processLowConfidenceOCRFiles"
|
||||
>
|
||||
<span class="mdi mdi-reload text-2xl"></span>
|
||||
Retry OCR on low confidence puzzle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-control">
|
||||
<div class="flex-1">
|
||||
@ -43,7 +66,7 @@
|
||||
<span class="label-text font-medium">Notes (Optional)</span>
|
||||
<span class="label-text-alt">{{ notesLength }}/500</span>
|
||||
</label>
|
||||
<textarea
|
||||
<textarea
|
||||
v-model="notes"
|
||||
class="flex textarea textarea-bordered h-24 w-full resize-none"
|
||||
placeholder="Add any notes about your solution, approach, or interesting findings..."
|
||||
@ -51,37 +74,44 @@
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Manual Validation Request -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="manualValidationRequested"
|
||||
class="checkbox checkbox-primary"
|
||||
:disabled="hasLowConfidence"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="label-text font-medium">Request manual validation</span>
|
||||
<span class="label-text font-medium"
|
||||
>Request manual validation</span
|
||||
>
|
||||
<div class="label-text-alt text-xs opacity-70 mt-1">
|
||||
Check this if you want an admin to manually review your submission, even if OCR confidence is high.
|
||||
<br>
|
||||
<em>Note: This will be automatically checked if any OCR confidence is below 50%.</em>
|
||||
Check this if you want an admin to manually review your
|
||||
submission, even if OCR confidence is high.
|
||||
<br />
|
||||
<em
|
||||
>Note: This will be automatically checked if any OCR
|
||||
confidence is below 80%.</em
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="card-actions justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
|
||||
<button type="submit" class="btn btn-primary" :disabled="!canSubmit">
|
||||
<span
|
||||
v-if="isSubmitting"
|
||||
class="loading loading-spinner loading-sm"
|
||||
></span>
|
||||
<span v-if="isSubmitting">Submitting...</span>
|
||||
<span v-else-if="filesNeedingManualSelection.length > 0">
|
||||
Select Puzzles ({{ filesNeedingManualSelection.length }} remaining)
|
||||
<span v-else-if="submissionFilesNeedingManualSelection.length > 0">
|
||||
Select Puzzles ({{ submissionFilesNeedingManualSelection.length }}
|
||||
remaining)
|
||||
</span>
|
||||
<span v-else>Submit Solution</span>
|
||||
</button>
|
||||
@ -92,104 +122,97 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import FileUpload from '@/components/FileUpload.vue'
|
||||
import type { SteamCollectionItem, SubmissionFile } from '@/types'
|
||||
import { ref, computed, watch } from "vue";
|
||||
import FileUpload from "@/components/FileUpload.vue";
|
||||
import type { SteamCollectionItem, SubmissionFile } from "@/types";
|
||||
import { useUploadsStore } from "@/stores/uploads";
|
||||
import { useSubmissionsStore } from "@/stores/submissions";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
interface Props {
|
||||
puzzles: SteamCollectionItem[]
|
||||
findPuzzleByName: (name: string) => SteamCollectionItem | null
|
||||
puzzles: SteamCollectionItem[];
|
||||
findPuzzleByName: (name: string) => SteamCollectionItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
submit: [submissionData: { files: SubmissionFile[], notes?: string, manualValidationRequested?: boolean }]
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
const uploadsStore = useUploadsStore();
|
||||
const {
|
||||
submissionFiles,
|
||||
hasLowConfidence,
|
||||
submissionFilesNeedingManualSelection,
|
||||
} = storeToRefs(uploadsStore);
|
||||
const { clearFiles, processLowConfidenceOCRFiles } = uploadsStore;
|
||||
const { handleSubmission } = useSubmissionsStore();
|
||||
|
||||
const submissionFiles = ref<SubmissionFile[]>([])
|
||||
const notes = ref('')
|
||||
const manualValidationRequested = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
const notes = ref("");
|
||||
const manualValidationRequested = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const notesLength = computed(() => notes.value.length)
|
||||
const notesLength = computed(() => notes.value.length);
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const hasFiles = submissionFiles.value.length > 0
|
||||
const noManualSelectionNeeded = !submissionFiles.value.some(file => file.needsManualPuzzleSelection)
|
||||
|
||||
return hasFiles &&
|
||||
!isSubmitting.value &&
|
||||
noManualSelectionNeeded
|
||||
})
|
||||
const hasFiles = submissionFiles.value.length > 0;
|
||||
const noManualSelectionNeeded = !submissionFiles.value.some(
|
||||
(file) => file.needsManualPuzzleSelection,
|
||||
);
|
||||
|
||||
return hasFiles && !isSubmitting.value && noManualSelectionNeeded;
|
||||
});
|
||||
|
||||
watch(hasLowConfidence, (newValue) => {
|
||||
if (newValue) {
|
||||
manualValidationRequested.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Group files by detected puzzle
|
||||
const responsesByPuzzle = computed(() => {
|
||||
const grouped: Record<string, { puzzle: SteamCollectionItem | null, files: SubmissionFile[] }> = {}
|
||||
|
||||
submissionFiles.value.forEach(file => {
|
||||
const grouped: Record<
|
||||
string,
|
||||
{ puzzle: SteamCollectionItem | null; files: SubmissionFile[] }
|
||||
> = {};
|
||||
|
||||
submissionFiles.value.forEach((file) => {
|
||||
// Use manual puzzle selection if available, otherwise fall back to OCR
|
||||
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
|
||||
|
||||
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle;
|
||||
|
||||
if (puzzleName) {
|
||||
if (!grouped[puzzleName]) {
|
||||
grouped[puzzleName] = {
|
||||
puzzle: props.findPuzzleByName(puzzleName),
|
||||
files: []
|
||||
}
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
grouped[puzzleName].files.push(file)
|
||||
grouped[puzzleName].files.push(file);
|
||||
}
|
||||
})
|
||||
|
||||
return grouped
|
||||
})
|
||||
});
|
||||
|
||||
// Count files that need manual puzzle selection
|
||||
const filesNeedingManualSelection = computed(() => {
|
||||
return submissionFiles.value.filter(file => file.needsManualPuzzleSelection)
|
||||
})
|
||||
|
||||
// Check if any OCR confidence is below 50%
|
||||
const hasLowConfidence = computed(() => {
|
||||
return submissionFiles.value.some(file => {
|
||||
if (!file.ocrData?.confidence) return false
|
||||
return file.ocrData.confidence.cost < 0.5 ||
|
||||
file.ocrData.confidence.cycles < 0.5 ||
|
||||
file.ocrData.confidence.area < 0.5
|
||||
})
|
||||
})
|
||||
|
||||
// Auto-check manual validation when confidence is low
|
||||
watch(hasLowConfidence, (newValue) => {
|
||||
if (newValue && !manualValidationRequested.value) {
|
||||
manualValidationRequested.value = true
|
||||
}
|
||||
}, { immediate: true })
|
||||
return grouped;
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
if (!canSubmit.value) return;
|
||||
|
||||
isSubmitting.value = true;
|
||||
|
||||
try {
|
||||
// Emit the files and notes for the parent to handle API submission
|
||||
emit('submit', {
|
||||
// Emit the files and notes for the store to handle API submission
|
||||
handleSubmission({
|
||||
files: submissionFiles.value,
|
||||
notes: notes.value.trim() || undefined,
|
||||
manualValidationRequested: manualValidationRequested.value
|
||||
})
|
||||
|
||||
manualValidationRequested:
|
||||
hasLowConfidence.value || manualValidationRequested.value,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
submissionFiles.value = []
|
||||
notes.value = ''
|
||||
manualValidationRequested.value = false
|
||||
|
||||
clearFiles();
|
||||
notes.value = "";
|
||||
manualValidationRequested.value = false;
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error)
|
||||
console.error("Submission error:", error);
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -35,7 +35,7 @@ export class OpusMagnumOCRService {
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.worker) return;
|
||||
|
||||
|
||||
this.worker = await createWorker('eng');
|
||||
await this.worker.setParameters({
|
||||
tessedit_ocr_engine_mode: '3',
|
||||
@ -62,29 +62,29 @@ export class OpusMagnumOCRService {
|
||||
await this.worker.setParameters({
|
||||
// Disable all system dictionaries to prevent interference
|
||||
load_system_dawg: '0',
|
||||
load_freq_dawg: '0',
|
||||
load_freq_dawg: '0',
|
||||
load_punc_dawg: '0',
|
||||
load_number_dawg: '0',
|
||||
load_unambig_dawg: '0',
|
||||
load_bigram_dawg: '0',
|
||||
load_fixed_length_dawgs: '0',
|
||||
|
||||
|
||||
// Use only characters from our puzzle names
|
||||
tessedit_char_whitelist: this.getPuzzleCharacterSet(),
|
||||
|
||||
|
||||
// Optimize for single words/short phrases
|
||||
tessedit_pageseg_mode: 8 as any, // Single word
|
||||
|
||||
|
||||
// Increase penalties for non-dictionary words
|
||||
segment_penalty_dict_nonword: '2.0',
|
||||
segment_penalty_dict_frequent_word: '0.001',
|
||||
segment_penalty_dict_case_ok: '0.001',
|
||||
segment_penalty_dict_case_bad: '0.1',
|
||||
|
||||
|
||||
// Make OCR more conservative about character recognition
|
||||
classify_enable_learning: '0',
|
||||
classify_enable_adaptive_matcher: '1',
|
||||
|
||||
|
||||
// Preserve word boundaries
|
||||
preserve_interword_spaces: '1'
|
||||
});
|
||||
@ -120,13 +120,13 @@ export class OpusMagnumOCRService {
|
||||
// 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);
|
||||
@ -138,10 +138,10 @@ export class OpusMagnumOCRService {
|
||||
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,
|
||||
@ -178,7 +178,7 @@ export class OpusMagnumOCRService {
|
||||
// Perform OCR on the region
|
||||
const { data: { text, confidence } } = await this.worker!.recognize(regionCanvas);
|
||||
let cleanText = text.trim();
|
||||
|
||||
|
||||
// Store the confidence score for this field
|
||||
confidenceScores[key] = confidence / 100; // Tesseract returns 0-100, we want 0-1
|
||||
|
||||
@ -203,7 +203,7 @@ export class OpusMagnumOCRService {
|
||||
} else if (key === 'puzzle') {
|
||||
// Post-process puzzle names with aggressive matching to force selection from available puzzles
|
||||
cleanText = this.findBestPuzzleMatch(cleanText);
|
||||
|
||||
|
||||
// If we still don't have a match and we have available puzzles, force the best match
|
||||
if (this.availablePuzzleNames.length > 0 && !this.availablePuzzleNames.includes(cleanText)) {
|
||||
const forcedMatch = this.findBestPuzzleMatchForced(cleanText);
|
||||
@ -218,13 +218,13 @@ export class OpusMagnumOCRService {
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
|
||||
|
||||
// Calculate overall confidence as the average of all field confidences
|
||||
const confidenceValues = Object.values(confidenceScores);
|
||||
const overallConfidence = confidenceValues.length > 0
|
||||
? confidenceValues.reduce((sum, conf) => sum + conf, 0) / confidenceValues.length
|
||||
const overallConfidence = confidenceValues.length > 0
|
||||
? confidenceValues.reduce((sum, conf) => sum + conf, 0) / confidenceValues.length
|
||||
: 0;
|
||||
|
||||
|
||||
resolve({
|
||||
puzzle: results.puzzle || '',
|
||||
cost: results.cost || '',
|
||||
@ -235,7 +235,7 @@ export class OpusMagnumOCRService {
|
||||
cost: confidenceScores.cost || 0,
|
||||
cycles: confidenceScores.cycles || 0,
|
||||
area: confidenceScores.area || 0,
|
||||
overall: overallConfidence
|
||||
overall: overallConfidence,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@ -256,14 +256,14 @@ export class OpusMagnumOCRService {
|
||||
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
|
||||
@ -276,10 +276,10 @@ export class OpusMagnumOCRService {
|
||||
*/
|
||||
private levenshteinDistance(str1: string, str2: string): number {
|
||||
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
||||
|
||||
|
||||
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;
|
||||
@ -290,7 +290,7 @@ export class OpusMagnumOCRService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return matrix[str2.length][str1.length];
|
||||
}
|
||||
|
||||
@ -304,7 +304,7 @@ export class OpusMagnumOCRService {
|
||||
|
||||
const cleanedOcr = ocrText.trim();
|
||||
if (!cleanedOcr) return '';
|
||||
|
||||
|
||||
// Strategy 1: Exact match (case insensitive)
|
||||
const exactMatch = this.availablePuzzleNames.find(
|
||||
name => name.toLowerCase() === cleanedOcr.toLowerCase()
|
||||
@ -314,31 +314,31 @@ export class OpusMagnumOCRService {
|
||||
// Strategy 2: Substring match (either direction)
|
||||
const substringMatch = this.availablePuzzleNames.find(
|
||||
name => name.toLowerCase().includes(cleanedOcr.toLowerCase()) ||
|
||||
cleanedOcr.toLowerCase().includes(name.toLowerCase())
|
||||
cleanedOcr.toLowerCase().includes(name.toLowerCase())
|
||||
);
|
||||
if (substringMatch) return substringMatch;
|
||||
|
||||
// Strategy 3: Multiple fuzzy matching approaches
|
||||
let bestMatch = cleanedOcr;
|
||||
let bestScore = 0;
|
||||
|
||||
|
||||
for (const puzzleName of this.availablePuzzleNames) {
|
||||
const scores = [
|
||||
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
|
||||
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
|
||||
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2)
|
||||
];
|
||||
|
||||
|
||||
// Use the maximum score from all algorithms
|
||||
const maxScore = Math.max(...scores);
|
||||
|
||||
|
||||
// Lower threshold for better matching - force selection even with moderate confidence
|
||||
if (maxScore > bestScore && maxScore > 0.4) {
|
||||
bestScore = maxScore;
|
||||
bestMatch = puzzleName;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Strategy 4: If no good match found, try character-based matching
|
||||
if (bestScore < 0.6) {
|
||||
const charMatch = this.findBestCharacterMatch(cleanedOcr);
|
||||
@ -346,7 +346,7 @@ export class OpusMagnumOCRService {
|
||||
bestMatch = charMatch;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
@ -365,23 +365,23 @@ export class OpusMagnumOCRService {
|
||||
private calculateJaroWinklerSimilarity(str1: string, str2: string): number {
|
||||
const s1 = str1.toLowerCase();
|
||||
const s2 = str2.toLowerCase();
|
||||
|
||||
|
||||
if (s1 === s2) return 1;
|
||||
|
||||
|
||||
const matchWindow = Math.floor(Math.max(s1.length, s2.length) / 2) - 1;
|
||||
if (matchWindow < 0) return 0;
|
||||
|
||||
|
||||
const s1Matches = new Array(s1.length).fill(false);
|
||||
const s2Matches = new Array(s2.length).fill(false);
|
||||
|
||||
|
||||
let matches = 0;
|
||||
let transpositions = 0;
|
||||
|
||||
|
||||
// Find matches
|
||||
for (let i = 0; i < s1.length; i++) {
|
||||
const start = Math.max(0, i - matchWindow);
|
||||
const end = Math.min(i + matchWindow + 1, s2.length);
|
||||
|
||||
|
||||
for (let j = start; j < end; j++) {
|
||||
if (s2Matches[j] || s1[i] !== s2[j]) continue;
|
||||
s1Matches[i] = true;
|
||||
@ -390,9 +390,9 @@ export class OpusMagnumOCRService {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (matches === 0) return 0;
|
||||
|
||||
|
||||
// Count transpositions
|
||||
let k = 0;
|
||||
for (let i = 0; i < s1.length; i++) {
|
||||
@ -401,16 +401,16 @@ export class OpusMagnumOCRService {
|
||||
if (s1[i] !== s2[k]) transpositions++;
|
||||
k++;
|
||||
}
|
||||
|
||||
|
||||
const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3;
|
||||
|
||||
|
||||
// Jaro-Winkler bonus for common prefix
|
||||
let prefix = 0;
|
||||
for (let i = 0; i < Math.min(s1.length, s2.length, 4); i++) {
|
||||
if (s1[i] === s2[i]) prefix++;
|
||||
else break;
|
||||
}
|
||||
|
||||
|
||||
return jaro + (0.1 * prefix * (1 - jaro));
|
||||
}
|
||||
|
||||
@ -420,24 +420,24 @@ export class OpusMagnumOCRService {
|
||||
private calculateNGramSimilarity(str1: string, str2: string, n: number): number {
|
||||
const s1 = str1.toLowerCase();
|
||||
const s2 = str2.toLowerCase();
|
||||
|
||||
|
||||
if (s1 === s2) return 1;
|
||||
if (s1.length < n || s2.length < n) return 0;
|
||||
|
||||
|
||||
const ngrams1 = new Set<string>();
|
||||
const ngrams2 = new Set<string>();
|
||||
|
||||
|
||||
for (let i = 0; i <= s1.length - n; i++) {
|
||||
ngrams1.add(s1.substr(i, n));
|
||||
}
|
||||
|
||||
|
||||
for (let i = 0; i <= s2.length - n; i++) {
|
||||
ngrams2.add(s2.substr(i, n));
|
||||
}
|
||||
|
||||
|
||||
const intersection = new Set([...ngrams1].filter(x => ngrams2.has(x)));
|
||||
const union = new Set([...ngrams1, ...ngrams2]);
|
||||
|
||||
|
||||
return intersection.size / union.size;
|
||||
}
|
||||
|
||||
@ -447,7 +447,7 @@ export class OpusMagnumOCRService {
|
||||
private findBestCharacterMatch(ocrText: string): string | null {
|
||||
let bestMatch = null;
|
||||
let bestScore = 0;
|
||||
|
||||
|
||||
for (const puzzleName of this.availablePuzzleNames) {
|
||||
const score = this.calculateCharacterFrequencyScore(ocrText.toLowerCase(), puzzleName.toLowerCase());
|
||||
if (score > bestScore && score > 0.3) {
|
||||
@ -455,7 +455,7 @@ export class OpusMagnumOCRService {
|
||||
bestMatch = puzzleName;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
@ -465,26 +465,26 @@ export class OpusMagnumOCRService {
|
||||
private calculateCharacterFrequencyScore(str1: string, str2: string): number {
|
||||
const freq1 = new Map<string, number>();
|
||||
const freq2 = new Map<string, number>();
|
||||
|
||||
|
||||
for (const char of str1) {
|
||||
freq1.set(char, (freq1.get(char) || 0) + 1);
|
||||
}
|
||||
|
||||
|
||||
for (const char of str2) {
|
||||
freq2.set(char, (freq2.get(char) || 0) + 1);
|
||||
}
|
||||
|
||||
|
||||
const allChars = new Set([...freq1.keys(), ...freq2.keys()]);
|
||||
let similarity = 0;
|
||||
let totalChars = 0;
|
||||
|
||||
|
||||
for (const char of allChars) {
|
||||
const count1 = freq1.get(char) || 0;
|
||||
const count2 = freq2.get(char) || 0;
|
||||
similarity += Math.min(count1, count2);
|
||||
totalChars += Math.max(count1, count2);
|
||||
}
|
||||
|
||||
|
||||
return totalChars === 0 ? 0 : similarity / totalChars;
|
||||
}
|
||||
|
||||
@ -539,7 +539,7 @@ export class OpusMagnumOCRService {
|
||||
const len2 = str2.length;
|
||||
const maxLen = Math.max(len1, len2);
|
||||
const minLen = Math.min(len1, len2);
|
||||
|
||||
|
||||
return maxLen === 0 ? 1 : minLen / maxLen;
|
||||
}
|
||||
|
||||
@ -563,11 +563,11 @@ export class OpusMagnumOCRService {
|
||||
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);
|
||||
@ -575,7 +575,7 @@ export class OpusMagnumOCRService {
|
||||
// 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);
|
||||
|
||||
@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
import type { Submission, SubmissionFile } from '@/types'
|
||||
import { submissionHelpers } from '@/services/apiService'
|
||||
import { usePuzzlesStore } from '@/stores/puzzles'
|
||||
import { errorHelpers } from "@/services/apiService";
|
||||
|
||||
export const useSubmissionsStore = defineStore('submissions', () => {
|
||||
// State
|
||||
@ -83,6 +84,49 @@ export const useSubmissionsStore = defineStore('submissions', () => {
|
||||
await loadSubmissions()
|
||||
}
|
||||
|
||||
const handleSubmission = async (submissionData: {
|
||||
files: any[];
|
||||
notes?: string;
|
||||
manualValidationRequested?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = "";
|
||||
|
||||
// Create submission via store
|
||||
const submission = await createSubmission(
|
||||
submissionData.files,
|
||||
submissionData.notes,
|
||||
submissionData.manualValidationRequested,
|
||||
);
|
||||
|
||||
// Show success message
|
||||
if (submission) {
|
||||
const puzzleNames = submission.responses
|
||||
.map((r) => r.puzzle_name)
|
||||
.join(", ");
|
||||
|
||||
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`);
|
||||
|
||||
} else {
|
||||
alert("Submission created successfully!");
|
||||
}
|
||||
|
||||
// Close modal
|
||||
closeSubmissionModal();
|
||||
|
||||
} catch (err) {
|
||||
|
||||
const errorMessage = errorHelpers.getErrorMessage(err);
|
||||
error.value = errorMessage;
|
||||
alert(`Submission failed: ${errorMessage}`);
|
||||
|
||||
console.error("Submission error:", err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
submissions,
|
||||
@ -95,6 +139,7 @@ export const useSubmissionsStore = defineStore('submissions', () => {
|
||||
createSubmission,
|
||||
openSubmissionModal,
|
||||
closeSubmissionModal,
|
||||
refreshSubmissions
|
||||
refreshSubmissions,
|
||||
handleSubmission
|
||||
}
|
||||
})
|
||||
|
||||
103
opus_submitter/src/stores/uploads.ts
Normal file
103
opus_submitter/src/stores/uploads.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { SubmissionFile } from '@/types'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, nextTick, computed } from "vue";
|
||||
import { ocrService } from "@/services/ocrService";
|
||||
|
||||
const CONFIDENCE_VALUE = 0.8;
|
||||
|
||||
export const useUploadsStore = defineStore('uploads', () => {
|
||||
const submissionFiles = ref<SubmissionFile[]>([])
|
||||
|
||||
const isProcessingOCR = computed(() =>
|
||||
submissionFiles.value.some(item => item.ocrProcessing)
|
||||
);
|
||||
|
||||
const hasLowConfidence = computed(() =>
|
||||
submissionFiles.value.some(file => {
|
||||
return isLowConfidence(file)
|
||||
})
|
||||
)
|
||||
|
||||
const submissionFilesNeedingManualSelection = computed(() => {
|
||||
return submissionFiles.value.filter(file => file.needsManualPuzzleSelection)
|
||||
})
|
||||
|
||||
const isLowConfidence = (file: SubmissionFile) => {
|
||||
if (!file.ocrData?.confidence) return false;
|
||||
return (
|
||||
file.ocrData.confidence.cost < CONFIDENCE_VALUE ||
|
||||
file.ocrData.confidence.cycles < CONFIDENCE_VALUE ||
|
||||
file.ocrData.confidence.area < CONFIDENCE_VALUE
|
||||
)
|
||||
}
|
||||
|
||||
const processOCR = async (submissionFile: SubmissionFile) => {
|
||||
while (isProcessingOCR.value) {
|
||||
const waitingTimeMs = Math.floor(Math.random() * 400) + 100;
|
||||
console.log(`OCR is already processing, waiting ${waitingTimeMs}ms...`);
|
||||
await new Promise((res) => setTimeout(res, waitingTimeMs));
|
||||
}
|
||||
|
||||
const index = submissionFiles.value.indexOf(submissionFile)
|
||||
|
||||
// Update the reactive array directly
|
||||
submissionFiles.value[index].ocrProcessing = true;
|
||||
submissionFiles.value[index].ocrError = undefined;
|
||||
submissionFiles.value[index].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();
|
||||
submissionFiles.value[index].ocrData = ocrData;
|
||||
|
||||
// Check if puzzle confidence is below CONFIDENCE_VALUE and needs manual selection
|
||||
if (ocrData.confidence.puzzle < CONFIDENCE_VALUE) {
|
||||
submissionFiles.value[index].needsManualPuzzleSelection = true;
|
||||
console.log(
|
||||
`Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`,
|
||||
);
|
||||
} else {
|
||||
submissionFiles.value[index].needsManualPuzzleSelection = false;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
} catch (error) {
|
||||
console.error("OCR processing failed:", error);
|
||||
submissionFiles.value[index].ocrError = "Failed to extract puzzle data";
|
||||
|
||||
} finally {
|
||||
submissionFiles.value[index].ocrProcessing = false;
|
||||
}
|
||||
};
|
||||
|
||||
const processLowConfidenceOCRFiles = async () => {
|
||||
const files = submissionFiles.value.filter(file => isLowConfidence(file))
|
||||
|
||||
for (const file of files) {
|
||||
processOCR(file)
|
||||
}
|
||||
}
|
||||
|
||||
const clearFiles = () => {
|
||||
submissionFiles.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
submissionFiles,
|
||||
submissionFilesNeedingManualSelection,
|
||||
processOCR,
|
||||
processLowConfidenceOCRFiles,
|
||||
clearFiles,
|
||||
|
||||
// computed
|
||||
isProcessingOCR,
|
||||
hasLowConfidence,
|
||||
|
||||
CONFIDENCE_VALUE
|
||||
}
|
||||
})
|
||||
@ -40,6 +40,7 @@ export interface OpusMagnumData {
|
||||
|
||||
export interface SubmissionFile {
|
||||
file: File
|
||||
file_url: string
|
||||
preview: string
|
||||
type: 'image' | 'gif'
|
||||
ocrData?: OpusMagnumData
|
||||
@ -52,7 +53,8 @@ export interface SubmissionFile {
|
||||
|
||||
export interface PuzzleResponse {
|
||||
id?: number
|
||||
puzzle: number | SteamCollectionItem
|
||||
// puzzle: number | SteamCollectionItem
|
||||
puzzle: number
|
||||
puzzle_name: string
|
||||
cost?: string
|
||||
cycles?: string
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
21
opus_submitter/static_source/vite/assets/main-NIi3b_aN.js
Normal file
21
opus_submitter/static_source/vite/assets/main-NIi3b_aN.js
Normal file
File diff suppressed because one or more lines are too long
@ -16,12 +16,12 @@
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
||||
},
|
||||
"src/main.ts": {
|
||||
"file": "assets/main-B14l8Jy0.js",
|
||||
"file": "assets/main-NIi3b_aN.js",
|
||||
"name": "main",
|
||||
"src": "src/main.ts",
|
||||
"isEntry": true,
|
||||
"css": [
|
||||
"assets/main-COx9N9qO.css"
|
||||
"assets/main-CYuvChoP.css"
|
||||
],
|
||||
"assets": [
|
||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||
|
||||
@ -33,11 +33,9 @@ def list_puzzles(request):
|
||||
@paginate
|
||||
def list_submissions(request):
|
||||
"""Get paginated list of submissions"""
|
||||
return (
|
||||
Submission.objects.prefetch_related("responses__files", "responses__puzzle")
|
||||
.filter(user=request.user)
|
||||
.filter()
|
||||
)
|
||||
return Submission.objects.prefetch_related(
|
||||
"responses__files", "responses__puzzle"
|
||||
).filter(user=request.user)
|
||||
|
||||
|
||||
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
||||
@ -70,19 +68,19 @@ def create_submission(
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Check if any confidence score is below 50% to auto-request validation
|
||||
# Check if any confidence score is below 80% to auto-request validation
|
||||
auto_request_validation = any(
|
||||
(
|
||||
response_data.ocr_confidence_cost is not None
|
||||
and response_data.ocr_confidence_cost < 0.5
|
||||
and response_data.ocr_confidence_cost < 0.8
|
||||
)
|
||||
or (
|
||||
response_data.ocr_confidence_cycles is not None
|
||||
and response_data.ocr_confidence_cycles < 0.5
|
||||
and response_data.ocr_confidence_cycles < 0.8
|
||||
)
|
||||
or (
|
||||
response_data.ocr_confidence_area is not None
|
||||
and response_data.ocr_confidence_area < 0.5
|
||||
and response_data.ocr_confidence_area < 0.8
|
||||
)
|
||||
for response_data in data.responses
|
||||
)
|
||||
|
||||
@ -4,7 +4,7 @@ Django management command to fetch Steam Workshop collections
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from submissions.utils import create_or_update_collection
|
||||
from submissions.models import SteamCollection
|
||||
from submissions.models import SteamAPIKey, SteamCollection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -12,11 +12,6 @@ class Command(BaseCommand):
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("url", type=str, help="Steam Workshop collection URL")
|
||||
parser.add_argument(
|
||||
"--api-key",
|
||||
type=str,
|
||||
help="Steam API key (optional, can also be set via STEAM_API_KEY environment variable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
@ -25,16 +20,23 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
url = options["url"]
|
||||
api_key = options.get("api_key")
|
||||
force = options["force"]
|
||||
|
||||
self.stdout.write(f"Fetching Steam collection from: {url}")
|
||||
|
||||
api_key = SteamAPIKey.objects.filter(is_active=True).first()
|
||||
|
||||
if not api_key:
|
||||
self.stderr.write(f"No API key defined! Aborting...")
|
||||
return
|
||||
|
||||
self.stdout.write(f"Using api key: {api_key}")
|
||||
|
||||
try:
|
||||
# Check if collection already exists
|
||||
from submissions.utils import SteamCollectionFetcher
|
||||
|
||||
fetcher = SteamCollectionFetcher(api_key)
|
||||
fetcher = SteamCollectionFetcher(api_key.api_key)
|
||||
collection_id = fetcher.extract_collection_id(url)
|
||||
|
||||
if collection_id and not force:
|
||||
|
||||
@ -497,20 +497,15 @@ def verify_and_validate_ocr_date_for_submission(file: SubmissionFile):
|
||||
)
|
||||
|
||||
valid_count = 0
|
||||
if r.cost == ocr_data[1]:
|
||||
r.validated_cost = r.cost
|
||||
valid_count += 1
|
||||
for index, field in enumerate(["cost", "cycles", "area"]):
|
||||
value = getattr(r, field, -1)
|
||||
|
||||
if r.cycles == ocr_data[2]:
|
||||
r.validated_cycles = r.cycles
|
||||
valid_count += 1
|
||||
if value == ocr_data[index + 1]:
|
||||
setattr(r, f"validated_{field}", value)
|
||||
valid_count += 1
|
||||
|
||||
if r.area == ocr_data[3]:
|
||||
r.validated_area = r.area
|
||||
valid_count += 1
|
||||
else:
|
||||
setattr(r, field, ocr_data[index + 1])
|
||||
|
||||
if valid_count == 3:
|
||||
r.needs_manual_validation = False
|
||||
|
||||
if valid_count:
|
||||
r.save()
|
||||
r.needs_manual_validation = valid_count != 3
|
||||
r.save()
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -6,7 +6,10 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: '/static/',
|
||||
plugins: [vue(), tailwindcss()],
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
@ -18,5 +21,5 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: { main: resolve('./src/main.ts') }
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -7,7 +7,10 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: '/static/',
|
||||
plugins: [vue(), tailwindcss()],
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
@ -20,6 +23,5 @@ export default defineConfig({
|
||||
input:
|
||||
{ main: resolve('./src/main.ts') }
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"django-extensions>=4.1",
|
||||
"django-stubs>=5.2.7",
|
||||
"django-stubs-ext>=5.2.7",
|
||||
"django-types>=0.22.0",
|
||||
|
||||
14
uv.lock
14
uv.lock
@ -185,6 +185,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-extensions"
|
||||
version = "4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-ninja"
|
||||
version = "1.4.5"
|
||||
@ -504,6 +516,7 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "django-extensions" },
|
||||
{ name = "django-stubs" },
|
||||
{ name = "django-stubs-ext" },
|
||||
{ name = "django-types" },
|
||||
@ -530,6 +543,7 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "django-extensions", specifier = ">=4.1" },
|
||||
{ name = "django-stubs", specifier = ">=5.2.7" },
|
||||
{ name = "django-stubs-ext", specifier = ">=5.2.7" },
|
||||
{ name = "django-types", specifier = ">=0.22.0" },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user