mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-13 08:12:40 +10:00
feat(metadata): change name, description and icon
This commit is contained in:
@ -118,7 +118,6 @@ import {
|
|||||||
TransitionChild,
|
TransitionChild,
|
||||||
TransitionRoot,
|
TransitionRoot,
|
||||||
} from "@headlessui/vue";
|
} from "@headlessui/vue";
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/vue/24/outline";
|
|
||||||
import { ArrowUpTrayIcon } from "@heroicons/vue/20/solid";
|
import { ArrowUpTrayIcon } from "@heroicons/vue/20/solid";
|
||||||
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
||||||
|
|
||||||
|
|||||||
@ -4,12 +4,28 @@
|
|||||||
class="grow flex flex-col gap-y-8"
|
class="grow flex flex-col gap-y-8"
|
||||||
>
|
>
|
||||||
<div class="grow w-full h-full lg:pr-[30vw] px-6 py-4 flex flex-col">
|
<div class="grow w-full h-full lg:pr-[30vw] px-6 py-4 flex flex-col">
|
||||||
|
<div
|
||||||
|
class="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2"
|
||||||
|
>
|
||||||
|
<div class="inline-flex items-center gap-4">
|
||||||
|
<img :src="useObject(game.mIconId)" class="size-20" />
|
||||||
|
<div>
|
||||||
<h1 class="text-5xl font-bold font-display text-zinc-100">
|
<h1 class="text-5xl font-bold font-display text-zinc-100">
|
||||||
{{ game.mName }}
|
{{ game.mName }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-1 text-lg text-zinc-400">
|
<p class="mt-1 text-lg text-zinc-400">
|
||||||
{{ game.mShortDescription }}
|
{{ game.mShortDescription }}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="() => (showEditCoreMetadata = true)"
|
||||||
|
type="button"
|
||||||
|
class="relative inline-flex gap-x-3 items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
>
|
||||||
|
Edit <PencilIcon class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- image carousel pick -->
|
<!-- image carousel pick -->
|
||||||
<div class="border-b border-zinc-700">
|
<div class="border-b border-zinc-700">
|
||||||
@ -77,13 +93,13 @@
|
|||||||
<div
|
<div
|
||||||
class="h-8 bg-zinc-800 rounded inline-flex gap-x-4 items-center justify-start p-2"
|
class="h-8 bg-zinc-800 rounded inline-flex gap-x-4 items-center justify-start p-2"
|
||||||
>
|
>
|
||||||
<button>
|
<div>
|
||||||
<CheckIcon
|
<CheckIcon
|
||||||
v-if="descriptionSaving == 0"
|
v-if="descriptionSaving == 0"
|
||||||
class="size-5 text-zinc-100"
|
class="size-5 text-zinc-100"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="descriptionSaving == 1">
|
<div v-else-if="descriptionSaving == 1">
|
||||||
<div class="animate-pulse w-5 h-[3px] bg-zinc-100 rounded-full" />
|
<PencilIcon class="animate-pulse size-5 text-zinc-100" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="descriptionSaving == 2" role="status">
|
<div v-else-if="descriptionSaving == 2" role="status">
|
||||||
<svg
|
<svg
|
||||||
@ -104,7 +120,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="sr-only">Loading...</span>
|
<span class="sr-only">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<button @click="() => (showAddImageDescriptionModal = true)">
|
<button @click="() => (showAddImageDescriptionModal = true)">
|
||||||
<PhotoIcon
|
<PhotoIcon
|
||||||
@ -332,11 +348,9 @@
|
|||||||
/>
|
/>
|
||||||
<ModalTemplate v-model="showAddCarouselModal">
|
<ModalTemplate v-model="showAddCarouselModal">
|
||||||
<template #default>
|
<template #default>
|
||||||
<div class="mt-3 grid grid-cols-2 grid-flow-dense gap-4">
|
<div class="grid grid-cols-2 grid-flow-dense gap-4">
|
||||||
<div
|
<div
|
||||||
v-for="(image, imageIdx) in game.mImageLibrary.filter(
|
v-for="(image, imageIdx) in validAddCarouselImages"
|
||||||
(e) => !game.mImageCarousel.includes(e)
|
|
||||||
)"
|
|
||||||
:key="image"
|
:key="image"
|
||||||
class="group relative flex items-center bg-zinc-950/30"
|
class="group relative flex items-center bg-zinc-950/30"
|
||||||
>
|
>
|
||||||
@ -353,6 +367,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="validAddCarouselImages.length == 0"
|
||||||
|
class="text-zinc-400 col-span-2"
|
||||||
|
>
|
||||||
|
No images to add.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
@ -368,7 +388,7 @@
|
|||||||
</ModalTemplate>
|
</ModalTemplate>
|
||||||
<ModalTemplate v-model="showAddImageDescriptionModal">
|
<ModalTemplate v-model="showAddImageDescriptionModal">
|
||||||
<template #default>
|
<template #default>
|
||||||
<div class="mt-3 grid grid-cols-2 grid-flow-dense gap-4">
|
<div class="grid grid-cols-2 grid-flow-dense gap-4">
|
||||||
<div
|
<div
|
||||||
v-for="(image, imageIdx) in game.mImageLibrary"
|
v-for="(image, imageIdx) in game.mImageLibrary"
|
||||||
:key="image"
|
:key="image"
|
||||||
@ -387,6 +407,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="game.mImageLibrary.length == 0"
|
||||||
|
class="text-zinc-400 col-span-2"
|
||||||
|
>
|
||||||
|
No images to add.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
@ -400,6 +426,82 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</ModalTemplate>
|
</ModalTemplate>
|
||||||
|
<ModalTemplate v-model="showEditCoreMetadata">
|
||||||
|
<template #default>
|
||||||
|
<div class="flex flex-col lg:flex-row gap-6">
|
||||||
|
<!-- icon upload div -->
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<img :src="coreMetadataIconUrl" class="size-24 aspect-square" />
|
||||||
|
<label for="file-upload">
|
||||||
|
<span
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
accept="image/*"
|
||||||
|
@change="(e) => coreMetadataUploadFiles(e as any)"
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
id="file-upload"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<!-- edit title -->
|
||||||
|
<div class="flex flex-col gap-y-4 grow">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm/6 font-medium text-zinc-100"
|
||||||
|
>Game Name</label
|
||||||
|
>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
id="name"
|
||||||
|
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||||
|
v-model="coreMetadataName"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="description"
|
||||||
|
class="block text-sm/6 font-medium text-zinc-100"
|
||||||
|
>Game Description</label
|
||||||
|
>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="description"
|
||||||
|
id="description"
|
||||||
|
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||||
|
v-model="coreMetadataDescription"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #buttons>
|
||||||
|
<LoadingButton
|
||||||
|
type="button"
|
||||||
|
:loading="coreMetadataLoading"
|
||||||
|
@click="() => coreMetadataUpdate_wrapper()"
|
||||||
|
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</LoadingButton>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 sm:mt-0 sm:w-auto"
|
||||||
|
@click="showEditCoreMetadata = false"
|
||||||
|
ref="cancelButtonRef"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</ModalTemplate>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -421,6 +523,7 @@ definePageMeta({
|
|||||||
const showUploadModal = ref(false);
|
const showUploadModal = ref(false);
|
||||||
const showAddCarouselModal = ref(false);
|
const showAddCarouselModal = ref(false);
|
||||||
const showAddImageDescriptionModal = ref(false);
|
const showAddImageDescriptionModal = ref(false);
|
||||||
|
const showEditCoreMetadata = ref(false);
|
||||||
const mobileShowFinalDescription = ref(true);
|
const mobileShowFinalDescription = ref(true);
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -434,6 +537,80 @@ const { game: rawGame, unimportedVersions } = await $fetch(
|
|||||||
);
|
);
|
||||||
const game = ref(rawGame);
|
const game = ref(rawGame);
|
||||||
|
|
||||||
|
const coreMetadataName = ref(game.value.mName);
|
||||||
|
const coreMetadataDescription = ref(game.value.mShortDescription);
|
||||||
|
const coreMetadataIconUrl = ref(useObject(game.value.mIconId));
|
||||||
|
const coreMetadataIconFileUpload = ref<FileList | undefined>();
|
||||||
|
const coreMetadataLoading = ref(false);
|
||||||
|
|
||||||
|
function coreMetadataUploadFiles(e: InputEvent) {
|
||||||
|
if (coreMetadataIconUrl.value.startsWith("blob")) {
|
||||||
|
console.log("freed object URL");
|
||||||
|
URL.revokeObjectURL(coreMetadataIconUrl.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
coreMetadataIconFileUpload.value = (e.target as any)?.files;
|
||||||
|
const file = coreMetadataIconFileUpload.value?.item(0);
|
||||||
|
if (!file) {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: "Failed to upload file",
|
||||||
|
description: "Drop couldn't upload this file.",
|
||||||
|
buttonText: "Close",
|
||||||
|
},
|
||||||
|
(e, c) => c()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
coreMetadataIconUrl.value = objectUrl;
|
||||||
|
}
|
||||||
|
async function coreMetadataUpdate() {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
const newIcon = coreMetadataIconFileUpload.value?.item(0);
|
||||||
|
if (newIcon) {
|
||||||
|
formData.append("icon", newIcon);
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.append("id", game.value.id);
|
||||||
|
formData.append("name", coreMetadataName.value);
|
||||||
|
formData.append("description", coreMetadataDescription.value);
|
||||||
|
|
||||||
|
const result = await $fetch(`/api/v1/admin/game/metadata`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coreMetadataUpdate_wrapper() {
|
||||||
|
coreMetadataLoading.value = true;
|
||||||
|
coreMetadataUpdate()
|
||||||
|
.catch((e) => {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: "Failed to update metadata",
|
||||||
|
description: `Drop failed to update the game's metadata: ${
|
||||||
|
e?.statusMessage || "An unknown error occurred. "
|
||||||
|
}`,
|
||||||
|
buttonText: "Close",
|
||||||
|
},
|
||||||
|
(e, c) => c()
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then((newGame) => {
|
||||||
|
if (!newGame) return;
|
||||||
|
Object.assign(game.value, newGame);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
coreMetadataLoading.value = false;
|
||||||
|
showEditCoreMetadata.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const descriptionHTML = computed(() =>
|
const descriptionHTML = computed(() =>
|
||||||
micromark(game.value?.mDescription ?? "")
|
micromark(game.value?.mDescription ?? "")
|
||||||
);
|
);
|
||||||
@ -476,6 +653,10 @@ watch(descriptionHTML, (v) => {
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const validAddCarouselImages = computed(() =>
|
||||||
|
game.value.mImageLibrary.filter((e) => !game.value.mImageCarousel.includes(e))
|
||||||
|
);
|
||||||
|
|
||||||
function insertImageAtCursor(id: string) {
|
function insertImageAtCursor(id: string) {
|
||||||
showAddImageDescriptionModal.value = false;
|
showAddImageDescriptionModal.value = false;
|
||||||
if (!descriptionEditor.value || !game.value) return;
|
if (!descriptionEditor.value || !game.value) return;
|
||||||
|
|||||||
@ -33,11 +33,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="relative w-full h-full bg-zinc-900/75 px-6 py-32 sm:px-12 sm:py-40 lg:px-16"
|
class="relative flex items-center justify-center w-full h-full bg-zinc-900/75 px-6 py-32 sm:px-12 sm:py-40 lg:px-16"
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="relative mx-auto flex max-w-xl flex-col items-center text-center"
|
|
||||||
>
|
>
|
||||||
|
<div class="relative text-center">
|
||||||
<h3 class="text-base/7 font-semibold text-blue-300">
|
<h3 class="text-base/7 font-semibold text-blue-300">
|
||||||
Recently added
|
Recently added
|
||||||
</h3>
|
</h3>
|
||||||
@ -49,11 +47,20 @@
|
|||||||
<p class="mt-3 text-lg text-zinc-300 line-clamp-2">
|
<p class="mt-3 text-lg text-zinc-300 line-clamp-2">
|
||||||
{{ game.mShortDescription }}
|
{{ game.mShortDescription }}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="mt-8 gap-x-4 inline-flex items-center">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:href="`/store/${game.id}`"
|
:href="`/store/${game.id}`"
|
||||||
class="mt-8 block w-full rounded-md border border-transparent bg-white px-8 py-3 text-base font-medium text-gray-900 hover:bg-gray-100 sm:w-auto"
|
class="block w-full rounded-md border border-transparent bg-white px-8 py-3 text-base font-medium text-gray-900 hover:bg-gray-100 sm:w-auto"
|
||||||
>Check it out</NuxtLink
|
>Check it out</NuxtLink
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-x-2 rounded-md px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm hover:bg-zinc-900/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-100"
|
||||||
|
>
|
||||||
|
Add to Library
|
||||||
|
<PlusIcon class="-mr-0.5 h-7 w-7" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -103,6 +110,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { PlusIcon } from "@heroicons/vue/24/solid";
|
||||||
|
|
||||||
const headers = useRequestHeaders(["cookie"]);
|
const headers = useRequestHeaders(["cookie"]);
|
||||||
const recent = await $fetch("/api/v1/store/recent", { headers });
|
const recent = await $fetch("/api/v1/store/recent", { headers });
|
||||||
const updated = await $fetch("/api/v1/store/updated", { headers });
|
const updated = await $fetch("/api/v1/store/updated", { headers });
|
||||||
|
|||||||
51
server/api/v1/admin/game/metadata.post.ts
Normal file
51
server/api/v1/admin/game/metadata.post.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import prisma from "~/server/internal/db/database";
|
||||||
|
import { handleFileUpload } from "~/server/internal/utils/handlefileupload";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const user = await h3.context.session.getAdminUser(h3);
|
||||||
|
if (!user) throw createError({ statusCode: 403 });
|
||||||
|
|
||||||
|
const form = await readMultipartFormData(h3);
|
||||||
|
if (!form)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "This endpoint requires multipart form data.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadResult = await handleFileUpload(h3, {}, ["internal:read"]);
|
||||||
|
if (!uploadResult)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Failed to upload file",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [id, options, pull, dump] = uploadResult;
|
||||||
|
|
||||||
|
// handleFileUpload reads the rest of the options for us.
|
||||||
|
const name = options.name;
|
||||||
|
const description = options.description;
|
||||||
|
const gameId = options.id;
|
||||||
|
|
||||||
|
if (!(id || name || description)) {
|
||||||
|
dump();
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Nothing has changed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await pull();
|
||||||
|
const newObject = await prisma.game.update({
|
||||||
|
where: {
|
||||||
|
id: gameId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
mIconId: id,
|
||||||
|
mName: name,
|
||||||
|
mShortDescription: description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return newObject;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user