Compare commits
No commits in common. "9ee45463a8d7dc7f94cff369263a03463c8cfd78" and "596731a8a7104045ba16c9cca2977b3a392e1f3a" have entirely different histories.
9ee45463a8
...
596731a8a7
@ -6,19 +6,7 @@ from submissions.schemas import UserInfoOut
|
||||
api = NinjaAPI(
|
||||
title="Opus Magnum Submission API",
|
||||
version="1.0.0",
|
||||
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",
|
||||
},
|
||||
}
|
||||
},
|
||||
description="API for managing Opus Magnum puzzle submissions",
|
||||
)
|
||||
|
||||
# Add authentication for protected endpoints
|
||||
@ -45,7 +33,8 @@ def api_info(request):
|
||||
"description": "API for managing puzzle submissions with OCR validation",
|
||||
"features": [
|
||||
"Multi-puzzle submissions",
|
||||
"OCR validation",
|
||||
"File upload to S3",
|
||||
"OCR validation tracking",
|
||||
"Manual validation workflow",
|
||||
"Admin validation tools",
|
||||
],
|
||||
|
||||
@ -1,112 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
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";
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
collectionTitle: string;
|
||||
collectionUrl: string;
|
||||
collectionDescription: string;
|
||||
}>();
|
||||
const props = defineProps<{collectionTitle: string, collectionUrl: string, collectionDescription: string}>()
|
||||
|
||||
const puzzlesStore = usePuzzlesStore();
|
||||
const submissionsStore = useSubmissionsStore();
|
||||
|
||||
const { submissions, isSubmissionModalOpen } = storeToRefs(submissionsStore);
|
||||
const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
|
||||
submissionsStore;
|
||||
// Pinia stores
|
||||
const puzzlesStore = usePuzzlesStore()
|
||||
const submissionsStore = useSubmissionsStore()
|
||||
|
||||
// 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[]> = {};
|
||||
submissions.value.forEach((submission) => {
|
||||
submission.responses.forEach((response) => {
|
||||
const grouped: Record<number, PuzzleResponse[]> = {}
|
||||
submissionsStore.submissions.forEach(submission => {
|
||||
submission.responses.forEach(response => {
|
||||
// Handle both number and object types for puzzle field
|
||||
if (!grouped[response.puzzle]) {
|
||||
grouped[response.puzzle] = [];
|
||||
const puzzleId = typeof response.puzzle === 'number' ? response.puzzle : response.puzzle.id
|
||||
if (!grouped[puzzleId]) {
|
||||
grouped[puzzleId] = []
|
||||
}
|
||||
grouped[response.puzzle].push(response);
|
||||
});
|
||||
});
|
||||
return grouped;
|
||||
});
|
||||
grouped[puzzleId].push(response)
|
||||
})
|
||||
})
|
||||
return grouped
|
||||
})
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = "";
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
console.log("Starting data load...");
|
||||
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 loadSubmissions();
|
||||
console.log("Submissions loaded:", submissions.value.length);
|
||||
console.log('Loading submissions...')
|
||||
await submissionsStore.loadSubmissions()
|
||||
console.log('Submissions loaded:', submissionsStore.submissions.length)
|
||||
|
||||
console.log('Data load complete!')
|
||||
|
||||
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();
|
||||
});
|
||||
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()
|
||||
}
|
||||
|
||||
// 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>
|
||||
@ -118,25 +157,19 @@ 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>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
||||
<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="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
||||
<a href="/admin" class="btn btn-xs btn-warning">
|
||||
Admin django
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -154,10 +187,7 @@ 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>
|
||||
@ -184,11 +214,12 @@ 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">
|
||||
<button
|
||||
@click="openSubmissionModal"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<i class="mdi mdi-plus mr-2"></i>
|
||||
Submit Solution
|
||||
</button>
|
||||
@ -216,16 +247,14 @@ 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="isSubmissionModalOpen" class="modal modal-open">
|
||||
<div class="modal-box max-w-6xl">
|
||||
<div v-if="submissionsStore.isSubmissionModalOpen" class="modal modal-open">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-bold text-lg">Submit Solution</h3>
|
||||
<button
|
||||
@ -239,6 +268,7 @@ const reloadPage = () => {
|
||||
<SubmissionForm
|
||||
:puzzles="puzzlesStore.puzzles"
|
||||
:find-puzzle-by-name="findPuzzleByName"
|
||||
@submit="handleSubmission"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
||||
|
||||
@ -10,27 +10,19 @@
|
||||
<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>
|
||||
|
||||
@ -54,50 +46,39 @@
|
||||
</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_name }}</div>
|
||||
<div class="font-bold">{{ response.puzzle_title }}</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>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>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>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>
|
||||
@ -112,19 +93,11 @@
|
||||
<td>
|
||||
<button
|
||||
@click="openValidationModal(response)"
|
||||
class="btn btn-sm btn-primary mr-2"
|
||||
class="btn btn-sm btn-primary"
|
||||
>
|
||||
<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>
|
||||
@ -135,9 +108,7 @@
|
||||
<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>
|
||||
@ -147,26 +118,21 @@
|
||||
<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>
|
||||
|
||||
<div class="mockup-code w-full">
|
||||
<pre><code>{{ validationModal}}</code></pre>
|
||||
<div v-for="file in validationModal.response.files">
|
||||
<img :src="file.file_url">
|
||||
</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_name }}
|
||||
</div>
|
||||
<div class="font-bold">{{ validationModal.response.puzzle_title }}</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="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Puzzle</span>
|
||||
@ -195,7 +161,7 @@
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="validationModal.response.cost || 'Enter cost'"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
@ -207,7 +173,7 @@
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="validationModal.response.cycles || 'Enter cycles'"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
@ -219,24 +185,19 @@
|
||||
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="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>
|
||||
@ -246,11 +207,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({
|
||||
@ -258,178 +219,160 @@ 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: -1,
|
||||
validated_cost: "",
|
||||
validated_cycles: "",
|
||||
validated_area: "",
|
||||
},
|
||||
});
|
||||
puzzle_title: '',
|
||||
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)) {
|
||||
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;
|
||||
} else if (error) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openValidationModal = (response: PuzzleResponse) => {
|
||||
validationModal.value.response = response;
|
||||
validationModal.value.data = {
|
||||
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.data = {
|
||||
puzzle: -1,
|
||||
validated_cost: "",
|
||||
validated_cycles: "",
|
||||
validated_area: "",
|
||||
};
|
||||
};
|
||||
|
||||
const autoValidation = async (id: number) => {
|
||||
const { data } = await apiService.autoValidateResponses(id);
|
||||
console.log(data);
|
||||
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 !== id,
|
||||
);
|
||||
console.log(stats.value);
|
||||
stats.value.needs_validation -= 1;
|
||||
console.log(stats.value);
|
||||
r => r.id !== response.id
|
||||
)
|
||||
stats.value.needs_validation -= 1
|
||||
|
||||
} else if (error) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openValidationModal = (response: PuzzleResponse) => {
|
||||
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
|
||||
}
|
||||
|
||||
const closeValidationModal = () => {
|
||||
validationModal.value.show = false
|
||||
validationModal.value.response = null
|
||||
validationModal.value.data = {
|
||||
puzzle: '',
|
||||
validated_cost: '',
|
||||
validated_cycles: '',
|
||||
validated_area: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const submitValidation = async () => {
|
||||
if (!validationModal.value.response?.id) return;
|
||||
if (!validationModal.value.response?.id) return
|
||||
|
||||
try {
|
||||
isValidating.value = true;
|
||||
isValidating.value = true
|
||||
|
||||
const response = await apiService.validateResponse(
|
||||
validationModal.value.response.id,
|
||||
validationModal.value.data,
|
||||
);
|
||||
validationModal.value.data
|
||||
)
|
||||
|
||||
if (response.error) {
|
||||
alert(`Validation failed: ${response.error}`);
|
||||
return;
|
||||
alert(`Validation failed: ${response.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove from validation list
|
||||
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
||||
(r) => r.id !== validationModal.value.response?.id,
|
||||
);
|
||||
r => r.id !== validationModal.value.response?.id
|
||||
)
|
||||
|
||||
// Update stats
|
||||
stats.value.needs_validation = Math.max(
|
||||
0,
|
||||
stats.value.needs_validation - 1,
|
||||
);
|
||||
stats.value.needs_validation = Math.max(0, stats.value.needs_validation - 1)
|
||||
|
||||
closeValidationModal()
|
||||
|
||||
closeValidationModal();
|
||||
} catch (error) {
|
||||
console.error("Validation error:", error);
|
||||
alert("Validation failed");
|
||||
console.error('Validation error:', error)
|
||||
alert('Validation failed')
|
||||
} finally {
|
||||
isValidating.value = false;
|
||||
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[];
|
||||
response.ocr_confidence_area
|
||||
].filter(conf => conf !== undefined && conf !== null) as number[]
|
||||
|
||||
if (confidences.length === 0) return 0;
|
||||
if (confidences.length === 0) return 0
|
||||
|
||||
const average =
|
||||
confidences.reduce((sum, conf) => sum + conf, 0) / confidences.length;
|
||||
return Math.round(average * 100);
|
||||
};
|
||||
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>
|
||||
|
||||
@ -20,12 +20,10 @@
|
||||
accept="image/*,.gif"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<div v-if="submissionFiles.length === 0" class="space-y-4">
|
||||
<div
|
||||
class="mx-auto w-12 h-12 text-base-content/40 flex items-center justify-center"
|
||||
>
|
||||
|
||||
<div v-if="files.length === 0" class="space-y-4">
|
||||
<div class="mx-auto w-12 h-12 text-base-content/40 flex items-center justify-center">
|
||||
<i class="mdi mdi-cloud-upload text-5xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
@ -44,9 +42,9 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="(file, index) in submissionFiles"
|
||||
v-for="(file, index) in files"
|
||||
:key="index"
|
||||
class="relative group"
|
||||
>
|
||||
@ -55,15 +53,13 @@
|
||||
:src="file.preview"
|
||||
:alt="file.file.name"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-black/80 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center">
|
||||
<button
|
||||
@click="removeFile(index)"
|
||||
class="btn btn-error btn-lg btn-circle"
|
||||
class="btn btn-error btn-sm btn-circle"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
@ -72,15 +68,11 @@
|
||||
<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>
|
||||
@ -96,16 +88,14 @@
|
||||
<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="processOCR(file)"
|
||||
@click="retryOCR(file)"
|
||||
class="btn btn-xs btn-ghost"
|
||||
title="Retry OCR"
|
||||
>
|
||||
@ -162,9 +152,7 @@
|
||||
<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">
|
||||
@ -186,12 +174,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Manual OCR trigger for non-auto detected files -->
|
||||
<div
|
||||
v-else-if="
|
||||
!file.ocrProcessing && !file.ocrError && !file.ocrData
|
||||
"
|
||||
class="mt-1"
|
||||
>
|
||||
<div v-else-if="!file.ocrProcessing && !file.ocrError && !file.ocrData" class="mt-1">
|
||||
<button
|
||||
@click="processOCR(file)"
|
||||
class="btn btn-xs btn-outline"
|
||||
@ -223,146 +206,199 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from "vue";
|
||||
import { ocrService } from "@/services/ocrService";
|
||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||
import { useUploadsStore } from "@/stores/uploads";
|
||||
import type { SubmissionFile } from "@/types";
|
||||
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>()
|
||||
|
||||
// Pinia store
|
||||
const puzzlesStore = usePuzzlesStore();
|
||||
const { submissionFiles, processOCR } = useUploadsStore();
|
||||
const puzzlesStore = usePuzzlesStore()
|
||||
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const isDragOver = ref(false);
|
||||
const error = ref("");
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const isDragOver = ref(false)
|
||||
const error = ref('')
|
||||
const files = ref<SubmissionFile[]>([])
|
||||
|
||||
// Watch for external changes to modelValue
|
||||
watch(() => props.modelValue, (newFiles) => {
|
||||
files.value = newFiles
|
||||
}, { immediate: true })
|
||||
|
||||
// Watch for internal changes and emit
|
||||
watch(files, (newFiles) => {
|
||||
emit('update:modelValue', newFiles)
|
||||
}, { deep: true })
|
||||
|
||||
// Watch for puzzle changes and update OCR service
|
||||
watch(
|
||||
() => puzzlesStore.puzzles,
|
||||
(newPuzzles) => {
|
||||
watch(() => puzzlesStore.puzzles, (newPuzzles) => {
|
||||
if (newPuzzles && newPuzzles.length > 0) {
|
||||
ocrService.setAvailablePuzzleNames(puzzlesStore.puzzleNames);
|
||||
ocrService.setAvailablePuzzleNames(puzzlesStore.puzzleNames)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
}, { 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,
|
||||
};
|
||||
ocrData: undefined
|
||||
}
|
||||
|
||||
submissionFiles.push(submissionFile);
|
||||
files.value.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) => {
|
||||
submissionFiles.splice(index, 1);
|
||||
};
|
||||
files.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
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));
|
||||
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];
|
||||
};
|
||||
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
|
||||
};
|
||||
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)
|
||||
}
|
||||
|
||||
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 = submissionFiles.findIndex(
|
||||
(f) => f.file === submissionFile.file,
|
||||
);
|
||||
if (fileIndex === -1) return;
|
||||
const fileIndex = files.value.findIndex(f => f.file === submissionFile.file)
|
||||
if (fileIndex === -1) return
|
||||
|
||||
// Clear the manual selection requirement once user has selected
|
||||
if (submissionFiles[fileIndex].manualPuzzleSelection) {
|
||||
submissionFiles[fileIndex].needsManualPuzzleSelection = false;
|
||||
console.log(
|
||||
`Manual puzzle selection: ${submissionFile.file.name} -> ${submissionFiles[fileIndex].manualPuzzleSelection}`,
|
||||
);
|
||||
if (files.value[fileIndex].manualPuzzleSelection) {
|
||||
files.value[fileIndex].needsManualPuzzleSelection = false
|
||||
console.log(`Manual puzzle selection: ${submissionFile.file.name} -> ${files.value[fileIndex].manualPuzzleSelection}`)
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -1,35 +1,21 @@
|
||||
<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"
|
||||
>
|
||||
<p v-if="puzzle.description" class="text-sm text-base-content/80 mb-4 line-clamp-2">
|
||||
{{ puzzle.description }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="puzzle.tags && puzzle.tags.length > 0"
|
||||
class="flex flex-wrap gap-1 mb-4"
|
||||
>
|
||||
<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"
|
||||
@ -37,10 +23,7 @@
|
||||
>
|
||||
{{ 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>
|
||||
@ -62,9 +45,7 @@
|
||||
<!-- 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>
|
||||
@ -78,59 +59,32 @@
|
||||
</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>
|
||||
@ -142,31 +96,33 @@
|
||||
</div>
|
||||
|
||||
<!-- No responses state -->
|
||||
<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"
|
||||
>
|
||||
<div v-else class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg">
|
||||
<i class="mdi mdi-upload text-2xl text-base-content/40"></i>
|
||||
<p class="text-sm text-base-content/60 mt-2">No solutions yet</p>
|
||||
<p class="text-xs text-base-content/40">
|
||||
Upload solutions using the submit button
|
||||
</p>
|
||||
<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 { useSubmissionsStore } from "@/stores/submissions";
|
||||
import type { SteamCollectionItem, PuzzleResponse } from '@/types'
|
||||
|
||||
interface Props {
|
||||
puzzle: SteamCollectionItem;
|
||||
responses?: PuzzleResponse[];
|
||||
puzzle: SteamCollectionItem
|
||||
responses?: PuzzleResponse[]
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
defineProps<Props>()
|
||||
|
||||
const { openSubmissionModal } = useSubmissionsStore();
|
||||
// Utility functions removed - not used in template
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -8,54 +8,31 @@
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<!-- Detected Puzzles Summary -->
|
||||
<div
|
||||
v-if="Object.keys(responsesByPuzzle).length > 0"
|
||||
class="alert alert-info"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<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
|
||||
>
|
||||
<span class="badge badge-ghost badge-sm ml-2">{{ data.files.length }} file(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<FileUpload />
|
||||
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
|
||||
|
||||
<!-- Manual Selection Warning -->
|
||||
<div
|
||||
v-if="submissionFilesNeedingManualSelection.length > 0"
|
||||
class="alert alert-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">
|
||||
{{ submissionFilesNeedingManualSelection.length }} file(s) have
|
||||
low OCR confidence for puzzle names. Please select the correct
|
||||
puzzle for each file before submitting.
|
||||
{{ filesNeedingManualSelection.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>
|
||||
|
||||
@ -82,20 +59,13 @@
|
||||
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 80%.</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 50%.</em>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@ -103,15 +73,15 @@
|
||||
|
||||
<!-- 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="submissionFilesNeedingManualSelection.length > 0">
|
||||
Select Puzzles ({{ submissionFilesNeedingManualSelection.length }}
|
||||
remaining)
|
||||
<span v-else-if="filesNeedingManualSelection.length > 0">
|
||||
Select Puzzles ({{ filesNeedingManualSelection.length }} remaining)
|
||||
</span>
|
||||
<span v-else>Submit Solution</span>
|
||||
</button>
|
||||
@ -122,97 +92,104 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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";
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import FileUpload from '@/components/FileUpload.vue'
|
||||
import type { SteamCollectionItem, SubmissionFile } from '@/types'
|
||||
|
||||
interface Props {
|
||||
puzzles: SteamCollectionItem[];
|
||||
findPuzzleByName: (name: string) => SteamCollectionItem | null;
|
||||
puzzles: SteamCollectionItem[]
|
||||
findPuzzleByName: (name: string) => SteamCollectionItem | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
interface Emits {
|
||||
submit: [submissionData: { files: SubmissionFile[], notes?: string, manualValidationRequested?: boolean }]
|
||||
}
|
||||
|
||||
const uploadsStore = useUploadsStore();
|
||||
const {
|
||||
submissionFiles,
|
||||
hasLowConfidence,
|
||||
submissionFilesNeedingManualSelection,
|
||||
} = storeToRefs(uploadsStore);
|
||||
const { clearFiles, processLowConfidenceOCRFiles } = uploadsStore;
|
||||
const { handleSubmission } = useSubmissionsStore();
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const notes = ref("");
|
||||
const manualValidationRequested = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const submissionFiles = ref<SubmissionFile[]>([])
|
||||
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,
|
||||
);
|
||||
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;
|
||||
}
|
||||
});
|
||||
return hasFiles &&
|
||||
!isSubmitting.value &&
|
||||
noManualSelectionNeeded
|
||||
})
|
||||
|
||||
// Group files by detected puzzle
|
||||
const responsesByPuzzle = computed(() => {
|
||||
const grouped: Record<
|
||||
string,
|
||||
{ puzzle: SteamCollectionItem | null; files: SubmissionFile[] }
|
||||
> = {};
|
||||
const grouped: Record<string, { puzzle: SteamCollectionItem | null, files: SubmissionFile[] }> = {}
|
||||
|
||||
submissionFiles.value.forEach((file) => {
|
||||
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;
|
||||
});
|
||||
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 })
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit.value) return;
|
||||
if (!canSubmit.value) return
|
||||
|
||||
isSubmitting.value = true;
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
// Emit the files and notes for the store to handle API submission
|
||||
handleSubmission({
|
||||
// Emit the files and notes for the parent to handle API submission
|
||||
emit('submit', {
|
||||
files: submissionFiles.value,
|
||||
notes: notes.value.trim() || undefined,
|
||||
manualValidationRequested:
|
||||
hasLowConfidence.value || manualValidationRequested.value,
|
||||
});
|
||||
manualValidationRequested: manualValidationRequested.value
|
||||
})
|
||||
|
||||
// Reset form
|
||||
clearFiles();
|
||||
notes.value = "";
|
||||
manualValidationRequested.value = false;
|
||||
submissionFiles.value = []
|
||||
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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -3,7 +3,6 @@ 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
|
||||
@ -84,49 +83,6 @@ 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,
|
||||
@ -139,7 +95,6 @@ export const useSubmissionsStore = defineStore('submissions', () => {
|
||||
createSubmission,
|
||||
openSubmissionModal,
|
||||
closeSubmissionModal,
|
||||
refreshSubmissions,
|
||||
handleSubmission
|
||||
refreshSubmissions
|
||||
}
|
||||
})
|
||||
|
||||
@ -1,103 +0,0 @@
|
||||
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,7 +40,6 @@ export interface OpusMagnumData {
|
||||
|
||||
export interface SubmissionFile {
|
||||
file: File
|
||||
file_url: string
|
||||
preview: string
|
||||
type: 'image' | 'gif'
|
||||
ocrData?: OpusMagnumData
|
||||
@ -53,8 +52,7 @@ export interface SubmissionFile {
|
||||
|
||||
export interface PuzzleResponse {
|
||||
id?: number
|
||||
// puzzle: number | SteamCollectionItem
|
||||
puzzle: number
|
||||
puzzle: number | SteamCollectionItem
|
||||
puzzle_name: string
|
||||
cost?: string
|
||||
cycles?: string
|
||||
|
||||
21
opus_submitter/static_source/vite/assets/main-B14l8Jy0.js
Normal file
21
opus_submitter/static_source/vite/assets/main-B14l8Jy0.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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-NIi3b_aN.js",
|
||||
"file": "assets/main-B14l8Jy0.js",
|
||||
"name": "main",
|
||||
"src": "src/main.ts",
|
||||
"isEntry": true,
|
||||
"css": [
|
||||
"assets/main-CYuvChoP.css"
|
||||
"assets/main-COx9N9qO.css"
|
||||
],
|
||||
"assets": [
|
||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||
|
||||
@ -33,9 +33,11 @@ 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)
|
||||
return (
|
||||
Submission.objects.prefetch_related("responses__files", "responses__puzzle")
|
||||
.filter(user=request.user)
|
||||
.filter()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
||||
@ -68,19 +70,19 @@ def create_submission(
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Check if any confidence score is below 80% to auto-request validation
|
||||
# Check if any confidence score is below 50% to auto-request validation
|
||||
auto_request_validation = any(
|
||||
(
|
||||
response_data.ocr_confidence_cost is not None
|
||||
and response_data.ocr_confidence_cost < 0.8
|
||||
and response_data.ocr_confidence_cost < 0.5
|
||||
)
|
||||
or (
|
||||
response_data.ocr_confidence_cycles is not None
|
||||
and response_data.ocr_confidence_cycles < 0.8
|
||||
and response_data.ocr_confidence_cycles < 0.5
|
||||
)
|
||||
or (
|
||||
response_data.ocr_confidence_area is not None
|
||||
and response_data.ocr_confidence_area < 0.8
|
||||
and response_data.ocr_confidence_area < 0.5
|
||||
)
|
||||
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 SteamAPIKey, SteamCollection
|
||||
from submissions.models import SteamCollection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -12,6 +12,11 @@ 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",
|
||||
@ -20,23 +25,16 @@ 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.api_key)
|
||||
fetcher = SteamCollectionFetcher(api_key)
|
||||
collection_id = fetcher.extract_collection_id(url)
|
||||
|
||||
if collection_id and not force:
|
||||
|
||||
@ -497,15 +497,20 @@ def verify_and_validate_ocr_date_for_submission(file: SubmissionFile):
|
||||
)
|
||||
|
||||
valid_count = 0
|
||||
for index, field in enumerate(["cost", "cycles", "area"]):
|
||||
value = getattr(r, field, -1)
|
||||
|
||||
if value == ocr_data[index + 1]:
|
||||
setattr(r, f"validated_{field}", value)
|
||||
if r.cost == ocr_data[1]:
|
||||
r.validated_cost = r.cost
|
||||
valid_count += 1
|
||||
|
||||
else:
|
||||
setattr(r, field, ocr_data[index + 1])
|
||||
if r.cycles == ocr_data[2]:
|
||||
r.validated_cycles = r.cycles
|
||||
valid_count += 1
|
||||
|
||||
r.needs_manual_validation = valid_count != 3
|
||||
if r.area == ocr_data[3]:
|
||||
r.validated_area = r.area
|
||||
valid_count += 1
|
||||
|
||||
if valid_count == 3:
|
||||
r.needs_manual_validation = False
|
||||
|
||||
if valid_count:
|
||||
r.save()
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -6,10 +6,7 @@ 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)),
|
||||
@ -21,5 +18,5 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: { main: resolve('./src/main.ts') }
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
@ -7,10 +7,7 @@ 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)),
|
||||
@ -23,5 +20,6 @@ export default defineConfig({
|
||||
input:
|
||||
{ main: resolve('./src/main.ts') }
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -21,7 +21,6 @@ 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,18 +185,6 @@ 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"
|
||||
@ -516,7 +504,6 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "django-extensions" },
|
||||
{ name = "django-stubs" },
|
||||
{ name = "django-stubs-ext" },
|
||||
{ name = "django-types" },
|
||||
@ -543,7 +530,6 @@ 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