mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
Compare commits
19 Commits
dependabot
...
2db8e753b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 2db8e753b7 | |||
| b4f9b77809 | |||
| 0b9a715bf2 | |||
| 5c1b0e6c1e | |||
| d84c70a05f | |||
| bfd5c8e761 | |||
| 3311aa7274 | |||
| fcfc30e5df | |||
| 7266d0485b | |||
| cf3a458bdf | |||
| ca7a89bbcf | |||
| d323816b9e | |||
| 367d349a68 | |||
| 8efddc07bc | |||
| 3af00e085e | |||
| b7d685814b | |||
| f1957a418c | |||
| 322af0b4ca | |||
| 6853383e86 |
@ -1,7 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
@config "../tailwind.config.js";
|
||||
@config "../../tailwind.config.js";
|
||||
|
||||
@layer base {
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
@ -86,7 +86,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XCircleIcon } from "@heroicons/vue/20/solid";
|
||||
import type { UserModel } from "~/prisma/client/models";
|
||||
import type { UserModel } from "~~/prisma/client/models";
|
||||
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
@ -106,7 +106,7 @@ function signin_wrapper() {
|
||||
router.push(route.query.redirect?.toString() ?? "/");
|
||||
})
|
||||
.catch((response) => {
|
||||
const message = response.statusMessage || t("errors.unknown");
|
||||
const message = response.message || t("errors.unknown");
|
||||
error.value = message;
|
||||
})
|
||||
.finally(() => {
|
||||
@ -35,7 +35,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GameModel } from "~/prisma/client/models";
|
||||
import type { GameModel } from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
const props = defineProps<{
|
||||
@ -444,7 +444,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GameModel, GameTagModel } from "~/prisma/client/models";
|
||||
import type { GameModel, GameTagModel } from "~~/prisma/client/models";
|
||||
import { micromark } from "micromark";
|
||||
import {
|
||||
CheckIcon,
|
||||
@ -466,7 +466,7 @@ const game = defineModel<ModelType>() as Ref<ModelType>;
|
||||
if (!game.value)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Game not provided to editor component",
|
||||
message: "Game not provided to editor component",
|
||||
});
|
||||
|
||||
const currentTags = ref<{ [key: string]: boolean }>(
|
||||
@ -553,7 +553,7 @@ function coreMetadataUpdate_wrapper() {
|
||||
{
|
||||
title: t("errors.game.metadata.title"),
|
||||
description: t("errors.game.metadata.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
@ -614,7 +614,7 @@ watch(descriptionHTML, (_v) => {
|
||||
{
|
||||
title: t("errors.game.description.title"),
|
||||
description: t("errors.game.description.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
@ -660,7 +660,7 @@ async function updateBannerImage(id: string) {
|
||||
{
|
||||
title: t("errors.game.banner.title"),
|
||||
description: t("errors.game.banner.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
@ -688,7 +688,7 @@ async function updateCoverImage(id: string) {
|
||||
{
|
||||
title: t("errors.game.cover.title"),
|
||||
description: t("errors.game.cover.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
@ -717,7 +717,7 @@ async function deleteImage(id: string) {
|
||||
{
|
||||
title: t("errors.game.deleteImage.title"),
|
||||
description: t("errors.game.deleteImage.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
@ -761,7 +761,7 @@ async function updateImageCarousel() {
|
||||
{
|
||||
title: t("errors.game.carousel.title"),
|
||||
description: t("errors.game.carousel.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
285
app/components/GameEditor/Version.vue
Normal file
285
app/components/GameEditor/Version.vue
Normal file
@ -0,0 +1,285 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div v-if="game && unimportedVersions" class="p-8">
|
||||
<div>
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-base font-semibold text-zinc-100">Versions</h1>
|
||||
<p class="mt-2 text-sm text-zinc-400 max-w-lg">
|
||||
Versions are a collection of files that are downloaded to clients.
|
||||
Each version can have multiple configurations, for different
|
||||
platforms.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<NuxtLink
|
||||
:href="canImport ? `/admin/library/g/${game.id}/import` : ''"
|
||||
type="button"
|
||||
:class="[
|
||||
canImport ? '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',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
canImport
|
||||
? $t("library.admin.import.version.import")
|
||||
: $t("library.admin.import.version.noVersions")
|
||||
}}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm">
|
||||
<div>
|
||||
<table class="min-w-full divide-y divide-zinc-800">
|
||||
<thead>
|
||||
<tr class="bg-zinc-800/50">
|
||||
<th
|
||||
scope="col"
|
||||
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
|
||||
>
|
||||
Version Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
Imported
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
Platforms
|
||||
</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">{{ $t("actions") }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-800">
|
||||
<tr
|
||||
v-for="version in game.versions"
|
||||
:key="version.versionId"
|
||||
class="transition-colors duration-150 hover:bg-zinc-800/50"
|
||||
>
|
||||
<td
|
||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
|
||||
>
|
||||
{{ version.versionName }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<RelativeTime :date="version.created" />
|
||||
</td>
|
||||
<td class="px-3 py-4">
|
||||
<ul class="space-y-4">
|
||||
<li
|
||||
v-for="gameVersion in version.gameVersions"
|
||||
:key="gameVersion.versionId"
|
||||
class="px-3 py-2 border border-zinc-800 rounded-lg shadow"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="text-sm flex items-center gap-x-2 text-zinc-200 font-semibold"
|
||||
>
|
||||
<IconsPlatform
|
||||
:platform="
|
||||
platforms[gameVersion.platformId].platformIcon.key
|
||||
"
|
||||
:fallback="
|
||||
platforms[gameVersion.platformId].platformIcon
|
||||
.fallback
|
||||
"
|
||||
class="size-5 text-blue-500"
|
||||
/>
|
||||
<span class="block truncate">{{
|
||||
platforms[gameVersion.platformId].name
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- launch commands -->
|
||||
<div class="space-y-1 mt-4">
|
||||
<div
|
||||
v-if="gameVersion.install"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<span
|
||||
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
|
||||
>Install</span
|
||||
>
|
||||
|
||||
<div
|
||||
class="whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
|
||||
>
|
||||
<span class="text-zinc-700">(install dir)/</span
|
||||
>{{ gameVersion.install.command }}
|
||||
{{ gameVersion.install.args }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="font-semibold text-sm text-zinc-100"
|
||||
>Launch options</span
|
||||
>
|
||||
<ul class="divide-y divide-zinc-700">
|
||||
<li
|
||||
v-for="launch in gameVersion.launches"
|
||||
:key="launch.command"
|
||||
class="ml-2 py-2 flex justify-between items-center"
|
||||
>
|
||||
<h1
|
||||
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
|
||||
>
|
||||
{{ launch.name }}
|
||||
</h1>
|
||||
<div
|
||||
class="mt-1 whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
|
||||
>
|
||||
<span class="text-zinc-700"
|
||||
>(install dir)/</span
|
||||
>{{ launch.command }} {{ launch.args }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="gameVersion.uninstall"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<span
|
||||
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
|
||||
>Uninstall</span
|
||||
>
|
||||
|
||||
<div
|
||||
class="whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
|
||||
>
|
||||
<span class="text-zinc-700">(install dir)/</span
|
||||
>{{ gameVersion.uninstall.command }}
|
||||
{{ gameVersion.uninstall.args }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td
|
||||
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
|
||||
>
|
||||
<button
|
||||
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
|
||||
@click="() => deleteVersion(version.versionId)"
|
||||
>
|
||||
Delete
|
||||
<span class="sr-only">
|
||||
{{ $t("chars.srComma", [version.versionName]) }}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="game.versions.length === 0">
|
||||
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
|
||||
No versions
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="grow w-full flex items-center justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<ExclamationCircleIcon
|
||||
class="h-12 w-12 text-red-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="mt-3 text-center sm:mt-5">
|
||||
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
|
||||
{{ $t("library.admin.offlineTitle") }}
|
||||
</h1>
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-zinc-400 max-w-md">
|
||||
{{ $t("library.admin.offline") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SerializeObject, TypedInternalResponse } from "nitropack";
|
||||
import type { H3Error } from "h3";
|
||||
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
// TODO implement UI for this page
|
||||
|
||||
const props = defineProps<{ unimportedVersions: string[] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const hasDeleted = ref(false);
|
||||
|
||||
const canImport = computed(
|
||||
() => hasDeleted.value || props.unimportedVersions.length > 0,
|
||||
);
|
||||
|
||||
type GameFetchType = TypedInternalResponse<
|
||||
"/api/v1/admin/game/:id",
|
||||
unknown,
|
||||
"get"
|
||||
>["game"];
|
||||
const game = defineModel<SerializeObject<GameFetchType>>({ required: true });
|
||||
if (!game.value)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: "Game not provided to editor component",
|
||||
});
|
||||
|
||||
const rawPlatforms = await useAdminPlatforms();
|
||||
const platforms = Object.fromEntries(
|
||||
renderPlatforms(rawPlatforms).map((v) => [v.param, v]),
|
||||
);
|
||||
|
||||
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.versionId),
|
||||
},
|
||||
});
|
||||
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)?.message ?? t("errors.unknown"),
|
||||
}),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVersion(versionId: string) {
|
||||
await $dropFetch("/api/v1/admin/game/version", {
|
||||
method: "DELETE",
|
||||
body: {
|
||||
id: versionId,
|
||||
},
|
||||
failTitle: "Failed to delete version.",
|
||||
});
|
||||
game.value.versions.splice(
|
||||
game.value.versions.findIndex((e) => e.versionId === versionId),
|
||||
1,
|
||||
);
|
||||
hasDeleted.value = true;
|
||||
}
|
||||
</script>
|
||||
@ -77,7 +77,7 @@ const {
|
||||
}>
|
||||
| undefined
|
||||
| null;
|
||||
href?: string;
|
||||
href?: string | undefined;
|
||||
showTitleDescription?: boolean;
|
||||
animate?: boolean;
|
||||
defaultPlaceholder?: boolean;
|
||||
@ -16,7 +16,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
|
||||
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
|
||||
|
||||
const { game } = defineProps<{
|
||||
game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string };
|
||||
26
app/components/Icons/Platform.vue
Normal file
26
app/components/Icons/Platform.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<component
|
||||
:is="platformIcons[props.platform as HardwarePlatform]"
|
||||
v-if="platformIcons[props.platform as HardwarePlatform]"
|
||||
/>
|
||||
<div v-else-if="props.fallback" v-html="props.fallback" />
|
||||
<DropLogo v-else />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HardwarePlatform } from "~~/prisma/client/enums";
|
||||
import type { Component } from "vue";
|
||||
import LinuxLogo from "./LinuxLogo.vue";
|
||||
import WindowsLogo from "./WindowsLogo.vue";
|
||||
import MacLogo from "./MacLogo.vue";
|
||||
import DropLogo from "../DropLogo.vue";
|
||||
|
||||
const props = defineProps<{ platform: string; fallback?: string | undefined }>();
|
||||
|
||||
const platformIcons: { [key in HardwarePlatform]: Component } = {
|
||||
[HardwarePlatform.Linux]: LinuxLogo,
|
||||
[HardwarePlatform.Windows]: WindowsLogo,
|
||||
[HardwarePlatform.macOS]: MacLogo,
|
||||
};
|
||||
</script>
|
||||
238
app/components/Import/Game.vue
Normal file
238
app/components/Import/Game.vue
Normal file
@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-y-4">
|
||||
<!-- without metadata option -->
|
||||
<div>
|
||||
<LoadingButton
|
||||
class="w-fit"
|
||||
:loading="props.loading"
|
||||
@click="() => importGame(false)"
|
||||
>
|
||||
{{ $t("library.admin.import.withoutMetadata") }}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
|
||||
<!-- divider -->
|
||||
<div
|
||||
class="inline-flex items-center gap-x-4 text-zinc-600 font-display font-bold"
|
||||
>
|
||||
<div class="h-[1px] grow bg-zinc-800" />
|
||||
{{ $t("auth.signin.or") }}
|
||||
<div class="h-[1px] grow bg-zinc-800" />
|
||||
</div>
|
||||
|
||||
<!-- with metadata option -->
|
||||
|
||||
<div class="flex flex-col gap-y-4">
|
||||
<form @submit.prevent="() => searchGame()">
|
||||
<label
|
||||
for="searchTerm"
|
||||
class="block text-sm/6 font-medium text-zinc-100"
|
||||
>{{ $t("library.admin.import.search") }}</label
|
||||
>
|
||||
<div class="mt-2 flex">
|
||||
<div class="-mr-px grid grow grid-cols-1 focus-within:relative">
|
||||
<input
|
||||
id="searchTerm"
|
||||
v-model="gameSearchTerm"
|
||||
type="text"
|
||||
name="searchTerm"
|
||||
class="col-start-1 row-start-1 block w-full rounded-l-md bg-zinc-950 py-1.5 px-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
:placeholder="$t('library.admin.import.searchPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<LoadingButton
|
||||
:loading="gameSearchLoading"
|
||||
:style="'none'"
|
||||
type="submit"
|
||||
class="w-24 flex shrink-0 items-center justify-center gap-x-1.5 rounded-r-md bg-zinc-950 px-3 py-2 text-sm font-semibold text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 hover:bg-zinc-900 focus:relative focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
|
||||
>
|
||||
<MagnifyingGlassIcon
|
||||
class="-ml-0.5 size-4 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ $t("library.admin.import.search") }}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Listbox
|
||||
v-if="metadataResults && metadataResults.length > 0"
|
||||
v-model="model"
|
||||
as="div"
|
||||
>
|
||||
<ListboxLabel
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("library.admin.import.selectGameSearch") }}</ListboxLabel
|
||||
>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<GameSearchResultWidget v-if="model !== undefined" :game="model" />
|
||||
<span v-else class="block truncate text-zinc-600">
|
||||
{{ $t("library.admin.import.selectGamePlaceholder") }}
|
||||
</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</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-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="result in metadataResults"
|
||||
:key="result.id"
|
||||
v-slot="{ active }"
|
||||
as="template"
|
||||
:value="result"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<GameSearchResultWidget :game="result" />
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
<div
|
||||
v-else-if="gameSearchResultsLoading"
|
||||
role="status"
|
||||
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
|
||||
>
|
||||
{{ $t("library.admin.import.loading") }}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-6 h-6 text-transparent animate-spin fill-white"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="gameSearchResultsError"
|
||||
class="w-fit rounded-md bg-red-600/10 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
{{ gameSearchResultsError }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LoadingButton
|
||||
class="w-fit"
|
||||
:loading="props.loading"
|
||||
:disabled="model === undefined"
|
||||
@click="() => importGame()"
|
||||
>
|
||||
{{ $t("library.admin.import.import") }}
|
||||
</LoadingButton>
|
||||
|
||||
<div
|
||||
v-if="props.error"
|
||||
class="mt-4 w-fit rounded-md bg-red-600/10 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
{{ props.error }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { XCircleIcon } from "@heroicons/vue/16/solid";
|
||||
import { ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
|
||||
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
|
||||
|
||||
const model = ref<GameMetadataSearchResult | undefined>(undefined);
|
||||
|
||||
const props = defineProps<{
|
||||
gameName: string;
|
||||
loading: boolean;
|
||||
error?: string | undefined;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
import: [metadata: GameMetadataSearchResult | undefined];
|
||||
}>();
|
||||
|
||||
function importGame(metadata = true) {
|
||||
emit("import", metadata ? model.value : undefined);
|
||||
}
|
||||
|
||||
const metadataResults = ref<Array<GameMetadataSearchResult> | undefined>();
|
||||
|
||||
onMounted(() => {
|
||||
if (!metadataResults.value) searchGame();
|
||||
});
|
||||
|
||||
const gameSearchLoading = ref(false);
|
||||
const gameSearchResultsLoading = ref(false);
|
||||
const gameSearchResultsError = ref<string | undefined>();
|
||||
const gameSearchTerm = ref(props.gameName);
|
||||
|
||||
async function searchGame() {
|
||||
gameSearchResultsError.value = undefined;
|
||||
gameSearchLoading.value = true;
|
||||
try {
|
||||
const results = await $dropFetch(
|
||||
`/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`,
|
||||
);
|
||||
metadataResults.value = results;
|
||||
gameSearchLoading.value = false;
|
||||
} catch (e) {
|
||||
gameSearchLoading.value = false;
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
336
app/components/Import/Redist.vue
Normal file
336
app/components/Import/Redist.vue
Normal file
@ -0,0 +1,336 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="flex flex-row gap-x-4">
|
||||
<label
|
||||
for="icon-upload"
|
||||
class="relative size-24 bg-zinc-800 rounded-md overflow-hidden has-[:focus]:ring-2 has-[:focus]:ring-blue-600"
|
||||
>
|
||||
<input
|
||||
id="icon-upload"
|
||||
type="file"
|
||||
class="sr-only"
|
||||
accept="image/*"
|
||||
@change="addFile"
|
||||
/>
|
||||
<img
|
||||
v-if="currentFileObjectUrl"
|
||||
:src="currentFileObjectUrl"
|
||||
class="absolute inset-0 object-cover w-full h-full"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 cursor-pointer flex flex-col gap-y-1 items-center justify-center text-zinc-300 bg-zinc-900/50"
|
||||
>
|
||||
<ArrowUpTrayIcon class="size-6" />
|
||||
<span class="text-xs font-bold font-display uppercase">Upload</span>
|
||||
</div>
|
||||
</label>
|
||||
<div class="grow flex flex-col gap-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-zinc-100"
|
||||
>Name</label
|
||||
>
|
||||
<input
|
||||
id="name"
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-zinc-100"
|
||||
>Description</label
|
||||
>
|
||||
<input
|
||||
id="description"
|
||||
v-model="description"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SwitchGroup
|
||||
as="div"
|
||||
class="max-w-lg flex items-center justify-between gap-x-4"
|
||||
>
|
||||
<span class="flex flex-grow flex-col">
|
||||
<SwitchLabel
|
||||
as="span"
|
||||
class="text-sm font-medium leading-6 text-zinc-100"
|
||||
passive
|
||||
>Create as platform</SwitchLabel
|
||||
>
|
||||
<SwitchDescription as="span" class="text-sm text-zinc-400"
|
||||
>Versions for this redistributable will be able to take a series of
|
||||
launch commands. Intended to be used with emulators and similar
|
||||
programs.</SwitchDescription
|
||||
>
|
||||
</span>
|
||||
<Switch
|
||||
v-model="isPlatform"
|
||||
:class="[
|
||||
isPlatform ? 'bg-blue-600' : 'bg-zinc-800',
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
isPlatform ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
]"
|
||||
/>
|
||||
</Switch>
|
||||
</SwitchGroup>
|
||||
<div class="relative">
|
||||
<div class="flex flex-row gap-x-4">
|
||||
<label
|
||||
for="platform-icon-upload"
|
||||
class="relative size-24 bg-zinc-800 rounded-md overflow-hidden has-[:focus]:ring-2 has-[:focus]:ring-blue-600"
|
||||
>
|
||||
<input
|
||||
id="platform-icon-upload"
|
||||
type="file"
|
||||
class="sr-only"
|
||||
accept="image/svg+xml"
|
||||
:disabled="!isPlatform"
|
||||
@change="addSvg"
|
||||
/>
|
||||
<div
|
||||
v-if="platform.icon"
|
||||
class="absolute inset-0 object-cover w-full h-full text-blue-600"
|
||||
v-html="platform.icon"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 cursor-pointer flex flex-col gap-y-1 items-center justify-center text-zinc-300 bg-zinc-900/50 focus:text-zinc-100"
|
||||
>
|
||||
<ArrowUpTrayIcon class="size-6" />
|
||||
<span class="text-xs font-bold font-display uppercase"
|
||||
>Upload SVG</span
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
<div class="grow flex flex-col gap-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="platform-name"
|
||||
class="block text-sm font-medium text-zinc-100"
|
||||
>Platform Name</label
|
||||
>
|
||||
<input
|
||||
id="platform-name"
|
||||
v-model="platform.name"
|
||||
type="text"
|
||||
:disabled="!isPlatform"
|
||||
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 w-full">
|
||||
<label for="platform-name" class="block text-sm font-medium text-zinc-100"
|
||||
>File Extensions {{ currentExtDotted }}
|
||||
</label
|
||||
>
|
||||
<Combobox
|
||||
as="div"
|
||||
:model-value="currentExtDotted"
|
||||
nullable
|
||||
class="mt-1 w-full"
|
||||
:disabled="!isPlatform"
|
||||
@update:model-value="(v) => addExt(v)"
|
||||
>
|
||||
<div class="relative">
|
||||
<ComboboxInput
|
||||
class="w-full block flex-1 rounded-lg border-1 border-zinc-800 py-2 px-2 bg-zinc-950 text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
placeholder=".exe"
|
||||
@change="currentExt = $event.target.value"
|
||||
@blur="currentExt = ''"
|
||||
/>
|
||||
<ComboboxOptions
|
||||
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-none sm:text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-if="currentExt"
|
||||
v-slot="{ active }"
|
||||
:value="currentExtDotted"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span class="block">
|
||||
<span class="text-blue-300">filename</span
|
||||
><span class="font-semibold">{{ currentExtDotted }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
<div class="mt-2 flex gap-1 flex-wrap">
|
||||
<div
|
||||
v-for="ext in platform.fileExts"
|
||||
:key="ext"
|
||||
class="bg-blue-600/10 border-1 border-blue-700 rounded-full px-2 py-1 text-xs text-blue-400"
|
||||
>
|
||||
{{ ext }}
|
||||
</div>
|
||||
<span
|
||||
v-if="platform.fileExts.length == 0"
|
||||
class="uppercase font-display text-zinc-700 font-bold text-xs"
|
||||
>No suggested file extensions.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPlatform" class="absolute inset-0 bg-zinc-950/20" />
|
||||
</div>
|
||||
<div>
|
||||
<LoadingButton
|
||||
class="w-fit"
|
||||
:loading="props.loading"
|
||||
:disabled="buttonDisabled"
|
||||
@click="() => importRedist()"
|
||||
>
|
||||
{{ $t("library.admin.import.import") }}
|
||||
</LoadingButton>
|
||||
|
||||
<div v-if="props.error" class="mt-4 w-fit rounded-md bg-red-600/10 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
{{ props.error }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
Switch,
|
||||
SwitchDescription,
|
||||
SwitchGroup,
|
||||
SwitchLabel,
|
||||
} from "@headlessui/vue";
|
||||
import { ArrowUpTrayIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const currentFile = ref<File | undefined>(undefined);
|
||||
const currentFileObjectUrl = ref<string | undefined>(undefined);
|
||||
|
||||
const emit = defineEmits<{
|
||||
import: [
|
||||
metadata: { name: string; description: string; icon: File } | undefined,
|
||||
platform: typeof platform.value | undefined,
|
||||
];
|
||||
}>();
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const isPlatform = ref(false);
|
||||
const currentExt = ref("");
|
||||
const currentExtDotted = computed(() => {
|
||||
if(!currentExt.value) return "";
|
||||
const cleaned = currentExt.value.replace(/\W/g, "").toLowerCase();
|
||||
return `.${cleaned}`;
|
||||
});
|
||||
const platform = ref<{ name: string; icon: string; fileExts: string[] }>({
|
||||
name: "",
|
||||
icon: "",
|
||||
fileExts: [],
|
||||
});
|
||||
|
||||
const buttonDisabled = computed<boolean>(
|
||||
() =>
|
||||
!(
|
||||
name.value &&
|
||||
description.value &&
|
||||
currentFileObjectUrl.value &&
|
||||
(!isPlatform.value || (platform.value.name && platform.value.icon))
|
||||
),
|
||||
);
|
||||
|
||||
function addFile(event: Event) {
|
||||
const file = (event.target as HTMLInputElement)?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (currentFileObjectUrl.value) {
|
||||
URL.revokeObjectURL(currentFileObjectUrl.value);
|
||||
}
|
||||
|
||||
currentFile.value = file;
|
||||
currentFileObjectUrl.value = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
async function addSvg(event: Event) {
|
||||
const file = (event.target as HTMLInputElement)?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const svgContent = await file.text();
|
||||
const parser = new DOMParser();
|
||||
try {
|
||||
const document = parser.parseFromString(svgContent, "image/svg+xml");
|
||||
const svg = document.getElementsByTagName("svg").item(0);
|
||||
if (!svg) throw "No SVG in uploaded image.";
|
||||
svg.removeAttribute("width");
|
||||
svg.removeAttribute("height");
|
||||
platform.value.icon = svg.outerHTML;
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "Failed to upload SVG",
|
||||
description: (e as string)?.toString() ?? e,
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function addExt(ext: string | null) {
|
||||
if (!ext) return;
|
||||
if (platform.value.fileExts.includes(ext)) return;
|
||||
platform.value.fileExts.push(ext);
|
||||
currentExt.value = "";
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
gameName: string;
|
||||
loading: boolean;
|
||||
error?: string | undefined;
|
||||
}>();
|
||||
|
||||
function importRedist() {
|
||||
if (!currentFile.value) return;
|
||||
emit(
|
||||
"import",
|
||||
{
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
icon: currentFile.value,
|
||||
},
|
||||
isPlatform.value
|
||||
? {
|
||||
...platform.value,
|
||||
fileExts: platform.value.fileExts.map((e) => e.slice(1)),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
@ -92,7 +92,7 @@ import type { Locale } from "vue-i18n";
|
||||
|
||||
const { showText = true } = defineProps<{ showText?: boolean }>();
|
||||
|
||||
const { locales, locale: currLocale, setLocale } = useI18n();
|
||||
const { availableLocales, locale: currLocale, setLocale } = useI18n();
|
||||
|
||||
function changeLocale(locale: Locale) {
|
||||
setLocale(locale);
|
||||
@ -102,7 +102,7 @@ function changeLocale(locale: Locale) {
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: locale,
|
||||
dir: locales.value.find((l) => l.code === locale)?.dir || "ltr",
|
||||
// dir: availableLocales.find((l) => l === locale)?.dir || "ltr",
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -150,6 +150,6 @@ const wiredLocale = computed({
|
||||
},
|
||||
});
|
||||
const currentLocaleInformation = computed(() =>
|
||||
locales.value.find((e) => e.code == wiredLocale.value),
|
||||
availableLocales.find((e) => e == wiredLocale.value),
|
||||
);
|
||||
</script>
|
||||
@ -15,7 +15,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskLog } from "~/server/internal/tasks";
|
||||
import type { TaskLog } from "~~/server/internal/tasks";
|
||||
|
||||
defineProps<{ log: typeof TaskLog.infer; short?: boolean }>();
|
||||
|
||||
@ -162,7 +162,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { GameModel } from "~/prisma/client/models";
|
||||
import type { GameModel } from "~~/prisma/client/models";
|
||||
import {
|
||||
DialogTitle,
|
||||
Listbox,
|
||||
@ -171,7 +171,7 @@ import {
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
|
||||
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
|
||||
import { FetchError } from "ofetch";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
||||
@ -208,7 +208,7 @@ const { t } = useI18n();
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
|
||||
const currentGame = ref<(typeof metadataGames.value)[number]>();
|
||||
const currentGame = ref<NonNullable<(typeof metadataGames.value)[number]> | null>(null);
|
||||
const developed = ref(false);
|
||||
const published = ref(false);
|
||||
const addGameLoading = ref(false);
|
||||
@ -231,12 +231,12 @@ async function addGame() {
|
||||
emit("created", game, published.value, developed.value);
|
||||
} catch (e) {
|
||||
if (e instanceof FetchError) {
|
||||
addError.value = e.statusMessage ?? e.message ?? t("errors.unknown");
|
||||
addError.value = e.message ?? t("errors.unknown");
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
currentGame.value = undefined;
|
||||
currentGame.value = null;
|
||||
developed.value = false;
|
||||
published.value = false;
|
||||
addGameLoading.value = false;
|
||||
@ -46,7 +46,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
import type { CollectionEntryModel, GameModel } from "~/prisma/client/models";
|
||||
import type { CollectionEntryModel, GameModel } from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
const props = defineProps<{
|
||||
@ -97,13 +97,13 @@ async function createCollection() {
|
||||
} catch (error) {
|
||||
console.error("Failed to create collection:", error);
|
||||
|
||||
const err = error as { statusMessage?: string };
|
||||
const err = error as { message?: string };
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: t("errors.library.collection.create.title"),
|
||||
description: t("errors.library.collection.create.desc", [
|
||||
err?.statusMessage ?? t("errors.unknown"),
|
||||
err?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
@ -110,7 +110,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CompanyModel } from "~/prisma/client/models";
|
||||
import type { CompanyModel } from "~~/prisma/client/models";
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
|
||||
@ -45,7 +45,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
import type { GameTagModel } from "~/prisma/client/models";
|
||||
import type { GameTagModel } from "~~/prisma/client/models";
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [tag: GameTagModel];
|
||||
@ -35,7 +35,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CollectionModel } from "~/prisma/client/models";
|
||||
import type { CollectionModel } from "~~/prisma/client/models";
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
|
||||
const collection = defineModel<CollectionModel | undefined>();
|
||||
@ -67,8 +67,8 @@ async function deleteCollection() {
|
||||
{
|
||||
title: t("errors.library.add.title"),
|
||||
description: t("errors.library.add.desc", [
|
||||
// @ts-expect-error attempt to display statusMessage on error
|
||||
e?.statusMessage ?? t("errors.unknown"),
|
||||
// @ts-expect-error attempt to display message on error
|
||||
e?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
@ -71,8 +71,8 @@ async function deleteArticle() {
|
||||
{
|
||||
title: t("errors.news.article.delete.title"),
|
||||
description: t("errors.news.article.delete.desc", [
|
||||
// @ts-expect-error attempt to display statusMessage on error
|
||||
e?.statusMessage ?? t("errors.unknown"),
|
||||
// @ts-expect-error attempt to display message on error
|
||||
e?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
@ -36,7 +36,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
import type { UserModel } from "~/prisma/client/models";
|
||||
import type { UserModel } from "~~/prisma/client/models";
|
||||
|
||||
const user = defineModel<UserModel | undefined>();
|
||||
const deleteLoading = ref(false);
|
||||
@ -62,8 +62,8 @@ async function deleteUser() {
|
||||
{
|
||||
title: t("errors.admin.user.delete.title"),
|
||||
description: t("errors.admin.user.delete.desc", [
|
||||
// @ts-expect-error attempt to display statusMessage on error
|
||||
e?.statusMessage ?? t("errors.unknown"),
|
||||
// @ts-expect-error attempt to display message on error
|
||||
e?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
@ -177,7 +177,7 @@ function uploadFile_wrapper() {
|
||||
uploadLoading.value = true;
|
||||
uploadFile()
|
||||
.catch((error) => {
|
||||
uploadError.value = error.statusMessage ?? t("errors.unknown");
|
||||
uploadError.value = error.message ?? t("errors.unknown");
|
||||
})
|
||||
.finally(() => {
|
||||
uploadLoading.value = false;
|
||||
@ -414,8 +414,8 @@ async function createArticle() {
|
||||
|
||||
modalOpen.value = false;
|
||||
} catch (e) {
|
||||
// @ts-expect-error attempt to get statusMessage on error
|
||||
error.value = e?.statusMessage ?? t("errors.unknown");
|
||||
// @ts-expect-error attempt to get message on error
|
||||
error.value = e?.message ?? t("errors.unknown");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@ -44,7 +44,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
||||
import type { NotificationModel } from "~/prisma/client/models";
|
||||
import type { NotificationModel } from "~~/prisma/client/models";
|
||||
|
||||
const props = defineProps<{ notification: NotificationModel }>();
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
<template>
|
||||
<Listbox v-model="typedModel" as="div">
|
||||
<Listbox v-model="model" as="div">
|
||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
><slot
|
||||
/></ListboxLabel>
|
||||
<div class="relative mt-2">
|
||||
<div class="relative">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<span v-if="model" class="flex items-center">
|
||||
<component
|
||||
:is="PLATFORM_ICONS[model]"
|
||||
alt=""
|
||||
<span v-if="currentEntry" class="flex items-center">
|
||||
<IconsPlatform
|
||||
:platform="currentEntry.platformIcon.key"
|
||||
:fallback="currentEntry.platformIcon.fallback"
|
||||
class="h-5 w-5 flex-shrink-0 text-blue-600"
|
||||
/>
|
||||
<span class="ml-3 block truncate">{{ model }}</span>
|
||||
<span class="ml-3 block truncate">{{ currentEntry.name }}</span>
|
||||
</span>
|
||||
<span v-else>{{ $t("library.admin.import.selectPlatform") }}</span>
|
||||
<span
|
||||
@ -32,11 +32,11 @@
|
||||
class="absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-950 ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="[name, value] in Object.entries(values)"
|
||||
:key="value"
|
||||
v-for="entry in values"
|
||||
:key="entry.param"
|
||||
v-slot="{ active, selected }"
|
||||
as="template"
|
||||
:value="value"
|
||||
:value="entry.param"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
@ -45,15 +45,13 @@
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<component
|
||||
:is="PLATFORM_ICONS[value]"
|
||||
alt=""
|
||||
:class="[
|
||||
active ? 'text-zinc-100' : 'text-blue-600',
|
||||
'h-5 w-5 flex-shrink-0',
|
||||
]"
|
||||
<IconsPlatform
|
||||
v-if="entry.platformIcon"
|
||||
:platform="entry.platformIcon.key"
|
||||
:fallback="entry.platformIcon.fallback"
|
||||
class="size-5 text-blue-500"
|
||||
/>
|
||||
<span class="ml-3 block truncate">{{ name }}</span>
|
||||
<span class="ml-3 block truncate">{{ entry.name }}</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
@ -83,17 +81,14 @@ import {
|
||||
} from "@headlessui/vue";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
|
||||
const model = defineModel<PlatformClient | undefined>();
|
||||
const model = defineModel<string | undefined>();
|
||||
|
||||
const typedModel = computed<PlatformClient | null>({
|
||||
get() {
|
||||
return model.value || null;
|
||||
},
|
||||
set(v) {
|
||||
if (v === null) return (model.value = undefined);
|
||||
model.value = v;
|
||||
},
|
||||
});
|
||||
const props = defineProps<{ platforms: PlatformRenderable[] }>();
|
||||
const currentEntry = computed(() =>
|
||||
model.value
|
||||
? props.platforms.find((v) => v.param === model.value)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const values = Object.fromEntries(Object.entries(PlatformClient));
|
||||
const values = props.platforms;
|
||||
</script>
|
||||
120
app/components/PreloadSelector.vue
Normal file
120
app/components/PreloadSelector.vue
Normal file
@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<Combobox
|
||||
as="div"
|
||||
:value="props.value"
|
||||
nullable
|
||||
@update:model-value="(v) => emit('update', v)"
|
||||
>
|
||||
<div class="relative">
|
||||
<ComboboxInput
|
||||
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
placeholder="file.exe"
|
||||
@change="query = $event.target.value"
|
||||
@blur="query = ''"
|
||||
/>
|
||||
<ComboboxButton
|
||||
v-if="filtered?.length ?? 0 > 0"
|
||||
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
|
||||
>
|
||||
<ChevronUpDownIcon class="size-5 text-gray-400" aria-hidden="true" />
|
||||
</ComboboxButton>
|
||||
|
||||
<ComboboxOptions
|
||||
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-none sm:text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="guess in filtered"
|
||||
:key="guess.filename"
|
||||
v-slot="{ active, selected }"
|
||||
:value="guess.filename"
|
||||
as="template"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active ? 'bg-blue-600 text-white outline-none' : 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-x-2 block truncate',
|
||||
selected && 'font-semibold',
|
||||
]"
|
||||
>
|
||||
{{ guess.filename }}
|
||||
<IconsPlatform
|
||||
:platform="guess.platform.platformIcon.key"
|
||||
:fallback="guess.platform.platformIcon.fallback"
|
||||
class="size-5 flex-shrink-0 text-blue-600"
|
||||
/>
|
||||
</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>
|
||||
<ComboboxOption
|
||||
v-if="query"
|
||||
v-slot="{ active, selected }"
|
||||
:value="query"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active ? 'bg-blue-600 text-white outline-none' : 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span :class="['block truncate', selected && 'font-semibold']">
|
||||
{{ $t("chars.quoted", { text: query }) }}
|
||||
</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 {
|
||||
Combobox,
|
||||
ComboboxButton,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [v: string];
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
value?: string;
|
||||
guesses?: Array<{ platform: PlatformRenderable; filename: string }>;
|
||||
}>();
|
||||
|
||||
const query = ref("");
|
||||
|
||||
const filtered = computed(() =>
|
||||
props.guesses?.filter((e) =>
|
||||
e.filename.toLowerCase().includes(query.value.toLowerCase()),
|
||||
),
|
||||
);
|
||||
</script>
|
||||
14
app/components/RedistEditor/Metadata.vue
Normal file
14
app/components/RedistEditor/Metadata.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div>{{ model }}</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { RedistModel, UserPlatformModel } from "~~/prisma/client/models";
|
||||
|
||||
type ModelType = SerializeObject<
|
||||
RedistModel & { platform?: UserPlatformModel }
|
||||
>;
|
||||
|
||||
const model = defineModel<ModelType>({ required: true });
|
||||
</script>
|
||||
@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AdminSourcesPage from "~/pages/admin/library/sources/index.vue";
|
||||
import AdminSourcesPage from "~~/pages/admin/library/sources/index.vue";
|
||||
|
||||
const complete = defineModel<boolean>({ required: true });
|
||||
// Only runs on component load, so it's fine
|
||||
@ -247,7 +247,7 @@
|
||||
<div
|
||||
v-for="(option, optionIdx) in section.options"
|
||||
:key="option.param"
|
||||
class="flex gap-3"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div class="flex h-5 shrink-0 items-center">
|
||||
<div class="group grid size-4 grid-cols-1">
|
||||
@ -272,6 +272,12 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<IconsPlatform
|
||||
v-if="option.platformIcon"
|
||||
:platform="option.platformIcon.key"
|
||||
:fallback="option.platformIcon.fallback"
|
||||
class="size-5 text-blue-500"
|
||||
/>
|
||||
<label
|
||||
:for="`filter-${section.param}-${optionIdx}`"
|
||||
class="text-sm text-zinc-400"
|
||||
@ -359,7 +365,7 @@ import {
|
||||
Squares2X2Icon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { GameModel, GameTagModel } from "~/prisma/client/models";
|
||||
import type { GameModel, GameTagModel } from "~~/prisma/client/models";
|
||||
import MultiItemSelector from "./MultiItemSelector.vue";
|
||||
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
|
||||
|
||||
@ -376,6 +382,8 @@ const props = defineProps<{
|
||||
const tags =
|
||||
await $dropFetch<Array<SerializeObject<GameTagModel>>>("/api/v1/store/tags");
|
||||
|
||||
const userPlatforms = await $dropFetch("/api/v1/store/platforms");
|
||||
|
||||
const sorts: Array<StoreSortOption> = [
|
||||
{
|
||||
name: "Default",
|
||||
@ -407,7 +415,7 @@ const options: Array<StoreFilterOption> = [
|
||||
name: "Platform",
|
||||
param: "platform",
|
||||
multiple: true,
|
||||
options: Object.values(PlatformClient).map((e) => ({ name: e, param: e })),
|
||||
options: renderPlatforms(userPlatforms),
|
||||
},
|
||||
...(props.extraOptions ?? []),
|
||||
];
|
||||
@ -49,7 +49,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/24/solid";
|
||||
import type { TaskMessage } from "~/server/internal/tasks";
|
||||
import type { TaskMessage } from "~~/server/internal/tasks";
|
||||
|
||||
defineProps<{ task: TaskMessage | undefined; active?: boolean }>();
|
||||
</script>
|
||||
@ -46,7 +46,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { NotificationModel } from "~/prisma/client/models";
|
||||
import type { NotificationModel } from "~~/prisma/client/models";
|
||||
|
||||
const props = defineProps<{ notifications: Array<NotificationModel> }>();
|
||||
</script>
|
||||
@ -81,8 +81,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
|
||||
import { ChevronDownIcon } from "@heroicons/vue/16/solid";
|
||||
import { useObject } from "~/composables/objects";
|
||||
import type { NavigationItem } from "~/composables/types";
|
||||
|
||||
const user = useUser();
|
||||
|
||||
@ -2,7 +2,7 @@ import type {
|
||||
CollectionModel,
|
||||
CollectionEntryModel,
|
||||
GameModel,
|
||||
} from "~/prisma/client/models";
|
||||
} from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
type FullCollection = CollectionModel & {
|
||||
@ -1,4 +1,4 @@
|
||||
import type { ArticleModel } from "~/prisma/client/models";
|
||||
import type { ArticleModel } from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
export const useNews = () =>
|
||||
@ -1,4 +1,4 @@
|
||||
import type { NotificationModel } from "~/prisma/client/models";
|
||||
import type { NotificationModel } from "~~/prisma/client/models";
|
||||
|
||||
const ws = new WebSocketHandler("/api/v1/notifications/ws");
|
||||
|
||||
36
app/composables/platform.ts
Normal file
36
app/composables/platform.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { UserPlatform } from "~~/prisma/client/client";
|
||||
import { HardwarePlatform } from "~~/prisma/client/enums";
|
||||
|
||||
export type PlatformRenderable = {
|
||||
name: string;
|
||||
param: string;
|
||||
platformIcon: { key: string; fallback?: string };
|
||||
};
|
||||
|
||||
export function renderPlatforms(
|
||||
userPlatforms: { platformName: string; id: string; iconSvg: string }[],
|
||||
): PlatformRenderable[] {
|
||||
return [
|
||||
...Object.values(HardwarePlatform).map((e) => ({
|
||||
name: e,
|
||||
param: e,
|
||||
platformIcon: { key: e },
|
||||
})),
|
||||
...userPlatforms.map((e) => ({
|
||||
name: e.platformName,
|
||||
param: e.id,
|
||||
platformIcon: { key: e.id, fallback: e.iconSvg },
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
const rawUseAdminPlatforms = () => useState<Array<UserPlatform> | null>('adminPlatforms', () => null);
|
||||
|
||||
export async function useAdminPlatforms() {
|
||||
const platforms = rawUseAdminPlatforms();
|
||||
if(platforms.value === null){
|
||||
platforms.value = await $dropFetch("/api/v1/admin/platforms");
|
||||
}
|
||||
|
||||
return platforms.value!
|
||||
}
|
||||
@ -4,7 +4,7 @@ import type {
|
||||
NitroFetchRequest,
|
||||
TypedInternalResponse,
|
||||
} from "nitropack/types";
|
||||
import type { FetchError } from "ofetch";
|
||||
import { FetchError } from "ofetch";
|
||||
|
||||
interface DropFetch<
|
||||
DefaultT = unknown,
|
||||
@ -60,12 +60,15 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
|
||||
{
|
||||
title: opts.failTitle,
|
||||
description:
|
||||
(e as FetchError)?.statusMessage ?? (e as string).toString(),
|
||||
(e as FetchError)?.message ?? (e as string).toString(),
|
||||
//buttonText: $t("common.close"),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
}
|
||||
if(e instanceof FetchError) {
|
||||
e.message = e.data.message ?? e.message;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@ -8,4 +8,5 @@ export type StoreFilterOption = {
|
||||
export type StoreSortOption = {
|
||||
name: string;
|
||||
param: string;
|
||||
platformIcon?: { key: string; fallback?: string };
|
||||
};
|
||||
@ -1,4 +1,4 @@
|
||||
import type { TaskMessage } from "~/server/internal/tasks";
|
||||
import type { TaskMessage } from "~~/server/internal/tasks";
|
||||
import { WebSocketHandler } from "./ws";
|
||||
|
||||
const websocketHandler = new WebSocketHandler("/api/v1/task");
|
||||
@ -10,10 +10,4 @@ export type QuickActionNav = {
|
||||
icon: Component;
|
||||
notifications?: Ref<number>;
|
||||
action: () => Promise<void>;
|
||||
};
|
||||
|
||||
export enum PlatformClient {
|
||||
Windows = "Windows",
|
||||
Linux = "Linux",
|
||||
macOS = "macOS",
|
||||
}
|
||||
};
|
||||
@ -1,4 +1,4 @@
|
||||
import type { UserModel } from "~/prisma/client/models";
|
||||
import type { UserModel } from "~~/prisma/client/models";
|
||||
|
||||
// undefined = haven't check
|
||||
// null = check, no user
|
||||
@ -1,6 +1,6 @@
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { UserModel } from "~/prisma/client/models";
|
||||
import type { AuthMec } from "~/prisma/client/enums";
|
||||
import type { UserModel } from "~~/prisma/client/models";
|
||||
import type { AuthMec } from "~~/prisma/client/enums";
|
||||
|
||||
export const useUsers = () =>
|
||||
useState<
|
||||
@ -33,7 +33,7 @@ export class WebSocketHandler {
|
||||
case "unauthenticated": {
|
||||
const error = createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Unable to connect to websocket - unauthenticated",
|
||||
message: "Unable to connect to websocket - unauthenticated",
|
||||
});
|
||||
if (this.errorHandler) {
|
||||
return this.errorHandler(error);
|
||||
@ -8,6 +8,8 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
await updateUser();
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const user = useUser();
|
||||
@ -166,8 +166,6 @@ import {
|
||||
RectangleStackIcon,
|
||||
DocumentIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import type { NavigationItem } from "~/composables/types";
|
||||
import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
|
||||
import { ArrowLeftIcon } from "@heroicons/vue/16/solid";
|
||||
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
||||
|
||||
@ -92,7 +92,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, TrashIcon } from "@heroicons/vue/24/outline";
|
||||
import type { NotificationModel } from "~/prisma/client/models";
|
||||
import type { NotificationModel } from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
definePageMeta({
|
||||
@ -180,7 +180,7 @@ if (route.query.payload) {
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Failed to parse the token create payload.",
|
||||
message: "Failed to parse the token create payload.",
|
||||
fatal: true,
|
||||
});
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-y-4 max-w-lg">
|
||||
<div class="flex flex-col gap-y-4">
|
||||
<Listbox
|
||||
as="div"
|
||||
:model-value="currentlySelectedVersion"
|
||||
class="max-w-lg"
|
||||
@update:model-value="(value) => updateCurrentlySelectedVersion(value)"
|
||||
>
|
||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">{{
|
||||
@ -73,9 +74,32 @@
|
||||
</div>
|
||||
</Listbox>
|
||||
|
||||
<div v-if="versionGuesses" class="flex flex-col gap-8">
|
||||
<!-- setup executable -->
|
||||
<div>
|
||||
<div v-if="versionGuesses" class="flex flex-col gap-4">
|
||||
<!-- version name -->
|
||||
<div class="max-w-lg">
|
||||
<label
|
||||
for="startup"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>Version name</label
|
||||
>
|
||||
<p class="text-zinc-400 text-xs">
|
||||
Shown to users when selecting what version to install.
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="name"
|
||||
v-model="versionSettings.name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="my version name"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-950 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- install command -->
|
||||
<div class="max-w-lg">
|
||||
<label
|
||||
for="startup"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
@ -93,109 +117,14 @@
|
||||
>
|
||||
{{ $t("library.admin.import.version.installDir") }}
|
||||
</span>
|
||||
<Combobox
|
||||
as="div"
|
||||
:value="versionSettings.setup"
|
||||
nullable
|
||||
@update:model-value="(v) => updateSetupCommand(v)"
|
||||
>
|
||||
<div class="relative">
|
||||
<ComboboxInput
|
||||
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
:placeholder="
|
||||
$t('library.admin.import.version.setupPlaceholder')
|
||||
"
|
||||
@change="setupProcessQuery = $event.target.value"
|
||||
@blur="setupProcessQuery = ''"
|
||||
/>
|
||||
<ComboboxButton
|
||||
v-if="setupFilteredVersionGuesses?.length ?? 0 > 0"
|
||||
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="size-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ComboboxButton>
|
||||
|
||||
<ComboboxOptions
|
||||
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-none sm:text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="guess in setupFilteredVersionGuesses"
|
||||
:key="guess.filename"
|
||||
v-slot="{ active, selected }"
|
||||
:value="guess.filename"
|
||||
as="template"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-x-2 block truncate',
|
||||
selected && 'font-semibold',
|
||||
]"
|
||||
>
|
||||
{{ guess.filename }}
|
||||
<component
|
||||
:is="PLATFORM_ICONS[guess.platform as PlatformClient]"
|
||||
class="size-5"
|
||||
/>
|
||||
</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>
|
||||
<ComboboxOption
|
||||
v-if="setupProcessQuery"
|
||||
v-slot="{ active, selected }"
|
||||
:value="setupProcessQuery"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="['block truncate', selected && 'font-semibold']"
|
||||
>
|
||||
{{ $t("chars.quoted", { text: setupProcessQuery }) }}
|
||||
</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>
|
||||
<PreloadSelector
|
||||
:value="versionSettings.install"
|
||||
:guesses="versionGuesses"
|
||||
@update="(v) => updateInstallCommand(v)"
|
||||
/>
|
||||
<input
|
||||
id="startup"
|
||||
v-model="versionSettings.setupArgs"
|
||||
v-model="versionSettings.installArgs"
|
||||
type="text"
|
||||
name="startup"
|
||||
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
@ -205,7 +134,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- setup mode -->
|
||||
<SwitchGroup as="div" class="flex items-center justify-between">
|
||||
<SwitchGroup as="div" class="max-w-lg flex items-center justify-between">
|
||||
<span class="flex flex-grow flex-col">
|
||||
<SwitchLabel
|
||||
as="span"
|
||||
@ -233,7 +162,8 @@
|
||||
/>
|
||||
</Switch>
|
||||
</SwitchGroup>
|
||||
<div class="relative">
|
||||
<!-- launch commands -->
|
||||
<div class="relative max-w-3xl">
|
||||
<label
|
||||
for="startup"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
@ -242,134 +172,122 @@
|
||||
<p class="text-zinc-400 text-xs">
|
||||
{{ $t("library.admin.import.version.launchDesc") }}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<div class="mt-2 ml-4 flex flex-col gap-y-2 items-start">
|
||||
<div
|
||||
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
|
||||
v-for="(launch, launchIdx) in versionSettings.launches"
|
||||
:key="launchIdx"
|
||||
class="inline-flex items-center gap-x-2"
|
||||
>
|
||||
<span
|
||||
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
|
||||
>{{ $t("library.admin.import.version.installDir") }}</span
|
||||
>
|
||||
<Combobox
|
||||
as="div"
|
||||
:value="versionSettings.launch"
|
||||
nullable
|
||||
@update:model-value="(v) => updateLaunchCommand(v)"
|
||||
>
|
||||
<div class="relative">
|
||||
<ComboboxInput
|
||||
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
:placeholder="
|
||||
$t('library.admin.import.version.launchPlaceholder')
|
||||
"
|
||||
@change="launchProcessQuery = $event.target.value"
|
||||
@blur="launchProcessQuery = ''"
|
||||
/>
|
||||
<ComboboxButton
|
||||
v-if="launchFilteredVersionGuesses?.length ?? 0 > 0"
|
||||
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="size-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ComboboxButton>
|
||||
|
||||
<ComboboxOptions
|
||||
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-none sm:text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="guess in launchFilteredVersionGuesses"
|
||||
:key="guess.filename"
|
||||
v-slot="{ active, selected }"
|
||||
:value="guess.filename"
|
||||
as="template"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-x-2 block truncate',
|
||||
selected && 'font-semibold',
|
||||
]"
|
||||
>
|
||||
{{ guess.filename }}
|
||||
<component
|
||||
:is="PLATFORM_ICONS[guess.platform as PlatformClient]"
|
||||
class="size-5"
|
||||
/>
|
||||
</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>
|
||||
<ComboboxOption
|
||||
v-if="launchProcessQuery"
|
||||
v-slot="{ active, selected }"
|
||||
:value="launchProcessQuery"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="['block truncate', selected && 'font-semibold']"
|
||||
>
|
||||
{{ $t("chars.quoted", { text: launchProcessQuery }) }}
|
||||
</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>
|
||||
<input
|
||||
id="startup"
|
||||
v-model="versionSettings.launchArgs"
|
||||
id="launch-name"
|
||||
v-model="launch.name"
|
||||
type="text"
|
||||
name="startup"
|
||||
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
placeholder="--launch"
|
||||
name="launch-name"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-950 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
placeholder="My Launch Command"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex w-full rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
|
||||
>
|
||||
<span
|
||||
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
|
||||
>{{ $t("library.admin.import.version.installDir") }}</span
|
||||
>
|
||||
<PreloadSelector
|
||||
:value="launch.launchCommand"
|
||||
:guesses="versionGuesses"
|
||||
@update="(v) => updateLaunchCommand(launchIdx, v)"
|
||||
/>
|
||||
<input
|
||||
id="startup"
|
||||
v-model="launch.launchArgs"
|
||||
type="text"
|
||||
name="startup"
|
||||
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
placeholder="--launch"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="transition bg-zinc-800 rounded-sm aspect-square p-1 text-zinc-600 hover:text-red-600 hover:bg-red-600/20"
|
||||
@click="() => versionSettings.launches!.splice(launchIdx, 1)"
|
||||
>
|
||||
<TrashIcon class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="versionSettings.launches!.length == 0"
|
||||
class="uppercase font-display font-bold text-zinc-500 text-xs"
|
||||
>
|
||||
No launch commands
|
||||
</p>
|
||||
|
||||
<LoadingButton
|
||||
:loading="false"
|
||||
class="inline-flex items-center gap-x-4"
|
||||
@click="
|
||||
() =>
|
||||
versionSettings.launches!.push({
|
||||
name: '',
|
||||
description: '',
|
||||
launchCommand: '',
|
||||
launchArgs: '',
|
||||
})
|
||||
"
|
||||
>
|
||||
Add new <PlusIcon class="size-5" />
|
||||
</LoadingButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="versionSettings.onlySetup"
|
||||
class="absolute inset-0 bg-zinc-900/50"
|
||||
/>
|
||||
</div>
|
||||
<!-- uninstall command -->
|
||||
<div class="max-w-lg">
|
||||
<label
|
||||
for="startup"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>Uninstall command</label
|
||||
>
|
||||
<p class="text-zinc-400 text-xs">
|
||||
Executable to be run on uninstalling a game. Useful for installer-only games.
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<div
|
||||
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
|
||||
>
|
||||
<span
|
||||
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
|
||||
>
|
||||
{{ $t("library.admin.import.version.installDir") }}
|
||||
</span>
|
||||
<PreloadSelector
|
||||
:value="versionSettings.uninstall"
|
||||
:guesses="versionGuesses"
|
||||
@update="(v) => updateUninstallCommand(v)"
|
||||
/>
|
||||
<input
|
||||
id="startup"
|
||||
v-model="versionSettings.uninstallArgs"
|
||||
type="text"
|
||||
name="startup"
|
||||
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
placeholder="--uninstall"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PlatformSelector v-model="versionSettings.platform">
|
||||
<PlatformSelector
|
||||
v-model="versionSettings.platform"
|
||||
class="max-w-lg"
|
||||
:platforms="allPlatforms"
|
||||
>
|
||||
{{ $t("library.admin.import.version.platform") }}
|
||||
</PlatformSelector>
|
||||
<SwitchGroup as="div" class="flex items-center justify-between">
|
||||
<SwitchGroup as="div" class="flex items-center justify-between max-w-lg">
|
||||
<span class="flex flex-grow flex-col">
|
||||
<SwitchLabel
|
||||
as="span"
|
||||
@ -398,7 +316,7 @@
|
||||
/>
|
||||
</Switch>
|
||||
</SwitchGroup>
|
||||
<Disclosure v-slot="{ open }" as="div" class="py-2">
|
||||
<Disclosure v-slot="{ open }" as="div" class="py-2 max-w-lg">
|
||||
<dt>
|
||||
<DisclosureButton
|
||||
class="border-b border-zinc-600 pb-2 flex w-full items-start justify-between text-left text-zinc-100"
|
||||
@ -418,7 +336,7 @@
|
||||
>
|
||||
<!-- UMU launcher configuration -->
|
||||
<div
|
||||
v-if="versionSettings.platform == PlatformClient.Windows"
|
||||
v-if="versionSettings.platform == 'Linux'"
|
||||
class="flex flex-col gap-y-4"
|
||||
>
|
||||
<SwitchGroup as="div" class="flex items-center justify-between">
|
||||
@ -467,7 +385,7 @@
|
||||
required
|
||||
:disabled="!umuIdEnabled"
|
||||
placeholder="umu-starcitizen"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-950 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-950 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -539,15 +457,13 @@ import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
Combobox,
|
||||
ComboboxButton,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { XCircleIcon } from "@heroicons/vue/16/solid";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/vue/24/outline";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { ImportVersion } from "~~/server/api/v1/admin/import/version/index.post";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
@ -560,52 +476,32 @@ const gameId = route.params.id.toString();
|
||||
const versions = await $dropFetch(
|
||||
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
|
||||
);
|
||||
const userPlatforms = await useAdminPlatforms();
|
||||
const allPlatforms = renderPlatforms(userPlatforms);
|
||||
const currentlySelectedVersion = ref(-1);
|
||||
const versionSettings = ref<{
|
||||
platform: PlatformClient | undefined;
|
||||
|
||||
onlySetup: boolean;
|
||||
launch: string;
|
||||
launchArgs: string;
|
||||
setup: string;
|
||||
setupArgs: string;
|
||||
|
||||
delta: boolean;
|
||||
umuId: string;
|
||||
}>({
|
||||
platform: undefined,
|
||||
launch: "",
|
||||
launchArgs: "",
|
||||
setup: "",
|
||||
setupArgs: "",
|
||||
delta: false,
|
||||
onlySetup: false,
|
||||
umuId: "",
|
||||
const versionSettings = ref<Partial<typeof ImportVersion.infer>>({
|
||||
id: gameId,
|
||||
launches: [],
|
||||
});
|
||||
|
||||
const versionGuesses =
|
||||
ref<Array<{ platform: PlatformClient; filename: string }>>();
|
||||
const launchProcessQuery = ref("");
|
||||
const setupProcessQuery = ref("");
|
||||
ref<
|
||||
Array<SerializeObject<{ platform: PlatformRenderable; filename: string }>>
|
||||
>();
|
||||
|
||||
const launchFilteredVersionGuesses = computed(() =>
|
||||
versionGuesses.value?.filter((e) =>
|
||||
e.filename.toLowerCase().includes(launchProcessQuery.value.toLowerCase()),
|
||||
),
|
||||
);
|
||||
const setupFilteredVersionGuesses = computed(() =>
|
||||
versionGuesses.value?.filter((e) =>
|
||||
e.filename.toLowerCase().includes(setupProcessQuery.value.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
function updateLaunchCommand(value: string) {
|
||||
versionSettings.value.launch = value;
|
||||
function updateLaunchCommand(idx: number, value: string) {
|
||||
versionSettings.value.launches![idx].launchCommand = value;
|
||||
autosetPlatform(value);
|
||||
}
|
||||
|
||||
function updateSetupCommand(value: string) {
|
||||
versionSettings.value.setup = value;
|
||||
function updateInstallCommand(value: string) {
|
||||
versionSettings.value.install = value;
|
||||
autosetPlatform(value);
|
||||
}
|
||||
|
||||
function updateUninstallCommand(value: string) {
|
||||
versionSettings.value.uninstall = value;
|
||||
autosetPlatform(value);
|
||||
}
|
||||
|
||||
@ -616,7 +512,8 @@ function autosetPlatform(value: string) {
|
||||
(e) => e.filename === value,
|
||||
);
|
||||
if (guessIndex == -1) return;
|
||||
versionSettings.value.platform = versionGuesses.value[guessIndex].platform;
|
||||
versionSettings.value.platform =
|
||||
versionGuesses.value[guessIndex].platform.param;
|
||||
}
|
||||
|
||||
const umuIdEnabled = ref(false);
|
||||
@ -639,15 +536,16 @@ async function updateCurrentlySelectedVersion(value: number) {
|
||||
if (currentlySelectedVersion.value == value) return;
|
||||
currentlySelectedVersion.value = value;
|
||||
const version = versions[currentlySelectedVersion.value];
|
||||
const results = await $dropFetch(
|
||||
const options = await $dropFetch(
|
||||
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
|
||||
gameId,
|
||||
)}&version=${encodeURIComponent(version)}`,
|
||||
);
|
||||
versionGuesses.value = results.map((e) => ({
|
||||
versionGuesses.value = options.map((e) => ({
|
||||
...e,
|
||||
platform: e.platform as PlatformClient,
|
||||
platform: allPlatforms.find((v) => v.param === e.platform)!,
|
||||
}));
|
||||
versionSettings.value.name = version;
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
@ -667,7 +565,7 @@ function startImport_wrapper() {
|
||||
importLoading.value = true;
|
||||
startImport()
|
||||
.catch((error) => {
|
||||
importError.value = error.statusMessage ?? t("errors.unknown");
|
||||
importError.value = error.message ?? t("errors.unknown");
|
||||
})
|
||||
.finally(() => {
|
||||
importLoading.value = false;
|
||||
@ -7,66 +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
|
||||
@ -112,18 +52,10 @@
|
||||
</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 {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
DocumentIcon,
|
||||
PencilIcon,
|
||||
ServerStackIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import type { Component } from "vue";
|
||||
@ -158,7 +90,6 @@ const components: {
|
||||
const currentMode = ref<GameEditorMode>(GameEditorMode.Metadata);
|
||||
|
||||
useHead({
|
||||
// To do a title with the game name in it, we need some sort of watch
|
||||
title: `${currentMode.value} - ${game.value.mName}`,
|
||||
});
|
||||
|
||||
357
app/pages/admin/library/import.vue
Normal file
357
app/pages/admin/library/import.vue
Normal file
@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-y-6 w-full max-w-md">
|
||||
<Listbox
|
||||
as="div"
|
||||
:model="currentlySelectedGame"
|
||||
@update:model-value="(value) => updateSelectedGame(value)"
|
||||
>
|
||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">
|
||||
{{ $t("library.admin.import.selectGame") }}
|
||||
</ListboxLabel>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<span v-if="currentlySelectedGame != -1" class="block truncate"
|
||||
>{{ games.unimportedGames[currentlySelectedGame].game }}
|
||||
<span
|
||||
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
|
||||
>{{
|
||||
games.unimportedGames[currentlySelectedGame].library.name
|
||||
}}</span
|
||||
></span
|
||||
>
|
||||
<span v-else class="block truncate text-zinc-400">{{
|
||||
$t("library.admin.import.selectDir")
|
||||
}}</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</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-zinc-800 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="({ game, library }, gameIdx) in games.unimportedGames"
|
||||
:key="game"
|
||||
v-slot="{ active }"
|
||||
as="template"
|
||||
:value="gameIdx"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
gameIdx === currentlySelectedGame
|
||||
? 'font-semibold'
|
||||
: 'font-normal',
|
||||
'inline-flex items-center gap-x-2 block truncate py-1 w-full',
|
||||
]"
|
||||
>{{ game }}
|
||||
<span
|
||||
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
|
||||
>{{ library.name }}</span
|
||||
></span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="gameIdx === currentlySelectedGame"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
<div
|
||||
v-if="games.unimportedGames.length == 0"
|
||||
class="w-full uppercase font-display font-bold text-zinc-600 p-2 text-center"
|
||||
>
|
||||
Nothing to import
|
||||
</div>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
<Listbox v-model="currentImportMode" as="div">
|
||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">
|
||||
Import as
|
||||
</ListboxLabel>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="relative inline-flex items-center w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<span class="inline-flex items-top gap-x-2">
|
||||
<component
|
||||
:is="importModes[currentImportMode].icon"
|
||||
class="text-blue-600 size-8 p-1 bg-zinc-800 rounded-sm mt-1"
|
||||
/>
|
||||
<div>
|
||||
<h1 class="text-sm font-bold text-zinc-200">
|
||||
{{ importModes[currentImportMode].name }}
|
||||
</h1>
|
||||
<p class="text-xs text-zinc-400 max-w-xs">
|
||||
{{ importModes[currentImportMode].description }}
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</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-zinc-800 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="[mode, metadata] in Object.entries(importModes)"
|
||||
:key="mode"
|
||||
v-slot="{ active }"
|
||||
as="template"
|
||||
:value="mode"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
mode == currentImportMode ? 'font-semibold' : 'font-normal',
|
||||
'inline-flex items-center gap-x-2 block truncate py-1 w-full',
|
||||
]"
|
||||
>{{ metadata.name }}
|
||||
|
||||
<span
|
||||
v-if="mode == currentImportMode"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
|
||||
<div class="flex items-center justify-between gap-x-8">
|
||||
<span class="flex grow flex-col">
|
||||
<label
|
||||
id="bulkImport-label"
|
||||
class="text-sm/6 font-medium text-zinc-100"
|
||||
>{{ $t("library.admin.import.bulkImportTitle") }}</label
|
||||
>
|
||||
<span id="bulkImport-description" class="text-sm text-zinc-400">{{
|
||||
$t("library.admin.import.bulkImportDescription")
|
||||
}}</span>
|
||||
</span>
|
||||
<div
|
||||
class="group relative inline-flex w-11 shrink-0 rounded-full bg-zinc-800 p-0.5 inset-ring inset-ring-zinc-100/5 outline-offset-2 outline-blue-600 transition-colors duration-200 ease-in-out has-checked:bg-blue-600 has-focus-visible:outline-2"
|
||||
>
|
||||
<span
|
||||
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-zinc-100/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5"
|
||||
/>
|
||||
<input
|
||||
id="bulkImport"
|
||||
v-model="bulkImportMode"
|
||||
type="checkbox"
|
||||
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
|
||||
name="bulkImport"
|
||||
aria-labelledby="bulkImport-label"
|
||||
aria-describedby="bulkImport-description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<component
|
||||
:is="importModes[currentImportMode].component"
|
||||
v-if="currentlySelectedGame !== -1"
|
||||
:game-name="games.unimportedGames[currentlySelectedGame].game"
|
||||
:loading="importLoading"
|
||||
:error="importError"
|
||||
@import="(...v: unknown[]) => importModes[currentImportMode].import(...v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ImportGame, ImportRedist } from "#components";
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
import { PuzzlePieceIcon, ArchiveBoxIcon } from "@heroicons/vue/24/solid";
|
||||
import type { FetchError } from "ofetch";
|
||||
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
type ImportMode = "Game" | "Redist";
|
||||
|
||||
const importModes = shallowRef<{
|
||||
[key in ImportMode]: {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: Component;
|
||||
component: Component;
|
||||
import: (...v: unknown[]) => void;
|
||||
};
|
||||
}>({
|
||||
Game: {
|
||||
name: "Game",
|
||||
description: "Games can be added to user libraries, installed, and played.",
|
||||
icon: PuzzlePieceIcon,
|
||||
component: ImportGame,
|
||||
import: importGame_wrapper as (v: unknown) => void,
|
||||
},
|
||||
Redist: {
|
||||
name: "Redistributable",
|
||||
description:
|
||||
"Redistributables are packaged dependencies for games, that are installed alongside and required to play certain games.",
|
||||
icon: ArchiveBoxIcon,
|
||||
component: ImportRedist,
|
||||
import: importRedist as (v: unknown, k: unknown) => void,
|
||||
},
|
||||
});
|
||||
|
||||
const currentImportMode = ref<ImportMode>("Game");
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const rawGames = await $dropFetch("/api/v1/admin/import/game");
|
||||
const games = ref(rawGames);
|
||||
const currentlySelectedGame = ref(-1);
|
||||
const bulkImportMode = ref(false);
|
||||
|
||||
async function updateSelectedGame(value: number) {
|
||||
if (currentlySelectedGame.value == value || value == -1) return;
|
||||
const option = games.value.unimportedGames[value];
|
||||
if (!option) return;
|
||||
|
||||
currentlySelectedGame.value = value;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const importLoading = ref(false);
|
||||
const importError = ref<string | undefined>();
|
||||
async function importGame(metadata: GameMetadataSearchResult | undefined) {
|
||||
const option = games.value.unimportedGames[currentlySelectedGame.value];
|
||||
|
||||
const { taskId } = await $dropFetch("/api/v1/admin/import/game", {
|
||||
method: "POST",
|
||||
body: {
|
||||
path: option.game,
|
||||
library: option.library.id,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
if (!bulkImportMode.value) {
|
||||
router.push(`/admin/task/${taskId}`);
|
||||
} else {
|
||||
games.value.unimportedGames.splice(currentlySelectedGame.value, 1);
|
||||
currentlySelectedGame.value = -1;
|
||||
}
|
||||
}
|
||||
async function importGame_wrapper(
|
||||
metadata: GameMetadataSearchResult | undefined,
|
||||
) {
|
||||
importLoading.value = true;
|
||||
importError.value = undefined;
|
||||
try {
|
||||
await importGame(metadata);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
importError.value =
|
||||
(error as FetchError)?.message || t("errors.unknown");
|
||||
}
|
||||
importLoading.value = false;
|
||||
}
|
||||
|
||||
async function importRedist(data: object, platform: object | undefined) {
|
||||
importLoading.value = true;
|
||||
importError.value = undefined;
|
||||
try {
|
||||
const option = games.value.unimportedGames[currentlySelectedGame.value];
|
||||
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
formData.append(
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
if (platform) {
|
||||
for (const [key, value] of Object.entries(platform)) {
|
||||
// Because we know there will be no file, and we need to handle more complex objects for
|
||||
// the platform, we do this.
|
||||
// Maybe we shouldn't.
|
||||
formData.append(
|
||||
`platform.${key}`,
|
||||
typeof value === "object" ? JSON.stringify(value) : value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
formData.append("library", option.library.id);
|
||||
formData.append("path", option.game);
|
||||
const redist = await $dropFetch("/api/v1/admin/import/redist", {
|
||||
body: formData,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!bulkImportMode.value) {
|
||||
router.push(`/admin/library/r/${redist.id}`);
|
||||
} else {
|
||||
games.value.unimportedGames.splice(currentlySelectedGame.value, 1);
|
||||
currentlySelectedGame.value = -1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
importError.value =
|
||||
(error as FetchError)?.message || t("errors.unknown");
|
||||
}
|
||||
importLoading.value = false;
|
||||
}
|
||||
</script>
|
||||
@ -71,41 +71,59 @@
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-x-4 text-zinc-300 font-bold uppercase font-display text-sm">
|
||||
<span class="inline-flex items-center gap-x-1"
|
||||
><div class="size-2 rounded-full bg-blue-600" />
|
||||
Game</span
|
||||
>
|
||||
<span class="inline-flex items-center gap-x-1"
|
||||
><div class="size-2 rounded-full bg-emerald-600" />
|
||||
Redistributable</span
|
||||
>
|
||||
</div>
|
||||
<ul
|
||||
role="list"
|
||||
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<li
|
||||
v-for="game in filteredLibraryGames"
|
||||
:key="game.id"
|
||||
v-for="entry in filteredLibrary"
|
||||
:key="entry.id"
|
||||
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
|
||||
>
|
||||
<div class="flex flex-1 flex-row p-4 gap-x-4">
|
||||
<div
|
||||
v-if="entry.type === 'game'"
|
||||
class="relative flex flex-1 flex-row p-4 gap-x-4"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 right-0 w-10 bg-blue-600 h-4 rotate-[45deg] translate-x-1/2"
|
||||
/>
|
||||
|
||||
<img
|
||||
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
|
||||
:src="useObject(game.mIconObjectId)"
|
||||
:src="useObject(entry.mIconObjectId)"
|
||||
alt=""
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<h3
|
||||
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
|
||||
>
|
||||
{{ game.mName }}
|
||||
{{ entry.mName }}
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'rounded-full p-1 shadow-xs focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
game.featured
|
||||
entry.featured
|
||||
? 'bg-yellow-400 hover:bg-yellow-300 focus-visible:outline-yellow-400 text-zinc-900'
|
||||
: 'bg-zinc-800 hover:bg-zinc-700 focus-visible:outline-zinc-400 text-white',
|
||||
]"
|
||||
@click="() => featureGame(game.id)"
|
||||
@click="() => featureGame(entry.id)"
|
||||
>
|
||||
<svg
|
||||
v-if="gameFeatureLoading[game.id]"
|
||||
v-if="gameFeatureLoading[entry.id]"
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
game.featured ? ' fill-zinc-900' : 'fill-zinc-100',
|
||||
entry.featured ? ' fill-zinc-900' : 'fill-zinc-100',
|
||||
'size-3 text-transparent animate-spin',
|
||||
]"
|
||||
viewBox="0 0 100 101"
|
||||
@ -126,13 +144,13 @@
|
||||
</button>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
|
||||
>{{ game.library!.name }}</span
|
||||
>{{ entry.library.name }}</span
|
||||
>
|
||||
</h3>
|
||||
<dl class="mt-1 flex flex-col justify-between">
|
||||
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
|
||||
<dd class="text-sm text-zinc-400">
|
||||
{{ game.mShortDescription }}
|
||||
{{ entry.mShortDescription }}
|
||||
</dd>
|
||||
<dt class="sr-only">
|
||||
{{ $t("library.admin.metadataProvider") }}
|
||||
@ -140,7 +158,7 @@
|
||||
</dl>
|
||||
<div class="mt-4 flex flex-col gap-y-1">
|
||||
<NuxtLink
|
||||
:href="`/admin/library/${game.id}`"
|
||||
:href="`/admin/library/g/${entry.id}`"
|
||||
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<i18n-t
|
||||
@ -155,16 +173,79 @@
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
|
||||
@click="() => deleteGame(game.id)"
|
||||
@click="() => deleteGame(entry.id)"
|
||||
>
|
||||
{{ $t("delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="game.hasNotifications" class="flex flex-col gap-y-2 p-2">
|
||||
<div
|
||||
v-else-if="entry.type === 'redist'"
|
||||
class="relative flex flex-1 flex-row p-4 gap-x-4"
|
||||
>
|
||||
<div
|
||||
v-if="game.notifications.toImport"
|
||||
class="absolute top-0 right-0 w-10 bg-emerald-600 h-4 rotate-[45deg] translate-x-1/2"
|
||||
/>
|
||||
<img
|
||||
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
|
||||
:src="useObject(entry.mIconObjectId)"
|
||||
alt=""
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<h3
|
||||
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
|
||||
>
|
||||
{{ entry.mName }}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
|
||||
>{{ entry.library.name }}</span
|
||||
>
|
||||
</h3>
|
||||
<dl class="mt-1 flex flex-col justify-between">
|
||||
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
|
||||
<dd class="text-sm text-zinc-400">
|
||||
{{ entry.mShortDescription }}
|
||||
</dd>
|
||||
</dl>
|
||||
<dl
|
||||
v-if="entry.platform"
|
||||
class="mt-2 flex items-center text-zinc-200 font-semibold text-sm gap-x-1 p-2 bg-zinc-800 rounded-xl"
|
||||
>
|
||||
<IconsPlatform
|
||||
:platform="entry.platform.id"
|
||||
:fallback="entry.platform.iconSvg"
|
||||
class="size-6 text-blue-600"
|
||||
/>
|
||||
<span>{{ entry.platform.platformName }}</span>
|
||||
</dl>
|
||||
<div class="mt-4 flex flex-col gap-y-1">
|
||||
<NuxtLink
|
||||
:href="`/admin/library/r/${entry.id}`"
|
||||
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.openEditor"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
|
||||
@click="() => deleteRedist(entry.id)"
|
||||
>
|
||||
{{ $t("delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="entry.hasNotifications" class="flex flex-col gap-y-2 p-2">
|
||||
<div
|
||||
v-if="entry.notifications.toImport"
|
||||
class="rounded-md bg-blue-600/10 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
@ -180,7 +261,7 @@
|
||||
</p>
|
||||
<p class="mt-3 text-sm md:ml-6 md:mt-0">
|
||||
<NuxtLink
|
||||
:href="`/admin/library/${game.id}/import`"
|
||||
:href="`/admin/library/g/${entry.id}/import`"
|
||||
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
|
||||
>
|
||||
<i18n-t
|
||||
@ -198,7 +279,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="game.notifications.noVersions"
|
||||
v-if="entry.notifications.noVersions"
|
||||
class="rounded-md bg-yellow-600/10 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
@ -216,7 +297,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="game.notifications.offline"
|
||||
v-if="entry.notifications.offline"
|
||||
class="rounded-md bg-red-600/10 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
@ -236,14 +317,14 @@
|
||||
</div>
|
||||
</li>
|
||||
<p
|
||||
v-if="filteredLibraryGames.length == 0 && libraryGames.length != 0"
|
||||
v-if="filteredLibrary.length == 0 && libraryGames.length != 0"
|
||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
|
||||
>
|
||||
{{ $t("common.noResults") }}
|
||||
</p>
|
||||
<p
|
||||
v-if="
|
||||
filteredLibraryGames.length == 0 &&
|
||||
filteredLibrary.length == 0 &&
|
||||
libraryGames.length == 0 &&
|
||||
libraryState.hasLibraries
|
||||
"
|
||||
@ -305,29 +386,33 @@ useHead({
|
||||
const searchQuery = ref("");
|
||||
|
||||
const libraryState = await $dropFetch("/api/v1/admin/library");
|
||||
type LibraryStateGame = (typeof libraryState.games)[number]["game"];
|
||||
|
||||
const toImport = ref(
|
||||
Object.values(libraryState.unimportedGames).flat().length > 0,
|
||||
);
|
||||
|
||||
const libraryGames = ref<
|
||||
Array<
|
||||
LibraryStateGame & {
|
||||
status: "online" | "offline";
|
||||
hasNotifications?: boolean;
|
||||
notifications: {
|
||||
noVersions?: boolean;
|
||||
toImport?: boolean;
|
||||
offline?: boolean;
|
||||
};
|
||||
}
|
||||
>
|
||||
>(
|
||||
libraryState.games.map((e) => {
|
||||
// Potentially make a server-side transformation to make the client lighter
|
||||
function clientSideTransformation<T, V extends keyof T, K extends string>(
|
||||
values: Array<T & { status: (typeof libraryState.games)[number]["status"] }>,
|
||||
expand: V,
|
||||
type: K,
|
||||
): Array<
|
||||
T[V] & {
|
||||
status: "online" | "offline";
|
||||
type: K;
|
||||
hasNotifications?: boolean;
|
||||
notifications: {
|
||||
noVersions?: boolean;
|
||||
toImport?: boolean;
|
||||
offline?: boolean;
|
||||
};
|
||||
}
|
||||
> {
|
||||
return values.map((e) => {
|
||||
if (e.status == "offline") {
|
||||
return {
|
||||
...e.game,
|
||||
...e[expand],
|
||||
type: type,
|
||||
status: "offline" as const,
|
||||
hasNotifications: true,
|
||||
notifications: {
|
||||
@ -340,7 +425,8 @@ const libraryGames = ref<
|
||||
const toImport = e.status.unimportedVersions.length > 0;
|
||||
|
||||
return {
|
||||
...e.game,
|
||||
...e[expand],
|
||||
type: type,
|
||||
notifications: {
|
||||
noVersions,
|
||||
toImport,
|
||||
@ -348,13 +434,18 @@ const libraryGames = ref<
|
||||
hasNotifications: noVersions || toImport,
|
||||
status: "online" as const,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const libraryGames = ref(
|
||||
clientSideTransformation(libraryState.games, "value", "game"),
|
||||
);
|
||||
const libraryRedists = ref(
|
||||
clientSideTransformation(libraryState.redists, "value", "redist"),
|
||||
);
|
||||
|
||||
const filteredLibraryGames = computed(() =>
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore excessively deep ts
|
||||
libraryGames.value.filter((e) => {
|
||||
const filteredLibrary = computed(() =>
|
||||
[...libraryGames.value, ...libraryRedists.value].filter((e) => {
|
||||
if (!searchQuery.value) return true;
|
||||
const searchQueryLower = searchQuery.value.toLowerCase();
|
||||
if (e.mName.toLowerCase().includes(searchQueryLower)) return true;
|
||||
@ -374,6 +465,16 @@ async function deleteGame(id: string) {
|
||||
toImport.value = true;
|
||||
}
|
||||
|
||||
async function deleteRedist(id: string) {
|
||||
await $dropFetch(`/api/v1/admin/redist/${id}`, {
|
||||
method: "DELETE",
|
||||
failTitle: "Failed to delete game",
|
||||
});
|
||||
const index = libraryRedists.value.findIndex((e) => e.id === id);
|
||||
libraryRedists.value.splice(index, 1);
|
||||
toImport.value = true;
|
||||
}
|
||||
|
||||
const gameFeatureLoading = ref<{ [key: string]: boolean }>({});
|
||||
async function featureGame(id: string) {
|
||||
const gameIndex = libraryGames.value.findIndex((e) => e.id === id);
|
||||
85
app/pages/admin/library/r/[id]/index.vue
Normal file
85
app/pages/admin/library/r/[id]/index.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div
|
||||
class="pt-8 lg:pt-0 lg:pl-20 fixed inset-0 flex flex-col overflow-auto bg-zinc-900"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-950 w-full flex flex-col sm:flex-row items-center gap-2 justify-between pr-2"
|
||||
>
|
||||
<!--start-->
|
||||
<div>
|
||||
<div class="pt-4 inline-flex gap-x-2">
|
||||
<div
|
||||
v-for="[value, { icon }] in Object.entries(components)"
|
||||
:key="value"
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'inline-flex items-center gap-x-1 py-2 px-3 rounded-t-md font-semibold text-sm',
|
||||
value == currentMode
|
||||
? 'bg-zinc-900 text-zinc-100'
|
||||
: 'bg-transparent text-zinc-500',
|
||||
]"
|
||||
@click="() => (currentMode = value as RedistEditorMode)"
|
||||
>
|
||||
<component :is="icon" class="size-4" />
|
||||
{{ value }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- open in store button -->
|
||||
</div>
|
||||
</div>
|
||||
<component
|
||||
:is="components[currentMode].editor"
|
||||
v-model="redist"
|
||||
:unimported-versions="unimportedVersions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GameEditorVersion, RedistEditorMetadata } from "#components";
|
||||
import { DocumentIcon, ServerStackIcon } from "@heroicons/vue/24/outline";
|
||||
import type { Component } from "vue";
|
||||
|
||||
const route = useRoute();
|
||||
const redistId = route.params.id.toString();
|
||||
const { redist: rawRedist, unimportedVersions } = await $dropFetch(
|
||||
`/api/v1/admin/redist/:id`,
|
||||
{ params: { id: redistId } },
|
||||
);
|
||||
const redist = ref(rawRedist);
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
enum RedistEditorMode {
|
||||
Metadata = "Metadata",
|
||||
Versions = "Versions",
|
||||
}
|
||||
|
||||
const components: {
|
||||
[key in RedistEditorMode]: { editor: Component; icon: Component };
|
||||
} = {
|
||||
[RedistEditorMode.Metadata]: { editor: RedistEditorMetadata, icon: DocumentIcon },
|
||||
[RedistEditorMode.Versions]: {
|
||||
editor: GameEditorVersion,
|
||||
icon: ServerStackIcon,
|
||||
},
|
||||
};
|
||||
|
||||
const currentMode = ref<RedistEditorMode>(RedistEditorMode.Metadata);
|
||||
|
||||
useHead({
|
||||
title: `${currentMode.value} - ${redist.value.mName}`,
|
||||
});
|
||||
|
||||
watch(currentMode, (v) => {
|
||||
useHead({
|
||||
title: `${v} - ${redist.value.mName}`,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@ -198,8 +198,8 @@
|
||||
>{{ metadata.title }}
|
||||
<span class="ml-2 font-mono text-zinc-500 text-xs">{{
|
||||
source
|
||||
}}</span></RadioGroupLabel
|
||||
>
|
||||
}}</span>
|
||||
</RadioGroupLabel>
|
||||
<RadioGroupDescription
|
||||
as="span"
|
||||
class="text-zinc-400 text-xs"
|
||||
@ -316,8 +316,8 @@ import {
|
||||
import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
|
||||
import { FetchError } from "ofetch";
|
||||
import type { Component } from "vue";
|
||||
import type { LibraryBackend } from "~/prisma/client/enums";
|
||||
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
|
||||
import type { LibraryBackend } from "~~/prisma/client/enums";
|
||||
import type { WorkingLibrarySource } from "~~/server/api/v1/admin/library/sources/index.get";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
@ -405,18 +405,21 @@ function performActionSource_wrapper() {
|
||||
modalError.value = undefined;
|
||||
modalLoading.value = true;
|
||||
performActionSource()
|
||||
.then(() => {
|
||||
actionSourceOpen.value = false;
|
||||
sourceConfig.value = {};
|
||||
sourceName.value = "";
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e instanceof FetchError) {
|
||||
modalError.value = e.statusMessage ?? e.message;
|
||||
} else {
|
||||
modalError.value = e as string;
|
||||
}
|
||||
})
|
||||
.then(
|
||||
() => {
|
||||
actionSourceOpen.value = false;
|
||||
sourceConfig.value = {};
|
||||
sourceName.value = "";
|
||||
},
|
||||
(e) => {
|
||||
if (e instanceof FetchError) {
|
||||
console.log(e.data.message);
|
||||
modalError.value = e.message;
|
||||
} else {
|
||||
modalError.value = e as string;
|
||||
}
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
modalLoading.value = false;
|
||||
});
|
||||
@ -449,8 +452,8 @@ async function deleteSource(index: number) {
|
||||
{
|
||||
title: t("errors.library.source.delete.title"),
|
||||
description: t("errors.library.source.delete.desc", [
|
||||
// @ts-expect-error attempt to display statusMessage on error
|
||||
e?.statusMessage ?? t("errors.unknown"),
|
||||
// @ts-expect-error attempt to display message on error
|
||||
e?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
@ -238,7 +238,7 @@
|
||||
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
|
||||
import { ArrowUpTrayIcon, PencilIcon, PlusIcon } from "@heroicons/vue/24/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { GameModel } from "~/prisma/client/models";
|
||||
import type { GameModel } from "~~/prisma/client/models";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
@ -106,7 +106,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
|
||||
import type { CompanyModel } from "~/prisma/client/models";
|
||||
import type { CompanyModel } from "~~/prisma/client/models";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon } from "@heroicons/vue/24/outline";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { GameTagModel } from "~/prisma/client/models";
|
||||
import type { GameTagModel } from "~~/prisma/client/models";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
@ -96,7 +96,7 @@ async function saveSettings() {
|
||||
title: `Failed to save settings.`,
|
||||
description:
|
||||
e instanceof FetchError
|
||||
? (e.statusMessage ?? e.message)
|
||||
? (e.message)
|
||||
: (e as string).toString(),
|
||||
},
|
||||
(_, c) => c(),
|
||||
@ -184,7 +184,7 @@ if (route.query.payload) {
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Failed to parse the token create payload.",
|
||||
message: "Failed to parse the token create payload.",
|
||||
fatal: true,
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user