You've already forked AstralRinth
forked from didirus/AstralRinth
Files UX Sprint (#3019)
* chore: dedupe lockfile Signed-off-by: Evan Song <theevansong@gmail.com> * fix: incorrect spacing between editing and browsing state Signed-off-by: Evan Song <theevansong@gmail.com> * chore: improve files image viewer toolbar Signed-off-by: Evan Song <theevansong@gmail.com> * chore: image viewer cursor affordance Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean imports Signed-off-by: Evan Song <theevansong@gmail.com> * chore: add tooltips Signed-off-by: Evan Song <theevansong@gmail.com> * chore: use black background Signed-off-by: Evan Song <theevansong@gmail.com> * feat: show scale factor, handle large images, consolidate state Signed-off-by: Evan Song <theevansong@gmail.com> * chore: add types to fs operations Signed-off-by: Evan Song <theevansong@gmail.com> * feat: add date create sorting option Signed-off-by: Evan Song <theevansong@gmail.com> * fix: match name of folder creation modal Signed-off-by: Evan Song <theevansong@gmail.com> * fix: add it here too Signed-off-by: Evan Song <theevansong@gmail.com> * feat: add creation date to file item, file manager header Signed-off-by: Evan Song <theevansong@gmail.com> * chore: a11y Signed-off-by: Evan Song <theevansong@gmail.com> * fix: ensure move item modal always has leading slash Signed-off-by: Evan Song <theevansong@gmail.com> * fix: correct move input placeholder Signed-off-by: Evan Song <theevansong@gmail.com> * chore: correct design disparity Signed-off-by: Evan Song <theevansong@gmail.com> * chore: add better affordance on active file item state Signed-off-by: Evan Song <theevansong@gmail.com> * fix: correct instances where we dont sentence case Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * chore: notify that server restarted on saveandrestart Signed-off-by: Evan Song <theevansong@gmail.com> * fix: consolidate error state in file manager Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust sizing Signed-off-by: Evan Song <theevansong@gmail.com> * feat: drag and drop file items to move them Signed-off-by: Evan Song <theevansong@gmail.com> * feat: enable ability to drag folders too Signed-off-by: Evan Song <theevansong@gmail.com> * feat: better file movement toasts Signed-off-by: Evan Song <theevansong@gmail.com> * just say u hate me Signed-off-by: Evan Song <theevansong@gmail.com> * feat: uploading indicator for file uploads Signed-off-by: Evan Song <theevansong@gmail.com> * chore: cleaner file item ghost when dragging Signed-off-by: Evan Song <theevansong@gmail.com> * fix: enforce max length and truncate on ghost Signed-off-by: Evan Song <theevansong@gmail.com> * chore: improve item rename toast Signed-off-by: Evan Song <theevansong@gmail.com> * chore: improve item create toast Signed-off-by: Evan Song <theevansong@gmail.com> * feat: undo and redo stack Signed-off-by: Evan Song <theevansong@gmail.com> * fix: confusing behavior where folders were not sorted alphabetically Signed-off-by: Evan Song <theevansong@gmail.com> * feat: find and replace in file editor Signed-off-by: Evan Song <theevansong@gmail.com> * feat: correctly set language mode of file editor Signed-off-by: Evan Song <theevansong@gmail.com> * chore: slop Signed-off-by: Evan Song <theevansong@gmail.com> * chore: actually handle case with multiple dots in file name before setting language Signed-off-by: Evan Song <theevansong@gmail.com> * fix: match move icons in file context/threedot Signed-off-by: Evan Song <theevansong@gmail.com> * feat: upload indicator Signed-off-by: Evan Song <theevansong@gmail.com> * chore: dedupe lockfile again Signed-off-by: Evan Song <theevansong@gmail.com> * lockfile Signed-off-by: Evan Song <theevansong@gmail.com> * fix: file undefinedness Signed-off-by: Evan Song <theevansong@gmail.com> * checkpoint Signed-off-by: Evan Song <theevansong@gmail.com> * checkpoint Signed-off-by: Evan Song <theevansong@gmail.com> * checkpoint Signed-off-by: Evan Song <theevansong@gmail.com> * remove shitty animation logic Signed-off-by: Evan Song <theevansong@gmail.com> * feat: file upload queuer Signed-off-by: Evan Song <theevansong@gmail.com> * chore: only allow editable files to have active affordance Signed-off-by: Evan Song <theevansong@gmail.com> * fix: properly throw pyrofetcherror when rename fails Signed-off-by: Evan Song <theevansong@gmail.com> * feat: cancel file uploads Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> --------- Signed-off-by: Evan Song <theevansong@gmail.com>
This commit is contained in:
@@ -2,40 +2,61 @@
|
|||||||
<li
|
<li
|
||||||
role="button"
|
role="button"
|
||||||
data-pyro-file
|
data-pyro-file
|
||||||
:class="containerClasses"
|
:class="[
|
||||||
|
containerClasses,
|
||||||
|
isDragOver && type === 'directory' ? 'bg-brand-highlight' : '',
|
||||||
|
isDragging ? 'opacity-50' : '',
|
||||||
|
]"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
draggable="true"
|
||||||
@click="selectItem"
|
@click="selectItem"
|
||||||
@contextmenu="openContextMenu"
|
@contextmenu="openContextMenu"
|
||||||
@keydown="(e) => e.key === 'Enter' && selectItem()"
|
@keydown="(e) => e.key === 'Enter' && selectItem()"
|
||||||
|
@dragstart="handleDragStart"
|
||||||
|
@dragend="handleDragEnd"
|
||||||
|
@dragenter.prevent="handleDragEnter"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragleave.prevent="handleDragLeave"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
>
|
>
|
||||||
<div data-pyro-file-metadata class="flex w-full items-center gap-4 truncate">
|
<div
|
||||||
|
data-pyro-file-metadata
|
||||||
|
class="pointer-events-none flex w-full items-center gap-4 truncate"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex size-8 items-center justify-center rounded-full bg-bg-raised p-[6px] group-hover:bg-brand-highlight group-hover:text-brand group-focus:bg-brand-highlight group-focus:text-brand"
|
class="pointer-events-none flex size-8 items-center justify-center rounded-full bg-bg-raised p-[6px] group-hover:bg-brand-highlight group-hover:text-brand group-focus:bg-brand-highlight group-focus:text-brand"
|
||||||
|
:class="isEditableFile ? 'group-active:scale-[0.8]' : ''"
|
||||||
>
|
>
|
||||||
<component :is="iconComponent" class="size-6" />
|
<component :is="iconComponent" class="size-6" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full flex-col truncate">
|
<div class="pointer-events-none flex w-full flex-col truncate">
|
||||||
<span
|
<span
|
||||||
class="w-[98%] truncate font-bold group-hover:text-contrast group-focus:text-contrast"
|
class="pointer-events-none w-[98%] truncate font-bold group-hover:text-contrast group-focus:text-contrast"
|
||||||
>{{ name }}</span
|
|
||||||
>
|
>
|
||||||
<span class="text-xs text-secondary group-hover:text-primary">
|
{{ name }}
|
||||||
|
</span>
|
||||||
|
<span class="pointer-events-none text-xs text-secondary group-hover:text-primary">
|
||||||
{{ subText }}
|
{{ subText }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
<div data-pyro-file-actions class="flex w-fit flex-shrink-0 items-center gap-4 md:gap-12">
|
data-pyro-file-actions
|
||||||
<span class="w-[160px] text-nowrap text-right font-mono text-sm text-secondary">{{
|
class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 md:gap-12"
|
||||||
formattedDate
|
>
|
||||||
}}</span>
|
<span class="hidden w-[160px] text-nowrap font-mono text-sm text-secondary md:flex">
|
||||||
|
{{ formattedCreationDate }}
|
||||||
|
</span>
|
||||||
|
<span class="w-[160px] text-nowrap font-mono text-sm text-secondary">
|
||||||
|
{{ formattedModifiedDate }}
|
||||||
|
</span>
|
||||||
<ButtonStyled circular type="transparent">
|
<ButtonStyled circular type="transparent">
|
||||||
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
|
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
|
||||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||||
<template #rename> <EditIcon /> Rename </template>
|
<template #rename><EditIcon /> Rename</template>
|
||||||
<template #move> <RightArrowIcon /> Move </template>
|
<template #move><RightArrowIcon /> Move</template>
|
||||||
<template #download> <DownloadIcon /> Download </template>
|
<template #download><DownloadIcon /> Download</template>
|
||||||
<template #delete> <TrashIcon /> Delete </template>
|
<template #delete><TrashIcon /> Delete</template>
|
||||||
</UiServersTeleportOverflowMenu>
|
</UiServersTeleportOverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,6 +75,7 @@ import {
|
|||||||
RightArrowIcon,
|
RightArrowIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { computed, shallowRef, ref } from "vue";
|
import { computed, shallowRef, ref } from "vue";
|
||||||
|
import { renderToString } from "@vue/server-renderer";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import {
|
import {
|
||||||
UiServersIconsCogFolderIcon,
|
UiServersIconsCogFolderIcon,
|
||||||
@@ -70,12 +92,27 @@ interface FileItemProps {
|
|||||||
size?: number;
|
size?: number;
|
||||||
count?: number;
|
count?: number;
|
||||||
modified: number;
|
modified: number;
|
||||||
|
created: number;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<FileItemProps>();
|
const props = defineProps<FileItemProps>();
|
||||||
|
|
||||||
const emit = defineEmits(["rename", "download", "delete", "move", "edit", "contextmenu"]);
|
const emit = defineEmits<{
|
||||||
|
(e: "rename", item: { name: string; type: string; path: string }): void;
|
||||||
|
(e: "move", 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;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isDragOver = ref(false);
|
||||||
|
const isDragging = ref(false);
|
||||||
|
|
||||||
const codeExtensions = Object.freeze([
|
const codeExtensions = Object.freeze([
|
||||||
"json",
|
"json",
|
||||||
@@ -114,6 +151,7 @@ const router = useRouter();
|
|||||||
const containerClasses = computed(() => [
|
const containerClasses = computed(() => [
|
||||||
"group m-0 p-0 focus:!outline-none flex w-full select-none items-center justify-between overflow-hidden border-0 border-b border-solid border-bg-raised p-3 last:border-none hover:bg-bg-raised focus:bg-bg-raised",
|
"group m-0 p-0 focus:!outline-none flex w-full select-none items-center justify-between overflow-hidden border-0 border-b border-solid border-bg-raised p-3 last:border-none hover:bg-bg-raised focus:bg-bg-raised",
|
||||||
isEditableFile.value ? "cursor-pointer" : props.type === "directory" ? "cursor-pointer" : "",
|
isEditableFile.value ? "cursor-pointer" : props.type === "directory" ? "cursor-pointer" : "",
|
||||||
|
isDragOver.value ? "bg-brand-highlight" : "",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
|
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
|
||||||
@@ -161,7 +199,7 @@ const subText = computed(() => {
|
|||||||
return formattedSize.value;
|
return formattedSize.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const formattedDate = computed(() => {
|
const formattedModifiedDate = computed(() => {
|
||||||
const date = new Date(props.modified * 1000);
|
const date = new Date(props.modified * 1000);
|
||||||
return `${date.toLocaleDateString("en-US", {
|
return `${date.toLocaleDateString("en-US", {
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
@@ -174,6 +212,19 @@ const formattedDate = computed(() => {
|
|||||||
})}`;
|
})}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const formattedCreationDate = computed(() => {
|
||||||
|
const date = new Date(props.created * 1000);
|
||||||
|
return `${date.toLocaleDateString("en-US", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
year: "2-digit",
|
||||||
|
})}, ${date.toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
hour12: true,
|
||||||
|
})}`;
|
||||||
|
});
|
||||||
|
|
||||||
const isEditableFile = computed(() => {
|
const isEditableFile = computed(() => {
|
||||||
if (props.type === "file") {
|
if (props.type === "file") {
|
||||||
const ext = fileExtension.value;
|
const ext = fileExtension.value;
|
||||||
@@ -226,4 +277,121 @@ const selectItem = () => {
|
|||||||
isNavigating.value = false;
|
isNavigating.value = false;
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDragIcon = async () => {
|
||||||
|
let iconToUse;
|
||||||
|
|
||||||
|
if (props.type === "directory") {
|
||||||
|
if (props.name === "config") {
|
||||||
|
iconToUse = UiServersIconsCogFolderIcon;
|
||||||
|
} else if (props.name === "world") {
|
||||||
|
iconToUse = UiServersIconsEarthIcon;
|
||||||
|
} else if (props.name === "resourcepacks") {
|
||||||
|
iconToUse = PaletteIcon;
|
||||||
|
} else {
|
||||||
|
iconToUse = FolderOpenIcon;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const ext = fileExtension.value;
|
||||||
|
if (codeExtensions.includes(ext)) {
|
||||||
|
iconToUse = UiServersIconsCodeFileIcon;
|
||||||
|
} else if (textExtensions.includes(ext)) {
|
||||||
|
iconToUse = UiServersIconsTextFileIcon;
|
||||||
|
} else if (imageExtensions.includes(ext)) {
|
||||||
|
iconToUse = UiServersIconsImageFileIcon;
|
||||||
|
} else {
|
||||||
|
iconToUse = FileIcon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await renderToString(h(iconToUse));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = async (event: DragEvent) => {
|
||||||
|
if (!event.dataTransfer) return;
|
||||||
|
isDragging.value = true;
|
||||||
|
|
||||||
|
const dragGhost = document.createElement("div");
|
||||||
|
dragGhost.className =
|
||||||
|
"fixed left-0 top-0 flex items-center max-w-[500px] flex-row gap-3 rounded-lg bg-bg-raised p-3 shadow-lg pointer-events-none";
|
||||||
|
|
||||||
|
const iconContainer = document.createElement("div");
|
||||||
|
iconContainer.className = "flex size-6 items-center justify-center";
|
||||||
|
|
||||||
|
const icon = document.createElement("div");
|
||||||
|
icon.className = "size-4";
|
||||||
|
icon.innerHTML = await getDragIcon();
|
||||||
|
iconContainer.appendChild(icon);
|
||||||
|
|
||||||
|
const nameSpan = document.createElement("span");
|
||||||
|
nameSpan.className = "font-bold truncate text-contrast";
|
||||||
|
nameSpan.textContent = props.name;
|
||||||
|
|
||||||
|
dragGhost.appendChild(iconContainer);
|
||||||
|
dragGhost.appendChild(nameSpan);
|
||||||
|
document.body.appendChild(dragGhost);
|
||||||
|
|
||||||
|
event.dataTransfer.setDragImage(dragGhost, 0, 0);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.body.removeChild(dragGhost);
|
||||||
|
});
|
||||||
|
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
"application/pyro-file-move",
|
||||||
|
JSON.stringify({
|
||||||
|
name: props.name,
|
||||||
|
type: props.type,
|
||||||
|
path: props.path,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isChildPath = (parentPath: string, childPath: string) => {
|
||||||
|
return childPath.startsWith(parentPath + "/");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
isDragging.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnter = () => {
|
||||||
|
if (props.type !== "directory") return;
|
||||||
|
isDragOver.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent) => {
|
||||||
|
if (props.type !== "directory" || !event.dataTransfer) return;
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
isDragOver.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: DragEvent) => {
|
||||||
|
isDragOver.value = false;
|
||||||
|
if (props.type !== "directory" || !event.dataTransfer) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dragData = JSON.parse(event.dataTransfer.getData("application/pyro-file-move"));
|
||||||
|
|
||||||
|
if (dragData.path === props.path) return;
|
||||||
|
|
||||||
|
if (dragData.type === "directory" && isChildPath(dragData.path, props.path)) {
|
||||||
|
console.error("Cannot move a folder into its own subfolder");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("moveDirectTo", {
|
||||||
|
name: dragData.name,
|
||||||
|
type: dragData.type,
|
||||||
|
path: dragData.path,
|
||||||
|
destination: props.path,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error handling file drop:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
@rename="$emit('rename', item)"
|
@rename="$emit('rename', item)"
|
||||||
@download="$emit('download', item)"
|
@download="$emit('download', item)"
|
||||||
@move="$emit('move', item)"
|
@move="$emit('move', item)"
|
||||||
|
@move-direct-to="$emit('moveDirectTo', $event)"
|
||||||
@edit="$emit('edit', item)"
|
@edit="$emit('edit', item)"
|
||||||
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
|
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
|
||||||
/>
|
/>
|
||||||
@@ -55,6 +56,7 @@ const emit = defineEmits<{
|
|||||||
(e: "edit", item: any): void;
|
(e: "edit", item: any): void;
|
||||||
(e: "contextmenu", item: any, x: number, y: number): void;
|
(e: "contextmenu", item: any, x: number, y: number): void;
|
||||||
(e: "loadMore"): void;
|
(e: "loadMore"): void;
|
||||||
|
(e: "moveDirectTo", item: any): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const ITEM_HEIGHT = 61;
|
const ITEM_HEIGHT = 61;
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
:options="[
|
:options="[
|
||||||
{ id: 'normal', action: () => $emit('sort', 'default') },
|
{ id: 'normal', action: () => $emit('sort', 'default') },
|
||||||
{ id: 'modified', action: () => $emit('sort', 'modified') },
|
{ id: 'modified', action: () => $emit('sort', 'modified') },
|
||||||
|
{ id: 'created', action: () => $emit('sort', 'created') },
|
||||||
{ id: 'filesOnly', action: () => $emit('sort', 'filesOnly') },
|
{ id: 'filesOnly', action: () => $emit('sort', 'filesOnly') },
|
||||||
{ id: 'foldersOnly', action: () => $emit('sort', 'foldersOnly') },
|
{ id: 'foldersOnly', action: () => $emit('sort', 'foldersOnly') },
|
||||||
]"
|
]"
|
||||||
@@ -91,11 +92,12 @@
|
|||||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||||
<template #normal> Alphabetical </template>
|
<template #normal> Alphabetical </template>
|
||||||
<template #modified> Date modified </template>
|
<template #modified> Date modified </template>
|
||||||
|
<template #created> Date created </template>
|
||||||
<template #filesOnly> Files only </template>
|
<template #filesOnly> Files only </template>
|
||||||
<template #foldersOnly> Folders only </template>
|
<template #foldersOnly> Folders only </template>
|
||||||
</UiServersTeleportOverflowMenu>
|
</UiServersTeleportOverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<div class="mx-1 w-full text-sm sm:w-40">
|
<div class="mx-1 w-full text-sm sm:w-48">
|
||||||
<label for="search-folder" class="sr-only">Search folder</label>
|
<label for="search-folder" class="sr-only">Search folder</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<SearchIcon
|
<SearchIcon
|
||||||
@@ -183,6 +185,8 @@ const sortMethodLabel = computed(() => {
|
|||||||
switch (props.sortMethod) {
|
switch (props.sortMethod) {
|
||||||
case "modified":
|
case "modified":
|
||||||
return "Date modified";
|
return "Date modified";
|
||||||
|
case "created":
|
||||||
|
return "Date created";
|
||||||
case "filesOnly":
|
case "filesOnly":
|
||||||
return "Files only";
|
return "Files only";
|
||||||
case "foldersOnly":
|
case "foldersOnly":
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
id="item-context-menu"
|
id="item-context-menu"
|
||||||
ref="ctxRef"
|
ref="ctxRef"
|
||||||
:style="{
|
:style="{
|
||||||
border: '1px solid var(--color-button-bg)',
|
border: '1px solid var(--color-divider)',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-lg)',
|
||||||
backgroundColor: 'var(--color-raised-bg)',
|
backgroundColor: 'var(--color-raised-bg)',
|
||||||
padding: 'var(--gap-sm)',
|
padding: 'var(--gap-sm)',
|
||||||
boxShadow: 'var(--shadow-floating)',
|
boxShadow: 'var(--shadow-floating)',
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
Rename
|
Rename
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-transparent flex !w-full items-center" @click="$emit('move', item)">
|
<button class="btn btn-transparent flex !w-full items-center" @click="$emit('move', item)">
|
||||||
<ArrowBigUpDashIcon class="h-5 w-5" />
|
<RightArrowIcon />
|
||||||
Move
|
Move
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { EditIcon, ArrowBigUpDashIcon, DownloadIcon, TrashIcon } from "@modrinth/assets";
|
import { EditIcon, DownloadIcon, TrashIcon, RightArrowIcon } from "@modrinth/assets";
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<NewModal ref="modal" :header="`Creating a ${type}`">
|
<NewModal ref="modal" :header="`Creating a ${displayType}`">
|
||||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="font-semibold text-contrast">Name</div>
|
<div class="font-semibold text-contrast">Name</div>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button :disabled="!!error" type="submit">
|
<button :disabled="!!error" type="submit">
|
||||||
<PlusIcon class="h-5 w-5" />
|
<PlusIcon class="h-5 w-5" />
|
||||||
Create
|
Create {{ displayType }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled>
|
<ButtonStyled>
|
||||||
@@ -46,6 +46,7 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const modal = ref<typeof NewModal>();
|
const modal = ref<typeof NewModal>();
|
||||||
|
const displayType = computed(() => (props.type === "directory" ? "folder" : props.type));
|
||||||
const createInput = ref<HTMLInputElement | null>(null);
|
const createInput = ref<HTMLInputElement | null>(null);
|
||||||
const itemName = ref("");
|
const itemName = ref("");
|
||||||
const submitted = ref(false);
|
const submitted = ref(false);
|
||||||
|
|||||||
@@ -6,15 +6,15 @@
|
|||||||
>
|
>
|
||||||
<nav
|
<nav
|
||||||
aria-label="Breadcrumb navigation"
|
aria-label="Breadcrumb navigation"
|
||||||
class="m-0 flex list-none items-center p-0 text-contrast"
|
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
|
||||||
>
|
>
|
||||||
<ol class="m-0 flex list-none items-center p-0">
|
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
|
||||||
<li class="-ml-1">
|
<li class="-ml-1 flex-shrink-0">
|
||||||
<ButtonStyled type="transparent">
|
<ButtonStyled type="transparent">
|
||||||
<button
|
<button
|
||||||
v-tooltip="'Back to home'"
|
v-tooltip="'Back to home'"
|
||||||
type="button"
|
type="button"
|
||||||
class="grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
class="mr-2 grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||||
@click="goHome"
|
@click="goHome"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,159 +1,178 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center bg-bg-raised">
|
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center">
|
||||||
<div
|
<div
|
||||||
ref="container"
|
ref="container"
|
||||||
class="relative w-full flex-grow overflow-hidden bg-bg-raised"
|
class="relative w-full flex-grow cursor-grab overflow-hidden rounded-b-2xl bg-black active:cursor-grabbing"
|
||||||
@mousedown="startPan"
|
@mousedown="startPan"
|
||||||
@mousemove="pan"
|
@mousemove="handlePan"
|
||||||
@mouseup="endPan"
|
@mouseup="stopPan"
|
||||||
@mouseleave="endPan"
|
@mouseleave="stopPan"
|
||||||
@wheel.prevent="handleWheel"
|
@wheel.prevent="handleWheel"
|
||||||
>
|
>
|
||||||
<UiServersPyroLoading v-if="loading" />
|
<UiServersPyroLoading v-if="state.isLoading" />
|
||||||
<div v-if="error" class="flex h-full w-full flex-col items-center justify-center gap-8">
|
<div
|
||||||
<svg
|
v-if="state.hasError"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
class="flex h-full w-full flex-col items-center justify-center gap-8"
|
||||||
width="24"
|
>
|
||||||
height="24"
|
<UiServersIconsPanelErrorIcon />
|
||||||
viewBox="0 0 24 24"
|
<p class="m-0">{{ state.errorMessage || "Invalid or empty image file." }}</p>
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="size-12"
|
|
||||||
>
|
|
||||||
<path d="M4 13c3.5-2 8-2 10 2a5.5 5.5 0 0 1 8 5" />
|
|
||||||
<path
|
|
||||||
d="M5.15 17.89c5.52-1.52 8.65-6.89 7-12C11.55 4 11.5 2 13 2c3.22 0 5 5.5 5 8 0 6.5-4.2 12-10.49 12C5.11 22 2 22 2 20c0-1.5 1.14-1.55 3.15-2.11Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p class="m-0">Invalid or empty image file.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
v-show="!loading && !error"
|
v-show="isReady"
|
||||||
ref="image"
|
ref="imageRef"
|
||||||
:src="imageUrl"
|
:src="imageObjectUrl"
|
||||||
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
|
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
|
||||||
:style="{
|
:style="imageStyle"
|
||||||
transform: `translate(-50%, -50%) scale(${scale}) translate(${translateX}px, ${translateY}px)`,
|
|
||||||
transition: isPanning ? 'none' : 'transform 0.3s ease-out',
|
|
||||||
}"
|
|
||||||
alt="Viewed image"
|
alt="Viewed image"
|
||||||
@load="onImageLoad"
|
@load="handleImageLoad"
|
||||||
@error="onImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="!error"
|
v-if="!state.hasError"
|
||||||
class="absolute bottom-0 mb-2 flex w-fit justify-center space-x-4 rounded-xl bg-bg p-2"
|
class="absolute bottom-0 mb-2 flex w-fit justify-center gap-2 space-x-4 rounded-2xl bg-bg p-2"
|
||||||
>
|
>
|
||||||
<Button icon-only transparent @click="zoomIn">
|
<ButtonStyled type="transparent" @click="zoom(ZOOM_IN_FACTOR)">
|
||||||
<ZoomInIcon />
|
<button v-tooltip="'Zoom in'">
|
||||||
</Button>
|
<ZoomInIcon />
|
||||||
<Button icon-only transparent @click="resetZoom">
|
</button>
|
||||||
<HomeIcon />
|
</ButtonStyled>
|
||||||
</Button>
|
<ButtonStyled type="transparent" @click="zoom(ZOOM_OUT_FACTOR)">
|
||||||
<Button icon-only transparent @click="zoomOut">
|
<button v-tooltip="'Zoom out'">
|
||||||
<ZoomOutIcon />
|
<ZoomOutIcon />
|
||||||
</Button>
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled type="transparent" @click="reset">
|
||||||
|
<button>
|
||||||
|
<span class="font-mono">{{ Math.round(state.scale * 100) }}%</span>
|
||||||
|
<span class="ml-4 text-sm text-blue">Reset</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from "vue";
|
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||||
import { HomeIcon, ZoomInIcon, ZoomOutIcon } from "@modrinth/assets";
|
import { ZoomInIcon, ZoomOutIcon } from "@modrinth/assets";
|
||||||
import { Button } from "@modrinth/ui";
|
import { ButtonStyled } from "@modrinth/ui";
|
||||||
|
|
||||||
const props = defineProps({
|
const ZOOM_MIN = 0.1;
|
||||||
imageBlob: {
|
const ZOOM_MAX = 5;
|
||||||
type: Blob,
|
const ZOOM_IN_FACTOR = 1.2;
|
||||||
required: true,
|
const ZOOM_OUT_FACTOR = 0.8;
|
||||||
},
|
const INITIAL_SCALE = 0.5;
|
||||||
|
const MAX_IMAGE_DIMENSION = 4096;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
imageBlob: Blob;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const state = ref({
|
||||||
|
scale: INITIAL_SCALE,
|
||||||
|
translateX: 0,
|
||||||
|
translateY: 0,
|
||||||
|
isPanning: false,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
isLoading: false,
|
||||||
|
hasError: false,
|
||||||
|
errorMessage: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const container = ref(null);
|
const imageRef = ref<HTMLImageElement | null>(null);
|
||||||
const image = ref(null);
|
const container = ref<HTMLElement | null>(null);
|
||||||
const scale = ref(1);
|
const imageObjectUrl = ref("");
|
||||||
const translateX = ref(0);
|
const rafId = ref(0);
|
||||||
const translateY = ref(0);
|
|
||||||
const isPanning = ref(false);
|
|
||||||
const startX = ref(0);
|
|
||||||
const startY = ref(0);
|
|
||||||
const imageUrl = ref("");
|
|
||||||
const loading = ref(true);
|
|
||||||
const error = ref(false);
|
|
||||||
|
|
||||||
const createImageUrl = (blob) => {
|
const isReady = computed(() => !state.value.isLoading && !state.value.hasError);
|
||||||
if (imageUrl.value) {
|
|
||||||
URL.revokeObjectURL(imageUrl.value);
|
const imageStyle = computed(() => ({
|
||||||
|
transform: `translate(-50%, -50%) scale(${state.value.scale}) translate(${state.value.translateX}px, ${state.value.translateY}px)`,
|
||||||
|
transition: state.value.isPanning ? "none" : "transform 0.3s ease-out",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const validateImageDimensions = (img: HTMLImageElement): boolean => {
|
||||||
|
if (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION) {
|
||||||
|
state.value.hasError = true;
|
||||||
|
state.value.errorMessage = `Image too large to view (max ${MAX_IMAGE_DIMENSION}x${MAX_IMAGE_DIMENSION} pixels)`;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
imageUrl.value = URL.createObjectURL(blob);
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateImageUrl = (blob: Blob) => {
|
||||||
|
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value);
|
||||||
|
imageObjectUrl.value = URL.createObjectURL(blob);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageLoad = () => {
|
||||||
|
if (!imageRef.value || !validateImageDimensions(imageRef.value)) {
|
||||||
|
state.value.isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.value.isLoading = false;
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
state.value.isLoading = false;
|
||||||
|
state.value.hasError = true;
|
||||||
|
state.value.errorMessage = "Failed to load image";
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoom = (factor: number) => {
|
||||||
|
const newScale = state.value.scale * factor;
|
||||||
|
state.value.scale = Math.max(ZOOM_MIN, Math.min(newScale, ZOOM_MAX));
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
state.value.scale = INITIAL_SCALE;
|
||||||
|
state.value.translateX = 0;
|
||||||
|
state.value.translateY = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPan = (e: MouseEvent) => {
|
||||||
|
state.value.isPanning = true;
|
||||||
|
state.value.startX = e.clientX - state.value.translateX;
|
||||||
|
state.value.startY = e.clientY - state.value.translateY;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePan = (e: MouseEvent) => {
|
||||||
|
if (!state.value.isPanning) return;
|
||||||
|
cancelAnimationFrame(rafId.value);
|
||||||
|
rafId.value = requestAnimationFrame(() => {
|
||||||
|
state.value.translateX = e.clientX - state.value.startX;
|
||||||
|
state.value.translateY = e.clientY - state.value.startY;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPan = () => {
|
||||||
|
state.value.isPanning = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (e: WheelEvent) => {
|
||||||
|
const delta = e.deltaY * -0.001;
|
||||||
|
const factor = 1 + delta;
|
||||||
|
zoom(factor);
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.imageBlob,
|
() => props.imageBlob,
|
||||||
(newBlob) => {
|
(newBlob) => {
|
||||||
if (newBlob) {
|
if (!newBlob) return;
|
||||||
loading.value = true;
|
state.value.isLoading = true;
|
||||||
error.value = false;
|
state.value.hasError = false;
|
||||||
createImageUrl(newBlob);
|
updateImageUrl(newBlob);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.imageBlob) {
|
if (props.imageBlob) updateImageUrl(props.imageBlob);
|
||||||
createImageUrl(props.imageBlob);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onImageLoad = () => {
|
onUnmounted(() => {
|
||||||
loading.value = false;
|
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value);
|
||||||
resetZoom();
|
cancelAnimationFrame(rafId.value);
|
||||||
};
|
});
|
||||||
|
|
||||||
const onImageError = () => {
|
|
||||||
loading.value = false;
|
|
||||||
error.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoomIn = () => {
|
|
||||||
scale.value = Math.min(scale.value * 1.2, 5);
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoomOut = () => {
|
|
||||||
scale.value = Math.max(scale.value / 1.2, 0.1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetZoom = () => {
|
|
||||||
scale.value = 0.5;
|
|
||||||
translateX.value = 0;
|
|
||||||
translateY.value = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const startPan = (e) => {
|
|
||||||
isPanning.value = true;
|
|
||||||
startX.value = e.clientX - translateX.value;
|
|
||||||
startY.value = e.clientY - translateY.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pan = (e) => {
|
|
||||||
if (isPanning.value) {
|
|
||||||
translateX.value = e.clientX - startX.value;
|
|
||||||
translateY.value = e.clientY - startY.value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const endPan = () => {
|
|
||||||
isPanning.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWheel = (e) => {
|
|
||||||
const delta = (e.deltaY * -0.01) / 10;
|
|
||||||
const newScale = Math.max(0.1, Math.min(scale.value + delta, 5));
|
|
||||||
|
|
||||||
scale.value = newScale;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
14
apps/frontend/src/components/ui/servers/FilesLabelBar.vue
Normal file
14
apps/frontend/src/components/ui/servers/FilesLabelBar.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised px-3 py-2 text-xs font-bold uppercase"
|
||||||
|
>
|
||||||
|
<div class="min-w-[48px]"></div>
|
||||||
|
<span class="flex w-full">Name</span>
|
||||||
|
<div class="flex shrink-0 gap-4 text-right md:gap-12">
|
||||||
|
<span class="hidden min-w-[160px] md:block">Created</span>
|
||||||
|
<span class="mr-4 min-w-[160px]">Modified</span>
|
||||||
|
<div class="min-w-[36px]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
autofocus
|
autofocus
|
||||||
type="text"
|
type="text"
|
||||||
class="bg-bg-input w-full rounded-lg p-4"
|
class="bg-bg-input w-full rounded-lg p-4"
|
||||||
placeholder="e.g. mods/modname"
|
placeholder="e.g. /mods/modname"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowBigUpDashIcon, XIcon } from "@modrinth/assets";
|
import { ArrowBigUpDashIcon, XIcon } from "@modrinth/assets";
|
||||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||||
import { ref, nextTick } from "vue";
|
import { ref, nextTick, computed } from "vue";
|
||||||
|
|
||||||
const destinationInput = ref<HTMLInputElement | null>(null);
|
const destinationInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
@@ -55,11 +55,12 @@ const emit = defineEmits<{
|
|||||||
const modal = ref<typeof NewModal>();
|
const modal = ref<typeof NewModal>();
|
||||||
const destination = ref("");
|
const destination = ref("");
|
||||||
const newpath = computed(() => {
|
const newpath = computed(() => {
|
||||||
return destination.value.replace("//", "/");
|
const path = destination.value.replace("//", "/");
|
||||||
|
return path.startsWith("/") ? path : `/${path}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
emit("move", destination.value);
|
emit("move", newpath.value);
|
||||||
hide();
|
hide();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="subdomain"
|
v-if="subdomain"
|
||||||
v-tooltip="'Copy subdomain'"
|
v-tooltip="'Copy custom URL'"
|
||||||
class="flex min-w-0 flex-row items-center gap-4 truncate hover:cursor-pointer"
|
class="flex min-w-0 flex-row items-center gap-4 truncate hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
|
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
|
||||||
|
|||||||
@@ -226,6 +226,21 @@ interface JWTAuth {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DirectoryItem {
|
||||||
|
name: string;
|
||||||
|
type: "directory" | "file";
|
||||||
|
count?: number;
|
||||||
|
modified: number;
|
||||||
|
created: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectoryResponse {
|
||||||
|
items: DirectoryItem[];
|
||||||
|
total: number;
|
||||||
|
current?: number;
|
||||||
|
}
|
||||||
|
|
||||||
type ContentType = "Mod" | "Plugin";
|
type ContentType = "Mod" | "Plugin";
|
||||||
|
|
||||||
const constructServerProperties = (properties: any): string => {
|
const constructServerProperties = (properties: any): string => {
|
||||||
@@ -738,6 +753,8 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
|
|||||||
await internalServerRefrence.value.refresh(["fs"]);
|
await internalServerRefrence.value.refresh(["fs"]);
|
||||||
return await requestFn();
|
return await requestFn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -763,21 +780,74 @@ const createFileOrFolder = (path: string, type: "file" | "directory") => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const uploadFile = (path: string, file: File) => {
|
const uploadFile = (path: string, file: File) => {
|
||||||
|
// eslint-disable-next-line require-await
|
||||||
return retryWithAuth(async () => {
|
return retryWithAuth(async () => {
|
||||||
const encodedPath = encodeURIComponent(path);
|
const encodedPath = encodeURIComponent(path);
|
||||||
return await PyroFetch(`/create?path=${encodedPath}&type=file`, {
|
const progressSubject = new EventTarget();
|
||||||
method: "POST",
|
const abortController = new AbortController();
|
||||||
contentType: "application/octet-stream",
|
|
||||||
body: file,
|
const uploadPromise = new Promise((resolve, reject) => {
|
||||||
override: internalServerRefrence.value.fs.auth,
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.upload.addEventListener("progress", (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const progress = (e.loaded / e.total) * 100;
|
||||||
|
progressSubject.dispatchEvent(
|
||||||
|
new CustomEvent("progress", {
|
||||||
|
detail: {
|
||||||
|
loaded: e.loaded,
|
||||||
|
total: e.total,
|
||||||
|
progress,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve(xhr.response);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new Error("Upload failed"));
|
||||||
|
xhr.onabort = () => reject(new Error("Upload cancelled"));
|
||||||
|
|
||||||
|
xhr.open(
|
||||||
|
"POST",
|
||||||
|
`https://${internalServerRefrence.value.fs.auth.url}/create?path=${encodedPath}&type=file`,
|
||||||
|
);
|
||||||
|
xhr.setRequestHeader("Authorization", `Bearer ${internalServerRefrence.value.fs.auth.token}`);
|
||||||
|
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||||
|
xhr.send(file);
|
||||||
|
|
||||||
|
abortController.signal.addEventListener("abort", () => {
|
||||||
|
xhr.abort();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
promise: uploadPromise,
|
||||||
|
onProgress: (
|
||||||
|
callback: (progress: { loaded: number; total: number; progress: number }) => void,
|
||||||
|
) => {
|
||||||
|
progressSubject.addEventListener("progress", ((e: CustomEvent) => {
|
||||||
|
callback(e.detail);
|
||||||
|
}) as EventListener);
|
||||||
|
},
|
||||||
|
cancel: () => {
|
||||||
|
abortController.abort();
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renameFileOrFolder = (path: string, name: string) => {
|
const renameFileOrFolder = (path: string, name: string) => {
|
||||||
const pathName = path.split("/").slice(0, -1).join("/") + "/" + name;
|
const pathName = path.split("/").slice(0, -1).join("/") + "/" + name;
|
||||||
return retryWithAuth(async () => {
|
return retryWithAuth(async () => {
|
||||||
return await PyroFetch(`/move`, {
|
await PyroFetch(`/move`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
override: internalServerRefrence.value.fs.auth,
|
override: internalServerRefrence.value.fs.auth,
|
||||||
body: {
|
body: {
|
||||||
@@ -785,6 +855,7 @@ const renameFileOrFolder = (path: string, name: string) => {
|
|||||||
destination: pathName,
|
destination: pathName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1235,7 +1306,7 @@ type FSFunctions = {
|
|||||||
* @param pageSize - The page size to list.
|
* @param pageSize - The page size to list.
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
listDirContents: (path: string, page: number, pageSize: number) => Promise<any>;
|
listDirContents: (path: string, page: number, pageSize: number) => Promise<DirectoryResponse>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param path - The path to create the file or folder at.
|
* @param path - The path to create the file or folder at.
|
||||||
|
|||||||
@@ -596,17 +596,22 @@ const handleInstallationResult = async (data: WSInstallationResultEvent) => {
|
|||||||
errorMessage.value = data.reason ?? "Unknown error";
|
errorMessage.value = data.reason ?? "Unknown error";
|
||||||
error.value = new Error(data.reason ?? "Unknown error");
|
error.value = new Error(data.reason ?? "Unknown error");
|
||||||
let files = await server.fs?.listDirContents("/", 1, 100);
|
let files = await server.fs?.listDirContents("/", 1, 100);
|
||||||
if (files.total > 1) {
|
if (files) {
|
||||||
for (let i = 1; i < files.total; i++) {
|
if (files.total > 1) {
|
||||||
files = await server.fs?.listDirContents("/", i, 100);
|
for (let i = 1; i < files.total; i++) {
|
||||||
if (files.items?.length === 0) break;
|
const nextFiles = await server.fs?.listDirContents("/", i, 100);
|
||||||
|
if (nextFiles?.items?.length === 0) break;
|
||||||
|
if (nextFiles) files = nextFiles;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const fileName = await files.items?.find((file: { name: string }) =>
|
const fileName = files?.items?.find((file: { name: string }) =>
|
||||||
file.name.startsWith("modrinth-installation"),
|
file.name.startsWith("modrinth-installation"),
|
||||||
)?.name;
|
)?.name;
|
||||||
errorLogFile.value = fileName;
|
errorLogFile.value = fileName ?? "";
|
||||||
errorLog.value = await server.fs?.downloadFile(fileName);
|
if (fileName) {
|
||||||
|
errorLog.value = await server.fs?.downloadFile(fileName);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
type="search"
|
type="search"
|
||||||
name="search"
|
name="search"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:placeholder="`Search ${type}s...`"
|
:placeholder="`Search ${type.toLocaleLowerCase()}s...`"
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<FilterIcon aria-hidden="true" />
|
<FilterIcon aria-hidden="true" />
|
||||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||||
<template #all> All {{ type }}s </template>
|
<template #all> All {{ type.toLocaleLowerCase() }}s </template>
|
||||||
<template #enabled> Only enabled </template>
|
<template #enabled> Only enabled </template>
|
||||||
<template #disabled> Only disabled </template>
|
<template #disabled> Only disabled </template>
|
||||||
</UiServersTeleportOverflowMenu>
|
</UiServersTeleportOverflowMenu>
|
||||||
@@ -238,8 +238,10 @@
|
|||||||
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||||
>
|
>
|
||||||
<PackageClosedIcon class="size-24" />
|
<PackageClosedIcon class="size-24" />
|
||||||
<p class="m-0 font-bold text-contrast">No {{ type }}s found!</p>
|
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
|
||||||
<p class="m-0">Add some {{ type }}s to your server to manage them here.</p>
|
<p class="m-0">
|
||||||
|
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
|
||||||
|
</p>
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<NuxtLink :to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`">
|
<NuxtLink :to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
@@ -333,7 +335,7 @@ const filterMethodLabel = computed(() => {
|
|||||||
case "enabled":
|
case "enabled":
|
||||||
return "Only enabled";
|
return "Only enabled";
|
||||||
default:
|
default:
|
||||||
return `All ${type.value}s`;
|
return `All ${type.value.toLocaleLowerCase()}s`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,17 +33,106 @@
|
|||||||
@drop.prevent="handleDrop"
|
@drop.prevent="handleDrop"
|
||||||
>
|
>
|
||||||
<div ref="mainContent" class="relative isolate flex w-full flex-col">
|
<div ref="mainContent" class="relative isolate flex w-full flex-col">
|
||||||
<UiServersFilesBrowseNavbar
|
<div v-if="!isEditing" class="contents">
|
||||||
v-if="!isEditing"
|
<UiServersFilesBrowseNavbar
|
||||||
:breadcrumb-segments="breadcrumbSegments"
|
:breadcrumb-segments="breadcrumbSegments"
|
||||||
:search-query="searchQuery"
|
:search-query="searchQuery"
|
||||||
:sort-method="sortMethod"
|
:sort-method="sortMethod"
|
||||||
@navigate="navigateToSegment"
|
@navigate="navigateToSegment"
|
||||||
@sort="sortFiles"
|
@sort="sortFiles"
|
||||||
@create="showCreateModal"
|
@create="showCreateModal"
|
||||||
@upload="initiateFileUpload"
|
@upload="initiateFileUpload"
|
||||||
@update:search-query="searchQuery = $event"
|
@update:search-query="searchQuery = $event"
|
||||||
/>
|
/>
|
||||||
|
<Transition
|
||||||
|
name="upload-status"
|
||||||
|
@enter="onUploadStatusEnter"
|
||||||
|
@leave="onUploadStatusLeave"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isUploading"
|
||||||
|
ref="uploadStatusRef"
|
||||||
|
class="upload-status rounded-b-xl border-0 border-t border-solid border-bg bg-table-alternateRow text-contrast"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col p-4 text-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 font-bold">
|
||||||
|
<FolderOpenIcon class="size-4" />
|
||||||
|
<span>
|
||||||
|
File Uploads{{
|
||||||
|
activeUploads.length > 0 ? ` - ${activeUploads.length} left` : ""
|
||||||
|
}}
|
||||||
|
</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'"
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
<UiServersFilesEditingNavbar
|
<UiServersFilesEditingNavbar
|
||||||
v-else
|
v-else
|
||||||
@@ -64,7 +153,20 @@
|
|||||||
:is="VAceEditor"
|
:is="VAceEditor"
|
||||||
v-if="!isEditingImage"
|
v-if="!isEditingImage"
|
||||||
v-model:value="fileContent"
|
v-model:value="fileContent"
|
||||||
lang="json"
|
:lang="
|
||||||
|
(() => {
|
||||||
|
const ext = editingFile?.name?.split('.')?.pop()?.toLowerCase() ?? '';
|
||||||
|
return ext === 'json'
|
||||||
|
? 'json'
|
||||||
|
: ext === 'toml'
|
||||||
|
? 'toml'
|
||||||
|
: ext === 'sh'
|
||||||
|
? 'sh'
|
||||||
|
: ['yml', 'yaml'].includes(ext)
|
||||||
|
? 'yaml'
|
||||||
|
: 'text';
|
||||||
|
})()
|
||||||
|
"
|
||||||
theme="one_dark"
|
theme="one_dark"
|
||||||
:print-margin="false"
|
:print-margin="false"
|
||||||
style="height: 750px; font-size: 1rem"
|
style="height: 750px; font-size: 1rem"
|
||||||
@@ -73,13 +175,16 @@
|
|||||||
/>
|
/>
|
||||||
<UiServersFilesImageViewer v-else :image-blob="imagePreview" />
|
<UiServersFilesImageViewer v-else :image-blob="imagePreview" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
|
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
|
||||||
|
<UiServersFilesLabelBar />
|
||||||
<UiServersFileVirtualList
|
<UiServersFileVirtualList
|
||||||
:items="filteredItems"
|
:items="filteredItems"
|
||||||
@delete="showDeleteModal"
|
@delete="showDeleteModal"
|
||||||
@rename="showRenameModal"
|
@rename="showRenameModal"
|
||||||
@download="downloadFile"
|
@download="downloadFile"
|
||||||
@move="showMoveModal"
|
@move="showMoveModal"
|
||||||
|
@move-direct-to="handleDirectMove"
|
||||||
@edit="editFile"
|
@edit="editFile"
|
||||||
@contextmenu="showContextMenu"
|
@contextmenu="showContextMenu"
|
||||||
@load-more="handleLoadMore"
|
@load-more="handleLoadMore"
|
||||||
@@ -87,35 +192,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="!isLoading && items.length === 0"
|
v-else-if="!isLoading && items.length === 0 && !loadError"
|
||||||
class="flex h-full w-full items-center justify-center p-20"
|
class="flex h-full w-full items-center justify-center p-20"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center gap-4 text-center">
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
<FolderOpenIcon class="h-16 w-16 text-secondary" />
|
<FolderOpenIcon class="h-16 w-16 text-secondary" />
|
||||||
<h3 class="text-2xl font-bold text-contrast">This folder is empty</h3>
|
<h3 class="m-0 text-2xl font-bold text-contrast">This folder is empty</h3>
|
||||||
<p class="m-0 text-sm text-secondary">There are no files or folders.</p>
|
<p class="m-0 text-sm text-secondary">There are no files or folders.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LazyUiServersFileManagerError
|
|
||||||
v-else-if="!isLoading"
|
|
||||||
title="Unable to list files"
|
|
||||||
message="Unfortunately, we were unable to list the files in this folder. If this issue persists, contact support."
|
|
||||||
@refetch="refreshList"
|
|
||||||
@home="navigateToSegment(-1)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LazyUiServersFileManagerError
|
<LazyUiServersFileManagerError
|
||||||
v-else-if="loadError"
|
v-else-if="loadError"
|
||||||
title="Unable to fetch files"
|
title="Unable to load files"
|
||||||
message="This path is invalid or the server is not responding."
|
message="The folder may not exist."
|
||||||
@refetch="refreshList"
|
@refetch="refreshList"
|
||||||
@home="navigateToSegment(-1)"
|
@home="navigateToSegment(-1)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isDragging"
|
v-if="isDragging"
|
||||||
class="absolute inset-0 flex items-center justify-center rounded-xl bg-black bg-opacity-50 text-white"
|
class="absolute inset-0 flex items-center justify-center rounded-2xl bg-black bg-opacity-50 text-white"
|
||||||
>
|
>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<UploadIcon class="mx-auto h-16 w-16" />
|
<UploadIcon class="mx-auto h-16 w-16" />
|
||||||
@@ -140,8 +238,38 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useInfiniteScroll } from "@vueuse/core";
|
import { useInfiniteScroll } from "@vueuse/core";
|
||||||
import { UploadIcon, FolderOpenIcon } from "@modrinth/assets";
|
import { UploadIcon, FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
||||||
import type { Server } from "~/composables/pyroServers";
|
import { ButtonStyled } from "@modrinth/ui";
|
||||||
|
import type { DirectoryResponse, DirectoryItem, Server } from "~/composables/pyroServers";
|
||||||
|
|
||||||
|
interface BaseOperation {
|
||||||
|
type: "move" | "rename";
|
||||||
|
itemType: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MoveOperation extends BaseOperation {
|
||||||
|
type: "move";
|
||||||
|
sourcePath: string;
|
||||||
|
destinationPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenameOperation extends BaseOperation {
|
||||||
|
type: "rename";
|
||||||
|
path: string;
|
||||||
|
oldName: string;
|
||||||
|
newName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Operation = MoveOperation | RenameOperation;
|
||||||
|
|
||||||
|
interface UploadItem {
|
||||||
|
file: File;
|
||||||
|
progress: number;
|
||||||
|
status: "pending" | "uploading" | "completed" | "error" | "cancelled";
|
||||||
|
size: string;
|
||||||
|
uploader?: any;
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||||
@@ -154,6 +282,8 @@ const VAceEditor = ref();
|
|||||||
const mainContent = ref<HTMLElement | null>(null);
|
const mainContent = ref<HTMLElement | null>(null);
|
||||||
const scrollContainer = ref<HTMLElement | null>(null);
|
const scrollContainer = ref<HTMLElement | null>(null);
|
||||||
const contextMenu = ref();
|
const contextMenu = ref();
|
||||||
|
const operationHistory = ref<Operation[]>([]);
|
||||||
|
const redoStack = ref<Operation[]>([]);
|
||||||
|
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
const sortMethod = ref("default");
|
const sortMethod = ref("default");
|
||||||
@@ -184,13 +314,52 @@ const imagePreview = ref();
|
|||||||
const isDragging = ref(false);
|
const isDragging = ref(false);
|
||||||
const dragCounter = ref(0);
|
const dragCounter = ref(0);
|
||||||
|
|
||||||
|
const uploadStatusRef = ref<HTMLElement | null>(null);
|
||||||
|
const isUploading = computed(() => uploadQueue.value.length > 0);
|
||||||
|
const uploadQueue = ref<UploadItem[]>([]);
|
||||||
|
|
||||||
|
const activeUploads = computed(() =>
|
||||||
|
uploadQueue.value.filter((item) => item.status === "pending" || item.status === "uploading"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onUploadStatusEnter = (el: Element) => {
|
||||||
|
const height = (el as HTMLElement).scrollHeight;
|
||||||
|
(el as HTMLElement).style.height = "0";
|
||||||
|
// eslint-disable-next-line no-void
|
||||||
|
void (el as HTMLElement).offsetHeight;
|
||||||
|
(el as HTMLElement).style.height = `${height}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUploadStatusLeave = (el: Element) => {
|
||||||
|
const height = (el as HTMLElement).scrollHeight;
|
||||||
|
(el as HTMLElement).style.height = `${height}px`;
|
||||||
|
// eslint-disable-next-line no-void
|
||||||
|
void (el as HTMLElement).offsetHeight;
|
||||||
|
(el as HTMLElement).style.height = "0";
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
uploadQueue,
|
||||||
|
() => {
|
||||||
|
if (!uploadStatusRef.value) return;
|
||||||
|
const el = uploadStatusRef.value;
|
||||||
|
const itemsHeight = uploadQueue.value.length * 32;
|
||||||
|
const headerHeight = 12;
|
||||||
|
const gap = 8;
|
||||||
|
const padding = 32;
|
||||||
|
const totalHeight = padding + headerHeight + gap + itemsHeight;
|
||||||
|
el.style.height = `${totalHeight}px`;
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
const data = computed(() => props.server.general);
|
const data = computed(() => props.server.general);
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: computed(() => `Files - ${data.value?.name ?? "Server"} - Modrinth`),
|
title: computed(() => `Files - ${data.value?.name ?? "Server"} - Modrinth`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchDirectoryContents = async (): Promise<{ items: any[]; total: number }> => {
|
const fetchDirectoryContents = async (): Promise<DirectoryResponse> => {
|
||||||
const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value;
|
const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value;
|
||||||
try {
|
try {
|
||||||
const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults);
|
const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults);
|
||||||
@@ -240,16 +409,97 @@ const refreshList = () => {
|
|||||||
reset();
|
reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const undoLastOperation = async () => {
|
||||||
|
const lastOperation = operationHistory.value.pop();
|
||||||
|
if (!lastOperation) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (lastOperation.type) {
|
||||||
|
case "move":
|
||||||
|
await props.server.fs?.moveFileOrFolder(
|
||||||
|
`${lastOperation.destinationPath}/${lastOperation.fileName}`.replace("//", "/"),
|
||||||
|
`${lastOperation.sourcePath}/${lastOperation.fileName}`.replace("//", "/"),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "rename":
|
||||||
|
await props.server.fs?.renameFileOrFolder(
|
||||||
|
`${lastOperation.path}/${lastOperation.newName}`.replace("//", "/"),
|
||||||
|
lastOperation.oldName,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
redoStack.value.push(lastOperation);
|
||||||
|
|
||||||
|
refreshList();
|
||||||
|
addNotification({
|
||||||
|
group: "files",
|
||||||
|
title: `${lastOperation.type === "move" ? "Move" : "Rename"} undone`,
|
||||||
|
text: `${lastOperation.fileName} has been restored to its original ${lastOperation.type === "move" ? "location" : "name"}`,
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error undoing ${lastOperation.type}:`, error);
|
||||||
|
addNotification({
|
||||||
|
group: "files",
|
||||||
|
title: "Undo failed",
|
||||||
|
text: `Failed to undo the last ${lastOperation.type} operation`,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const redoLastOperation = async () => {
|
||||||
|
const lastOperation = redoStack.value.pop();
|
||||||
|
if (!lastOperation) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (lastOperation.type) {
|
||||||
|
case "move":
|
||||||
|
await props.server.fs?.moveFileOrFolder(
|
||||||
|
`${lastOperation.sourcePath}/${lastOperation.fileName}`.replace("//", "/"),
|
||||||
|
`${lastOperation.destinationPath}/${lastOperation.fileName}`.replace("//", "/"),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "rename":
|
||||||
|
await props.server.fs?.renameFileOrFolder(
|
||||||
|
`${lastOperation.path}/${lastOperation.oldName}`.replace("//", "/"),
|
||||||
|
lastOperation.newName,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
operationHistory.value.push(lastOperation);
|
||||||
|
|
||||||
|
refreshList();
|
||||||
|
addNotification({
|
||||||
|
group: "files",
|
||||||
|
title: `${lastOperation.type === "move" ? "Move" : "Rename"} redone`,
|
||||||
|
text: `${lastOperation.fileName} has been ${lastOperation.type === "move" ? "moved" : "renamed"} again`,
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error redoing ${lastOperation.type}:`, error);
|
||||||
|
addNotification({
|
||||||
|
group: "files",
|
||||||
|
title: "Redo failed",
|
||||||
|
text: `Failed to redo the last ${lastOperation.type} operation`,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateNewItem = async (name: string) => {
|
const handleCreateNewItem = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
const path = `${currentPath.value}/${name}`.replace("//", "/");
|
const path = `${currentPath.value}/${name}`.replace("//", "/");
|
||||||
await props.server.fs?.createFileOrFolder(path, newItemType.value);
|
await props.server.fs?.createFileOrFolder(path, newItemType.value);
|
||||||
|
|
||||||
refreshList();
|
refreshList();
|
||||||
|
|
||||||
addNotification({
|
addNotification({
|
||||||
group: "files",
|
group: "files",
|
||||||
title: "File created",
|
title: `${newItemType.value === "directory" ? "Folder" : "File"} created`,
|
||||||
text: "Your file has been created.",
|
text: `New ${newItemType.value === "directory" ? "folder" : "file"} ${name} has been created.`,
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -262,6 +512,16 @@ const handleRenameItem = async (newName: string) => {
|
|||||||
const path = `${currentPath.value}/${selectedItem.value.name}`.replace("//", "/");
|
const path = `${currentPath.value}/${selectedItem.value.name}`.replace("//", "/");
|
||||||
await props.server.fs?.renameFileOrFolder(path, newName);
|
await props.server.fs?.renameFileOrFolder(path, newName);
|
||||||
|
|
||||||
|
redoStack.value = [];
|
||||||
|
operationHistory.value.push({
|
||||||
|
type: "rename",
|
||||||
|
itemType: selectedItem.value.type,
|
||||||
|
fileName: selectedItem.value.name,
|
||||||
|
path: currentPath.value,
|
||||||
|
oldName: selectedItem.value.name,
|
||||||
|
newName,
|
||||||
|
});
|
||||||
|
|
||||||
refreshList();
|
refreshList();
|
||||||
|
|
||||||
if (closeEditor.value) {
|
if (closeEditor.value) {
|
||||||
@@ -274,27 +534,90 @@ const handleRenameItem = async (newName: string) => {
|
|||||||
|
|
||||||
addNotification({
|
addNotification({
|
||||||
group: "files",
|
group: "files",
|
||||||
title: "File renamed",
|
title: `${selectedItem.value.type === "directory" ? "Folder" : "File"} renamed`,
|
||||||
text: "Your file has been renamed.",
|
text: `${selectedItem.value.name} has been renamed to ${newName}`,
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleRenameError(error);
|
console.error("Error renaming item:", error);
|
||||||
|
if (error instanceof PyroFetchError) {
|
||||||
|
if (error.statusCode === 400) {
|
||||||
|
addNotification({
|
||||||
|
group: "files",
|
||||||
|
title: "Could not rename",
|
||||||
|
text: `An item named "${newName}" already exists in this location`,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addNotification({
|
||||||
|
group: "files",
|
||||||
|
title: "Could not rename item",
|
||||||
|
text: "An unexpected error occurred",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMoveItem = async (destination: string) => {
|
const handleMoveItem = async (destination: string) => {
|
||||||
try {
|
try {
|
||||||
|
const itemType = selectedItem.value.type;
|
||||||
|
const sourcePath = currentPath.value;
|
||||||
|
const newPath = `${destination}/${selectedItem.value.name}`.replace("//", "/");
|
||||||
|
|
||||||
await props.server.fs?.moveFileOrFolder(
|
await props.server.fs?.moveFileOrFolder(
|
||||||
`${currentPath.value}/${selectedItem.value.name}`.replace("//", "/"),
|
`${sourcePath}/${selectedItem.value.name}`.replace("//", "/"),
|
||||||
`${destination}/${selectedItem.value.name}`.replace("//", "/"),
|
newPath,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
redoStack.value = [];
|
||||||
|
operationHistory.value.push({
|
||||||
|
type: "move",
|
||||||
|
sourcePath,
|
||||||
|
destinationPath: destination,
|
||||||
|
fileName: selectedItem.value.name,
|
||||||
|
itemType,
|
||||||
|
});
|
||||||
|
|
||||||
refreshList();
|
refreshList();
|
||||||
addNotification({
|
addNotification({
|
||||||
group: "files",
|
group: "files",
|
||||||
title: "File moved",
|
title: `${itemType === "directory" ? "Folder" : "File"} moved`,
|
||||||
text: "Your file has been moved.",
|
text: `${selectedItem.value.name} has been moved to ${newPath}`,
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error moving item:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDirectMove = async (moveData: {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
path: string;
|
||||||
|
destination: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const newPath = `${moveData.destination}/${moveData.name}`.replace("//", "/");
|
||||||
|
const sourcePath = moveData.path.substring(0, moveData.path.lastIndexOf("/"));
|
||||||
|
|
||||||
|
await props.server.fs?.moveFileOrFolder(moveData.path, newPath);
|
||||||
|
|
||||||
|
redoStack.value = [];
|
||||||
|
operationHistory.value.push({
|
||||||
|
type: "move",
|
||||||
|
sourcePath,
|
||||||
|
destinationPath: moveData.destination,
|
||||||
|
fileName: moveData.name,
|
||||||
|
itemType: moveData.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshList();
|
||||||
|
addNotification({
|
||||||
|
group: "files",
|
||||||
|
title: `${moveData.type === "directory" ? "Folder" : "File"} moved`,
|
||||||
|
text: `${moveData.name} has been moved to ${newPath}`,
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -356,40 +679,18 @@ const handleCreateError = (error: any) => {
|
|||||||
addNotification({
|
addNotification({
|
||||||
group: "files",
|
group: "files",
|
||||||
title: "Error creating item",
|
title: "Error creating item",
|
||||||
text: "File already exists",
|
text: "Something went wrong. The file may already exist.",
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRenameError = (error: any) => {
|
const applyDefaultSort = (items: DirectoryItem[]) => {
|
||||||
console.error("Error renaming item:", error);
|
|
||||||
if (error instanceof PyroFetchError) {
|
|
||||||
if (error.statusCode === 400) {
|
|
||||||
addNotification({
|
|
||||||
group: "files",
|
|
||||||
title: "Could not rename item",
|
|
||||||
text: "This item already exists or is invalid.",
|
|
||||||
type: "error",
|
|
||||||
});
|
|
||||||
} else if (error.statusCode === 500) {
|
|
||||||
addNotification({
|
|
||||||
group: "files",
|
|
||||||
title: "Could not rename item",
|
|
||||||
text: "Invalid file",
|
|
||||||
type: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyDefaultSort = (items: any[]) => {
|
|
||||||
return items.sort((a: any, b: any) => {
|
return items.sort((a: any, b: any) => {
|
||||||
if (a.type === "directory" && b.type !== "directory") return -1;
|
if (a.type === "directory" && b.type !== "directory") return -1;
|
||||||
if (a.type !== "directory" && b.type === "directory") return 1;
|
if (a.type !== "directory" && b.type === "directory") return 1;
|
||||||
if (a.count > b.count) return -1;
|
|
||||||
if (a.count < b.count) return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -406,6 +707,9 @@ const filteredItems = computed(() => {
|
|||||||
case "modified":
|
case "modified":
|
||||||
result.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
result.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
||||||
break;
|
break;
|
||||||
|
case "created":
|
||||||
|
result.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
|
||||||
|
break;
|
||||||
case "filesOnly":
|
case "filesOnly":
|
||||||
result = result.filter((item) => item.type !== "directory");
|
result = result.filter((item) => item.type !== "directory");
|
||||||
break;
|
break;
|
||||||
@@ -509,15 +813,31 @@ const editFile = async (item: { name: string; type: string; path: string }) => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await import("ace-builds");
|
await import("ace-builds");
|
||||||
await import("ace-builds/src-noconflict/mode-json");
|
await import("ace-builds/src-noconflict/mode-json");
|
||||||
|
await import("ace-builds/src-noconflict/mode-yaml");
|
||||||
|
await import("ace-builds/src-noconflict/mode-toml");
|
||||||
|
await import("ace-builds/src-noconflict/mode-sh");
|
||||||
await import("ace-builds/src-noconflict/theme-one_dark");
|
await import("ace-builds/src-noconflict/theme-one_dark");
|
||||||
|
await import("ace-builds/src-noconflict/ext-searchbox");
|
||||||
VAceEditor.value = markRaw((await import("vue3-ace-editor")).VAceEditor);
|
VAceEditor.value = markRaw((await import("vue3-ace-editor")).VAceEditor);
|
||||||
document.addEventListener("click", onAnywhereClicked);
|
document.addEventListener("click", onAnywhereClicked);
|
||||||
window.addEventListener("scroll", onScroll);
|
window.addEventListener("scroll", onScroll);
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === "z") {
|
||||||
|
e.preventDefault();
|
||||||
|
undoLastOperation();
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "z") {
|
||||||
|
e.preventDefault();
|
||||||
|
redoLastOperation();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener("click", onAnywhereClicked);
|
document.removeEventListener("click", onAnywhereClicked);
|
||||||
window.removeEventListener("scroll", onScroll);
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
document.removeEventListener("keydown", () => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -600,8 +920,10 @@ const requestShareLink = async () => {
|
|||||||
const handleDragEnter = (event: DragEvent) => {
|
const handleDragEnter = (event: DragEvent) => {
|
||||||
if (isEditing.value) return;
|
if (isEditing.value) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
dragCounter.value++;
|
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
|
||||||
isDragging.value = true;
|
dragCounter.value++;
|
||||||
|
isDragging.value = true;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragOver = (event: DragEvent) => {
|
const handleDragOver = (event: DragEvent) => {
|
||||||
@@ -618,43 +940,123 @@ const handleDragLeave = (event: DragEvent) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line require-await
|
||||||
const handleDrop = async (event: DragEvent) => {
|
const handleDrop = async (event: DragEvent) => {
|
||||||
if (isEditing.value) return;
|
if (isEditing.value) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
isDragging.value = false;
|
isDragging.value = false;
|
||||||
dragCounter.value = 0;
|
dragCounter.value = 0;
|
||||||
|
|
||||||
|
const isInternalMove = event.dataTransfer?.types.includes("application/pyro-file-move");
|
||||||
|
if (isInternalMove) return;
|
||||||
|
|
||||||
const files = event.dataTransfer?.files;
|
const files = event.dataTransfer?.files;
|
||||||
if (files) {
|
if (files) {
|
||||||
for (let i = 0; i < files.length; i++) {
|
Array.from(files).forEach((file) => {
|
||||||
await uploadFile(files[i]);
|
uploadFile(file);
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return bytes + " B";
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelUpload = (item: UploadItem) => {
|
||||||
|
if (item.uploader && item.status === "uploading") {
|
||||||
|
item.uploader.cancel();
|
||||||
|
item.status = "cancelled";
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name);
|
||||||
|
if (index !== -1) {
|
||||||
|
uploadQueue.value.splice(index, 1);
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadFile = async (file: File) => {
|
const uploadFile = async (file: File) => {
|
||||||
try {
|
const uploadItem: UploadItem = {
|
||||||
const filePath = `${currentPath.value}/${file.name}`.replace("//", "/");
|
file,
|
||||||
await props.server.fs?.uploadFile(filePath, file);
|
progress: 0,
|
||||||
refreshList();
|
status: "pending",
|
||||||
|
size: formatFileSize(file.size),
|
||||||
|
};
|
||||||
|
|
||||||
addNotification({
|
uploadQueue.value.push(uploadItem);
|
||||||
group: "files",
|
|
||||||
title: "File uploaded",
|
try {
|
||||||
text: "Your file has been uploaded.",
|
uploadItem.status = "uploading";
|
||||||
type: "success",
|
const filePath = `${currentPath.value}/${file.name}`.replace("//", "/");
|
||||||
});
|
const uploader = await props.server.fs?.uploadFile(filePath, file);
|
||||||
|
uploadItem.uploader = uploader;
|
||||||
|
|
||||||
|
if (uploader?.onProgress) {
|
||||||
|
uploader.onProgress(({ progress }: { progress: number }) => {
|
||||||
|
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||||
|
if (index !== -1) {
|
||||||
|
uploadQueue.value[index].progress = Math.round(progress);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploader?.promise;
|
||||||
|
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||||
|
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||||
|
uploadQueue.value[index].status = "completed";
|
||||||
|
uploadQueue.value[index].progress = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||||
|
if (removeIndex !== -1) {
|
||||||
|
uploadQueue.value.splice(removeIndex, 1);
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
await refreshList();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error uploading file:", error);
|
console.error("Error uploading file:", error);
|
||||||
|
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||||
|
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||||
|
uploadQueue.value[index].status = "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||||
|
if (removeIndex !== -1) {
|
||||||
|
uploadQueue.value.splice(removeIndex, 1);
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message !== "Upload cancelled") {
|
||||||
|
addNotification({
|
||||||
|
group: "files",
|
||||||
|
title: "Upload failed",
|
||||||
|
text: `Failed to upload ${file.name}`,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const initiateFileUpload = () => {
|
const initiateFileUpload = () => {
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.onchange = async () => {
|
input.multiple = true;
|
||||||
const file = input.files?.[0];
|
input.onchange = () => {
|
||||||
if (file) {
|
if (input.files) {
|
||||||
await uploadFile(file);
|
Array.from(input.files).forEach((file) => {
|
||||||
|
uploadFile(file);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
@@ -708,6 +1110,13 @@ const saveFileContent = async (exit: boolean = true) => {
|
|||||||
const saveFileContentRestart = async () => {
|
const saveFileContentRestart = async () => {
|
||||||
await saveFileContent();
|
await saveFileContent();
|
||||||
await props.server.general?.power("Restart");
|
await props.server.general?.power("Restart");
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
group: "files",
|
||||||
|
title: "Server restarted",
|
||||||
|
text: "Your server has been restarted.",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveFileContentAs = async () => {
|
const saveFileContentAs = async () => {
|
||||||
@@ -734,3 +1143,38 @@ const onScroll = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.upload-status {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status-enter-active,
|
||||||
|
.upload-status-leave-active {
|
||||||
|
transition: height 0.2s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status-enter-from,
|
||||||
|
.upload-status-leave-to {
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon-enter-active,
|
||||||
|
.status-icon-leave-active {
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon-enter-from,
|
||||||
|
.status-icon-leave-to {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon-enter-to,
|
||||||
|
.status-icon-leave-from {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
2686
pnpm-lock.yaml
generated
2686
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user