382 lines
9.8 KiB
Vue
382 lines
9.8 KiB
Vue
<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>
|