Skip to content

Commit 0437503

Browse files
he3alsferothefox
andauthored
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>
1 parent 2fea772 commit 0437503

File tree

7 files changed

+694
-455
lines changed

7 files changed

+694
-455
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<template>
2+
<div
3+
@dragenter.prevent="handleDragEnter"
4+
@dragover.prevent="handleDragOver"
5+
@dragleave.prevent="handleDragLeave"
6+
@drop.prevent="handleDrop"
7+
>
8+
<slot />
9+
<div
10+
v-if="isDragging"
11+
:class="[
12+
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black bg-opacity-50 text-white',
13+
overlayClass,
14+
]"
15+
>
16+
<div class="text-center">
17+
<UploadIcon class="mx-auto h-16 w-16" />
18+
<p class="mt-2 text-xl">
19+
Drop {{ type ? type.toLocaleLowerCase() : "file" }}s here to upload
20+
</p>
21+
</div>
22+
</div>
23+
</div>
24+
</template>
25+
26+
<script setup lang="ts">
27+
import { UploadIcon } from "@modrinth/assets";
28+
import { ref } from "vue";
29+
30+
const emit = defineEmits<{
31+
(event: "filesDropped", files: File[]): void;
32+
}>();
33+
34+
defineProps<{
35+
overlayClass?: string;
36+
type?: string;
37+
}>();
38+
39+
const isDragging = ref(false);
40+
const dragCounter = ref(0);
41+
42+
const handleDragEnter = (event: DragEvent) => {
43+
event.preventDefault();
44+
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
45+
dragCounter.value++;
46+
isDragging.value = true;
47+
}
48+
};
49+
50+
const handleDragOver = (event: DragEvent) => {
51+
event.preventDefault();
52+
};
53+
54+
const handleDragLeave = (event: DragEvent) => {
55+
event.preventDefault();
56+
dragCounter.value--;
57+
if (dragCounter.value === 0) {
58+
isDragging.value = false;
59+
}
60+
};
61+
62+
const handleDrop = (event: DragEvent) => {
63+
event.preventDefault();
64+
isDragging.value = false;
65+
dragCounter.value = 0;
66+
67+
const isInternalMove = event.dataTransfer?.types.includes("application/pyro-file-move");
68+
if (isInternalMove) return;
69+
70+
const files = event.dataTransfer?.files;
71+
if (files) {
72+
emit("filesDropped", Array.from(files));
73+
}
74+
};
75+
</script>
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
<template>
2+
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
3+
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
4+
<div
5+
ref="statusContentRef"
6+
:class="['flex flex-col p-4 text-sm text-contrast', $attrs.class]"
7+
>
8+
<div class="flex items-center justify-between">
9+
<div class="flex items-center gap-2 font-bold">
10+
<FolderOpenIcon class="size-4" />
11+
<span>
12+
<span class="capitalize">
13+
{{ props.fileType ? props.fileType : "File" }} Uploads
14+
</span>
15+
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
16+
</span>
17+
</div>
18+
</div>
19+
20+
<div class="mt-2 space-y-2">
21+
<div
22+
v-for="item in uploadQueue"
23+
:key="item.file.name"
24+
class="flex h-6 items-center justify-between gap-2 text-xs"
25+
>
26+
<div class="flex flex-1 items-center gap-2 truncate">
27+
<transition-group name="status-icon" mode="out-in">
28+
<UiServersPanelSpinner
29+
v-show="item.status === 'uploading'"
30+
key="spinner"
31+
class="absolute !size-4"
32+
/>
33+
<CheckCircleIcon
34+
v-show="item.status === 'completed'"
35+
key="check"
36+
class="absolute size-4 text-green"
37+
/>
38+
<XCircleIcon
39+
v-show="
40+
item.status === 'error' ||
41+
item.status === 'cancelled' ||
42+
item.status === 'incorrect-type'
43+
"
44+
key="error"
45+
class="absolute size-4 text-red"
46+
/>
47+
</transition-group>
48+
<span class="ml-6 truncate">{{ item.file.name }}</span>
49+
<span class="text-secondary">{{ item.size }}</span>
50+
</div>
51+
<div class="flex min-w-[80px] items-center justify-end gap-2">
52+
<template v-if="item.status === 'completed'">
53+
<span>Done</span>
54+
</template>
55+
<template v-else-if="item.status === 'error'">
56+
<span class="text-red">Failed - File already exists</span>
57+
</template>
58+
<template v-else-if="item.status === 'incorrect-type'">
59+
<span class="text-red">Failed - Incorrect file type</span>
60+
</template>
61+
<template v-else>
62+
<template v-if="item.status === 'uploading'">
63+
<span>{{ item.progress }}%</span>
64+
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
65+
<div
66+
class="h-full bg-contrast transition-all duration-200"
67+
:style="{ width: item.progress + '%' }"
68+
/>
69+
</div>
70+
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
71+
<button>Cancel</button>
72+
</ButtonStyled>
73+
</template>
74+
<template v-else-if="item.status === 'cancelled'">
75+
<span class="text-red">Cancelled</span>
76+
</template>
77+
<template v-else>
78+
<span>{{ item.progress }}%</span>
79+
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
80+
<div
81+
class="h-full bg-contrast transition-all duration-200"
82+
:style="{ width: item.progress + '%' }"
83+
/>
84+
</div>
85+
</template>
86+
</template>
87+
</div>
88+
</div>
89+
</div>
90+
</div>
91+
</div>
92+
</Transition>
93+
</template>
94+
95+
<script setup lang="ts">
96+
import { FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
97+
import { ButtonStyled } from "@modrinth/ui";
98+
import { ref, computed, watch, nextTick } from "vue";
99+
100+
interface UploadItem {
101+
file: File;
102+
progress: number;
103+
status: "pending" | "uploading" | "completed" | "error" | "cancelled" | "incorrect-type";
104+
size: string;
105+
uploader?: any;
106+
}
107+
108+
interface Props {
109+
currentPath: string;
110+
fileType?: string;
111+
marginBottom?: number;
112+
acceptedTypes?: Array<string>;
113+
fs: FSModule;
114+
}
115+
116+
defineOptions({
117+
inheritAttrs: false,
118+
});
119+
120+
const props = defineProps<Props>();
121+
122+
const emit = defineEmits<{
123+
(e: "uploadComplete"): void;
124+
}>();
125+
126+
const uploadStatusRef = ref<HTMLElement | null>(null);
127+
const statusContentRef = ref<HTMLElement | null>(null);
128+
const uploadQueue = ref<UploadItem[]>([]);
129+
130+
const isUploading = computed(() => uploadQueue.value.length > 0);
131+
const activeUploads = computed(() =>
132+
uploadQueue.value.filter((item) => item.status === "pending" || item.status === "uploading"),
133+
);
134+
135+
const onUploadStatusEnter = (el: Element) => {
136+
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
137+
(el as HTMLElement).style.height = "0";
138+
// eslint-disable-next-line no-void
139+
void (el as HTMLElement).offsetHeight;
140+
(el as HTMLElement).style.height = `${height}px`;
141+
};
142+
143+
const onUploadStatusLeave = (el: Element) => {
144+
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
145+
(el as HTMLElement).style.height = `${height}px`;
146+
// eslint-disable-next-line no-void
147+
void (el as HTMLElement).offsetHeight;
148+
(el as HTMLElement).style.height = "0";
149+
};
150+
151+
watch(
152+
uploadQueue,
153+
() => {
154+
if (!uploadStatusRef.value) return;
155+
const el = uploadStatusRef.value;
156+
const itemsHeight = uploadQueue.value.length * 32;
157+
const headerHeight = 12;
158+
const gap = 8;
159+
const padding = 32;
160+
const totalHeight = padding + headerHeight + gap + itemsHeight + (props.marginBottom || 0);
161+
el.style.height = `${totalHeight}px`;
162+
},
163+
{ deep: true },
164+
);
165+
166+
const formatFileSize = (bytes: number): string => {
167+
if (bytes < 1024) return bytes + " B";
168+
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + " KB";
169+
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + " MB";
170+
return (bytes / 1024 ** 3).toFixed(1) + " GB";
171+
};
172+
173+
const cancelUpload = (item: UploadItem) => {
174+
if (item.uploader && item.status === "uploading") {
175+
item.uploader.cancel();
176+
item.status = "cancelled";
177+
178+
setTimeout(async () => {
179+
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name);
180+
if (index !== -1) {
181+
uploadQueue.value.splice(index, 1);
182+
await nextTick();
183+
}
184+
}, 5000);
185+
}
186+
};
187+
188+
const badFileTypeMsg = "Upload had incorrect file type";
189+
const uploadFile = async (file: File) => {
190+
const uploadItem: UploadItem = {
191+
file,
192+
progress: 0,
193+
status: "pending",
194+
size: formatFileSize(file.size),
195+
};
196+
197+
uploadQueue.value.push(uploadItem);
198+
199+
try {
200+
if (
201+
props.acceptedTypes &&
202+
!props.acceptedTypes.includes(file.type) &&
203+
!props.acceptedTypes.some((type) => file.name.endsWith(type))
204+
) {
205+
throw new Error(badFileTypeMsg);
206+
}
207+
208+
uploadItem.status = "uploading";
209+
const filePath = `${props.currentPath}/${file.name}`.replace("//", "/");
210+
const uploader = await props.fs.uploadFile(filePath, file);
211+
uploadItem.uploader = uploader;
212+
213+
if (uploader?.onProgress) {
214+
uploader.onProgress(({ progress }: { progress: number }) => {
215+
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
216+
if (index !== -1) {
217+
uploadQueue.value[index].progress = Math.round(progress);
218+
}
219+
});
220+
}
221+
222+
await uploader?.promise;
223+
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
224+
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
225+
uploadQueue.value[index].status = "completed";
226+
uploadQueue.value[index].progress = 100;
227+
}
228+
229+
await nextTick();
230+
231+
setTimeout(async () => {
232+
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
233+
if (removeIndex !== -1) {
234+
uploadQueue.value.splice(removeIndex, 1);
235+
await nextTick();
236+
}
237+
}, 5000);
238+
239+
emit("uploadComplete");
240+
} catch (error) {
241+
console.error("Error uploading file:", error);
242+
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
243+
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
244+
uploadQueue.value[index].status =
245+
error instanceof Error && error.message === badFileTypeMsg ? "incorrect-type" : "error";
246+
}
247+
248+
setTimeout(async () => {
249+
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
250+
if (removeIndex !== -1) {
251+
uploadQueue.value.splice(removeIndex, 1);
252+
await nextTick();
253+
}
254+
}, 5000);
255+
256+
if (error instanceof Error && error.message !== "Upload cancelled") {
257+
addNotification({
258+
group: "files",
259+
title: "Upload failed",
260+
text: `Failed to upload ${file.name}`,
261+
type: "error",
262+
});
263+
}
264+
}
265+
};
266+
267+
defineExpose({
268+
uploadFile,
269+
cancelUpload,
270+
});
271+
</script>
272+
273+
<style scoped>
274+
.upload-status {
275+
overflow: hidden;
276+
transition: height 0.2s ease;
277+
}
278+
279+
.upload-status-enter-active,
280+
.upload-status-leave-active {
281+
transition: height 0.2s ease;
282+
overflow: hidden;
283+
}
284+
285+
.upload-status-enter-from,
286+
.upload-status-leave-to {
287+
height: 0 !important;
288+
}
289+
290+
.status-icon-enter-active,
291+
.status-icon-leave-active {
292+
transition: all 0.25s ease;
293+
}
294+
295+
.status-icon-enter-from,
296+
.status-icon-leave-to {
297+
transform: scale(0);
298+
opacity: 0;
299+
}
300+
301+
.status-icon-enter-to,
302+
.status-icon-leave-from {
303+
transform: scale(1);
304+
opacity: 1;
305+
}
306+
</style>

0 commit comments

Comments
 (0)