small ui fixes

This commit is contained in:
2025-11-23 12:03:07 +01:00
parent 596731a8a7
commit 012b72527b
15 changed files with 712 additions and 578 deletions
+122 -112
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"
+178 -148
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>
+198 -160
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>
+146 -108
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>
+3 -1
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