mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-09 20:12:10 +10:00
version importing
This commit is contained in:
10
components/LinuxLogo.vue
Normal file
10
components/LinuxLogo.vue
Normal file
@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.53918 2.40715C4.82145 1.0075 6.06066 0 7.49996 0C8.93926 0 10.1785 1.0075 10.4607 2.40715L10.798 4.07944C10.9743 4.9539 11.3217 5.78562 11.8205 6.52763L12.4009 7.39103C12.7631 7.92978 12.9999 8.5385 13.0979 9.17323C13.6747 9.22167 14.1803 9.58851 14.398 10.1283L14.8897 11.3474C15.1376 11.962 14.9583 12.665 14.4455 13.0887L12.5614 14.6458C12.0128 15.0992 11.2219 15.1193 10.6506 14.6944L9.89192 14.1301C9.88189 14.1227 9.87197 14.1151 9.86216 14.1074C9.48973 14.2075 9.09793 14.261 8.69355 14.261H6.30637C5.90201 14.261 5.51023 14.2076 5.13782 14.1074C5.12802 14.1151 5.11811 14.1227 5.10808 14.1301L4.34942 14.6944C3.77811 15.1193 2.98725 15.0992 2.43863 14.6458L0.55446 13.0887C0.0417175 12.665 -0.1376 11.962 0.110281 11.3474L0.602025 10.1283C0.819715 9.58854 1.32527 9.2217 1.90198 9.17324C2 8.5385 2.2368 7.92978 2.59897 7.39103L3.17938 6.52763C3.67818 5.78562 4.02557 4.9539 4.20193 4.07944L4.53918 2.40715ZM10.8445 9.47585C10.6345 9.63293 10.4642 9.84382 10.3561 10.0938L9.58799 11.8713C9.20026 12.0979 8.75209 12.2237 8.28465 12.2237H6.7153C6.24789 12.2237 5.79975 12.0979 5.41203 11.8714L4.64386 10.0938C4.53581 9.8438 4.36552 9.6329 4.15546 9.47582C4.18121 9.15355 4.2689 8.83503 4.41853 8.53826L5.67678 6.04259L5.68433 6.05007C6.68715 7.04458 8.31304 7.04458 9.31585 6.05007L9.32324 6.04274L10.5814 8.53825C10.7311 8.83504 10.8187 9.15357 10.8445 9.47585ZM9.04068 4.26906V3.05592H8.01353V3.85713C8.23151 3.90123 8.44506 3.97371 8.64848 4.07458L9.04068 4.26906ZM6.98638 3.85718V3.05592H5.95923V4.26919L6.3517 4.07458C6.55504 3.97375 6.7685 3.90129 6.98638 3.85718ZM2.03255 10.1864C1.82255 10.1864 1.6337 10.3132 1.55571 10.5066L1.06397 11.7257C0.981339 11.9306 1.04111 12.1649 1.21203 12.3062L3.0962 13.8633C3.27907 14.0144 3.54269 14.0211 3.73313 13.8795L4.49179 13.3152C4.6813 13.1743 4.74901 12.923 4.6557 12.7071L3.69976 10.4951C3.61884 10.3078 3.43316 10.1864 3.22771 10.1864H2.03255ZM13.4443 10.5066C13.3663 10.3132 13.1775 10.1864 12.9674 10.1864H11.7723C11.5668 10.1864 11.3812 10.3078 11.3002 10.4951L10.3443 12.7071C10.251 12.923 10.3187 13.1743 10.5082 13.3152L11.2669 13.8795C11.4573 14.0211 11.7209 14.0144 11.9038 13.8633L13.788 12.3062C13.9589 12.1649 14.0187 11.9306 13.936 11.7257L13.4443 10.5066ZM6.81106 4.98568C7.24481 4.7706 7.75537 4.7706 8.18912 4.98568L8.68739 5.23275L8.58955 5.32978C7.98786 5.92649 7.01232 5.92649 6.41063 5.32978L6.31279 5.23275L6.81106 4.98568Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
101
components/PlatformSelector.vue
Normal file
101
components/PlatformSelector.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<Listbox as="div" v-model="model">
|
||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
><slot
|
||||
/></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-500 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<span v-if="model && values[model]" class="flex items-center">
|
||||
<component
|
||||
:is="values[model].icon"
|
||||
alt=""
|
||||
class="h-5 w-5 flex-shrink-0 text-blue-600"
|
||||
/>
|
||||
<span class="ml-3 block truncate">{{ values[model].name }}</span>
|
||||
</span>
|
||||
<span v-else>Please select a platform...</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 ml-3 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-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
|
||||
as="template"
|
||||
v-for="[value, options] in Object.entries(values)"
|
||||
:key="value"
|
||||
:value="value"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<component
|
||||
:is="options.icon"
|
||||
alt=""
|
||||
:class="[
|
||||
active ? 'text-zinc-100' : 'text-blue-600',
|
||||
'h-5 w-5 flex-shrink-0',
|
||||
]"
|
||||
/>
|
||||
<span class="ml-3 block truncate">{{ options.name }}</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
import type { Component } from "vue";
|
||||
import LinuxLogo from "./LinuxLogo.vue";
|
||||
import WindowsLogo from "./WindowsLogo.vue";
|
||||
|
||||
const model = defineModel<string>();
|
||||
|
||||
const values: { [key: string]: { name: string; icon: Component } } = {
|
||||
Linux: {
|
||||
name: "Linux",
|
||||
icon: LinuxLogo,
|
||||
},
|
||||
Windows: {
|
||||
name: "Windows",
|
||||
icon: WindowsLogo,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
12
components/WindowsLogo.vue
Normal file
12
components/WindowsLogo.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 1920 1920"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M1863.53 1016.437c31.171 0 56.47 25.299 56.47 56.47v790.589c0 16.376-7.115 31.849-19.313 42.465-10.39 9.149-23.605 14.005-37.158 14.005-2.484 0-5.082-.113-7.567-.452l-903.53-123.331c-28.008-3.84-48.903-27.784-48.903-56.02v-667.256c0-31.171 25.3-56.47 56.471-56.47Zm-1129.412 0c31.171 0 56.47 25.299 56.47 56.47v634.504c0 16.376-7.115 31.85-19.426 42.579-10.39 9.035-23.491 13.891-37.044 13.891-2.485 0-5.196-.113-7.68-.564L48.79 1669.35C20.78 1665.51 0 1641.68 0 1613.444v-540.537c0-31.171 25.299-56.47 56.47-56.47Zm-7.726-859.855c16.151-2.372 32.415 2.597 44.725 13.327 12.424 10.73 19.426 26.315 19.426 42.579V846.99c0 31.285-25.186 56.47-56.47 56.47H56.424c-31.171 0-56.47-25.185-56.47-56.47V306.455c0-28.123 20.781-52.066 48.79-55.906ZM1855.974.474c16.15-2.033 32.414 2.71 44.724 13.44 12.198 10.73 19.313 26.203 19.313 42.466v790.588c0 31.285-25.299 56.471-56.47 56.471H960.01c-31.171 0-56.47-25.186-56.47-56.47V179.711c0-28.235 20.78-52.066 48.903-55.906Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@ -7,6 +7,7 @@ const useTaskStates = () =>
|
||||
useState<{ [key: string]: Ref<TaskMessage> }>("task-states", () => ({
|
||||
connect: useState<TaskMessage>("task-connect", () => ({
|
||||
id: "connect",
|
||||
name: "Connect",
|
||||
success: false,
|
||||
progress: 0,
|
||||
log: [],
|
||||
@ -51,8 +52,10 @@ export const useTask = (taskId: string): Ref<TaskMessage> => {
|
||||
if (taskStates.value[taskId]) return taskStates.value[taskId];
|
||||
|
||||
if (!ws) initWs();
|
||||
|
||||
taskStates.value[taskId] = useState(`task-${taskId}`, () => ({
|
||||
id: taskId,
|
||||
name: "loading...",
|
||||
success: false,
|
||||
progress: 0,
|
||||
error: undefined,
|
||||
|
||||
@ -48,8 +48,8 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main class="lg:pl-20 min-h-screen bg-zinc-900">
|
||||
<div class="px-4 py-10 sm:px-6 lg:px-8 lg:py-6">
|
||||
<main class="lg:pl-20 min-h-screen bg-zinc-900 flex flex-col">
|
||||
<div class="flex flex-col grow px-4 py-10 sm:px-6 lg:px-8 lg:py-6">
|
||||
<!-- Main area -->
|
||||
<NuxtPage />
|
||||
</div>
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@drop/droplet": "^0.4.1",
|
||||
"@drop/droplet": "^0.4.4",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@prisma/client": "5.20.0",
|
||||
@ -42,7 +42,7 @@
|
||||
"tailwindcss": "^3.4.13"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@drop/droplet-linux-x64-gnu": "^0.4.1",
|
||||
"@drop/droplet-win32-x64-msvc": "^0.4.1"
|
||||
"@drop/droplet-linux-x64-gnu": "^0.4.4",
|
||||
"@drop/droplet-win32-x64-msvc": "^0.4.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,74 +1,176 @@
|
||||
<template>
|
||||
<Listbox
|
||||
as="div"
|
||||
class="max-w-md"
|
||||
v-on:update:model-value="(value) => updateCurrentlySelectedVersion(value)"
|
||||
:model-value="currentlySelectedVersion"
|
||||
>
|
||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>Select version to import</ListboxLabel
|
||||
<div class="flex flex-col gap-y-4">
|
||||
<Listbox
|
||||
as="div"
|
||||
class="max-w-md"
|
||||
v-on:update:model-value="(value) => updateCurrentlySelectedVersion(value)"
|
||||
:model-value="currentlySelectedVersion"
|
||||
>
|
||||
<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"
|
||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>Select version to import</ListboxLabel
|
||||
>
|
||||
<span v-if="currentlySelectedVersion != -1" class="block truncate">{{
|
||||
versions[currentlySelectedVersion]
|
||||
}}</span>
|
||||
<span v-else class="block truncate text-zinc-600"
|
||||
>Please select a directory...</span
|
||||
<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
|
||||
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
|
||||
as="template"
|
||||
v-for="(version, versionIdx) in versions"
|
||||
:key="version"
|
||||
:value="versionIdx"
|
||||
v-slot="{ active, selected }"
|
||||
<span v-if="currentlySelectedVersion != -1" class="block truncate">{{
|
||||
versions[currentlySelectedVersion]
|
||||
}}</span>
|
||||
<span v-else class="block truncate text-zinc-600"
|
||||
>Please select a directory...</span
|
||||
>
|
||||
<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
|
||||
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>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
<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
|
||||
as="template"
|
||||
v-for="(version, versionIdx) in versions"
|
||||
:key="version"
|
||||
:value="versionIdx"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
<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 class="flex flex-col gap-4 max-w-md" v-if="versionSettings">
|
||||
<div>
|
||||
<label
|
||||
for="startup"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>Startup executable/command</label
|
||||
>
|
||||
<p class="text-zinc-400 text-xs">Executable to launch the game</p>
|
||||
<div class="mt-2">
|
||||
<div
|
||||
class="flex 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 sm:max-w-md"
|
||||
>
|
||||
<span
|
||||
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
|
||||
>(install_dir)/</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="startup"
|
||||
id="startup"
|
||||
v-model="versionSettings.startup"
|
||||
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="my-game.exe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="startup"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>Setup executable/command</label
|
||||
>
|
||||
<p class="text-zinc-400 text-xs">Ran once when the game is installed</p>
|
||||
<div class="mt-2">
|
||||
<div
|
||||
class="flex 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 sm:max-w-md"
|
||||
>
|
||||
<span
|
||||
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
|
||||
>(install_dir)/</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="startup"
|
||||
id="startup"
|
||||
v-model="versionSettings.setup"
|
||||
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="setup.exe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PlatformSelector v-model="versionSettings.platform">
|
||||
Version platform
|
||||
</PlatformSelector>
|
||||
<LoadingButton @click="startImport_wrapper" class="w-fit" :loading="importLoading">
|
||||
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>
|
||||
</Listbox>
|
||||
<div
|
||||
v-else-if="currentlySelectedVersion != -1"
|
||||
role="status"
|
||||
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
|
||||
>
|
||||
Loading version metadata...
|
||||
<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>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@ -79,12 +181,15 @@ import {
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { XCircleIcon } from "@heroicons/vue/16/solid";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const route = useRoute();
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
const gameId = route.params.id.toString();
|
||||
@ -95,6 +200,12 @@ const versions = await $fetch(
|
||||
}
|
||||
);
|
||||
const currentlySelectedVersion = ref(-1);
|
||||
const versionSettings = ref<
|
||||
{ platform: string; startup: string; setup: string } | undefined
|
||||
>();
|
||||
|
||||
const importLoading = ref(false);
|
||||
const importError = ref<string | undefined>();
|
||||
|
||||
async function updateCurrentlySelectedVersion(value: number) {
|
||||
if (currentlySelectedVersion.value == value) return;
|
||||
@ -105,5 +216,36 @@ async function updateCurrentlySelectedVersion(value: number) {
|
||||
gameId
|
||||
)}&version=${encodeURIComponent(version)}`
|
||||
);
|
||||
versionSettings.value = {
|
||||
platform: results.platformGuess,
|
||||
startup: results.startupGuess,
|
||||
setup: "",
|
||||
};
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
if (!versionSettings.value) return;
|
||||
const taskId = await $fetch("/api/v1/admin/import/version", {
|
||||
method: "POST",
|
||||
body: {
|
||||
id: gameId,
|
||||
version: versions[currentlySelectedVersion.value],
|
||||
platform: versionSettings.value.platform,
|
||||
startup: versionSettings.value.startup,
|
||||
setup: versionSettings.value.setup,
|
||||
},
|
||||
});
|
||||
router.push(`/admin/task/${taskId.taskId}`);
|
||||
}
|
||||
|
||||
function startImport_wrapper() {
|
||||
importLoading.value = true;
|
||||
startImport()
|
||||
.catch((error) => {
|
||||
importError.value = error.statusMessage ?? "An unknown error occurred.";
|
||||
})
|
||||
.finally(() => {
|
||||
importLoading.value = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
11
pages/admin/library/[id]/index.vue
Normal file
11
pages/admin/library/[id]/index.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
})
|
||||
</script>
|
||||
@ -178,8 +178,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ metadataResults }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -200,7 +198,7 @@ definePageMeta({
|
||||
});
|
||||
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
const games = await $fetch("/api/v1/admin/library/game/import", { headers });
|
||||
const games = await $fetch("/api/v1/admin/import/game", { headers });
|
||||
|
||||
const currentlySelectedGame = ref(-1);
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
|
||||
>
|
||||
<li
|
||||
v-for="{ game, status } in libraryState.games"
|
||||
v-for="game in libraryNotifications"
|
||||
:key="game.id"
|
||||
class="col-span-1 flex flex-col justify-center divide-y divide-zinc-700 rounded-lg bg-zinc-950/20 text-left shadow"
|
||||
>
|
||||
@ -58,9 +58,9 @@
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-y-2 p-2">
|
||||
<div v-if="game.hasNotifications" class="flex flex-col gap-y-2 p-2">
|
||||
<div
|
||||
v-if="status.unimportedVersions"
|
||||
v-if="game.notifications.toImport"
|
||||
class="rounded-md bg-blue-600/10 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
@ -86,7 +86,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="status.noVersions" class="rounded-md bg-yellow-600/10 p-4">
|
||||
<div v-if="game.notifications.noVersions" class="rounded-md bg-yellow-600/10 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<ExclamationTriangleIcon
|
||||
@ -119,4 +119,17 @@ useHead({
|
||||
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
const libraryState = await $fetch("/api/v1/admin/library", { headers });
|
||||
const libraryNotifications = libraryState.games.map((e) => {
|
||||
const noVersions = e.status.noVersions;
|
||||
const toImport = e.status.unimportedVersions.length > 0;
|
||||
|
||||
return {
|
||||
...e.game,
|
||||
notifications: {
|
||||
noVersions,
|
||||
toImport,
|
||||
},
|
||||
hasNotifications: noVersions || toImport,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
53
pages/admin/task/[id]/index.vue
Normal file
53
pages/admin/task/[id]/index.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div
|
||||
class="grow w-full flex items-center justify-center"
|
||||
v-if="taskValue && taskValue.success"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<CheckCircleIcon class="h-12 w-12 text-green-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">
|
||||
Successful!
|
||||
</h1>
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-zinc-400 max-w-md">
|
||||
"{{ taskValue.name }}" completed successfully.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="taskValue" class="flex flex-col w-full gap-y-4">
|
||||
<h1 class="text-3xl text-zinc-100 font-bold font-display">
|
||||
{{ taskValue.name }}
|
||||
</h1>
|
||||
<div class="h-3 rounded-full bg-zinc-950 overflow-hidden">
|
||||
<div
|
||||
:style="{ width: `${taskValue.progress}%` }"
|
||||
class="bg-blue-600 h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-950/50 rounded-md p-2 text-zinc-100">
|
||||
<pre v-for="line in taskValue.log">{{ line }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon } from '@heroicons/vue/16/solid';
|
||||
|
||||
const route = useRoute();
|
||||
const taskId = route.params.id.toString();
|
||||
|
||||
const task = useTask(taskId);
|
||||
const taskValue = computed(() => task.value);
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "Task",
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `dropletManifest` to the `GameVersion` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "GameVersion" ADD COLUMN "dropletManifest" JSONB NOT NULL;
|
||||
@ -104,9 +104,10 @@ model GameVersion {
|
||||
game Game @relation(fields: [gameId], references: [id])
|
||||
versionName String // Sub directory for the game files
|
||||
|
||||
platform Platform
|
||||
launchCommand String // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine
|
||||
setupCommand String // Command to setup game (dependencies and such)
|
||||
platform Platform
|
||||
launchCommand String // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine
|
||||
setupCommand String // Command to setup game (dependencies and such)
|
||||
dropletManifest Json // Results from droplet
|
||||
|
||||
@@id([gameId, versionName])
|
||||
}
|
||||
|
||||
37
server/api/v1/admin/import/version/index.post.ts
Normal file
37
server/api/v1/admin/import/version/index.post.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import libraryManager from "~/server/internal/library";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const user = await h3.context.session.getAdminUser(h3);
|
||||
if (!user) throw createError({ statusCode: 403 });
|
||||
|
||||
const body = await readBody(h3);
|
||||
const gameId = body.id;
|
||||
const versionName = body.version;
|
||||
const platform = body.platform;
|
||||
const startup = body.startup;
|
||||
const setup = body.setup ?? "";
|
||||
if (
|
||||
!gameId ||
|
||||
!versionName ||
|
||||
!platform ||
|
||||
!startup
|
||||
)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage:
|
||||
"Missing id, version, platform, setup or startup from body",
|
||||
});
|
||||
|
||||
const taskId = await libraryManager.importVersion(gameId, versionName, {
|
||||
platform,
|
||||
startup,
|
||||
setup,
|
||||
});
|
||||
if (!taskId)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid options for import",
|
||||
});
|
||||
|
||||
return { taskId: taskId };
|
||||
});
|
||||
@ -17,8 +17,10 @@ export default defineWebSocketHandler({
|
||||
peer.send("unauthenticated");
|
||||
return;
|
||||
}
|
||||
const admin = session.getAdminUser(dummyEvent);
|
||||
const peerId = uuidv4();
|
||||
peer.ctx.id = peerId;
|
||||
peer.ctx.admin = admin !== undefined;
|
||||
|
||||
const rtMsg: TaskMessage = {
|
||||
id: "connect",
|
||||
@ -34,7 +36,7 @@ export default defineWebSocketHandler({
|
||||
const text = message.text();
|
||||
if (text.startsWith("connect/")) {
|
||||
const id = text.substring("connect/".length);
|
||||
taskHandler.connect(peer.ctx.id, id, peer);
|
||||
taskHandler.connect(peer.ctx.id, id, peer, peer.ctx.admin);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
@ -11,6 +11,9 @@ import prisma from "../db/database";
|
||||
import { GameVersion, Platform } from "@prisma/client";
|
||||
import { fuzzy } from "fast-fuzzy";
|
||||
import { recursivelyReaddir } from "../utils/recursivedirs";
|
||||
import taskHandler from "../tasks";
|
||||
import { parsePlatform } from "../utils/parseplatform";
|
||||
import droplet from "@drop/droplet";
|
||||
|
||||
class LibraryManager {
|
||||
private basePath: string;
|
||||
@ -143,7 +146,7 @@ class LibraryManager {
|
||||
match: number;
|
||||
}> = [];
|
||||
|
||||
const files = recursivelyReaddir(targetDir);
|
||||
const files = recursivelyReaddir(targetDir, 2);
|
||||
for (const file of files) {
|
||||
const filename = path.basename(file);
|
||||
const dotLocation = file.lastIndexOf(".");
|
||||
@ -188,6 +191,73 @@ class LibraryManager {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async importVersion(
|
||||
gameId: string,
|
||||
versionName: string,
|
||||
metadata: { platform: string; setup: string; startup: string }
|
||||
) {
|
||||
const taskId = `import:${gameId}:${versionName}`;
|
||||
|
||||
const platform = parsePlatform(metadata.platform);
|
||||
if (!platform) return undefined;
|
||||
|
||||
const game = await prisma.game.findUnique({
|
||||
where: { id: gameId },
|
||||
select: { mName: true, libraryBasePath: true },
|
||||
});
|
||||
if (!game) return undefined;
|
||||
|
||||
const baseDir = path.join(this.basePath, game.libraryBasePath, versionName);
|
||||
if (!fs.existsSync(baseDir)) return undefined;
|
||||
|
||||
taskHandler.create({
|
||||
id: taskId,
|
||||
name: `Importing version ${versionName} for ${game.mName}`,
|
||||
requireAdmin: true,
|
||||
async run({ progress, log }) {
|
||||
// First, create the manifest via droplet.
|
||||
// This takes up 90% of our progress, so we wrap it in a *0.9
|
||||
const manifest = await new Promise<string>((resolve, reject) => {
|
||||
droplet.generateManifest(
|
||||
baseDir,
|
||||
(err, value) => {
|
||||
if (err) return reject(err);
|
||||
progress(value * 0.9);
|
||||
},
|
||||
(err, line) => {
|
||||
if (err) return reject(err);
|
||||
log(line);
|
||||
},
|
||||
(err, manifest) => {
|
||||
if (err) return reject(err);
|
||||
resolve(manifest);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
log("Created manifest successfully!");
|
||||
|
||||
// Then, create the database object
|
||||
const version = await prisma.gameVersion.create({
|
||||
data: {
|
||||
gameId: gameId,
|
||||
versionName: versionName,
|
||||
platform: platform,
|
||||
setupCommand: metadata.setup,
|
||||
launchCommand: metadata.startup,
|
||||
dropletManifest: manifest,
|
||||
},
|
||||
});
|
||||
|
||||
log("Successfully created version!");
|
||||
|
||||
progress(100);
|
||||
},
|
||||
});
|
||||
|
||||
return taskId;
|
||||
}
|
||||
}
|
||||
|
||||
export const libraryManager = new LibraryManager();
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import droplet from "@drop/droplet";
|
||||
|
||||
/**
|
||||
* The TaskHandler setups up two-way connections to web clients and manages the state for them
|
||||
* This allows long-running tasks (like game imports and such) to report progress, success and error states
|
||||
@ -5,18 +7,19 @@
|
||||
*/
|
||||
|
||||
type TaskRegistryEntry = {
|
||||
runPromise: Promise<void>;
|
||||
success: boolean;
|
||||
progress: number;
|
||||
log: string[];
|
||||
error: string | undefined;
|
||||
clients: { [key: string]: boolean };
|
||||
name: string;
|
||||
requireAdmin: boolean;
|
||||
};
|
||||
|
||||
class TaskHandler {
|
||||
private taskRegistry: { [key: string]: TaskRegistryEntry } = {};
|
||||
private clientRegistry: { [key: string]: PeerImpl } = {};
|
||||
startTasks: (() => void)[] = [];
|
||||
|
||||
constructor() {}
|
||||
|
||||
@ -30,17 +33,18 @@ class TaskHandler {
|
||||
if (!taskEntry) return;
|
||||
const taskMessage: TaskMessage = {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
success: taskEntry.success,
|
||||
progress: taskEntry.progress,
|
||||
error: taskEntry.error,
|
||||
log: taskEntry.log,
|
||||
log: taskEntry.log.reverse().slice(0, 50),
|
||||
};
|
||||
for (const client of Object.keys(taskEntry.clients)) {
|
||||
if (!this.clientRegistry[client]) continue;
|
||||
this.clientRegistry[client].send(taskMessage);
|
||||
}
|
||||
updateCollectTimeout = undefined;
|
||||
}, 500);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const progress = (progress: number) => {
|
||||
@ -57,40 +61,46 @@ class TaskHandler {
|
||||
updateAllClients();
|
||||
};
|
||||
|
||||
const promiseRun = task.run({ progress, log });
|
||||
promiseRun.then(() => {
|
||||
const taskEntry = this.taskRegistry[task.id];
|
||||
if (!taskEntry) return;
|
||||
this.taskRegistry[task.id].success = true;
|
||||
updateAllClients();
|
||||
});
|
||||
promiseRun.catch((error) => {
|
||||
const taskEntry = this.taskRegistry[task.id];
|
||||
if (!taskEntry) return;
|
||||
this.taskRegistry[task.id].success = false;
|
||||
this.taskRegistry[task.id].error = error;
|
||||
updateAllClients();
|
||||
});
|
||||
this.taskRegistry[task.id] = {
|
||||
name: task.name,
|
||||
runPromise: promiseRun,
|
||||
success: false,
|
||||
progress: 0,
|
||||
error: undefined,
|
||||
log: [],
|
||||
clients: {},
|
||||
requireAdmin: task.requireAdmin ?? false,
|
||||
};
|
||||
|
||||
droplet.callAltThreadFunc(async () => {
|
||||
const promiseRun = task.run({ progress, log });
|
||||
promiseRun.then(() => {
|
||||
const taskEntry = this.taskRegistry[task.id];
|
||||
if (!taskEntry) return;
|
||||
this.taskRegistry[task.id].success = true;
|
||||
updateAllClients();
|
||||
});
|
||||
promiseRun.catch((error) => {
|
||||
const taskEntry = this.taskRegistry[task.id];
|
||||
if (!taskEntry) return;
|
||||
this.taskRegistry[task.id].success = false;
|
||||
this.taskRegistry[task.id].error = error;
|
||||
updateAllClients();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
connect(id: string, taskId: string, peer: PeerImpl) {
|
||||
connect(id: string, taskId: string, peer: PeerImpl, isAdmin = false) {
|
||||
const task = this.taskRegistry[taskId];
|
||||
if (!task) return false;
|
||||
|
||||
if (task.requireAdmin && !isAdmin) return false;
|
||||
|
||||
this.clientRegistry[id] = peer;
|
||||
this.taskRegistry[taskId].clients[id] = true; // Uniquely insert client to avoid sending duplicate traffic
|
||||
|
||||
const catchupMessage: TaskMessage = {
|
||||
id: taskId,
|
||||
name: task.name,
|
||||
success: task.success,
|
||||
error: task.error,
|
||||
log: task.log,
|
||||
@ -127,10 +137,12 @@ export interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
run: (context: TaskRunContext) => Promise<void>;
|
||||
requireAdmin?: boolean;
|
||||
}
|
||||
|
||||
export type TaskMessage = {
|
||||
id: string;
|
||||
name: string;
|
||||
success: boolean;
|
||||
progress: number;
|
||||
error: undefined | string;
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export function recursivelyReaddir(dir: string) {
|
||||
export function recursivelyReaddir(dir: string, depth: number = 100) {
|
||||
if (depth == 0) return [];
|
||||
const result: Array<string> = [];
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const targetDir = path.join(dir, file);
|
||||
const stat = fs.lstatSync(targetDir);
|
||||
if (stat.isDirectory()) {
|
||||
const subdirs = recursivelyReaddir(targetDir);
|
||||
const subdirs = recursivelyReaddir(targetDir, depth - 1);
|
||||
const subdirsWithBase = subdirs.map((e) => path.join(dir, e));
|
||||
result.push(...subdirsWithBase);
|
||||
continue;
|
||||
|
||||
28
yarn.lock
28
yarn.lock
@ -296,23 +296,23 @@
|
||||
dependencies:
|
||||
mime "^3.0.0"
|
||||
|
||||
"@drop/droplet-linux-x64-gnu@0.4.1", "@drop/droplet-linux-x64-gnu@^0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-linux-x64-gnu/-/@drop/droplet-linux-x64-gnu-0.4.1.tgz#24f9ccebf7349bec450b855571b300284fb3731f"
|
||||
integrity sha1-JPnM6/c0m+xFC4VVcbMAKE+zcx8=
|
||||
"@drop/droplet-linux-x64-gnu@^0.4.4":
|
||||
version "0.4.4"
|
||||
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-linux-x64-gnu/-/@drop/droplet-linux-x64-gnu-0.4.4.tgz#6678a0923bb13d37e20cae467f45c72bc5d9fe6e"
|
||||
integrity sha1-ZnigkjuxPTfiDK5Gf0XHK8XZ/m4=
|
||||
|
||||
"@drop/droplet-win32-x64-msvc@0.4.1", "@drop/droplet-win32-x64-msvc@^0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-win32-x64-msvc/-/@drop/droplet-win32-x64-msvc-0.4.1.tgz#58238faca15b36abb02162354c2f39526bc213a1"
|
||||
integrity sha1-WCOPrKFbNquwIWI1TC85UmvCE6E=
|
||||
"@drop/droplet-win32-x64-msvc@^0.4.4":
|
||||
version "0.4.4"
|
||||
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-win32-x64-msvc/-/@drop/droplet-win32-x64-msvc-0.4.4.tgz#10802bb36c6ec7d69aa17ea22081e5d5f0dac3c3"
|
||||
integrity sha1-EIArs2xux9aaoX6iIIHl1fDaw8M=
|
||||
|
||||
"@drop/droplet@^0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet/-/@drop/droplet-0.4.1.tgz#d4f3a7950fad2a95487ce4c014e1c782c2fcc3c7"
|
||||
integrity sha1-1POnlQ+tKpVIfOTAFOHHgsL8w8c=
|
||||
"@drop/droplet@^0.4.4":
|
||||
version "0.4.4"
|
||||
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet/-/@drop/droplet-0.4.4.tgz#a9b6e3a341e85703b25c7fee597261e1b239a280"
|
||||
integrity sha1-qbbjo0HoVwOyXH/uWXJh4bI5ooA=
|
||||
optionalDependencies:
|
||||
"@drop/droplet-linux-x64-gnu" "0.4.1"
|
||||
"@drop/droplet-win32-x64-msvc" "0.4.1"
|
||||
"@drop/droplet-linux-x64-gnu" "0.4.4"
|
||||
"@drop/droplet-win32-x64-msvc" "0.4.4"
|
||||
|
||||
"@esbuild/aix-ppc64@0.20.2":
|
||||
version "0.20.2"
|
||||
|
||||
Reference in New Issue
Block a user