mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
Compare commits
11 Commits
v0.3.0
...
65-cloud-s
| Author | SHA1 | Date | |
|---|---|---|---|
| 95d6089453 | |||
| 5b27430ece | |||
| 885e6c8c83 | |||
| c3e7f3192e | |||
| 361da30a93 | |||
| 2e98251035 | |||
| 37766d94b9 | |||
| c76b9894c6 | |||
| 9fda9c5cb9 | |||
| 816355be0c | |||
| 951a741f3e |
266
components/GameEditor/Configuration.vue
Normal file
266
components/GameEditor/Configuration.vue
Normal file
@ -0,0 +1,266 @@
|
||||
<!-- 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 gap-8">
|
||||
<!-- version 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"
|
||||
>
|
||||
<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',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
{{ 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>
|
||||
<div
|
||||
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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",
|
||||
});
|
||||
|
||||
// TODO implement UI for this page
|
||||
|
||||
defineProps<{ unimportedVersions: string[] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
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", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: game.value.id,
|
||||
versions: game.value.versions.map((e) => e.versionName),
|
||||
},
|
||||
});
|
||||
game.value.versions = newVersions;
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: t("errors.version.order.title"),
|
||||
description: t("errors.version.order.desc", {
|
||||
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
}),
|
||||
buttonText: t("close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVersion(versionName: string) {
|
||||
try {
|
||||
await $dropFetch("/api/v1/admin/game/version", {
|
||||
method: "DELETE",
|
||||
body: {
|
||||
id: game.value.id,
|
||||
versionName: versionName,
|
||||
},
|
||||
});
|
||||
game.value.versions.splice(
|
||||
game.value.versions.findIndex((e) => e.versionName === versionName),
|
||||
1,
|
||||
);
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: t("errors.version.delete.title"),
|
||||
description: t("errors.version.delete.desc", {
|
||||
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
}),
|
||||
buttonText: t("close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -1,177 +0,0 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div v-if="game">
|
||||
<div class="grow flex flex-row gap-y-8">
|
||||
<div class="grow w-full h-full px-6 py-4 flex flex-col"></div>
|
||||
<div
|
||||
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4"
|
||||
>
|
||||
<!-- 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"
|
||||
>
|
||||
<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>
|
||||
</h3>
|
||||
</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()"
|
||||
>
|
||||
<template #item="{ element: item }: { element: GameVersion }">
|
||||
<div
|
||||
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Game, GameVersion } from "~/prisma/client";
|
||||
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { H3Error } from "h3";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
// TODO implement UI for this page
|
||||
|
||||
defineProps<{ unimportedVersions: string[] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
type GameAndVersions = Game & { versions: GameVersion[] };
|
||||
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
|
||||
SerializeObject<GameAndVersions>
|
||||
>;
|
||||
if (!game.value)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Game not provided to editor component",
|
||||
});
|
||||
|
||||
async function updateVersionOrder() {
|
||||
try {
|
||||
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: game.value.id,
|
||||
versions: game.value.versions.map((e) => e.versionName),
|
||||
},
|
||||
});
|
||||
game.value.versions = newVersions;
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: t("errors.version.order.title"),
|
||||
description: t("errors.version.order.desc", {
|
||||
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
}),
|
||||
buttonText: t("close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVersion(versionName: string) {
|
||||
try {
|
||||
await $dropFetch("/api/v1/admin/game/version", {
|
||||
method: "DELETE",
|
||||
body: {
|
||||
id: game.value.id,
|
||||
versionName: versionName,
|
||||
},
|
||||
});
|
||||
game.value.versions.splice(
|
||||
game.value.versions.findIndex((e) => e.versionName === versionName),
|
||||
1,
|
||||
);
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: t("errors.version.delete.title"),
|
||||
description: t("errors.version.delete.desc", {
|
||||
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
}),
|
||||
buttonText: t("close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
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": {
|
||||
@ -415,7 +427,10 @@
|
||||
"cleanupSessionsName": "Clean up sessions.",
|
||||
"cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.",
|
||||
"checkUpdateName": "Check update.",
|
||||
"checkUpdateDescription": "Check if Drop has an update."
|
||||
"checkUpdateDescription": "Check if Drop has an update.",
|
||||
|
||||
"ludusaviImportName": "Imports the latest Ludusavi manifest.",
|
||||
"ludusaviImportDescription": "Downloads and imports the latest Ludusavi manifest, to improve cloud save compatibility with new games and updates."
|
||||
},
|
||||
"runningTasksTitle": "Running tasks",
|
||||
"noTasksRunning": "No tasks currently running",
|
||||
|
||||
@ -36,11 +36,13 @@
|
||||
"fast-fuzzy": "^1.12.0",
|
||||
"file-type-mime": "^0.4.3",
|
||||
"jdenticon": "^3.3.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"luxon": "^3.6.1",
|
||||
"micromark": "^4.0.1",
|
||||
"nuxt": "^3.17.4",
|
||||
"nuxt-security": "2.2.0",
|
||||
"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",
|
||||
@ -59,6 +61,7 @@
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/node": "^22.13.16",
|
||||
"@types/semver": "^7.7.0",
|
||||
|
||||
@ -7,67 +7,6 @@
|
||||
>
|
||||
<!--start-->
|
||||
<div>
|
||||
<Listbox v-if="false" v-model="currentMode" as="div">
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="min-w-[10vw] w-full cursor-default inline-flex items-center gap-x-2 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-200 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
>
|
||||
<span class="col-start-1 row-start-1 truncate">{{
|
||||
currentMode
|
||||
}}</span>
|
||||
|
||||
<PencilIcon class="ml-auto size-5" />
|
||||
|
||||
<ChevronUpDownIcon
|
||||
class="text-gray-500 size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="[value] in Object.entries(components)"
|
||||
v-slot="{ active, selected }"
|
||||
:key="value"
|
||||
as="template"
|
||||
:value="value"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-hidden'
|
||||
: 'text-zinc-100',
|
||||
'relative cursor-default py-2 pr-9 pl-3 select-none',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected ? 'font-semibold' : 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>{{ value }}</span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
class="text-white absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
>
|
||||
<PencilIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
|
||||
<div class="pt-4 inline-flex gap-x-2">
|
||||
<div
|
||||
v-for="[value, { icon }] in Object.entries(components)"
|
||||
@ -112,19 +51,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
|
||||
import { GameEditorMetadata, GameEditorVersion } from "#components";
|
||||
import { GameEditorConfiguration, GameEditorMetadata } from "#components";
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
DocumentIcon,
|
||||
PencilIcon,
|
||||
ServerStackIcon,
|
||||
WrenchIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import type { Component } from "vue";
|
||||
|
||||
@ -146,16 +77,16 @@ useHead({
|
||||
|
||||
enum GameEditorMode {
|
||||
Metadata = "Metadata",
|
||||
Versions = "Versions",
|
||||
Configuration = "Configuration",
|
||||
}
|
||||
|
||||
const components: {
|
||||
[key in GameEditorMode]: { editor: Component; icon: Component };
|
||||
} = {
|
||||
[GameEditorMode.Metadata]: { editor: GameEditorMetadata, icon: DocumentIcon },
|
||||
[GameEditorMode.Versions]: {
|
||||
editor: GameEditorVersion,
|
||||
icon: ServerStackIcon,
|
||||
[GameEditorMode.Configuration]: {
|
||||
editor: GameEditorConfiguration,
|
||||
icon: WrenchIcon,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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"
|
||||
@ -230,5 +230,9 @@ const scheduledTasks: {
|
||||
name: "",
|
||||
description: "",
|
||||
},
|
||||
"ludusavi:import": {
|
||||
name: t("tasks.admin.scheduled.ludusaviImportName"),
|
||||
description: t("tasks.admin.scheduled.ludusaviImportDescription"),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "LudusaviEntry" (
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "LudusaviEntry_pkey" PRIMARY KEY ("name")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LudusaviPlatformEntry" (
|
||||
"ludusaviEntryName" TEXT NOT NULL,
|
||||
"platform" "Platform" NOT NULL,
|
||||
"files" TEXT[],
|
||||
"registry" TEXT[],
|
||||
|
||||
CONSTRAINT "LudusaviPlatformEntry_pkey" PRIMARY KEY ("ludusaviEntryName","platform")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LudusaviPlatformEntry" ADD CONSTRAINT "LudusaviPlatformEntry_ludusaviEntryName_fkey" FOREIGN KEY ("ludusaviEntryName") REFERENCES "LudusaviEntry"("name") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LudusaviEntry" ADD COLUMN "steamId" TEXT;
|
||||
@ -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")
|
||||
}
|
||||
|
||||
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: {
|
||||
|
||||
@ -14,6 +14,9 @@ export const taskGroups = {
|
||||
"import:game": {
|
||||
concurrency: true,
|
||||
},
|
||||
"ludusavi:import": {
|
||||
concurrency: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type TaskGroup = keyof typeof taskGroups;
|
||||
|
||||
@ -10,6 +10,7 @@ import cleanupObjects from "./registry/objects";
|
||||
import { taskGroups, type TaskGroup } from "./group";
|
||||
import prisma from "../db/database";
|
||||
import { type } from "arktype";
|
||||
import ludusavi from "./registry/ludusavi";
|
||||
|
||||
// a task that has been run
|
||||
type FinishedTask = {
|
||||
@ -50,6 +51,7 @@ class TaskHandler {
|
||||
"cleanup:invitations",
|
||||
"cleanup:sessions",
|
||||
"check:update",
|
||||
"ludusavi:import"
|
||||
];
|
||||
private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects"];
|
||||
|
||||
@ -59,6 +61,7 @@ class TaskHandler {
|
||||
this.saveScheduledTask(cleanupSessions);
|
||||
this.saveScheduledTask(checkUpdate);
|
||||
this.saveScheduledTask(cleanupObjects);
|
||||
this.saveScheduledTask(ludusavi)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
145
server/internal/tasks/registry/ludusavi.ts
Normal file
145
server/internal/tasks/registry/ludusavi.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { defineDropTask } from "..";
|
||||
import yaml from "js-yaml";
|
||||
import { Platform } from "~/prisma/client";
|
||||
import type { LudusaviPlatformEntryCreateOrConnectWithoutLudusaviEntryInput } from "~/prisma/client/models";
|
||||
import prisma from "../../db/database";
|
||||
|
||||
type ConnectOrCreateShorthand =
|
||||
LudusaviPlatformEntryCreateOrConnectWithoutLudusaviEntryInput;
|
||||
|
||||
type LudusaviModel = {
|
||||
[key: string]: {
|
||||
files?: {
|
||||
[key: string]: {
|
||||
tags?: Array<string>;
|
||||
when?: Array<{ os?: string }>;
|
||||
};
|
||||
};
|
||||
registry?: { [key: string]: { tags?: Array<string> } };
|
||||
steam?: { id: number };
|
||||
};
|
||||
};
|
||||
|
||||
export default defineDropTask({
|
||||
buildId: () => `ludusavi:import:${new Date().toISOString()}`,
|
||||
name: "Import Ludusavi",
|
||||
acls: [],
|
||||
taskGroup: "ludusavi:import",
|
||||
async run({ log, progress }) {
|
||||
const manifest = yaml.load(
|
||||
await $fetch<string>(
|
||||
"https://raw.githubusercontent.com/mtkennerly/ludusavi-manifest/refs/heads/master/data/manifest.yaml",
|
||||
),
|
||||
) as LudusaviModel;
|
||||
let currentProgress = 10;
|
||||
|
||||
progress(currentProgress);
|
||||
|
||||
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;
|
||||
|
||||
function findFilesForOperatingSystem(os: string) {
|
||||
return iterableFiles?.filter((e) =>
|
||||
e[1].when?.find((v) => v.os === os),
|
||||
);
|
||||
}
|
||||
|
||||
const connectOrCreate: ConnectOrCreateShorthand[] = [];
|
||||
|
||||
const windowsData = {
|
||||
registry: data.registry,
|
||||
files: findFilesForOperatingSystem("windows"),
|
||||
};
|
||||
|
||||
if (
|
||||
windowsData.registry ||
|
||||
(windowsData.files && windowsData.files.length > 0)
|
||||
) {
|
||||
const create: ConnectOrCreateShorthand = {
|
||||
where: {
|
||||
ludusaviEntryName_platform: {
|
||||
ludusaviEntryName: name,
|
||||
platform: Platform.Windows,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
platform: Platform.Windows,
|
||||
files: windowsData.files?.map((e) => e[0]) ?? [],
|
||||
registry: Object.entries(windowsData.registry ?? {}).map(
|
||||
(e) => e[0],
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
connectOrCreate.push(create);
|
||||
}
|
||||
|
||||
const linuxData = {
|
||||
files: findFilesForOperatingSystem("linux"),
|
||||
};
|
||||
|
||||
if (linuxData.files && linuxData.files.length > 0) {
|
||||
const create: ConnectOrCreateShorthand = {
|
||||
where: {
|
||||
ludusaviEntryName_platform: {
|
||||
ludusaviEntryName: name,
|
||||
platform: Platform.Linux,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
platform: Platform.Linux,
|
||||
files: linuxData.files?.map((e) => e[0]) ?? [],
|
||||
registry: [],
|
||||
},
|
||||
};
|
||||
|
||||
connectOrCreate.push(create);
|
||||
}
|
||||
|
||||
const macData = {
|
||||
files: findFilesForOperatingSystem("mac"),
|
||||
};
|
||||
|
||||
if (macData.files && macData.files.length > 0) {
|
||||
const create: ConnectOrCreateShorthand = {
|
||||
where: {
|
||||
ludusaviEntryName_platform: {
|
||||
ludusaviEntryName: name,
|
||||
platform: Platform.macOS,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
platform: Platform.macOS,
|
||||
files: macData.files?.map((e) => e[0]) ?? [],
|
||||
registry: [],
|
||||
},
|
||||
};
|
||||
|
||||
connectOrCreate.push(create);
|
||||
}
|
||||
|
||||
const steamId = data.steam?.id.toString() ?? null;
|
||||
|
||||
await prisma.ludusaviEntry.upsert({
|
||||
where: {
|
||||
name,
|
||||
},
|
||||
create: {
|
||||
name,
|
||||
steamId,
|
||||
entries: { connectOrCreate },
|
||||
},
|
||||
update: {
|
||||
steamId,
|
||||
entries: { connectOrCreate },
|
||||
},
|
||||
});
|
||||
|
||||
currentProgress += increment;
|
||||
progress(currentProgress);
|
||||
log(`Imported game "${name}"`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -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
@ -2332,6 +2332,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
|
||||
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
|
||||
|
||||
"@types/js-yaml@^4.0.9":
|
||||
version "4.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2"
|
||||
integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==
|
||||
|
||||
"@types/json-schema@^7.0.15":
|
||||
version "7.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
|
||||
@ -7320,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