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:
Prospector 2025-05-07 19:08:38 -07:00 committed by GitHub
parent 1884410e0d
commit 16766be82f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1042 additions and 255 deletions

View File

@ -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
View 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

View 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

View File

@ -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>

View File

@ -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;
});

View File

@ -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;

View File

@ -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;
}>();

View File

@ -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>

View File

@ -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;

View 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>

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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>

View File

@ -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);
}

View File

@ -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

View File

@ -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>

View File

@ -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[];

View 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

View 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

View File

@ -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

View File

@ -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 />

View File

@ -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 }]"