Compress answers and show upload progress

This commit is contained in:
2025-09-14 17:45:34 +02:00
parent b6b43618a7
commit 5c66430cb4
6 changed files with 93 additions and 16 deletions
+3 -1
View File
@@ -114,7 +114,9 @@
"required_answer": "Erforderliche Antwort", "required_answer": "Erforderliche Antwort",
"finalize": "Antwort finalisieren", "finalize": "Antwort finalisieren",
"already_submitted": "Deine Antwort wurde bereits übermittelt!", "already_submitted": "Deine Antwort wurde bereits übermittelt!",
"finalize_explain": "Dies gibt den Admins die Möglichkeit deine Antwort zu bewerten und dir Punkte zu geben. Du kannst deine Antwort anschließend nicht mehr ändern!" "finalize_explain": "Dies gibt den Admins die Möglichkeit deine Antwort zu bewerten und dir Punkte zu geben. Du kannst deine Antwort anschließend nicht mehr ändern!",
"upload_title": "Antwort wird hochgeladen...",
"upload_compress": "Bild wird komprimiert..."
}, },
"hunt": { "hunt": {
"total_points": "Punktestand: {score}", "total_points": "Punktestand: {score}",
+3 -1
View File
@@ -114,7 +114,9 @@
"required_answer": "Required Answer", "required_answer": "Required Answer",
"finalize": "Finalize answer", "finalize": "Finalize answer",
"already_submitted": "Your final answer has already been submitted!", "already_submitted": "Your final answer has already been submitted!",
"finalize_explain": "This allows the admins to review your answer and give you points. You cannot change your answer afterwards!" "finalize_explain": "This allows the admins to review your answer and give you points. You cannot change your answer afterwards!",
"upload_title": "Uploading answer...",
"upload_compress": "Compressing image..."
}, },
"hunt": { "hunt": {
"total_points": "Total points: {score}", "total_points": "Total points: {score}",
+58 -14
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { createImageURL } from '#shared/utils/image'; import { compressImage, createImageURL } from '#shared/utils/image';
import ClickableImage from '~/components/ClickableImage.vue'; import ClickableImage from '~/components/ClickableImage.vue';
import MultilineString from '~/components/MultilineString.vue'; import MultilineString from '~/components/MultilineString.vue';
import { useI18nKey } from '~/composables/useI18nKey'; import { useI18nKey } from '~/composables/useI18nKey';
@@ -55,11 +55,24 @@ const textType = {
const newImage = ref<File>(); const newImage = ref<File>();
const newImageURL = computed(() =>
newImage.value ? createImageURL(newImage.value) : undefined
);
const uploading = ref(false); const uploading = ref(false);
const uploadingImage = ref(false);
const compressing = ref(false);
const uploadProgress = ref(1);
const submitError = ref(''); const submitError = ref('');
useEventListener(window, 'beforeunload', (ev) => {
if (uploading.value) {
ev.preventDefault();
}
});
async function submit() { async function submit() {
uploading.value = true; uploading.value = true;
submitError.value = ''; submitError.value = '';
uploadProgress.value = 0;
const body = new FormData(); const body = new FormData();
body.set('lang', locale.value); body.set('lang', locale.value);
@@ -74,27 +87,38 @@ async function submit() {
body.set('final', 'true'); body.set('final', 'true');
} }
if (newImage.value) { if (newImage.value) {
body.set('image', newImage.value); uploadingImage.value = true;
compressing.value = true;
const compressedImage = await compressImage(newImage.value);
compressing.value = false;
body.set('image', compressedImage);
} }
const xhr = new XMLHttpRequest();
try { try {
await $fetch(`/api/quest/${questSlug?.id}/submit`, { await new Promise((resolve) => {
method: 'POST', xhr.upload.addEventListener('progress', (event) => {
body if (event.lengthComputable) {
uploadProgress.value = event.loaded / event.total;
}
});
xhr.addEventListener('loadend', () => {
resolve(xhr.readyState === 4 && xhr.status === 200);
uploadProgress.value = 1;
});
xhr.open('POST', `/api/quest/${questSlug?.id}/submit`, true);
xhr.withCredentials = true;
xhr.send(body);
}); });
await refresh(); await refresh();
answer.value = data.value?.answer?.text; answer.value = data.value?.answer?.text;
newImage.value = undefined; newImage.value = undefined;
} catch (e) { } catch (e) {
if (e instanceof Error && 'statusCode' in e && e.statusCode === 418) { if (xhr.status === 418) {
submitError.value = t('page.quest.already_finalized'); submitError.value = t('page.quest.already_finalized');
await refresh(); await refresh();
} else if ( } else if (xhr.status === 409) {
e instanceof Error &&
'statusCode' in e &&
e.statusCode === 409
) {
submitError.value = t('page.quest.conflict'); submitError.value = t('page.quest.conflict');
} else { } else {
submitError.value = parseError(e); submitError.value = parseError(e);
@@ -102,11 +126,31 @@ async function submit() {
} }
uploading.value = false; uploading.value = false;
uploadingImage.value = false;
} }
</script> </script>
<template> <template>
<div> <div>
<VaModal
:model-value="uploadingImage"
no-outside-dismiss
hide-default-actions
no-dismiss
>
<h2 class="text-2xl font-bold">{{ t('page.quest.upload_title') }}</h2>
<p v-if="compressing" class="text-lg">
{{ t('page.quest.upload_compress') }}
</p>
<VaProgressCircle
v-else
class="mx-auto"
:model-value="uploadProgress * 100"
size="50dvw"
>
{{ (uploadProgress * 100).toFixed(0) + '%' }}
</VaProgressCircle>
</VaModal>
<VaButtonGroup> <VaButtonGroup>
<VaButton <VaButton
icon="arrow_back" icon="arrow_back"
@@ -230,8 +274,8 @@ async function submit() {
</VaInput> </VaInput>
<VaImage <VaImage
v-if="newImage" v-if="newImageURL"
:src="createImageURL(newImage)" :src="newImageURL"
class="max-h-60 w-full" class="max-h-60 w-full"
fit="contain" fit="contain"
lazy lazy
@@ -279,7 +323,7 @@ async function submit() {
newImage || newImage ||
data.answer?.text !== answer || data.answer?.text !== answer ||
data.answer?.final !== final data.answer?.final !== final
) ) || uploading
" "
:loading="uploading" :loading="uploading"
@click="submit" @click="submit"
+7
View File
@@ -13,6 +13,7 @@
"@vuestic/tailwind": "^0.1.5", "@vuestic/tailwind": "^0.1.5",
"@vueuse/nuxt": "13.6.0", "@vueuse/nuxt": "13.6.0",
"argon2": "^0.43.1", "argon2": "^0.43.1",
"compressorjs": "^1.2.1",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"h3-zod": "^0.5.3", "h3-zod": "^0.5.3",
"minio": "^8.0.5", "minio": "^8.0.5",
@@ -777,6 +778,8 @@
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="], "block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
"blueimp-canvas-to-blob": ["blueimp-canvas-to-blob@3.29.0", "", {}, "sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
@@ -875,6 +878,8 @@
"compress-commons": ["compress-commons@6.0.2", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^6.0.0", "is-stream": "^2.0.1", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg=="], "compress-commons": ["compress-commons@6.0.2", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^6.0.0", "is-stream": "^2.0.1", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg=="],
"compressorjs": ["compressorjs@1.2.1", "", { "dependencies": { "blueimp-canvas-to-blob": "^3.29.0", "is-blob": "^2.1.0" } }, "sha512-+geIjeRnPhQ+LLvvA7wxBQE5ddeLU7pJ3FsKFWirDw6veY3s9iLxAQEw7lXGHnhCJvBujEQWuNnGzZcvCvdkLQ=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
@@ -1295,6 +1300,8 @@
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-blob": ["is-blob@2.1.0", "", {}, "sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw=="],
"is-builtin-module": ["is-builtin-module@5.0.0", "", { "dependencies": { "builtin-modules": "^5.0.0" } }, "sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA=="], "is-builtin-module": ["is-builtin-module@5.0.0", "", { "dependencies": { "builtin-modules": "^5.0.0" } }, "sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA=="],
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
+1
View File
@@ -27,6 +27,7 @@
"@vuestic/tailwind": "^0.1.5", "@vuestic/tailwind": "^0.1.5",
"@vueuse/nuxt": "13.6.0", "@vueuse/nuxt": "13.6.0",
"argon2": "^0.43.1", "argon2": "^0.43.1",
"compressorjs": "^1.2.1",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"h3-zod": "^0.5.3", "h3-zod": "^0.5.3",
"minio": "^8.0.5", "minio": "^8.0.5",
+21
View File
@@ -1 +1,22 @@
import Compressor from 'compressorjs';
export const createImageURL = (file: File) => URL.createObjectURL(file); export const createImageURL = (file: File) => URL.createObjectURL(file);
export async function compressImage(file: File) {
return new Promise<File | Blob>((resolve, reject) => {
if (!file) {
reject();
return;
}
new Compressor(file, {
quality: 0.8,
error(error) {
console.error(error);
reject(error);
},
success(result) {
resolve(result);
}
});
}).catch(() => file);
}