You've already forked AstralRinth
forked from didirus/AstralRinth
Add server unzipping (#3622)
* Initial unzipping feature * Remove explicit backup provider naming from frontend * CF placeholder * Use regex for CF links * Lint * Add unzip warning for conflicting files, fix hydration error * Adjust conflict modal ui * Fix old queued ops sticking around, remove conflict warning * Add vscode "editor.detectIndentation": true
This commit is contained in:
@@ -1,85 +1,140 @@
|
||||
<template>
|
||||
<div class="vue-notification-group">
|
||||
<div class="vue-notification-group experimental-styles-within">
|
||||
<transition-group name="notifs">
|
||||
<div
|
||||
v-for="(item, index) in notifications"
|
||||
:key="item.id"
|
||||
class="vue-notification-wrapper"
|
||||
@click="notifications.splice(index, 1)"
|
||||
@mouseenter="stopTimer(item)"
|
||||
@mouseleave="setNotificationTimer(item)"
|
||||
>
|
||||
<div class="vue-notification-template vue-notification" :class="{ [item.type]: true }">
|
||||
<div class="notification-title" v-html="item.title"></div>
|
||||
<div class="notification-content" v-html="item.text"></div>
|
||||
<div class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl">
|
||||
<div
|
||||
class="w-2"
|
||||
:class="{
|
||||
'bg-red': item.type === 'error',
|
||||
'bg-orange': item.type === 'warning',
|
||||
'bg-green': item.type === 'success',
|
||||
'bg-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
class="grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2 gap-y-1 py-2 pl-1 pr-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center"
|
||||
:class="{
|
||||
'text-red': item.type === 'error',
|
||||
'text-orange': item.type === 'warning',
|
||||
'text-green': item.type === 'success',
|
||||
'text-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
|
||||
}"
|
||||
>
|
||||
<IssuesIcon v-if="item.type === 'warning'" class="h-6 w-6" />
|
||||
<CheckCircleIcon v-else-if="item.type === 'success'" class="h-6 w-6" />
|
||||
<XCircleIcon v-else-if="item.type === 'error'" class="h-6 w-6" />
|
||||
<InfoIcon v-else class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="m-0 text-wrap font-bold text-contrast" v-html="item.title"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div v-if="item.count && item.count > 1" class="text-xs font-bold text-contrast">
|
||||
x{{ item.count }}
|
||||
</div>
|
||||
<ButtonStyled circular size="small">
|
||||
<button v-tooltip="'Copy to clipboard'" @click="copyToClipboard(item)">
|
||||
<CheckIcon v-if="copied[createNotifText(item)]" />
|
||||
<CopyIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular size="small">
|
||||
<button v-tooltip="`Dismiss`" @click="notifications.splice(index, 1)">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div></div>
|
||||
<div class="col-span-2 text-sm text-primary" v-html="item.text"></div>
|
||||
<template v-if="item.errorCode">
|
||||
<div></div>
|
||||
<div
|
||||
class="m-0 text-wrap text-xs font-medium text-secondary"
|
||||
v-html="item.errorCode"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import {
|
||||
XCircleIcon,
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
InfoIcon,
|
||||
IssuesIcon,
|
||||
XIcon,
|
||||
CopyIcon,
|
||||
} from "@modrinth/assets";
|
||||
const notifications = useNotifications();
|
||||
|
||||
function stopTimer(notif) {
|
||||
clearTimeout(notif.timer);
|
||||
}
|
||||
|
||||
const copied = ref({});
|
||||
|
||||
const createNotifText = (notif) => {
|
||||
let text = "";
|
||||
if (notif.title) {
|
||||
text += notif.title;
|
||||
}
|
||||
if (notif.text) {
|
||||
if (text.length > 0) {
|
||||
text += "\n";
|
||||
}
|
||||
text += notif.text;
|
||||
}
|
||||
if (notif.errorCode) {
|
||||
if (text.length > 0) {
|
||||
text += "\n";
|
||||
}
|
||||
text += notif.errorCode;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
function copyToClipboard(notif) {
|
||||
const text = createNotifText(notif);
|
||||
|
||||
copied.value[text] = true;
|
||||
navigator.clipboard.writeText(text);
|
||||
setTimeout(() => {
|
||||
delete copied.value[text];
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.vue-notification {
|
||||
background: var(--color-blue) !important;
|
||||
border-left: 5px solid var(--color-blue) !important;
|
||||
color: var(--color-brand-inverted) !important;
|
||||
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
padding: 10px;
|
||||
margin: 0 5px 5px;
|
||||
|
||||
&.success {
|
||||
background: var(--color-green) !important;
|
||||
border-left-color: var(--color-green) !important;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
background: var(--color-orange) !important;
|
||||
border-left-color: var(--color-orange) !important;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: var(--color-red) !important;
|
||||
border-left-color: var(--color-red) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.vue-notification-group {
|
||||
position: fixed;
|
||||
right: 25px;
|
||||
bottom: 25px;
|
||||
z-index: 99999999;
|
||||
width: 300px;
|
||||
right: 1.5rem;
|
||||
bottom: 1.5rem;
|
||||
z-index: 200;
|
||||
width: 450px;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
width: calc(100% - 0.75rem * 2);
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.vue-notification-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.vue-notification-template {
|
||||
border-radius: var(--size-rounded-card);
|
||||
margin: 0;
|
||||
|
||||
.notification-title {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-right: auto;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
margin-right: auto;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -98,10 +153,18 @@ function stopTimer(notif) {
|
||||
.notifs-enter-active,
|
||||
.notifs-leave-active,
|
||||
.notifs-move {
|
||||
transition: all 0.5s;
|
||||
transition: all 0.25s ease-in-out;
|
||||
}
|
||||
.notifs-enter-from,
|
||||
.notifs-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.notifs-enter-from {
|
||||
transform: translateY(100%) scale(0.8);
|
||||
}
|
||||
|
||||
.notifs-leave-to {
|
||||
transform: translateX(100%) scale(0.8);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<ButtonStyled circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
|
||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||
<template #extract><PackageOpenIcon /> Extract</template>
|
||||
<template #rename><EditIcon /> Rename</template>
|
||||
<template #move><RightArrowIcon /> Move</template>
|
||||
<template #download><DownloadIcon /> Download</template>
|
||||
@@ -73,6 +74,8 @@ import {
|
||||
FolderOpenIcon,
|
||||
FileIcon,
|
||||
RightArrowIcon,
|
||||
PackageOpenIcon,
|
||||
FileArchiveIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { computed, shallowRef, ref } from "vue";
|
||||
import { renderToString } from "vue/server-renderer";
|
||||
@@ -99,15 +102,14 @@ interface FileItemProps {
|
||||
const props = defineProps<FileItemProps>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "rename", item: { name: string; type: string; path: string }): void;
|
||||
(e: "move", item: { name: string; type: string; path: string }): void;
|
||||
(
|
||||
e: "rename" | "move" | "download" | "delete" | "edit" | "extract",
|
||||
item: { name: string; type: string; path: string },
|
||||
): void;
|
||||
(
|
||||
e: "moveDirectTo",
|
||||
item: { name: string; type: string; path: string; destination: string },
|
||||
): void;
|
||||
(e: "download", item: { name: string; type: string; path: string }): void;
|
||||
(e: "delete", item: { name: string; type: string; path: string }): void;
|
||||
(e: "edit", item: { name: string; type: string; path: string }): void;
|
||||
(e: "contextmenu", x: number, y: number): void;
|
||||
}>();
|
||||
|
||||
@@ -143,6 +145,7 @@ const codeExtensions = Object.freeze([
|
||||
|
||||
const textExtensions = Object.freeze(["txt", "md", "log", "cfg", "conf", "properties", "ini"]);
|
||||
const imageExtensions = Object.freeze(["png", "jpg", "jpeg", "gif", "svg", "webp"]);
|
||||
const supportedArchiveExtensions = Object.freeze(["zip"]);
|
||||
const units = Object.freeze(["B", "KB", "MB", "GB", "TB", "PB", "EB"]);
|
||||
|
||||
const route = shallowRef(useRoute());
|
||||
@@ -156,7 +159,18 @@ const containerClasses = computed(() => [
|
||||
|
||||
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
|
||||
|
||||
const isZip = computed(() => fileExtension.value === "zip");
|
||||
|
||||
const menuOptions = computed(() => [
|
||||
{
|
||||
id: "extract",
|
||||
shown: isZip.value,
|
||||
action: () => emit("extract", { name: props.name, type: props.type, path: props.path }),
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: isZip.value,
|
||||
},
|
||||
{
|
||||
id: "rename",
|
||||
action: () => emit("rename", { name: props.name, type: props.type, path: props.path }),
|
||||
@@ -189,6 +203,7 @@ const iconComponent = computed(() => {
|
||||
if (codeExtensions.includes(ext)) return UiServersIconsCodeFileIcon;
|
||||
if (textExtensions.includes(ext)) return UiServersIconsTextFileIcon;
|
||||
if (imageExtensions.includes(ext)) return UiServersIconsImageFileIcon;
|
||||
if (supportedArchiveExtensions.includes(ext)) return FileArchiveIcon;
|
||||
return FileIcon;
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
:size="item.size"
|
||||
@delete="$emit('delete', item)"
|
||||
@rename="$emit('rename', item)"
|
||||
@extract="$emit('extract', item)"
|
||||
@download="$emit('download', item)"
|
||||
@move="$emit('move', item)"
|
||||
@move-direct-to="$emit('moveDirectTo', $event)"
|
||||
@@ -49,14 +50,12 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete", item: any): void;
|
||||
(e: "rename", item: any): void;
|
||||
(e: "download", item: any): void;
|
||||
(e: "move", item: any): void;
|
||||
(e: "edit", item: any): void;
|
||||
(
|
||||
e: "delete" | "rename" | "download" | "move" | "edit" | "moveDirectTo" | "extract",
|
||||
item: any,
|
||||
): void;
|
||||
(e: "contextmenu", item: any, x: number, y: number): void;
|
||||
(e: "loadMore"): void;
|
||||
(e: "moveDirectTo", item: any): void;
|
||||
}>();
|
||||
|
||||
const ITEM_HEIGHT = 61;
|
||||
|
||||
@@ -117,7 +117,8 @@
|
||||
</div>
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
<OverflowMenu
|
||||
:dropdown-id="`create-new-${baseId}`"
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Create new..."
|
||||
@@ -125,6 +126,10 @@
|
||||
{ id: 'file', action: () => $emit('create', 'file') },
|
||||
{ id: 'directory', action: () => $emit('create', 'directory') },
|
||||
{ id: 'upload', action: () => $emit('upload') },
|
||||
{ divider: true },
|
||||
{ id: 'upload-zip', shown: false, action: () => $emit('upload-zip') },
|
||||
{ id: 'install-from-url', action: () => $emit('unzip-from-url', false) },
|
||||
{ id: 'install-cf-pack', action: () => $emit('unzip-from-url', true) },
|
||||
]"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
@@ -132,7 +137,16 @@
|
||||
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
|
||||
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
|
||||
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
<template #upload-zip>
|
||||
<FileArchiveIcon aria-hidden="true" /> Upload from .zip file
|
||||
</template>
|
||||
<template #install-from-url>
|
||||
<LinkIcon aria-hidden="true" /> Upload from .zip URL
|
||||
</template>
|
||||
<template #install-cf-pack>
|
||||
<CurseForgeIcon aria-hidden="true" /> Install CurseForge pack
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</header>
|
||||
@@ -140,6 +154,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
LinkIcon,
|
||||
CurseForgeIcon,
|
||||
FileArchiveIcon,
|
||||
BoxIcon,
|
||||
PlusIcon,
|
||||
UploadIcon,
|
||||
@@ -150,7 +167,7 @@ import {
|
||||
ChevronRightIcon,
|
||||
FilterIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ButtonStyled, OverflowMenu } from "@modrinth/ui";
|
||||
import { ref, computed } from "vue";
|
||||
import { useIntersectionObserver } from "@vueuse/core";
|
||||
|
||||
@@ -158,12 +175,14 @@ const props = defineProps<{
|
||||
breadcrumbSegments: string[];
|
||||
searchQuery: string;
|
||||
currentFilter: string;
|
||||
baseId: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "navigate", index: number): void;
|
||||
(e: "create", type: "file" | "directory"): void;
|
||||
(e: "upload"): void;
|
||||
(e: "upload" | "upload-zip"): void;
|
||||
(e: "unzip-from-url", cf: boolean): void;
|
||||
(e: "update:searchQuery", value: string): void;
|
||||
(e: "filter", type: string): void;
|
||||
}>();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<ConfirmModal
|
||||
ref="modal"
|
||||
title="Do you want to overwrite these conflicting files?"
|
||||
:proceed-label="`Overwrite`"
|
||||
:proceed-icon="CheckIcon"
|
||||
@proceed="proceed"
|
||||
>
|
||||
<div class="flex max-w-[30rem] flex-col gap-4">
|
||||
<p class="m-0 font-semibold leading-normal">
|
||||
<template v-if="hasMany">
|
||||
Over 100 files will be overwritten if you proceed with extraction; here is just some of
|
||||
them:
|
||||
</template>
|
||||
<template v-else>
|
||||
The following {{ files.length }} files already exist on your server, and will be
|
||||
overwritten if you proceed with extraction:
|
||||
</template>
|
||||
</p>
|
||||
<ul class="m-0 max-h-80 list-none overflow-auto rounded-2xl bg-bg px-4 py-3">
|
||||
<li v-for="file in files" :key="file" class="flex items-center gap-1 py-1 font-medium">
|
||||
<XIcon class="shrink-0 text-red" /> {{ file }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ConfirmModal } from "@modrinth/ui";
|
||||
import { ref } from "vue";
|
||||
import { XIcon, CheckIcon } from "@modrinth/assets";
|
||||
|
||||
const path = ref("");
|
||||
const files = ref<string[]>([]);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "proceed", path: string): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof ConfirmModal>();
|
||||
|
||||
const hasMany = computed(() => files.value.length > 100);
|
||||
|
||||
const show = (zipPath: string, conflictingFiles: string[]) => {
|
||||
path.value = zipPath;
|
||||
files.value = conflictingFiles;
|
||||
modal.value?.show();
|
||||
};
|
||||
|
||||
const proceed = () => {
|
||||
emit("proceed", path.value);
|
||||
};
|
||||
|
||||
defineExpose({ show });
|
||||
</script>
|
||||
@@ -1,101 +1,105 @@
|
||||
<template>
|
||||
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
|
||||
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
|
||||
<div
|
||||
ref="statusContentRef"
|
||||
:class="['flex flex-col p-4 text-sm text-contrast', $attrs.class]"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<FolderOpenIcon class="size-4" />
|
||||
<span>
|
||||
<span class="capitalize">
|
||||
{{ props.fileType ? props.fileType : "File" }} Uploads
|
||||
<div>
|
||||
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
|
||||
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
|
||||
<div
|
||||
ref="statusContentRef"
|
||||
v-bind="$attrs"
|
||||
:class="['flex flex-col p-4 text-sm text-contrast']"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<FolderOpenIcon class="size-4" />
|
||||
<span>
|
||||
<span class="capitalize">
|
||||
{{ props.fileType ? props.fileType : "File" }} uploads
|
||||
</span>
|
||||
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
|
||||
</span>
|
||||
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-2">
|
||||
<div
|
||||
v-for="item in uploadQueue"
|
||||
:key="item.file.name"
|
||||
class="flex h-6 items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-2 truncate">
|
||||
<transition-group name="status-icon" mode="out-in">
|
||||
<UiServersPanelSpinner
|
||||
v-show="item.status === 'uploading'"
|
||||
key="spinner"
|
||||
class="absolute !size-4"
|
||||
/>
|
||||
<CheckCircleIcon
|
||||
v-show="item.status === 'completed'"
|
||||
key="check"
|
||||
class="absolute size-4 text-green"
|
||||
/>
|
||||
<XCircleIcon
|
||||
v-show="
|
||||
item.status === 'error' ||
|
||||
item.status === 'cancelled' ||
|
||||
item.status === 'incorrect-type'
|
||||
"
|
||||
key="error"
|
||||
class="absolute size-4 text-red"
|
||||
/>
|
||||
</transition-group>
|
||||
<span class="ml-6 truncate">{{ item.file.name }}</span>
|
||||
<span class="text-secondary">{{ item.size }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
||||
<template v-if="item.status === 'completed'">
|
||||
<span>Done</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error'">
|
||||
<span class="text-red">Failed - File already exists</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'incorrect-type'">
|
||||
<span class="text-red">Failed - Incorrect file type</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="item.status === 'uploading'">
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
||||
<button>Cancel</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-2">
|
||||
<div
|
||||
v-for="item in uploadQueue"
|
||||
:key="item.file.name"
|
||||
class="flex h-6 items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-2 truncate">
|
||||
<transition-group name="status-icon" mode="out-in">
|
||||
<UiServersPanelSpinner
|
||||
v-show="item.status === 'uploading'"
|
||||
key="spinner"
|
||||
class="absolute !size-4"
|
||||
/>
|
||||
<CheckCircleIcon
|
||||
v-show="item.status === 'completed'"
|
||||
key="check"
|
||||
class="absolute size-4 text-green"
|
||||
/>
|
||||
<XCircleIcon
|
||||
v-show="
|
||||
item.status === 'error' ||
|
||||
item.status === 'cancelled' ||
|
||||
item.status === 'incorrect-type'
|
||||
"
|
||||
key="error"
|
||||
class="absolute size-4 text-red"
|
||||
/>
|
||||
</transition-group>
|
||||
<span class="ml-6 truncate">{{ item.file.name }}</span>
|
||||
<span class="text-secondary">{{ item.size }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
||||
<template v-if="item.status === 'completed'">
|
||||
<span>Done</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'cancelled'">
|
||||
<span class="text-red">Cancelled</span>
|
||||
<template v-else-if="item.status === 'error'">
|
||||
<span class="text-red">Failed - File already exists</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'incorrect-type'">
|
||||
<span class="text-red">Failed - Incorrect file type</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="item.status === 'uploading'">
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
||||
<button>Cancel</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'cancelled'">
|
||||
<span class="text-red">Cancelled</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
import type { FSModule } from "~/composables/pyroServers.ts";
|
||||
|
||||
interface UploadItem {
|
||||
file: File;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="cf ? `Installing a CurseForge pack` : `Uploading .zip contents from URL`"
|
||||
>
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-bold text-contrast">
|
||||
{{ cf ? `How to get the modpack version's URL` : "URL of .zip file" }}
|
||||
</div>
|
||||
<ol v-if="cf" class="mb-1 mt-0 flex flex-col gap-1 pl-8 leading-normal text-secondary">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.curseforge.com/minecraft/search?page=1&pageSize=40&sortBy=relevancy&class=modpacks"
|
||||
class="inline-flex font-semibold text-[#F16436] transition-all hover:underline active:brightness-[--hover-brightness]"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Find the CurseForge modpack
|
||||
<ExternalIcon class="ml-1 inline size-4" stroke-width="3" />
|
||||
</a>
|
||||
you'd like to install on your server.
|
||||
</li>
|
||||
<li>
|
||||
On the modpack's page, go to the
|
||||
<span class="font-semibold text-primary">"Files"</span> tab, and
|
||||
<span class="font-semibold text-primary">select the version</span> of the modpack you
|
||||
want to install.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-semibold text-primary">Copy the URL</span> of the version you want to
|
||||
install, and paste it in the box below.
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="mb-1 mt-0">Copy and paste the direct download URL of a .zip file.</p>
|
||||
<input
|
||||
ref="urlInput"
|
||||
v-model="url"
|
||||
autofocus
|
||||
:disabled="submitted"
|
||||
type="text"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-protonpass-ignore="true"
|
||||
required
|
||||
:placeholder="
|
||||
cf
|
||||
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
|
||||
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
|
||||
"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||
</div>
|
||||
<BackupWarning :backup-link="`/servers/manage/${props.server.serverId}/backups`" />
|
||||
<div class="flex justify-start gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button v-tooltip="error" :disabled="submitted || !!error" type="submit">
|
||||
<SpinnerIcon v-if="submitted" class="animate-spin" />
|
||||
<DownloadIcon v-else class="h-5 w-5" />
|
||||
{{ submitted ? "Installing..." : "Install" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ submitted ? "Close" : "Cancel" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon, SpinnerIcon, DownloadIcon, XIcon } from "@modrinth/assets";
|
||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import { handleError, type Server } from "~/composables/pyroServers.ts";
|
||||
|
||||
const cf = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof NewModal>();
|
||||
const urlInput = ref<HTMLInputElement | null>(null);
|
||||
const url = ref("");
|
||||
const submitted = ref(false);
|
||||
|
||||
const trimmedUrl = computed(() => url.value.trim());
|
||||
|
||||
const regex = /https:\/\/(www\.)?curseforge\.com\/minecraft\/modpacks\/[^/]+\/files\/\d+/;
|
||||
|
||||
const error = computed(() => {
|
||||
if (trimmedUrl.value.length === 0) {
|
||||
return "URL is required.";
|
||||
}
|
||||
if (cf.value && !regex.test(trimmedUrl.value)) {
|
||||
return "URL must be a CurseForge modpack version URL.";
|
||||
} else if (!cf.value && !trimmedUrl.value.includes("/")) {
|
||||
return "URL must be valid.";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
submitted.value = true;
|
||||
if (!error.value) {
|
||||
// hide();
|
||||
try {
|
||||
const dry = await props.server.fs?.extractFile(trimmedUrl.value, true, true);
|
||||
|
||||
if (!cf.value || dry.modpack_name) {
|
||||
await props.server.fs?.extractFile(trimmedUrl.value, true, false, true);
|
||||
hide();
|
||||
} else {
|
||||
submitted.value = false;
|
||||
handleError(
|
||||
new ServersError(
|
||||
"Could not find CurseForge modpack at that URL.",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
context: "Error installing modpack",
|
||||
error: `url: ${url.value}`,
|
||||
description: "Could not find CurseForge modpack at that URL.",
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
submitted.value = false;
|
||||
console.error("Error installing:", error);
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const show = (isCf: boolean) => {
|
||||
cf.value = isCf;
|
||||
url.value = "";
|
||||
submitted.value = false;
|
||||
modal.value?.show();
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
urlInput.value?.focus();
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
@@ -32,68 +32,68 @@
|
||||
@mousedown.stop
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<ButtonStyled
|
||||
<template
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="option.id"
|
||||
type="transparent"
|
||||
role="menuitem"
|
||||
:color="option.color"
|
||||
:key="isDivider(option) ? `divider-${index}` : option.id"
|
||||
>
|
||||
<button
|
||||
v-if="typeof option.action === 'function'"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</button>
|
||||
<nuxt-link
|
||||
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
:to="option.action"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</nuxt-link>
|
||||
<a
|
||||
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
:href="option.action"
|
||||
target="_blank"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</a>
|
||||
<span v-else>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</span>
|
||||
</ButtonStyled>
|
||||
<div v-if="isDivider(option)" class="h-px w-full bg-button-bg"></div>
|
||||
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
|
||||
<button
|
||||
v-if="typeof option.action === 'function'"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</button>
|
||||
<nuxt-link
|
||||
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
:to="option.action"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</nuxt-link>
|
||||
<a
|
||||
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
:href="option.action"
|
||||
target="_blank"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</a>
|
||||
<span v-else>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</span>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
@@ -112,9 +112,20 @@ interface Option {
|
||||
color?: "standard" | "brand" | "red" | "orange" | "green" | "blue" | "purple";
|
||||
}
|
||||
|
||||
type Divider = {
|
||||
divider: true;
|
||||
shown?: boolean;
|
||||
};
|
||||
|
||||
type Item = Option | Divider;
|
||||
|
||||
function isDivider(item: Item): item is Divider {
|
||||
return (item as Divider).divider;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
options: Option[];
|
||||
options: Item[];
|
||||
hoverable?: boolean;
|
||||
}>(),
|
||||
{
|
||||
@@ -338,7 +349,9 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
if (selectedIndex.value >= 0) {
|
||||
selectOption(filteredOptions.value[selectedIndex.value]);
|
||||
const option = filteredOptions.value[selectedIndex.value];
|
||||
if (isDivider(option)) break;
|
||||
selectOption(option);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
@@ -361,8 +374,9 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
default:
|
||||
if (event.key.length === 1) {
|
||||
typeAheadBuffer.value += event.key.toLowerCase();
|
||||
const matchIndex = filteredOptions.value.findIndex((option) =>
|
||||
option.id.toLowerCase().startsWith(typeAheadBuffer.value),
|
||||
const matchIndex = filteredOptions.value.findIndex(
|
||||
(option) =>
|
||||
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
|
||||
);
|
||||
if (matchIndex !== -1) {
|
||||
selectedIndex.value = matchIndex;
|
||||
|
||||
Reference in New Issue
Block a user