You've already forked AstralRinth
forked from didirus/AstralRinth
Add server unzipping (#3622)
* Initial unzipping feature * Remove explicit backup provider naming from frontend * CF placeholder * Use regex for CF links * Lint * Add unzip warning for conflicting files, fix hydration error * Adjust conflict modal ui * Fix old queued ops sticking around, remove conflict warning * Add vscode "editor.detectIndentation": true
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user