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>
|
||||
+37
-1
@@ -49,6 +49,7 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"400": "Falsche Daten",
|
||||
"401": "Bitte melde dich vorher an!",
|
||||
"404": "Nicht gefunden",
|
||||
"default": "Unbekannter Fehler",
|
||||
@@ -190,6 +191,41 @@
|
||||
"upload_title": "Antwort wird hochgeladen...",
|
||||
"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": {
|
||||
"answers": "Antworten",
|
||||
"crew": "Crew",
|
||||
@@ -295,4 +331,4 @@
|
||||
"uploadFile": "Datei hochladen",
|
||||
"voteRating": "vote rating {value} of {max}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+37
-1
@@ -49,6 +49,7 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"400": "Wrong data",
|
||||
"401": "Please login!",
|
||||
"404": "Not found",
|
||||
"default": "Unknown error",
|
||||
@@ -190,6 +191,41 @@
|
||||
"upload_title": "Uploading 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": {
|
||||
"answers": "Answers",
|
||||
"crew": "Crew",
|
||||
@@ -295,4 +331,4 @@
|
||||
"uploadFile": "Upload file",
|
||||
"voteRating": "vote rating {value} of {max}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +73,12 @@ async function submit() {
|
||||
<VaCardContent>
|
||||
<VaForm class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<h3 class="text-center">{{ t('page.create_hunt.de') }}</h3>
|
||||
<h3 class="text-center">{{ t('page.create_hunt.en') }}</h3>
|
||||
<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.name_de"
|
||||
:label="t('page.create_hunt.name_de')"
|
||||
@@ -126,7 +130,7 @@ async function submit() {
|
||||
v-model="form.revealAnswers"
|
||||
:label="t('page.create_hunt.reveal_answers')"
|
||||
/>
|
||||
<hr />
|
||||
<VaDivider />
|
||||
<div class="flex flex-row gap-2">
|
||||
<VaCheckbox
|
||||
:label="t('page.create_hunt.has_start')"
|
||||
|
||||
@@ -8,8 +8,7 @@ import { useTitleStore } from '~/stores/title';
|
||||
import { fullDate } from '~~/server/utils/date';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth'],
|
||||
keepalive: false
|
||||
middleware: ['auth']
|
||||
});
|
||||
|
||||
const app = useNuxtApp();
|
||||
@@ -380,6 +379,9 @@ async function deleteImage() {
|
||||
</VaButtonGroup>
|
||||
</div>
|
||||
|
||||
<VaDivider />
|
||||
<AdminQuestList :hunt-id="id" />
|
||||
|
||||
<VaDivider />
|
||||
<h2 class="text-2xl">Hunt admin members:</h2>
|
||||
<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';
|
||||
case 404:
|
||||
return 'error.404';
|
||||
case 400:
|
||||
return 'error.400';
|
||||
}
|
||||
}
|
||||
return 'error.default';
|
||||
|
||||
Reference in New Issue
Block a user