Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(servers content): file upload + extra mod info + misc #3055

Merged
merged 24 commits into from
Dec 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d2643e8
feat: only scroll up if scrolled down
he3als Dec 22, 2024
0e629e8
feat: no query results message
he3als Dec 22, 2024
7419b08
feat: content files support, mobile fixes
he3als Dec 23, 2024
97abefd
fix(drag & drop): type of file prop
he3als Dec 23, 2024
7cd53d0
Merge remote-tracking branch 'upstream/main' into he3als/content-page…
he3als Dec 23, 2024
140e5f5
chore: show number of mods in searchbar
ferothefox Dec 23, 2024
e08a5c5
chore: adjust btn styles
ferothefox Dec 23, 2024
ba178f7
feat: prepare for mod author in backend response
ferothefox Dec 23, 2024
87ac9b0
fix: external mods & mobile
he3als Dec 23, 2024
aa81940
Merge branch 'modrinth:main' into he3als/content-page-improvements
ferothefox Dec 23, 2024
1756458
chore: adjust edit mod version modal copy
ferothefox Dec 23, 2024
e8ad4c9
chore: add tooltips for version/filename
ferothefox Dec 23, 2024
ec982d6
chore: swap delete/change version btn
ferothefox Dec 23, 2024
318f5b5
fix: dont allow mod link to be dragged
ferothefox Dec 23, 2024
0610c46
fix: oops
ferothefox Dec 23, 2024
39a8347
chore: remove author field
ferothefox Dec 23, 2024
56742f9
chore: drill down tooltip
ferothefox Dec 23, 2024
8d617f2
Merge branch 'modrinth:main' into he3als/content-page-improvements
ferothefox Dec 24, 2024
437dd0f
Merge branch 'modrinth:main' into he3als/content-page-improvements
ferothefox Dec 25, 2024
1fec577
Merge branch 'modrinth:main' into he3als/content-page-improvements
ferothefox Dec 26, 2024
a0c4923
Merge branch 'modrinth:main' into he3als/content-page-improvements
ferothefox Dec 28, 2024
d148a3f
Merge branch 'modrinth:main' into he3als/content-page-improvements
ferothefox Dec 28, 2024
45103b8
fix: fighting types
ferothefox Dec 28, 2024
7fabcb4
prepare for owner field
ferothefox Dec 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions apps/frontend/src/components/ui/servers/FilesUploadDragAndDrop.vue
Original file line number Diff line number Diff line change
@@ -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 changes: 306 additions & 0 deletions apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
Original file line number Diff line number Diff line change
@@ -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>
Loading
Loading