-
Notifications
You must be signed in to change notification settings - Fork 206
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]> * chore: adjust btn styles Signed-off-by: Evan Song <[email protected]> * feat: prepare for mod author in backend response Signed-off-by: Evan Song <[email protected]> * fix: external mods & mobile * chore: adjust edit mod version modal copy Signed-off-by: Evan Song <[email protected]> * chore: add tooltips for version/filename Signed-off-by: Evan Song <[email protected]> * chore: swap delete/change version btn Signed-off-by: Evan Song <[email protected]> * fix: dont allow mod link to be dragged Signed-off-by: Evan Song <[email protected]> * fix: oops Signed-off-by: Evan Song <[email protected]> * chore: remove author field Signed-off-by: Evan Song <[email protected]> * chore: drill down tooltip Signed-off-by: Evan Song <[email protected]> * fix: fighting types Signed-off-by: Evan Song <[email protected]> * prepare for owner field Signed-off-by: Evan Song <[email protected]> --------- Signed-off-by: Evan Song <[email protected]> Co-authored-by: Evan Song <[email protected]> Co-authored-by: Evan Song <[email protected]>
- Loading branch information
1 parent
2fea772
commit 0437503
Showing
7 changed files
with
694 additions
and
455 deletions.
There are no files selected for viewing
75 changes: 75 additions & 0 deletions
75
apps/frontend/src/components/ui/servers/FilesUploadDragAndDrop.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
306
apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
Oops, something went wrong.