small ui fixes

This commit is contained in:
Loïc Gremaud 2025-11-23 12:03:07 +01:00
parent 596731a8a7
commit 012b72527b
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
15 changed files with 712 additions and 578 deletions

View File

@ -6,7 +6,19 @@ from submissions.schemas import UserInfoOut
api = NinjaAPI(
title="Opus Magnum Submission API",
version="1.0.0",
description="API for managing Opus Magnum puzzle submissions",
description="""API for managing Opus Magnum puzzle submissions.
The Opus Magnum Submission API allows clients to upload, manage, validate, and review puzzle solution submissions for the Opus Magnum puzzle game community.
It provides features for user authentication, puzzle listing, submission uploads, automated and manual OCR validation, and administrative workflows.
""",
openapi_extra={
"info": {
"contact": {
"name": "Legrems",
"email": "loic.gremaud@polylan.ch",
},
}
},
)
# Add authentication for protected endpoints
@ -33,8 +45,7 @@ def api_info(request):
"description": "API for managing puzzle submissions with OCR validation",
"features": [
"Multi-puzzle submissions",
"File upload to S3",
"OCR validation tracking",
"OCR validation",
"Manual validation workflow",
"Admin validation tools",
],

View File

@ -1,151 +1,154 @@
<script setup lang="ts">
import { ref, onMounted, computed, defineProps } from 'vue'
import PuzzleCard from '@/components/PuzzleCard.vue'
import SubmissionForm from '@/components/SubmissionForm.vue'
import AdminPanel from '@/components/AdminPanel.vue'
import { apiService, errorHelpers } from '@/services/apiService'
import { usePuzzlesStore } from '@/stores/puzzles'
import { useSubmissionsStore } from '@/stores/submissions'
import type { SteamCollection, PuzzleResponse, UserInfo } from '@/types'
import { useCountdown } from '@vueuse/core'
import { ref, onMounted, computed, 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 { 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;
}>();
// Pinia stores
const puzzlesStore = usePuzzlesStore()
const submissionsStore = useSubmissionsStore()
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[]> = {}
submissionsStore.submissions.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
const puzzleId = typeof response.puzzle === 'number' ? response.puzzle : response.puzzle.id
if (!grouped[puzzleId]) {
grouped[puzzleId] = []
if (!grouped[response.puzzle]) {
grouped[response.puzzle] = [];
}
grouped[puzzleId].push(response)
})
})
return grouped
})
grouped[response.puzzle].push(response);
});
});
return grouped;
});
async function initialize() {
try {
isLoading.value = true
error.value = ''
console.log('Starting data load...')
isLoading.value = true;
error.value = "";
console.log("Starting data load...");
// Load user info
console.log('Loading user info...')
const userResponse = await apiService.getUserInfo()
console.log("Loading user info...");
const userResponse = await apiService.getUserInfo();
if (userResponse.data) {
userInfo.value = userResponse.data
console.log('User info loaded:', userResponse.data)
userInfo.value = userResponse.data;
console.log("User info loaded:", userResponse.data);
} else if (userResponse.error) {
console.warn('User info error:', userResponse.error)
console.warn("User info error:", userResponse.error);
}
// Load puzzles from API using store
console.log('Loading puzzles...')
await puzzlesStore.loadPuzzles()
console.log('Puzzles loaded:', puzzlesStore.puzzles.length)
console.log("Loading puzzles...");
await puzzlesStore.loadPuzzles();
console.log("Puzzles loaded:", puzzlesStore.puzzles.length);
// Load existing submissions using store
console.log('Loading submissions...')
await submissionsStore.loadSubmissions()
console.log('Submissions loaded:', submissionsStore.submissions.length)
console.log('Data load complete!')
console.log("Loading submissions...");
await submissionsStore.loadSubmissions();
console.log("Submissions loaded:", submissionsStore.submissions.length);
console.log("Data load complete!");
} catch (err) {
error.value = errorHelpers.getErrorMessage(err)
console.error('Failed to load data:', err)
error.value = errorHelpers.getErrorMessage(err);
console.error("Failed to load data:", err);
} finally {
isLoading.value = false
console.log('Loading state set to false')
isLoading.value = false;
console.log("Loading state set to false");
}
if (userInfo.value.is_superuser) {
start()
if (userInfo.value?.is_superuser) {
start();
}
}
const { remaining, start } = useCountdown(60, {
onComplete() {
initialize()
}
})
initialize();
},
});
onMounted(async () => {
await initialize()
})
await initialize();
});
const handleSubmission = async (submissionData: {
files: any[],
notes?: string,
manualValidationRequested?: boolean
const handleSubmission = async (submissionData: {
files: any[];
notes?: string;
manualValidationRequested?: boolean;
}) => {
try {
isLoading.value = true
error.value = ''
isLoading.value = true;
error.value = "";
// Create submission via store
const submission = await submissionsStore.createSubmission(
submissionData.files,
submissionData.notes,
submissionData.manualValidationRequested
)
submissionData.manualValidationRequested,
);
// Show success message
if (submission) {
const puzzleNames = submission.responses.map(r => r.puzzle_name).join(', ')
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
const puzzleNames = submission.responses
.map((r) => r.puzzle_name)
.join(", ");
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`);
} else {
alert('Submission created successfully!')
alert("Submission created successfully!");
}
// Close modal
submissionsStore.closeSubmissionModal()
submissionsStore.closeSubmissionModal();
} catch (err) {
const errorMessage = errorHelpers.getErrorMessage(err)
error.value = errorMessage
alert(`Submission failed: ${errorMessage}`)
console.error('Submission error:', err)
const errorMessage = errorHelpers.getErrorMessage(err);
error.value = errorMessage;
alert(`Submission failed: ${errorMessage}`);
console.error("Submission error:", err);
} finally {
isLoading.value = false
isLoading.value = false;
}
}
};
const openSubmissionModal = () => {
submissionsStore.openSubmissionModal()
}
submissionsStore.openSubmissionModal();
};
const closeSubmissionModal = () => {
submissionsStore.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>
@ -157,19 +160,22 @@ 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 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 django
</a>
<a href="/admin" class="btn btn-xs btn-warning"> Admin panel </a>
</div>
</div>
</div>
@ -187,13 +193,16 @@ const reloadPage = () => {
</div>
</div>
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
<div
v-if="isLoading"
class="flex justify-center items-center min-h-[400px]"
>
<div class="text-center">
<span class="loading loading-spinner loading-lg"></span>
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="alert alert-error max-w-2xl mx-auto">
<i class="mdi mdi-alert-circle text-xl"></i>
@ -214,14 +223,13 @@ const reloadPage = () => {
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2>
<p class="text-base-content/70">{{ props.collectionDescription }}</p>
<p class="text-base-content/70">
{{ props.collectionDescription }}
</p>
<div class="flex flex-wrap gap-4 mt-4">
<button
@click="openSubmissionModal"
class="btn btn-primary"
>
<i class="mdi mdi-plus mr-2"></i>
Submit Solution
<button @click="openSubmissionModal" class="btn btn-primary">
<i class="mdi mdi-plus mr-2"></i>
Submit Solution
</button>
</div>
</div>
@ -247,25 +255,27 @@ const reloadPage = () => {
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
<div class="text-6xl mb-4">🧩</div>
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3>
<p class="text-base-content/70">Check back later for new puzzle collections!</p>
<p class="text-base-content/70">
Check back later for new puzzle collections!
</p>
</div>
</div>
</div>
<!-- Submission Modal -->
<div v-if="submissionsStore.isSubmissionModalOpen" class="modal modal-open">
<div class="modal-box max-w-4xl">
<div class="modal-box max-w-6xl">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">Submit Solution</h3>
<button
<button
@click="closeSubmissionModal"
class="btn btn-sm btn-circle btn-ghost"
>
<i class="mdi mdi-close"></i>
</button>
</div>
<SubmissionForm
<SubmissionForm
:puzzles="puzzlesStore.puzzles"
:find-puzzle-by-name="findPuzzleByName"
@submit="handleSubmission"

View File

@ -5,24 +5,32 @@
<i class="mdi mdi-shield-account text-2xl text-warning"></i>
Admin Panel
</h2>
<!-- Stats -->
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6">
<div class="stat">
<div class="stat-title">Total Submissions</div>
<div class="stat-value text-primary">{{ stats.total_submissions }}</div>
<div class="stat-value text-primary">
{{ stats.total_submissions }}
</div>
</div>
<div class="stat">
<div class="stat-title">Total Responses</div>
<div class="stat-value text-secondary">{{ stats.total_responses }}</div>
<div class="stat-value text-secondary">
{{ stats.total_responses }}
</div>
</div>
<div class="stat">
<div class="stat-title">Need Validation</div>
<div class="stat-value text-warning">{{ stats.needs_validation }}</div>
<div class="stat-value text-warning">
{{ stats.needs_validation }}
</div>
</div>
<div class="stat">
<div class="stat-title">Validation Rate</div>
<div class="stat-value text-success">{{ Math.round(stats.validation_rate * 100) }}%</div>
<div class="stat-value text-success">
{{ Math.round(stats.validation_rate * 100) }}%
</div>
</div>
</div>
@ -30,11 +38,11 @@
<i class="mdi mdi-check-circle mr-1"></i>
Auto validation for all responses
</button>
<!-- Responses Needing Validation -->
<div v-if="responsesNeedingValidation.length > 0">
<h3 class="text-lg font-bold mb-4">Responses Needing Validation</h3>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
@ -46,39 +54,50 @@
</tr>
</thead>
<tbody>
<tr v-for="response in responsesNeedingValidation" :key="response.id">
<tr
v-for="response in responsesNeedingValidation"
:key="response.id"
>
<td>
<div class="font-bold">{{ response.puzzle_title }}</div>
<div class="font-bold">{{ response.puzzle_name }}</div>
<div class="text-sm opacity-50">ID: {{ response.id }}</div>
</td>
<td>
<div class="text-sm space-y-1">
<div class="flex justify-between items-center">
<span>Cost: {{ response.cost || '-' }}</span>
<span
v-if="response.ocr_confidence_cost"
<span>Cost: {{ response.cost || "-" }}</span>
<span
v-if="response.ocr_confidence_cost"
class="badge badge-xs"
:class="getConfidenceBadgeClass(response.ocr_confidence_cost)"
:class="
getConfidenceBadgeClass(response.ocr_confidence_cost)
"
>
{{ Math.round(response.ocr_confidence_cost * 100) }}%
</span>
</div>
<div class="flex justify-between items-center">
<span>Cycles: {{ response.cycles || '-' }}</span>
<span
v-if="response.ocr_confidence_cycles"
<span>Cycles: {{ response.cycles || "-" }}</span>
<span
v-if="response.ocr_confidence_cycles"
class="badge badge-xs"
:class="getConfidenceBadgeClass(response.ocr_confidence_cycles)"
:class="
getConfidenceBadgeClass(
response.ocr_confidence_cycles,
)
"
>
{{ Math.round(response.ocr_confidence_cycles * 100) }}%
</span>
</div>
<div class="flex justify-between items-center">
<span>Area: {{ response.area || '-' }}</span>
<span
v-if="response.ocr_confidence_area"
<span>Area: {{ response.area || "-" }}</span>
<span
v-if="response.ocr_confidence_area"
class="badge badge-xs"
:class="getConfidenceBadgeClass(response.ocr_confidence_area)"
:class="
getConfidenceBadgeClass(response.ocr_confidence_area)
"
>
{{ Math.round(response.ocr_confidence_area * 100) }}%
</span>
@ -91,8 +110,8 @@
</div>
</td>
<td>
<button
@click="openValidationModal(response)"
<button
@click="openValidationModal(response)"
class="btn btn-sm btn-primary"
>
<i class="mdi mdi-check-circle mr-1"></i>
@ -104,50 +123,53 @@
</table>
</div>
</div>
<div v-else class="text-center py-8">
<i class="mdi mdi-check-all text-6xl text-success opacity-50"></i>
<p class="text-lg font-medium mt-2">All responses validated!</p>
<p class="text-sm opacity-70">No responses currently need manual validation.</p>
<p class="text-sm opacity-70">
No responses currently need manual validation.
</p>
</div>
</div>
</div>
<!-- Validation Modal -->
<div v-if="validationModal.show" class="modal modal-open">
<div class="modal-box w-11/12 max-w-5xl">
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
<div v-for="file in validationModal.response.files">
<img :src="file.file_url">
<div v-for="file in validationModal.response?.files ?? []">
<img :src="file.file_url" />
</div>
<div v-if="validationModal.response" class="space-y-4">
<div class="alert alert-info">
<i class="mdi mdi-information-outline"></i>
<div>
<div class="font-bold">{{ validationModal.response.puzzle_title }}</div>
<div class="font-bold">
{{ validationModal.response.puzzle_name }}
</div>
<div class="text-sm">Review and correct the OCR data below</div>
</div>
</div>
<div class="grid grid-cols-4 gap-4">
<div class="grid grid-cols-4 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Puzzle</span>
</label>
<select
<select
v-model="validationModal.data.puzzle"
class="select select-bordered select-sm w-full"
>
<option value="">Select puzzle...</option>
<option
v-for="puzzle in puzzlesStore.puzzles"
:key="puzzle.id"
<option
v-for="puzzle in puzzlesStore.puzzles"
:key="puzzle.id"
:value="puzzle.id"
>
{{ puzzle.title }}
{{ puzzle.title }}
</option>
</select>
</div>
@ -156,48 +178,53 @@
<label class="label">
<span class="label-text">Cost</span>
</label>
<input
v-model="validationModal.data.validated_cost"
type="text"
<input
v-model="validationModal.data.validated_cost"
type="text"
class="input input-bordered input-sm"
:placeholder="validationModal.response.cost || 'Enter cost'"
>
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Cycles</span>
</label>
<input
v-model="validationModal.data.validated_cycles"
type="text"
<input
v-model="validationModal.data.validated_cycles"
type="text"
class="input input-bordered input-sm"
:placeholder="validationModal.response.cycles || 'Enter cycles'"
>
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Area</span>
</label>
<input
v-model="validationModal.data.validated_area"
type="text"
<input
v-model="validationModal.data.validated_area"
type="text"
class="input input-bordered input-sm"
:placeholder="validationModal.response.area || 'Enter area'"
>
/>
</div>
</div>
<div class="modal-action">
<button @click="closeValidationModal" class="btn btn-ghost">Cancel</button>
<button
@click="submitValidation"
<button @click="closeValidationModal" class="btn btn-ghost">
Cancel
</button>
<button
@click="submitValidation"
class="btn btn-primary"
:disabled="isValidating"
>
<span v-if="isValidating" class="loading loading-spinner loading-sm"></span>
{{ isValidating ? 'Validating...' : 'Validate' }}
<span
v-if="isValidating"
class="loading loading-spinner loading-sm"
></span>
{{ isValidating ? "Validating..." : "Validate" }}
</button>
</div>
</div>
@ -207,11 +234,11 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { apiService } from '@/services/apiService'
import type { PuzzleResponse } from '@/types'
import {usePuzzlesStore} from '@/stores/puzzles'
const puzzlesStore = usePuzzlesStore()
import { ref, onMounted } from "vue";
import { apiService } from "@/services/apiService";
import type { PuzzleResponse } from "@/types";
import { usePuzzlesStore } from "@/stores/puzzles";
const puzzlesStore = usePuzzlesStore();
// Reactive data
const stats = ref({
@ -219,160 +246,163 @@ const stats = ref({
total_responses: 0,
needs_validation: 0,
validated_submissions: 0,
validation_rate: 0
})
validation_rate: 0,
});
const responsesNeedingValidation = ref<PuzzleResponse[]>([])
const isLoading = ref(false)
const isValidating = ref(false)
const responsesNeedingValidation = ref<PuzzleResponse[]>([]);
const isLoading = ref(false);
const isValidating = ref(false);
const validationModal = ref({
show: false,
response: null as PuzzleResponse | null,
data: {
puzzle_title: '',
validated_cost: '',
validated_cycles: '',
validated_area: ''
}
})
puzzle: -1,
validated_cost: "",
validated_cycles: "",
validated_area: "",
},
});
// Methods
const loadData = async () => {
try {
isLoading.value = true
isLoading.value = true;
// Load stats (skip if endpoint doesn't exist)
try {
const statsResponse = await apiService.getStats()
const statsResponse = await apiService.getStats();
if (statsResponse.data) {
stats.value = statsResponse.data
stats.value = statsResponse.data;
}
} catch (error) {
console.warn('Stats endpoint not available:', error)
console.warn("Stats endpoint not available:", error);
// Set default stats
stats.value = {
total_submissions: 0,
total_responses: 0,
needs_validation: 0,
validated_submissions: 0,
validation_rate: 0
}
validation_rate: 0,
};
}
// Load responses needing validation
const responsesResponse = await apiService.getResponsesNeedingValidation()
const responsesResponse = await apiService.getResponsesNeedingValidation();
if (responsesResponse.data) {
responsesNeedingValidation.value = responsesResponse.data
responsesNeedingValidation.value = responsesResponse.data;
}
} catch (error) {
console.error('Failed to load admin data:', error)
console.error("Failed to load admin data:", error);
} finally {
isLoading.value = false
isLoading.value = false;
}
}
};
const autoValidationResponse = async () => {
for (const response of Array.from(responsesNeedingValidation.value)) {
const {data, error} = await apiService.autoValidateResponses(response.id)
if (!response.id) {
continue;
}
const { data, error } = await apiService.autoValidateResponses(response.id);
if (data && !data.needs_manual_validation) {
// Remove from validation list
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
r => r.id !== response.id
)
stats.value.needs_validation -= 1
responsesNeedingValidation.value =
responsesNeedingValidation.value.filter((r) => r.id !== response.id);
stats.value.needs_validation -= 1;
} else if (error) {
break
break;
}
}
}
};
const openValidationModal = (response: PuzzleResponse) => {
validationModal.value.response = response
validationModal.value.response = response;
validationModal.value.data = {
puzzle: response.puzzle || '',
validated_cost: response.cost || '',
validated_cycles: response.cycles || '',
validated_area: response.area || ''
}
validationModal.value.show = true
}
puzzle: response.puzzle || -1,
validated_cost: response.cost || "",
validated_cycles: response.cycles || "",
validated_area: response.area || "",
};
validationModal.value.show = true;
};
const closeValidationModal = () => {
validationModal.value.show = false
validationModal.value.response = null
validationModal.value.show = false;
validationModal.value.response = null;
validationModal.value.data = {
puzzle: '',
validated_cost: '',
validated_cycles: '',
validated_area: ''
}
}
puzzle: -1,
validated_cost: "",
validated_cycles: "",
validated_area: "",
};
};
const submitValidation = async () => {
if (!validationModal.value.response?.id) return
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)
closeValidationModal()
stats.value.needs_validation = Math.max(
0,
stats.value.needs_validation - 1,
);
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[]
if (confidences.length === 0) return 0
const average = confidences.reduce((sum, conf) => sum + conf, 0) / confidences.length
return Math.round(average * 100)
}
response.ocr_confidence_area,
].filter((conf) => conf !== undefined && conf !== null) as number[];
if (confidences.length === 0) return 0;
const average =
confidences.reduce((sum, conf) => sum + conf, 0) / confidences.length;
return Math.round(average * 100);
};
// Expose refresh method
defineExpose({
refresh: loadData
})
refresh: loadData,
});
</script>

View File

@ -4,8 +4,8 @@
<span class="label-text font-medium">Upload Solution Files</span>
<span class="label-text-alt text-xs">Images or GIFs only</span>
</label>
<div
<div
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center hover:border-primary transition-colors duration-300"
:class="{ 'border-primary bg-primary/5': isDragOver }"
@drop="handleDrop"
@ -20,15 +20,17 @@
accept="image/*,.gif"
class="hidden"
@change="handleFileSelect"
>
/>
<div v-if="files.length === 0" class="space-y-4">
<div class="mx-auto w-12 h-12 text-base-content/40 flex items-center justify-center">
<div
class="mx-auto w-12 h-12 text-base-content/40 flex items-center justify-center"
>
<i class="mdi mdi-cloud-upload text-5xl"></i>
</div>
<div>
<p class="text-base-content/70 mb-2">Drop your files here or</p>
<button
<button
type="button"
@click="fileInput?.click()"
class="btn btn-primary btn-sm"
@ -40,62 +42,70 @@
Supported formats: JPG, PNG, GIF (max 256MB each)
</p>
</div>
<div v-else class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="(file, index) in files"
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-4">
<div
v-for="(file, index) in files"
:key="index"
class="relative group"
>
<div class="aspect-square rounded-lg overflow-hidden bg-base-200">
<img
:src="file.preview"
<img
:src="file.preview"
:alt="file.file.name"
class="w-full h-full object-cover"
>
/>
</div>
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center">
<div
class="absolute inset-0 bg-black/80 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center"
>
<button
@click="removeFile(index)"
class="btn btn-error btn-sm btn-circle"
class="btn btn-error btn-lg btn-circle"
>
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="mt-2">
<p class="text-xs font-medium truncate">{{ file.file.name }}</p>
<p class="text-xs text-base-content/60">
{{ formatFileSize(file.file.size) }} {{ file.type.toUpperCase() }}
{{ formatFileSize(file.file.size) }}
{{ file.type.toUpperCase() }}
</p>
<!-- OCR Status and Results -->
<div v-if="file.ocrProcessing" class="mt-1 flex items-center gap-1">
<div
v-if="file.ocrProcessing"
class="mt-1 flex items-center gap-1"
>
<span class="loading loading-spinner loading-xs"></span>
<span class="text-xs text-info">Extracting puzzle data...</span>
</div>
<div v-else-if="file.ocrError" class="mt-1">
<p class="text-xs text-error">{{ file.ocrError }}</p>
</div>
<div v-else-if="file.ocrData" class="mt-1 space-y-1">
<div class="text-xs flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium text-success"> OCR Complete</span>
<span
v-if="file.ocrData.confidence"
<span
v-if="file.ocrData.confidence"
class="badge badge-xs"
:class="getConfidenceBadgeClass(file.ocrData.confidence.overall)"
:class="
getConfidenceBadgeClass(file.ocrData.confidence.overall)
"
:title="`Overall confidence: ${Math.round(file.ocrData.confidence.overall * 100)}%`"
>
{{ Math.round(file.ocrData.confidence.overall * 100) }}%
</span>
</div>
<button
@click="retryOCR(file)"
<button
@click="retryOCR(file)"
class="btn btn-xs btn-ghost"
title="Retry OCR"
>
@ -105,8 +115,8 @@
<div class="text-xs space-y-1 bg-base-200 p-2 rounded">
<div v-if="file.ocrData.puzzle">
<strong>Puzzle:</strong> {{ file.ocrData.puzzle }}
<span
v-if="file.ocrData.confidence?.puzzle"
<span
v-if="file.ocrData.confidence?.puzzle"
class="ml-2 opacity-60"
:title="`Puzzle confidence: ${Math.round(file.ocrData.confidence.puzzle * 100)}%`"
>
@ -115,8 +125,8 @@
</div>
<div v-if="file.ocrData.cost">
<strong>Cost:</strong> {{ file.ocrData.cost }}
<span
v-if="file.ocrData.confidence?.cost"
<span
v-if="file.ocrData.confidence?.cost"
class="ml-2 opacity-60"
:title="`Cost confidence: ${Math.round(file.ocrData.confidence.cost * 100)}%`"
>
@ -125,8 +135,8 @@
</div>
<div v-if="file.ocrData.cycles">
<strong>Cycles:</strong> {{ file.ocrData.cycles }}
<span
v-if="file.ocrData.confidence?.cycles"
<span
v-if="file.ocrData.confidence?.cycles"
class="ml-2 opacity-60"
:title="`Cycles confidence: ${Math.round(file.ocrData.confidence.cycles * 100)}%`"
>
@ -135,8 +145,8 @@
</div>
<div v-if="file.ocrData.area">
<strong>Area:</strong> {{ file.ocrData.area }}
<span
v-if="file.ocrData.confidence?.area"
<span
v-if="file.ocrData.confidence?.area"
class="ml-2 opacity-60"
:title="`Area confidence: ${Math.round(file.ocrData.confidence.area * 100)}%`"
>
@ -145,26 +155,28 @@
</div>
</div>
</div>
<!-- Manual Puzzle Selection (when OCR confidence is low) -->
<div v-if="file.needsManualPuzzleSelection" class="mt-2">
<div class="alert alert-warning alert-sm">
<i class="mdi mdi-alert-circle text-lg"></i>
<div class="flex-1">
<div class="font-medium">Low OCR Confidence</div>
<div class="text-xs">Please select the correct puzzle manually</div>
<div class="text-xs">
Please select the correct puzzle manually
</div>
</div>
</div>
<div class="mt-2">
<select
<select
v-model="file.manualPuzzleSelection"
class="select select-bordered select-sm w-full"
@change="onManualPuzzleSelection(file)"
>
<option value="">Select puzzle...</option>
<option
v-for="puzzle in puzzlesStore.puzzles"
:key="puzzle.id"
<option
v-for="puzzle in puzzlesStore.puzzles"
:key="puzzle.id"
:value="puzzle.title"
>
{{ puzzle.title }}
@ -172,11 +184,16 @@
</select>
</div>
</div>
<!-- Manual OCR trigger for non-auto detected files -->
<div v-else-if="!file.ocrProcessing && !file.ocrError && !file.ocrData" class="mt-1">
<button
@click="processOCR(file)"
<div
v-else-if="
!file.ocrProcessing && !file.ocrError && !file.ocrData
"
class="mt-1"
>
<button
@click="processOCR(file)"
class="btn btn-xs btn-outline"
>
<i class="mdi mdi-text-recognition"></i>
@ -186,9 +203,9 @@
</div>
</div>
</div>
<div class="flex justify-center">
<button
<button
type="button"
@click="fileInput?.click()"
class="btn btn-outline btn-sm"
@ -198,7 +215,7 @@
</div>
</div>
</div>
<div v-if="error" class="label">
<span class="label-text-alt text-error">{{ error }}</span>
</div>
@ -206,199 +223,220 @@
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { ocrService } from '@/services/ocrService'
import { usePuzzlesStore } from '@/stores/puzzles'
import type { SubmissionFile, SteamCollectionItem } from '@/types'
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[]
modelValue: SubmissionFile[];
puzzles?: SteamCollectionItem[];
}
interface Emits {
'update:modelValue': [files: SubmissionFile[]]
"update:modelValue": [files: SubmissionFile[]];
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Pinia store
const puzzlesStore = usePuzzlesStore()
const puzzlesStore = usePuzzlesStore();
const fileInput = ref<HTMLInputElement>()
const isDragOver = ref(false)
const error = ref('')
const files = ref<SubmissionFile[]>([])
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(
() => props.modelValue,
(newFiles) => {
files.value = newFiles;
},
{ immediate: true },
);
// Watch for internal changes and emit
watch(files, (newFiles) => {
emit('update:modelValue', newFiles)
}, { deep: true })
watch(
files,
(newFiles) => {
emit("update:modelValue", newFiles);
},
{ deep: true },
);
// Watch for puzzle changes and update OCR service
watch(() => puzzlesStore.puzzles, (newPuzzles) => {
if (newPuzzles && newPuzzles.length > 0) {
ocrService.setAvailablePuzzleNames(puzzlesStore.puzzleNames)
}
}, { immediate: true })
watch(
() => puzzlesStore.puzzles,
(newPuzzles) => {
if (newPuzzles && newPuzzles.length > 0) {
ocrService.setAvailablePuzzleNames(puzzlesStore.puzzleNames);
}
},
{ immediate: true },
);
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
const target = event.target as HTMLInputElement;
if (target.files) {
processFiles(Array.from(target.files))
processFiles(Array.from(target.files));
}
}
};
const handleDrop = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = false
event.preventDefault();
isDragOver.value = false;
if (event.dataTransfer?.files) {
processFiles(Array.from(event.dataTransfer.files))
processFiles(Array.from(event.dataTransfer.files));
}
}
};
const processFiles = async (newFiles: File[]) => {
error.value = ''
error.value = "";
for (const file of newFiles) {
if (!isValidFile(file)) {
continue
continue;
}
try {
const preview = await createPreview(file)
const fileType = file.type.startsWith('image/gif') ? 'gif' : 'image'
const preview = await createPreview(file);
const fileType = file.type.startsWith("image/gif") ? "gif" : "image";
const submissionFile: SubmissionFile = {
file,
file_url: "",
preview,
type: fileType,
ocrProcessing: false,
ocrError: undefined,
ocrData: undefined
}
files.value.push(submissionFile)
ocrData: undefined,
};
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) => {
files.value.splice(index, 1)
}
files.value.splice(index, 1);
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const isOpusMagnumImage = (file: File): boolean => {
// Basic heuristic - could be enhanced with actual image analysis
return file.type.startsWith('image/') && file.size > 50000 // > 50KB likely screenshot
}
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
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
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)
console.log("Starting OCR processing for:", submissionFile.file.name);
await ocrService.initialize();
const ocrData = await ocrService.extractOpusMagnumData(submissionFile.file);
console.log("OCR completed:", ocrData);
// Force reactivity update
await nextTick()
files.value[fileIndex].ocrData = ocrData
await nextTick();
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`)
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
files.value[fileIndex].needsManualPuzzleSelection = false;
}
await nextTick()
await nextTick();
} catch (error) {
console.error('OCR processing failed:', error)
files.value[fileIndex].ocrError = 'Failed to extract puzzle data'
console.error("OCR processing failed:", error);
files.value[fileIndex].ocrError = "Failed to extract puzzle data";
} finally {
files.value[fileIndex].ocrProcessing = false
files.value[fileIndex].ocrProcessing = false;
}
}
};
const retryOCR = (submissionFile: SubmissionFile) => {
processOCR(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 = files.value.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 (files.value[fileIndex].manualPuzzleSelection) {
files.value[fileIndex].needsManualPuzzleSelection = false
console.log(`Manual puzzle selection: ${submissionFile.file.name} -> ${files.value[fileIndex].manualPuzzleSelection}`)
files.value[fileIndex].needsManualPuzzleSelection = false;
console.log(
`Manual puzzle selection: ${submissionFile.file.name} -> ${files.value[fileIndex].manualPuzzleSelection}`,
);
}
}
};
</script>

View File

@ -5,37 +5,52 @@
<i class="mdi mdi-check-circle text-2xl text-primary"></i>
Submit Solution
</h2>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Detected Puzzles Summary -->
<div v-if="Object.keys(responsesByPuzzle).length > 0" class="alert alert-info">
<i class="mdi mdi-information-outline text-xl"></i>
<div class="flex-1">
<h4 class="font-bold">Detected Puzzles ({{ Object.keys(responsesByPuzzle).length }})</h4>
<div class="text-sm space-y-1 mt-1">
<div v-for="(data, puzzleName) in responsesByPuzzle" :key="puzzleName" class="flex justify-between">
<span>{{ puzzleName }}</span>
<span class="badge badge-ghost badge-sm ml-2">{{ data.files.length }} file(s)</span>
</div>
</div>
</div>
</div>
<!-- File Upload -->
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
<!-- Manual Selection Warning -->
<div v-if="filesNeedingManualSelection.length > 0" class="alert alert-warning">
<i class="mdi mdi-alert-circle text-xl"></i>
<div class="flex-1">
<div class="font-bold">Manual Puzzle Selection Required</div>
<div class="text-sm">
{{ filesNeedingManualSelection.length }} file(s) have low OCR confidence for puzzle names.
Please select the correct puzzle for each file before submitting.
</div>
</div>
</div>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Detected Puzzles Summary -->
<div
v-if="Object.keys(responsesByPuzzle).length > 0"
class="alert alert-info"
>
<i class="mdi mdi-information-outline text-xl"></i>
<div class="flex-1">
<h4 class="font-bold">
Detected Puzzles ({{ Object.keys(responsesByPuzzle).length }})
</h4>
<div class="text-sm space-y-1 mt-1">
<div
v-for="(data, puzzleName) in responsesByPuzzle"
:key="puzzleName"
class="flex justify-between"
>
<span>{{ puzzleName }}</span>
<span class="badge badge-ghost badge-sm ml-2"
>{{ data.files.length }} file(s)</span
>
</div>
</div>
</div>
</div>
<!-- File Upload -->
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
<!-- Manual Selection Warning -->
<div
v-if="filesNeedingManualSelection.length > 0"
class="alert alert-warning"
>
<i class="mdi mdi-alert-circle text-xl"></i>
<div class="flex-1">
<div class="font-bold">Manual Puzzle Selection Required</div>
<div class="text-sm">
{{ filesNeedingManualSelection.length }} file(s) have low OCR
confidence for puzzle names. Please select the correct puzzle for
each file before submitting.
</div>
</div>
</div>
<!-- Notes -->
<div class="form-control">
<div class="flex-1">
@ -43,7 +58,7 @@
<span class="label-text font-medium">Notes (Optional)</span>
<span class="label-text-alt">{{ notesLength }}/500</span>
</label>
<textarea
<textarea
v-model="notes"
class="flex textarea textarea-bordered h-24 w-full resize-none"
placeholder="Add any notes about your solution, approach, or interesting findings..."
@ -51,37 +66,43 @@
></textarea>
</div>
</div>
<!-- Manual Validation Request -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
<input
type="checkbox"
v-model="manualValidationRequested"
class="checkbox checkbox-primary"
/>
<div class="flex-1">
<span class="label-text font-medium">Request manual validation</span>
<span class="label-text font-medium"
>Request manual validation</span
>
<div class="label-text-alt text-xs opacity-70 mt-1">
Check this if you want an admin to manually review your submission, even if OCR confidence is high.
<br>
<em>Note: This will be automatically checked if any OCR confidence is below 50%.</em>
Check this if you want an admin to manually review your
submission, even if OCR confidence is high.
<br />
<em
>Note: This will be automatically checked if any OCR
confidence is below 80%.</em
>
</div>
</div>
</label>
</div>
<!-- Submit Button -->
<div class="card-actions justify-end">
<button
type="submit"
class="btn btn-primary"
:disabled="!canSubmit"
>
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
<button type="submit" class="btn btn-primary" :disabled="!canSubmit">
<span
v-if="isSubmitting"
class="loading loading-spinner loading-sm"
></span>
<span v-if="isSubmitting">Submitting...</span>
<span v-else-if="filesNeedingManualSelection.length > 0">
Select Puzzles ({{ filesNeedingManualSelection.length }} remaining)
Select Puzzles ({{ filesNeedingManualSelection.length }}
remaining)
</span>
<span v-else>Submit Solution</span>
</button>
@ -92,104 +113,121 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import FileUpload from '@/components/FileUpload.vue'
import type { SteamCollectionItem, SubmissionFile } from '@/types'
import { ref, computed, watch } from "vue";
import FileUpload from "@/components/FileUpload.vue";
import type { SteamCollectionItem, SubmissionFile } from "@/types";
interface Props {
puzzles: SteamCollectionItem[]
findPuzzleByName: (name: string) => SteamCollectionItem | null
puzzles: SteamCollectionItem[];
findPuzzleByName: (name: string) => SteamCollectionItem | null;
}
interface Emits {
submit: [submissionData: { files: SubmissionFile[], notes?: string, manualValidationRequested?: boolean }]
submit: [
submissionData: {
files: SubmissionFile[];
notes?: string;
manualValidationRequested?: boolean;
},
];
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const submissionFiles = ref<SubmissionFile[]>([])
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)
return hasFiles &&
!isSubmitting.value &&
noManualSelectionNeeded
})
const hasFiles = submissionFiles.value.length > 0;
const noManualSelectionNeeded = !submissionFiles.value.some(
(file) => file.needsManualPuzzleSelection,
);
return hasFiles && !isSubmitting.value && noManualSelectionNeeded;
});
// Group files by detected puzzle
const responsesByPuzzle = computed(() => {
const grouped: Record<string, { puzzle: SteamCollectionItem | null, files: SubmissionFile[] }> = {}
submissionFiles.value.forEach(file => {
const grouped: Record<
string,
{ puzzle: SteamCollectionItem | null; files: SubmissionFile[] }
> = {};
submissionFiles.value.forEach((file) => {
// Use manual puzzle selection if available, otherwise fall back to OCR
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle;
if (puzzleName) {
if (!grouped[puzzleName]) {
grouped[puzzleName] = {
puzzle: props.findPuzzleByName(puzzleName),
files: []
}
files: [],
};
}
grouped[puzzleName].files.push(file)
grouped[puzzleName].files.push(file);
}
})
return grouped
})
});
return grouped;
});
// Count files that need manual puzzle selection
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%
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
})
})
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 })
watch(
hasLowConfidence,
(newValue) => {
console.log(hasLowConfidence.value, newValue);
if (newValue && !manualValidationRequested.value) {
manualValidationRequested.value = true;
}
},
{ immediate: true },
);
const handleSubmit = async () => {
if (!canSubmit.value) return
isSubmitting.value = true
if (!canSubmit.value) return;
isSubmitting.value = true;
try {
// Emit the files and notes for the parent to handle API submission
emit('submit', {
emit("submit", {
files: submissionFiles.value,
notes: notes.value.trim() || undefined,
manualValidationRequested: manualValidationRequested.value
})
manualValidationRequested: manualValidationRequested.value,
});
// Reset form
submissionFiles.value = []
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>

View File

@ -40,6 +40,7 @@ export interface OpusMagnumData {
export interface SubmissionFile {
file: File
file_url: string
preview: string
type: 'image' | 'gif'
ocrData?: OpusMagnumData
@ -52,7 +53,8 @@ export interface SubmissionFile {
export interface PuzzleResponse {
id?: number
puzzle: number | SteamCollectionItem
// puzzle: number | SteamCollectionItem
puzzle: number
puzzle_name: string
cost?: string
cycles?: string

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -16,12 +16,12 @@
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
},
"src/main.ts": {
"file": "assets/main-B14l8Jy0.js",
"file": "assets/main-NIi3b_aN.js",
"name": "main",
"src": "src/main.ts",
"isEntry": true,
"css": [
"assets/main-COx9N9qO.css"
"assets/main-CYuvChoP.css"
],
"assets": [
"assets/materialdesignicons-webfont-CSr8KVlo.eot",

View File

@ -33,11 +33,9 @@ def list_puzzles(request):
@paginate
def list_submissions(request):
"""Get paginated list of submissions"""
return (
Submission.objects.prefetch_related("responses__files", "responses__puzzle")
.filter(user=request.user)
.filter()
)
return Submission.objects.prefetch_related(
"responses__files", "responses__puzzle"
).filter(user=request.user)
@router.get("/submissions/{submission_id}", response=SubmissionOut)
@ -74,15 +72,15 @@ def create_submission(
auto_request_validation = any(
(
response_data.ocr_confidence_cost is not None
and response_data.ocr_confidence_cost < 0.5
and response_data.ocr_confidence_cost < 0.8
)
or (
response_data.ocr_confidence_cycles is not None
and response_data.ocr_confidence_cycles < 0.5
and response_data.ocr_confidence_cycles < 0.8
)
or (
response_data.ocr_confidence_area is not None
and response_data.ocr_confidence_area < 0.5
and response_data.ocr_confidence_area < 0.8
)
for response_data in data.responses
)

View File

@ -4,7 +4,7 @@ Django management command to fetch Steam Workshop collections
from django.core.management.base import BaseCommand, CommandError
from submissions.utils import create_or_update_collection
from submissions.models import SteamCollection
from submissions.models import SteamAPIKey, SteamCollection
class Command(BaseCommand):
@ -12,11 +12,6 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument("url", type=str, help="Steam Workshop collection URL")
parser.add_argument(
"--api-key",
type=str,
help="Steam API key (optional, can also be set via STEAM_API_KEY environment variable)",
)
parser.add_argument(
"--force",
action="store_true",
@ -25,16 +20,23 @@ class Command(BaseCommand):
def handle(self, *args, **options):
url = options["url"]
api_key = options.get("api_key")
force = options["force"]
self.stdout.write(f"Fetching Steam collection from: {url}")
api_key = SteamAPIKey.objects.filter(is_active=True).first()
if not api_key:
self.stderr.write(f"No API key defined! Aborting...")
return
self.stdout.write(f"Using api key: {api_key}")
try:
# Check if collection already exists
from submissions.utils import SteamCollectionFetcher
fetcher = SteamCollectionFetcher(api_key)
fetcher = SteamCollectionFetcher(api_key.api_key)
collection_id = fetcher.extract_collection_id(url)
if collection_id and not force:

File diff suppressed because one or more lines are too long

View File

@ -6,7 +6,10 @@ import tailwindcss from '@tailwindcss/vite';
// https://vitejs.dev/config/
export default defineConfig({
base: '/static/',
plugins: [vue(), tailwindcss()],
plugins: [
vue(),
tailwindcss(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
@ -18,5 +21,5 @@ export default defineConfig({
rollupOptions: {
input: { main: resolve('./src/main.ts') }
}
}
},
});

View File

@ -7,7 +7,10 @@ import tailwindcss from '@tailwindcss/vite'
// https://vitejs.dev/config/
export default defineConfig({
base: '/static/',
plugins: [vue(), tailwindcss()],
plugins: [
vue(),
tailwindcss(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
@ -20,6 +23,5 @@ export default defineConfig({
input:
{ main: resolve('./src/main.ts') }
}
}
},
})