21 Commits

Author SHA1 Message Date
4c9a2c681a fix: remaining type issues 2025-09-25 12:13:07 +10:00
55878bdf5f fix: fixes for Nuxt v4 update 2025-09-25 09:15:29 +10:00
2db8e753b7 update to nuxt 4 2025-09-20 11:21:53 +10:00
b4f9b77809 checkpoint: before migrating to nuxt v4 2025-09-20 11:21:51 +10:00
0b9a715bf2 Merge branch 'develop' into redistributable 2025-09-12 09:11:43 +10:00
5c1b0e6c1e feat: uninstall commands, new R UI 2025-09-07 17:30:24 +10:00
d84c70a05f fix: remove platform 2025-09-06 19:23:02 +10:00
bfd5c8e761 fix: redelete platform 2025-09-06 18:33:27 +10:00
3311aa7274 Merge branch 'develop' into redistributable 2025-09-06 18:32:09 +10:00
fcfc30e5df feat: import of custom platforms & file extensions 2025-09-06 18:29:04 +10:00
7266d0485b fix: update drop-base commit 2025-09-06 15:20:31 +10:00
cf3a458bdf feat: add server side redist patching 2025-08-28 11:14:38 +10:00
ca7a89bbcf feat: beginnings of platform & redist management 2025-08-27 19:52:36 +10:00
d323816b9e fix: sanitize svg uploads
... copilot suggested this

I feel dirty.
2025-08-27 12:21:17 +10:00
367d349a68 feat: add user platform filters to store view 2025-08-27 12:16:27 +10:00
8efddc07bc feat: partial user platform support + statusMessage -> message 2025-08-27 11:25:23 +10:00
3af00e085e fix: giantbomb logging bug 2025-08-25 16:22:53 +10:00
b7d685814b Merge branch 'develop' into redistributable 2025-08-25 16:19:48 +10:00
f1957a418c feat: import redists 2025-08-22 13:48:47 +10:00
322af0b4ca feat: rearchitecture of database schemas, migration reset, and #180 2025-08-20 20:35:50 +10:00
6853383e86 feat: database redist support 2025-08-20 11:50:59 +10:00
446 changed files with 7025 additions and 7498 deletions

View File

@ -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,

View File

@ -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(() => {

View File

@ -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<{

View File

@ -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"),
},

View 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>

View File

@ -77,7 +77,7 @@ const {
}>
| undefined
| null;
href?: string;
href?: string | undefined;
showTitleDescription?: boolean;
animate?: boolean;
defaultPlaceholder?: boolean;

View File

@ -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 };

View 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>

View 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>

View 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>

View File

@ -92,7 +92,7 @@ import type { Locale } from "vue-i18n";
const { showText = true } = defineProps<{ showText?: boolean }>();
const { locales, locale: currLocale, setLocale } = useI18n();
const { locale: currLocale, setLocale, locales } = useI18n();
function changeLocale(locale: Locale) {
setLocale(locale);

View File

@ -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 }>();

View File

@ -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;

View File

@ -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(),

View File

@ -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 });

View File

@ -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];

View File

@ -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(),

View File

@ -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(),

View File

@ -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(),

View File

@ -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;

View File

@ -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;
}

View File

@ -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 }>();

View File

@ -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>

View 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 | undefined;
guesses?: Array<{ platform: PlatformRenderable; filename: string }>;
}>();
const query = ref("");
const filtered = computed(() =>
props.guesses?.filter((e) =>
e.filename.toLowerCase().includes(query.value.toLowerCase()),
),
);
</script>

View 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>

View File

@ -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 ?? []),
];

View File

@ -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>

View File

@ -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>

View File

@ -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();

View File

@ -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 & {

View File

@ -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 = () =>

View File

@ -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");

View 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!
}

View File

@ -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;
}
}

View File

@ -8,4 +8,5 @@ export type StoreFilterOption = {
export type StoreSortOption = {
name: string;
param: string;
platformIcon?: { key: string; fallback?: string };
};

View File

@ -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");

View File

@ -10,10 +10,4 @@ export type QuickActionNav = {
icon: Component;
notifications?: Ref<number>;
action: () => Promise<void>;
};
export enum PlatformClient {
Windows = "Windows",
Linux = "Linux",
macOS = "macOS",
}
};

View File

@ -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

View File

@ -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<

View File

@ -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);

View File

@ -8,6 +8,8 @@ const props = defineProps({
},
});
await updateUser();
const { t } = useI18n();
const route = useRoute();
const user = useUser();

View File

@ -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";

View File

@ -1,10 +1,10 @@
<template>
<div v-if="!noWrapper" class="flex flex-col w-full min-h-screen bg-zinc-900">
<UserHeader class="z-50" hydrate-on-idle />
<LazyUserHeader class="z-50" hydrate-on-idle />
<div class="grow flex">
<NuxtPage />
</div>
<UserFooter class="z-50" hydrate-on-interaction />
<LazyUserFooter class="z-50" hydrate-on-interaction />
</div>
<div v-else class="flex w-full min-h-screen bg-zinc-900">
<NuxtPage />

View File

@ -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({

View File

@ -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,
});
}

View File

@ -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,123 @@
<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"
@ -383,7 +302,8 @@
</SwitchDescription>
</span>
<Switch
v-model="versionSettings.delta"
:model-value="versionSettings.delta || false"
@update:model-value="(v) => (versionSettings.delta = v)"
:class="[
versionSettings.delta ? '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',
@ -398,7 +318,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 +338,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 +387,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 +459,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 +478,31 @@ 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 +513,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 +537,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 +566,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;

View File

@ -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}`,
});

View 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>

View File

@ -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);

View File

@ -0,0 +1 @@
<template></template>

View 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>

View File

@ -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(),

View File

@ -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",

View File

@ -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();

View File

@ -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",

View File

@ -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(),

View File

@ -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