small ui fixes
This commit is contained in:
parent
596731a8a7
commit
012b72527b
@ -6,7 +6,19 @@ from submissions.schemas import UserInfoOut
|
|||||||
api = NinjaAPI(
|
api = NinjaAPI(
|
||||||
title="Opus Magnum Submission API",
|
title="Opus Magnum Submission API",
|
||||||
version="1.0.0",
|
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
|
# Add authentication for protected endpoints
|
||||||
@ -33,8 +45,7 @@ def api_info(request):
|
|||||||
"description": "API for managing puzzle submissions with OCR validation",
|
"description": "API for managing puzzle submissions with OCR validation",
|
||||||
"features": [
|
"features": [
|
||||||
"Multi-puzzle submissions",
|
"Multi-puzzle submissions",
|
||||||
"File upload to S3",
|
"OCR validation",
|
||||||
"OCR validation tracking",
|
|
||||||
"Manual validation workflow",
|
"Manual validation workflow",
|
||||||
"Admin validation tools",
|
"Admin validation tools",
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,151 +1,154 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, defineProps } from 'vue'
|
import { ref, onMounted, computed, defineProps } from "vue";
|
||||||
import PuzzleCard from '@/components/PuzzleCard.vue'
|
import PuzzleCard from "@/components/PuzzleCard.vue";
|
||||||
import SubmissionForm from '@/components/SubmissionForm.vue'
|
import SubmissionForm from "@/components/SubmissionForm.vue";
|
||||||
import AdminPanel from '@/components/AdminPanel.vue'
|
import AdminPanel from "@/components/AdminPanel.vue";
|
||||||
import { apiService, errorHelpers } from '@/services/apiService'
|
import { apiService, errorHelpers } from "@/services/apiService";
|
||||||
import { usePuzzlesStore } from '@/stores/puzzles'
|
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||||
import { useSubmissionsStore } from '@/stores/submissions'
|
import { useSubmissionsStore } from "@/stores/submissions";
|
||||||
import type { SteamCollection, PuzzleResponse, UserInfo } from '@/types'
|
import type { PuzzleResponse, UserInfo } from "@/types";
|
||||||
import { useCountdown } from '@vueuse/core'
|
import { useCountdown } from "@vueuse/core";
|
||||||
|
|
||||||
const props = defineProps<{collectionTitle: string, collectionUrl: string, collectionDescription: string}>()
|
const props = defineProps<{
|
||||||
|
collectionTitle: string;
|
||||||
|
collectionUrl: string;
|
||||||
|
collectionDescription: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
// Pinia stores
|
// Pinia stores
|
||||||
const puzzlesStore = usePuzzlesStore()
|
const puzzlesStore = usePuzzlesStore();
|
||||||
const submissionsStore = useSubmissionsStore()
|
const submissionsStore = useSubmissionsStore();
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const userInfo = ref<UserInfo | null>(null)
|
const userInfo = ref<UserInfo | null>(null);
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true);
|
||||||
const error = ref<string>('')
|
const error = ref<string>("");
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const isSuperuser = computed(() => {
|
const isSuperuser = computed(() => {
|
||||||
return userInfo.value?.is_superuser || false
|
return userInfo.value?.is_superuser || false;
|
||||||
})
|
});
|
||||||
|
|
||||||
// Computed property to get responses grouped by puzzle
|
// Computed property to get responses grouped by puzzle
|
||||||
const responsesByPuzzle = computed(() => {
|
const responsesByPuzzle = computed(() => {
|
||||||
const grouped: Record<number, PuzzleResponse[]> = {}
|
const grouped: Record<number, PuzzleResponse[]> = {};
|
||||||
submissionsStore.submissions.forEach(submission => {
|
submissionsStore.submissions.forEach((submission) => {
|
||||||
submission.responses.forEach(response => {
|
submission.responses.forEach((response) => {
|
||||||
// Handle both number and object types for puzzle field
|
// Handle both number and object types for puzzle field
|
||||||
const puzzleId = typeof response.puzzle === 'number' ? response.puzzle : response.puzzle.id
|
if (!grouped[response.puzzle]) {
|
||||||
if (!grouped[puzzleId]) {
|
grouped[response.puzzle] = [];
|
||||||
grouped[puzzleId] = []
|
|
||||||
}
|
}
|
||||||
grouped[puzzleId].push(response)
|
grouped[response.puzzle].push(response);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
return grouped
|
return grouped;
|
||||||
})
|
});
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true;
|
||||||
error.value = ''
|
error.value = "";
|
||||||
|
|
||||||
console.log('Starting data load...')
|
console.log("Starting data load...");
|
||||||
|
|
||||||
// Load user info
|
// Load user info
|
||||||
console.log('Loading user info...')
|
console.log("Loading user info...");
|
||||||
const userResponse = await apiService.getUserInfo()
|
const userResponse = await apiService.getUserInfo();
|
||||||
if (userResponse.data) {
|
if (userResponse.data) {
|
||||||
userInfo.value = userResponse.data
|
userInfo.value = userResponse.data;
|
||||||
console.log('User info loaded:', userResponse.data)
|
console.log("User info loaded:", userResponse.data);
|
||||||
} else if (userResponse.error) {
|
} else if (userResponse.error) {
|
||||||
console.warn('User info error:', userResponse.error)
|
console.warn("User info error:", userResponse.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load puzzles from API using store
|
// Load puzzles from API using store
|
||||||
console.log('Loading puzzles...')
|
console.log("Loading puzzles...");
|
||||||
await puzzlesStore.loadPuzzles()
|
await puzzlesStore.loadPuzzles();
|
||||||
console.log('Puzzles loaded:', puzzlesStore.puzzles.length)
|
console.log("Puzzles loaded:", puzzlesStore.puzzles.length);
|
||||||
|
|
||||||
// Load existing submissions using store
|
// Load existing submissions using store
|
||||||
console.log('Loading submissions...')
|
console.log("Loading submissions...");
|
||||||
await submissionsStore.loadSubmissions()
|
await submissionsStore.loadSubmissions();
|
||||||
console.log('Submissions loaded:', submissionsStore.submissions.length)
|
console.log("Submissions loaded:", submissionsStore.submissions.length);
|
||||||
|
|
||||||
console.log('Data load complete!')
|
|
||||||
|
|
||||||
|
console.log("Data load complete!");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = errorHelpers.getErrorMessage(err)
|
error.value = errorHelpers.getErrorMessage(err);
|
||||||
console.error('Failed to load data:', err)
|
console.error("Failed to load data:", err);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
console.log('Loading state set to false')
|
console.log("Loading state set to false");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userInfo.value.is_superuser) {
|
if (userInfo.value?.is_superuser) {
|
||||||
start()
|
start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { remaining, start } = useCountdown(60, {
|
const { remaining, start } = useCountdown(60, {
|
||||||
onComplete() {
|
onComplete() {
|
||||||
initialize()
|
initialize();
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await initialize()
|
await initialize();
|
||||||
})
|
});
|
||||||
|
|
||||||
const handleSubmission = async (submissionData: {
|
const handleSubmission = async (submissionData: {
|
||||||
files: any[],
|
files: any[];
|
||||||
notes?: string,
|
notes?: string;
|
||||||
manualValidationRequested?: boolean
|
manualValidationRequested?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true;
|
||||||
error.value = ''
|
error.value = "";
|
||||||
|
|
||||||
// Create submission via store
|
// Create submission via store
|
||||||
const submission = await submissionsStore.createSubmission(
|
const submission = await submissionsStore.createSubmission(
|
||||||
submissionData.files,
|
submissionData.files,
|
||||||
submissionData.notes,
|
submissionData.notes,
|
||||||
submissionData.manualValidationRequested
|
submissionData.manualValidationRequested,
|
||||||
)
|
);
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
if (submission) {
|
if (submission) {
|
||||||
const puzzleNames = submission.responses.map(r => r.puzzle_name).join(', ')
|
const puzzleNames = submission.responses
|
||||||
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
|
.map((r) => r.puzzle_name)
|
||||||
|
.join(", ");
|
||||||
|
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`);
|
||||||
} else {
|
} else {
|
||||||
alert('Submission created successfully!')
|
alert("Submission created successfully!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal
|
// Close modal
|
||||||
submissionsStore.closeSubmissionModal()
|
submissionsStore.closeSubmissionModal();
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = errorHelpers.getErrorMessage(err)
|
const errorMessage = errorHelpers.getErrorMessage(err);
|
||||||
error.value = errorMessage
|
error.value = errorMessage;
|
||||||
alert(`Submission failed: ${errorMessage}`)
|
alert(`Submission failed: ${errorMessage}`);
|
||||||
console.error('Submission error:', err)
|
console.error("Submission error:", err);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const openSubmissionModal = () => {
|
const openSubmissionModal = () => {
|
||||||
submissionsStore.openSubmissionModal()
|
submissionsStore.openSubmissionModal();
|
||||||
}
|
};
|
||||||
|
|
||||||
const closeSubmissionModal = () => {
|
const closeSubmissionModal = () => {
|
||||||
submissionsStore.closeSubmissionModal()
|
submissionsStore.closeSubmissionModal();
|
||||||
}
|
};
|
||||||
|
|
||||||
// Function to match puzzle name from OCR to actual puzzle
|
// Function to match puzzle name from OCR to actual puzzle
|
||||||
const findPuzzleByName = (ocrPuzzleName: string) => {
|
const findPuzzleByName = (ocrPuzzleName: string) => {
|
||||||
return puzzlesStore.findPuzzleByName(ocrPuzzleName)
|
return puzzlesStore.findPuzzleByName(ocrPuzzleName);
|
||||||
}
|
};
|
||||||
|
|
||||||
const reloadPage = () => {
|
const reloadPage = () => {
|
||||||
window.location.reload()
|
window.location.reload();
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -157,19 +160,22 @@ const reloadPage = () => {
|
|||||||
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start justify-between">
|
<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">
|
<div class="text-sm">
|
||||||
<span class="font-medium">{{ userInfo.username }}</span>
|
<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>
|
</div>
|
||||||
<div v-else class="text-sm text-base-content/70">
|
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
||||||
Not logged in
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-end gap-2">
|
<div class="flex flex-col items-end gap-2">
|
||||||
<a href="/admin" class="btn btn-xs btn-warning">
|
<a href="/admin" class="btn btn-xs btn-warning"> Admin panel </a>
|
||||||
Admin django
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -187,7 +193,10 @@ const reloadPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="text-center">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
||||||
@ -214,12 +223,11 @@ const reloadPage = () => {
|
|||||||
<div class="card bg-base-100 shadow-lg">
|
<div class="card bg-base-100 shadow-lg">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2>
|
<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">
|
<div class="flex flex-wrap gap-4 mt-4">
|
||||||
<button
|
<button @click="openSubmissionModal" class="btn btn-primary">
|
||||||
@click="openSubmissionModal"
|
|
||||||
class="btn btn-primary"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-plus mr-2"></i>
|
<i class="mdi mdi-plus mr-2"></i>
|
||||||
Submit Solution
|
Submit Solution
|
||||||
</button>
|
</button>
|
||||||
@ -247,14 +255,16 @@ const reloadPage = () => {
|
|||||||
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
|
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
|
||||||
<div class="text-6xl mb-4">🧩</div>
|
<div class="text-6xl mb-4">🧩</div>
|
||||||
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submission Modal -->
|
<!-- Submission Modal -->
|
||||||
<div v-if="submissionsStore.isSubmissionModalOpen" class="modal modal-open">
|
<div v-if="submissionsStore.isSubmissionModalOpen" class="modal modal-open">
|
||||||
<div class="modal-box max-w-4xl">
|
<div class="modal-box max-w-6xl">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 class="font-bold text-lg">Submit Solution</h3>
|
<h3 class="font-bold text-lg">Submit Solution</h3>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -10,19 +10,27 @@
|
|||||||
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6">
|
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">Total Submissions</div>
|
<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>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">Total Responses</div>
|
<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>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">Need Validation</div>
|
<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>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">Validation Rate</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -46,39 +54,50 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="response in responsesNeedingValidation" :key="response.id">
|
<tr
|
||||||
|
v-for="response in responsesNeedingValidation"
|
||||||
|
:key="response.id"
|
||||||
|
>
|
||||||
<td>
|
<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>
|
<div class="text-sm opacity-50">ID: {{ response.id }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="text-sm space-y-1">
|
<div class="text-sm space-y-1">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span>Cost: {{ response.cost || '-' }}</span>
|
<span>Cost: {{ response.cost || "-" }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="response.ocr_confidence_cost"
|
v-if="response.ocr_confidence_cost"
|
||||||
class="badge badge-xs"
|
class="badge badge-xs"
|
||||||
:class="getConfidenceBadgeClass(response.ocr_confidence_cost)"
|
:class="
|
||||||
|
getConfidenceBadgeClass(response.ocr_confidence_cost)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ Math.round(response.ocr_confidence_cost * 100) }}%
|
{{ Math.round(response.ocr_confidence_cost * 100) }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span>Cycles: {{ response.cycles || '-' }}</span>
|
<span>Cycles: {{ response.cycles || "-" }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="response.ocr_confidence_cycles"
|
v-if="response.ocr_confidence_cycles"
|
||||||
class="badge badge-xs"
|
class="badge badge-xs"
|
||||||
:class="getConfidenceBadgeClass(response.ocr_confidence_cycles)"
|
:class="
|
||||||
|
getConfidenceBadgeClass(
|
||||||
|
response.ocr_confidence_cycles,
|
||||||
|
)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ Math.round(response.ocr_confidence_cycles * 100) }}%
|
{{ Math.round(response.ocr_confidence_cycles * 100) }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span>Area: {{ response.area || '-' }}</span>
|
<span>Area: {{ response.area || "-" }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="response.ocr_confidence_area"
|
v-if="response.ocr_confidence_area"
|
||||||
class="badge badge-xs"
|
class="badge badge-xs"
|
||||||
:class="getConfidenceBadgeClass(response.ocr_confidence_area)"
|
:class="
|
||||||
|
getConfidenceBadgeClass(response.ocr_confidence_area)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ Math.round(response.ocr_confidence_area * 100) }}%
|
{{ Math.round(response.ocr_confidence_area * 100) }}%
|
||||||
</span>
|
</span>
|
||||||
@ -108,7 +127,9 @@
|
|||||||
<div v-else class="text-center py-8">
|
<div v-else class="text-center py-8">
|
||||||
<i class="mdi mdi-check-all text-6xl text-success opacity-50"></i>
|
<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-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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -118,21 +139,22 @@
|
|||||||
<div class="modal-box w-11/12 max-w-5xl">
|
<div class="modal-box w-11/12 max-w-5xl">
|
||||||
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
|
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
|
||||||
|
|
||||||
<div v-for="file in validationModal.response.files">
|
<div v-for="file in validationModal.response?.files ?? []">
|
||||||
<img :src="file.file_url">
|
<img :src="file.file_url" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="validationModal.response" class="space-y-4">
|
<div v-if="validationModal.response" class="space-y-4">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<i class="mdi mdi-information-outline"></i>
|
<i class="mdi mdi-information-outline"></i>
|
||||||
<div>
|
<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 class="text-sm">Review and correct the OCR data below</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-4 gap-4">
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Puzzle</span>
|
<span class="label-text">Puzzle</span>
|
||||||
@ -161,7 +183,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:placeholder="validationModal.response.cost || 'Enter cost'"
|
:placeholder="validationModal.response.cost || 'Enter cost'"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
@ -173,7 +195,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:placeholder="validationModal.response.cycles || 'Enter cycles'"
|
:placeholder="validationModal.response.cycles || 'Enter cycles'"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
@ -185,19 +207,24 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:placeholder="validationModal.response.area || 'Enter area'"
|
:placeholder="validationModal.response.area || 'Enter area'"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button @click="closeValidationModal" class="btn btn-ghost">Cancel</button>
|
<button @click="closeValidationModal" class="btn btn-ghost">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="submitValidation"
|
@click="submitValidation"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
:disabled="isValidating"
|
:disabled="isValidating"
|
||||||
>
|
>
|
||||||
<span v-if="isValidating" class="loading loading-spinner loading-sm"></span>
|
<span
|
||||||
{{ isValidating ? 'Validating...' : 'Validate' }}
|
v-if="isValidating"
|
||||||
|
class="loading loading-spinner loading-sm"
|
||||||
|
></span>
|
||||||
|
{{ isValidating ? "Validating..." : "Validate" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -207,11 +234,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from "vue";
|
||||||
import { apiService } from '@/services/apiService'
|
import { apiService } from "@/services/apiService";
|
||||||
import type { PuzzleResponse } from '@/types'
|
import type { PuzzleResponse } from "@/types";
|
||||||
import {usePuzzlesStore} from '@/stores/puzzles'
|
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||||
const puzzlesStore = usePuzzlesStore()
|
const puzzlesStore = usePuzzlesStore();
|
||||||
|
|
||||||
// Reactive data
|
// Reactive data
|
||||||
const stats = ref({
|
const stats = ref({
|
||||||
@ -219,160 +246,163 @@ const stats = ref({
|
|||||||
total_responses: 0,
|
total_responses: 0,
|
||||||
needs_validation: 0,
|
needs_validation: 0,
|
||||||
validated_submissions: 0,
|
validated_submissions: 0,
|
||||||
validation_rate: 0
|
validation_rate: 0,
|
||||||
})
|
});
|
||||||
|
|
||||||
const responsesNeedingValidation = ref<PuzzleResponse[]>([])
|
const responsesNeedingValidation = ref<PuzzleResponse[]>([]);
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false);
|
||||||
const isValidating = ref(false)
|
const isValidating = ref(false);
|
||||||
|
|
||||||
const validationModal = ref({
|
const validationModal = ref({
|
||||||
show: false,
|
show: false,
|
||||||
response: null as PuzzleResponse | null,
|
response: null as PuzzleResponse | null,
|
||||||
data: {
|
data: {
|
||||||
puzzle_title: '',
|
puzzle: -1,
|
||||||
validated_cost: '',
|
validated_cost: "",
|
||||||
validated_cycles: '',
|
validated_cycles: "",
|
||||||
validated_area: ''
|
validated_area: "",
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true;
|
||||||
|
|
||||||
// Load stats (skip if endpoint doesn't exist)
|
// Load stats (skip if endpoint doesn't exist)
|
||||||
try {
|
try {
|
||||||
const statsResponse = await apiService.getStats()
|
const statsResponse = await apiService.getStats();
|
||||||
if (statsResponse.data) {
|
if (statsResponse.data) {
|
||||||
stats.value = statsResponse.data
|
stats.value = statsResponse.data;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Stats endpoint not available:', error)
|
console.warn("Stats endpoint not available:", error);
|
||||||
// Set default stats
|
// Set default stats
|
||||||
stats.value = {
|
stats.value = {
|
||||||
total_submissions: 0,
|
total_submissions: 0,
|
||||||
total_responses: 0,
|
total_responses: 0,
|
||||||
needs_validation: 0,
|
needs_validation: 0,
|
||||||
validated_submissions: 0,
|
validated_submissions: 0,
|
||||||
validation_rate: 0
|
validation_rate: 0,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load responses needing validation
|
// Load responses needing validation
|
||||||
const responsesResponse = await apiService.getResponsesNeedingValidation()
|
const responsesResponse = await apiService.getResponsesNeedingValidation();
|
||||||
if (responsesResponse.data) {
|
if (responsesResponse.data) {
|
||||||
responsesNeedingValidation.value = responsesResponse.data
|
responsesNeedingValidation.value = responsesResponse.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load admin data:', error)
|
console.error("Failed to load admin data:", error);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const autoValidationResponse = async () => {
|
const autoValidationResponse = async () => {
|
||||||
for (const response of Array.from(responsesNeedingValidation.value)) {
|
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) {
|
if (data && !data.needs_manual_validation) {
|
||||||
// Remove from validation list
|
// Remove from validation list
|
||||||
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
responsesNeedingValidation.value =
|
||||||
r => r.id !== response.id
|
responsesNeedingValidation.value.filter((r) => r.id !== response.id);
|
||||||
)
|
stats.value.needs_validation -= 1;
|
||||||
stats.value.needs_validation -= 1
|
|
||||||
|
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const openValidationModal = (response: PuzzleResponse) => {
|
const openValidationModal = (response: PuzzleResponse) => {
|
||||||
validationModal.value.response = response
|
validationModal.value.response = response;
|
||||||
validationModal.value.data = {
|
validationModal.value.data = {
|
||||||
puzzle: response.puzzle || '',
|
puzzle: response.puzzle || -1,
|
||||||
validated_cost: response.cost || '',
|
validated_cost: response.cost || "",
|
||||||
validated_cycles: response.cycles || '',
|
validated_cycles: response.cycles || "",
|
||||||
validated_area: response.area || ''
|
validated_area: response.area || "",
|
||||||
}
|
};
|
||||||
validationModal.value.show = true
|
validationModal.value.show = true;
|
||||||
}
|
};
|
||||||
|
|
||||||
const closeValidationModal = () => {
|
const closeValidationModal = () => {
|
||||||
validationModal.value.show = false
|
validationModal.value.show = false;
|
||||||
validationModal.value.response = null
|
validationModal.value.response = null;
|
||||||
validationModal.value.data = {
|
validationModal.value.data = {
|
||||||
puzzle: '',
|
puzzle: -1,
|
||||||
validated_cost: '',
|
validated_cost: "",
|
||||||
validated_cycles: '',
|
validated_cycles: "",
|
||||||
validated_area: ''
|
validated_area: "",
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
const submitValidation = async () => {
|
const submitValidation = async () => {
|
||||||
if (!validationModal.value.response?.id) return
|
if (!validationModal.value.response?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isValidating.value = true
|
isValidating.value = true;
|
||||||
|
|
||||||
const response = await apiService.validateResponse(
|
const response = await apiService.validateResponse(
|
||||||
validationModal.value.response.id,
|
validationModal.value.response.id,
|
||||||
validationModal.value.data
|
validationModal.value.data,
|
||||||
)
|
);
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
alert(`Validation failed: ${response.error}`)
|
alert(`Validation failed: ${response.error}`);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from validation list
|
// Remove from validation list
|
||||||
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
||||||
r => r.id !== validationModal.value.response?.id
|
(r) => r.id !== validationModal.value.response?.id,
|
||||||
)
|
);
|
||||||
|
|
||||||
// Update stats
|
// Update stats
|
||||||
stats.value.needs_validation = Math.max(0, stats.value.needs_validation - 1)
|
stats.value.needs_validation = Math.max(
|
||||||
|
0,
|
||||||
closeValidationModal()
|
stats.value.needs_validation - 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
closeValidationModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Validation error:', error)
|
console.error("Validation error:", error);
|
||||||
alert('Validation failed')
|
alert("Validation failed");
|
||||||
} finally {
|
} finally {
|
||||||
isValidating.value = false
|
isValidating.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData()
|
loadData();
|
||||||
})
|
});
|
||||||
|
|
||||||
// Helper functions for confidence display
|
// Helper functions for confidence display
|
||||||
const getConfidenceBadgeClass = (confidence: number): string => {
|
const getConfidenceBadgeClass = (confidence: number): string => {
|
||||||
if (confidence >= 0.8) return 'badge-success'
|
if (confidence >= 0.8) return "badge-success";
|
||||||
if (confidence >= 0.6) return 'badge-warning'
|
if (confidence >= 0.6) return "badge-warning";
|
||||||
return 'badge-error'
|
return "badge-error";
|
||||||
}
|
};
|
||||||
|
|
||||||
const getOverallConfidence = (response: PuzzleResponse): number => {
|
const getOverallConfidence = (response: PuzzleResponse): number => {
|
||||||
const confidences = [
|
const confidences = [
|
||||||
response.ocr_confidence_cost,
|
response.ocr_confidence_cost,
|
||||||
response.ocr_confidence_cycles,
|
response.ocr_confidence_cycles,
|
||||||
response.ocr_confidence_area
|
response.ocr_confidence_area,
|
||||||
].filter(conf => conf !== undefined && conf !== null) as number[]
|
].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
|
const average =
|
||||||
return Math.round(average * 100)
|
confidences.reduce((sum, conf) => sum + conf, 0) / confidences.length;
|
||||||
}
|
return Math.round(average * 100);
|
||||||
|
};
|
||||||
|
|
||||||
// Expose refresh method
|
// Expose refresh method
|
||||||
defineExpose({
|
defineExpose({
|
||||||
refresh: loadData
|
refresh: loadData,
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -20,10 +20,12 @@
|
|||||||
accept="image/*,.gif"
|
accept="image/*,.gif"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
@change="handleFileSelect"
|
@change="handleFileSelect"
|
||||||
>
|
/>
|
||||||
|
|
||||||
<div v-if="files.length === 0" class="space-y-4">
|
<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
|
||||||
|
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>
|
<i class="mdi mdi-cloud-upload text-5xl"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -42,7 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-4">
|
||||||
<div
|
<div
|
||||||
v-for="(file, index) in files"
|
v-for="(file, index) in files"
|
||||||
:key="index"
|
:key="index"
|
||||||
@ -53,13 +55,15 @@
|
|||||||
:src="file.preview"
|
:src="file.preview"
|
||||||
:alt="file.file.name"
|
:alt="file.file.name"
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
>
|
/>
|
||||||
</div>
|
</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
|
<button
|
||||||
@click="removeFile(index)"
|
@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>
|
<i class="mdi mdi-close"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -68,11 +72,15 @@
|
|||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<p class="text-xs font-medium truncate">{{ file.file.name }}</p>
|
<p class="text-xs font-medium truncate">{{ file.file.name }}</p>
|
||||||
<p class="text-xs text-base-content/60">
|
<p class="text-xs text-base-content/60">
|
||||||
{{ formatFileSize(file.file.size) }} • {{ file.type.toUpperCase() }}
|
{{ formatFileSize(file.file.size) }} •
|
||||||
|
{{ file.type.toUpperCase() }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- OCR Status and Results -->
|
<!-- 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="loading loading-spinner loading-xs"></span>
|
||||||
<span class="text-xs text-info">Extracting puzzle data...</span>
|
<span class="text-xs text-info">Extracting puzzle data...</span>
|
||||||
</div>
|
</div>
|
||||||
@ -88,7 +96,9 @@
|
|||||||
<span
|
<span
|
||||||
v-if="file.ocrData.confidence"
|
v-if="file.ocrData.confidence"
|
||||||
class="badge badge-xs"
|
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)}%`"
|
:title="`Overall confidence: ${Math.round(file.ocrData.confidence.overall * 100)}%`"
|
||||||
>
|
>
|
||||||
{{ Math.round(file.ocrData.confidence.overall * 100) }}%
|
{{ Math.round(file.ocrData.confidence.overall * 100) }}%
|
||||||
@ -152,7 +162,9 @@
|
|||||||
<i class="mdi mdi-alert-circle text-lg"></i>
|
<i class="mdi mdi-alert-circle text-lg"></i>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-medium">Low OCR Confidence</div>
|
<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>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
@ -174,7 +186,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manual OCR trigger for non-auto detected files -->
|
<!-- 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
|
<button
|
||||||
@click="processOCR(file)"
|
@click="processOCR(file)"
|
||||||
class="btn btn-xs btn-outline"
|
class="btn btn-xs btn-outline"
|
||||||
@ -206,199 +223,220 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick } from 'vue'
|
import { ref, watch, nextTick } from "vue";
|
||||||
import { ocrService } from '@/services/ocrService'
|
import { ocrService } from "@/services/ocrService";
|
||||||
import { usePuzzlesStore } from '@/stores/puzzles'
|
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||||
import type { SubmissionFile, SteamCollectionItem } from '@/types'
|
import type { SubmissionFile, SteamCollectionItem } from "@/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: SubmissionFile[]
|
modelValue: SubmissionFile[];
|
||||||
puzzles?: SteamCollectionItem[]
|
puzzles?: SteamCollectionItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
'update:modelValue': [files: SubmissionFile[]]
|
"update:modelValue": [files: SubmissionFile[]];
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>();
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
// Pinia store
|
// Pinia store
|
||||||
const puzzlesStore = usePuzzlesStore()
|
const puzzlesStore = usePuzzlesStore();
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement>()
|
const fileInput = ref<HTMLInputElement>();
|
||||||
const isDragOver = ref(false)
|
const isDragOver = ref(false);
|
||||||
const error = ref('')
|
const error = ref("");
|
||||||
const files = ref<SubmissionFile[]>([])
|
const files = ref<SubmissionFile[]>([]);
|
||||||
|
|
||||||
// Watch for external changes to modelValue
|
// Watch for external changes to modelValue
|
||||||
watch(() => props.modelValue, (newFiles) => {
|
watch(
|
||||||
files.value = newFiles
|
() => props.modelValue,
|
||||||
}, { immediate: true })
|
(newFiles) => {
|
||||||
|
files.value = newFiles;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
// Watch for internal changes and emit
|
// Watch for internal changes and emit
|
||||||
watch(files, (newFiles) => {
|
watch(
|
||||||
emit('update:modelValue', newFiles)
|
files,
|
||||||
}, { deep: true })
|
(newFiles) => {
|
||||||
|
emit("update:modelValue", newFiles);
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
// Watch for puzzle changes and update OCR service
|
// Watch for puzzle changes and update OCR service
|
||||||
watch(() => puzzlesStore.puzzles, (newPuzzles) => {
|
watch(
|
||||||
|
() => puzzlesStore.puzzles,
|
||||||
|
(newPuzzles) => {
|
||||||
if (newPuzzles && newPuzzles.length > 0) {
|
if (newPuzzles && newPuzzles.length > 0) {
|
||||||
ocrService.setAvailablePuzzleNames(puzzlesStore.puzzleNames)
|
ocrService.setAvailablePuzzleNames(puzzlesStore.puzzleNames);
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
const handleFileSelect = (event: Event) => {
|
const handleFileSelect = (event: Event) => {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement;
|
||||||
if (target.files) {
|
if (target.files) {
|
||||||
processFiles(Array.from(target.files))
|
processFiles(Array.from(target.files));
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDrop = (event: DragEvent) => {
|
const handleDrop = (event: DragEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
isDragOver.value = false
|
isDragOver.value = false;
|
||||||
|
|
||||||
if (event.dataTransfer?.files) {
|
if (event.dataTransfer?.files) {
|
||||||
processFiles(Array.from(event.dataTransfer.files))
|
processFiles(Array.from(event.dataTransfer.files));
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const processFiles = async (newFiles: File[]) => {
|
const processFiles = async (newFiles: File[]) => {
|
||||||
error.value = ''
|
error.value = "";
|
||||||
|
|
||||||
for (const file of newFiles) {
|
for (const file of newFiles) {
|
||||||
if (!isValidFile(file)) {
|
if (!isValidFile(file)) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const preview = await createPreview(file)
|
const preview = await createPreview(file);
|
||||||
const fileType = file.type.startsWith('image/gif') ? 'gif' : 'image'
|
const fileType = file.type.startsWith("image/gif") ? "gif" : "image";
|
||||||
|
|
||||||
const submissionFile: SubmissionFile = {
|
const submissionFile: SubmissionFile = {
|
||||||
file,
|
file,
|
||||||
|
file_url: "",
|
||||||
preview,
|
preview,
|
||||||
type: fileType,
|
type: fileType,
|
||||||
ocrProcessing: false,
|
ocrProcessing: false,
|
||||||
ocrError: undefined,
|
ocrError: undefined,
|
||||||
ocrData: undefined
|
ocrData: undefined,
|
||||||
}
|
};
|
||||||
|
|
||||||
files.value.push(submissionFile)
|
files.value.push(submissionFile);
|
||||||
|
|
||||||
// Start OCR processing for Opus Magnum images (with delay to ensure reactivity)
|
// Start OCR processing for Opus Magnum images (with delay to ensure reactivity)
|
||||||
if (isOpusMagnumImage(file)) {
|
if (isOpusMagnumImage(file)) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
processOCR(submissionFile)
|
processOCR(submissionFile);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = `Failed to process ${file.name}`
|
error.value = `Failed to process ${file.name}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const isValidFile = (file: File): boolean => {
|
const isValidFile = (file: File): boolean => {
|
||||||
// Check file type
|
// Check file type
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith("image/")) {
|
||||||
error.value = `${file.name} is not a valid image file`
|
error.value = `${file.name} is not a valid image file`;
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check file size (256MB limit)
|
// Check file size (256MB limit)
|
||||||
if (file.size > 256 * 1024 * 1024) {
|
if (file.size > 256 * 1024 * 1024) {
|
||||||
error.value = `${file.name} is too large (max 256MB)`
|
error.value = `${file.name} is too large (max 256MB)`;
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
}
|
};
|
||||||
|
|
||||||
const createPreview = (file: File): Promise<string> => {
|
const createPreview = (file: File): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => resolve(e.target?.result as string)
|
reader.onload = (e) => resolve(e.target?.result as string);
|
||||||
reader.onerror = reject
|
reader.onerror = reject;
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const removeFile = (index: number) => {
|
const removeFile = (index: number) => {
|
||||||
files.value.splice(index, 1)
|
files.value.splice(index, 1);
|
||||||
}
|
};
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
const formatFileSize = (bytes: number): string => {
|
||||||
if (bytes === 0) return '0 Bytes'
|
if (bytes === 0) return "0 Bytes";
|
||||||
|
|
||||||
const k = 1024
|
const k = 1024;
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
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 => {
|
const isOpusMagnumImage = (file: File): boolean => {
|
||||||
// Basic heuristic - could be enhanced with actual image analysis
|
// 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) => {
|
const processOCR = async (submissionFile: SubmissionFile) => {
|
||||||
// Find the file in the reactive array to ensure proper reactivity
|
// Find the file in the reactive array to ensure proper reactivity
|
||||||
const fileIndex = files.value.findIndex(f => f.file === submissionFile.file)
|
const fileIndex = files.value.findIndex(
|
||||||
if (fileIndex === -1) return
|
(f) => f.file === submissionFile.file,
|
||||||
|
);
|
||||||
|
if (fileIndex === -1) return;
|
||||||
|
|
||||||
// Update the reactive array directly
|
// Update the reactive array directly
|
||||||
files.value[fileIndex].ocrProcessing = true
|
files.value[fileIndex].ocrProcessing = true;
|
||||||
files.value[fileIndex].ocrError = undefined
|
files.value[fileIndex].ocrError = undefined;
|
||||||
files.value[fileIndex].ocrData = undefined
|
files.value[fileIndex].ocrData = undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Starting OCR processing for:', submissionFile.file.name)
|
console.log("Starting OCR processing for:", submissionFile.file.name);
|
||||||
await ocrService.initialize()
|
await ocrService.initialize();
|
||||||
const ocrData = await ocrService.extractOpusMagnumData(submissionFile.file)
|
const ocrData = await ocrService.extractOpusMagnumData(submissionFile.file);
|
||||||
console.log('OCR completed:', ocrData)
|
console.log("OCR completed:", ocrData);
|
||||||
|
|
||||||
// Force reactivity update
|
// Force reactivity update
|
||||||
await nextTick()
|
await nextTick();
|
||||||
files.value[fileIndex].ocrData = ocrData
|
files.value[fileIndex].ocrData = ocrData;
|
||||||
|
|
||||||
// Check if puzzle confidence is below 80% and needs manual selection
|
// Check if puzzle confidence is below 80% and needs manual selection
|
||||||
if (ocrData.confidence.puzzle < 0.8) {
|
if (ocrData.confidence.puzzle < 0.8) {
|
||||||
files.value[fileIndex].needsManualPuzzleSelection = true
|
files.value[fileIndex].needsManualPuzzleSelection = true;
|
||||||
console.log(`Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`)
|
console.log(
|
||||||
|
`Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
files.value[fileIndex].needsManualPuzzleSelection = false
|
files.value[fileIndex].needsManualPuzzleSelection = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await nextTick()
|
await nextTick();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('OCR processing failed:', error)
|
console.error("OCR processing failed:", error);
|
||||||
files.value[fileIndex].ocrError = 'Failed to extract puzzle data'
|
files.value[fileIndex].ocrError = "Failed to extract puzzle data";
|
||||||
} finally {
|
} finally {
|
||||||
files.value[fileIndex].ocrProcessing = false
|
files.value[fileIndex].ocrProcessing = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const retryOCR = (submissionFile: SubmissionFile) => {
|
const retryOCR = (submissionFile: SubmissionFile) => {
|
||||||
processOCR(submissionFile)
|
processOCR(submissionFile);
|
||||||
}
|
};
|
||||||
|
|
||||||
const getConfidenceBadgeClass = (confidence: number): string => {
|
const getConfidenceBadgeClass = (confidence: number): string => {
|
||||||
if (confidence >= 0.8) return 'badge-success'
|
if (confidence >= 0.8) return "badge-success";
|
||||||
if (confidence >= 0.6) return 'badge-warning'
|
if (confidence >= 0.6) return "badge-warning";
|
||||||
return 'badge-error'
|
return "badge-error";
|
||||||
}
|
};
|
||||||
|
|
||||||
const onManualPuzzleSelection = (submissionFile: SubmissionFile) => {
|
const onManualPuzzleSelection = (submissionFile: SubmissionFile) => {
|
||||||
// Find the file in the reactive array
|
// Find the file in the reactive array
|
||||||
const fileIndex = files.value.findIndex(f => f.file === submissionFile.file)
|
const fileIndex = files.value.findIndex(
|
||||||
if (fileIndex === -1) return
|
(f) => f.file === submissionFile.file,
|
||||||
|
);
|
||||||
|
if (fileIndex === -1) return;
|
||||||
|
|
||||||
// Clear the manual selection requirement once user has selected
|
// Clear the manual selection requirement once user has selected
|
||||||
if (files.value[fileIndex].manualPuzzleSelection) {
|
if (files.value[fileIndex].manualPuzzleSelection) {
|
||||||
files.value[fileIndex].needsManualPuzzleSelection = false
|
files.value[fileIndex].needsManualPuzzleSelection = false;
|
||||||
console.log(`Manual puzzle selection: ${submissionFile.file.name} -> ${files.value[fileIndex].manualPuzzleSelection}`)
|
console.log(
|
||||||
|
`Manual puzzle selection: ${submissionFile.file.name} -> ${files.value[fileIndex].manualPuzzleSelection}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -8,14 +8,25 @@
|
|||||||
|
|
||||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
<!-- Detected Puzzles Summary -->
|
<!-- 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>
|
<i class="mdi mdi-information-outline text-xl"></i>
|
||||||
<div class="flex-1">
|
<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 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>{{ 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>
|
||||||
</div>
|
</div>
|
||||||
@ -25,13 +36,17 @@
|
|||||||
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
|
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
|
||||||
|
|
||||||
<!-- Manual Selection Warning -->
|
<!-- Manual Selection Warning -->
|
||||||
<div v-if="filesNeedingManualSelection.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>
|
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-bold">Manual Puzzle Selection Required</div>
|
<div class="font-bold">Manual Puzzle Selection Required</div>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
{{ filesNeedingManualSelection.length }} file(s) have low OCR confidence for puzzle names.
|
{{ filesNeedingManualSelection.length }} file(s) have low OCR
|
||||||
Please select the correct puzzle for each file before submitting.
|
confidence for puzzle names. Please select the correct puzzle for
|
||||||
|
each file before submitting.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -61,11 +76,17 @@
|
|||||||
class="checkbox checkbox-primary"
|
class="checkbox checkbox-primary"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1">
|
<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">
|
<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.
|
Check this if you want an admin to manually review your
|
||||||
<br>
|
submission, even if OCR confidence is high.
|
||||||
<em>Note: This will be automatically checked if any OCR confidence is below 50%.</em>
|
<br />
|
||||||
|
<em
|
||||||
|
>Note: This will be automatically checked if any OCR
|
||||||
|
confidence is below 80%.</em
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@ -73,15 +94,15 @@
|
|||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<div class="card-actions justify-end">
|
<div class="card-actions justify-end">
|
||||||
<button
|
<button type="submit" class="btn btn-primary" :disabled="!canSubmit">
|
||||||
type="submit"
|
<span
|
||||||
class="btn btn-primary"
|
v-if="isSubmitting"
|
||||||
:disabled="!canSubmit"
|
class="loading loading-spinner loading-sm"
|
||||||
>
|
></span>
|
||||||
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
|
|
||||||
<span v-if="isSubmitting">Submitting...</span>
|
<span v-if="isSubmitting">Submitting...</span>
|
||||||
<span v-else-if="filesNeedingManualSelection.length > 0">
|
<span v-else-if="filesNeedingManualSelection.length > 0">
|
||||||
Select Puzzles ({{ filesNeedingManualSelection.length }} remaining)
|
Select Puzzles ({{ filesNeedingManualSelection.length }}
|
||||||
|
remaining)
|
||||||
</span>
|
</span>
|
||||||
<span v-else>Submit Solution</span>
|
<span v-else>Submit Solution</span>
|
||||||
</button>
|
</button>
|
||||||
@ -92,104 +113,121 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from "vue";
|
||||||
import FileUpload from '@/components/FileUpload.vue'
|
import FileUpload from "@/components/FileUpload.vue";
|
||||||
import type { SteamCollectionItem, SubmissionFile } from '@/types'
|
import type { SteamCollectionItem, SubmissionFile } from "@/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
puzzles: SteamCollectionItem[]
|
puzzles: SteamCollectionItem[];
|
||||||
findPuzzleByName: (name: string) => SteamCollectionItem | null
|
findPuzzleByName: (name: string) => SteamCollectionItem | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
submit: [submissionData: { files: SubmissionFile[], notes?: string, manualValidationRequested?: boolean }]
|
submit: [
|
||||||
|
submissionData: {
|
||||||
|
files: SubmissionFile[];
|
||||||
|
notes?: string;
|
||||||
|
manualValidationRequested?: boolean;
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>();
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
const submissionFiles = ref<SubmissionFile[]>([])
|
const submissionFiles = ref<SubmissionFile[]>([]);
|
||||||
const notes = ref('')
|
const notes = ref("");
|
||||||
const manualValidationRequested = ref(false)
|
const manualValidationRequested = ref(false);
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
const notesLength = computed(() => notes.value.length)
|
const notesLength = computed(() => notes.value.length);
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
const hasFiles = submissionFiles.value.length > 0
|
const hasFiles = submissionFiles.value.length > 0;
|
||||||
const noManualSelectionNeeded = !submissionFiles.value.some(file => file.needsManualPuzzleSelection)
|
const noManualSelectionNeeded = !submissionFiles.value.some(
|
||||||
|
(file) => file.needsManualPuzzleSelection,
|
||||||
|
);
|
||||||
|
|
||||||
return hasFiles &&
|
return hasFiles && !isSubmitting.value && noManualSelectionNeeded;
|
||||||
!isSubmitting.value &&
|
});
|
||||||
noManualSelectionNeeded
|
|
||||||
})
|
|
||||||
|
|
||||||
// Group files by detected puzzle
|
// Group files by detected puzzle
|
||||||
const responsesByPuzzle = computed(() => {
|
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
|
// 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 (puzzleName) {
|
||||||
if (!grouped[puzzleName]) {
|
if (!grouped[puzzleName]) {
|
||||||
grouped[puzzleName] = {
|
grouped[puzzleName] = {
|
||||||
puzzle: props.findPuzzleByName(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
|
// Count files that need manual puzzle selection
|
||||||
const filesNeedingManualSelection = computed(() => {
|
const filesNeedingManualSelection = computed(() => {
|
||||||
return submissionFiles.value.filter(file => file.needsManualPuzzleSelection)
|
return submissionFiles.value.filter(
|
||||||
})
|
(file) => file.needsManualPuzzleSelection,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Check if any OCR confidence is below 50%
|
// Check if any OCR confidence is below 50%
|
||||||
const hasLowConfidence = computed(() => {
|
const hasLowConfidence = computed(() => {
|
||||||
return submissionFiles.value.some(file => {
|
return submissionFiles.value.some((file) => {
|
||||||
if (!file.ocrData?.confidence) return false
|
if (!file.ocrData?.confidence) return false;
|
||||||
return file.ocrData.confidence.cost < 0.5 ||
|
return (
|
||||||
|
file.ocrData.confidence.cost < 0.5 ||
|
||||||
file.ocrData.confidence.cycles < 0.5 ||
|
file.ocrData.confidence.cycles < 0.5 ||
|
||||||
file.ocrData.confidence.area < 0.5
|
file.ocrData.confidence.area < 0.5
|
||||||
})
|
);
|
||||||
})
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Auto-check manual validation when confidence is low
|
// Auto-check manual validation when confidence is low
|
||||||
watch(hasLowConfidence, (newValue) => {
|
watch(
|
||||||
|
hasLowConfidence,
|
||||||
|
(newValue) => {
|
||||||
|
console.log(hasLowConfidence.value, newValue);
|
||||||
if (newValue && !manualValidationRequested.value) {
|
if (newValue && !manualValidationRequested.value) {
|
||||||
manualValidationRequested.value = true
|
manualValidationRequested.value = true;
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!canSubmit.value) return
|
if (!canSubmit.value) return;
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Emit the files and notes for the parent to handle API submission
|
// Emit the files and notes for the parent to handle API submission
|
||||||
emit('submit', {
|
emit("submit", {
|
||||||
files: submissionFiles.value,
|
files: submissionFiles.value,
|
||||||
notes: notes.value.trim() || undefined,
|
notes: notes.value.trim() || undefined,
|
||||||
manualValidationRequested: manualValidationRequested.value
|
manualValidationRequested: manualValidationRequested.value,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
submissionFiles.value = []
|
submissionFiles.value = [];
|
||||||
notes.value = ''
|
notes.value = "";
|
||||||
manualValidationRequested.value = false
|
manualValidationRequested.value = false;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submission error:', error)
|
console.error("Submission error:", error);
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export interface OpusMagnumData {
|
|||||||
|
|
||||||
export interface SubmissionFile {
|
export interface SubmissionFile {
|
||||||
file: File
|
file: File
|
||||||
|
file_url: string
|
||||||
preview: string
|
preview: string
|
||||||
type: 'image' | 'gif'
|
type: 'image' | 'gif'
|
||||||
ocrData?: OpusMagnumData
|
ocrData?: OpusMagnumData
|
||||||
@ -52,7 +53,8 @@ export interface SubmissionFile {
|
|||||||
|
|
||||||
export interface PuzzleResponse {
|
export interface PuzzleResponse {
|
||||||
id?: number
|
id?: number
|
||||||
puzzle: number | SteamCollectionItem
|
// puzzle: number | SteamCollectionItem
|
||||||
|
puzzle: number
|
||||||
puzzle_name: string
|
puzzle_name: string
|
||||||
cost?: string
|
cost?: string
|
||||||
cycles?: 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": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
||||||
},
|
},
|
||||||
"src/main.ts": {
|
"src/main.ts": {
|
||||||
"file": "assets/main-B14l8Jy0.js",
|
"file": "assets/main-NIi3b_aN.js",
|
||||||
"name": "main",
|
"name": "main",
|
||||||
"src": "src/main.ts",
|
"src": "src/main.ts",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"css": [
|
"css": [
|
||||||
"assets/main-COx9N9qO.css"
|
"assets/main-CYuvChoP.css"
|
||||||
],
|
],
|
||||||
"assets": [
|
"assets": [
|
||||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||||
|
|||||||
@ -33,11 +33,9 @@ def list_puzzles(request):
|
|||||||
@paginate
|
@paginate
|
||||||
def list_submissions(request):
|
def list_submissions(request):
|
||||||
"""Get paginated list of submissions"""
|
"""Get paginated list of submissions"""
|
||||||
return (
|
return Submission.objects.prefetch_related(
|
||||||
Submission.objects.prefetch_related("responses__files", "responses__puzzle")
|
"responses__files", "responses__puzzle"
|
||||||
.filter(user=request.user)
|
).filter(user=request.user)
|
||||||
.filter()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
||||||
@ -74,15 +72,15 @@ def create_submission(
|
|||||||
auto_request_validation = any(
|
auto_request_validation = any(
|
||||||
(
|
(
|
||||||
response_data.ocr_confidence_cost is not None
|
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 (
|
or (
|
||||||
response_data.ocr_confidence_cycles is not None
|
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 (
|
or (
|
||||||
response_data.ocr_confidence_area is not None
|
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
|
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 django.core.management.base import BaseCommand, CommandError
|
||||||
from submissions.utils import create_or_update_collection
|
from submissions.utils import create_or_update_collection
|
||||||
from submissions.models import SteamCollection
|
from submissions.models import SteamAPIKey, SteamCollection
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@ -12,11 +12,6 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument("url", type=str, help="Steam Workshop collection URL")
|
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(
|
parser.add_argument(
|
||||||
"--force",
|
"--force",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@ -25,16 +20,23 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
url = options["url"]
|
url = options["url"]
|
||||||
api_key = options.get("api_key")
|
|
||||||
force = options["force"]
|
force = options["force"]
|
||||||
|
|
||||||
self.stdout.write(f"Fetching Steam collection from: {url}")
|
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:
|
try:
|
||||||
# Check if collection already exists
|
# Check if collection already exists
|
||||||
from submissions.utils import SteamCollectionFetcher
|
from submissions.utils import SteamCollectionFetcher
|
||||||
|
|
||||||
fetcher = SteamCollectionFetcher(api_key)
|
fetcher = SteamCollectionFetcher(api_key.api_key)
|
||||||
collection_id = fetcher.extract_collection_id(url)
|
collection_id = fetcher.extract_collection_id(url)
|
||||||
|
|
||||||
if collection_id and not force:
|
if collection_id and not force:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -6,7 +6,10 @@ import tailwindcss from '@tailwindcss/vite';
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/static/',
|
base: '/static/',
|
||||||
plugins: [vue(), tailwindcss()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
@ -18,5 +21,5 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: { main: resolve('./src/main.ts') }
|
input: { main: resolve('./src/main.ts') }
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,7 +7,10 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/static/',
|
base: '/static/',
|
||||||
plugins: [vue(), tailwindcss()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
@ -20,6 +23,5 @@ export default defineConfig({
|
|||||||
input:
|
input:
|
||||||
{ main: resolve('./src/main.ts') }
|
{ main: resolve('./src/main.ts') }
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user