feat(admin ui): add modifying game descriptions

This commit is contained in:
DecDuck
2024-12-27 19:52:59 +11:00
parent b75ebd13b7
commit e505e58192
4 changed files with 214 additions and 16 deletions

View File

@ -17,7 +17,7 @@
<div class="fixed inset-0 z-10 w-screen overflow-y-auto"> <div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div <div
class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0" class="flex min-h-full items-start justify-center p-4 text-center sm:items-center sm:p-0"
> >
<TransitionChild <TransitionChild
as="template" as="template"
@ -120,6 +120,7 @@ import {
} from "@headlessui/vue"; } from "@headlessui/vue";
import { ExclamationTriangleIcon } from "@heroicons/vue/24/outline"; 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";
const open = defineModel<boolean>(); const open = defineModel<boolean>();

View File

@ -1,9 +1,9 @@
<template> <template>
<div <div
v-if="game && unimportedVersions !== undefined" v-if="game && unimportedVersions !== undefined"
class="flex flex-col lg:block gap-y-8" class="grow flex flex-col gap-y-8"
> >
<div class="w-full lg:pr-[30vw] px-6 py-4"> <div class="grow w-full h-full lg:pr-[30vw] px-6 py-4 flex flex-col">
<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>
@ -51,12 +51,108 @@
class="w-full flex flex-row gap-x-4 overflow-x-auto my-2 py-4" class="w-full flex flex-row gap-x-4 overflow-x-auto my-2 py-4"
> >
<template #item="{ element }: { element: string }"> <template #item="{ element }: { element: string }">
<img :src="useObject(element)" class="h-48 w-auto" /> <div class="relative group min-w-fit">
<img :src="useObject(element)" class="h-48 w-auto" />
<div
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
>
<button
@click="() => removeImageFromCarousel(element)"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 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"
>
Remove image
</button>
</div>
</div>
</template> </template>
</draggable> </draggable>
</div> </div>
<div v-html="descriptionHTML" class="mt-8 prose prose-invert prose-blue"></div> <!-- description editor -->
<div
class="mt-4 grow flex flex-col w-full space-y-4 border border-zinc-800 rounded overflow-hidden p-2"
>
<!-- toolbar -->
<div
class="h-8 bg-zinc-800 rounded inline-flex gap-x-4 items-center justify-start p-2"
>
<button>
<CheckIcon
v-if="descriptionSaving == 0"
class="size-5 text-zinc-100"
/>
<div v-else-if="descriptionSaving == 1">
<div class="animate-pulse w-5 h-[3px] bg-zinc-100 rounded-full" />
</div>
<div v-else-if="descriptionSaving == 2" role="status">
<svg
aria-hidden="true"
class="w-5 h-5 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</button>
<button @click="() => (showAddImageDescriptionModal = true)">
<PhotoIcon
class="transition size-5 text-zinc-100 hover:text-zinc-300"
/>
</button>
<button
@click="
() => (mobileShowFinalDescription = !mobileShowFinalDescription)
"
class="block lg:hidden"
>
<DocumentIcon
v-if="!mobileShowFinalDescription"
class="transition size-5 text-zinc-100 hover:text-zinc-300"
/>
<PencilIcon
v-else
class="transition size-5 text-zinc-100 hover:text-zinc-300"
/>
</button>
</div>
<!-- edit area -->
<div class="grid lg:grid-cols-2 lg:gap-x-8 grow">
<!-- editing box -->
<div
:class="[
mobileShowFinalDescription ? 'hidden' : 'block',
'lg:block',
]"
>
<textarea
ref="descriptionEditor"
v-model="game.mDescription"
class="grow h-full w-full bg-zinc-950/30 text-zinc-100 border-zinc-900 rounded"
/>
</div>
<!-- result box -->
<div
:class="[
mobileShowFinalDescription ? 'block' : 'hidden',
'lg:block prose prose-invert prose-blue bg-zinc-950/30 rounded px-4 py-3',
]"
v-html="descriptionHTML"
></div>
</div>
</div>
</div> </div>
<div <div
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:fixed lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col lg:right-0 gap-y-8 px-6 py-4" class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:fixed lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col lg:right-0 gap-y-8 px-6 py-4"
@ -65,7 +161,11 @@
<div class="inline-flex justify-end items-stretch gap-x-4"> <div class="inline-flex justify-end items-stretch gap-x-4">
<!-- import games button --> <!-- import games button -->
<NuxtLink <NuxtLink
:href="unimportedVersions.length > 0 ? `/store/${game.id}` : ''" :href="
unimportedVersions.length > 0
? `/admin/library/${game.id}/import`
: ''
"
type="button" type="button"
:class="[ :class="[
unimportedVersions.length > 0 unimportedVersions.length > 0
@ -98,7 +198,7 @@
<div> <div>
<div class="border-b border-zinc-800 pb-3"> <div class="border-b border-zinc-800 pb-3">
<div <div
class="flex flex-wrap items-center justify-between sm:flex-nowrap" class="flex flex-wrap items-center justify-between sm:flex-nowrap gap-4"
> >
<div> <div>
<h3 <h3
@ -130,7 +230,7 @@
> >
<img :src="useObject(image)" class="w-full h-auto" /> <img :src="useObject(image)" class="w-full h-auto" />
<div <div
class="transition-all opacity-0 group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50" class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
> >
<button <button
v-if="image !== game.mBannerId" v-if="image !== game.mBannerId"
@ -242,7 +342,7 @@
> >
<img :src="useObject(image)" class="w-full h-auto" /> <img :src="useObject(image)" class="w-full h-auto" />
<div <div
class="transition-all opacity-0 group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50" class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
> >
<button <button
@click="() => addImageToCarousel(image)" @click="() => addImageToCarousel(image)"
@ -266,13 +366,53 @@
</button> </button>
</template> </template>
</ModalTemplate> </ModalTemplate>
<ModalTemplate v-model="showAddImageDescriptionModal">
<template #default>
<div class="mt-3 grid grid-cols-2 grid-flow-dense gap-4">
<div
v-for="(image, imageIdx) in game.mImageLibrary"
:key="image"
class="group relative flex items-center bg-zinc-950/30"
>
<img :src="useObject(image)" class="w-full h-auto" />
<div
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
>
<button
@click="() => insertImageAtCursor(image)"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 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"
>
Insert
</button>
</div>
</div>
</div>
</template>
<template #buttons>
<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="showAddCarouselModal = false"
ref="cancelButtonRef"
>
Cancel
</button>
</template>
</ModalTemplate>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Bars3Icon, TrashIcon } from "@heroicons/vue/16/solid"; import { Bars3Icon, TrashIcon } from "@heroicons/vue/16/solid";
import type { Game, GameVersion } from "@prisma/client"; import type { Game, GameVersion } from "@prisma/client";
import { micromark } from "micromark"; import { micromark } from "micromark";
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/solid"; import {
ArrowTopRightOnSquareIcon,
CheckIcon,
DocumentIcon,
PencilIcon,
PhotoIcon,
} from "@heroicons/vue/24/solid";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
@ -280,6 +420,8 @@ definePageMeta({
const showUploadModal = ref(false); const showUploadModal = ref(false);
const showAddCarouselModal = ref(false); const showAddCarouselModal = ref(false);
const showAddImageDescriptionModal = ref(false);
const mobileShowFinalDescription = ref(true);
const route = useRoute(); const route = useRoute();
const gameId = route.params.id.toString(); const gameId = route.params.id.toString();
@ -292,7 +434,58 @@ const { game: rawGame, unimportedVersions } = await $fetch(
); );
const game = ref(rawGame); const game = ref(rawGame);
const descriptionHTML = micromark(game.value?.mDescription ?? ""); const descriptionHTML = computed(() =>
micromark(game.value?.mDescription ?? "")
);
const descriptionEditor = ref<HTMLTextAreaElement | undefined>();
// 0 is not loading
// 1 is waiting for stop
// 2 is loading
const descriptionSaving = ref<number>(0);
let savingTimeout: undefined | NodeJS.Timeout;
watch(descriptionHTML, (v) => {
console.log(game.value.mDescription);
descriptionSaving.value = 1;
if (savingTimeout) clearTimeout(savingTimeout);
savingTimeout = setTimeout(async () => {
try {
descriptionSaving.value = 2;
await $fetch("/api/v1/admin/game", {
method: "PATCH",
body: {
id: gameId,
mDescription: game.value.mDescription,
},
});
descriptionSaving.value = 0;
} catch (e: any) {
createModal(
ModalType.Notification,
{
title: "Failed to update game description",
description: `Drop failed to update the game description: ${
e?.statusMessage || "An unknown error occurred."
}`,
buttonText: "Close",
},
(e, c) => c()
);
}
}, 1500);
});
function insertImageAtCursor(id: string) {
showAddImageDescriptionModal.value = false;
if (!descriptionEditor.value || !game.value) return;
const insertPosition = descriptionEditor.value.selectionStart;
const text = `![](/api/v1/object/${id})`;
game.value.mDescription = `${game.value.mDescription.slice(
0,
insertPosition
)}${text}${game.value.mDescription.slice(insertPosition)}`;
}
async function updateBannerImage(id: string) { async function updateBannerImage(id: string) {
try { try {
@ -434,8 +627,14 @@ async function updateVersionOrder() {
} }
function addImageToCarousel(id: string) { function addImageToCarousel(id: string) {
game.value.mImageCarousel.push(id);
showAddCarouselModal.value = false; showAddCarouselModal.value = false;
game.value.mImageCarousel.push(id);
updateImageCarousel();
}
function removeImageFromCarousel(id: string) {
const imageIndex = game.value.mImageCarousel.findIndex((e) => e == id);
game.value.mImageCarousel.splice(imageIndex, 1);
updateImageCarousel(); updateImageCarousel();
} }

View File

@ -15,9 +15,10 @@ export default defineNitroPlugin((nitro) => {
case 403: case 403:
const userId = await event.context.session.getUserId(event); const userId = await event.context.session.getUserId(event);
if (userId) break; if (userId) break;
console.log("user is signed out, redirecting");
return sendRedirect( return sendRedirect(
event, event,
`/signin?redirect=${encodeURIComponent(event.path)}`, `/signin?redirect=${encodeURIComponent(event.path)}`
); );
} }
}); });

View File

@ -1,6 +1,3 @@
import * as forms from "@tailwindcss/forms";
import * as tpyo from "@tailwindcss/typography"; // lol
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: [ content: [