refactor upload array

This commit is contained in:
Ramires Viana 2025-08-02 12:12:01 -03:00
parent 7c713f27fa
commit 9ef68e7f85
7 changed files with 111 additions and 203 deletions

View File

@ -13,7 +13,7 @@ export default async function search(base: string, query: string) {
let data = await res.json(); let data = await res.json();
data = data.map((item: UploadItem) => { data = data.map((item: ResourceItem & { dir: boolean }) => {
item.url = `/files${base}` + url.encodePath(item.path); item.url = `/files${base}` + url.encodePath(item.path);
if (item.dir) { if (item.dir) {

View File

@ -1,20 +1,25 @@
<template> <template>
<div <div
v-if="filesInUploadCount > 0" v-if="uploadStore.activeUploads.size > 0"
class="upload-files" class="upload-files"
v-bind:class="{ closed: !open }" v-bind:class="{ closed: !open }"
> >
<div class="card floating"> <div class="card floating">
<div class="card-title"> <div class="card-title">
<h2>{{ $t("prompts.uploadFiles", { files: filesInUploadCount }) }}</h2> <h2>
{{
$t("prompts.uploadFiles", { files: uploadStore.activeUploads.size })
}}
</h2>
<div class="upload-info"> <div class="upload-info">
<div class="upload-speed">{{ speed.toFixed(2) }} MB/s</div> <div class="upload-speed">{{ speed.toFixed(2) }} MB/s</div>
<div class="upload-eta">{{ formattedETA }} remaining</div> <div class="upload-eta">{{ formattedETA }} remaining</div>
<div class="upload-percentage"> <div class="upload-percentage">
{{ getProgressDecimal }}% Completed {{ uploadStore.getProgressDecimal }}% Completed
</div> </div>
<div class="upload-fraction"> <div class="upload-fraction">
{{ getTotalProgressBytes }} / {{ getTotalSize }} {{ uploadStore.getTotalProgressBytes }} /
{{ uploadStore.getTotalSize }}
</div> </div>
</div> </div>
<button <button
@ -40,17 +45,21 @@
<div class="card-content file-icons"> <div class="card-content file-icons">
<div <div
class="file" class="file"
v-for="file in filesInUpload" v-for="upload in uploadStore.activeUploads"
:key="file.id" :key="upload.path"
:data-dir="file.isDir" :data-dir="upload.type === 'dir'"
:data-type="file.type" :data-type="upload.type"
:aria-label="file.name" :aria-label="upload.name"
> >
<div class="file-name"> <div class="file-name">
<i class="material-icons"></i> {{ file.name }} <i class="material-icons"></i> {{ upload.name }}
</div> </div>
<div class="file-progress"> <div class="file-progress">
<div v-bind:style="{ width: file.progress + '%' }"></div> <div
v-bind:style="{
width: (upload.sentBytes / upload.totalBytes) * 100 + '%',
}"
></div>
</div> </div>
</div> </div>
</div> </div>
@ -76,22 +85,14 @@ const eta = ref<number>(Infinity);
const fileStore = useFileStore(); const fileStore = useFileStore();
const uploadStore = useUploadStore(); const uploadStore = useUploadStore();
const { const { sentBytes } = storeToRefs(uploadStore);
filesInUpload,
filesInUploadCount,
getProgressDecimal,
getTotalProgressBytes,
getTotalProgress,
getTotalSize,
getTotalBytes,
} = storeToRefs(uploadStore);
let lastSpeedUpdate: number = 0; let lastSpeedUpdate: number = 0;
const recentSpeeds: number[] = []; const recentSpeeds: number[] = [];
const calculateSpeed = (progress: number, oldProgress: number) => { const calculateSpeed = (sentBytes: number, oldSentBytes: number) => {
const elapsedTime = (Date.now() - (lastSpeedUpdate ?? 0)) / 1000; const elapsedTime = (Date.now() - (lastSpeedUpdate ?? 0)) / 1000;
const bytesSinceLastUpdate = progress - oldProgress; const bytesSinceLastUpdate = sentBytes - oldSentBytes;
const currentSpeed = bytesSinceLastUpdate / (1024 * 1024) / elapsedTime; const currentSpeed = bytesSinceLastUpdate / (1024 * 1024) / elapsedTime;
recentSpeeds.push(currentSpeed); recentSpeeds.push(currentSpeed);
@ -115,13 +116,13 @@ const calculateEta = () => {
return Infinity; return Infinity;
} }
const remainingSize = getTotalBytes.value - getTotalProgress.value; const remainingSize = uploadStore.totalBytes - uploadStore.sentBytes;
const speedBytesPerSecond = speed.value * 1024 * 1024; const speedBytesPerSecond = speed.value * 1024 * 1024;
eta.value = remainingSize / speedBytesPerSecond; eta.value = remainingSize / speedBytesPerSecond;
}; };
watch(getTotalProgress, calculateSpeed); watch(sentBytes, calculateSpeed);
const formattedETA = computed(() => { const formattedETA = computed(() => {
if (!eta.value || eta.value === Infinity) { if (!eta.value || eta.value === Infinity) {

View File

@ -3,7 +3,7 @@ import { useFileStore } from "./file";
import { files as api } from "@/api"; import { files as api } from "@/api";
import { throttle } from "lodash-es"; import { throttle } from "lodash-es";
import buttons from "@/utils/buttons"; import buttons from "@/utils/buttons";
import { computed, ref } from "vue"; import { computed, inject, ref } from "vue";
// TODO: make this into a user setting // TODO: make this into a user setting
const UPLOADS_LIMIT = 5; const UPLOADS_LIMIT = 5;
@ -27,162 +27,104 @@ function formatSize(bytes: number): string {
} }
export const useUploadStore = defineStore("upload", () => { export const useUploadStore = defineStore("upload", () => {
const $showError = inject<IToastError>("$showError")!;
// //
// STATE // STATE
// //
const id = ref<number>(0); const allUploads = ref<Upload[]>([]);
const sizes = ref<number[]>([]); const activeUploads = ref<Set<Upload>>(new Set());
const progress = ref<number[]>([]); const lastUpload = ref<number>(-1);
const queue = ref<UploadItem[]>([]); const totalBytes = ref<number>(0);
const uploads = ref<Uploads>({}); const sentBytes = ref<number>(0);
const error = ref<Error | null>(null);
// //
// GETTERS // GETTERS
// //
const getProgress = computed(() => { const getProgress = computed(() => {
if (progress.value.length === 0) { return Math.ceil((sentBytes.value / totalBytes.value) * 100);
return 0;
}
const totalSize = sizes.value.reduce((a, b) => a + b, 0);
const sum = progress.value.reduce((a, b) => a + b, 0);
return Math.ceil((sum / totalSize) * 100);
}); });
const getProgressDecimal = computed(() => { const getProgressDecimal = computed(() => {
if (progress.value.length === 0) { return ((sentBytes.value / totalBytes.value) * 100).toFixed(2);
return 0;
}
const totalSize = sizes.value.reduce((a, b) => a + b, 0);
const sum = progress.value.reduce((a, b) => a + b, 0);
return ((sum / totalSize) * 100).toFixed(2);
}); });
const getTotalProgressBytes = computed(() => { const getTotalProgressBytes = computed(() => {
if (progress.value.length === 0 || sizes.value.length === 0) { return formatSize(sentBytes.value);
return "0 Bytes";
}
const sum = progress.value.reduce((a, b) => a + b, 0);
return formatSize(sum);
});
const getTotalProgress = computed(() => {
return progress.value.reduce((a, b) => a + b, 0);
}); });
const getTotalSize = computed(() => { const getTotalSize = computed(() => {
if (sizes.value.length === 0) { return formatSize(totalBytes.value);
return "0 Bytes";
}
const totalSize = sizes.value.reduce((a, b) => a + b, 0);
return formatSize(totalSize);
});
const getTotalBytes = computed(() => {
return sizes.value.reduce((a, b) => a + b, 0);
});
const filesInUploadCount = computed(() => {
return Object.keys(uploads.value).length + queue.value.length;
});
const filesInUpload = computed(() => {
const files = [];
for (const index in uploads.value) {
const upload = uploads.value[index];
const id = upload.id;
const type = upload.type;
const name = upload.file.name;
const size = sizes.value[id];
const isDir = upload.file.isDir;
const p = isDir ? 100 : Math.ceil((progress.value[id] / size) * 100);
files.push({
id,
name,
progress: p,
type,
isDir,
});
}
return files.sort((a, b) => a.progress - b.progress);
}); });
// //
// ACTIONS // ACTIONS
// //
const setProgress = ({ id, loaded }: { id: number; loaded: number }) => {
progress.value[id] = loaded;
};
const setError = (err: Error) => {
error.value = err;
};
const reset = () => { const reset = () => {
id.value = 0; allUploads.value = [];
sizes.value = []; activeUploads.value = new Set();
progress.value = []; lastUpload.value = -1;
queue.value = []; totalBytes.value = 0;
uploads.value = {}; sentBytes.value = 0;
error.value = null;
}; };
const addJob = (item: UploadItem) => { const nextUpload = (): Upload => {
queue.value.push(item); lastUpload.value++;
sizes.value[id.value] = item.file.size;
id.value++; const upload = allUploads.value[lastUpload.value];
activeUploads.value.add(upload);
return upload;
}; };
const moveJob = () => { const hasActiveUploads = () => activeUploads.value.size > 0;
const item = queue.value[0];
queue.value.shift();
uploads.value[item.id] = item;
};
const removeJob = (id: number) => { const hasPendingUploads = () =>
delete uploads.value[id]; allUploads.value.length > lastUpload.value + 1;
};
const upload = (item: UploadItem) => { const upload = (
const uploadsCount = Object.keys(uploads.value).length; path: string,
name: string,
const isQueueEmpty = queue.value.length == 0; file: File | null,
const isUploadsEmpty = uploadsCount == 0; overwrite: boolean,
type: ResourceType
if (isQueueEmpty && isUploadsEmpty) { ) => {
if (!hasActiveUploads() && !hasPendingUploads()) {
window.addEventListener("beforeunload", beforeUnload); window.addEventListener("beforeunload", beforeUnload);
buttons.loading("upload"); buttons.loading("upload");
} }
addJob(item); const upload: Upload = {
path,
name,
file,
overwrite,
type,
totalBytes: file?.size ?? 0,
sentBytes: 0,
};
totalBytes.value += upload.totalBytes;
allUploads.value.push(upload);
processUploads(); processUploads();
}; };
const finishUpload = (item: UploadItem) => { const finishUpload = (upload: Upload) => {
setProgress({ id: item.id, loaded: item.file.size }); upload.sentBytes = upload.totalBytes;
removeJob(item.id); upload.file = null;
activeUploads.value.delete(upload);
processUploads(); processUploads();
}; };
const isActiveUploadsOnLimit = () => activeUploads.value.size < UPLOADS_LIMIT;
const processUploads = async () => { const processUploads = async () => {
const uploadsCount = Object.keys(uploads.value).length; if (!hasActiveUploads() && !hasPendingUploads()) {
const isBelowLimit = uploadsCount < UPLOADS_LIMIT;
const isQueueEmpty = queue.value.length == 0;
const isUploadsEmpty = uploadsCount == 0;
const isFinished = isQueueEmpty && isUploadsEmpty;
const canProcess = isBelowLimit && !isQueueEmpty;
if (isFinished) {
const fileStore = useFileStore(); const fileStore = useFileStore();
window.removeEventListener("beforeunload", beforeUnload); window.removeEventListener("beforeunload", beforeUnload);
buttons.success("upload"); buttons.success("upload");
@ -190,58 +132,48 @@ export const useUploadStore = defineStore("upload", () => {
fileStore.reload = true; fileStore.reload = true;
} }
if (canProcess) { if (isActiveUploadsOnLimit() && hasPendingUploads()) {
const item = queue.value[0]; const upload = nextUpload();
moveJob();
if (item.file.isDir) { if (upload.type === "dir") {
await api.post(item.path).catch(setError); await api.post(upload.path).catch($showError);
} else { } else {
const onUpload = throttle( const onUpload = throttle(
(event: ProgressEvent) => (event: ProgressEvent) => {
setProgress({ const delta = event.loaded - upload.sentBytes;
id: item.id, sentBytes.value += delta;
loaded: event.loaded,
}), upload.sentBytes = event.loaded;
},
100, 100,
{ leading: true, trailing: false } { leading: true, trailing: false }
); );
await api await api
.post(item.path, item.file.file as File, item.overwrite, onUpload) .post(upload.path, upload.file!, upload.overwrite, onUpload)
.catch(setError); .catch($showError);
} }
finishUpload(item); finishUpload(upload);
} }
}; };
return { return {
// STATE // STATE
id, allUploads,
sizes, activeUploads,
progress, lastUpload,
queue, totalBytes,
uploads, sentBytes,
error,
// GETTERS // GETTERS
getProgress, getProgress,
getProgressDecimal, getProgressDecimal,
getTotalProgressBytes, getTotalProgressBytes,
getTotalProgress,
getTotalSize, getTotalSize,
getTotalBytes,
filesInUploadCount,
filesInUpload,
// ACTIONS // ACTIONS
setProgress,
setError,
reset, reset,
addJob,
moveJob,
removeJob,
upload, upload,
finishUpload, finishUpload,
processUploads, processUploads,

View File

@ -29,6 +29,7 @@ interface ResourceItem extends ResourceBase {
} }
type ResourceType = type ResourceType =
| "dir"
| "video" | "video"
| "audio" | "audio"
| "image" | "image"

View File

@ -1,22 +1,12 @@
interface Uploads { type Upload = {
[key: number]: Upload;
}
interface Upload {
id: number;
file: UploadEntry;
type?: ResourceType;
}
interface UploadItem {
id: number;
url?: string;
path: string; path: string;
file: UploadEntry; name: string;
dir?: boolean; file: File | null;
overwrite?: boolean; type: ResourceType;
type?: ResourceType; overwrite: boolean;
} totalBytes: number;
sentBytes: number;
};
interface UploadEntry { interface UploadEntry {
name: string; name: string;

View File

@ -132,7 +132,6 @@ export function handleFiles(
layoutStore.closeHovers(); layoutStore.closeHovers();
for (const file of files) { for (const file of files) {
const id = uploadStore.id;
let path = base; let path = base;
if (file.fullPath !== undefined) { if (file.fullPath !== undefined) {
@ -145,14 +144,8 @@ export function handleFiles(
path += "/"; path += "/";
} }
const item: UploadItem = { const type = file.isDir ? "dir" : detectType((file.file as File).type);
id,
path,
file,
overwrite,
...(!file.isDir && { type: detectType((file.file as File).type) }),
};
uploadStore.upload(item); uploadStore.upload(path, file.name, file.file ?? null, overwrite, type);
} }
} }

View File

@ -26,7 +26,6 @@
import { import {
computed, computed,
defineAsyncComponent, defineAsyncComponent,
inject,
onBeforeUnmount, onBeforeUnmount,
onMounted, onMounted,
onUnmounted, onUnmounted,
@ -37,7 +36,6 @@ import { files as api } from "@/api";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { useFileStore } from "@/stores/file"; import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout"; import { useLayoutStore } from "@/stores/layout";
import { useUploadStore } from "@/stores/upload";
import HeaderBar from "@/components/header/HeaderBar.vue"; import HeaderBar from "@/components/header/HeaderBar.vue";
import Breadcrumbs from "@/components/Breadcrumbs.vue"; import Breadcrumbs from "@/components/Breadcrumbs.vue";
@ -51,14 +49,10 @@ import { name } from "../utils/constants";
const Editor = defineAsyncComponent(() => import("@/views/files/Editor.vue")); const Editor = defineAsyncComponent(() => import("@/views/files/Editor.vue"));
const Preview = defineAsyncComponent(() => import("@/views/files/Preview.vue")); const Preview = defineAsyncComponent(() => import("@/views/files/Preview.vue"));
const $showError = inject<IToastError>("$showError")!;
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const fileStore = useFileStore(); const fileStore = useFileStore();
const uploadStore = useUploadStore();
const { reload } = storeToRefs(fileStore); const { reload } = storeToRefs(fileStore);
const { error: uploadError } = storeToRefs(uploadStore);
const route = useRoute(); const route = useRoute();
@ -111,9 +105,6 @@ watch(route, () => {
watch(reload, (newValue) => { watch(reload, (newValue) => {
newValue && fetchData(); newValue && fetchData();
}); });
watch(uploadError, (newValue) => {
newValue && $showError(newValue);
});
// Define functions // Define functions