Toggle for showing title & description overlay on store page #51 (#130)

* #51 Adds settings page with showTitleDescriptionOnGamePanel

* Removes console.log

* Renames isHidden to system, adds missing system column on Game and fixes nitro plugin on fresh database

* Implements a different way to handle the placeholder image

* Removes system column on Game

* Groups settings keys together

* Removes unused i18n keys

* fix: fix eslints and other small tweaks

---------

Co-authored-by: Francois Ribemont <ribemont.francois@gmail.com>
This commit is contained in:
DecDuck
2025-07-06 13:10:57 +10:00
parent 706f2aac83
commit e4fbc7cd50
18 changed files with 404 additions and 149 deletions
+9 -1
View File
@@ -7,7 +7,11 @@
:key="gameIdx"
class="justify-start"
>
<GamePanel :game="game" />
<GamePanel
:game="game"
:href="game ? `/store/${game.id}` : undefined"
:show-title-description="showGamePanelTextDecoration"
/>
</VueSlide>
<template #addons>
@@ -40,6 +44,10 @@ const props = defineProps<{
width?: number;
}>();
const { showGamePanelTextDecoration } = await $dropFetch(
`/api/v1/admin/settings`,
);
const currentComponent = ref<HTMLDivElement>();
const min = computed(() => Math.max(props.min ?? 8, props.items.length));
+58 -18
View File
@@ -1,44 +1,70 @@
<template>
<NuxtLink
v-if="game"
:href="props.href ?? `/store/${game.id}`"
class="group relative w-48 h-64 rounded-lg overflow-hidden transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5"
@click="active = game.id"
v-if="game || defaultPlaceholder"
:href="href"
:class="{
'transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5':
animate,
}"
class="group relative w-48 h-64 rounded-lg overflow-hidden"
>
<div
class="absolute inset-0 transition-all duration-300 group-hover:scale-110"
:class="{
'transition-all duration-300 group-hover:scale-110': animate,
}"
class="absolute inset-0"
>
<img
:src="useObject(game.mCoverObjectId)"
:src="imageProps.src"
class="w-full h-full object-cover brightness-[90%]"
:class="{ active: active === game.id }"
:alt="game.mName"
:alt="imageProps.alt"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/20 to-transparent"
/>
</div>
<div class="absolute bottom-0 left-0 w-full p-3">
<h1
class="text-zinc-100 text-sm font-bold font-display group-hover:text-white transition-colors"
<div
v-if="showTitleDescription"
class="absolute bottom-0 left-0 w-full p-3"
>
{{ game.mName }}
<h1
:class="{ 'group-hover:text-white transition-colors': animate }"
class="text-zinc-100 text-sm font-bold font-display"
>
{{ game ? game.mName : $t("settings.admin.store.dropGameNamePlaceholder") }}
</h1>
<p
class="text-zinc-400 text-xs line-clamp-2 group-hover:text-zinc-300 transition-colors"
:class="{
'group-hover:text-zinc-300 transition-colors': animate,
}"
class="text-zinc-400 text-xs line-clamp-2"
>
{{ game.mShortDescription }}
{{
game
? game.mShortDescription
: $t("settings.admin.store.dropGameDescriptionPlaceholder")
}}
</p>
</div>
</NuxtLink>
<SkeletonCard v-else :message="$t('store.noGame')" />
<SkeletonCard
v-else-if="defaultPlaceholder === false"
:message="$t('store.noGame')"
/>
</template>
<script setup lang="ts">
import type { SerializeObject } from "nitropack";
const props = defineProps<{
const { t } = useI18n();
const {
game,
href = undefined,
showTitleDescription = true,
animate = true,
defaultPlaceholder = false,
} = defineProps<{
game:
| SerializeObject<{
id: string;
@@ -46,11 +72,25 @@ const props = defineProps<{
mName: string;
mShortDescription: string;
}>
| undefined;
| undefined
| null;
href?: string;
showTitleDescription?: boolean;
animate?: boolean;
defaultPlaceholder?: boolean;
}>();
const active = useState();
const imageProps = {
src: "",
alt: t("settings.admin.store.dropGameAltPlaceholder"),
};
if (game) {
imageProps.src = useObject(game.mCoverObjectId);
imageProps.alt = game.mName;
} else if (defaultPlaceholder) {
imageProps.src = "/game-panel-placeholder.png";
}
</script>
<style scoped>
+22
View File
@@ -0,0 +1,22 @@
<template>
<div
:class="[
'transition border border-3 rounded-xl relative cursor-pointer',
active ? 'border-blue-600' : 'border-zinc-700',
]"
>
<div v-if="active" class="absolute top-1 right-1 z-1">
<CheckIcon
class="rounded-full p-1.5 bg-blue-600 size-6 text-transparent stroke-3 stroke-zinc-900 font-bold"
/>
</div>
<slot />
</div>
</template>
<script setup lang="ts">
import { CheckIcon } from '@heroicons/vue/24/solid';
const { active = false } = defineProps<{ active?: boolean }>();
</script>
+1 -1
View File
@@ -13,7 +13,7 @@ export default withNuxt([
// Optional.
"@intlify/vue-i18n/no-dynamic-keys": "error",
"@intlify/vue-i18n/no-unused-keys": [
"error",
"off",
{
extensions: [".js", ".vue", ".ts"],
},
+17 -2
View File
@@ -227,7 +227,8 @@
"admin": {
"admin": "Admin",
"tasks": "Tasks",
"users": "Users"
"users": "Users",
"settings": "Settings"
},
"back": "Back",
"openSidebar": "Open sidebar"
@@ -462,10 +463,24 @@
},
"options": "Options",
"save": "Save",
"saved": "Saved",
"add": "Add",
"insert": "Insert",
"security": "Security",
"settings": "Settings",
"settings": {
"admin": {
"title": "Settings",
"description": "Configure Drop settings",
"store": {
"title": "Store",
"showGamePanelTextDecoration": "Show title and description on game tiles (default: on)",
"dropGameNamePlaceholder": "Example Game",
"dropGameDescriptionPlaceholder": "This is an example game. It will be replaced if you import a game.",
"dropGameAltPlaceholder": "Example Game icon"
}
}
},
"store": {
"commingSoon": "coming soon",
"exploreMore": "Explore more {arrow}",
+1 -1
View File
@@ -193,7 +193,7 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
icon: RectangleStackIcon,
},
{
label: $t("settings"),
label: $t("header.admin.settings"),
route: "/admin/settings",
prefix: "/admin/settings",
icon: Cog6ToothIcon,
+114 -5
View File
@@ -1,12 +1,121 @@
<template>
<div class="text-gray-100">{{ $t("todo") }}</div>
<div class="space-y-4">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-zinc-100">
{{ $t("settings.admin.title") }}
</h1>
<p class="mt-2 text-base text-zinc-400">
{{ $t("settings.admin.description") }}
</p>
</div>
</div>
<form class="space-y-4" @submit.prevent="() => saveSettings()">
<div class="py-6 border-y border-zinc-700">
<h2 class="text-xl font-semibold text-zinc-100">
{{ $t("settings.admin.store.title") }}
</h2>
<h3 class="text-base font-medium text-zinc-400 mb-3 m-x-0">
{{ $t("settings.admin.store.showGamePanelTextDecoration") }}
</h3>
<ul class="flex gap-3">
<li class="inline-block">
<OptionWrapper
:active="showGamePanelTextDecoration"
@click="setShowTitleDescription(true)"
>
<div class="flex">
<GamePanel
:animate="false"
:game="game"
:default-placeholder="true"
/>
</div>
</OptionWrapper>
</li>
<li class="inline-block">
<OptionWrapper
:active="!showGamePanelTextDecoration"
@click="setShowTitleDescription(false)"
>
<div class="flex">
<GamePanel
:game="game"
:show-title-description="false"
:animate="false"
:default-placeholder="true"
/>
</div>
</OptionWrapper>
</li>
</ul>
</div>
<LoadingButton
type="submit"
class="inline-flex w-full shadow-sm sm:w-auto"
:loading="saving"
:disabled="!allowSave"
>
{{ allowSave ? $t("save") : $t("saved") }}
</LoadingButton>
</form>
</div>
</template>
<script lang="ts" setup>
useHead({
title: "Settings",
});
<script setup lang="ts">
import { FetchError } from "ofetch";
const { t } = useI18n();
definePageMeta({
layout: "admin",
});
useHead({
title: t("settings.admin.title"),
});
const settings = await $dropFetch("/api/v1/admin/settings");
const { game } = await $dropFetch("/api/v1/admin/settings/dummy-data");
const allowSave = ref(false);
const showGamePanelTextDecoration = ref<boolean>(
settings.showGamePanelTextDecoration,
);
function setShowTitleDescription(value: boolean) {
showGamePanelTextDecoration.value = value;
allowSave.value = true;
}
const saving = ref<boolean>(false);
async function saveSettings() {
saving.value = true;
try {
await $dropFetch("/api/v1/admin/settings", {
method: "PATCH",
body: {
showGamePanelTextDecoration: showGamePanelTextDecoration.value,
},
});
} catch (e) {
createModal(
ModalType.Notification,
{
title: `Failed to save settings.`,
description:
e instanceof FetchError
? (e.statusMessage ?? e.message)
: (e as string).toString(),
},
(_, c) => c(),
);
}
saving.value = false;
allowSave.value = false;
}
</script>
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ApplicationSettings" ADD COLUMN "showGamePanelTextDecoration" BOOLEAN NOT NULL DEFAULT true;
+2
View File
@@ -6,6 +6,8 @@ model ApplicationSettings {
saveSlotCountLimit Int @default(5)
saveSlotSizeLimit Float @default(10) // MB
saveSlotHistoryLimit Int @default(3)
showGamePanelTextDecoration Boolean @default(true)
}
enum Platform {
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,11 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.getUserACL(h3, ["settings:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const game = await prisma.game.findFirst();
return { game };
});
+13
View File
@@ -0,0 +1,13 @@
import aclManager from "~/server/internal/acls";
import { applicationSettings } from "~/server/internal/config/application-configuration";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.getUserACL(h3, ["settings:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const showGamePanelTextDecoration = await applicationSettings.get(
"showGamePanelTextDecoration",
);
return { showGamePanelTextDecoration };
});
@@ -0,0 +1,23 @@
import { type } from "arktype";
import { applicationSettings } from "~/server/internal/config/application-configuration";
import { readDropValidatedBody } from "~/server/arktype";
import { defineEventHandler, createError } from "h3";
import aclManager from "~/server/internal/acls";
const UpdateSettings = type({
showGamePanelTextDecoration: "boolean",
});
export default defineEventHandler<{ body: typeof UpdateSettings.infer }>(
async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["settings:update"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, UpdateSettings);
await applicationSettings.set(
"showGamePanelTextDecoration",
body.showGamePanelTextDecoration,
);
},
);
+4
View File
@@ -39,6 +39,8 @@ export const userACLDescriptions: ObjectFromList<typeof userACLs> = {
"clients:revoke": "",
"news:read": "Read the server's news articles.",
"settings:read": "Read system settings.",
};
export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
@@ -87,4 +89,6 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"maintenance:read":
"Read tasks and maintenance information, like updates available and cleanup.",
"settings:update": "Update system settings.",
};
+4
View File
@@ -33,6 +33,8 @@ export const userACLs = [
"clients:revoke",
"news:read",
"settings:read",
] as const;
const userACLPrefix = "user:";
@@ -80,6 +82,8 @@ export const systemACLs = [
"task:start",
"maintenance:read",
"settings:update",
] as const;
const systemACLPrefix = "system:";
+3 -1
View File
@@ -172,7 +172,9 @@ class LibraryManager {
// Checks are done in least to most expensive order
async checkUnimportedGamePath(libraryId: string, libraryPath: string) {
const hasGame =
(await prisma.game.count({ where: { libraryId, libraryPath } })) > 0;
(await prisma.game.count({
where: { libraryId, libraryPath },
})) > 0;
if (hasGame) return false;
return true;