mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-09 20:12:10 +10:00
feat: add cloudsave configuration w/ ludusavi search
This commit is contained in:
@ -1,88 +1,149 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div v-if="game" class="h-full grow flex flex-row gap-y-8">
|
||||
<div class="grow w-full h-full px-6 py-4 flex flex-col">
|
||||
<div class="grow w-full h-full px-6 py-4 flex flex-col gap-8">
|
||||
<!-- version manager -->
|
||||
<div>
|
||||
<!-- version priority -->
|
||||
<div>
|
||||
<div class="border-b border-zinc-800 pb-3">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between sm:flex-nowrap"
|
||||
|
||||
<div class="rounded-lg bg-zinc-950 shadow-sm divide-y divide-zinc-800">
|
||||
<div class="px-4 py-3 sm:px-6">
|
||||
<h1
|
||||
class="w-full inline-flex items-center justify gap-x-2 text-lg text-zinc-200 font-semibold"
|
||||
>
|
||||
<ServerStackIcon class="size-5" />
|
||||
{{ $t("library.admin.versionPriority.title") }}
|
||||
<NuxtLink
|
||||
v-if="unimportedVersions !== undefined"
|
||||
:href="
|
||||
unimportedVersions.length > 0
|
||||
? `/admin/library/${game.id}/import`
|
||||
: ''
|
||||
"
|
||||
type="button"
|
||||
:class="[
|
||||
unimportedVersions.length > 0
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-blue-800/50',
|
||||
'ml-auto inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1.5 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
|
||||
]"
|
||||
>
|
||||
<h3
|
||||
class="text-base font-semibold font-display leading-6 text-zinc-100"
|
||||
>
|
||||
{{ $t("library.admin.versionPriority") }}
|
||||
|
||||
<!-- import games button -->
|
||||
|
||||
<NuxtLink
|
||||
v-if="unimportedVersions !== undefined"
|
||||
:href="
|
||||
unimportedVersions.length > 0
|
||||
? `/admin/library/${game.id}/import`
|
||||
: ''
|
||||
"
|
||||
type="button"
|
||||
:class="[
|
||||
unimportedVersions.length > 0
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-blue-800/50',
|
||||
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
|
||||
]"
|
||||
{{
|
||||
unimportedVersions.length > 0
|
||||
? $t("library.admin.import.version.import")
|
||||
: $t("library.admin.import.version.noVersions")
|
||||
}}
|
||||
</NuxtLink>
|
||||
</h1>
|
||||
<p class="text-sm text-zinc-400">
|
||||
{{ $t("library.admin.versionPriority.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-zinc-950/10 px-4 py-5 sm:p-6">
|
||||
<!-- version priority -->
|
||||
<div>
|
||||
<div class="text-center w-full text-sm text-zinc-600">
|
||||
{{ $t("lowest") }}
|
||||
</div>
|
||||
<draggable
|
||||
:list="game.versions"
|
||||
handle=".handle"
|
||||
class="mt-2 space-y-4"
|
||||
@update="() => updateVersionOrder()"
|
||||
>
|
||||
<template #item="{ element: item }: { element: GameVersion }">
|
||||
<div
|
||||
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
|
||||
>
|
||||
{{
|
||||
unimportedVersions.length > 0
|
||||
? $t("library.admin.import.version.import")
|
||||
: $t("library.admin.import.version.noVersions")
|
||||
}}
|
||||
</NuxtLink>
|
||||
</h3>
|
||||
<div class="text-zinc-100 font-semibold">
|
||||
{{ item.versionName }}
|
||||
</div>
|
||||
<div class="text-zinc-400">
|
||||
{{ item.delta ? $t("library.admin.version.delta") : "" }}
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-x-2">
|
||||
<component
|
||||
:is="PLATFORM_ICONS[item.platform]"
|
||||
class="size-6 text-blue-600"
|
||||
/>
|
||||
<Bars3Icon
|
||||
class="cursor-move w-6 h-6 text-zinc-400 handle"
|
||||
/>
|
||||
<button @click="() => deleteVersion(item.versionName)">
|
||||
<TrashIcon class="w-5 h-5 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div
|
||||
v-if="game.versions.length == 0"
|
||||
class="text-center font-bold text-zinc-400 my-3"
|
||||
>
|
||||
{{ $t("library.admin.version.noVersionsAdded") }}
|
||||
</div>
|
||||
<div class="mt-2 text-center w-full text-sm text-zinc-600">
|
||||
{{ $t("highest") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center w-full text-sm text-zinc-600">
|
||||
{{ $t("lowest") }}
|
||||
</div>
|
||||
<draggable
|
||||
:list="game.versions"
|
||||
handle=".handle"
|
||||
class="mt-2 space-y-4"
|
||||
@update="() => updateVersionOrder()"
|
||||
<!-- cloud saves manager -->
|
||||
<div class="rounded-lg bg-zinc-950 shadow-sm divide-y divide-zinc-800">
|
||||
<div class="px-4 py-3 sm:px-6">
|
||||
<h1
|
||||
class="w-full inline-flex items-center justify gap-x-2 text-lg text-zinc-200 font-semibold"
|
||||
>
|
||||
<template #item="{ element: item }: { element: GameVersion }">
|
||||
<div
|
||||
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
|
||||
<CloudIcon class="size-5" />
|
||||
{{ $t("library.admin.cloudSaves.title") }}
|
||||
</h1>
|
||||
<p class="text-sm text-zinc-400">
|
||||
{{ $t("library.admin.cloudSaves.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-zinc-950/10 px-4 py-5 sm:p-6">
|
||||
<LudusaviSearchbar
|
||||
:default="game.cloudSaveConfiguration?.ludusaviEntry?.name"
|
||||
@update="(name) => updateLudusaviEntry(name)"
|
||||
/>
|
||||
<dl
|
||||
v-if="game.cloudSaveConfiguration?.ludusaviEntry"
|
||||
class="mt-4 grid grid-cols-1 gap-0.5 overflow-hidden rounded-2xl text-center sm:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="stat in [
|
||||
{ name: 'Name', value: game.cloudSaveConfiguration.ludusaviEntry.name },
|
||||
{ name: 'Steam ID', value: game.cloudSaveConfiguration.ludusaviEntry.steamId },
|
||||
]"
|
||||
:key="stat.name"
|
||||
class="flex flex-col bg-white/5 p-8"
|
||||
>
|
||||
<dt class="text-sm/6 font-semibold text-gray-300">
|
||||
{{ stat.name }}
|
||||
</dt>
|
||||
<dd
|
||||
class="order-first text-3xl font-semibold tracking-tight text-white"
|
||||
>
|
||||
<div class="text-zinc-100 font-semibold">
|
||||
{{ item.versionName }}
|
||||
</div>
|
||||
<div class="text-zinc-400">
|
||||
{{ item.delta ? $t("library.admin.version.delta") : "" }}
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-x-2">
|
||||
<component
|
||||
:is="PLATFORM_ICONS[item.platform]"
|
||||
class="size-6 text-blue-600"
|
||||
/>
|
||||
<Bars3Icon class="cursor-move w-6 h-6 text-zinc-400 handle" />
|
||||
<button @click="() => deleteVersion(item.versionName)">
|
||||
<TrashIcon class="w-5 h-5 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div
|
||||
v-if="game.versions.length == 0"
|
||||
class="text-center font-bold text-zinc-400 my-3"
|
||||
>
|
||||
{{ $t("library.admin.version.noVersionsAdded") }}
|
||||
</div>
|
||||
<div class="mt-2 text-center w-full text-sm text-zinc-600">
|
||||
{{ $t("highest") }}
|
||||
</div>
|
||||
{{ stat.value }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col bg-white/5 p-8">
|
||||
<dt class="mt-1 text-sm/6 font-semibold text-gray-300">
|
||||
Platforms
|
||||
</dt>
|
||||
<dd
|
||||
class="inline-flex items-center justify-center gap-x-4 order-first text-3xl font-semibold tracking-tight text-white"
|
||||
>
|
||||
<component
|
||||
:is="item"
|
||||
v-for="item in game.cloudSaveConfiguration.ludusaviEntry.entries
|
||||
.map((e) => e.platform as PlatformClient)
|
||||
.map((e) => PLATFORM_ICONS[e])"
|
||||
:key="item.__name"
|
||||
class="size-8 text-zinc-100"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -93,10 +154,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Game, GameVersion } from "~/prisma/client";
|
||||
import type {
|
||||
CloudSaveConfiguration,
|
||||
Game,
|
||||
GameVersion,
|
||||
LudusaviEntry,
|
||||
LudusaviPlatformEntry,
|
||||
} from "~/prisma/client";
|
||||
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { H3Error } from "h3";
|
||||
import { CloudIcon, ServerStackIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
@ -108,16 +176,41 @@ defineProps<{ unimportedVersions: string[] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
type GameAndVersions = Game & { versions: GameVersion[] };
|
||||
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
|
||||
SerializeObject<GameAndVersions>
|
||||
>;
|
||||
type FullGame = Game & { versions: GameVersion[] } & {
|
||||
cloudSaveConfiguration?: CloudSaveConfiguration & {
|
||||
ludusaviEntry?: LudusaviEntry & { entries: Array<LudusaviPlatformEntry> } | null;
|
||||
};
|
||||
};
|
||||
const game = defineModel() as Ref<SerializeObject<FullGame>>;
|
||||
if (!game.value)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Game not provided to editor component",
|
||||
});
|
||||
|
||||
async function updateLudusaviEntry(name: string) {
|
||||
try {
|
||||
const newConfig = await $dropFetch("/api/v1/admin/game/cloudsaves", {
|
||||
method: "PATCH",
|
||||
body: { id: game.value.id, name: name },
|
||||
});
|
||||
console.log(newConfig);
|
||||
game.value.cloudSaveConfiguration = newConfig;
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: t("errors.game.ludusavi.title"),
|
||||
description: t("errors.game.ludusavi.description", [
|
||||
// @ts-expect-error attempt to display statusMessage on error
|
||||
e?.statusMessage ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVersionOrder() {
|
||||
try {
|
||||
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
|
||||
|
||||
98
components/LudusaviSearchbar.vue
Normal file
98
components/LudusaviSearchbar.vue
Normal file
@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<Combobox v-model="selectedGame" as="div" @update:model-value="query = ''">
|
||||
<ComboboxLabel class="block text-sm/6 font-medium text-zinc-100">{{
|
||||
$t("library.admin.cloudSaves.search")
|
||||
}}</ComboboxLabel>
|
||||
<div class="relative mt-2">
|
||||
<ComboboxInput
|
||||
class="block w-full rounded-md bg-zinc-900 py-1.5 pr-12 pl-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
:display-value="(value) => value as string"
|
||||
@change="query = $event.target.value"
|
||||
@blur="query = ''"
|
||||
/>
|
||||
<ComboboxButton
|
||||
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden"
|
||||
>
|
||||
<ChevronUpDownIcon class="size-5 text-gray-400" aria-hidden="true" />
|
||||
</ComboboxButton>
|
||||
|
||||
<ComboboxOptions
|
||||
v-if="searchResults.length > 0"
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-950 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="result in searchResults"
|
||||
:key="result"
|
||||
v-slot="{ active, selected }"
|
||||
:value="result"
|
||||
as="template"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default py-2 pr-9 pl-3 select-none',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-hidden'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span :class="['block truncate', selected && 'font-semibold']">
|
||||
{{ result }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxButton,
|
||||
ComboboxInput,
|
||||
ComboboxLabel,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update", id: string): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{ default?: string }>();
|
||||
|
||||
const query = ref("");
|
||||
const selectedGame = ref<string | null>(props.default ?? null);
|
||||
|
||||
const searchResults = ref<Array<string>>([]);
|
||||
|
||||
let searchTimeout: NodeJS.Timeout | null = null;
|
||||
watch(query, (name) => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
if (!name) return;
|
||||
searchResults.value = [];
|
||||
searchTimeout = setTimeout(async () => {
|
||||
const results = await $dropFetch("/api/v1/admin/game/cloudsaves/search", {
|
||||
query: { name },
|
||||
});
|
||||
searchResults.value = results.map((e) => e.name);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
watch(selectedGame, (v) => {
|
||||
if (!v) return;
|
||||
emit("update", v);
|
||||
});
|
||||
</script>
|
||||
@ -201,6 +201,10 @@
|
||||
"carousel": {
|
||||
"title": "Failed to update image carousel",
|
||||
"description": "Drop failed to update the image carousel: {0}"
|
||||
},
|
||||
"ludusavi": {
|
||||
"title": "Failed to update Ludusavi entry",
|
||||
"description": "Drop failed to update the Ludusavi entry: {0}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -382,7 +386,15 @@
|
||||
},
|
||||
"subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.",
|
||||
"title": "Libraries",
|
||||
"versionPriority": "Version priority"
|
||||
"versionPriority": {
|
||||
"title": "Version priority",
|
||||
"description": "Version priority is used to order games for upgrade mode versions."
|
||||
},
|
||||
"cloudSaves": {
|
||||
"title": "Cloud Saves",
|
||||
"description": "Specify the Ludusavi manifest used to back up this game to the cloud.",
|
||||
"search": "Search"
|
||||
}
|
||||
},
|
||||
"back": "Back to Library",
|
||||
"collection": {
|
||||
|
||||
@ -41,8 +41,8 @@
|
||||
"micromark": "^4.0.1",
|
||||
"nuxt": "^3.17.4",
|
||||
"nuxt-security": "2.2.0",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"prisma": "^6.7.0",
|
||||
"prisma-extension-pg-trgm": "^1.1.0",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"semver": "^7.7.1",
|
||||
"stream-mime-type": "^2.0.0",
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 truncate text-sm text-zinc-400">
|
||||
{{ task.value.log.at(-1) }}
|
||||
{{ parseTaskLog(task.value.log.at(-1)).message }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
type="button"
|
||||
@ -115,7 +115,7 @@
|
||||
{{ task.id }}
|
||||
</p>
|
||||
<p class="mt-1 truncate text-sm text-zinc-400">
|
||||
{{ parseTaskLog(task.log.at(-1) ?? "").message }}
|
||||
{{ parseTaskLog(task.log.at(-1)).message }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
type="button"
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Game" ADD COLUMN "ludusaviEntryName" TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Game" ADD CONSTRAINT "Game_ludusaviEntryName_fkey" FOREIGN KEY ("ludusaviEntryName") REFERENCES "LudusaviEntry"("name") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -0,0 +1,30 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `ludusaviEntryName` on the `Game` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CloudSaveType" AS ENUM ('Ludusavi', 'LuaScript');
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Game" DROP CONSTRAINT "Game_ludusaviEntryName_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Game" DROP COLUMN "ludusaviEntryName";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CloudSaveConfiguration" (
|
||||
"gameId" TEXT NOT NULL,
|
||||
"type" "CloudSaveType" NOT NULL,
|
||||
"ludusaviEntryName" TEXT,
|
||||
"scriptContent" TEXT,
|
||||
|
||||
CONSTRAINT "CloudSaveConfiguration_pkey" PRIMARY KEY ("gameId")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CloudSaveConfiguration" ADD CONSTRAINT "CloudSaveConfiguration_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CloudSaveConfiguration" ADD CONSTRAINT "CloudSaveConfiguration_ludusaviEntryName_fkey" FOREIGN KEY ("ludusaviEntryName") REFERENCES "LudusaviEntry"("name") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
36
prisma/models/cloudsaves.prisma
Normal file
36
prisma/models/cloudsaves.prisma
Normal file
@ -0,0 +1,36 @@
|
||||
enum CloudSaveType {
|
||||
Ludusavi
|
||||
LuaScript
|
||||
}
|
||||
|
||||
model CloudSaveConfiguration {
|
||||
gameId String @id
|
||||
game Game @relation(fields: [gameId], references: [id])
|
||||
|
||||
type CloudSaveType
|
||||
|
||||
ludusaviEntryName String?
|
||||
ludusaviEntry LudusaviEntry? @relation(fields: [ludusaviEntryName], references: [name])
|
||||
|
||||
scriptContent String?
|
||||
}
|
||||
|
||||
model LudusaviEntry {
|
||||
name String @id
|
||||
steamId String?
|
||||
|
||||
entries LudusaviPlatformEntry[]
|
||||
configurations CloudSaveConfiguration[]
|
||||
}
|
||||
|
||||
model LudusaviPlatformEntry {
|
||||
ludusaviEntryName String
|
||||
ludusaviEntry LudusaviEntry @relation(fields: [ludusaviEntryName], references: [name])
|
||||
|
||||
platform Platform
|
||||
|
||||
files String[]
|
||||
registry String[]
|
||||
|
||||
@@id([ludusaviEntryName, platform])
|
||||
}
|
||||
@ -46,6 +46,8 @@ model Game {
|
||||
developers Company[] @relation(name: "developers")
|
||||
publishers Company[] @relation(name: "publishers")
|
||||
|
||||
cloudSaveConfiguration CloudSaveConfiguration?
|
||||
|
||||
@@unique([metadataSource, metadataId], name: "metadataKey")
|
||||
@@unique([libraryId, libraryPath], name: "libraryKey")
|
||||
}
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
model LudusaviEntry {
|
||||
name String @id
|
||||
steamId String?
|
||||
|
||||
entries LudusaviPlatformEntry[]
|
||||
}
|
||||
|
||||
model LudusaviPlatformEntry {
|
||||
ludusaviEntryName String
|
||||
ludusaviEntry LudusaviEntry @relation(fields: [ludusaviEntryName], references: [name])
|
||||
|
||||
platform Platform
|
||||
|
||||
files String[]
|
||||
registry String[]
|
||||
|
||||
@@id([ludusaviEntryName, platform])
|
||||
}
|
||||
69
server/api/v1/admin/game/cloudsaves/index.patch.ts
Normal file
69
server/api/v1/admin/game/cloudsaves/index.patch.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { type } from "arktype";
|
||||
import { CloudSaveType } from "~/prisma/client";
|
||||
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
const UpdateEntry = type({
|
||||
id: "string",
|
||||
name: "string",
|
||||
}).configure(throwingArktype);
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, [
|
||||
"game:cloudsaves:update",
|
||||
]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const body = await readDropValidatedBody(h3, UpdateEntry);
|
||||
const entry = await prisma.ludusaviEntry.findUnique({
|
||||
where: {
|
||||
name: body.name,
|
||||
},
|
||||
include: {
|
||||
entries: true,
|
||||
},
|
||||
});
|
||||
if (!entry)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid Ludusavi name",
|
||||
});
|
||||
|
||||
const configuration = await prisma.cloudSaveConfiguration.upsert({
|
||||
where: {
|
||||
gameId: body.id,
|
||||
},
|
||||
create: {
|
||||
gameId: body.id,
|
||||
type: CloudSaveType.Ludusavi,
|
||||
ludusaviEntryName: entry.name,
|
||||
},
|
||||
update: {
|
||||
type: CloudSaveType.Ludusavi,
|
||||
ludusaviEntryName: entry.name,
|
||||
},
|
||||
include: {
|
||||
ludusaviEntry: {
|
||||
include: {
|
||||
entries: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.game.update({
|
||||
where: {
|
||||
id: body.id,
|
||||
},
|
||||
data: {
|
||||
cloudSaveConfiguration: {
|
||||
connect: {
|
||||
gameId: body.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return configuration;
|
||||
});
|
||||
21
server/api/v1/admin/game/cloudsaves/search.get.ts
Normal file
21
server/api/v1/admin/game/cloudsaves/search.get.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
|
||||
/* eslint-disable @typescript-eslint/no-extra-non-null-assertion */
|
||||
|
||||
import type { LudusaviEntry } from "~/prisma/client";
|
||||
import aclManager from "~/server/internal/acls";
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["game:cloudsaves:read"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const query = getQuery(h3);
|
||||
const name = query.name?.toString()!!;
|
||||
|
||||
// Remove all non alphanumberical characters
|
||||
const sanatisedName = name.replaceAll(/[^a-zA-Z\d\s:]/g, "");
|
||||
|
||||
const results = await prisma.$queryRaw`SELECT * FROM "LudusaviEntry" ORDER BY SIMILARITY(name, ${sanatisedName}) DESC LIMIT 20;`;
|
||||
|
||||
return results as Array<LudusaviEntry>;
|
||||
});
|
||||
@ -30,6 +30,15 @@ export default defineEventHandler(async (h3) => {
|
||||
delta: true,
|
||||
},
|
||||
},
|
||||
cloudSaveConfiguration: {
|
||||
include: {
|
||||
ludusaviEntry: {
|
||||
include: {
|
||||
entries: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -68,6 +68,10 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
|
||||
"game:image:new": "Upload an image for a game.",
|
||||
"game:image:delete": "Delete an image for a game.",
|
||||
|
||||
"game:cloudsaves:read":
|
||||
"Read cloud save data and search through Ludusavi database.",
|
||||
"game:cloudsaves:update": "Update the Ludusavi manifest entry for a game.",
|
||||
|
||||
"import:version:read":
|
||||
"Fetch versions to be imported, and information about versions to be imported.",
|
||||
"import:version:new": "Import a game version.",
|
||||
|
||||
@ -63,6 +63,10 @@ export const systemACLs = [
|
||||
"game:image:new",
|
||||
"game:image:delete",
|
||||
|
||||
"game:cloudsaves:read",
|
||||
"game:cloudsaves:update",
|
||||
|
||||
|
||||
"import:version:read",
|
||||
"import:version:new",
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { PrismaClient } from "~/prisma/client";
|
||||
import { withPgTrgm } from "prisma-extension-pg-trgm";
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
return new PrismaClient({});
|
||||
return new PrismaClient({}).$extends(withPgTrgm({ logQueries: true }));
|
||||
};
|
||||
|
||||
declare const globalThis: {
|
||||
|
||||
@ -35,9 +35,7 @@ export default defineDropTask({
|
||||
|
||||
progress(currentProgress);
|
||||
|
||||
const entries = Object.entries(manifest).filter(
|
||||
([, data]) => data.files || data.registry,
|
||||
);
|
||||
const entries = Object.entries(manifest);
|
||||
const increment = 90 / entries.length;
|
||||
for (const [name, data] of entries) {
|
||||
const iterableFiles = data.files ? Object.entries(data.files) : undefined;
|
||||
@ -55,7 +53,10 @@ export default defineDropTask({
|
||||
files: findFilesForOperatingSystem("windows"),
|
||||
};
|
||||
|
||||
if (windowsData.registry || windowsData.files) {
|
||||
if (
|
||||
windowsData.registry ||
|
||||
(windowsData.files && windowsData.files.length > 0)
|
||||
) {
|
||||
const create: ConnectOrCreateShorthand = {
|
||||
where: {
|
||||
ludusaviEntryName_platform: {
|
||||
@ -79,7 +80,7 @@ export default defineDropTask({
|
||||
files: findFilesForOperatingSystem("linux"),
|
||||
};
|
||||
|
||||
if (linuxData.files) {
|
||||
if (linuxData.files && linuxData.files.length > 0) {
|
||||
const create: ConnectOrCreateShorthand = {
|
||||
where: {
|
||||
ludusaviEntryName_platform: {
|
||||
@ -101,7 +102,7 @@ export default defineDropTask({
|
||||
files: findFilesForOperatingSystem("mac"),
|
||||
};
|
||||
|
||||
if (macData.files) {
|
||||
if (macData.files && macData.files.length > 0) {
|
||||
const create: ConnectOrCreateShorthand = {
|
||||
where: {
|
||||
ludusaviEntryName_platform: {
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
|
||||
/* eslint-disable @typescript-eslint/no-extra-non-null-assertion */
|
||||
|
||||
import prisma from "../internal/db/database";
|
||||
import { parsePlatform } from "../internal/utils/parseplatform";
|
||||
import tsquery from "pg-tsquery";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const query = getQuery(h3);
|
||||
const name = query.name?.toString()!!;
|
||||
const platform = parsePlatform(query.platform?.toString()!!)!!;
|
||||
|
||||
const parser = tsquery({});
|
||||
|
||||
return await prisma.ludusaviEntry.findMany({
|
||||
orderBy: {
|
||||
_relevance: {
|
||||
fields: ["name"],
|
||||
search: parser(name),
|
||||
sort: "desc",
|
||||
},
|
||||
},
|
||||
include: {
|
||||
entries: {
|
||||
where: {
|
||||
platform,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 20,
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
import type { TaskLog } from "~/server/internal/tasks";
|
||||
|
||||
export function parseTaskLog(logStr: string): typeof TaskLog.infer {
|
||||
export function parseTaskLog(logStr: string | undefined): typeof TaskLog.infer {
|
||||
if (!logStr) return { message: "", timestamp: "" };
|
||||
const log = JSON.parse(logStr);
|
||||
|
||||
return {
|
||||
|
||||
10
yarn.lock
10
yarn.lock
@ -6987,11 +6987,6 @@ perfect-debounce@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz#9c2e8bc30b169cc984a58b7d5b28049839591d2a"
|
||||
integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==
|
||||
|
||||
pg-tsquery@^8.4.2:
|
||||
version "8.4.2"
|
||||
resolved "https://registry.yarnpkg.com/pg-tsquery/-/pg-tsquery-8.4.2.tgz#f28e6242f15f4d8535ac08a0f9083ce04e42e1e4"
|
||||
integrity sha512-waJSlBIKE+shDhuDpuQglTH6dG5zakDhnrnxu8XB8V5c7yoDSuy4pOxY6t2dyoxTjaKMcMmlByJN7n9jx9eqMA==
|
||||
|
||||
picocolors@^1.0.0, picocolors@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
||||
@ -7330,6 +7325,11 @@ pretty-bytes@^6.1.1:
|
||||
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b"
|
||||
integrity sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==
|
||||
|
||||
prisma-extension-pg-trgm@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/prisma-extension-pg-trgm/-/prisma-extension-pg-trgm-1.1.0.tgz#79929264bbb4ceaf6d9ad186543f3b2f52b138bc"
|
||||
integrity sha512-EXRsW0OMoQU/5aQax67FLkU0jonfiF++R3pylj5lYvXicfVD1GvkEDB5hDnGzPwfIMwryT2RLbRplndNFpZ49w==
|
||||
|
||||
prisma@^6.7.0:
|
||||
version "6.9.0"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-6.9.0.tgz#c8bce4fc63f0c6972f3868692e649bb163fd807d"
|
||||
|
||||
Reference in New Issue
Block a user