diff --git a/app/components/admin/QuestList.vue b/app/components/admin/QuestList.vue new file mode 100644 index 0000000..2229c7c --- /dev/null +++ b/app/components/admin/QuestList.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/app/components/admin/QuestModal.vue b/app/components/admin/QuestModal.vue new file mode 100644 index 0000000..484ef72 --- /dev/null +++ b/app/components/admin/QuestModal.vue @@ -0,0 +1,381 @@ + + + diff --git a/app/lang/de.json b/app/lang/de.json index dadf2d5..a04ae48 100644 --- a/app/lang/de.json +++ b/app/lang/de.json @@ -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}" } -} \ No newline at end of file +} diff --git a/app/lang/en.json b/app/lang/en.json index 3889b28..7c0280d 100644 --- a/app/lang/en.json +++ b/app/lang/en.json @@ -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}" } -} \ No newline at end of file +} diff --git a/app/pages/admin/hunt/create.vue b/app/pages/admin/hunt/create.vue index 5e3d2fe..3d45870 100644 --- a/app/pages/admin/hunt/create.vue +++ b/app/pages/admin/hunt/create.vue @@ -73,8 +73,12 @@ async function submit() {
-

{{ t('page.create_hunt.de') }}

-

{{ t('page.create_hunt.en') }}

+ + -
+
+ + +

Hunt admin members:

{ + 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 }; +}); diff --git a/server/api/admin/hunt/[huntId]/quest/[questId]/image.post.ts b/server/api/admin/hunt/[huntId]/quest/[questId]/image.post.ts new file mode 100644 index 0000000..8446f30 --- /dev/null +++ b/server/api/admin/hunt/[huntId]/quest/[questId]/image.post.ts @@ -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 }; +}); diff --git a/server/api/admin/hunt/[huntId]/quest/[questId]/index.delete.ts b/server/api/admin/hunt/[huntId]/quest/[questId]/index.delete.ts new file mode 100644 index 0000000..2a87d5b --- /dev/null +++ b/server/api/admin/hunt/[huntId]/quest/[questId]/index.delete.ts @@ -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 }; +}); diff --git a/server/api/admin/hunt/[huntId]/quest/[questId]/index.get.ts b/server/api/admin/hunt/[huntId]/quest/[questId]/index.get.ts new file mode 100644 index 0000000..1f8e7ac --- /dev/null +++ b/server/api/admin/hunt/[huntId]/quest/[questId]/index.get.ts @@ -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; +}); diff --git a/server/api/admin/hunt/[huntId]/quest/[questId]/index.put.ts b/server/api/admin/hunt/[huntId]/quest/[questId]/index.put.ts new file mode 100644 index 0000000..f0b14ce --- /dev/null +++ b/server/api/admin/hunt/[huntId]/quest/[questId]/index.put.ts @@ -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 + } + }); +}); diff --git a/server/api/admin/hunt/[huntId]/quest/[questId]/reorder.patch.ts b/server/api/admin/hunt/[huntId]/quest/[questId]/reorder.patch.ts new file mode 100644 index 0000000..6d9a7d1 --- /dev/null +++ b/server/api/admin/hunt/[huntId]/quest/[questId]/reorder.patch.ts @@ -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 }; +}); diff --git a/server/api/admin/hunt/[huntId]/quest/index.get.ts b/server/api/admin/hunt/[huntId]/quest/index.get.ts new file mode 100644 index 0000000..d83a555 --- /dev/null +++ b/server/api/admin/hunt/[huntId]/quest/index.get.ts @@ -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' + } + }); +}); diff --git a/server/api/admin/hunt/[huntId]/quest/index.post.ts b/server/api/admin/hunt/[huntId]/quest/index.post.ts new file mode 100644 index 0000000..cb9e587 --- /dev/null +++ b/server/api/admin/hunt/[huntId]/quest/index.post.ts @@ -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 + } + }); +}); diff --git a/shared/utils/error.ts b/shared/utils/error.ts index 30ac522..5e9e718 100644 --- a/shared/utils/error.ts +++ b/shared/utils/error.ts @@ -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';