mirror of
https://github.com/Drop-OSS/drop.git
synced 2026-06-22 04:11:32 +10:00
* #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:
@@ -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));
|
||||||
|
|||||||
+58
-18
@@ -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
|
||||||
<h1
|
v-if="showTitleDescription"
|
||||||
class="text-zinc-100 text-sm font-bold font-display group-hover:text-white transition-colors"
|
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>
|
</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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ApplicationSettings" ADD COLUMN "showGamePanelTextDecoration" BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -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 {
|
||||||
|
|||||||
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 };
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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:";
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user