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:
parent
1884410e0d
commit
16766be82f
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -2,6 +2,7 @@
|
||||
"prettier.endOfLine": "lf",
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||
"editor.detectIndentation": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
|
4
apps/frontend/.env.prod
Normal file
4
apps/frontend/.env.prod
Normal file
@ -0,0 +1,4 @@
|
||||
BASE_URL=https://api.modrinth.com/v2/
|
||||
BROWSER_BASE_URL=https://api.modrinth.com/v2/
|
||||
PYRO_BASE_URL=https://archon.modrinth.com
|
||||
PROD_OVERRIDE=true
|
4
apps/frontend/.env.staging
Normal file
4
apps/frontend/.env.staging
Normal file
@ -0,0 +1,4 @@
|
||||
BASE_URL=https://staging-api.modrinth.com/v2/
|
||||
BROWSER_BASE_URL=https://staging-api.modrinth.com/v2/
|
||||
PYRO_BASE_URL=https://staging-archon.modrinth.com
|
||||
PROD_OVERRIDE=true
|
@ -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,16 +1,18 @@
|
||||
<template>
|
||||
<div>
|
||||
<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]"
|
||||
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
|
||||
{{ props.fileType ? props.fileType : "File" }} uploads
|
||||
</span>
|
||||
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
|
||||
</span>
|
||||
@ -90,12 +92,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</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,13 +32,12 @@
|
||||
@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"
|
||||
>
|
||||
<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="
|
||||
@ -94,6 +93,7 @@
|
||||
<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;
|
||||
|
@ -11,11 +11,13 @@ export const addNotification = (notification) => {
|
||||
);
|
||||
if (existingNotif) {
|
||||
setNotificationTimer(existingNotif);
|
||||
existingNotif.count++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
notification.id = new Date();
|
||||
notification.count = 1;
|
||||
|
||||
setNotificationTimer(notification);
|
||||
notifications.value.push(notification);
|
||||
|
@ -1,7 +1,7 @@
|
||||
// usePyroServer is a composable that interfaces with the REDACTED API to get data and control the users server
|
||||
import { $fetch, FetchError } from "ofetch";
|
||||
import type { ServerNotice } from "@modrinth/utils";
|
||||
import type { WSBackupState, WSBackupTask } from "~/types/servers.ts";
|
||||
import type { FilesystemOp, FSQueuedOp, WSBackupState, WSBackupTask } from "~/types/servers.ts";
|
||||
|
||||
interface PyroFetchOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
@ -40,12 +40,19 @@ class PyroServerError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class PyroServersFetchError extends Error {
|
||||
type V1ErrorInfo = {
|
||||
context?: string;
|
||||
error: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export class ServersError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode?: number,
|
||||
public readonly originalError?: Error,
|
||||
public readonly module?: string,
|
||||
public readonly v1Error?: V1ErrorInfo,
|
||||
) {
|
||||
let errorMessage = message;
|
||||
let method = "GET";
|
||||
@ -96,17 +103,35 @@ export class PyroServersFetchError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export const handleError = (err: any) => {
|
||||
if (err instanceof ServersError && err.v1Error) {
|
||||
addNotification({
|
||||
title: err.v1Error?.context ?? `An error occurred`,
|
||||
type: "error",
|
||||
text: err.v1Error.description,
|
||||
errorCode: err.v1Error.error,
|
||||
});
|
||||
} else {
|
||||
addNotification({
|
||||
title: "An error occurred",
|
||||
type: "error",
|
||||
text: err.message ?? (err.data ? err.data.description : err),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
async function PyroFetch<T>(
|
||||
path: string,
|
||||
options: PyroFetchOptions = {},
|
||||
module?: string,
|
||||
errorContext?: string,
|
||||
): Promise<T> {
|
||||
const config = useRuntimeConfig();
|
||||
const auth = await useAuth();
|
||||
const authToken = auth.value?.token;
|
||||
|
||||
if (!authToken) {
|
||||
throw new PyroServersFetchError("Missing auth token", 401, undefined, module);
|
||||
throw new ServersError("Missing auth token", 401, undefined, module);
|
||||
}
|
||||
|
||||
const {
|
||||
@ -124,16 +149,18 @@ async function PyroFetch<T>(
|
||||
);
|
||||
|
||||
if (!base) {
|
||||
throw new PyroServersFetchError(
|
||||
"Configuration error: Missing PYRO_BASE_URL",
|
||||
500,
|
||||
undefined,
|
||||
module,
|
||||
);
|
||||
throw new ServersError("Configuration error: Missing PYRO_BASE_URL", 500, undefined, module);
|
||||
}
|
||||
|
||||
const fullUrl = override?.url
|
||||
? `https://${override.url}/${path.replace(/^\//, "")}`
|
||||
const versionString = `v${version}`;
|
||||
|
||||
let newOverrideUrl = override?.url;
|
||||
if (newOverrideUrl && newOverrideUrl.includes("v0") && version !== 0) {
|
||||
newOverrideUrl = newOverrideUrl.replace("v0", versionString);
|
||||
}
|
||||
|
||||
const fullUrl = newOverrideUrl
|
||||
? `https://${newOverrideUrl}/${path.replace(/^\//, "")}`
|
||||
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
@ -170,11 +197,20 @@ async function PyroFetch<T>(
|
||||
attempts++;
|
||||
|
||||
if (error instanceof FetchError) {
|
||||
let v1Error: V1ErrorInfo | undefined;
|
||||
|
||||
if (error.data.error && error.data.description) {
|
||||
v1Error = {
|
||||
context: errorContext,
|
||||
...error.data,
|
||||
};
|
||||
}
|
||||
|
||||
const statusCode = error.response?.status;
|
||||
const isRetryable = statusCode ? [408, 429, 500, 502, 503, 504].includes(statusCode) : true;
|
||||
|
||||
if (!isRetryable || attempts >= maxAttempts) {
|
||||
throw new PyroServersFetchError(error.message, statusCode, error, module);
|
||||
throw new ServersError(error.message, statusCode, error, module, v1Error);
|
||||
}
|
||||
|
||||
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
|
||||
@ -182,7 +218,7 @@ async function PyroFetch<T>(
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new PyroServersFetchError(
|
||||
throw new ServersError(
|
||||
"Unexpected error during fetch operation",
|
||||
undefined,
|
||||
error as Error,
|
||||
@ -419,7 +455,7 @@ const processImage = async (iconUrl: string | undefined) => {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PyroServersFetchError && error.statusCode === 404 && iconUrl) {
|
||||
if (error instanceof ServersError && error.statusCode === 404 && iconUrl) {
|
||||
try {
|
||||
const response = await fetch(iconUrl);
|
||||
if (!response.ok) throw new Error("Failed to fetch icon");
|
||||
@ -892,7 +928,7 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
|
||||
try {
|
||||
return await requestFn();
|
||||
} catch (error) {
|
||||
if (error instanceof PyroServersFetchError && error.statusCode === 401) {
|
||||
if (error instanceof ServersError && error.statusCode === 401) {
|
||||
await internalServerReference.value.refresh(["fs"]);
|
||||
return await requestFn();
|
||||
}
|
||||
@ -1051,6 +1087,68 @@ const moveFileOrFolder = (path: string, newPath: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
const clearQueuedOps = () => {
|
||||
internalServerReference.value.fs.queuedOps = [];
|
||||
};
|
||||
|
||||
const removeQueuedOp = (op: FSQueuedOp["op"], src: string) => {
|
||||
internalServerReference.value.fs.queuedOps = internalServerReference.value.fs.queuedOps.filter(
|
||||
(x: FSQueuedOp) => x.op !== op || x.src !== src,
|
||||
);
|
||||
};
|
||||
|
||||
const extractFile = (path: string, override = true, dry = false, silentQueue = false) =>
|
||||
retryWithAuth(async () => {
|
||||
console.log(
|
||||
`Extracting: ${path}` + (dry ? " (dry run)" : "") + (silentQueue ? " (silent)" : ""),
|
||||
);
|
||||
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
|
||||
if (!silentQueue) {
|
||||
internalServerReference.value.fs.queuedOps.push({
|
||||
op: "unarchive",
|
||||
src: path,
|
||||
});
|
||||
|
||||
setTimeout(() => internalServerReference.value.fs.removeQueuedOp("unarchive", path), 4000);
|
||||
}
|
||||
|
||||
return (await PyroFetch(
|
||||
`/unarchive?src=${encodedPath}&trg=/&override=${override}&dry=${dry}`,
|
||||
{
|
||||
method: "POST",
|
||||
override: internalServerReference.value.fs.auth,
|
||||
version: 1,
|
||||
},
|
||||
undefined,
|
||||
"Error extracting file",
|
||||
).catch((err) => {
|
||||
removeQueuedOp("unarchive", path);
|
||||
throw err;
|
||||
})) as { modpack_name: string | null };
|
||||
});
|
||||
|
||||
const modifyOp = (id: string, action: "dismiss" | "cancel") =>
|
||||
retryWithAuth(async () => {
|
||||
return await PyroFetch(
|
||||
`/ops/${action}?id=${id}`,
|
||||
{
|
||||
method: "POST",
|
||||
override: internalServerReference.value.fs.auth,
|
||||
version: 1,
|
||||
},
|
||||
undefined,
|
||||
`Error ${action === "dismiss" ? "dismissing" : "cancelling"} filesystem operation`,
|
||||
).then(() => {
|
||||
internalServerReference.value.fs.opsQueuedForModification =
|
||||
internalServerReference.value.fs.opsQueuedForModification.filter((x: string) => x !== id);
|
||||
internalServerReference.value.fs.ops = internalServerReference.value.fs.ops.filter(
|
||||
(x: FilesystemOp) => x.id !== id,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const deleteFileOrFolder = (path: string, recursive: boolean) => {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
return retryWithAuth(async () => {
|
||||
@ -1104,9 +1202,9 @@ const modules: any = {
|
||||
return data;
|
||||
} catch (error) {
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
error instanceof ServersError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
: new ServersError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
@ -1135,9 +1233,9 @@ const modules: any = {
|
||||
};
|
||||
} catch (error) {
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
error instanceof ServersError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
: new ServersError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
data: [],
|
||||
@ -1160,9 +1258,9 @@ const modules: any = {
|
||||
};
|
||||
} catch (error) {
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
error instanceof ServersError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
: new ServersError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
data: [],
|
||||
@ -1196,9 +1294,9 @@ const modules: any = {
|
||||
};
|
||||
} catch (error) {
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
error instanceof ServersError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
: new ServersError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
allocations: [],
|
||||
@ -1221,9 +1319,9 @@ const modules: any = {
|
||||
return await PyroFetch<Startup>(`servers/${serverId}/startup`, {}, "startup");
|
||||
} catch (error) {
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
error instanceof ServersError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
: new ServersError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
error: {
|
||||
@ -1241,9 +1339,9 @@ const modules: any = {
|
||||
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`, {}, "ws");
|
||||
} catch (error) {
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
error instanceof ServersError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
: new ServersError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
error: {
|
||||
@ -1255,14 +1353,16 @@ const modules: any = {
|
||||
},
|
||||
},
|
||||
fs: {
|
||||
queuedOps: [],
|
||||
opsQueuedForModification: [],
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`, {}, "fs") };
|
||||
} catch (error) {
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
error instanceof ServersError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
: new ServersError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
auth: undefined,
|
||||
@ -1281,6 +1381,10 @@ const modules: any = {
|
||||
moveFileOrFolder,
|
||||
deleteFileOrFolder,
|
||||
downloadFile,
|
||||
extractFile,
|
||||
removeQueuedOp,
|
||||
clearQueuedOps,
|
||||
modifyOp,
|
||||
},
|
||||
};
|
||||
|
||||
@ -1588,10 +1692,29 @@ type FSFunctions = {
|
||||
* @returns
|
||||
*/
|
||||
downloadFile: (path: string, raw?: boolean) => Promise<any>;
|
||||
|
||||
/**
|
||||
* @param path - The path of the file to extract
|
||||
* @returns
|
||||
*/
|
||||
extractFile: (
|
||||
path: string,
|
||||
override?: boolean,
|
||||
dry?: boolean,
|
||||
silentQueue?: boolean,
|
||||
) => Promise<{
|
||||
modpack_name: string | null;
|
||||
conflicting_files: string[];
|
||||
}>;
|
||||
|
||||
removeQueuedOp: (op: FSQueuedOp["op"], src: string) => void;
|
||||
clearQueuedOps: () => void;
|
||||
|
||||
modifyOp: (id: string, action: "dismiss" | "cancel") => Promise<any>;
|
||||
};
|
||||
|
||||
type ModuleError = {
|
||||
error: PyroServersFetchError;
|
||||
error: ServersError;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
@ -1624,8 +1747,11 @@ type WSModule = JWTAuth & {
|
||||
error?: ModuleError;
|
||||
};
|
||||
|
||||
type FSModule = {
|
||||
export type FSModule = {
|
||||
auth: JWTAuth;
|
||||
ops: FilesystemOp[];
|
||||
queuedOps: FSQueuedOp[];
|
||||
opsQueuedForModification: string[];
|
||||
error?: ModuleError;
|
||||
} & FSFunctions;
|
||||
|
||||
|
@ -303,7 +303,7 @@
|
||||
</svg>
|
||||
<h2 class="m-0 text-lg font-bold">Backups included</h2>
|
||||
<h3 class="m-0 text-base font-normal text-secondary">
|
||||
Every server comes with 15 backups stored securely off-site with Backblaze.
|
||||
Every server comes with 15 backups stored securely off-site.
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -787,6 +787,40 @@ const handleWebSocketMessage = (data: WSEvent) => {
|
||||
|
||||
break;
|
||||
}
|
||||
case "filesystem-ops": {
|
||||
if (!server.fs) {
|
||||
console.error("FilesystemOps received, but server.fs is not available", data.all);
|
||||
break;
|
||||
}
|
||||
if (JSON.stringify(server.fs.ops) !== JSON.stringify(data.all)) {
|
||||
server.fs.ops = data.all;
|
||||
}
|
||||
|
||||
server.fs.queuedOps = server.fs.queuedOps.filter(
|
||||
(queuedOp) => !data.all.some((x) => x.src === queuedOp.src),
|
||||
);
|
||||
|
||||
const cancelled = data.all.filter((x) => x.state === "cancelled");
|
||||
Promise.all(cancelled.map((x) => server.fs?.modifyOp(x.id, "dismiss")));
|
||||
|
||||
const completed = data.all.filter((x) => x.state === "done");
|
||||
if (completed.length > 0) {
|
||||
setTimeout(
|
||||
async () =>
|
||||
await Promise.all(
|
||||
completed.map((x) => {
|
||||
if (!server.fs?.opsQueuedForModification.includes(x.id)) {
|
||||
server.fs?.opsQueuedForModification.push(x.id);
|
||||
return server.fs?.modifyOp(x.id, "dismiss");
|
||||
}
|
||||
return Promise.resolve();
|
||||
}),
|
||||
),
|
||||
3000,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.warn("Unhandled WebSocket event:", data);
|
||||
}
|
||||
|
@ -56,8 +56,7 @@
|
||||
</TagItem>
|
||||
</div>
|
||||
<p class="m-0">
|
||||
You can have up to {{ data.backup_quota }} backups at once, securely off-site with
|
||||
Backblaze.
|
||||
You can have up to {{ data.backup_quota }} backups at once, stored securely off-site.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
@ -5,6 +5,8 @@
|
||||
:type="newItemType"
|
||||
@create="handleCreateNewItem"
|
||||
/>
|
||||
<FilesUploadZipUrlModal ref="uploadZipModal" :server="server" />
|
||||
<FilesUploadConflictModal ref="uploadConflictModal" @proceed="extractItem" />
|
||||
|
||||
<LazyUiServersFilesRenameItemModal
|
||||
ref="renameItemModal"
|
||||
@ -35,9 +37,12 @@
|
||||
:breadcrumb-segments="breadcrumbSegments"
|
||||
:search-query="searchQuery"
|
||||
:current-filter="viewFilter"
|
||||
:base-id="`browse-navbar-${baseId}`"
|
||||
@navigate="navigateToSegment"
|
||||
@create="showCreateModal"
|
||||
@upload="initiateFileUpload"
|
||||
@upload-zip="() => {}"
|
||||
@unzip-from-url="showUnzipFromUrlModal"
|
||||
@filter="handleFilter"
|
||||
@update:search-query="searchQuery = $event"
|
||||
/>
|
||||
@ -46,6 +51,110 @@
|
||||
:sort-desc="sortDesc"
|
||||
@sort="handleSort"
|
||||
/>
|
||||
<div
|
||||
v-for="op in ops"
|
||||
:key="`fs-op-${op.op}-${op.src}`"
|
||||
class="sticky top-20 z-20 grid grid-cols-[auto_1fr_auto] items-center gap-2 border-0 border-b-[1px] border-solid border-button-bg bg-table-alternateRow px-4 py-2 md:grid-cols-[auto_1fr_1fr_2fr_auto]"
|
||||
>
|
||||
<div>
|
||||
<PackageOpenIcon class="h-5 w-5 text-secondary" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 md:contents">
|
||||
<div class="flex items-center text-wrap break-all text-sm font-bold text-contrast">
|
||||
Extracting {{ op.src.includes("https://") ? "modpack from URL" : op.src }}
|
||||
</div>
|
||||
<span
|
||||
class="flex items-center gap-2 text-sm font-semibold"
|
||||
:class="{
|
||||
'text-green': op.state === 'done',
|
||||
'text-red': op.state?.startsWith('fail'),
|
||||
'text-orange': !op.state?.startsWith('fail') && op.state !== 'done',
|
||||
}"
|
||||
>
|
||||
<template v-if="op.state === 'done'">
|
||||
Done
|
||||
<CheckIcon style="stroke-width: 3px" />
|
||||
</template>
|
||||
<template v-else-if="op.state?.startsWith('fail')">
|
||||
Failed
|
||||
<XIcon style="stroke-width: 3px" />
|
||||
</template>
|
||||
<template v-else-if="op.state === 'cancelled'">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Cancelling
|
||||
</template>
|
||||
<template v-else-if="op.state === 'queued'">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Queued...
|
||||
</template>
|
||||
<template v-else-if="op.state === 'ongoing'">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Extracting...
|
||||
</template>
|
||||
<template v-else>
|
||||
<UnknownIcon />
|
||||
Unknown state: {{ op.state }}
|
||||
</template>
|
||||
</span>
|
||||
<div class="col-span-2 flex grow flex-col gap-1 md:col-span-1 md:items-end">
|
||||
<div class="text-xs font-semibold text-contrast opacity-80">
|
||||
<span :class="{ invisible: 'current_file' in op && !op.current_file }">
|
||||
{{
|
||||
"current_file" in op
|
||||
? op.current_file?.split("/")?.pop() ?? "unknown"
|
||||
: "unknown"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
:progress="'progress' in op ? op.progress : 0"
|
||||
:max="1"
|
||||
:color="
|
||||
op.state === 'done'
|
||||
? 'green'
|
||||
: op.state?.startsWith('fail')
|
||||
? 'red'
|
||||
: op.state === 'cancelled'
|
||||
? 'gray'
|
||||
: 'orange'
|
||||
"
|
||||
:waiting="op.state === 'queued' || !op.progress || op.progress === 0"
|
||||
/>
|
||||
<div
|
||||
class="text-xs text-secondary opacity-80"
|
||||
:class="{ invisible: 'bytes_processed' in op && !op.bytes_processed }"
|
||||
>
|
||||
{{ "bytes_processed" in op ? formatBytes(op.bytes_processed) : "0 B" }} extracted
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
:disabled="!('id' in op) || !op.id"
|
||||
class="radial-progress-animation-overlay"
|
||||
:class="{ active: op.state === 'done' }"
|
||||
@click="
|
||||
() => {
|
||||
op.state === 'done'
|
||||
? server.fs?.modifyOp(op.id, 'dismiss')
|
||||
: 'id' in op
|
||||
? server.fs?.modifyOp(op.id, 'cancel')
|
||||
: () => {};
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<pre
|
||||
v-if="flags.advancedDebugInfo"
|
||||
class="markdown-body col-span-full m-0 rounded-xl bg-button-bg text-xs"
|
||||
>{{ op }}</pre
|
||||
>
|
||||
</div>
|
||||
<FilesUploadDropdown
|
||||
v-if="props.server.fs"
|
||||
ref="uploadDropdownRef"
|
||||
@ -55,7 +164,6 @@
|
||||
@upload-complete="refreshList()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UiServersFilesEditingNavbar
|
||||
v-else
|
||||
:file-name="editingFile?.name"
|
||||
@ -97,10 +205,10 @@
|
||||
/>
|
||||
<UiServersFilesImageViewer v-else :image-blob="imagePreview" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
|
||||
<UiServersFileVirtualList
|
||||
:items="filteredItems"
|
||||
@extract="handleExtractItem"
|
||||
@delete="showDeleteModal"
|
||||
@rename="showRenameModal"
|
||||
@download="downloadFile"
|
||||
@ -159,10 +267,32 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInfiniteScroll } from "@vueuse/core";
|
||||
import { UploadIcon, FolderOpenIcon } from "@modrinth/assets";
|
||||
import type { DirectoryResponse, DirectoryItem, Server } from "~/composables/pyroServers";
|
||||
import {
|
||||
UnknownIcon,
|
||||
XIcon,
|
||||
SpinnerIcon,
|
||||
PackageOpenIcon,
|
||||
CheckIcon,
|
||||
UploadIcon,
|
||||
FolderOpenIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { computed } from "vue";
|
||||
import { ButtonStyled, ProgressBar } from "@modrinth/ui";
|
||||
import { formatBytes } from "@modrinth/utils";
|
||||
import {
|
||||
type DirectoryResponse,
|
||||
type DirectoryItem,
|
||||
type Server,
|
||||
handleError,
|
||||
} from "~/composables/pyroServers.ts";
|
||||
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
|
||||
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
|
||||
import type { FilesystemOp, FSQueuedOp } from "~/types/servers.ts";
|
||||
import FilesUploadZipUrlModal from "~/components/ui/servers/FilesUploadZipUrlModal.vue";
|
||||
import FilesUploadConflictModal from "~/components/ui/servers/FilesUploadConflictModal.vue";
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
const baseId = useId();
|
||||
|
||||
interface BaseOperation {
|
||||
type: "move" | "rename";
|
||||
@ -217,6 +347,8 @@ const createItemModal = ref();
|
||||
const renameItemModal = ref();
|
||||
const moveItemModal = ref();
|
||||
const deleteItemModal = ref();
|
||||
const uploadZipModal = ref();
|
||||
const uploadConflictModal = ref();
|
||||
|
||||
const newItemType = ref<"file" | "directory">("file");
|
||||
const selectedItem = ref<any>(null);
|
||||
@ -449,6 +581,33 @@ const handleRenameItem = async (newName: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const extractItem = async (path: string) => {
|
||||
try {
|
||||
await props.server.fs?.extractFile(path, true, false);
|
||||
} catch (error) {
|
||||
console.error("Error extracting item:", error);
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtractItem = async (item: { name: string; type: string; path: string }) => {
|
||||
try {
|
||||
const dry = await props.server.fs?.extractFile(item.path, true, true, true);
|
||||
if (dry) {
|
||||
if (dry.conflicting_files.length === 0) {
|
||||
await extractItem(item.path);
|
||||
} else {
|
||||
uploadConflictModal.value.show(item.path, dry.conflicting_files);
|
||||
}
|
||||
} else {
|
||||
handleError(new Error("Error running dry run"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error extracting item:", error);
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveItem = async (destination: string) => {
|
||||
try {
|
||||
const itemType = selectedItem.value.type;
|
||||
@ -536,6 +695,10 @@ const showCreateModal = (type: "file" | "directory") => {
|
||||
createItemModal.value?.show();
|
||||
};
|
||||
|
||||
const showUnzipFromUrlModal = (cf: boolean) => {
|
||||
uploadZipModal.value?.show(cf);
|
||||
};
|
||||
|
||||
const showRenameModal = (item: any) => {
|
||||
selectedItem.value = item;
|
||||
renameItemModal.value?.show(item);
|
||||
@ -760,6 +923,8 @@ onMounted(async () => {
|
||||
redoLastOperation();
|
||||
}
|
||||
});
|
||||
|
||||
props.server.fs?.clearQueuedOps();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@ -768,6 +933,22 @@ onUnmounted(() => {
|
||||
document.removeEventListener("keydown", () => {});
|
||||
});
|
||||
|
||||
const clientSideQueued = computed<FSQueuedOp[]>(() => props.server.fs?.queuedOps ?? []);
|
||||
|
||||
type QueuedOpWithState = FSQueuedOp & { state: "queued" };
|
||||
|
||||
const ops = computed<(QueuedOpWithState | FilesystemOp)[]>(() => [
|
||||
...clientSideQueued.value.map((x) => ({ ...x, state: "queued" }) satisfies QueuedOpWithState),
|
||||
...(props.server.fs?.ops ?? []),
|
||||
]);
|
||||
|
||||
watch(
|
||||
() => props.server.fs?.ops,
|
||||
() => {
|
||||
refreshList();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
async (newQuery) => {
|
||||
@ -984,4 +1165,43 @@ const onScroll = () => {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.radial-progress-animation-overlay {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@property --_radial-percentage {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
}
|
||||
|
||||
.radial-progress-animation-overlay.active::before {
|
||||
animation: radial-progress 3s linear forwards;
|
||||
}
|
||||
|
||||
.radial-progress-animation-overlay::before {
|
||||
content: "";
|
||||
inset: -2px;
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
box-sizing: content-box;
|
||||
border: 2px solid var(--color-button-bg);
|
||||
filter: brightness(var(--hover-brightness));
|
||||
mask-image: conic-gradient(
|
||||
black 0%,
|
||||
black var(--_radial-percentage),
|
||||
transparent var(--_radial-percentage),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes radial-progress {
|
||||
from {
|
||||
--_radial-percentage: 0%;
|
||||
}
|
||||
to {
|
||||
--_radial-percentage: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -224,6 +224,45 @@ export interface WSBackupProgressEvent {
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export type FSQueuedOpUnarchive = {
|
||||
op: "unarchive";
|
||||
src: string;
|
||||
};
|
||||
|
||||
export type FSQueuedOp = FSQueuedOpUnarchive;
|
||||
|
||||
export type FSOpUnarchive = {
|
||||
op: "unarchive";
|
||||
progress: number; // Note: 1 does not mean it's done
|
||||
id: string; // UUID
|
||||
|
||||
mime: string;
|
||||
src: string;
|
||||
state:
|
||||
| "queued"
|
||||
| "ongoing"
|
||||
| "cancelled"
|
||||
| "done"
|
||||
| "failed-corrupted"
|
||||
| "failed-invalid-path"
|
||||
| "failed-cf-no-serverpack"
|
||||
| "failed-cf-not-available"
|
||||
| "failed-not-reachable";
|
||||
|
||||
current_file: string | null;
|
||||
failed_path?: string;
|
||||
bytes_processed: number;
|
||||
files_processed: number;
|
||||
started: string;
|
||||
};
|
||||
|
||||
export type FilesystemOp = FSOpUnarchive;
|
||||
|
||||
export interface WSFilesystemOpsEvent {
|
||||
event: "filesystem-ops";
|
||||
all: FilesystemOp[];
|
||||
}
|
||||
|
||||
export type WSEvent =
|
||||
| WSLogEvent
|
||||
| WSStatsEvent
|
||||
@ -234,7 +273,8 @@ export type WSEvent =
|
||||
| WSAuthOkEvent
|
||||
| WSUptimeEvent
|
||||
| WSNewModEvent
|
||||
| WSBackupProgressEvent;
|
||||
| WSBackupProgressEvent
|
||||
| WSFilesystemOpsEvent;
|
||||
|
||||
export interface Servers {
|
||||
servers: Server[];
|
||||
|
1
packages/assets/external/curseforge.svg
vendored
Normal file
1
packages/assets/external/curseforge.svg
vendored
Normal file
@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>CurseForge</title><path fill="currentColor" d="M18.326 9.2145S23.2261 8.4418 24 6.1882h-7.5066V4.4H0l2.0318 2.3576V9.173s5.1267-.2665 7.1098 1.2372c2.7146 2.516-3.053 5.917-3.053 5.917L5.0995 19.6c1.5465-1.4726 4.494-3.3775 9.8983-3.2857-2.0565.65-4.1245 1.6651-5.7344 3.2857h10.9248l-1.0288-3.2726s-7.918-4.6688-.8336-7.1127z"/></svg>
|
After Width: | Height: | Size: 414 B |
1
packages/assets/icons/file-archive.svg
Normal file
1
packages/assets/icons/file-archive.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-archive-icon lucide-file-archive"><path d="M10 12v-1"/><path d="M10 18v-2"/><path d="M10 7V6"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M15.5 22H18a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v16a2 2 0 0 0 .274 1.01"/><circle cx="10" cy="20" r="2"/></svg>
|
After Width: | Height: | Size: 458 B |
@ -15,6 +15,7 @@ import _SSOSteamIcon from './external/sso/steam.svg?component'
|
||||
import _AppleIcon from './external/apple.svg?component'
|
||||
import _BlueskyIcon from './external/bluesky.svg?component'
|
||||
import _BuyMeACoffeeIcon from './external/bmac.svg?component'
|
||||
import _CurseForgeIcon from './external/curseforge.svg?component'
|
||||
import _DiscordIcon from './external/discord.svg?component'
|
||||
import _GithubIcon from './external/github.svg?component'
|
||||
import _KoFiIcon from './external/kofi.svg?component'
|
||||
@ -74,6 +75,7 @@ import _ExternalIcon from './icons/external.svg?component'
|
||||
import _EyeIcon from './icons/eye.svg?component'
|
||||
import _EyeOffIcon from './icons/eye-off.svg?component'
|
||||
import _FileIcon from './icons/file.svg?component'
|
||||
import _FileArchiveIcon from './icons/file-archive.svg?component'
|
||||
import _FileTextIcon from './icons/file-text.svg?component'
|
||||
import _FilterIcon from './icons/filter.svg?component'
|
||||
import _FilterXIcon from './icons/filter-x.svg?component'
|
||||
@ -225,6 +227,7 @@ export const AppleIcon = _AppleIcon
|
||||
export const BlueskyIcon = _BlueskyIcon
|
||||
export const BuyMeACoffeeIcon = _BuyMeACoffeeIcon
|
||||
export const GithubIcon = _GithubIcon
|
||||
export const CurseForgeIcon = _CurseForgeIcon
|
||||
export const DiscordIcon = _DiscordIcon
|
||||
export const KoFiIcon = _KoFiIcon
|
||||
export const MastodonIcon = _MastodonIcon
|
||||
@ -281,6 +284,7 @@ export const ExternalIcon = _ExternalIcon
|
||||
export const EyeIcon = _EyeIcon
|
||||
export const EyeOffIcon = _EyeOffIcon
|
||||
export const FileIcon = _FileIcon
|
||||
export const FileArchiveIcon = _FileArchiveIcon
|
||||
export const FileTextIcon = _FileTextIcon
|
||||
export const FilterIcon = _FilterIcon
|
||||
export const FilterXIcon = _FilterXIcon
|
||||
|
@ -4,7 +4,7 @@ import { computed } from 'vue'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
size?: 'standard' | 'large'
|
||||
size?: 'standard' | 'large' | 'small'
|
||||
circular?: boolean
|
||||
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text'
|
||||
colorFill?: 'auto' | 'background' | 'text' | 'none'
|
||||
@ -67,6 +67,8 @@ const colorVar = computed(() => {
|
||||
const height = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return '3rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '1.5rem'
|
||||
}
|
||||
return '2.25rem'
|
||||
})
|
||||
@ -74,6 +76,8 @@ const height = computed(() => {
|
||||
const width = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return props.circular ? '3rem' : 'auto'
|
||||
} else if (props.size === 'small') {
|
||||
return props.circular ? '1.5rem' : 'auto'
|
||||
}
|
||||
return props.circular ? '2.25rem' : 'auto'
|
||||
})
|
||||
@ -82,6 +86,8 @@ const paddingX = computed(() => {
|
||||
let padding = props.circular ? '0.5rem' : '0.75rem'
|
||||
if (props.size === 'large') {
|
||||
padding = props.circular ? '0.75rem' : '1rem'
|
||||
} else if (props.size === 'small') {
|
||||
padding = props.circular ? '0.125rem' : '0.5rem'
|
||||
}
|
||||
return `calc(${padding} - 0.125rem)`
|
||||
})
|
||||
@ -96,6 +102,8 @@ const paddingY = computed(() => {
|
||||
const gap = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return '0.5rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '0.25rem'
|
||||
}
|
||||
return '0.375rem'
|
||||
})
|
||||
@ -114,6 +122,8 @@ const radius = computed(() => {
|
||||
|
||||
if (props.size === 'large') {
|
||||
return '1rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '0.5rem'
|
||||
}
|
||||
return '0.75rem'
|
||||
})
|
||||
@ -121,6 +131,8 @@ const radius = computed(() => {
|
||||
const iconSize = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return '1.5rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '1rem'
|
||||
}
|
||||
return '1.25rem'
|
||||
})
|
||||
@ -192,12 +204,19 @@ const colorVariables = computed(() => {
|
||||
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text};`
|
||||
})
|
||||
|
||||
const fontSize = computed(() => {
|
||||
if (props.size === 'small') {
|
||||
return 'text-sm'
|
||||
}
|
||||
return 'text-base'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="btn-wrapper"
|
||||
:class="{ outline: type === 'outlined' }"
|
||||
:class="[{ outline: type === 'outlined' }, fontSize]"
|
||||
:style="`${colorVariables}--_height:${height};--_width:${width};--_radius: ${radius};--_padding-x:${paddingX};--_padding-y:${paddingY};--_gap:${gap};--_font-weight:${fontWeight};--_icon-size:${iconSize};`"
|
||||
>
|
||||
<slot />
|
||||
|
@ -49,7 +49,10 @@ const colors = {
|
||||
const percent = computed(() => props.progress / props.max)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex w-[15rem] h-1 rounded-full overflow-hidden" :class="colors[props.color].bg">
|
||||
<div
|
||||
class="flex w-full max-w-[15rem] h-1 rounded-full overflow-hidden"
|
||||
:class="colors[props.color].bg"
|
||||
>
|
||||
<div
|
||||
class="rounded-full progress-bar"
|
||||
:class="[colors[props.color].fg, { 'progress-bar--waiting': waiting }]"
|
||||
|
Loading…
x
Reference in New Issue
Block a user