diff --git a/apps/frontend/src/components/ui/servers/FilesUploadDragAndDrop.vue b/apps/frontend/src/components/ui/servers/FilesUploadDragAndDrop.vue new file mode 100644 index 000000000..68fd5c92e --- /dev/null +++ b/apps/frontend/src/components/ui/servers/FilesUploadDragAndDrop.vue @@ -0,0 +1,75 @@ + + + diff --git a/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue b/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue new file mode 100644 index 000000000..4841ea53c --- /dev/null +++ b/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue @@ -0,0 +1,306 @@ + + + + + diff --git a/apps/frontend/src/composables/pyroServers.ts b/apps/frontend/src/composables/pyroServers.ts index 95ca9d5b1..943ccb0b2 100644 --- a/apps/frontend/src/composables/pyroServers.ts +++ b/apps/frontend/src/composables/pyroServers.ts @@ -198,14 +198,16 @@ interface Startup { jdk_build: "corretto" | "temurin" | "graal"; } -interface Mod { +export interface Mod { filename: string; - project_id: string; - version_id: string; - name: string; - version_number: string; - icon_url: string; + project_id: string | undefined; + version_id: string | undefined; + name: string | undefined; + version_number: string | undefined; + icon_url: string | undefined; + owner: string | undefined; disabled: boolean; + installing: boolean; } interface Backup { @@ -1364,7 +1366,7 @@ type ContentModule = { data: Mod[] } & ContentFunctions; type BackupsModule = { data: Backup[] } & BackupFunctions; type NetworkModule = { allocations: Allocation[] } & NetworkFunctions; type StartupModule = Startup & StartupFunctions; -type FSModule = { auth: JWTAuth } & FSFunctions; +export type FSModule = { auth: JWTAuth } & FSFunctions; type ModulesMap = { general: GeneralModule; diff --git a/apps/frontend/src/pages/servers/manage/[id]/content/index.vue b/apps/frontend/src/pages/servers/manage/[id]/content/index.vue index d2f24957b..8161fa97c 100644 --- a/apps/frontend/src/pages/servers/manage/[id]/content/index.vue +++ b/apps/frontend/src/pages/servers/manage/[id]/content/index.vue @@ -15,12 +15,11 @@
-
+
@@ -57,9 +56,9 @@
-
-
-
+
+
+
@@ -88,7 +87,7 @@ { id: 'disabled', action: () => (filterMethod = 'disabled') }, ]" > - +
- - - - Add {{ type.toLocaleLowerCase() }} - - +
+ + + + + + + Add {{ type.toLocaleLowerCase() }} + + +
-
-
-
-
- +
-
- -
- -

No {{ type.toLocaleLowerCase() }}s found!

-

- Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here. -

- - - - Add {{ type.toLocaleLowerCase() }} - - -
-
- -

Your server is running Vanilla Minecraft

-

- Add content to your server by installing a modpack or choosing a different platform that - supports {{ type }}s. -

-
- - - - Find a modpack - - -
or
- - - - Change platform - - + +
+
+ +

+ No {{ type.toLocaleLowerCase() }}s found for your query! +

+

Try another query, or show everything.

+ + + +
+
+ +

No {{ type.toLocaleLowerCase() }}s found!

+

+ Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here. +

+
+ + + + + + + Add {{ type.toLocaleLowerCase() }} + + +
+
-
+
+ +

Your server is running Vanilla Minecraft

+

+ Add content to your server by installing a modpack or choosing a different platform that + supports {{ type }}s. +

+
+ + + + Find a modpack + + +
or
+ + + + Change platform + + +
+
+
@@ -290,10 +363,15 @@ import { MoreVerticalIcon, CompassIcon, WrenchIcon, + ListIcon, + FileIcon, } from "@modrinth/assets"; import { ButtonStyled, NewModal } from "@modrinth/ui"; import { ref, computed, watch, onMounted, onUnmounted } from "vue"; +import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue"; +import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue"; import type { Server } from "~/composables/pyroServers"; +import { acceptFileFromProjectType } from "~/helpers/fileUtils.js"; const props = defineProps<{ server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>; @@ -304,14 +382,7 @@ const type = computed(() => { return loader === "paper" || loader === "purpur" ? "Plugin" : "Mod"; }); -interface Mod { - name?: string; - filename: string; - project_id?: string; - version_id?: string; - version_number?: string; - icon_url?: string; - disabled: boolean; +interface ContentItem extends Mod { changing?: boolean; } @@ -322,12 +393,41 @@ const listContainer = ref(null); const windowScrollY = ref(0); const windowHeight = ref(0); -const localMods = ref([]); +const localMods = ref([]); const searchInput = ref(""); const modSearchInput = ref(""); const filterMethod = ref("all"); +const uploadDropdownRef = ref(); + +const handleDroppedFiles = (files: File[]) => { + files.forEach((file) => { + uploadDropdownRef.value?.uploadFile(file); + }); +}; + +const initiateFileUpload = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = acceptFileFromProjectType(type.value.toLowerCase()); + input.multiple = true; + input.onchange = () => { + if (input.files) { + Array.from(input.files).forEach((file) => { + uploadDropdownRef.value?.uploadFile(file); + }); + } + }; + input.click(); +}; + +const showAll = () => { + searchInput.value = ""; + modSearchInput.value = ""; + filterMethod.value = "all"; +}; + const filterMethodLabel = computed(() => { switch (filterMethod.value) { case "disabled": @@ -419,14 +519,17 @@ const debouncedSearch = debounce(() => { modSearchInput.value = searchInput.value; if (pyroContentSentinel.value) { - pyroContentSentinel.value.scrollIntoView({ - behavior: "smooth", - block: "start", - }); + const sentinelRect = pyroContentSentinel.value.getBoundingClientRect(); + if (sentinelRect.top < 0 || sentinelRect.bottom > window.innerHeight) { + pyroContentSentinel.value.scrollIntoView({ + // behavior: "smooth", + block: "start", + }); + } } }, 300); -async function toggleMod(mod: Mod) { +async function toggleMod(mod: ContentItem) { mod.changing = true; const originalFilename = mod.filename; @@ -458,7 +561,7 @@ async function toggleMod(mod: Mod) { mod.changing = false; } -async function removeMod(mod: Mod) { +async function removeMod(mod: ContentItem) { mod.changing = true; try { @@ -515,6 +618,10 @@ async function changeModVersion() { } const hasMods = computed(() => { + return localMods.value?.length > 0; +}); + +const hasFilteredMods = computed(() => { return filteredMods.value?.length > 0; }); diff --git a/apps/frontend/src/pages/servers/manage/[id]/files.vue b/apps/frontend/src/pages/servers/manage/[id]/files.vue index de29ffcf5..e61e2d6ac 100644 --- a/apps/frontend/src/pages/servers/manage/[id]/files.vue +++ b/apps/frontend/src/pages/servers/manage/[id]/files.vue @@ -25,12 +25,9 @@ @delete="handleDeleteItem" /> -
@@ -44,94 +41,14 @@ @upload="initiateFileUpload" @update:search-query="searchQuery = $event" /> - -
-
-
-
- - - File Uploads{{ - activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" - }} - -
-
- -
-
-
- - - - - - {{ item.file.name }} - {{ item.size }} -
-
- - - -
-
-
-
-
-
+
Drop files here to upload

-
+ import { useInfiniteScroll } from "@vueuse/core"; -import { UploadIcon, FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets"; -import { ButtonStyled } from "@modrinth/ui"; +import { UploadIcon, FolderOpenIcon } from "@modrinth/assets"; import type { DirectoryResponse, DirectoryItem, Server } from "~/composables/pyroServers"; +import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue"; +import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue"; interface BaseOperation { type: "move" | "rename"; @@ -263,14 +181,6 @@ interface RenameOperation extends BaseOperation { type Operation = MoveOperation | RenameOperation; -interface UploadItem { - file: File; - progress: number; - status: "pending" | "uploading" | "completed" | "error" | "cancelled"; - size: string; - uploader?: any; -} - const props = defineProps<{ server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>; }>(); @@ -312,46 +222,8 @@ const isEditingImage = ref(false); const imagePreview = ref(); const isDragging = ref(false); -const dragCounter = ref(0); -const uploadStatusRef = ref(null); -const isUploading = computed(() => uploadQueue.value.length > 0); -const uploadQueue = ref([]); - -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 uploadDropdownRef = ref(); const data = computed(() => props.server.general); @@ -917,135 +789,12 @@ const requestShareLink = async () => { } }; -const handleDragEnter = (event: DragEvent) => { +const handleDroppedFiles = (files: File[]) => { if (isEditing.value) return; - event.preventDefault(); - if (!event.dataTransfer?.types.includes("application/pyro-file-move")) { - dragCounter.value++; - isDragging.value = true; - } -}; -const handleDragOver = (event: DragEvent) => { - if (isEditing.value) return; - event.preventDefault(); -}; - -const handleDragLeave = (event: DragEvent) => { - if (isEditing.value) return; - event.preventDefault(); - dragCounter.value--; - if (dragCounter.value === 0) { - isDragging.value = false; - } -}; - -// eslint-disable-next-line require-await -const handleDrop = async (event: DragEvent) => { - if (isEditing.value) return; - event.preventDefault(); - isDragging.value = false; - dragCounter.value = 0; - - const isInternalMove = event.dataTransfer?.types.includes("application/pyro-file-move"); - if (isInternalMove) return; - - const files = event.dataTransfer?.files; - if (files) { - Array.from(files).forEach((file) => { - 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 uploadItem: UploadItem = { - file, - progress: 0, - status: "pending", - size: formatFileSize(file.size), - }; - - uploadQueue.value.push(uploadItem); - - try { - uploadItem.status = "uploading"; - 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) { - 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", - }); - } - } + files.forEach((file) => { + uploadDropdownRef.value?.uploadFile(file); + }); }; const initiateFileUpload = () => { @@ -1055,7 +804,7 @@ const initiateFileUpload = () => { input.onchange = () => { if (input.files) { Array.from(input.files).forEach((file) => { - uploadFile(file); + uploadDropdownRef.value?.uploadFile(file); }); } }; diff --git a/apps/frontend/src/pages/servers/manage/[id]/options/loader.vue b/apps/frontend/src/pages/servers/manage/[id]/options/loader.vue index 6eeba49b2..35a863db3 100644 --- a/apps/frontend/src/pages/servers/manage/[id]/options/loader.vue +++ b/apps/frontend/src/pages/servers/manage/[id]/options/loader.vue @@ -330,11 +330,11 @@ Upload .mrpack file - + - +
diff --git a/apps/frontend/src/types/servers.ts b/apps/frontend/src/types/servers.ts index 7ba96f797..8f76f594f 100644 --- a/apps/frontend/src/types/servers.ts +++ b/apps/frontend/src/types/servers.ts @@ -1,11 +1,11 @@ -export interface Mod { - id: string; - filename: string; - modrinth_ids: { - project_id: string; - version_id: string; - }; -} +// export interface Mod { +// id: string; +// filename: string; +// modrinth_ids: { +// project_id: string; +// version_id: string; +// }; +// } interface License { id: string;