feat(servers content): file upload + extra mod info + misc (#3055)
* feat: only scroll up if scrolled down * feat: no query results message * feat: content files support, mobile fixes * fix(drag & drop): type of file prop * chore: show number of mods in searchbar Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust btn styles Signed-off-by: Evan Song <theevansong@gmail.com> * feat: prepare for mod author in backend response Signed-off-by: Evan Song <theevansong@gmail.com> * fix: external mods & mobile * chore: adjust edit mod version modal copy Signed-off-by: Evan Song <theevansong@gmail.com> * chore: add tooltips for version/filename Signed-off-by: Evan Song <theevansong@gmail.com> * chore: swap delete/change version btn Signed-off-by: Evan Song <theevansong@gmail.com> * fix: dont allow mod link to be dragged Signed-off-by: Evan Song <theevansong@gmail.com> * fix: oops Signed-off-by: Evan Song <theevansong@gmail.com> * chore: remove author field Signed-off-by: Evan Song <theevansong@gmail.com> * chore: drill down tooltip Signed-off-by: Evan Song <theevansong@gmail.com> * fix: fighting types Signed-off-by: Evan Song <theevansong@gmail.com> * prepare for owner field Signed-off-by: Evan Song <theevansong@gmail.com> --------- Signed-off-by: Evan Song <theevansong@gmail.com> Co-authored-by: Evan Song <theevansong@gmail.com> Co-authored-by: Evan Song <52982404+ferothefox@users.noreply.github.com>
This commit is contained in:
parent
2fea772ffb
commit
0437503b75
@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
@dragenter.prevent="handleDragEnter"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragleave.prevent="handleDragLeave"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<div
|
||||||
|
v-if="isDragging"
|
||||||
|
:class="[
|
||||||
|
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black bg-opacity-50 text-white',
|
||||||
|
overlayClass,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<UploadIcon class="mx-auto h-16 w-16" />
|
||||||
|
<p class="mt-2 text-xl">
|
||||||
|
Drop {{ type ? type.toLocaleLowerCase() : "file" }}s here to upload
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { UploadIcon } from "@modrinth/assets";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "filesDropped", files: File[]): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
overlayClass?: string;
|
||||||
|
type?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const dragCounter = ref(0);
|
||||||
|
|
||||||
|
const handleDragEnter = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
|
||||||
|
dragCounter.value++;
|
||||||
|
isDragging.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dragCounter.value--;
|
||||||
|
if (dragCounter.value === 0) {
|
||||||
|
isDragging.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: DragEvent) => {
|
||||||
|
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) {
|
||||||
|
emit("filesDropped", Array.from(files));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
306
apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
Normal file
306
apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
|
||||||
|
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
|
||||||
|
<div
|
||||||
|
ref="statusContentRef"
|
||||||
|
:class="['flex flex-col p-4 text-sm text-contrast', $attrs.class]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 font-bold">
|
||||||
|
<FolderOpenIcon class="size-4" />
|
||||||
|
<span>
|
||||||
|
<span class="capitalize">
|
||||||
|
{{ props.fileType ? props.fileType : "File" }} Uploads
|
||||||
|
</span>
|
||||||
|
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="item in uploadQueue"
|
||||||
|
:key="item.file.name"
|
||||||
|
class="flex h-6 items-center justify-between gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 items-center gap-2 truncate">
|
||||||
|
<transition-group name="status-icon" mode="out-in">
|
||||||
|
<UiServersPanelSpinner
|
||||||
|
v-show="item.status === 'uploading'"
|
||||||
|
key="spinner"
|
||||||
|
class="absolute !size-4"
|
||||||
|
/>
|
||||||
|
<CheckCircleIcon
|
||||||
|
v-show="item.status === 'completed'"
|
||||||
|
key="check"
|
||||||
|
class="absolute size-4 text-green"
|
||||||
|
/>
|
||||||
|
<XCircleIcon
|
||||||
|
v-show="
|
||||||
|
item.status === 'error' ||
|
||||||
|
item.status === 'cancelled' ||
|
||||||
|
item.status === 'incorrect-type'
|
||||||
|
"
|
||||||
|
key="error"
|
||||||
|
class="absolute size-4 text-red"
|
||||||
|
/>
|
||||||
|
</transition-group>
|
||||||
|
<span class="ml-6 truncate">{{ item.file.name }}</span>
|
||||||
|
<span class="text-secondary">{{ item.size }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
||||||
|
<template v-if="item.status === 'completed'">
|
||||||
|
<span>Done</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.status === 'error'">
|
||||||
|
<span class="text-red">Failed - File already exists</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.status === 'incorrect-type'">
|
||||||
|
<span class="text-red">Failed - Incorrect file type</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<template v-if="item.status === 'uploading'">
|
||||||
|
<span>{{ item.progress }}%</span>
|
||||||
|
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||||
|
<div
|
||||||
|
class="h-full bg-contrast transition-all duration-200"
|
||||||
|
:style="{ width: item.progress + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
||||||
|
<button>Cancel</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
||||||
|
import { ButtonStyled } from "@modrinth/ui";
|
||||||
|
import { ref, computed, watch, nextTick } from "vue";
|
||||||
|
|
||||||
|
interface UploadItem {
|
||||||
|
file: File;
|
||||||
|
progress: number;
|
||||||
|
status: "pending" | "uploading" | "completed" | "error" | "cancelled" | "incorrect-type";
|
||||||
|
size: string;
|
||||||
|
uploader?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentPath: string;
|
||||||
|
fileType?: string;
|
||||||
|
marginBottom?: number;
|
||||||
|
acceptedTypes?: Array<string>;
|
||||||
|
fs: FSModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "uploadComplete"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const uploadStatusRef = ref<HTMLElement | null>(null);
|
||||||
|
const statusContentRef = ref<HTMLElement | null>(null);
|
||||||
|
const uploadQueue = ref<UploadItem[]>([]);
|
||||||
|
|
||||||
|
const isUploading = computed(() => uploadQueue.value.length > 0);
|
||||||
|
const activeUploads = computed(() =>
|
||||||
|
uploadQueue.value.filter((item) => item.status === "pending" || item.status === "uploading"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onUploadStatusEnter = (el: Element) => {
|
||||||
|
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
|
||||||
|
(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 + (props.marginBottom || 0);
|
||||||
|
(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 + (props.marginBottom || 0);
|
||||||
|
el.style.height = `${totalHeight}px`;
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return bytes + " B";
|
||||||
|
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + " KB";
|
||||||
|
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + " MB";
|
||||||
|
return (bytes / 1024 ** 3).toFixed(1) + " GB";
|
||||||
|
};
|
||||||
|
|
||||||
|
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 badFileTypeMsg = "Upload had incorrect file type";
|
||||||
|
const uploadFile = async (file: File) => {
|
||||||
|
const uploadItem: UploadItem = {
|
||||||
|
file,
|
||||||
|
progress: 0,
|
||||||
|
status: "pending",
|
||||||
|
size: formatFileSize(file.size),
|
||||||
|
};
|
||||||
|
|
||||||
|
uploadQueue.value.push(uploadItem);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
props.acceptedTypes &&
|
||||||
|
!props.acceptedTypes.includes(file.type) &&
|
||||||
|
!props.acceptedTypes.some((type) => file.name.endsWith(type))
|
||||||
|
) {
|
||||||
|
throw new Error(badFileTypeMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadItem.status = "uploading";
|
||||||
|
const filePath = `${props.currentPath}/${file.name}`.replace("//", "/");
|
||||||
|
const uploader = await props.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);
|
||||||
|
|
||||||
|
emit("uploadComplete");
|
||||||
|
} 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 instanceof Error && error.message === badFileTypeMsg ? "incorrect-type" : "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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
uploadFile,
|
||||||
|
cancelUpload,
|
||||||
|
});
|
||||||
|
</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>
|
||||||
@ -198,14 +198,16 @@ interface Startup {
|
|||||||
jdk_build: "corretto" | "temurin" | "graal";
|
jdk_build: "corretto" | "temurin" | "graal";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Mod {
|
export interface Mod {
|
||||||
filename: string;
|
filename: string;
|
||||||
project_id: string;
|
project_id: string | undefined;
|
||||||
version_id: string;
|
version_id: string | undefined;
|
||||||
name: string;
|
name: string | undefined;
|
||||||
version_number: string;
|
version_number: string | undefined;
|
||||||
icon_url: string;
|
icon_url: string | undefined;
|
||||||
|
owner: string | undefined;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
installing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Backup {
|
interface Backup {
|
||||||
@ -1364,7 +1366,7 @@ type ContentModule = { data: Mod[] } & ContentFunctions;
|
|||||||
type BackupsModule = { data: Backup[] } & BackupFunctions;
|
type BackupsModule = { data: Backup[] } & BackupFunctions;
|
||||||
type NetworkModule = { allocations: Allocation[] } & NetworkFunctions;
|
type NetworkModule = { allocations: Allocation[] } & NetworkFunctions;
|
||||||
type StartupModule = Startup & StartupFunctions;
|
type StartupModule = Startup & StartupFunctions;
|
||||||
type FSModule = { auth: JWTAuth } & FSFunctions;
|
export type FSModule = { auth: JWTAuth } & FSFunctions;
|
||||||
|
|
||||||
type ModulesMap = {
|
type ModulesMap = {
|
||||||
general: GeneralModule;
|
general: GeneralModule;
|
||||||
|
|||||||
@ -15,12 +15,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="props.server.general?.upstream" class="flex items-center gap-2">
|
<div v-if="props.server.general?.upstream" class="flex gap-2">
|
||||||
<InfoIcon class="hidden sm:block" />
|
<InfoIcon class="hidden sm:block" />
|
||||||
<span class="text-sm text-secondary">
|
<span class="text-sm text-secondary">
|
||||||
Your server was created from a modpack. Changing the mod version may cause unexpected
|
Changing the mod version may cause unexpected issues. Because your server was created
|
||||||
issues. You can update the modpack version in your server's Options > Platform
|
from a modpack, it is recommended to use the modpack's version of the mod.
|
||||||
settings.
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -57,9 +56,9 @@
|
|||||||
<div v-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
|
<div v-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
|
||||||
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
|
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
|
||||||
<div class="relative flex h-full w-full flex-col">
|
<div class="relative flex h-full w-full flex-col">
|
||||||
<div class="sticky top-0 z-20 -mt-4 flex items-center justify-between bg-bg py-4">
|
<div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
|
||||||
<div class="flex w-full flex-col items-center gap-2 sm:flex-row sm:gap-4">
|
<div class="flex w-full flex-col-reverse items-center gap-2 sm:flex-row">
|
||||||
<div class="flex w-full items-center gap-2 sm:gap-4">
|
<div class="flex w-full items-center gap-2">
|
||||||
<div class="relative flex-1 text-sm">
|
<div class="relative flex-1 text-sm">
|
||||||
<label class="sr-only" for="search">Search</label>
|
<label class="sr-only" for="search">Search</label>
|
||||||
<SearchIcon
|
<SearchIcon
|
||||||
@ -73,7 +72,7 @@
|
|||||||
type="search"
|
type="search"
|
||||||
name="search"
|
name="search"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:placeholder="`Search ${type.toLocaleLowerCase()}s...`"
|
:placeholder="`Search ${localMods.length} ${type.toLocaleLowerCase()}s...`"
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -88,7 +87,7 @@
|
|||||||
{ id: 'disabled', action: () => (filterMethod = 'disabled') },
|
{ id: 'disabled', action: () => (filterMethod = 'disabled') },
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<span class="whitespace-pre text-sm font-medium">
|
<span class="hidden whitespace-pre sm:block">
|
||||||
{{ filterMethodLabel }}
|
{{ filterMethodLabel }}
|
||||||
</span>
|
</span>
|
||||||
<FilterIcon aria-hidden="true" />
|
<FilterIcon aria-hidden="true" />
|
||||||
@ -99,45 +98,71 @@
|
|||||||
</UiServersTeleportOverflowMenu>
|
</UiServersTeleportOverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
<ButtonStyled v-if="hasMods" color="brand" type="outlined">
|
<div v-if="hasMods" class="flex w-full items-center gap-2 sm:w-fit">
|
||||||
<nuxt-link
|
<ButtonStyled>
|
||||||
class="w-full text-nowrap sm:w-fit"
|
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
|
||||||
:to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
|
<FileIcon />
|
||||||
>
|
Add file
|
||||||
<PlusIcon />
|
</button>
|
||||||
Add {{ type.toLocaleLowerCase() }}
|
</ButtonStyled>
|
||||||
</nuxt-link>
|
<ButtonStyled color="brand">
|
||||||
</ButtonStyled>
|
<nuxt-link
|
||||||
|
class="w-full text-nowrap sm:w-fit"
|
||||||
|
:to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
Add {{ type.toLocaleLowerCase() }}
|
||||||
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="hasMods" class="flex flex-col gap-2 transition-all">
|
<FilesUploadDropdown
|
||||||
<div ref="listContainer" class="relative w-full">
|
v-if="props.server.fs"
|
||||||
<div :style="{ position: 'relative', height: `${totalHeight}px` }">
|
ref="uploadDropdownRef"
|
||||||
<div :style="{ position: 'absolute', top: `${visibleTop}px`, width: '100%' }">
|
class="rounded-xl bg-bg-raised"
|
||||||
<template v-for="mod in visibleItems.items" :key="mod.filename">
|
:margin-bottom="16"
|
||||||
<div
|
:file-type="type"
|
||||||
class="relative mb-2 flex w-full items-center justify-between rounded-xl bg-bg-raised"
|
:current-path="'/mods'"
|
||||||
:class="mod.disabled ? 'bg-table-alternateRow text-secondary' : ''"
|
:fs="props.server.fs"
|
||||||
style="height: 64px"
|
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
|
||||||
>
|
@upload-complete="() => props.server.refresh(['content'])"
|
||||||
<NuxtLink
|
/>
|
||||||
:to="
|
<FilesUploadDragAndDrop
|
||||||
mod.project_id
|
v-if="server.general && localMods"
|
||||||
? `/project/${mod.project_id}/version/${mod.version_id}`
|
class="relative min-h-[50vh]"
|
||||||
: `files?path=mods`
|
overlay-class="rounded-xl border-2 border-dashed border-secondary"
|
||||||
"
|
:type="type"
|
||||||
class="group flex min-w-0 items-center rounded-xl p-2"
|
@files-dropped="handleDroppedFiles"
|
||||||
|
>
|
||||||
|
<div v-if="hasFilteredMods" class="flex flex-col gap-2 transition-all">
|
||||||
|
<div ref="listContainer" class="relative w-full">
|
||||||
|
<div :style="{ position: 'relative', height: `${totalHeight}px` }">
|
||||||
|
<div :style="{ position: 'absolute', top: `${visibleTop}px`, width: '100%' }">
|
||||||
|
<template v-for="mod in visibleItems.items" :key="mod.filename">
|
||||||
|
<div
|
||||||
|
class="relative mb-2 flex w-full items-center justify-between rounded-xl bg-bg-raised"
|
||||||
|
:class="mod.disabled ? 'bg-table-alternateRow text-secondary' : ''"
|
||||||
|
style="height: 64px"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<NuxtLink
|
||||||
|
:to="
|
||||||
|
mod.project_id
|
||||||
|
? `/project/${mod.project_id}/version/${mod.version_id}`
|
||||||
|
: `files?path=mods`
|
||||||
|
"
|
||||||
|
class="flex min-w-0 flex-1 items-center gap-2 rounded-xl p-2"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
<UiAvatar
|
<UiAvatar
|
||||||
:src="mod.icon_url"
|
:src="mod.icon_url"
|
||||||
size="sm"
|
size="sm"
|
||||||
alt="Server Icon"
|
alt="Server Icon"
|
||||||
:class="mod.disabled ? 'grayscale' : ''"
|
:class="mod.disabled ? 'opacity-75 grayscale' : ''"
|
||||||
/>
|
/>
|
||||||
<div class="flex min-w-0 flex-col">
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
<span class="flex min-w-0 items-center gap-2 text-lg font-bold">
|
<span class="text-md flex min-w-0 items-center gap-2 font-bold">
|
||||||
<span class="truncate">{{
|
<span class="truncate text-contrast">{{
|
||||||
mod.name || mod.filename.replace(".disabled", "")
|
mod.name || mod.filename.replace(".disabled", "")
|
||||||
}}</span>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
@ -146,132 +171,180 @@
|
|||||||
>Disabled</span
|
>Disabled</span
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
<span class="min-w-0 text-xs text-secondary">{{
|
<div class="min-w-0 text-xs text-secondary">
|
||||||
|
<span v-if="mod.owner" class="hidden sm:block"> by {{ mod.owner }} </span>
|
||||||
|
<span class="block font-semibold sm:hidden">
|
||||||
|
{{ mod.version_number || "External mod" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
<div class="ml-2 hidden min-w-0 flex-1 flex-col text-sm sm:flex">
|
||||||
|
<div class="truncate font-semibold text-contrast">
|
||||||
|
<span v-tooltip="'Mod version'">{{
|
||||||
mod.version_number || "External mod"
|
mod.version_number || "External mod"
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="truncate">
|
||||||
|
<span v-tooltip="'Mod file name'">{{ mod.filename }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
<div
|
||||||
<div class="flex items-center gap-2 pr-4 font-semibold text-contrast">
|
class="flex items-center justify-end gap-2 pr-4 font-semibold text-contrast sm:min-w-44"
|
||||||
<ButtonStyled v-if="mod.project_id" type="transparent">
|
>
|
||||||
<button
|
<ButtonStyled color="red" type="transparent">
|
||||||
v-tooltip="'Edit mod version'"
|
<button
|
||||||
:disabled="mod.changing"
|
v-tooltip="'Delete mod'"
|
||||||
class="!hidden sm:!block"
|
:disabled="mod.changing"
|
||||||
@click="beginChangeModVersion(mod)"
|
class="!hidden sm:!block"
|
||||||
>
|
@click="removeMod(mod)"
|
||||||
<template v-if="mod.changing">
|
|
||||||
<UiServersIconsLoadingIcon />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<EditIcon />
|
|
||||||
</template>
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled type="transparent">
|
|
||||||
<button
|
|
||||||
v-tooltip="'Delete mod'"
|
|
||||||
:disabled="mod.changing"
|
|
||||||
class="!hidden sm:!block"
|
|
||||||
@click="removeMod(mod)"
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
|
|
||||||
<!-- Dropdown for mobile -->
|
|
||||||
<div class="mr-2 flex items-center sm:hidden">
|
|
||||||
<UiServersIconsLoadingIcon
|
|
||||||
v-if="mod.changing"
|
|
||||||
class="mr-2 h-5 w-5 animate-spin"
|
|
||||||
style="color: var(--color-base)"
|
|
||||||
/>
|
|
||||||
<ButtonStyled v-else circular type="transparent">
|
|
||||||
<UiServersTeleportOverflowMenu
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
id: 'edit',
|
|
||||||
action: () => beginChangeModVersion(mod),
|
|
||||||
shown: !!(mod.project_id && !mod.changing),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'delete',
|
|
||||||
action: () => removeMod(mod),
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<MoreVerticalIcon aria-hidden="true" />
|
<TrashIcon />
|
||||||
<template #edit>
|
</button>
|
||||||
<EditIcon class="h-5 w-5" />
|
</ButtonStyled>
|
||||||
<span>Edit</span>
|
<ButtonStyled type="transparent">
|
||||||
</template>
|
<button
|
||||||
<template #delete>
|
v-tooltip="
|
||||||
<TrashIcon class="h-5 w-5" />
|
mod.project_id ? 'Edit mod version' : 'External mods cannot be edited'
|
||||||
<span>Delete</span>
|
"
|
||||||
</template>
|
:disabled="mod.changing || !mod.project_id"
|
||||||
</UiServersTeleportOverflowMenu>
|
class="!hidden sm:!block"
|
||||||
|
@click="beginChangeModVersion(mod)"
|
||||||
|
>
|
||||||
|
<template v-if="mod.changing">
|
||||||
|
<UiServersIconsLoadingIcon />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<EditIcon />
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
<!-- Dropdown for mobile -->
|
||||||
:id="`toggle-${mod.filename}`"
|
<div class="mr-2 flex items-center sm:hidden">
|
||||||
:checked="!mod.disabled"
|
<UiServersIconsLoadingIcon
|
||||||
:disabled="mod.changing"
|
v-if="mod.changing"
|
||||||
class="switch stylized-toggle"
|
class="mr-2 h-5 w-5 animate-spin"
|
||||||
type="checkbox"
|
style="color: var(--color-base)"
|
||||||
@change="toggleMod(mod)"
|
/>
|
||||||
/>
|
<ButtonStyled v-else circular type="transparent">
|
||||||
|
<UiServersTeleportOverflowMenu
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
id: 'edit',
|
||||||
|
action: () => beginChangeModVersion(mod),
|
||||||
|
shown: !!(mod.project_id && !mod.changing),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delete',
|
||||||
|
action: () => removeMod(mod),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<MoreVerticalIcon aria-hidden="true" />
|
||||||
|
<template #edit>
|
||||||
|
<EditIcon class="h-5 w-5" />
|
||||||
|
<span>Edit</span>
|
||||||
|
</template>
|
||||||
|
<template #delete>
|
||||||
|
<TrashIcon class="h-5 w-5" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</template>
|
||||||
|
</UiServersTeleportOverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
:id="`toggle-${mod.filename}`"
|
||||||
|
:checked="!mod.disabled"
|
||||||
|
:disabled="mod.changing"
|
||||||
|
class="switch stylized-toggle"
|
||||||
|
type="checkbox"
|
||||||
|
@change="toggleMod(mod)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- no mods has platform -->
|
||||||
<!-- no mods has platform -->
|
<div
|
||||||
<div
|
v-else-if="
|
||||||
v-else-if="
|
props.server.general?.loader &&
|
||||||
!hasMods &&
|
props.server.general?.loader.toLocaleLowerCase() !== 'vanilla'
|
||||||
props.server.general?.loader &&
|
"
|
||||||
props.server.general?.loader.toLocaleLowerCase() !== 'vanilla'
|
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"
|
<div
|
||||||
>
|
v-if="!hasFilteredMods && hasMods"
|
||||||
<PackageClosedIcon class="size-24" />
|
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||||
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
|
>
|
||||||
<p class="m-0">
|
<SearchIcon class="size-24" />
|
||||||
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
|
<p class="m-0 font-bold text-contrast">
|
||||||
</p>
|
No {{ type.toLocaleLowerCase() }}s found for your query!
|
||||||
<ButtonStyled color="brand">
|
</p>
|
||||||
<NuxtLink :to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`">
|
<p class="m-0">Try another query, or show everything.</p>
|
||||||
<PlusIcon />
|
<ButtonStyled>
|
||||||
Add {{ type.toLocaleLowerCase() }}
|
<button @click="showAll">
|
||||||
</NuxtLink>
|
<ListIcon />
|
||||||
</ButtonStyled>
|
Show everything
|
||||||
</div>
|
</button>
|
||||||
<div v-else class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center">
|
</ButtonStyled>
|
||||||
<UiServersIconsLoaderIcon loader="Vanilla" class="size-24" />
|
</div>
|
||||||
<p class="m-0 pt-3 font-bold text-contrast">Your server is running Vanilla Minecraft</p>
|
<div
|
||||||
<p class="m-0">
|
v-else
|
||||||
Add content to your server by installing a modpack or choosing a different platform that
|
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||||
supports {{ type }}s.
|
>
|
||||||
</p>
|
<PackageClosedIcon class="size-24" />
|
||||||
<div class="flex flex-row items-center gap-4">
|
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
|
||||||
<ButtonStyled class="mt-8">
|
<p class="m-0">
|
||||||
<NuxtLink :to="`/modpacks?sid=${props.server.serverId}`">
|
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
|
||||||
<CompassIcon />
|
</p>
|
||||||
Find a modpack
|
<div class="flex flex-row items-center gap-4">
|
||||||
</NuxtLink>
|
<ButtonStyled type="outlined">
|
||||||
</ButtonStyled>
|
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
|
||||||
<div>or</div>
|
<FileIcon />
|
||||||
<ButtonStyled class="mt-8">
|
Add file
|
||||||
<NuxtLink :to="`/servers/manage/${props.server.serverId}/options/loader`">
|
</button>
|
||||||
<WrenchIcon />
|
</ButtonStyled>
|
||||||
Change platform
|
<ButtonStyled color="brand">
|
||||||
</NuxtLink>
|
<nuxt-link
|
||||||
</ButtonStyled>
|
class="w-full text-nowrap sm:w-fit"
|
||||||
|
:to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
Add {{ type.toLocaleLowerCase() }}
|
||||||
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-else class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center">
|
||||||
|
<UiServersIconsLoaderIcon loader="Vanilla" class="size-24" />
|
||||||
|
<p class="m-0 pt-3 font-bold text-contrast">Your server is running Vanilla Minecraft</p>
|
||||||
|
<p class="m-0">
|
||||||
|
Add content to your server by installing a modpack or choosing a different platform that
|
||||||
|
supports {{ type }}s.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-row items-center gap-4">
|
||||||
|
<ButtonStyled class="mt-8">
|
||||||
|
<NuxtLink :to="`/modpacks?sid=${props.server.serverId}`">
|
||||||
|
<CompassIcon />
|
||||||
|
Find a modpack
|
||||||
|
</NuxtLink>
|
||||||
|
</ButtonStyled>
|
||||||
|
<div>or</div>
|
||||||
|
<ButtonStyled class="mt-8">
|
||||||
|
<NuxtLink :to="`/servers/manage/${props.server.serverId}/options/loader`">
|
||||||
|
<WrenchIcon />
|
||||||
|
Change platform
|
||||||
|
</NuxtLink>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FilesUploadDragAndDrop>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -290,10 +363,15 @@ import {
|
|||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
CompassIcon,
|
CompassIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
|
ListIcon,
|
||||||
|
FileIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
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 type { Server } from "~/composables/pyroServers";
|
||||||
|
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||||
@ -304,14 +382,7 @@ const type = computed(() => {
|
|||||||
return loader === "paper" || loader === "purpur" ? "Plugin" : "Mod";
|
return loader === "paper" || loader === "purpur" ? "Plugin" : "Mod";
|
||||||
});
|
});
|
||||||
|
|
||||||
interface Mod {
|
interface ContentItem extends Mod {
|
||||||
name?: string;
|
|
||||||
filename: string;
|
|
||||||
project_id?: string;
|
|
||||||
version_id?: string;
|
|
||||||
version_number?: string;
|
|
||||||
icon_url?: string;
|
|
||||||
disabled: boolean;
|
|
||||||
changing?: boolean;
|
changing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -322,12 +393,41 @@ const listContainer = ref<HTMLElement | null>(null);
|
|||||||
const windowScrollY = ref(0);
|
const windowScrollY = ref(0);
|
||||||
const windowHeight = ref(0);
|
const windowHeight = ref(0);
|
||||||
|
|
||||||
const localMods = ref<Mod[]>([]);
|
const localMods = ref<ContentItem[]>([]);
|
||||||
|
|
||||||
const searchInput = ref("");
|
const searchInput = ref("");
|
||||||
const modSearchInput = ref("");
|
const modSearchInput = ref("");
|
||||||
const filterMethod = ref("all");
|
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(() => {
|
const filterMethodLabel = computed(() => {
|
||||||
switch (filterMethod.value) {
|
switch (filterMethod.value) {
|
||||||
case "disabled":
|
case "disabled":
|
||||||
@ -419,14 +519,17 @@ const debouncedSearch = debounce(() => {
|
|||||||
modSearchInput.value = searchInput.value;
|
modSearchInput.value = searchInput.value;
|
||||||
|
|
||||||
if (pyroContentSentinel.value) {
|
if (pyroContentSentinel.value) {
|
||||||
pyroContentSentinel.value.scrollIntoView({
|
const sentinelRect = pyroContentSentinel.value.getBoundingClientRect();
|
||||||
behavior: "smooth",
|
if (sentinelRect.top < 0 || sentinelRect.bottom > window.innerHeight) {
|
||||||
block: "start",
|
pyroContentSentinel.value.scrollIntoView({
|
||||||
});
|
// behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
async function toggleMod(mod: Mod) {
|
async function toggleMod(mod: ContentItem) {
|
||||||
mod.changing = true;
|
mod.changing = true;
|
||||||
|
|
||||||
const originalFilename = mod.filename;
|
const originalFilename = mod.filename;
|
||||||
@ -458,7 +561,7 @@ async function toggleMod(mod: Mod) {
|
|||||||
mod.changing = false;
|
mod.changing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeMod(mod: Mod) {
|
async function removeMod(mod: ContentItem) {
|
||||||
mod.changing = true;
|
mod.changing = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -515,6 +618,10 @@ async function changeModVersion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasMods = computed(() => {
|
const hasMods = computed(() => {
|
||||||
|
return localMods.value?.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasFilteredMods = computed(() => {
|
||||||
return filteredMods.value?.length > 0;
|
return filteredMods.value?.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -25,12 +25,9 @@
|
|||||||
@delete="handleDeleteItem"
|
@delete="handleDeleteItem"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<FilesUploadDragAndDrop
|
||||||
class="relative flex w-full flex-col rounded-2xl border border-solid border-bg-raised"
|
class="relative flex w-full flex-col rounded-2xl border border-solid border-bg-raised"
|
||||||
@dragenter.prevent="handleDragEnter"
|
@files-dropped="handleDroppedFiles"
|
||||||
@dragover.prevent="handleDragOver"
|
|
||||||
@dragleave.prevent="handleDragLeave"
|
|
||||||
@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">
|
||||||
<div v-if="!isEditing" class="contents">
|
<div v-if="!isEditing" class="contents">
|
||||||
@ -44,94 +41,14 @@
|
|||||||
@upload="initiateFileUpload"
|
@upload="initiateFileUpload"
|
||||||
@update:search-query="searchQuery = $event"
|
@update:search-query="searchQuery = $event"
|
||||||
/>
|
/>
|
||||||
<Transition
|
<FilesUploadDropdown
|
||||||
name="upload-status"
|
v-if="props.server.fs"
|
||||||
@enter="onUploadStatusEnter"
|
ref="uploadDropdownRef"
|
||||||
@leave="onUploadStatusLeave"
|
class="rounded-b-xl border-0 border-t border-solid border-bg bg-table-alternateRow"
|
||||||
>
|
:current-path="currentPath"
|
||||||
<div
|
:fs="props.server.fs"
|
||||||
v-if="isUploading"
|
@upload-complete="refreshList()"
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<UiServersFilesEditingNavbar
|
<UiServersFilesEditingNavbar
|
||||||
@ -220,7 +137,7 @@
|
|||||||
<p class="mt-2 text-xl">Drop files here to upload</p>
|
<p class="mt-2 text-xl">Drop files here to upload</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FilesUploadDragAndDrop>
|
||||||
|
|
||||||
<UiServersFilesContextMenu
|
<UiServersFilesContextMenu
|
||||||
ref="contextMenu"
|
ref="contextMenu"
|
||||||
@ -238,9 +155,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useInfiniteScroll } from "@vueuse/core";
|
import { useInfiniteScroll } from "@vueuse/core";
|
||||||
import { UploadIcon, FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
import { UploadIcon, FolderOpenIcon } from "@modrinth/assets";
|
||||||
import { ButtonStyled } from "@modrinth/ui";
|
|
||||||
import type { DirectoryResponse, DirectoryItem, Server } from "~/composables/pyroServers";
|
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 {
|
interface BaseOperation {
|
||||||
type: "move" | "rename";
|
type: "move" | "rename";
|
||||||
@ -263,14 +181,6 @@ interface RenameOperation extends BaseOperation {
|
|||||||
|
|
||||||
type Operation = MoveOperation | RenameOperation;
|
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"]>;
|
||||||
}>();
|
}>();
|
||||||
@ -312,46 +222,8 @@ const isEditingImage = ref(false);
|
|||||||
const imagePreview = ref();
|
const imagePreview = ref();
|
||||||
|
|
||||||
const isDragging = ref(false);
|
const isDragging = ref(false);
|
||||||
const dragCounter = ref(0);
|
|
||||||
|
|
||||||
const uploadStatusRef = ref<HTMLElement | null>(null);
|
const uploadDropdownRef = ref();
|
||||||
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);
|
||||||
|
|
||||||
@ -917,135 +789,12 @@ const requestShareLink = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnter = (event: DragEvent) => {
|
const handleDroppedFiles = (files: File[]) => {
|
||||||
if (isEditing.value) return;
|
if (isEditing.value) return;
|
||||||
event.preventDefault();
|
|
||||||
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
|
|
||||||
dragCounter.value++;
|
|
||||||
isDragging.value = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (event: DragEvent) => {
|
files.forEach((file) => {
|
||||||
if (isEditing.value) return;
|
uploadDropdownRef.value?.uploadFile(file);
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const initiateFileUpload = () => {
|
const initiateFileUpload = () => {
|
||||||
@ -1055,7 +804,7 @@ const initiateFileUpload = () => {
|
|||||||
input.onchange = () => {
|
input.onchange = () => {
|
||||||
if (input.files) {
|
if (input.files) {
|
||||||
Array.from(input.files).forEach((file) => {
|
Array.from(input.files).forEach((file) => {
|
||||||
uploadFile(file);
|
uploadDropdownRef.value?.uploadFile(file);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -330,11 +330,11 @@
|
|||||||
<UploadIcon class="size-4" /> Upload .mrpack file
|
<UploadIcon class="size-4" /> Upload .mrpack file
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<DownloadIcon v-if="hasNewerVersion" color="brand">
|
<ButtonStyled v-if="hasNewerVersion" color="brand">
|
||||||
<button class="!w-full sm:!w-auto" @click="handleUpdateToLatest">
|
<button class="!w-full sm:!w-auto" @click="handleUpdateToLatest">
|
||||||
<UploadIcon class="size-4" /> Update modpack
|
<UploadIcon class="size-4" /> Update modpack
|
||||||
</button>
|
</button>
|
||||||
</DownloadIcon>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="data.upstream" class="contents">
|
<div v-if="data.upstream" class="contents">
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
export interface Mod {
|
// export interface Mod {
|
||||||
id: string;
|
// id: string;
|
||||||
filename: string;
|
// filename: string;
|
||||||
modrinth_ids: {
|
// modrinth_ids: {
|
||||||
project_id: string;
|
// project_id: string;
|
||||||
version_id: string;
|
// version_id: string;
|
||||||
};
|
// };
|
||||||
}
|
// }
|
||||||
|
|
||||||
interface License {
|
interface License {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user