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:
he3als 2024-12-29 00:31:52 +00:00 committed by GitHub
parent 2fea772ffb
commit 0437503b75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 696 additions and 457 deletions

View File

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

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

View File

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

View File

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

View File

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

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

View File

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