chore: opus-submitter -> polylan-submitter

This commit is contained in:
2026-05-09 23:25:34 +02:00
parent eb1eed852b
commit 404af4f90d
101 changed files with 14 additions and 48 deletions
+250
View File
@@ -0,0 +1,250 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import PuzzleCard from "@/components/PuzzleCard.vue";
import SubmissionForm from "@/components/SubmissionForm.vue";
import AdminPanel from "@/components/AdminPanel.vue";
import Results from "@/components/Results.vue";
import { apiService, errorHelpers } from "@/services/apiService";
import { usePuzzlesStore } from "@/stores/puzzles";
import { useSubmissionsStore } from "@/stores/submissions";
import type { PuzzleResponse, UserInfo } from "@/types";
import { useCountdown } from "@vueuse/core";
import { storeToRefs } from "pinia";
const props = defineProps<{
collectionTitle: string;
collectionUrl: string;
collectionDescription: string;
}>();
const puzzlesStore = usePuzzlesStore();
const submissionsStore = useSubmissionsStore();
const { submissions, isSubmissionModalOpen } = storeToRefs(submissionsStore);
const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
submissionsStore;
// Local state
const userInfo = ref<UserInfo | null>(null);
const isLoading = ref(true);
const error = ref<string>("");
// Computed properties
const isSuperuser = computed(() => {
return userInfo.value?.is_superuser || false;
});
// Computed property to get responses grouped by puzzle
const responsesByPuzzle = computed(() => {
const grouped: Record<number, PuzzleResponse[]> = {};
submissions.value.forEach((submission) => {
submission.responses.forEach((response) => {
// Handle both number and object types for puzzle field
if (!grouped[response.puzzle_id]) {
grouped[response.puzzle_id] = [];
}
grouped[response.puzzle_id].push(response);
});
});
return grouped;
});
async function initialize() {
try {
isLoading.value = true;
error.value = "";
console.log("Starting data load...");
// Load user info
console.log("Loading user info...");
const userResponse = await apiService.getUserInfo();
if (userResponse.data) {
userInfo.value = userResponse.data;
console.log("User info loaded:", userResponse.data);
} else if (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);
// Load existing submissions using store
console.log("Loading submissions...");
await loadSubmissions();
console.log("Submissions loaded:", submissions.value.length);
console.log("Data load complete!");
} catch (err) {
error.value = errorHelpers.getErrorMessage(err);
console.error("Failed to load data:", err);
} finally {
isLoading.value = false;
console.log("Loading state set to false");
}
if (userInfo.value?.is_superuser) {
start();
}
}
const { remaining, start } = useCountdown(60, {
onComplete() {
initialize();
},
});
onMounted(async () => {
await initialize();
});
// Function to match puzzle name from OCR to actual puzzle
const findPuzzleByName = (ocrPuzzleName: string) => {
return puzzlesStore.findPuzzleByName(ocrPuzzleName);
};
const reloadPage = () => {
window.location.reload();
};
</script>
<template>
<div class="min-h-screen bg-base-200">
<!-- Header -->
<div class="navbar bg-base-100 shadow-lg">
<div class="container mx-auto">
<div class="flex-1">
<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 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
>
</div>
</div>
<div v-else class="text-sm text-base-content/70">Not logged in</div>
<div class="flex flex-col items-end gap-2">
<a href="/api/docs" class="btn btn-xs">API docs</a>
</div>
<div class="flex flex-col items-end gap-2">
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<div class="container mx-auto px-4 py-8">
<!-- Loading State -->
<div v-if="userInfo?.is_superuser" class="flex justify-center">
<div class="text-center">
<p class="mb-6 text-base-content/70">
<span class="loading loading-spinner loading-lg"></span>
Auto reload page in {{ remaining }} seconds ...
</p>
</div>
</div>
<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>
<div>
<h3 class="font-bold">Error Loading Data</h3>
<div class="text-sm">{{ error }}</div>
</div>
<button @click="reloadPage" class="btn btn-sm btn-outline">
<i class="mdi mdi-refresh mr-1"></i>
Retry
</button>
</div>
<!-- Main Content -->
<div v-else class="space-y-8">
<!-- Collection Info -->
<div class="mb-8">
<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>
<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>
</div>
</div>
</div>
</div>
<Results />
<!-- Admin Panel (only for superusers) -->
<div v-if="isSuperuser">
<AdminPanel />
</div>
<!-- Puzzles Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<PuzzleCard
v-for="puzzle in puzzlesStore.puzzles"
:key="puzzle.id"
:puzzle="puzzle"
:responses="responsesByPuzzle[puzzle.id] || []"
/>
</div>
<!-- Empty State -->
<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>
</div>
</div>
</div>
<!-- Submission Modal -->
<div v-if="isSubmissionModalOpen" class="modal modal-open">
<div class="modal-box max-w-6xl">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">Submit Solution</h3>
<button
@click="closeSubmissionModal"
class="btn btn-sm btn-circle btn-ghost"
>
<i class="mdi mdi-close"></i>
</button>
</div>
<SubmissionForm
:puzzles="puzzlesStore.puzzles"
:find-puzzle-by-name="findPuzzleByName"
/>
</div>
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
</div>
</div>
</template>
@@ -0,0 +1,441 @@
<template>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<i class="mdi mdi-shield-account text-2xl text-warning"></i>
Admin Panel
</h2>
<!-- Stats -->
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6">
<div class="stat">
<div class="stat-title">Total Submissions</div>
<div class="stat-value text-primary">
{{ stats.total_submissions }}
</div>
</div>
<div class="stat">
<div class="stat-title">Total Responses</div>
<div class="stat-value text-secondary">
{{ stats.total_responses }}
</div>
</div>
<div class="stat">
<div class="stat-title">Need Validation</div>
<div class="stat-value text-warning">
{{ stats.needs_validation }}
</div>
</div>
<div class="stat">
<div class="stat-title">Validation Rate</div>
<div class="stat-value text-success">
{{ Math.round(stats.validation_rate * 100) }}%
</div>
</div>
</div>
<button class="btn btn-sm btn-primary" @click="autoValidationResponse">
<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>
<tr>
<th>Puzzle</th>
<th>OCR Data</th>
<th>Confidence</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="response in responsesNeedingValidation"
:key="response.id"
>
<td>
<div class="font-bold">{{ response.puzzle_name }}</div>
<div class="text-sm opacity-50">ID: {{ response.id }}</div>
</td>
<td>
<div class="text-sm space-y-1">
<div class="flex justify-between items-center">
<span>Cost: {{ response.cost || "-" }}</span>
<span
v-if="response.ocr_confidence_cost"
class="badge badge-xs"
: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"
class="badge badge-xs"
: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"
class="badge badge-xs"
:class="
getConfidenceBadgeClass(response.ocr_confidence_area)
"
>
{{ Math.round(response.ocr_confidence_area * 100) }}%
</span>
</div>
</div>
</td>
<td>
<div class="badge badge-warning badge-sm">
{{ getOverallConfidence(response) }}%
</div>
</td>
<td>
<button
@click="openValidationModal(response)"
class="btn btn-sm btn-primary mr-2"
>
<i class="mdi mdi-check-circle mr-1"></i>
Validate
</button>
<button
v-if="response.id"
@click="autoValidation(response.id)"
class="btn btn-sm btn-warning"
>
<i class="mdi mdi-check-circle mr-1"></i>
Auto Validation
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else class="text-center py-8">
<i class="mdi mdi-check-all text-6xl text-success opacity-50"></i>
<p class="text-lg font-medium mt-2">All responses validated!</p>
<p class="text-sm opacity-70">
No responses currently need manual validation.
</p>
</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>
<div v-if="validationModal.response" class="space-y-4">
<div class="alert alert-info">
<i class="mdi mdi-information-outline"></i>
<div>
<div class="font-bold">
{{ validationModal.response.puzzle_name }}
</div>
<div class="text-sm">Review and correct the OCR data below</div>
</div>
</div>
<div class="grid grid-cols-4 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Puzzle</span>
</label>
<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"
:value="puzzle.id"
>
{{ puzzle.title }}
</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Cost</span>
</label>
<input
v-model="validationModal.data.validated_cost"
type="text"
class="input input-bordered input-sm"
:placeholder="
validationModal.response.cost?.toString() || 'Enter cost'
"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Cycles</span>
</label>
<input
v-model="validationModal.data.validated_cycles"
type="text"
class="input input-bordered input-sm"
:placeholder="
validationModal.response.cycles?.toString() || 'Enter cycles'
"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Area</span>
</label>
<input
v-model="validationModal.data.validated_area"
type="text"
class="input input-bordered input-sm"
:placeholder="
validationModal.response.area?.toString() || 'Enter area'
"
/>
</div>
</div>
<div class="modal-action">
<button @click="closeValidationModal" class="btn btn-ghost">
Cancel
</button>
<button
@click="submitValidation"
class="btn btn-primary"
:disabled="isValidating"
>
<span
v-if="isValidating"
class="loading loading-spinner loading-sm"
></span>
{{ isValidating ? "Validating..." : "Validate" }}
</button>
</div>
<div class="mockup-code w-full">
<pre><code>{{ validationModal}}</code></pre>
</div>
</div>
</div>
<div class="modal-backdrop" @click="closeValidationModal"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiService } from "@/services/apiService";
import type { PuzzleResponse } from "@/types";
import { usePuzzlesStore } from "@/stores/puzzles";
const puzzlesStore = usePuzzlesStore();
// Reactive data
const stats = ref({
total_submissions: 0,
total_responses: 0,
needs_validation: 0,
validated_submissions: 0,
validation_rate: 0,
});
const responsesNeedingValidation = ref<PuzzleResponse[]>([]);
const isLoading = ref(false);
const isValidating = ref(false);
const validationModal = ref({
show: false,
response: null as PuzzleResponse | null,
data: {
puzzle: -1,
validated_cost: 0,
validated_cycles: 0,
validated_area: 0,
},
});
// Methods
const loadData = async () => {
try {
isLoading.value = true;
// Load stats (skip if endpoint doesn't exist)
try {
const statsResponse = await apiService.getStats();
if (statsResponse.data) {
stats.value = statsResponse.data;
}
} catch (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,
};
}
// Load responses needing validation
const responsesResponse = await apiService.getResponsesNeedingValidation();
if (responsesResponse.data) {
responsesNeedingValidation.value = responsesResponse.data;
}
} catch (error) {
console.error("Failed to load admin data:", error);
} finally {
isLoading.value = false;
}
};
const autoValidationResponse = async () => {
for (const response of Array.from(responsesNeedingValidation.value)) {
if (!response.id) {
continue;
}
const { data, error } = await apiService.autoValidateResponses(response.id);
if (data && !data.needs_manual_validation) {
// Remove from validation list
responsesNeedingValidation.value =
responsesNeedingValidation.value.filter((r) => r.id !== response.id);
stats.value.needs_validation -= 1;
} else if (error) {
break;
}
}
};
const openValidationModal = (response: PuzzleResponse) => {
validationModal.value.response = response;
validationModal.value.data = {
puzzle: response.puzzle_id || -1,
validated_cost: response.cost || 0,
validated_cycles: response.cycles || 0,
validated_area: response.area || 0,
};
validationModal.value.show = true;
};
const closeValidationModal = () => {
validationModal.value.show = false;
validationModal.value.response = null;
validationModal.value.data = {
puzzle: -1,
validated_cost: 0,
validated_cycles: 0,
validated_area: 0,
};
};
const autoValidation = async (id: number) => {
const { data } = await apiService.autoValidateResponses(id);
console.log(data);
if (data && !data.needs_manual_validation) {
// Remove from validation list
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
(r) => r.id !== id,
);
console.log(stats.value);
stats.value.needs_validation -= 1;
console.log(stats.value);
}
};
const submitValidation = async () => {
if (!validationModal.value.response?.id) return;
try {
isValidating.value = true;
const response = await apiService.validateResponse(
validationModal.value.response.id,
validationModal.value.data,
);
if (response.error) {
alert(`Validation failed: ${response.error}`);
return;
}
// Remove from validation list
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
(r) => r.id !== validationModal.value.response?.id,
);
// Update stats
stats.value.needs_validation = Math.max(
0,
stats.value.needs_validation - 1,
);
closeValidationModal();
} catch (error) {
console.error("Validation error:", error);
alert("Validation failed");
} finally {
isValidating.value = false;
}
};
// Lifecycle
onMounted(() => {
loadData();
});
// 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";
};
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);
};
// Expose refresh method
defineExpose({
refresh: loadData,
});
</script>
@@ -0,0 +1,368 @@
<template>
<div class="form-control w-full">
<label class="label">
<span class="label-text font-medium">Upload Solution Files</span>
<span class="label-text-alt text-xs">Images or GIFs only</span>
</label>
<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"
@dragover.prevent="isDragOver = true"
@dragleave="isDragOver = false"
@dragenter.prevent
>
<input
ref="fileInput"
type="file"
multiple
accept="image/*,.gif"
class="hidden"
@change="handleFileSelect"
/>
<div v-if="submissionFiles.length === 0" class="space-y-4">
<div
class="mx-auto w-12 h-12 text-base-content/40 flex items-center justify-center"
>
<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
type="button"
@click="fileInput?.click()"
class="btn btn-primary btn-sm"
>
Choose Files
</button>
</div>
<p class="text-xs text-base-content/50">
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-2 gap-4">
<div
v-for="(file, index) in submissionFiles"
:key="index"
class="relative group"
>
<div class="aspect-square rounded-lg overflow-hidden bg-base-200">
<img
:src="file.preview"
:alt="file.file.name"
class="w-full h-full object-cover"
/>
</div>
<div
class="absolute inset-0 bg-black/80 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center"
>
<button
@click="removeFile(index)"
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() }}
</p>
<!-- OCR Status and Results -->
<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"
class="badge badge-xs"
:class="
getConfidenceBadgeClass(file.ocrData.confidence.overall)
"
:title="`Overall confidence: ${Math.round(file.ocrData.confidence.overall * 100)}%`"
>
{{ Math.round(file.ocrData.confidence.overall * 100) }}%
</span>
</div>
<button
@click="processOCR(file)"
class="btn btn-xs btn-ghost"
title="Retry OCR"
>
<i class="mdi mdi-refresh"></i>
</button>
</div>
<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"
class="ml-2 opacity-60"
:title="`Puzzle confidence: ${Math.round(file.ocrData.confidence.puzzle * 100)}%`"
>
({{ Math.round(file.ocrData.confidence.puzzle * 100) }}%)
</span>
</div>
<div v-if="file.ocrData.cost">
<strong>Cost:</strong> {{ file.ocrData.cost }}
<span
v-if="file.ocrData.confidence?.cost"
class="ml-2 opacity-60"
:title="`Cost confidence: ${Math.round(file.ocrData.confidence.cost * 100)}%`"
>
({{ Math.round(file.ocrData.confidence.cost * 100) }}%)
</span>
</div>
<div v-if="file.ocrData.cycles">
<strong>Cycles:</strong> {{ file.ocrData.cycles }}
<span
v-if="file.ocrData.confidence?.cycles"
class="ml-2 opacity-60"
:title="`Cycles confidence: ${Math.round(file.ocrData.confidence.cycles * 100)}%`"
>
({{ Math.round(file.ocrData.confidence.cycles * 100) }}%)
</span>
</div>
<div v-if="file.ocrData.area">
<strong>Area:</strong> {{ file.ocrData.area }}
<span
v-if="file.ocrData.confidence?.area"
class="ml-2 opacity-60"
:title="`Area confidence: ${Math.round(file.ocrData.confidence.area * 100)}%`"
>
({{ Math.round(file.ocrData.confidence.area * 100) }}%)
</span>
</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>
</div>
<div class="mt-2">
<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"
:value="puzzle.title"
>
{{ puzzle.title }}
</option>
</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)"
class="btn btn-xs btn-outline"
>
<i class="mdi mdi-text-recognition"></i>
Extract Puzzle Data
</button>
</div>
</div>
</div>
</div>
<div class="flex justify-center">
<button
type="button"
@click="fileInput?.click()"
class="btn btn-outline btn-sm"
>
Add More Files
</button>
</div>
</div>
</div>
<div v-if="error" class="label">
<span class="label-text-alt text-error">{{ error }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from "vue";
import { ocrService } from "@/services/ocrService";
import { usePuzzlesStore } from "@/stores/puzzles";
import { useUploadsStore } from "@/stores/uploads";
import type { SubmissionFile } from "@/types";
// Pinia store
const puzzlesStore = usePuzzlesStore();
const { submissionFiles, processOCR } = useUploadsStore();
const fileInput = ref<HTMLInputElement>();
const isDragOver = ref(false);
const error = ref("");
// Watch for puzzle changes and update OCR service
watch(
() => puzzlesStore.puzzles,
(newPuzzles) => {
if (newPuzzles && newPuzzles.length > 0) {
ocrService.setAvailablePuzzleNames(puzzlesStore.puzzleNames);
}
},
{ immediate: true },
);
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
processFiles(Array.from(target.files));
}
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
isDragOver.value = false;
if (event.dataTransfer?.files) {
processFiles(Array.from(event.dataTransfer.files));
}
};
const processFiles = async (newFiles: File[]) => {
error.value = "";
for (const file of newFiles) {
if (!isValidFile(file)) {
continue;
}
try {
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,
};
submissionFiles.push(submissionFile);
// Start OCR processing for Opus Magnum images (with delay to ensure reactivity)
if (isOpusMagnumImage(file)) {
nextTick(() => {
processOCR(submissionFile);
});
}
} catch (err) {
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;
}
// Check file size (256MB limit)
if (file.size > 256 * 1024 * 1024) {
error.value = `${file.name} is too large (max 256MB)`;
return false;
}
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 removeFile = (index: number) => {
submissionFiles.splice(index, 1);
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const isOpusMagnumImage = (file: File): boolean => {
// Basic heuristic - could be enhanced with actual image analysis
return file.type.startsWith("image/") && file.size > 50000; // > 50KB likely screenshot
};
const getConfidenceBadgeClass = (confidence: number): string => {
if (confidence >= 0.8) return "badge-success";
if (confidence >= 0.6) return "badge-warning";
return "badge-error";
};
const onManualPuzzleSelection = (submissionFile: SubmissionFile) => {
// Find the file in the reactive array
const fileIndex = submissionFiles.findIndex(
(f) => f.file === submissionFile.file,
);
if (fileIndex === -1) return;
// Clear the manual selection requirement once user has selected
if (submissionFiles[fileIndex].manualPuzzleSelection) {
submissionFiles[fileIndex].needsManualPuzzleSelection = false;
console.log(
`Manual puzzle selection: ${submissionFile.file.name} -> ${submissionFiles[fileIndex].manualPuzzleSelection}`,
);
}
};
</script>
@@ -0,0 +1,171 @@
<template>
<div
class="card bg-base-100 shadow-lg hover:shadow-2xl transition-shadow duration-300"
:class="responses?.length == 0 ? 'shadow-red-900' : 'shadow-primary-300'"
>
<div class="card-body">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="card-title text-lg font-bold">{{ puzzle.title }}</h3>
<p class="text-sm text-base-content/70 mb-2">
by {{ puzzle.author_name }}
</p>
<div class="flex items-center gap-2 mb-3">
<div class="badge badge-primary badge-sm">
{{ puzzle.steam_item_id }}
</div>
<div class="badge badge-ghost badge-sm">ID: {{ puzzle.id }}</div>
</div>
<p
v-if="puzzle.description"
class="text-sm text-base-content/80 mb-4"
>
{{ puzzle.description }}
</p>
<div
v-if="puzzle.tags && puzzle.tags.length > 0"
class="flex flex-wrap gap-1 mb-4"
>
<span
v-for="tag in puzzle.tags.slice(0, 3)"
:key="tag"
class="badge badge-outline badge-xs"
>
{{ tag }}
</span>
<span
v-if="puzzle.tags.length > 3"
class="badge badge-outline badge-xs"
>
+{{ puzzle.tags.length - 3 }} more
</span>
</div>
</div>
<div class="flex flex-col items-end gap-2">
<div class="tooltip" data-tip="View on Steam Workshop">
<a
:href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`"
target="_blank"
class="btn btn-ghost btn-sm btn-square"
>
<i class="mdi mdi-steam text-lg"></i>
</a>
</div>
</div>
</div>
<!-- Responses Table -->
<div v-if="responses && responses.length > 0" class="mt-6">
<div class="divider">
<span class="text-sm font-medium"
>Solutions ({{ responses.length }})</span
>
</div>
<div>
<table class="table table-xs">
<thead>
<tr>
<th>Cost</th>
<th>Cycles</th>
<th>Area</th>
<th>Files</th>
</tr>
</thead>
<tbody>
<tr
v-for="response in responses"
:key="response.id"
class="hover"
>
<td>
<span
v-if="response.final_cost || response.cost"
class="badge badge-success badge-xs"
>
{{ response.final_cost || response.cost }}
</span>
<span v-else class="text-base-content/50">-</span>
</td>
<td>
<span
v-if="response.final_cycles || response.cycles"
class="badge badge-info badge-xs"
>
{{ response.final_cycles || response.cycles }}
</span>
<span v-else class="text-base-content/50">-</span>
</td>
<td>
<span
v-if="response.final_area || response.area"
class="badge badge-warning badge-xs"
>
{{ response.final_area || response.area }}
</span>
<span v-else class="text-base-content/50">-</span>
</td>
<td>
<div class="flex items-center gap-1">
<span class="badge badge-ghost badge-xs">{{
response.files?.length || 0
}}</span>
<div
v-if="response.files?.length"
class="tooltip"
:data-tip="
response.files
.map((f) => f.original_filename || f.file?.name)
.join(', ')
"
>
<i class="mdi mdi-information-outline text-xs"></i>
</div>
<div
v-if="response.needs_manual_validation"
class="tooltip"
data-tip="Needs manual validation"
>
<i class="mdi mdi-alert-circle text-xs text-warning"></i>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- No responses state -->
<div
v-else
class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg hover:border-primary transition-colors duration-300 cursor-pointer"
@click="openSubmissionModal"
>
<i class="mdi mdi-upload text-2xl text-base-content/40"></i>
<p class="text-sm text-base-content/60 mt-2">No solutions yet</p>
<p class="text-xs text-base-content/40">
Upload solutions using the submit button
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { SteamCollectionItem, PuzzleResponse } from "@/types";
import { useSubmissionsStore } from "@/stores/submissions";
interface Props {
puzzle: SteamCollectionItem;
responses?: PuzzleResponse[];
}
defineProps<Props>();
const { openSubmissionModal } = useSubmissionsStore();
</script>
@@ -0,0 +1,12 @@
<script setup lang="ts"></script>
<template>
<div class="mb-8">
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl">General Results</h2>
<div class="flex flex-wrap gap-4 mt-4">TODO :)</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,218 @@
<template>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-xl mb-6">
<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 />
<!-- Manual Selection Warning -->
<div
v-if="submissionFilesNeedingManualSelection.length > 0"
class="alert alert-warning"
>
<i class="mdi mdi-alert-circle text-xl"></i>
<div class="flex-1">
<div class="font-bold">Manual Puzzle Selection Required</div>
<div class="text-sm">
{{ submissionFilesNeedingManualSelection.length }} file(s) have
low OCR confidence for puzzle names. Please select the correct
puzzle for each file before submitting.
</div>
<button
class="btn mt-3 w-full"
@click="processLowConfidenceOCRFiles"
>
<span class="mdi mdi-reload text-2xl"></span>
Retry OCR on low confidence puzzle
</button>
</div>
</div>
<!-- Notes -->
<div class="form-control">
<div class="flex-1">
<label class="flex label">
<span class="label-text font-medium">Notes (Optional)</span>
<span class="label-text-alt">{{ notesLength }}/500</span>
</label>
<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..."
maxlength="500"
></textarea>
</div>
</div>
<!-- Manual Validation Request -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
v-model="manualValidationRequested"
class="checkbox checkbox-primary"
:disabled="hasLowConfidence"
/>
<div class="flex-1">
<span class="label-text font-medium"
>Request manual validation</span
>
<div class="label-text-alt text-xs opacity-70 mt-1">
Check this if you want an admin to manually review your
submission, even if OCR confidence is high.
<br />
<em
>Note: This will be automatically checked if any OCR
confidence is below 80%.</em
>
</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>
<span v-if="isSubmitting">Submitting...</span>
<span v-else-if="submissionFilesNeedingManualSelection.length > 0">
Select Puzzles ({{ submissionFilesNeedingManualSelection.length }}
remaining)
</span>
<span v-else>Submit Solution</span>
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import FileUpload from "@/components/FileUpload.vue";
import type { SteamCollectionItem, SubmissionFile } from "@/types";
import { useUploadsStore } from "@/stores/uploads";
import { useSubmissionsStore } from "@/stores/submissions";
import { storeToRefs } from "pinia";
interface Props {
puzzles: SteamCollectionItem[];
findPuzzleByName: (name: string) => SteamCollectionItem | null;
}
const props = defineProps<Props>();
const uploadsStore = useUploadsStore();
const {
submissionFiles,
hasLowConfidence,
submissionFilesNeedingManualSelection,
} = storeToRefs(uploadsStore);
const { clearFiles, processLowConfidenceOCRFiles } = uploadsStore;
const { handleSubmission } = useSubmissionsStore();
const notes = ref("");
const manualValidationRequested = ref(false);
const isSubmitting = ref(false);
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;
});
watch(hasLowConfidence, (newValue) => {
if (newValue) {
manualValidationRequested.value = true;
}
});
// Group files by detected puzzle
const responsesByPuzzle = computed(() => {
const grouped: Record<
string,
{ puzzle: SteamCollectionItem | null; files: SubmissionFile[] }
> = {};
submissionFiles.value.forEach((file) => {
// Use manual puzzle selection if available, otherwise fall back to OCR
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle;
if (puzzleName) {
if (!grouped[puzzleName]) {
grouped[puzzleName] = {
puzzle: props.findPuzzleByName(puzzleName),
files: [],
};
}
grouped[puzzleName].files.push(file);
}
});
return grouped;
});
const handleSubmit = async () => {
if (!canSubmit.value) return;
isSubmitting.value = true;
try {
// Emit the files and notes for the store to handle API submission
handleSubmission({
files: submissionFiles.value,
notes: notes.value.trim() || undefined,
manualValidationRequested:
hasLowConfidence.value || manualValidationRequested.value,
});
// Reset form
clearFiles();
notes.value = "";
manualValidationRequested.value = false;
} catch (error) {
console.error("Submission error:", error);
} finally {
isSubmitting.value = false;
}
};
</script>
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import App from '@/App.vue'
import { pinia } from '@/stores'
import '@/style.css'
// const app = createApp(App)
const selector = "#app"
const mountData = document.querySelector<HTMLElement>(selector)
const app = createApp(App, { ...mountData?.dataset })
app.use(pinia)
app.mount(selector)
@@ -0,0 +1,300 @@
import type {
SteamCollectionItem,
Submission,
PuzzleResponse,
SubmissionFile,
UserInfo
} from '../types'
// API Configuration
const API_BASE_URL = '/api'
// API Response Types
interface ApiResponse<T> {
data?: T
error?: string
status: number
}
interface PaginatedResponse<T> {
items: T[]
count: number
}
interface SubmissionStats {
total_submissions: number
total_responses: number
needs_validation: number
validated_submissions: number
validation_rate: number
}
// API Service Class
export class ApiService {
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
})
const data = await response.json()
if (!response.ok) {
return {
error: data.detail || `HTTP ${response.status}`,
status: response.status
}
}
return {
data,
status: response.status
}
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Network error',
status: 0
}
}
}
private async uploadRequest<T>(
endpoint: string,
formData: FormData
): Promise<ApiResponse<T>> {
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
body: formData,
})
const data = await response.json()
if (!response.ok) {
return {
error: data.detail || `HTTP ${response.status}`,
status: response.status
}
}
return {
data,
status: response.status
}
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Network error',
status: 0
}
}
}
// Puzzle endpoints
async getPuzzles(): Promise<ApiResponse<SteamCollectionItem[]>> {
return this.request<SteamCollectionItem[]>('/submissions/puzzles')
}
// Submission endpoints
async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> {
return this.request<PaginatedResponse<Submission>>(
`/submissions/submissions?limit=${limit}&offset=${offset}`
)
}
async getSubmission(id: string): Promise<ApiResponse<Submission>> {
return this.request<Submission>(`/submissions/submissions/${id}`)
}
async createSubmission(
submissionData: {
notes?: string
manual_validation_requested?: boolean
responses: Array<{
puzzle_id: number
puzzle_name: string
cost?: number
cycles?: number
area?: number
needs_manual_validation?: boolean
ocr_confidence_cost?: number
ocr_confidence_cycles?: number
ocr_confidence_area?: number
}>
},
files: File[]
): Promise<ApiResponse<Submission>> {
const formData = new FormData()
// Add JSON data
formData.append('data', JSON.stringify(submissionData))
// Add files
files.forEach((file) => {
formData.append('files', file)
})
return this.uploadRequest<Submission>('/submissions/submissions', formData)
}
// Admin endpoints (require staff permissions)
async validateResponse(
responseId: number,
validationData: {
validated_cost?: number
validated_cycles?: number
validated_area?: number
}
): Promise<ApiResponse<PuzzleResponse>> {
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate`, {
method: 'PUT',
body: JSON.stringify(validationData),
})
}
async autoValidateResponses(responseId: number): Promise<ApiResponse<PuzzleResponse>> {
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate/auto`, {
method: 'PUT',
})
}
async getResponsesNeedingValidation(): Promise<ApiResponse<PuzzleResponse[]>> {
return this.request<PuzzleResponse[]>('/submissions/responses/needs-validation')
}
async validateSubmission(submissionId: string): Promise<ApiResponse<Submission>> {
return this.request<Submission>(`/submissions/submissions/${submissionId}/validate`, {
method: 'POST',
})
}
async deleteSubmission(submissionId: string): Promise<ApiResponse<{ detail: string }>> {
return this.request<{ detail: string }>(`/submissions/submissions/${submissionId}`, {
method: 'DELETE',
})
}
// Statistics endpoint
async getStats(): Promise<ApiResponse<SubmissionStats>> {
return this.request<SubmissionStats>('/submissions/stats')
}
// Health check
async healthCheck(): Promise<ApiResponse<{ status: string; service: string }>> {
return this.request<{ status: string; service: string }>('/health')
}
// User info
async getUserInfo(): Promise<ApiResponse<UserInfo>> {
return this.request<UserInfo>('/user')
}
}
// Singleton instance
export const apiService = new ApiService()
// Helper functions for common operations
export const puzzleHelpers = {
async loadPuzzles(): Promise<SteamCollectionItem[]> {
const response = await apiService.getPuzzles()
if (response.error) {
console.error('Failed to load puzzles:', response.error)
return []
}
return response.data || []
},
findPuzzleByName(puzzles: SteamCollectionItem[], name: string): SteamCollectionItem | null {
if (!name) return null
// Try exact match first
let match = puzzles.find(p =>
p.title.toLowerCase() === name.toLowerCase()
)
if (!match) {
// Try partial match
match = puzzles.find(p =>
p.title.toLowerCase().includes(name.toLowerCase()) ||
name.toLowerCase().includes(p.title.toLowerCase())
)
}
return match || null
}
}
export const submissionHelpers = {
async createFromFiles(
files: SubmissionFile[],
puzzles: SteamCollectionItem[],
notes?: string,
manualValidationRequested?: boolean
): Promise<ApiResponse<Submission>> {
const responses = files.map(item => {
const puzzle = puzzleHelpers.findPuzzleByName(puzzles, item.ocrData?.puzzle || '')
if (!puzzle) { return }
return {
puzzle_id: puzzle.id,
puzzle_name: item.ocrData?.puzzle || '',
cost: item.ocrData?.cost,
cycles: item.ocrData?.cycles,
area: item.ocrData?.area,
needs_manual_validation: (item.ocrData?.confidence.overall ?? 0) <= 0.8,
ocr_confidence_cost: item.ocrData?.confidence?.cost || 0.0,
ocr_confidence_cycles: item.ocrData?.confidence?.cycles || 0.0,
ocr_confidence_area: item.ocrData?.confidence?.area || 0.0
}
}).filter(item => item !== undefined)
// Extract actual File objects for upload
const fileObjects = files.map(f => f.file)
return apiService.createSubmission({
notes,
manual_validation_requested: manualValidationRequested,
responses
}, fileObjects)
},
async loadSubmissions(limit = 20, offset = 0): Promise<Submission[]> {
const response = await apiService.getSubmissions(limit, offset)
if (response.error) {
console.error('Failed to load submissions:', response.error)
return []
}
return response.data?.items || []
}
}
// Error handling utilities
export const errorHelpers = {
getErrorMessage(error: unknown): string {
if (typeof error === 'string') return error
if (error instanceof Error) return error.message
if (typeof error === 'object' && error !== null && 'detail' in error) {
return String((error as any).detail)
}
return 'An unknown error occurred'
},
isNetworkError(error: unknown): boolean {
return typeof error === 'string' && error.includes('Network')
},
isValidationError(status: number): boolean {
return status === 400
},
isAuthError(status: number): boolean {
return status === 401 || status === 403
}
}
@@ -0,0 +1,600 @@
import { OpusMagnumData } from '@/types';
import { createWorker } from 'tesseract.js';
export interface OpusMagnumOCRData {
puzzle: string;
cost: string;
cycles: string;
area: string;
confidence: {
puzzle: number;
cost: number;
cycles: number;
area: number;
overall: number;
};
}
export interface OCRRegion {
x: number;
y: number;
width: number;
height: number;
}
export class OpusMagnumOCRService {
private worker: Tesseract.Worker | null = null;
private availablePuzzleNames: string[] = [];
// Regions based on main.py coordinates (adjusted for web usage)
private readonly regions: Record<string, OCRRegion> = {
puzzle: { x: 15, y: 600, width: 330, height: 28 },
cost: { x: 412, y: 603, width: 65, height: 22 },
cycles: { x: 577, y: 603, width: 65, height: 22 },
area: { x: 739, y: 603, width: 65, height: 22 }
};
async initialize(): Promise<void> {
if (this.worker) return;
this.worker = await createWorker('eng');
await this.worker.setParameters({
tessedit_ocr_engine_mode: '3',
tessedit_pageseg_mode: 7 as any
});
}
/**
* Set the list of available puzzle names for better OCR matching
*/
setAvailablePuzzleNames(puzzleNames: string[]): void {
this.availablePuzzleNames = puzzleNames;
console.log('OCR service updated with puzzle names:', puzzleNames);
}
/**
* Configure OCR specifically for puzzle name recognition
* Uses aggressive character whitelisting and dictionary constraints
*/
private async configurePuzzleOCR(): Promise<void> {
if (!this.worker) return;
// Configure Tesseract for maximum constraint to our puzzle names
await this.worker.setParameters({
// Disable all system dictionaries to prevent interference
load_system_dawg: '0',
load_freq_dawg: '0',
load_punc_dawg: '0',
load_number_dawg: '0',
load_unambig_dawg: '0',
load_bigram_dawg: '0',
load_fixed_length_dawgs: '0',
// Use only characters from our puzzle names
tessedit_char_whitelist: this.getPuzzleCharacterSet(),
// Optimize for single words/short phrases
tessedit_pageseg_mode: 8 as any, // Single word
// Increase penalties for non-dictionary words
segment_penalty_dict_nonword: '2.0',
segment_penalty_dict_frequent_word: '0.001',
segment_penalty_dict_case_ok: '0.001',
segment_penalty_dict_case_bad: '0.1',
// Make OCR more conservative about character recognition
classify_enable_learning: '0',
classify_enable_adaptive_matcher: '1',
// Preserve word boundaries
preserve_interword_spaces: '1'
});
console.log('OCR configured for puzzle names with character set:', this.getPuzzleCharacterSet());
}
/**
* Get character set from available puzzle names for more accurate OCR (fallback)
*/
private getPuzzleCharacterSet(): string {
if (this.availablePuzzleNames.length === 0) {
// Fallback to common characters
return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 -'
}
// Extract unique characters from all puzzle names
const chars = new Set<string>()
this.availablePuzzleNames.forEach(name => {
for (const char of name) {
chars.add(char)
}
})
return Array.from(chars).join('')
}
async extractOpusMagnumData(imageFile: File): Promise<OpusMagnumData> {
if (!this.worker) {
await this.initialize();
}
// Convert file to image element for canvas processing
const imageUrl = URL.createObjectURL(imageFile);
const img = new Image();
return new Promise((resolve, reject) => {
img.onload = async () => {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// Extract text from each region
const results: Partial<OpusMagnumOCRData> = {};
const confidenceScores: Record<string, number> = {};
for (const [key, region] of Object.entries(this.regions)) {
const regionCanvas = document.createElement('canvas');
const regionCtx = regionCanvas.getContext('2d')!;
regionCanvas.width = region.width;
regionCanvas.height = region.height;
// Extract region from main image
regionCtx.drawImage(
canvas,
region.x, region.y, region.width, region.height,
0, 0, region.width, region.height
);
// Convert to grayscale and invert (similar to main.py processing)
const imageData = regionCtx.getImageData(0, 0, region.width, region.height);
this.preprocessImage(imageData);
regionCtx.putImageData(imageData, 0, 0);
// Configure OCR based on content type
if (key === 'cost') {
// Cost field has digits + 'G' for gold (content type: 'digits_with_6')
await this.worker!.setParameters({
tessedit_char_whitelist: '0123456789G'
});
} else if (key === 'cycles' || key === 'area') {
// Pure digits (content type: 'digits')
await this.worker!.setParameters({
tessedit_char_whitelist: '0123456789'
});
} else if (key === 'puzzle') {
// Puzzle name - use user words file for better matching
await this.configurePuzzleOCR();
} else {
// Default - allow all characters
await this.worker!.setParameters({
tessedit_char_whitelist: ''
});
}
// Perform OCR on the region
const { data: { text, confidence } } = await this.worker!.recognize(regionCanvas);
let cleanText = text.trim();
// Store the confidence score for this field
confidenceScores[key] = confidence / 100; // Tesseract returns 0-100, we want 0-1
// Post-process based on field type
if (key === 'cost') {
// Handle common OCR misreadings where G is read as 6
// If the text ends with 6 and looks like it should be G, remove it
if (cleanText.endsWith('6') && cleanText.length > 1) {
// Check if removing the last character gives a reasonable cost value
const withoutLast = cleanText.slice(0, -1);
if (/^\d+$/.test(withoutLast)) {
cleanText = withoutLast;
}
}
// Remove any trailing G characters
cleanText = cleanText.replace(/G+$/g, '');
// Ensure only digits remain
cleanText = cleanText.replace(/[^0-9]/g, '');
} else if (key === 'cycles' || key === 'area') {
// Ensure only digits remain
cleanText = cleanText.replace(/[^0-9]/g, '');
} else if (key === 'puzzle') {
// Post-process puzzle names with aggressive matching to force selection from available puzzles
cleanText = this.findBestPuzzleMatch(cleanText);
// If we still don't have a match and we have available puzzles, force the best match
if (this.availablePuzzleNames.length > 0 && !this.availablePuzzleNames.includes(cleanText)) {
const forcedMatch = this.findBestPuzzleMatchForced(cleanText);
if (forcedMatch) {
cleanText = forcedMatch;
console.log(`Forced OCR match: "${text.trim()}" -> "${cleanText}"`);
}
}
}
(results as any)[key] = cleanText;
}
URL.revokeObjectURL(imageUrl);
// Calculate overall confidence as the average of all field confidences
const confidenceValues = Object.values(confidenceScores);
const overallConfidence = confidenceValues.length > 0
? confidenceValues.reduce((sum, conf) => sum + conf, 0) / confidenceValues.length
: 0;
resolve({
puzzle: results.puzzle || '',
cost: parseInt(results.cost || ''),
cycles: parseInt(results.cycles || ''),
area: parseInt(results.area || ''),
confidence: {
puzzle: confidenceScores.puzzle || 0,
cost: confidenceScores.cost || 0,
cycles: confidenceScores.cycles || 0,
area: confidenceScores.area || 0,
overall: overallConfidence,
}
});
} catch (error) {
URL.revokeObjectURL(imageUrl);
reject(error);
}
};
img.onerror = () => {
URL.revokeObjectURL(imageUrl);
reject(new Error('Failed to load image'));
};
img.src = imageUrl;
});
}
private preprocessImage(imageData: ImageData): void {
// Convert to grayscale and invert (similar to cv2.bitwise_not in main.py)
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
// Convert to grayscale
const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
// Invert the grayscale value
const inverted = 255 - gray;
data[i] = inverted; // Red
data[i + 1] = inverted; // Green
data[i + 2] = inverted; // Blue
// Alpha channel (data[i + 3]) remains unchanged
}
}
/**
* Calculate Levenshtein distance between two strings
*/
private levenshteinDistance(str1: string, str2: string): number {
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
for (let j = 1; j <= str2.length; j++) {
for (let i = 1; i <= str1.length; i++) {
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[j][i] = Math.min(
matrix[j][i - 1] + 1, // deletion
matrix[j - 1][i] + 1, // insertion
matrix[j - 1][i - 1] + indicator // substitution
);
}
}
return matrix[str2.length][str1.length];
}
/**
* Find the best matching puzzle name from available options using multiple strategies
*/
private findBestPuzzleMatch(ocrText: string): string {
if (!this.availablePuzzleNames.length) {
return ocrText.trim();
}
const cleanedOcr = ocrText.trim();
if (!cleanedOcr) return '';
// Strategy 1: Exact match (case insensitive)
const exactMatch = this.availablePuzzleNames.find(
name => name.toLowerCase() === cleanedOcr.toLowerCase()
);
if (exactMatch) return exactMatch;
// Strategy 2: Substring match (either direction)
const substringMatch = this.availablePuzzleNames.find(
name => name.toLowerCase().includes(cleanedOcr.toLowerCase()) ||
cleanedOcr.toLowerCase().includes(name.toLowerCase())
);
if (substringMatch) return substringMatch;
// Strategy 3: Multiple fuzzy matching approaches
let bestMatch = cleanedOcr;
let bestScore = 0;
for (const puzzleName of this.availablePuzzleNames) {
const scores = [
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2)
];
// Use the maximum score from all algorithms
const maxScore = Math.max(...scores);
// Lower threshold for better matching - force selection even with moderate confidence
if (maxScore > bestScore && maxScore > 0.4) {
bestScore = maxScore;
bestMatch = puzzleName;
}
}
// Strategy 4: If no good match found, try character-based matching
if (bestScore < 0.6) {
const charMatch = this.findBestCharacterMatch(cleanedOcr);
if (charMatch) {
bestMatch = charMatch;
}
}
return bestMatch;
}
/**
* Calculate Levenshtein similarity (normalized)
*/
private calculateLevenshteinSimilarity(str1: string, str2: string): number {
const distance = this.levenshteinDistance(str1.toLowerCase(), str2.toLowerCase());
const maxLength = Math.max(str1.length, str2.length);
return maxLength === 0 ? 1 : 1 - (distance / maxLength);
}
/**
* Calculate Jaro-Winkler similarity
*/
private calculateJaroWinklerSimilarity(str1: string, str2: string): number {
const s1 = str1.toLowerCase();
const s2 = str2.toLowerCase();
if (s1 === s2) return 1;
const matchWindow = Math.floor(Math.max(s1.length, s2.length) / 2) - 1;
if (matchWindow < 0) return 0;
const s1Matches = new Array(s1.length).fill(false);
const s2Matches = new Array(s2.length).fill(false);
let matches = 0;
let transpositions = 0;
// Find matches
for (let i = 0; i < s1.length; i++) {
const start = Math.max(0, i - matchWindow);
const end = Math.min(i + matchWindow + 1, s2.length);
for (let j = start; j < end; j++) {
if (s2Matches[j] || s1[i] !== s2[j]) continue;
s1Matches[i] = true;
s2Matches[j] = true;
matches++;
break;
}
}
if (matches === 0) return 0;
// Count transpositions
let k = 0;
for (let i = 0; i < s1.length; i++) {
if (!s1Matches[i]) continue;
while (!s2Matches[k]) k++;
if (s1[i] !== s2[k]) transpositions++;
k++;
}
const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3;
// Jaro-Winkler bonus for common prefix
let prefix = 0;
for (let i = 0; i < Math.min(s1.length, s2.length, 4); i++) {
if (s1[i] === s2[i]) prefix++;
else break;
}
return jaro + (0.1 * prefix * (1 - jaro));
}
/**
* Calculate N-gram similarity
*/
private calculateNGramSimilarity(str1: string, str2: string, n: number): number {
const s1 = str1.toLowerCase();
const s2 = str2.toLowerCase();
if (s1 === s2) return 1;
if (s1.length < n || s2.length < n) return 0;
const ngrams1 = new Set<string>();
const ngrams2 = new Set<string>();
for (let i = 0; i <= s1.length - n; i++) {
ngrams1.add(s1.substr(i, n));
}
for (let i = 0; i <= s2.length - n; i++) {
ngrams2.add(s2.substr(i, n));
}
const intersection = new Set([...ngrams1].filter(x => ngrams2.has(x)));
const union = new Set([...ngrams1, ...ngrams2]);
return intersection.size / union.size;
}
/**
* Find best match based on character frequency
*/
private findBestCharacterMatch(ocrText: string): string | null {
let bestMatch = null;
let bestScore = 0;
for (const puzzleName of this.availablePuzzleNames) {
const score = this.calculateCharacterFrequencyScore(ocrText.toLowerCase(), puzzleName.toLowerCase());
if (score > bestScore && score > 0.3) {
bestScore = score;
bestMatch = puzzleName;
}
}
return bestMatch;
}
/**
* Calculate character frequency similarity
*/
private calculateCharacterFrequencyScore(str1: string, str2: string): number {
const freq1 = new Map<string, number>();
const freq2 = new Map<string, number>();
for (const char of str1) {
freq1.set(char, (freq1.get(char) || 0) + 1);
}
for (const char of str2) {
freq2.set(char, (freq2.get(char) || 0) + 1);
}
const allChars = new Set([...freq1.keys(), ...freq2.keys()]);
let similarity = 0;
let totalChars = 0;
for (const char of allChars) {
const count1 = freq1.get(char) || 0;
const count2 = freq2.get(char) || 0;
similarity += Math.min(count1, count2);
totalChars += Math.max(count1, count2);
}
return totalChars === 0 ? 0 : similarity / totalChars;
}
/**
* Force a match to available puzzle names - always returns a puzzle name
* This is used as a last resort to ensure OCR always selects from available puzzles
*/
private findBestPuzzleMatchForced(ocrText: string): string | null {
if (!this.availablePuzzleNames.length || !ocrText.trim()) {
return null;
}
const cleanedOcr = ocrText.trim().toLowerCase();
let bestMatch = this.availablePuzzleNames[0]; // Default to first puzzle
let bestScore = 0;
// Try all matching algorithms and pick the best overall score
for (const puzzleName of this.availablePuzzleNames) {
const scores = [
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2),
this.calculateCharacterFrequencyScore(cleanedOcr, puzzleName.toLowerCase()),
// Add length similarity bonus
this.calculateLengthSimilarity(cleanedOcr, puzzleName.toLowerCase())
];
// Use weighted average with emphasis on character frequency and length
const weightedScore = (
scores[0] * 0.25 + // Levenshtein
scores[1] * 0.25 + // Jaro-Winkler
scores[2] * 0.2 + // N-gram
scores[3] * 0.2 + // Character frequency
scores[4] * 0.1 // Length similarity
);
if (weightedScore > bestScore) {
bestScore = weightedScore;
bestMatch = puzzleName;
}
}
console.log(`Forced match for "${ocrText}": "${bestMatch}" (score: ${bestScore.toFixed(3)})`);
return bestMatch;
}
/**
* Calculate similarity based on string length
*/
private calculateLengthSimilarity(str1: string, str2: string): number {
const len1 = str1.length;
const len2 = str2.length;
const maxLen = Math.max(len1, len2);
const minLen = Math.min(len1, len2);
return maxLen === 0 ? 1 : minLen / maxLen;
}
async terminate(): Promise<void> {
if (this.worker) {
await this.worker.terminate();
this.worker = null;
}
}
// Utility method to validate if an image looks like an Opus Magnum screenshot
static isValidOpusMagnumImage(file: File): boolean {
// Basic validation - could be enhanced with actual image analysis
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
return validTypes.includes(file.type);
}
// Debug method to visualize OCR regions (similar to main.py debug rectangles)
static drawDebugRegions(imageFile: File): Promise<string> {
return new Promise((resolve, reject) => {
const imageUrl = URL.createObjectURL(imageFile);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// Draw debug rectangles
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 2;
const service = new OpusMagnumOCRService();
Object.values(service.regions).forEach(region => {
ctx.strokeRect(region.x, region.y, region.width, region.height);
});
URL.revokeObjectURL(imageUrl);
resolve(canvas.toDataURL());
};
img.onerror = () => {
URL.revokeObjectURL(imageUrl);
reject(new Error('Failed to load image for debug'));
};
img.src = imageUrl;
});
}
}
// Singleton instance for the application
export const ocrService = new OpusMagnumOCRService();
+3
View File
@@ -0,0 +1,3 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()
+78
View File
@@ -0,0 +1,78 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { SteamCollectionItem } from '@/types'
import { apiService } from '@/services/apiService'
export const usePuzzlesStore = defineStore('puzzles', () => {
// State
const puzzles = ref<SteamCollectionItem[]>([])
const isLoading = ref(false)
const error = ref<string>('')
// Getters
const puzzleNames = computed(() => puzzles.value.map(puzzle => puzzle.title))
const findPuzzleByName = computed(() => (name: string): SteamCollectionItem | null => {
if (!name) return null
// First try exact match (case insensitive)
const exactMatch = puzzles.value.find(
puzzle => puzzle.title.toLowerCase() === name.toLowerCase()
)
if (exactMatch) return exactMatch
// Then try partial match
const partialMatch = puzzles.value.find(
puzzle => puzzle.title.toLowerCase().includes(name.toLowerCase()) ||
name.toLowerCase().includes(puzzle.title.toLowerCase())
)
return partialMatch || null
})
// Actions
const loadPuzzles = async () => {
if (puzzles.value.length > 0) return // Already loaded
try {
isLoading.value = true
error.value = ''
const response = await apiService.getPuzzles()
if (response.error) {
error.value = response.error
console.error('Failed to load puzzles:', response.error)
return
}
if (response.data) {
puzzles.value = response.data
}
} catch (err) {
error.value = 'Failed to load puzzles'
console.error('Error loading puzzles:', err)
} finally {
isLoading.value = false
}
}
const refreshPuzzles = async () => {
puzzles.value = []
await loadPuzzles()
}
return {
// State
puzzles,
isLoading,
error,
// Getters
puzzleNames,
findPuzzleByName,
// Actions
loadPuzzles,
refreshPuzzles
}
})
+146
View File
@@ -0,0 +1,146 @@
import { defineStore, storeToRefs } from 'pinia'
import { ref } from 'vue'
import type { Submission, SubmissionFile } from '@/types'
import { submissionHelpers } from '@/services/apiService'
import { usePuzzlesStore } from '@/stores/puzzles'
import { errorHelpers } from "@/services/apiService";
export const useSubmissionsStore = defineStore('submissions', () => {
// State
const submissions = ref<Submission[]>([])
const isLoading = ref(false)
const error = ref<string>('')
const isSubmissionModalOpen = ref(false)
const puzzlesStore = usePuzzlesStore()
const { puzzles } = storeToRefs(puzzlesStore)
// Actions
const loadSubmissions = async (limit = 20, offset = 0) => {
try {
isLoading.value = true
error.value = ''
const loadedSubmissions = await submissionHelpers.loadSubmissions(limit, offset)
if (offset === 0) {
submissions.value = loadedSubmissions
} else {
submissions.value.push(...loadedSubmissions)
}
} catch (err) {
error.value = 'Failed to load submissions'
console.error('Error loading submissions:', err)
} finally {
isLoading.value = false
}
}
const createSubmission = async (
files: SubmissionFile[],
notes?: string,
manualValidationRequested?: boolean
): Promise<Submission | undefined> => {
try {
isLoading.value = true
error.value = ''
const response = await submissionHelpers.createFromFiles(
files,
puzzles.value,
notes,
manualValidationRequested
)
if (response.error) {
error.value = response.error
throw new Error(response.error)
}
if (response.data) {
// Add to local submissions list
submissions.value.unshift(response.data)
return response.data
}
return undefined
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to create submission'
throw err
} finally {
isLoading.value = false
}
}
const openSubmissionModal = () => {
isSubmissionModalOpen.value = true
}
const closeSubmissionModal = () => {
isSubmissionModalOpen.value = false
}
const refreshSubmissions = async () => {
submissions.value = []
await loadSubmissions()
}
const handleSubmission = async (submissionData: {
files: any[];
notes?: string;
manualValidationRequested?: boolean;
}) => {
try {
isLoading.value = true;
error.value = "";
// Create submission via store
const submission = await createSubmission(
submissionData.files,
submissionData.notes,
submissionData.manualValidationRequested,
);
// Show success message
if (submission) {
const puzzleNames = submission.responses
.map((r) => r.puzzle_name)
.join(", ");
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`);
} else {
alert("Submission created successfully!");
}
// Close modal
closeSubmissionModal();
} catch (err) {
const errorMessage = errorHelpers.getErrorMessage(err);
error.value = errorMessage;
alert(`Submission failed: ${errorMessage}`);
console.error("Submission error:", err);
} finally {
isLoading.value = false;
}
}
return {
// State
submissions,
isLoading,
error,
isSubmissionModalOpen,
// Actions
loadSubmissions,
createSubmission,
openSubmissionModal,
closeSubmissionModal,
refreshSubmissions,
handleSubmission
}
})
+103
View File
@@ -0,0 +1,103 @@
import { SubmissionFile } from '@/types'
import { defineStore } from 'pinia'
import { ref, nextTick, computed } from "vue";
import { ocrService } from "@/services/ocrService";
const CONFIDENCE_VALUE = 0.8;
export const useUploadsStore = defineStore('uploads', () => {
const submissionFiles = ref<SubmissionFile[]>([])
const isProcessingOCR = computed(() =>
submissionFiles.value.some(item => item.ocrProcessing)
);
const hasLowConfidence = computed(() =>
submissionFiles.value.some(file => {
return isLowConfidence(file)
})
)
const submissionFilesNeedingManualSelection = computed(() => {
return submissionFiles.value.filter(file => file.needsManualPuzzleSelection)
})
const isLowConfidence = (file: SubmissionFile) => {
if (!file.ocrData?.confidence) return false;
return (
file.ocrData.confidence.cost < CONFIDENCE_VALUE ||
file.ocrData.confidence.cycles < CONFIDENCE_VALUE ||
file.ocrData.confidence.area < CONFIDENCE_VALUE
)
}
const processOCR = async (submissionFile: SubmissionFile) => {
while (isProcessingOCR.value) {
const waitingTimeMs = Math.floor(Math.random() * 400) + 100;
console.log(`OCR is already processing, waiting ${waitingTimeMs}ms...`);
await new Promise((res) => setTimeout(res, waitingTimeMs));
}
const index = submissionFiles.value.indexOf(submissionFile)
// Update the reactive array directly
submissionFiles.value[index].ocrProcessing = true;
submissionFiles.value[index].ocrError = undefined;
submissionFiles.value[index].ocrData = undefined;
try {
console.log("Starting OCR processing for:", submissionFile.file.name);
await ocrService.initialize();
const ocrData = await ocrService.extractOpusMagnumData(submissionFile.file);
console.log("OCR completed:", ocrData);
// Force reactivity update
await nextTick();
submissionFiles.value[index].ocrData = ocrData;
// Check if puzzle confidence is below CONFIDENCE_VALUE and needs manual selection
if (ocrData.confidence.puzzle < CONFIDENCE_VALUE) {
submissionFiles.value[index].needsManualPuzzleSelection = true;
console.log(
`Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`,
);
} else {
submissionFiles.value[index].needsManualPuzzleSelection = false;
}
await nextTick();
} catch (error) {
console.error("OCR processing failed:", error);
submissionFiles.value[index].ocrError = "Failed to extract puzzle data";
} finally {
submissionFiles.value[index].ocrProcessing = false;
}
};
const processLowConfidenceOCRFiles = async () => {
const files = submissionFiles.value.filter(file => isLowConfidence(file))
for (const file of files) {
processOCR(file)
}
}
const clearFiles = () => {
submissionFiles.value = []
}
return {
submissionFiles,
submissionFilesNeedingManualSelection,
processOCR,
processLowConfidenceOCRFiles,
clearFiles,
// computed
isProcessingOCR,
hasLowConfidence,
CONFIDENCE_VALUE
}
})
+39
View File
@@ -0,0 +1,39 @@
@import '@mdi/font/css/materialdesignicons.css';
@import "tailwindcss";
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "dim";
default: false;
prefersdark: false;
color-scheme: "dark";
--color-base-100: oklch(30.857% 0.023 264.149);
--color-base-200: oklch(28.036% 0.019 264.182);
--color-base-300: oklch(26.346% 0.018 262.177);
--color-base-content: oklch(82.901% 0.031 222.959);
--color-primary: oklch(86.133% 0.141 139.549);
--color-primary-content: oklch(17.226% 0.028 139.549);
--color-secondary: oklch(73.375% 0.165 35.353);
--color-secondary-content: oklch(14.675% 0.033 35.353);
--color-accent: oklch(74.229% 0.133 311.379);
--color-accent-content: oklch(14.845% 0.026 311.379);
--color-neutral: oklch(24.731% 0.02 264.094);
--color-neutral-content: oklch(82.901% 0.031 222.959);
--color-info: oklch(86.078% 0.142 206.182);
--color-info-content: oklch(17.215% 0.028 206.182);
--color-success: oklch(86.171% 0.142 166.534);
--color-success-content: oklch(17.234% 0.028 166.534);
--color-warning: oklch(86.163% 0.142 94.818);
--color-warning-content: oklch(17.232% 0.028 94.818);
--color-error: oklch(82.418% 0.099 33.756);
--color-error-content: oklch(16.483% 0.019 33.756);
--radius-selector: 2rem;
--radius-field: 0.25rem;
--radius-box: 0.25rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
+102
View File
@@ -0,0 +1,102 @@
export interface SteamCollection {
id: number
steam_id: string
title: string
description: string
author_name: string
total_items: number
unique_visitors: number
current_favorites: number
created_at: string
updated_at: string
}
export interface SteamCollectionItem {
id: number
steam_item_id: string
title: string
author_name: string
description: string
tags: string[]
order_index: number
collection: number
created_at: string
updated_at: string
}
export interface OpusMagnumData {
puzzle: string
cost: number
cycles: number
area: number
confidence: {
puzzle: number
cost: number
cycles: number
area: number
overall: number
}
}
export interface SubmissionFile {
file: File
file_url: string
preview: string
type: 'image' | 'gif'
ocrData?: OpusMagnumData
ocrProcessing?: boolean
ocrError?: string
original_filename?: string
manualPuzzleSelection?: string
needsManualPuzzleSelection?: boolean
}
export interface PuzzleResponse {
id?: number
// puzzle: number | SteamCollectionItem
puzzle_id: number
puzzle_name: string
cost?: number
cycles?: number
area?: number
needs_manual_validation?: boolean
ocr_confidence_cost?: number
ocr_confidence_cycles?: number
ocr_confidence_area?: number
validated_cost?: number
validated_cycles?: number
validated_area?: number
final_cost?: number
final_cycles?: number
final_area?: number
files?: SubmissionFile[]
created_at?: string
updated_at?: string
}
export interface Submission {
id?: string
user?: number | null
responses: PuzzleResponse[]
notes?: string
is_validated?: boolean
validated_by?: number | null
validated_at?: string | null
manual_validation_requested?: boolean
total_responses?: number
needs_validation?: boolean
created_at?: string
updated_at?: string
}
export interface UserInfo {
id?: number
username?: string
first_name?: string
last_name?: string
email?: string
is_authenticated: boolean
is_staff: boolean
is_superuser: boolean
cas_groups?: string[]
}