mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-20 19:51:09 +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:
336
app/components/Import/Redist.vue
Normal file
336
app/components/Import/Redist.vue
Normal file
@ -0,0 +1,336 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="flex flex-row gap-x-4">
|
||||
<label
|
||||
for="icon-upload"
|
||||
class="relative size-24 bg-zinc-800 rounded-md overflow-hidden has-[:focus]:ring-2 has-[:focus]:ring-blue-600"
|
||||
>
|
||||
<input
|
||||
id="icon-upload"
|
||||
type="file"
|
||||
class="sr-only"
|
||||
accept="image/*"
|
||||
@change="addFile"
|
||||
/>
|
||||
<img
|
||||
v-if="currentFileObjectUrl"
|
||||
:src="currentFileObjectUrl"
|
||||
class="absolute inset-0 object-cover w-full h-full"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 cursor-pointer flex flex-col gap-y-1 items-center justify-center text-zinc-300 bg-zinc-900/50"
|
||||
>
|
||||
<ArrowUpTrayIcon class="size-6" />
|
||||
<span class="text-xs font-bold font-display uppercase">Upload</span>
|
||||
</div>
|
||||
</label>
|
||||
<div class="grow flex flex-col gap-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-zinc-100"
|
||||
>Name</label
|
||||
>
|
||||
<input
|
||||
id="name"
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-zinc-100"
|
||||
>Description</label
|
||||
>
|
||||
<input
|
||||
id="description"
|
||||
v-model="description"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SwitchGroup
|
||||
as="div"
|
||||
class="max-w-lg flex items-center justify-between gap-x-4"
|
||||
>
|
||||
<span class="flex flex-grow flex-col">
|
||||
<SwitchLabel
|
||||
as="span"
|
||||
class="text-sm font-medium leading-6 text-zinc-100"
|
||||
passive
|
||||
>Create as platform</SwitchLabel
|
||||
>
|
||||
<SwitchDescription as="span" class="text-sm text-zinc-400"
|
||||
>Versions for this redistributable will be able to take a series of
|
||||
launch commands. Intended to be used with emulators and similar
|
||||
programs.</SwitchDescription
|
||||
>
|
||||
</span>
|
||||
<Switch
|
||||
v-model="isPlatform"
|
||||
:class="[
|
||||
isPlatform ? 'bg-blue-600' : 'bg-zinc-800',
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
isPlatform ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
]"
|
||||
/>
|
||||
</Switch>
|
||||
</SwitchGroup>
|
||||
<div class="relative">
|
||||
<div class="flex flex-row gap-x-4">
|
||||
<label
|
||||
for="platform-icon-upload"
|
||||
class="relative size-24 bg-zinc-800 rounded-md overflow-hidden has-[:focus]:ring-2 has-[:focus]:ring-blue-600"
|
||||
>
|
||||
<input
|
||||
id="platform-icon-upload"
|
||||
type="file"
|
||||
class="sr-only"
|
||||
accept="image/svg+xml"
|
||||
:disabled="!isPlatform"
|
||||
@change="addSvg"
|
||||
/>
|
||||
<div
|
||||
v-if="platform.icon"
|
||||
class="absolute inset-0 object-cover w-full h-full text-blue-600"
|
||||
v-html="platform.icon"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 cursor-pointer flex flex-col gap-y-1 items-center justify-center text-zinc-300 bg-zinc-900/50 focus:text-zinc-100"
|
||||
>
|
||||
<ArrowUpTrayIcon class="size-6" />
|
||||
<span class="text-xs font-bold font-display uppercase"
|
||||
>Upload SVG</span
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
<div class="grow flex flex-col gap-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="platform-name"
|
||||
class="block text-sm font-medium text-zinc-100"
|
||||
>Platform Name</label
|
||||
>
|
||||
<input
|
||||
id="platform-name"
|
||||
v-model="platform.name"
|
||||
type="text"
|
||||
:disabled="!isPlatform"
|
||||
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 w-full">
|
||||
<label for="platform-name" class="block text-sm font-medium text-zinc-100"
|
||||
>File Extensions {{ currentExtDotted }}
|
||||
</label
|
||||
>
|
||||
<Combobox
|
||||
as="div"
|
||||
:model-value="currentExtDotted"
|
||||
nullable
|
||||
class="mt-1 w-full"
|
||||
:disabled="!isPlatform"
|
||||
@update:model-value="(v) => addExt(v)"
|
||||
>
|
||||
<div class="relative">
|
||||
<ComboboxInput
|
||||
class="w-full block flex-1 rounded-lg border-1 border-zinc-800 py-2 px-2 bg-zinc-950 text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
placeholder=".exe"
|
||||
@change="currentExt = $event.target.value"
|
||||
@blur="currentExt = ''"
|
||||
/>
|
||||
<ComboboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-if="currentExt"
|
||||
v-slot="{ active }"
|
||||
:value="currentExtDotted"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span class="block">
|
||||
<span class="text-blue-300">filename</span
|
||||
><span class="font-semibold">{{ currentExtDotted }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
<div class="mt-2 flex gap-1 flex-wrap">
|
||||
<div
|
||||
v-for="ext in platform.fileExts"
|
||||
:key="ext"
|
||||
class="bg-blue-600/10 border-1 border-blue-700 rounded-full px-2 py-1 text-xs text-blue-400"
|
||||
>
|
||||
{{ ext }}
|
||||
</div>
|
||||
<span
|
||||
v-if="platform.fileExts.length == 0"
|
||||
class="uppercase font-display text-zinc-700 font-bold text-xs"
|
||||
>No suggested file extensions.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPlatform" class="absolute inset-0 bg-zinc-950/20" />
|
||||
</div>
|
||||
<div>
|
||||
<LoadingButton
|
||||
class="w-fit"
|
||||
:loading="props.loading"
|
||||
:disabled="buttonDisabled"
|
||||
@click="() => importRedist()"
|
||||
>
|
||||
{{ $t("library.admin.import.import") }}
|
||||
</LoadingButton>
|
||||
|
||||
<div v-if="props.error" class="mt-4 w-fit rounded-md bg-red-600/10 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
{{ props.error }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
Switch,
|
||||
SwitchDescription,
|
||||
SwitchGroup,
|
||||
SwitchLabel,
|
||||
} from "@headlessui/vue";
|
||||
import { ArrowUpTrayIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const currentFile = ref<File | undefined>(undefined);
|
||||
const currentFileObjectUrl = ref<string | undefined>(undefined);
|
||||
|
||||
const emit = defineEmits<{
|
||||
import: [
|
||||
metadata: { name: string; description: string; icon: File } | undefined,
|
||||
platform: typeof platform.value | undefined,
|
||||
];
|
||||
}>();
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const isPlatform = ref(false);
|
||||
const currentExt = ref("");
|
||||
const currentExtDotted = computed(() => {
|
||||
if(!currentExt.value) return "";
|
||||
const cleaned = currentExt.value.replace(/\W/g, "").toLowerCase();
|
||||
return `.${cleaned}`;
|
||||
});
|
||||
const platform = ref<{ name: string; icon: string; fileExts: string[] }>({
|
||||
name: "",
|
||||
icon: "",
|
||||
fileExts: [],
|
||||
});
|
||||
|
||||
const buttonDisabled = computed<boolean>(
|
||||
() =>
|
||||
!(
|
||||
name.value &&
|
||||
description.value &&
|
||||
currentFileObjectUrl.value &&
|
||||
(!isPlatform.value || (platform.value.name && platform.value.icon))
|
||||
),
|
||||
);
|
||||
|
||||
function addFile(event: Event) {
|
||||
const file = (event.target as HTMLInputElement)?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (currentFileObjectUrl.value) {
|
||||
URL.revokeObjectURL(currentFileObjectUrl.value);
|
||||
}
|
||||
|
||||
currentFile.value = file;
|
||||
currentFileObjectUrl.value = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
async function addSvg(event: Event) {
|
||||
const file = (event.target as HTMLInputElement)?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const svgContent = await file.text();
|
||||
const parser = new DOMParser();
|
||||
try {
|
||||
const document = parser.parseFromString(svgContent, "image/svg+xml");
|
||||
const svg = document.getElementsByTagName("svg").item(0);
|
||||
if (!svg) throw "No SVG in uploaded image.";
|
||||
svg.removeAttribute("width");
|
||||
svg.removeAttribute("height");
|
||||
platform.value.icon = svg.outerHTML;
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "Failed to upload SVG",
|
||||
description: (e as string)?.toString() ?? e,
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function addExt(ext: string | null) {
|
||||
if (!ext) return;
|
||||
if (platform.value.fileExts.includes(ext)) return;
|
||||
platform.value.fileExts.push(ext);
|
||||
currentExt.value = "";
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
gameName: string;
|
||||
loading: boolean;
|
||||
error?: string | undefined;
|
||||
}>();
|
||||
|
||||
function importRedist() {
|
||||
if (!currentFile.value) return;
|
||||
emit(
|
||||
"import",
|
||||
{
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
icon: currentFile.value,
|
||||
},
|
||||
isPlatform.value
|
||||
? {
|
||||
...platform.value,
|
||||
fileExts: platform.value.fileExts.map((e) => e.slice(1)),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user