feat(quest): add full CRUD and image handling for hunt quests
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
<script setup lang="ts">
|
||||
import type { TextType } from '#shared/generated/prisma/enums';
|
||||
import { useForm } from 'vuestic-ui';
|
||||
|
||||
const props = defineProps<{
|
||||
huntId: number;
|
||||
questId?: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { init } = useToast();
|
||||
const validation = useValidation();
|
||||
|
||||
const { validate } = useForm('formRef');
|
||||
const showModal = defineModel<boolean>('modelValue', { default: false });
|
||||
|
||||
const textTypeOptions: { value: TextType }[] = [
|
||||
{ value: 'TEXT' },
|
||||
{ value: 'WORD' },
|
||||
{ value: 'CHAR' },
|
||||
{ value: 'NUMBER' },
|
||||
{ value: 'INTEGER' }
|
||||
];
|
||||
|
||||
const defaultForm = () => ({
|
||||
title_de: '',
|
||||
title_en: '',
|
||||
description_de: '',
|
||||
description_en: '',
|
||||
question_de: '',
|
||||
question_en: '',
|
||||
location: '',
|
||||
locationLink: '',
|
||||
optional: true,
|
||||
pictureRequired: false,
|
||||
textRequired: false,
|
||||
textType: 'TEXT' as TextType,
|
||||
textUnit: '',
|
||||
textSolution: '',
|
||||
points: 10,
|
||||
extraPoints: null as number | null,
|
||||
extraText_de: '',
|
||||
extraText_en: ''
|
||||
});
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
const loading = ref(false);
|
||||
const formError = ref('');
|
||||
|
||||
const isEdit = computed(() => !!props.questId);
|
||||
|
||||
// Image management
|
||||
const currentPicture = ref<{ id: number; url: string } | null>(null);
|
||||
const newImage = ref<File>();
|
||||
const uploading = ref(false);
|
||||
const imageError = ref('');
|
||||
|
||||
watch(showModal, async (visible) => {
|
||||
if (visible && props.questId) {
|
||||
// Load quest data for editing
|
||||
loading.value = true;
|
||||
formError.value = '';
|
||||
try {
|
||||
const quest = await $fetch(
|
||||
`/api/admin/hunt/${props.huntId}/quest/${props.questId}`
|
||||
);
|
||||
Object.assign(form, {
|
||||
title_de: quest.title_de,
|
||||
title_en: quest.title_en,
|
||||
description_de: quest.description_de,
|
||||
description_en: quest.description_en,
|
||||
question_de: quest.question_de ?? '',
|
||||
question_en: quest.question_en ?? '',
|
||||
location: quest.location ?? '',
|
||||
locationLink: quest.locationLink ?? '',
|
||||
optional: quest.optional,
|
||||
pictureRequired: quest.pictureRequired,
|
||||
textRequired: quest.textRequired,
|
||||
textType: quest.textType,
|
||||
textUnit: quest.textUnit ?? '',
|
||||
textSolution: quest.textSolution ?? '',
|
||||
points: quest.points,
|
||||
extraPoints: quest.extraPoints,
|
||||
extraText_de: quest.extraText_de ?? '',
|
||||
extraText_en: quest.extraText_en ?? ''
|
||||
});
|
||||
currentPicture.value = quest.picture;
|
||||
} catch (e) {
|
||||
formError.value = parseError(e);
|
||||
}
|
||||
loading.value = false;
|
||||
} else if (visible) {
|
||||
// Reset form for creating
|
||||
Object.assign(form, defaultForm());
|
||||
formError.value = '';
|
||||
currentPicture.value = null;
|
||||
newImage.value = undefined;
|
||||
imageError.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
loading.value = true;
|
||||
formError.value = '';
|
||||
|
||||
// Convert empty strings to null for optional fields
|
||||
const body = {
|
||||
...form,
|
||||
question_de: form.question_de || null,
|
||||
question_en: form.question_en || null,
|
||||
location: form.location || null,
|
||||
locationLink: form.locationLink || null,
|
||||
textUnit: form.textUnit || null,
|
||||
textSolution: form.textSolution || null,
|
||||
extraText_de: form.extraText_de || null,
|
||||
extraText_en: form.extraText_en || null
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await $fetch(`/api/admin/hunt/${props.huntId}/quest/${props.questId}`, {
|
||||
method: 'PUT',
|
||||
body
|
||||
});
|
||||
} else {
|
||||
await $fetch(`/api/admin/hunt/${props.huntId}/quest`, {
|
||||
method: 'POST',
|
||||
body
|
||||
});
|
||||
}
|
||||
|
||||
init({
|
||||
message: isEdit.value ? 'Quest updated!' : 'Quest created!',
|
||||
color: 'success'
|
||||
});
|
||||
|
||||
showModal.value = false;
|
||||
emit('saved');
|
||||
} catch (e) {
|
||||
formError.value = parseError(e);
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function uploadImage() {
|
||||
if (!newImage.value || !props.questId) {
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
imageError.value = '';
|
||||
|
||||
const body = new FormData();
|
||||
body.set('image', newImage.value);
|
||||
|
||||
try {
|
||||
await $fetch(
|
||||
`/api/admin/hunt/${props.huntId}/quest/${props.questId}/image`,
|
||||
{ method: 'POST', body }
|
||||
);
|
||||
// Reload quest to get updated picture
|
||||
const quest = await $fetch(
|
||||
`/api/admin/hunt/${props.huntId}/quest/${props.questId}`
|
||||
);
|
||||
currentPicture.value = quest.picture;
|
||||
newImage.value = undefined;
|
||||
} catch (e) {
|
||||
imageError.value = parseError(e);
|
||||
}
|
||||
uploading.value = false;
|
||||
}
|
||||
|
||||
async function deleteImage() {
|
||||
if (!props.questId) {
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
imageError.value = '';
|
||||
|
||||
try {
|
||||
await $fetch(
|
||||
`/api/admin/hunt/${props.huntId}/quest/${props.questId}/image`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
currentPicture.value = null;
|
||||
newImage.value = undefined;
|
||||
} catch (e) {
|
||||
imageError.value = parseError(e);
|
||||
}
|
||||
uploading.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VaModal
|
||||
v-model="showModal"
|
||||
:title="
|
||||
isEdit
|
||||
? t('page.quest_admin.edit_title')
|
||||
: t('page.quest_admin.create_title')
|
||||
"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
hide-default-actions
|
||||
>
|
||||
<template #footer>
|
||||
<div class="flex flex-row gap-2">
|
||||
<VaButton
|
||||
preset="secondary"
|
||||
color="secondary"
|
||||
@click="() => (showModal = false)"
|
||||
>{{ t('page.common.cancel') }}</VaButton
|
||||
>
|
||||
<VaButton color="primary" @click="validate() && submit()">{{
|
||||
t('page.quest_admin.save')
|
||||
}}</VaButton>
|
||||
</div>
|
||||
</template>
|
||||
<VaForm ref="formRef" class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<h3 class="hidden text-center md:block">
|
||||
{{ t('page.create_hunt.de') }}
|
||||
</h3>
|
||||
<h3 class="hidden text-center md:block">
|
||||
{{ t('page.create_hunt.en') }}
|
||||
</h3>
|
||||
<VaInput
|
||||
v-model="form.title_de"
|
||||
:label="t('page.quest_admin.title_de')"
|
||||
:rules="[validation.required(t('page.quest_admin.title_de'))]"
|
||||
/>
|
||||
<VaInput
|
||||
v-model="form.title_en"
|
||||
:label="t('page.quest_admin.title_en')"
|
||||
:rules="[validation.required(t('page.quest_admin.title_en'))]"
|
||||
/>
|
||||
<VaTextarea
|
||||
v-model="form.description_de"
|
||||
:label="t('page.quest_admin.description_de')"
|
||||
/>
|
||||
<VaTextarea
|
||||
v-model="form.description_en"
|
||||
:label="t('page.quest_admin.description_en')"
|
||||
/>
|
||||
<VaTextarea
|
||||
v-model="form.question_de"
|
||||
:label="t('page.quest_admin.question_de')"
|
||||
/>
|
||||
<VaTextarea
|
||||
v-model="form.question_en"
|
||||
:label="t('page.quest_admin.question_en')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VaInput
|
||||
v-model="form.location"
|
||||
:label="t('page.quest_admin.location')"
|
||||
/>
|
||||
<VaInput
|
||||
v-model="form.locationLink"
|
||||
:label="t('page.quest_admin.location_link')"
|
||||
type="url"
|
||||
/>
|
||||
|
||||
<VaDivider />
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<VaInput
|
||||
v-model.number="form.points"
|
||||
:label="t('page.quest_admin.points')"
|
||||
type="number"
|
||||
/>
|
||||
<VaInput
|
||||
v-model.number="form.extraPoints"
|
||||
:label="t('page.quest_admin.extra_points')"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<VaInput
|
||||
v-model="form.extraText_de"
|
||||
:label="t('page.quest_admin.extra_text_de')"
|
||||
/>
|
||||
<VaInput
|
||||
v-model="form.extraText_en"
|
||||
:label="t('page.quest_admin.extra_text_en')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VaDivider />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<VaCheckbox
|
||||
v-model="form.optional"
|
||||
:label="t('page.quest_admin.optional')"
|
||||
/>
|
||||
<VaCheckbox
|
||||
v-model="form.pictureRequired"
|
||||
:label="t('page.quest_admin.picture_required')"
|
||||
/>
|
||||
<VaCheckbox
|
||||
v-model="form.textRequired"
|
||||
:label="t('page.quest_admin.text_required')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VaSelect
|
||||
v-model="form.textType"
|
||||
:label="t('page.quest_admin.text_type')"
|
||||
:options="textTypeOptions"
|
||||
track-by="value"
|
||||
value-by="value"
|
||||
:text-by="
|
||||
(opt) =>
|
||||
t(
|
||||
`enums.text_type.${(opt as (typeof textTypeOptions)[number]).value}`
|
||||
)
|
||||
"
|
||||
/>
|
||||
<VaInput
|
||||
v-model="form.textUnit"
|
||||
:label="t('page.quest_admin.text_unit')"
|
||||
/>
|
||||
<VaInput
|
||||
v-model="form.textSolution"
|
||||
:label="t('page.quest_admin.text_solution')"
|
||||
/>
|
||||
|
||||
<VaDivider />
|
||||
|
||||
<div v-if="isEdit" class="flex flex-col gap-2">
|
||||
<h3 class="text-lg font-bold">{{ t('page.quest_admin.picture') }}</h3>
|
||||
<div v-if="currentPicture" class="flex items-center gap-4">
|
||||
<img
|
||||
:src="currentPicture.url"
|
||||
alt="Quest picture"
|
||||
class="h-24 w-32 rounded object-cover"
|
||||
/>
|
||||
<VaButton
|
||||
color="danger"
|
||||
preset="secondary"
|
||||
:loading="uploading"
|
||||
@click="deleteImage"
|
||||
>{{ t('page.quest_admin.delete_picture') }}</VaButton
|
||||
>
|
||||
</div>
|
||||
<p v-else class="text-sm text-gray-500">
|
||||
{{ t('page.quest_admin.no_picture') }}
|
||||
</p>
|
||||
<VaFileUpload
|
||||
v-model="newImage"
|
||||
type="single"
|
||||
file-types="jpg,png,jpeg"
|
||||
:disabled="uploading"
|
||||
/>
|
||||
<VaButton
|
||||
v-if="newImage"
|
||||
color="primary"
|
||||
:loading="uploading"
|
||||
@click="uploadImage"
|
||||
>{{ t('page.quest_admin.upload_picture') }}</VaButton
|
||||
>
|
||||
<VaAlert v-if="imageError" color="danger" class="w-full text-center">{{
|
||||
t(imageError)
|
||||
}}</VaAlert>
|
||||
</div>
|
||||
|
||||
<VaAlert
|
||||
v-if="formError.length > 0"
|
||||
color="danger"
|
||||
class="w-full text-center"
|
||||
>{{ t(formError) }}</VaAlert
|
||||
>
|
||||
</VaForm>
|
||||
</VaModal>
|
||||
</template>
|
||||
Reference in New Issue
Block a user