chore: opus-submitter -> polylan-submitter
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user