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" :key="gameIdx"
class="justify-start" class="justify-start"
> >
<GamePanel :game="game" /> <GamePanel
:game="game"
:href="game ? `/store/${game.id}` : undefined"
:show-title-description="showGamePanelTextDecoration"
/>
</VueSlide> </VueSlide>
<template #addons> <template #addons>
@@ -40,6 +44,10 @@ const props = defineProps<{
width?: number; width?: number;
}>(); }>();
const { showGamePanelTextDecoration } = await $dropFetch(
`/api/v1/admin/settings`,
);
const currentComponent = ref<HTMLDivElement>(); const currentComponent = ref<HTMLDivElement>();
const min = computed(() => Math.max(props.min ?? 8, props.items.length)); const min = computed(() => Math.max(props.min ?? 8, props.items.length));
+57 -17
View File
@@ -1,44 +1,70 @@
<template> <template>
<NuxtLink <NuxtLink
v-if="game" v-if="game || defaultPlaceholder"
:href="props.href ?? `/store/${game.id}`" :href="href"
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" :class="{
@click="active = game.id" '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 <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 <img
:src="useObject(game.mCoverObjectId)" :src="imageProps.src"
class="w-full h-full object-cover brightness-[90%]" class="w-full h-full object-cover brightness-[90%]"
:class="{ active: active === game.id }" :alt="imageProps.alt"
:alt="game.mName"
/> />
<div <div
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/20 to-transparent" class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/20 to-transparent"
/> />
</div> </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 <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> </h1>
<p <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> </p>
</div> </div>
</NuxtLink> </NuxtLink>
<SkeletonCard v-else :message="$t('store.noGame')" /> <SkeletonCard
v-else-if="defaultPlaceholder === false"
:message="$t('store.noGame')"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
const props = defineProps<{ const { t } = useI18n();
const {
game,
href = undefined,
showTitleDescription = true,
animate = true,
defaultPlaceholder = false,
} = defineProps<{
game: game:
| SerializeObject<{ | SerializeObject<{
id: string; id: string;
@@ -46,11 +72,25 @@ const props = defineProps<{
mName: string; mName: string;
mShortDescription: string; mShortDescription: string;
}> }>
| undefined; | undefined
| null;
href?: string; 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> </script>
<style scoped> <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. // Optional.
"@intlify/vue-i18n/no-dynamic-keys": "error", "@intlify/vue-i18n/no-dynamic-keys": "error",
"@intlify/vue-i18n/no-unused-keys": [ "@intlify/vue-i18n/no-unused-keys": [
"error", "off",
{ {
extensions: [".js", ".vue", ".ts"], extensions: [".js", ".vue", ".ts"],
}, },
+17 -2
View File
@@ -227,7 +227,8 @@
"admin": { "admin": {
"admin": "Admin", "admin": "Admin",
"tasks": "Tasks", "tasks": "Tasks",
"users": "Users" "users": "Users",
"settings": "Settings"
}, },
"back": "Back", "back": "Back",
"openSidebar": "Open sidebar" "openSidebar": "Open sidebar"
@@ -462,10 +463,24 @@
}, },
"options": "Options", "options": "Options",
"save": "Save", "save": "Save",
"saved": "Saved",
"add": "Add", "add": "Add",
"insert": "Insert", "insert": "Insert",
"security": "Security", "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": { "store": {
"commingSoon": "coming soon", "commingSoon": "coming soon",
"exploreMore": "Explore more {arrow}", "exploreMore": "Explore more {arrow}",
+1 -1
View File
@@ -193,7 +193,7 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
icon: RectangleStackIcon, icon: RectangleStackIcon,
}, },
{ {
label: $t("settings"), label: $t("header.admin.settings"),
route: "/admin/settings", route: "/admin/settings",
prefix: "/admin/settings", prefix: "/admin/settings",
icon: Cog6ToothIcon, icon: Cog6ToothIcon,
+114 -5
View File
@@ -1,12 +1,121 @@
<template> <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> </template>
<script lang="ts" setup>
useHead({ <script setup lang="ts">
title: "Settings", import { FetchError } from "ofetch";
});
const { t } = useI18n();
definePageMeta({ definePageMeta({
layout: "admin", 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> </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) saveSlotCountLimit Int @default(5)
saveSlotSizeLimit Float @default(10) // MB saveSlotSizeLimit Float @default(10) // MB
saveSlotHistoryLimit Int @default(3) saveSlotHistoryLimit Int @default(3)
showGamePanelTextDecoration Boolean @default(true)
} }
enum Platform { enum Platform {
+13 -13
View File
@@ -1,22 +1,22 @@
enum ClientCapabilities { enum ClientCapabilities {
PeerAPI @map("peerAPI") // other clients can use the HTTP API to P2P with this client 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) UserStatus @map("userStatus") // this client can report this user's status (playing, online, etc etc)
CloudSaves @map("cloudSaves") // ability to save to save slots CloudSaves @map("cloudSaves") // ability to save to save slots
TrackPlaytime @map("trackPlaytime") // ability to track user playtime TrackPlaytime @map("trackPlaytime") // ability to track user playtime
} }
// References a device // References a device
model Client { model Client {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
capabilities ClientCapabilities[] capabilities ClientCapabilities[]
name String name String
platform Platform platform Platform
lastConnected DateTime lastConnected DateTime
lastAccessedSaves SaveSlot[] lastAccessedSaves SaveSlot[]
tokens APIToken[] tokens APIToken[]
} }
+108 -108
View File
@@ -1,172 +1,172 @@
enum MetadataSource { enum MetadataSource {
Manual Manual
GiantBomb GiantBomb
PCGamingWiki PCGamingWiki
IGDB IGDB
Metacritic Metacritic
OpenCritic OpenCritic
} }
model Game { model Game {
id String @id @default(uuid()) id String @id @default(uuid())
metadataSource MetadataSource metadataSource MetadataSource
metadataId String metadataId String
created DateTime @default(now()) created DateTime @default(now())
// Any field prefixed with m is filled in from metadata // Any field prefixed with m is filled in from metadata
// Acts as a cache so we can search and filter it // Acts as a cache so we can search and filter it
mName String // Name of game mName String // Name of game
mShortDescription String // Short description mShortDescription String // Short description
mDescription String // Supports markdown mDescription String // Supports markdown
mReleased DateTime // When the game was released mReleased DateTime // When the game was released
ratings GameRating[] ratings GameRating[]
mIconObjectId String // linked to objects in s3 mIconObjectId String // linked to objects in s3
mBannerObjectId String // linked to objects in s3 mBannerObjectId String // linked to objects in s3
mCoverObjectId String mCoverObjectId String
mImageCarouselObjectIds String[] // linked to below array mImageCarouselObjectIds String[] // linked to below array
mImageLibraryObjectIds String[] // linked to objects in s3 mImageLibraryObjectIds String[] // linked to objects in s3
versions GameVersion[] versions GameVersion[]
// These fields will not be optional in the next version // 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 // Any game without a library ID will be assigned one at startup, based on the defaults
libraryId String? libraryId String?
library Library? @relation(fields: [libraryId], references: [id]) library Library? @relation(fields: [libraryId], references: [id])
libraryPath String libraryPath String
collections CollectionEntry[] collections CollectionEntry[]
saves SaveSlot[] saves SaveSlot[]
screenshots Screenshot[] screenshots Screenshot[]
tags Tag[] tags Tag[]
playtime Playtime[] playtime Playtime[]
developers Company[] @relation(name: "developers") developers Company[] @relation(name: "developers")
publishers Company[] @relation(name: "publishers") publishers Company[] @relation(name: "publishers")
@@unique([metadataSource, metadataId], name: "metadataKey") @@unique([metadataSource, metadataId], name: "metadataKey")
@@unique([libraryId, libraryPath], name: "libraryKey") @@unique([libraryId, libraryPath], name: "libraryKey")
} }
model GameRating { model GameRating {
id String @id @default(uuid()) id String @id @default(uuid())
metadataSource MetadataSource metadataSource MetadataSource
metadataId String metadataId String
created DateTime @default(now()) created DateTime @default(now())
mReviewCount Int mReviewCount Int
mReviewRating Float // 0 to 1 mReviewRating Float // 0 to 1
mReviewHref String? mReviewHref String?
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
gameId String gameId String
@@unique([metadataSource, metadataId], name: "metadataKey") @@unique([metadataSource, metadataId], name: "metadataKey")
} }
// A particular set of files that relate to the version // A particular set of files that relate to the version
model GameVersion { model GameVersion {
gameId String gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
versionName String // Sub directory for the game files 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 launchCommand String @default("") // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine
launchArgs String[] launchArgs String[]
setupCommand String @default("") // Command to setup game (dependencies and such) setupCommand String @default("") // Command to setup game (dependencies and such)
setupArgs String[] setupArgs String[]
onlySetup Boolean @default(false) onlySetup Boolean @default(false)
umuIdOverride String? umuIdOverride String?
dropletManifest Json // Results from droplet dropletManifest Json // Results from droplet
versionIndex Int versionIndex Int
delta Boolean @default(false) delta Boolean @default(false)
@@id([gameId, versionName]) @@id([gameId, versionName])
} }
// A save slot for a game // A save slot for a game
model SaveSlot { model SaveSlot {
gameId String gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
index Int index Int
createdAt DateTime @default(now()) createdAt DateTime @default(now())
playtime Float @default(0) // hours playtime Float @default(0) // hours
lastUsedClientId String? lastUsedClientId String?
lastUsedClient Client? @relation(fields: [lastUsedClientId], references: [id]) lastUsedClient Client? @relation(fields: [lastUsedClientId], references: [id])
historyObjectIds String[] // list of objects historyObjectIds String[] // list of objects
historyChecksums String[] // list of hashes historyChecksums String[] // list of hashes
@@id([gameId, userId, index], name: "id") @@id([gameId, userId, index], name: "id")
} }
model Screenshot { model Screenshot {
id String @id @default(uuid()) id String @id @default(uuid())
gameId String gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
objectId String objectId String
private Boolean // if other users can see 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([gameId, userId])
@@index([userId]) @@index([userId])
} }
model Playtime { model Playtime {
gameId String gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) 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) updatedAt DateTime @updatedAt @db.Timestamptz(6)
createdAt DateTime @default(now()) @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6)
@@id([gameId, userId]) @@id([gameId, userId])
@@index([userId]) @@index([userId])
} }
model Company { model Company {
id String @id @default(uuid()) id String @id @default(uuid())
metadataSource MetadataSource metadataSource MetadataSource
metadataId String metadataId String
metadataOriginalQuery String metadataOriginalQuery String
mName String mName String
mShortDescription String mShortDescription String
mDescription String mDescription String
mLogoObjectId String mLogoObjectId String
mBannerObjectId String mBannerObjectId String
mWebsite String mWebsite String
developed Game[] @relation(name: "developers") developed Game[] @relation(name: "developers")
published Game[] @relation(name: "publishers") published Game[] @relation(name: "publishers")
@@unique([metadataSource, metadataId], name: "metadataKey") @@unique([metadataSource, metadataId], name: "metadataKey")
} }
model ObjectHash { model ObjectHash {
id String @id id String @id
hash String hash String
} }
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": "", "clients:revoke": "",
"news:read": "Read the server's news articles.", "news:read": "Read the server's news articles.",
"settings:read": "Read system settings.",
}; };
export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = { export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
@@ -87,4 +89,6 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"maintenance:read": "maintenance:read":
"Read tasks and maintenance information, like updates available and cleanup.", "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", "clients:revoke",
"news:read", "news:read",
"settings:read",
] as const; ] as const;
const userACLPrefix = "user:"; const userACLPrefix = "user:";
@@ -80,6 +82,8 @@ export const systemACLs = [
"task:start", "task:start",
"maintenance:read", "maintenance:read",
"settings:update",
] as const; ] as const;
const systemACLPrefix = "system:"; const systemACLPrefix = "system:";
+3 -1
View File
@@ -172,7 +172,9 @@ class LibraryManager {
// Checks are done in least to most expensive order // Checks are done in least to most expensive order
async checkUnimportedGamePath(libraryId: string, libraryPath: string) { async checkUnimportedGamePath(libraryId: string, libraryPath: string) {
const hasGame = const hasGame =
(await prisma.game.count({ where: { libraryId, libraryPath } })) > 0; (await prisma.game.count({
where: { libraryId, libraryPath },
})) > 0;
if (hasGame) return false; if (hasGame) return false;
return true; return true;