mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-16 01:31:19 +10:00
Rearchitecture for v0.4.0 (#197)
* feat: database redist support * feat: rearchitecture of database schemas, migration reset, and #180 * feat: import redists * fix: giantbomb logging bug * feat: partial user platform support + statusMessage -> message * feat: add user platform filters to store view * fix: sanitize svg uploads ... copilot suggested this I feel dirty. * feat: beginnings of platform & redist management * feat: add server side redist patching * fix: update drop-base commit * feat: import of custom platforms & file extensions * fix: redelete platform * fix: remove platform * feat: uninstall commands, new R UI * checkpoint: before migrating to nuxt v4 * update to nuxt 4 * fix: fixes for Nuxt v4 update * fix: remaining type issues * feat: initial feedback to import other kinds of versions * working commit * fix: lint * feat: redist import
This commit is contained in:
610
app/pages/admin/library/g/[id]/import.vue
Normal file
610
app/pages/admin/library/g/[id]/import.vue
Normal file
@ -0,0 +1,610 @@
|
||||
<template>
|
||||
<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">{{
|
||||
$t("library.admin.import.version.version")
|
||||
}}</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="currentlySelectedVersion != -1" class="block truncate">{{
|
||||
versions[currentlySelectedVersion]
|
||||
}}</span>
|
||||
<span v-else class="block truncate text-zinc-600">{{
|
||||
$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="(version, versionIdx) in versions"
|
||||
:key="version"
|
||||
v-slot="{ active, selected }"
|
||||
as="template"
|
||||
:value="versionIdx"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected ? 'font-semibold' : 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>{{ version }}</span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
: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>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
|
||||
<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"
|
||||
>{{ $t("library.admin.import.version.setupCmd") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 text-xs">
|
||||
{{ $t("library.admin.import.version.setupDesc") }}
|
||||
</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.install"
|
||||
:guesses="versionGuesses"
|
||||
@update="(v) => updateInstallCommand(v)"
|
||||
/>
|
||||
<input
|
||||
id="startup"
|
||||
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"
|
||||
placeholder="--setup"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- setup mode -->
|
||||
<fieldset class="max-w-lg">
|
||||
<legend class="text-sm/6 font-semibold text-white">
|
||||
Select an import mode
|
||||
</legend>
|
||||
<div class="mt-2 grid grid-cols-1 gap-y-6 sm:grid-cols-2 sm:gap-x-4">
|
||||
<label
|
||||
v-for="mode in setupModes"
|
||||
:key="mode.id"
|
||||
:aria-label="mode.title"
|
||||
:aria-description="mode.description"
|
||||
class="cursor-pointer group relative flex rounded-lg border border-white/10 bg-zinc-800/50 p-4 has-checked:bg-blue-500/10 has-checked:outline-2 has-checked:-outline-offset-2 has-checked:outline-blue-500 has-focus-visible:outline-3 has-focus-visible:-outline-offset-1 has-disabled:bg-gray-800 has-disabled:opacity-25"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
:value="mode.id"
|
||||
:checked="versionSettings.onlySetup === mode.value"
|
||||
class="absolute inset-0 appearance-none opacity-0 focus:outline-none"
|
||||
@click="versionSettings.onlySetup = mode.value"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="block text-sm font-medium text-white">{{
|
||||
mode.title
|
||||
}}</span>
|
||||
<span class="mt-1 block text-xs text-zinc-400">{{
|
||||
mode.description
|
||||
}}</span>
|
||||
</div>
|
||||
<CheckCircleIcon
|
||||
class="invisible size-5 text-blue-500 group-has-checked:visible"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<!-- launch commands -->
|
||||
<div class="relative max-w-3xl">
|
||||
<label
|
||||
for="startup"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("library.admin.import.version.launchCmd") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 text-xs">
|
||||
{{ $t("library.admin.import.version.launchDesc") }}
|
||||
</p>
|
||||
<div class="mt-2 ml-4 flex flex-col gap-y-2 items-start">
|
||||
<div
|
||||
v-for="(launch, launchIdx) in versionSettings.launches"
|
||||
:key="launchIdx"
|
||||
class="inline-flex items-center gap-x-2"
|
||||
>
|
||||
<input
|
||||
id="launch-name"
|
||||
v-model="launch.name"
|
||||
type="text"
|
||||
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"
|
||||
class="max-w-lg"
|
||||
:platforms="allPlatforms"
|
||||
>
|
||||
{{ $t("library.admin.import.version.platform") }}
|
||||
</PlatformSelector>
|
||||
<SwitchGroup as="div" class="flex items-center justify-between max-w-lg">
|
||||
<span class="flex flex-grow flex-col">
|
||||
<SwitchLabel
|
||||
as="span"
|
||||
class="text-sm font-medium leading-6 text-zinc-100"
|
||||
passive
|
||||
>
|
||||
{{ $t("library.admin.import.version.updateMode") }}
|
||||
</SwitchLabel>
|
||||
<SwitchDescription as="span" class="text-sm text-zinc-400">
|
||||
{{ $t("library.admin.import.version.updateModeDesc") }}
|
||||
</SwitchDescription>
|
||||
</span>
|
||||
<Switch
|
||||
: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',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
versionSettings.delta ? '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>
|
||||
<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"
|
||||
>
|
||||
<span class="text-base/7 font-semibold">
|
||||
{{ $t("library.admin.import.version.advancedOptions") }}
|
||||
</span>
|
||||
<span class="ml-6 flex h-7 items-center">
|
||||
<ChevronUpIcon v-if="!open" class="size-6" aria-hidden="true" />
|
||||
<ChevronDownIcon v-else class="size-6" aria-hidden="true" />
|
||||
</span>
|
||||
</DisclosureButton>
|
||||
</dt>
|
||||
<DisclosurePanel
|
||||
as="dd"
|
||||
class="bg-zinc-950/30 p-3 rounded-b-lg mt-2 flex flex-col gap-y-4"
|
||||
>
|
||||
<!-- UMU launcher configuration -->
|
||||
<div
|
||||
v-if="versionSettings.platform == 'Linux'"
|
||||
class="flex flex-col gap-y-4"
|
||||
>
|
||||
<SwitchGroup as="div" class="flex items-center justify-between">
|
||||
<span class="flex flex-grow flex-col">
|
||||
<SwitchLabel
|
||||
as="span"
|
||||
class="text-sm font-medium leading-6 text-zinc-100"
|
||||
passive
|
||||
>
|
||||
{{ $t("library.admin.import.version.umuOverride") }}
|
||||
</SwitchLabel>
|
||||
<SwitchDescription as="span" class="text-sm text-zinc-400">
|
||||
{{ $t("library.admin.import.version.umuOverrideDesc") }}
|
||||
</SwitchDescription>
|
||||
</span>
|
||||
<Switch
|
||||
v-model="umuIdEnabled"
|
||||
:class="[
|
||||
umuIdEnabled ? '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="[
|
||||
umuIdEnabled ? '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>
|
||||
<label
|
||||
for="umu-id"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>
|
||||
{{ $t("library.admin.import.version.umuLauncherId") }}
|
||||
</label>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="umu-id"
|
||||
v-model="umuId"
|
||||
name="umu-id"
|
||||
type="text"
|
||||
autocomplete="umu-id"
|
||||
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-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>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-zinc-400">
|
||||
{{ $t("library.admin.import.version.noAdv") }}
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
|
||||
<LoadingButton
|
||||
class="w-fit"
|
||||
:loading="importLoading"
|
||||
@click="startImport_wrapper"
|
||||
>
|
||||
{{ $t("library.admin.import.import") }}
|
||||
</LoadingButton>
|
||||
<div v-if="importError" 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">
|
||||
{{ importError }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="currentlySelectedVersion != -1"
|
||||
role="status"
|
||||
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
|
||||
>
|
||||
{{ $t("library.admin.import.version.loadingVersion") }}
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
Switch,
|
||||
SwitchDescription,
|
||||
SwitchGroup,
|
||||
SwitchLabel,
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
} from "@headlessui/vue";
|
||||
import { XCircleIcon } from "@heroicons/vue/16/solid";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { ImportGameVersion } from "~~/server/api/v1/admin/import/version/index.post";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const gameId = route.params.id.toString();
|
||||
const versions = await $dropFetch(
|
||||
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}&mode=game`,
|
||||
);
|
||||
const userPlatforms = await useAdminPlatforms();
|
||||
const allPlatforms = renderPlatforms(userPlatforms);
|
||||
const currentlySelectedVersion = ref(-1);
|
||||
|
||||
const versionSettings = ref<Partial<ImportGameVersion>>({
|
||||
launches: [],
|
||||
onlySetup: false,
|
||||
});
|
||||
|
||||
const versionGuesses =
|
||||
ref<
|
||||
Array<SerializeObject<{ platform: PlatformRenderable; filename: string }>>
|
||||
>();
|
||||
|
||||
function updateLaunchCommand(idx: number, value: string) {
|
||||
versionSettings.value.launches![idx].launchCommand = value;
|
||||
autosetPlatform(value);
|
||||
}
|
||||
|
||||
function updateInstallCommand(value: string) {
|
||||
versionSettings.value.install = value;
|
||||
autosetPlatform(value);
|
||||
}
|
||||
|
||||
function updateUninstallCommand(value: string) {
|
||||
versionSettings.value.uninstall = value;
|
||||
autosetPlatform(value);
|
||||
}
|
||||
|
||||
function autosetPlatform(value: string) {
|
||||
if (!versionGuesses.value) return;
|
||||
if (versionSettings.value.platform) return;
|
||||
const guessIndex = versionGuesses.value.findIndex(
|
||||
(e) => e.filename === value,
|
||||
);
|
||||
if (guessIndex == -1) return;
|
||||
versionSettings.value.platform =
|
||||
versionGuesses.value[guessIndex].platform.param;
|
||||
}
|
||||
|
||||
const umuIdEnabled = ref(false);
|
||||
const umuId = computed({
|
||||
get() {
|
||||
if (umuIdEnabled.value) return versionSettings.value.umuId;
|
||||
return undefined;
|
||||
},
|
||||
set(v) {
|
||||
if (umuIdEnabled.value && v) {
|
||||
versionSettings.value.umuId = v;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const importLoading = ref(false);
|
||||
const importError = ref<string | undefined>();
|
||||
|
||||
async function updateCurrentlySelectedVersion(value: number) {
|
||||
if (currentlySelectedVersion.value == value) return;
|
||||
currentlySelectedVersion.value = value;
|
||||
const version = versions[currentlySelectedVersion.value];
|
||||
const options = await $dropFetch(
|
||||
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
|
||||
gameId,
|
||||
)}&version=${encodeURIComponent(version)}&mode=game`,
|
||||
);
|
||||
versionGuesses.value = options.map((e) => ({
|
||||
...e,
|
||||
platform: allPlatforms.find((v) => v.param === e.platform)!,
|
||||
}));
|
||||
versionSettings.value.name = version;
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
if (!versionSettings.value) return;
|
||||
const taskId = await $dropFetch("/api/v1/admin/import/version", {
|
||||
method: "POST",
|
||||
body: {
|
||||
id: gameId,
|
||||
version: versions[currentlySelectedVersion.value],
|
||||
mode: "game",
|
||||
...versionSettings.value,
|
||||
},
|
||||
});
|
||||
router.push(`/admin/task/${taskId.taskId}`);
|
||||
}
|
||||
|
||||
function startImport_wrapper() {
|
||||
importLoading.value = true;
|
||||
startImport()
|
||||
.catch((error) => {
|
||||
importError.value = error.message ?? t("errors.unknown");
|
||||
})
|
||||
.finally(() => {
|
||||
importLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
const setupModes: Array<{
|
||||
id: string;
|
||||
value: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
id: "portable",
|
||||
value: false,
|
||||
title: "Portable",
|
||||
description:
|
||||
"This mode is for games that are designed to be launched directly from the install directory. Drop works best with these.",
|
||||
},
|
||||
{
|
||||
id: "setup",
|
||||
value: true,
|
||||
title: "Installer",
|
||||
description:
|
||||
"Also known as 'setup-only', this mode is for installers that modify the system directly, and install to directories like Program Files.",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
101
app/pages/admin/library/g/[id]/index.vue
Normal file
101
app/pages/admin/library/g/[id]/index.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<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 GameEditorMode)"
|
||||
>
|
||||
<component :is="icon" class="size-4" />
|
||||
{{ value }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- open in store button -->
|
||||
<NuxtLink
|
||||
:href="`/store/${game.id}`"
|
||||
type="button"
|
||||
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display 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"
|
||||
>
|
||||
{{ $t("library.admin.openStore") }}
|
||||
<ArrowTopRightOnSquareIcon
|
||||
class="-mr-0.5 h-7 w-7 p-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<component
|
||||
:is="components[currentMode].editor"
|
||||
v-model="game"
|
||||
:unimported-versions="unimportedVersions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GameEditorMetadata, GameEditorVersion } from "#components";
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
DocumentIcon,
|
||||
ServerStackIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import type { Component } from "vue";
|
||||
|
||||
const route = useRoute();
|
||||
const gameId = route.params.id.toString();
|
||||
const { game: rawGame, unimportedVersions } = await $dropFetch(
|
||||
`/api/v1/admin/game/:id`,
|
||||
{ params: { id: gameId } },
|
||||
);
|
||||
const game = ref(rawGame);
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
enum GameEditorMode {
|
||||
Metadata = "Metadata",
|
||||
Versions = "Versions",
|
||||
}
|
||||
|
||||
const components: {
|
||||
[key in GameEditorMode]: { editor: Component; icon: Component };
|
||||
} = {
|
||||
[GameEditorMode.Metadata]: { editor: GameEditorMetadata, icon: DocumentIcon },
|
||||
[GameEditorMode.Versions]: {
|
||||
editor: GameEditorVersion,
|
||||
icon: ServerStackIcon,
|
||||
},
|
||||
};
|
||||
|
||||
const currentMode = ref<GameEditorMode>(GameEditorMode.Metadata);
|
||||
|
||||
useHead({
|
||||
title: `${currentMode.value} - ${game.value.mName}`,
|
||||
});
|
||||
|
||||
watch(currentMode, (v) => {
|
||||
useHead({
|
||||
title: `${v} - ${game.value.mName}`,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
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>
|
||||
499
app/pages/admin/library/index.vue
Normal file
499
app/pages/admin/library/index.vue
Normal file
@ -0,0 +1,499 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-base font-semibold text-zinc-100">
|
||||
{{ $t("library.admin.gameLibrary") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-zinc-400">
|
||||
{{ $t("library.admin.subheader") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<NuxtLink
|
||||
to="/admin/library/sources"
|
||||
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.sources.link"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="toImport" class="rounded-md bg-blue-600/10 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<InformationCircleIcon
|
||||
class="h-5 w-5 text-blue-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3 flex-1 md:flex md:justify-between">
|
||||
<p class="text-sm text-blue-400">
|
||||
{{ $t("library.admin.detectedGame") }}
|
||||
</p>
|
||||
<p class="mt-3 text-sm md:ml-6 md:mt-0">
|
||||
<NuxtLink
|
||||
href="/admin/library/import"
|
||||
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.import.link"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-1">
|
||||
<input
|
||||
id="search"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
name="search"
|
||||
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-900 py-1.5 pl-10 pr-3 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
|
||||
:placeholder="$t('library.search')"
|
||||
/>
|
||||
<MagnifyingGlassIcon
|
||||
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
|
||||
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="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
|
||||
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(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 }}
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'rounded-full p-1 shadow-xs focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
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(entry.id)"
|
||||
>
|
||||
<svg
|
||||
v-if="gameFeatureLoading[entry.id]"
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
entry.featured ? ' fill-zinc-900' : 'fill-zinc-100',
|
||||
'size-3 text-transparent animate-spin',
|
||||
]"
|
||||
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>
|
||||
|
||||
<StarIcon v-else class="size-3" aria-hidden="true" />
|
||||
</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"
|
||||
>{{ 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>
|
||||
<dt class="sr-only">
|
||||
{{ $t("library.admin.metadataProvider") }}
|
||||
</dt>
|
||||
</dl>
|
||||
<div class="mt-4 flex flex-col gap-y-1">
|
||||
<NuxtLink
|
||||
:href="`/admin/library/${entry.urlPrefix}/${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="() => deleteGame(entry.id)"
|
||||
>
|
||||
{{ $t("delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="entry.type === 'redist'"
|
||||
class="relative flex flex-1 flex-row p-4 gap-x-4"
|
||||
>
|
||||
<div
|
||||
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/${entry.urlPrefix}/${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">
|
||||
<div class="flex-shrink-0">
|
||||
<InformationCircleIcon
|
||||
class="h-5 w-5 text-blue-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3 flex-1 md:flex md:justify-between">
|
||||
<p class="text-sm text-blue-400">
|
||||
{{ $t("library.admin.detectedVersion") }}
|
||||
</p>
|
||||
<p class="mt-3 text-sm md:ml-6 md:mt-0">
|
||||
<NuxtLink
|
||||
:href="`/admin/library/${entry.urlPrefix}/${entry.id}/import`"
|
||||
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.import.link"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="entry.notifications.noVersions"
|
||||
class="rounded-md bg-yellow-600/10 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<ExclamationTriangleIcon
|
||||
class="h-5 w-5 text-yellow-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-600">
|
||||
{{ $t("library.admin.version.noVersions") }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="entry.notifications.offline"
|
||||
class="rounded-md bg-red-600/10 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<ExclamationCircleIcon
|
||||
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">
|
||||
{{ $t("library.admin.offline") }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<p
|
||||
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="
|
||||
filteredLibrary.length == 0 &&
|
||||
libraryGames.length == 0 &&
|
||||
libraryState.hasLibraries
|
||||
"
|
||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
|
||||
>
|
||||
{{ $t("library.admin.noGames") }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="!libraryState.hasLibraries"
|
||||
class="flex flex-col gap-2 text-zinc-600 text-center col-span-4"
|
||||
>
|
||||
<span class="text-sm font-display font-bold uppercase">{{
|
||||
$t("library.admin.libraryHint")
|
||||
}}</span>
|
||||
|
||||
<NuxtLink
|
||||
class="transition text-xs text-zinc-600 hover:underline hover:text-zinc-400"
|
||||
href="https://docs.droposs.org/docs/library"
|
||||
target="_blank"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.libraryHintDocsLink"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
>
|
||||
<template #arrow>
|
||||
<ArrowTopRightOnSquareIcon class="size-4" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from "@heroicons/vue/16/solid";
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
InformationCircleIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: t("library.admin.title"),
|
||||
});
|
||||
|
||||
const searchQuery = ref("");
|
||||
|
||||
const libraryState = await $dropFetch("/api/v1/admin/library");
|
||||
|
||||
const toImport = ref(
|
||||
Object.values(libraryState.unimportedGames).flat().length > 0,
|
||||
);
|
||||
|
||||
// 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;
|
||||
};
|
||||
urlPrefix: string,
|
||||
}
|
||||
> {
|
||||
return values.map((e) => {
|
||||
if (e.status == "offline") {
|
||||
return {
|
||||
...e[expand],
|
||||
type: type,
|
||||
status: "offline" as const,
|
||||
hasNotifications: true,
|
||||
notifications: {
|
||||
offline: true,
|
||||
},
|
||||
urlPrefix: type[0],
|
||||
};
|
||||
}
|
||||
|
||||
const noVersions = e.status.noVersions;
|
||||
const toImport = e.status.unimportedVersions.length > 0;
|
||||
|
||||
return {
|
||||
...e[expand],
|
||||
type: type,
|
||||
notifications: {
|
||||
noVersions,
|
||||
toImport,
|
||||
},
|
||||
hasNotifications: noVersions || toImport,
|
||||
status: "online" as const,
|
||||
urlPrefix: type[0],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const libraryGames = ref(
|
||||
clientSideTransformation(libraryState.games, "value", "game"),
|
||||
);
|
||||
const libraryRedists = ref(
|
||||
clientSideTransformation(libraryState.redists, "value", "redist"),
|
||||
);
|
||||
|
||||
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;
|
||||
if (e.mShortDescription.toLowerCase().includes(searchQueryLower))
|
||||
return true;
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
async function deleteGame(id: string) {
|
||||
await $dropFetch(`/api/v1/admin/game/${id}`, {
|
||||
method: "DELETE",
|
||||
failTitle: "Failed to delete game",
|
||||
});
|
||||
const index = libraryGames.value.findIndex((e) => e.id === id);
|
||||
libraryGames.value.splice(index, 1);
|
||||
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);
|
||||
const game = libraryGames.value[gameIndex];
|
||||
gameFeatureLoading.value[game.id] = true;
|
||||
|
||||
await $dropFetch(`/api/v1/admin/game/:id`, {
|
||||
method: "PATCH",
|
||||
params: {
|
||||
id: game.id,
|
||||
},
|
||||
body: { featured: !game.featured },
|
||||
failTitle: "Failed to feature/unfeature game",
|
||||
});
|
||||
|
||||
libraryGames.value[gameIndex].featured = !game.featured;
|
||||
gameFeatureLoading.value[game.id] = false;
|
||||
}
|
||||
</script>
|
||||
478
app/pages/admin/library/r/[id]/import.vue
Normal file
478
app/pages/admin/library/r/[id]/import.vue
Normal file
@ -0,0 +1,478 @@
|
||||
<template>
|
||||
<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">{{
|
||||
$t("library.admin.import.version.version")
|
||||
}}</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="currentlySelectedVersion != -1" class="block truncate">{{
|
||||
versions[currentlySelectedVersion]
|
||||
}}</span>
|
||||
<span v-else class="block truncate text-zinc-600">{{
|
||||
$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="(version, versionIdx) in versions"
|
||||
:key="version"
|
||||
v-slot="{ active, selected }"
|
||||
as="template"
|
||||
:value="versionIdx"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected ? 'font-semibold' : 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>{{ version }}</span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
: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>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
|
||||
<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"
|
||||
>{{ $t("library.admin.import.version.setupCmd") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 text-xs">
|
||||
{{ $t("library.admin.import.version.setupDesc") }}
|
||||
</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.install"
|
||||
:guesses="versionGuesses"
|
||||
@update="(v) => updateInstallCommand(v)"
|
||||
/>
|
||||
<input
|
||||
id="startup"
|
||||
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"
|
||||
placeholder="--setup"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- launch commands -->
|
||||
<div class="relative max-w-3xl">
|
||||
<label
|
||||
for="startup"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("library.admin.import.version.launchCmd") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 text-xs">
|
||||
{{ $t("library.admin.import.version.launchDesc") }}
|
||||
</p>
|
||||
<div class="mt-2 ml-4 flex flex-col gap-y-2 items-start">
|
||||
<div
|
||||
v-for="(launch, launchIdx) in versionSettings.launches"
|
||||
:key="launchIdx"
|
||||
class="inline-flex items-center gap-x-2"
|
||||
>
|
||||
<input
|
||||
id="launch-name"
|
||||
v-model="launch.name"
|
||||
type="text"
|
||||
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>
|
||||
<!-- 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"
|
||||
class="max-w-lg"
|
||||
:platforms="allPlatforms"
|
||||
>
|
||||
{{ $t("library.admin.import.version.platform") }}
|
||||
</PlatformSelector>
|
||||
<SwitchGroup as="div" class="flex items-center justify-between max-w-lg">
|
||||
<span class="flex flex-grow flex-col">
|
||||
<SwitchLabel
|
||||
as="span"
|
||||
class="text-sm font-medium leading-6 text-zinc-100"
|
||||
passive
|
||||
>
|
||||
{{ $t("library.admin.import.version.updateMode") }}
|
||||
</SwitchLabel>
|
||||
<SwitchDescription as="span" class="text-sm text-zinc-400">
|
||||
{{ $t("library.admin.import.version.updateModeDesc") }}
|
||||
</SwitchDescription>
|
||||
</span>
|
||||
<Switch
|
||||
:model-value="versionSettings.delta || false"
|
||||
: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',
|
||||
]"
|
||||
@update:model-value="(v) => (versionSettings.delta = v)"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
versionSettings.delta ? '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>
|
||||
<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"
|
||||
>
|
||||
<span class="text-base/7 font-semibold">
|
||||
{{ $t("library.admin.import.version.advancedOptions") }}
|
||||
</span>
|
||||
<span class="ml-6 flex h-7 items-center">
|
||||
<ChevronUpIcon v-if="!open" class="size-6" aria-hidden="true" />
|
||||
<ChevronDownIcon v-else class="size-6" aria-hidden="true" />
|
||||
</span>
|
||||
</DisclosureButton>
|
||||
</dt>
|
||||
<DisclosurePanel
|
||||
as="dd"
|
||||
class="bg-zinc-950/30 p-3 rounded-b-lg mt-2 flex flex-col gap-y-4"
|
||||
>
|
||||
<!-- UMU launcher configuration -->
|
||||
|
||||
<div class="text-zinc-400">
|
||||
{{ $t("library.admin.import.version.noAdv") }}
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
|
||||
<LoadingButton
|
||||
class="w-fit"
|
||||
:loading="importLoading"
|
||||
@click="startImport_wrapper"
|
||||
>
|
||||
{{ $t("library.admin.import.import") }}
|
||||
</LoadingButton>
|
||||
<div v-if="importError" 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">
|
||||
{{ importError }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="currentlySelectedVersion != -1"
|
||||
role="status"
|
||||
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
|
||||
>
|
||||
{{ $t("library.admin.import.version.loadingVersion") }}
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
Switch,
|
||||
SwitchDescription,
|
||||
SwitchGroup,
|
||||
SwitchLabel,
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
} 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 { ImportRedistVersion } from "~~/server/api/v1/admin/import/version/index.post";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const redistId = route.params.id.toString();
|
||||
const versions = await $dropFetch(
|
||||
`/api/v1/admin/import/version?id=${encodeURIComponent(redistId)}&mode=redist`,
|
||||
);
|
||||
const userPlatforms = await useAdminPlatforms();
|
||||
const allPlatforms = renderPlatforms(userPlatforms);
|
||||
const currentlySelectedVersion = ref(-1);
|
||||
|
||||
const versionSettings = ref<Partial<ImportRedistVersion>>({
|
||||
launches: [],
|
||||
});
|
||||
|
||||
const versionGuesses =
|
||||
ref<
|
||||
Array<SerializeObject<{ platform: PlatformRenderable; filename: string }>>
|
||||
>();
|
||||
|
||||
function updateLaunchCommand(idx: number, value: string) {
|
||||
versionSettings.value.launches![idx].launchCommand = value;
|
||||
autosetPlatform(value);
|
||||
}
|
||||
|
||||
function updateInstallCommand(value: string) {
|
||||
versionSettings.value.install = value;
|
||||
autosetPlatform(value);
|
||||
}
|
||||
|
||||
function updateUninstallCommand(value: string) {
|
||||
versionSettings.value.uninstall = value;
|
||||
autosetPlatform(value);
|
||||
}
|
||||
|
||||
function autosetPlatform(value: string) {
|
||||
if (!versionGuesses.value) return;
|
||||
if (versionSettings.value.platform) return;
|
||||
const guessIndex = versionGuesses.value.findIndex(
|
||||
(e) => e.filename === value,
|
||||
);
|
||||
if (guessIndex == -1) return;
|
||||
versionSettings.value.platform =
|
||||
versionGuesses.value[guessIndex].platform.param;
|
||||
}
|
||||
|
||||
const importLoading = ref(false);
|
||||
const importError = ref<string | undefined>();
|
||||
|
||||
async function updateCurrentlySelectedVersion(value: number) {
|
||||
if (currentlySelectedVersion.value == value) return;
|
||||
currentlySelectedVersion.value = value;
|
||||
const version = versions[currentlySelectedVersion.value];
|
||||
const options = await $dropFetch(
|
||||
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
|
||||
redistId,
|
||||
)}&version=${encodeURIComponent(version)}&mode=redist`,
|
||||
);
|
||||
versionGuesses.value = options.map((e) => ({
|
||||
...e,
|
||||
platform: allPlatforms.find((v) => v.param === e.platform)!,
|
||||
}));
|
||||
versionSettings.value.name = version;
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
if (!versionSettings.value) return;
|
||||
const taskId = await $dropFetch("/api/v1/admin/import/version", {
|
||||
method: "POST",
|
||||
body: {
|
||||
id: redistId,
|
||||
version: versions[currentlySelectedVersion.value],
|
||||
mode: "redist",
|
||||
...versionSettings.value,
|
||||
},
|
||||
});
|
||||
router.push(`/admin/task/${taskId.taskId}`);
|
||||
}
|
||||
|
||||
function startImport_wrapper() {
|
||||
importLoading.value = true;
|
||||
startImport()
|
||||
.catch((error) => {
|
||||
importError.value = error.message ?? t("errors.unknown");
|
||||
})
|
||||
.finally(() => {
|
||||
importLoading.value = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
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>
|
||||
378
app/pages/admin/library/sources/index.vue
Normal file
378
app/pages/admin/library/sources/index.vue
Normal file
@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-base font-semibold text-zinc-100">
|
||||
{{ $t("library.admin.sources.sources") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-zinc-400">
|
||||
{{ $t("library.admin.sources.desc") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<button
|
||||
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => (actionSourceOpen = true)"
|
||||
>
|
||||
{{ $t("common.create") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flow-root">
|
||||
<SourceTable
|
||||
:sources="sources"
|
||||
:edit-source="edit"
|
||||
:delete-source="deleteSource"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModalTemplate v-model="actionSourceOpen">
|
||||
<template #default>
|
||||
<div>
|
||||
<DialogTitle
|
||||
v-if="createMode"
|
||||
as="h3"
|
||||
class="text-lg font-medium leading-6 text-white"
|
||||
>
|
||||
{{ $t("library.admin.sources.create") }}
|
||||
</DialogTitle>
|
||||
<DialogTitle
|
||||
v-else
|
||||
as="h3"
|
||||
class="text-lg font-medium leading-6 text-white"
|
||||
>
|
||||
{{ $t("library.admin.sources.edit") }}
|
||||
</DialogTitle>
|
||||
<p class="mt-1 text-zinc-400 text-sm">
|
||||
{{ $t("library.admin.sources.createDesc") }}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
class="mt-2 space-y-4"
|
||||
@submit.prevent="() => performActionSource_wrapper()"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("common.name") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
||||
{{ $t("library.admin.sources.nameDesc") }}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="name"
|
||||
v-model="sourceName"
|
||||
name="name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
:placeholder="$t('library.admin.sources.namePlaceholder')"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="createMode">
|
||||
<label class="block text-sm font-medium leading-6 text-zinc-100">{{
|
||||
$t("type")
|
||||
}}</label>
|
||||
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
||||
{{ $t("library.admin.sources.typeDesc") }}
|
||||
</p>
|
||||
|
||||
<RadioGroup v-model="currentSourceOption" class="mt-2">
|
||||
<RadioGroupLabel class="sr-only">{{
|
||||
$t("type")
|
||||
}}</RadioGroupLabel>
|
||||
<div class="space-y-4">
|
||||
<RadioGroupOption
|
||||
v-for="[source, metadata] in optionsMetadataIter"
|
||||
:key="source"
|
||||
v-slot="{ checked }"
|
||||
as="template"
|
||||
:value="source"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'relative block cursor-pointer bg-zinc-800 rounded-lg border border-zinc-900 px-2 py-2 shadow-sm focus:outline-none sm:flex sm:justify-between',
|
||||
]"
|
||||
>
|
||||
<span class="flex items-center gap-x-4">
|
||||
<div>
|
||||
<component
|
||||
:is="metadata.icon"
|
||||
class="size-12 bg-zinc-900 rounded-xl p-3 text-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
<span class="flex flex-col text-sm">
|
||||
<RadioGroupLabel
|
||||
as="span"
|
||||
class="font-semibold text-zinc-100"
|
||||
>{{ metadata.title }}
|
||||
<span class="ml-2 font-mono text-zinc-500 text-xs">{{
|
||||
source
|
||||
}}</span>
|
||||
</RadioGroupLabel>
|
||||
<RadioGroupDescription
|
||||
as="span"
|
||||
class="text-zinc-400 text-xs"
|
||||
>
|
||||
{{ metadata.description }}
|
||||
</RadioGroupDescription>
|
||||
<NuxtLink
|
||||
:href="metadata.docsLink"
|
||||
:external="true"
|
||||
target="_blank"
|
||||
class="mt-2 block w-fit rounded-md bg-blue-600 px-2 py-1 text-center text-xs font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.sources.documentationLink"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
>
|
||||
<template #arrow>
|
||||
<ArrowTopRightOnSquareIcon class="size-4" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
:class="[
|
||||
checked ? 'ring-2 ring-blue-600' : '',
|
||||
'pointer-events-none absolute -inset-px rounded-lg',
|
||||
]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</RadioGroupOption>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div class="h-[1px] w-full bg-zinc-700 rounded-full" />
|
||||
<component
|
||||
:is="optionUIs[currentSourceOption]"
|
||||
v-model="sourceConfig"
|
||||
/>
|
||||
|
||||
<input type="submit" class="hidden" />
|
||||
</form>
|
||||
|
||||
<div v-if="modalError" class="mt-3 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">
|
||||
{{ modalError }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #buttons="{ close }">
|
||||
<LoadingButton
|
||||
:loading="modalLoading"
|
||||
:disabled="modalLoading"
|
||||
class="w-full sm:w-fit"
|
||||
@click="() => performActionSource_wrapper()"
|
||||
>
|
||||
{{ createMode ? $t("common.create") : $t("common.save") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
||||
@click="
|
||||
() => {
|
||||
editIndex = undefined;
|
||||
close();
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* I did something a little cursed for this
|
||||
* To avoid making a separate modal for saving, we
|
||||
* instead set the index of the source we want to edit
|
||||
* and there's a bunch of checks everywhere to switch
|
||||
* between 'create' and 'edit'
|
||||
*/
|
||||
|
||||
import {
|
||||
DropLogo,
|
||||
SourceOptionsFilesystem,
|
||||
SourceOptionsFlatFilesystem,
|
||||
} from "#components";
|
||||
import {
|
||||
DialogTitle,
|
||||
RadioGroup,
|
||||
RadioGroupDescription,
|
||||
RadioGroupLabel,
|
||||
RadioGroupOption,
|
||||
} from "@headlessui/vue";
|
||||
import {
|
||||
XCircleIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import { BackwardIcon } 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";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "Library Sources",
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Optional token for setup wizard
|
||||
const { token = undefined } = defineProps<{ token?: string }>();
|
||||
|
||||
const headers = token ? { Authorization: token } : undefined;
|
||||
|
||||
const sources = ref(
|
||||
await $dropFetch<WorkingLibrarySource[]>("/api/v1/admin/library/sources", {
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
|
||||
const editIndex = ref<undefined | number>(undefined);
|
||||
const createMode = computed(() => editIndex.value === undefined);
|
||||
|
||||
const actionSourceOpen = ref(false);
|
||||
const currentSourceOption = ref<LibraryBackend>("Filesystem");
|
||||
const sourceName = ref("");
|
||||
const sourceConfig = ref<object>({});
|
||||
|
||||
const modalError = ref<undefined | string>();
|
||||
const modalLoading = ref(false);
|
||||
|
||||
const optionUIs: { [key in LibraryBackend]: Component } = {
|
||||
Filesystem: SourceOptionsFilesystem,
|
||||
FlatFilesystem: SourceOptionsFlatFilesystem,
|
||||
};
|
||||
const optionsMetadata: {
|
||||
[key in LibraryBackend]: {
|
||||
title: string;
|
||||
description: string;
|
||||
docsLink: string;
|
||||
icon: Component;
|
||||
};
|
||||
} = {
|
||||
Filesystem: {
|
||||
title: t("library.admin.sources.fsTitle"),
|
||||
description: t("library.admin.sources.fsDesc"),
|
||||
docsLink: "https://docs.droposs.org/docs/library#drop-style",
|
||||
icon: DropLogo,
|
||||
},
|
||||
FlatFilesystem: {
|
||||
title: t("library.admin.sources.fsFlatTitle"),
|
||||
description: t("library.admin.sources.fsFlatDesc"),
|
||||
docsLink: "https://docs.droposs.org/docs/library#flat-style-or-compat",
|
||||
icon: BackwardIcon,
|
||||
},
|
||||
};
|
||||
const optionsMetadataIter = Object.entries(optionsMetadata);
|
||||
|
||||
async function performActionSource() {
|
||||
const createMode = editIndex.value === undefined;
|
||||
|
||||
const source = await $dropFetch<WorkingLibrarySource>(
|
||||
"/api/v1/admin/library/sources",
|
||||
{
|
||||
body: {
|
||||
id: createMode ? undefined : sources.value[editIndex.value!].id,
|
||||
name: sourceName.value,
|
||||
backend: createMode ? currentSourceOption.value : undefined,
|
||||
options: sourceConfig.value,
|
||||
},
|
||||
method: createMode ? "POST" : "PATCH",
|
||||
headers,
|
||||
},
|
||||
);
|
||||
if (createMode) {
|
||||
sources.value.push(source);
|
||||
} else {
|
||||
sources.value[editIndex.value!] = source;
|
||||
}
|
||||
}
|
||||
|
||||
function performActionSource_wrapper() {
|
||||
modalError.value = undefined;
|
||||
modalLoading.value = true;
|
||||
performActionSource()
|
||||
.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;
|
||||
});
|
||||
}
|
||||
|
||||
function edit(index: number) {
|
||||
const source = sources.value[index];
|
||||
if (!source) return;
|
||||
|
||||
sourceName.value = source.name;
|
||||
sourceConfig.value = source.options! as object;
|
||||
|
||||
editIndex.value = index;
|
||||
actionSourceOpen.value = true;
|
||||
}
|
||||
|
||||
async function deleteSource(index: number) {
|
||||
const source = sources.value[index];
|
||||
if (!source) return;
|
||||
|
||||
try {
|
||||
await $dropFetch("/api/v1/admin/library/sources", {
|
||||
method: "DELETE",
|
||||
body: { id: source.id },
|
||||
headers,
|
||||
});
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: t("errors.library.source.delete.title"),
|
||||
description: t("errors.library.source.delete.desc", [
|
||||
// @ts-expect-error attempt to display message on error
|
||||
e?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
}
|
||||
|
||||
sources.value.splice(index, 1);
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user