feat(quest): add full CRUD and image handling for hunt quests
This commit is contained in:
@@ -0,0 +1,208 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { parseError } from '#shared/utils/error';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
huntId: number;
|
||||||
|
}>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const { confirm } = useModal();
|
||||||
|
const { init } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: quests,
|
||||||
|
refresh: refreshQuests,
|
||||||
|
pending,
|
||||||
|
execute
|
||||||
|
} = await useFetch(`/api/admin/hunt/${props.huntId}/quest`, {
|
||||||
|
lazy: true,
|
||||||
|
immediate: false,
|
||||||
|
default: () => [] as never[]
|
||||||
|
});
|
||||||
|
|
||||||
|
type QuestListItem = {
|
||||||
|
id: number;
|
||||||
|
title_de: string;
|
||||||
|
title_en: string;
|
||||||
|
order: number;
|
||||||
|
points: number;
|
||||||
|
optional: boolean;
|
||||||
|
pictureRequired: boolean;
|
||||||
|
textRequired: boolean;
|
||||||
|
_count: { answers: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const questModalOpen = ref(false);
|
||||||
|
const editingQuestId = ref<number | null>(null);
|
||||||
|
const questLoading = ref(false);
|
||||||
|
|
||||||
|
function openCreateQuest() {
|
||||||
|
editingQuestId.value = null;
|
||||||
|
questModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditQuest(questId: number) {
|
||||||
|
editingQuestId.value = questId;
|
||||||
|
questModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteQuest(quest: QuestListItem) {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t('page.quest_admin.delete_title'),
|
||||||
|
message: t('page.quest_admin.delete_confirm'),
|
||||||
|
okText: t('auth.accept')
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
questLoading.value = true;
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/admin/hunt/${props.huntId}/quest/${quest.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
init({ message: 'Quest deleted!', color: 'success' });
|
||||||
|
await refreshQuests();
|
||||||
|
} catch (e) {
|
||||||
|
init({
|
||||||
|
title: 'Error',
|
||||||
|
color: 'danger',
|
||||||
|
// @ts-expect-error weird error stuff
|
||||||
|
message: e?.message || e?.statusMessage || t(parseError(e))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
questLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reorderQuest(quest: QuestListItem, direction: 'up' | 'down') {
|
||||||
|
questLoading.value = true;
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/admin/hunt/${props.huntId}/quest/${quest.id}/reorder`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { direction }
|
||||||
|
});
|
||||||
|
await refreshQuests();
|
||||||
|
} catch (e) {
|
||||||
|
init({
|
||||||
|
title: 'Error',
|
||||||
|
color: 'danger',
|
||||||
|
// @ts-expect-error weird error stuff
|
||||||
|
message: e?.message || e?.statusMessage || t(parseError(e))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
questLoading.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-2xl">{{ t('page.quest_admin.quests_title') }}</h2>
|
||||||
|
<VaButton
|
||||||
|
icon="add"
|
||||||
|
color="success"
|
||||||
|
:disabled="questLoading"
|
||||||
|
@click="openCreateQuest"
|
||||||
|
>{{ t('page.quest_admin.add_quest') }}</VaButton
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<VaCollapse
|
||||||
|
class="mt-2"
|
||||||
|
color="primary"
|
||||||
|
header="Quest list"
|
||||||
|
@update:model-value="(isOpen: boolean) => isOpen && execute()"
|
||||||
|
>
|
||||||
|
<p v-if="pending">pending...</p>
|
||||||
|
<div v-if="quests.length > 0" class="mt-4 flex flex-col gap-2">
|
||||||
|
<VaCard
|
||||||
|
v-for="(quest, index) in quests"
|
||||||
|
:key="quest.id"
|
||||||
|
class="flex items-center"
|
||||||
|
>
|
||||||
|
<VaCardContent class="flex w-full items-center gap-2">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<VaButton
|
||||||
|
icon="arrow_upward"
|
||||||
|
size="small"
|
||||||
|
preset="plain"
|
||||||
|
:disabled="index === 0 || questLoading"
|
||||||
|
:title="t('page.quest_admin.move_up')"
|
||||||
|
@click="reorderQuest(quest, 'up')"
|
||||||
|
/>
|
||||||
|
<VaButton
|
||||||
|
icon="arrow_downward"
|
||||||
|
size="small"
|
||||||
|
preset="plain"
|
||||||
|
:disabled="index === quests.length - 1 || questLoading"
|
||||||
|
:title="t('page.quest_admin.move_down')"
|
||||||
|
@click="reorderQuest(quest, 'down')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<VaButton
|
||||||
|
icon="edit"
|
||||||
|
preset="plain"
|
||||||
|
:disabled="questLoading"
|
||||||
|
@click="openEditQuest(quest.id)"
|
||||||
|
/>
|
||||||
|
<VaImage
|
||||||
|
v-if="quest.picture"
|
||||||
|
:src="quest.picture.url"
|
||||||
|
lazy
|
||||||
|
alt="Quest picture"
|
||||||
|
class="h-16 w-24 rounded object-cover"
|
||||||
|
><template #loader><VaProgressCircle indeterminate /></template
|
||||||
|
></VaImage>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p>
|
||||||
|
<span class="font-bold">{{ quest.title_de }}</span
|
||||||
|
><span class="text-secondary font-light"> | </span
|
||||||
|
><span class="font-bold">{{ quest.title_en }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
{{ quest.points }} {{ t('page.quest_admin.points') }} ·
|
||||||
|
{{ t('page.quest_admin.answers', quest._count.answers) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<VaChip v-if="quest.optional" size="small" color="secondary"
|
||||||
|
>Opt</VaChip
|
||||||
|
>
|
||||||
|
<VaChip v-if="quest.pictureRequired" size="small" color="info"
|
||||||
|
>Pic!</VaChip
|
||||||
|
>
|
||||||
|
<VaChip v-if="quest.textRequired" size="small" color="warning"
|
||||||
|
>Txt!</VaChip
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<VaButton
|
||||||
|
icon="edit"
|
||||||
|
size="small"
|
||||||
|
preset="plain"
|
||||||
|
:disabled="questLoading"
|
||||||
|
@click="openEditQuest(quest.id)"
|
||||||
|
/>
|
||||||
|
<VaButton
|
||||||
|
icon="delete"
|
||||||
|
size="small"
|
||||||
|
preset="plain"
|
||||||
|
color="danger"
|
||||||
|
:disabled="questLoading"
|
||||||
|
@click="deleteQuest(quest)"
|
||||||
|
/>
|
||||||
|
</VaCardContent>
|
||||||
|
</VaCard>
|
||||||
|
</div>
|
||||||
|
<p v-else class="mt-4 text-gray-500">
|
||||||
|
{{ t('page.quest_admin.no_quests') }}
|
||||||
|
</p>
|
||||||
|
</VaCollapse>
|
||||||
|
|
||||||
|
<AdminQuestModal
|
||||||
|
v-model="questModalOpen"
|
||||||
|
:hunt-id="Number(props.huntId)"
|
||||||
|
:quest-id="editingQuestId"
|
||||||
|
@saved="refreshQuests"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -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>
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
"400": "Falsche Daten",
|
||||||
"401": "Bitte melde dich vorher an!",
|
"401": "Bitte melde dich vorher an!",
|
||||||
"404": "Nicht gefunden",
|
"404": "Nicht gefunden",
|
||||||
"default": "Unbekannter Fehler",
|
"default": "Unbekannter Fehler",
|
||||||
@@ -190,6 +191,41 @@
|
|||||||
"upload_title": "Antwort wird hochgeladen...",
|
"upload_title": "Antwort wird hochgeladen...",
|
||||||
"your_answer": "Deine Antwort"
|
"your_answer": "Deine Antwort"
|
||||||
},
|
},
|
||||||
|
"quest_admin": {
|
||||||
|
"add_quest": "Quest hinzufügen",
|
||||||
|
"answers": "{n} Antworten",
|
||||||
|
"create_title": "Quest erstellen",
|
||||||
|
"delete_confirm": "Möchtest du diese Quest wirklich löschen? Alle Antworten gehen verloren!",
|
||||||
|
"delete_picture": "Bild löschen",
|
||||||
|
"delete_title": "Quest löschen",
|
||||||
|
"description_de": "Beschreibung (Deutsch)",
|
||||||
|
"description_en": "Beschreibung (Englisch)",
|
||||||
|
"edit_title": "Quest bearbeiten",
|
||||||
|
"extra_points": "Extrapunkte",
|
||||||
|
"extra_text_de": "Extra-Text (Deutsch)",
|
||||||
|
"extra_text_en": "Extra-Text (Englisch)",
|
||||||
|
"location": "Ort",
|
||||||
|
"location_link": "Orts-Link (URL)",
|
||||||
|
"move_down": "Nach unten",
|
||||||
|
"move_up": "Nach oben",
|
||||||
|
"no_picture": "Kein Bild hochgeladen",
|
||||||
|
"no_quests": "Noch keine Quests. Füge deine erste Quest hinzu!",
|
||||||
|
"optional": "Optionale Quest",
|
||||||
|
"picture": "Quest-Bild",
|
||||||
|
"picture_required": "Bild erforderlich",
|
||||||
|
"points": "Punkte",
|
||||||
|
"question_de": "Frage (Deutsch)",
|
||||||
|
"question_en": "Frage (Englisch)",
|
||||||
|
"quests_title": "Quests",
|
||||||
|
"save": "Speichern",
|
||||||
|
"text_required": "Textantwort erforderlich",
|
||||||
|
"text_solution": "Text-Lösung",
|
||||||
|
"text_type": "Text-Typ",
|
||||||
|
"text_unit": "Text-Einheit",
|
||||||
|
"title_de": "Titel (Deutsch)",
|
||||||
|
"title_en": "Titel (Englisch)",
|
||||||
|
"upload_picture": "Bild hochladen"
|
||||||
|
},
|
||||||
"showcase": {
|
"showcase": {
|
||||||
"answers": "Antworten",
|
"answers": "Antworten",
|
||||||
"crew": "Crew",
|
"crew": "Crew",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
"400": "Wrong data",
|
||||||
"401": "Please login!",
|
"401": "Please login!",
|
||||||
"404": "Not found",
|
"404": "Not found",
|
||||||
"default": "Unknown error",
|
"default": "Unknown error",
|
||||||
@@ -190,6 +191,41 @@
|
|||||||
"upload_title": "Uploading answer...",
|
"upload_title": "Uploading answer...",
|
||||||
"your_answer": "Your answer"
|
"your_answer": "Your answer"
|
||||||
},
|
},
|
||||||
|
"quest_admin": {
|
||||||
|
"add_quest": "Add Quest",
|
||||||
|
"answers": "{n} answers",
|
||||||
|
"create_title": "Create Quest",
|
||||||
|
"delete_confirm": "Do you really want to delete this quest? All answers will be lost!",
|
||||||
|
"delete_picture": "Delete picture",
|
||||||
|
"delete_title": "Delete Quest",
|
||||||
|
"description_de": "Description (German)",
|
||||||
|
"description_en": "Description (English)",
|
||||||
|
"edit_title": "Edit Quest",
|
||||||
|
"extra_points": "Extra points",
|
||||||
|
"extra_text_de": "Extra text (German)",
|
||||||
|
"extra_text_en": "Extra text (English)",
|
||||||
|
"location": "Location",
|
||||||
|
"location_link": "Location link (URL)",
|
||||||
|
"move_down": "Move down",
|
||||||
|
"move_up": "Move up",
|
||||||
|
"no_picture": "No picture uploaded",
|
||||||
|
"no_quests": "No quests yet. Add your first quest!",
|
||||||
|
"optional": "Optional quest",
|
||||||
|
"picture": "Quest picture",
|
||||||
|
"picture_required": "Picture required",
|
||||||
|
"points": "Points",
|
||||||
|
"question_de": "Question (German)",
|
||||||
|
"question_en": "Question (English)",
|
||||||
|
"quests_title": "Quests",
|
||||||
|
"save": "Save",
|
||||||
|
"text_required": "Text answer required",
|
||||||
|
"text_solution": "Text solution",
|
||||||
|
"text_type": "Text type",
|
||||||
|
"text_unit": "Text unit",
|
||||||
|
"title_de": "Title (German)",
|
||||||
|
"title_en": "Title (English)",
|
||||||
|
"upload_picture": "Upload picture"
|
||||||
|
},
|
||||||
"showcase": {
|
"showcase": {
|
||||||
"answers": "Answers",
|
"answers": "Answers",
|
||||||
"crew": "Crew",
|
"crew": "Crew",
|
||||||
|
|||||||
@@ -73,8 +73,12 @@ async function submit() {
|
|||||||
<VaCardContent>
|
<VaCardContent>
|
||||||
<VaForm class="flex flex-col gap-4">
|
<VaForm class="flex flex-col gap-4">
|
||||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||||
<h3 class="text-center">{{ t('page.create_hunt.de') }}</h3>
|
<h3 class="hidden text-center md:block">
|
||||||
<h3 class="text-center">{{ t('page.create_hunt.en') }}</h3>
|
{{ t('page.create_hunt.de') }}
|
||||||
|
</h3>
|
||||||
|
<h3 class="hidden text-center md:block">
|
||||||
|
{{ t('page.create_hunt.en') }}
|
||||||
|
</h3>
|
||||||
<VaInput
|
<VaInput
|
||||||
v-model="form.name_de"
|
v-model="form.name_de"
|
||||||
:label="t('page.create_hunt.name_de')"
|
:label="t('page.create_hunt.name_de')"
|
||||||
@@ -126,7 +130,7 @@ async function submit() {
|
|||||||
v-model="form.revealAnswers"
|
v-model="form.revealAnswers"
|
||||||
:label="t('page.create_hunt.reveal_answers')"
|
:label="t('page.create_hunt.reveal_answers')"
|
||||||
/>
|
/>
|
||||||
<hr />
|
<VaDivider />
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<VaCheckbox
|
<VaCheckbox
|
||||||
:label="t('page.create_hunt.has_start')"
|
:label="t('page.create_hunt.has_start')"
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import { useTitleStore } from '~/stores/title';
|
|||||||
import { fullDate } from '~~/server/utils/date';
|
import { fullDate } from '~~/server/utils/date';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['auth'],
|
middleware: ['auth']
|
||||||
keepalive: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = useNuxtApp();
|
const app = useNuxtApp();
|
||||||
@@ -380,6 +379,9 @@ async function deleteImage() {
|
|||||||
</VaButtonGroup>
|
</VaButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VaDivider />
|
||||||
|
<AdminQuestList :hunt-id="id" />
|
||||||
|
|
||||||
<VaDivider />
|
<VaDivider />
|
||||||
<h2 class="text-2xl">Hunt admin members:</h2>
|
<h2 class="text-2xl">Hunt admin members:</h2>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useValidatedParams, z, zh } from 'h3-zod';
|
||||||
|
import prisma from '~~/lib/prisma';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUserSession(event);
|
||||||
|
|
||||||
|
const userId = user.user.id;
|
||||||
|
const { huntId, questId } = await useValidatedParams(
|
||||||
|
event,
|
||||||
|
z.object({
|
||||||
|
huntId: zh.numAsString,
|
||||||
|
questId: zh.numAsString
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify membership
|
||||||
|
const quest = await prisma.huntQuest.findUnique({
|
||||||
|
where: {
|
||||||
|
id: questId,
|
||||||
|
huntId,
|
||||||
|
hunt: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
memberId: userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!quest) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.huntQuest.update({
|
||||||
|
where: {
|
||||||
|
id: questId
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
pictureId: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useValidatedParams, z, zh } from 'h3-zod';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import prisma from '~~/lib/prisma';
|
||||||
|
import { minioClient, s3Bucket, s3Host } from '~~/lib/s3';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUserSession(event);
|
||||||
|
|
||||||
|
const userId = user.user.id;
|
||||||
|
const { huntId, questId } = await useValidatedParams(
|
||||||
|
event,
|
||||||
|
z.object({
|
||||||
|
huntId: zh.numAsString,
|
||||||
|
questId: zh.numAsString
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify membership
|
||||||
|
const quest = await prisma.huntQuest.findUnique({
|
||||||
|
where: {
|
||||||
|
id: questId,
|
||||||
|
huntId,
|
||||||
|
hunt: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
memberId: userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!quest) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readFormData(event);
|
||||||
|
const formDataObj = Object.fromEntries(body.entries());
|
||||||
|
|
||||||
|
const { image } = z
|
||||||
|
.object({
|
||||||
|
image: z
|
||||||
|
.file()
|
||||||
|
.mime(['image/png', 'image/jpeg', 'image/jpg'])
|
||||||
|
.max(10_000_000)
|
||||||
|
})
|
||||||
|
.parse(formDataObj);
|
||||||
|
|
||||||
|
const uuid = randomUUID();
|
||||||
|
await minioClient.putObject(
|
||||||
|
s3Bucket,
|
||||||
|
uuid,
|
||||||
|
Buffer.from(await image.arrayBuffer()),
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||||
|
'Content-Type': image.type
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.huntQuest.update({
|
||||||
|
where: {
|
||||||
|
id: questId
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
picture: {
|
||||||
|
create: {
|
||||||
|
creatorId: userId,
|
||||||
|
url: `https://${s3Bucket}.${s3Host}/${uuid}`,
|
||||||
|
uuid,
|
||||||
|
questId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useValidatedParams, z, zh } from 'h3-zod';
|
||||||
|
import prisma from '~~/lib/prisma';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUserSession(event);
|
||||||
|
|
||||||
|
const userId = user.user.id;
|
||||||
|
const { huntId, questId } = await useValidatedParams(
|
||||||
|
event,
|
||||||
|
z.object({
|
||||||
|
huntId: zh.numAsString,
|
||||||
|
questId: zh.numAsString
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify membership and get quest order
|
||||||
|
const quest = await prisma.huntQuest.findUnique({
|
||||||
|
where: {
|
||||||
|
id: questId,
|
||||||
|
huntId,
|
||||||
|
hunt: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
memberId: userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
order: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!quest) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the quest
|
||||||
|
await prisma.huntQuest.delete({
|
||||||
|
where: {
|
||||||
|
id: questId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reorder remaining quests
|
||||||
|
await prisma.huntQuest.updateMany({
|
||||||
|
where: {
|
||||||
|
huntId,
|
||||||
|
order: {
|
||||||
|
gt: quest.order
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
order: {
|
||||||
|
decrement: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useValidatedParams, z, zh } from 'h3-zod';
|
||||||
|
import prisma from '~~/lib/prisma';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUserSession(event);
|
||||||
|
|
||||||
|
const userId = user.user.id;
|
||||||
|
const { huntId, questId } = await useValidatedParams(
|
||||||
|
event,
|
||||||
|
z.object({
|
||||||
|
huntId: zh.numAsString,
|
||||||
|
questId: zh.numAsString
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const quest = await prisma.huntQuest.findUnique({
|
||||||
|
where: {
|
||||||
|
id: questId,
|
||||||
|
huntId,
|
||||||
|
hunt: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
memberId: userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title_de: true,
|
||||||
|
title_en: true,
|
||||||
|
description_de: true,
|
||||||
|
description_en: true,
|
||||||
|
question_de: true,
|
||||||
|
question_en: true,
|
||||||
|
location: true,
|
||||||
|
locationLink: true,
|
||||||
|
order: true,
|
||||||
|
optional: true,
|
||||||
|
pictureRequired: true,
|
||||||
|
textRequired: true,
|
||||||
|
textType: true,
|
||||||
|
textUnit: true,
|
||||||
|
textSolution: true,
|
||||||
|
points: true,
|
||||||
|
extraPoints: true,
|
||||||
|
extraText_de: true,
|
||||||
|
extraText_en: true,
|
||||||
|
picture: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
url: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!quest) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return quest;
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useValidatedBody, useValidatedParams, z, zh } from 'h3-zod';
|
||||||
|
import prisma from '~~/lib/prisma';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUserSession(event);
|
||||||
|
|
||||||
|
const userId = user.user.id;
|
||||||
|
const { huntId, questId } = await useValidatedParams(
|
||||||
|
event,
|
||||||
|
z.object({
|
||||||
|
huntId: zh.numAsString,
|
||||||
|
questId: zh.numAsString
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify membership
|
||||||
|
const quest = await prisma.huntQuest.findUnique({
|
||||||
|
where: {
|
||||||
|
id: questId,
|
||||||
|
huntId,
|
||||||
|
hunt: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
memberId: userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!quest) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await useValidatedBody(
|
||||||
|
event,
|
||||||
|
z.object({
|
||||||
|
title_de: z.string().min(1).max(256).trim(),
|
||||||
|
title_en: z.string().min(1).max(256).trim(),
|
||||||
|
description_de: z.string().max(2000).trim().default(''),
|
||||||
|
description_en: z.string().max(2000).trim().default(''),
|
||||||
|
question_de: z.string().max(1000).trim().nullish(),
|
||||||
|
question_en: z.string().max(1000).trim().nullish(),
|
||||||
|
location: z.string().max(256).trim().nullish(),
|
||||||
|
locationLink: z.string().url().nullish(),
|
||||||
|
optional: z.boolean().default(true),
|
||||||
|
pictureRequired: z.boolean().default(false),
|
||||||
|
textRequired: z.boolean().default(false),
|
||||||
|
textType: z
|
||||||
|
.enum(['TEXT', 'WORD', 'CHAR', 'NUMBER', 'INTEGER'])
|
||||||
|
.default('TEXT'),
|
||||||
|
textUnit: z.string().max(64).trim().nullish(),
|
||||||
|
textSolution: z.string().max(256).trim().nullish(),
|
||||||
|
points: z.number().int().min(0).max(32767).default(10),
|
||||||
|
extraPoints: z.number().int().min(0).max(32767).nullish(),
|
||||||
|
extraText_de: z.string().max(256).trim().nullish(),
|
||||||
|
extraText_en: z.string().max(256).trim().nullish()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return await prisma.huntQuest.update({
|
||||||
|
where: {
|
||||||
|
id: questId
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title_de: true,
|
||||||
|
title_en: true,
|
||||||
|
order: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useValidatedBody, useValidatedParams, z, zh } from 'h3-zod';
|
||||||
|
import prisma from '~~/lib/prisma';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUserSession(event);
|
||||||
|
|
||||||
|
const userId = user.user.id;
|
||||||
|
const { huntId, questId } = await useValidatedParams(
|
||||||
|
event,
|
||||||
|
z.object({
|
||||||
|
huntId: zh.numAsString,
|
||||||
|
questId: zh.numAsString
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { direction } = await useValidatedBody(
|
||||||
|
event,
|
||||||
|
z.object({
|
||||||
|
direction: z.enum(['up', 'down'])
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify membership and get quest
|
||||||
|
const quest = await prisma.huntQuest.findUnique({
|
||||||
|
where: {
|
||||||
|
id: questId,
|
||||||
|
huntId,
|
||||||
|
hunt: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
memberId: userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
order: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!quest) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetOrder = direction === 'up' ? quest.order - 1 : quest.order + 1;
|
||||||
|
|
||||||
|
if (targetOrder < 0) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Already at top!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the quest to swap with
|
||||||
|
const swapQuest = await prisma.huntQuest.findFirst({
|
||||||
|
where: {
|
||||||
|
huntId,
|
||||||
|
order: targetOrder
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
order: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!swapQuest) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Already at bottom!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap orders in a transaction
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.huntQuest.update({
|
||||||
|
where: { id: quest.id },
|
||||||
|
data: { order: swapQuest.order }
|
||||||
|
}),
|
||||||
|
prisma.huntQuest.update({
|
||||||
|
where: { id: swapQuest.id },
|
||||||
|
data: { order: quest.order }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useValidatedParams, z, zh } from 'h3-zod';
|
||||||
|
import prisma from '~~/lib/prisma';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUserSession(event);
|
||||||
|
|
||||||
|
const userId = user.user.id;
|
||||||
|
const { huntId } = await useValidatedParams(
|
||||||
|
event,
|
||||||
|
z.object({
|
||||||
|
huntId: zh.numAsString
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify membership
|
||||||
|
const hunt = await prisma.hunt.findUnique({
|
||||||
|
where: {
|
||||||
|
id: huntId,
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
memberId: userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hunt) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.huntQuest.findMany({
|
||||||
|
where: {
|
||||||
|
huntId
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title_de: true,
|
||||||
|
title_en: true,
|
||||||
|
order: true,
|
||||||
|
points: true,
|
||||||
|
optional: true,
|
||||||
|
pictureRequired: true,
|
||||||
|
textRequired: true,
|
||||||
|
picture: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
url: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
answers: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
order: 'asc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useValidatedBody, useValidatedParams, z, zh } from 'h3-zod';
|
||||||
|
import prisma from '~~/lib/prisma';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUserSession(event);
|
||||||
|
|
||||||
|
const userId = user.user.id;
|
||||||
|
const { huntId } = await useValidatedParams(
|
||||||
|
event,
|
||||||
|
z.object({
|
||||||
|
huntId: zh.numAsString
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify membership
|
||||||
|
const hunt = await prisma.hunt.findUnique({
|
||||||
|
where: {
|
||||||
|
id: huntId,
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
memberId: userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
quests: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hunt) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await useValidatedBody(
|
||||||
|
event,
|
||||||
|
z.object({
|
||||||
|
title_de: z.string().min(1).max(256).trim(),
|
||||||
|
title_en: z.string().min(1).max(256).trim(),
|
||||||
|
description_de: z.string().max(2000).trim().default(''),
|
||||||
|
description_en: z.string().max(2000).trim().default(''),
|
||||||
|
question_de: z.string().max(1000).trim().nullish(),
|
||||||
|
question_en: z.string().max(1000).trim().nullish(),
|
||||||
|
location: z.string().max(256).trim().nullish(),
|
||||||
|
locationLink: z.string().url().nullish(),
|
||||||
|
optional: z.boolean().default(true),
|
||||||
|
pictureRequired: z.boolean().default(false),
|
||||||
|
textRequired: z.boolean().default(false),
|
||||||
|
textType: z
|
||||||
|
.enum(['TEXT', 'WORD', 'CHAR', 'NUMBER', 'INTEGER'])
|
||||||
|
.default('TEXT'),
|
||||||
|
textUnit: z.string().max(64).trim().nullish(),
|
||||||
|
textSolution: z.string().max(256).trim().nullish(),
|
||||||
|
points: z.number().int().min(0).max(32767).default(10),
|
||||||
|
extraPoints: z.number().int().min(0).max(32767).nullish(),
|
||||||
|
extraText_de: z.string().max(256).trim().nullish(),
|
||||||
|
extraText_en: z.string().max(256).trim().nullish()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return await prisma.huntQuest.create({
|
||||||
|
data: {
|
||||||
|
...body,
|
||||||
|
huntId,
|
||||||
|
order: hunt._count.quests
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title_de: true,
|
||||||
|
title_en: true,
|
||||||
|
order: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@ export function parseError(e: unknown): string {
|
|||||||
return 'error.401';
|
return 'error.401';
|
||||||
case 404:
|
case 404:
|
||||||
return 'error.404';
|
return 'error.404';
|
||||||
|
case 400:
|
||||||
|
return 'error.400';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 'error.default';
|
return 'error.default';
|
||||||
|
|||||||
Reference in New Issue
Block a user