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

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));

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">
<div
v-if="showTitleDescription"
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"
:class="{ 'group-hover:text-white transition-colors': animate }"
class="text-zinc-100 text-sm font-bold font-display"
>
{{ game.mName }}
{{ 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>

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>

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"],
},

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}",

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,

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>

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ApplicationSettings" ADD COLUMN "showGamePanelTextDecoration" BOOLEAN NOT NULL DEFAULT true;

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 {

View File

@ -1,22 +1,22 @@
enum ClientCapabilities {
PeerAPI @map("peerAPI") // other clients can use the HTTP API to P2P with this client
UserStatus @map("userStatus") // this client can report this user's status (playing, online, etc etc)
CloudSaves @map("cloudSaves") // ability to save to save slots
TrackPlaytime @map("trackPlaytime") // ability to track user playtime
PeerAPI @map("peerAPI") // other clients can use the HTTP API to P2P with this client
UserStatus @map("userStatus") // this client can report this user's status (playing, online, etc etc)
CloudSaves @map("cloudSaves") // ability to save to save slots
TrackPlaytime @map("trackPlaytime") // ability to track user playtime
}
// References a device
model Client {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
capabilities ClientCapabilities[]
capabilities ClientCapabilities[]
name String
platform Platform
lastConnected DateTime
name String
platform Platform
lastConnected DateTime
lastAccessedSaves SaveSlot[]
tokens APIToken[]
lastAccessedSaves SaveSlot[]
tokens APIToken[]
}

View File

@ -1,172 +1,172 @@
enum MetadataSource {
Manual
GiantBomb
PCGamingWiki
IGDB
Metacritic
OpenCritic
Manual
GiantBomb
PCGamingWiki
IGDB
Metacritic
OpenCritic
}
model Game {
id String @id @default(uuid())
id String @id @default(uuid())
metadataSource MetadataSource
metadataId String
created DateTime @default(now())
metadataSource MetadataSource
metadataId String
created DateTime @default(now())
// Any field prefixed with m is filled in from metadata
// Acts as a cache so we can search and filter it
mName String // Name of game
mShortDescription String // Short description
mDescription String // Supports markdown
mReleased DateTime // When the game was released
// Any field prefixed with m is filled in from metadata
// Acts as a cache so we can search and filter it
mName String // Name of game
mShortDescription String // Short description
mDescription String // Supports markdown
mReleased DateTime // When the game was released
ratings GameRating[]
ratings GameRating[]
mIconObjectId String // linked to objects in s3
mBannerObjectId String // linked to objects in s3
mCoverObjectId String
mImageCarouselObjectIds String[] // linked to below array
mImageLibraryObjectIds String[] // linked to objects in s3
mIconObjectId String // linked to objects in s3
mBannerObjectId String // linked to objects in s3
mCoverObjectId String
mImageCarouselObjectIds String[] // linked to below array
mImageLibraryObjectIds String[] // linked to objects in s3
versions GameVersion[]
versions GameVersion[]
// These fields will not be optional in the next version
// Any game without a library ID will be assigned one at startup, based on the defaults
libraryId String?
library Library? @relation(fields: [libraryId], references: [id])
libraryPath String
// These fields will not be optional in the next version
// Any game without a library ID will be assigned one at startup, based on the defaults
libraryId String?
library Library? @relation(fields: [libraryId], references: [id])
libraryPath String
collections CollectionEntry[]
saves SaveSlot[]
screenshots Screenshot[]
tags Tag[]
playtime Playtime[]
collections CollectionEntry[]
saves SaveSlot[]
screenshots Screenshot[]
tags Tag[]
playtime Playtime[]
developers Company[] @relation(name: "developers")
publishers Company[] @relation(name: "publishers")
developers Company[] @relation(name: "developers")
publishers Company[] @relation(name: "publishers")
@@unique([metadataSource, metadataId], name: "metadataKey")
@@unique([libraryId, libraryPath], name: "libraryKey")
@@unique([metadataSource, metadataId], name: "metadataKey")
@@unique([libraryId, libraryPath], name: "libraryKey")
}
model GameRating {
id String @id @default(uuid())
id String @id @default(uuid())
metadataSource MetadataSource
metadataId String
created DateTime @default(now())
metadataSource MetadataSource
metadataId String
created DateTime @default(now())
mReviewCount Int
mReviewRating Float // 0 to 1
mReviewCount Int
mReviewRating Float // 0 to 1
mReviewHref String?
mReviewHref String?
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
gameId String
@@unique([metadataSource, metadataId], name: "metadataKey")
@@unique([metadataSource, metadataId], name: "metadataKey")
}
// A particular set of files that relate to the version
model GameVersion {
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
versionName String // Sub directory for the game files
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
versionName String // Sub directory for the game files
created DateTime @default(now())
created DateTime @default(now())
platform Platform
platform Platform
launchCommand String @default("") // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine
launchArgs String[]
setupCommand String @default("") // Command to setup game (dependencies and such)
setupArgs String[]
onlySetup Boolean @default(false)
launchCommand String @default("") // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine
launchArgs String[]
setupCommand String @default("") // Command to setup game (dependencies and such)
setupArgs String[]
onlySetup Boolean @default(false)
umuIdOverride String?
umuIdOverride String?
dropletManifest Json // Results from droplet
dropletManifest Json // Results from droplet
versionIndex Int
delta Boolean @default(false)
versionIndex Int
delta Boolean @default(false)
@@id([gameId, versionName])
@@id([gameId, versionName])
}
// A save slot for a game
model SaveSlot {
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
index Int
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
index Int
createdAt DateTime @default(now())
playtime Float @default(0) // hours
createdAt DateTime @default(now())
playtime Float @default(0) // hours
lastUsedClientId String?
lastUsedClient Client? @relation(fields: [lastUsedClientId], references: [id])
lastUsedClientId String?
lastUsedClient Client? @relation(fields: [lastUsedClientId], references: [id])
historyObjectIds String[] // list of objects
historyChecksums String[] // list of hashes
historyObjectIds String[] // list of objects
historyChecksums String[] // list of hashes
@@id([gameId, userId, index], name: "id")
@@id([gameId, userId, index], name: "id")
}
model Screenshot {
id String @id @default(uuid())
id String @id @default(uuid())
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
objectId String
private Boolean // if other users can see
objectId String
private Boolean // if other users can see
createdAt DateTime @default(now()) @db.Timestamptz(0)
createdAt DateTime @default(now()) @db.Timestamptz(0)
@@index([gameId, userId])
@@index([userId])
@@index([gameId, userId])
@@index([userId])
}
model Playtime {
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
seconds Int // seconds user has spent playing the game
seconds Int // seconds user has spent playing the game
updatedAt DateTime @updatedAt @db.Timestamptz(6)
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
createdAt DateTime @default(now()) @db.Timestamptz(6)
@@id([gameId, userId])
@@index([userId])
@@id([gameId, userId])
@@index([userId])
}
model Company {
id String @id @default(uuid())
id String @id @default(uuid())
metadataSource MetadataSource
metadataId String
metadataOriginalQuery String
metadataSource MetadataSource
metadataId String
metadataOriginalQuery String
mName String
mShortDescription String
mDescription String
mLogoObjectId String
mBannerObjectId String
mWebsite String
mName String
mShortDescription String
mDescription String
mLogoObjectId String
mBannerObjectId String
mWebsite String
developed Game[] @relation(name: "developers")
published Game[] @relation(name: "publishers")
developed Game[] @relation(name: "developers")
published Game[] @relation(name: "publishers")
@@unique([metadataSource, metadataId], name: "metadataKey")
@@unique([metadataSource, metadataId], name: "metadataKey")
}
model ObjectHash {
id String @id
hash String
id String @id
hash String
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -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 };
});

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 };
});

View File

@ -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,
);
},
);

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.",
};

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:";

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;