Files
pichunt/app/components/admin/QuestModal.vue
T

388 lines
10 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: '',
isBingo: false,
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,
isBingo: quest.isBingo,
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.isBingo"
:label="t('page.quest_admin.is_bingo')"
/>
<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>