the most notable improvement is that uploads are now using streams so they no longer require the entire file to be stored in memory
306 lines
9.0 KiB
Vue
306 lines
9.0 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
Dialog,
|
|
DialogTrigger,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import byteSize from 'byte-size';
|
|
import { uploadToWarren } from '~/lib/api/warrens';
|
|
import { toast } from 'vue-sonner';
|
|
import UploadListEntry from './UploadListEntry.vue';
|
|
|
|
const warrenStore = useWarrenStore();
|
|
const uploadStore = useUploadStore();
|
|
|
|
const currentAndUploadRouteMatch = computed(
|
|
() =>
|
|
uploadStore.destination != null &&
|
|
warrenStore.current != null &&
|
|
uploadStore.destination.warrenId == warrenStore.current.warrenId &&
|
|
uploadStore.destination.path == warrenStore.current.path
|
|
);
|
|
|
|
const fileInputElement = ref<HTMLInputElement>(
|
|
null as unknown as HTMLInputElement
|
|
);
|
|
|
|
const uploading = ref(false);
|
|
const presentPaths = new Set<string>();
|
|
|
|
function onFilesChanged(event: Event) {
|
|
if (warrenStore.current == null) {
|
|
toast.warning('Upload', {
|
|
description: 'Enter a warren before attempting to upload files',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (event.target == null) {
|
|
return;
|
|
}
|
|
|
|
const target = event.target as HTMLInputElement;
|
|
|
|
if (target.files == null) {
|
|
return;
|
|
}
|
|
|
|
if (uploadStore.destination == null) {
|
|
uploadStore.destination = warrenStore.current;
|
|
} else if (!currentAndUploadRouteMatch.value) {
|
|
toast.warning('Upload', {
|
|
description:
|
|
'The unfinished items belong to a different directory. Remove them before attempting to upload to a different directory.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
for (const file of target.files) {
|
|
if (presentPaths.has(file.name)) {
|
|
continue;
|
|
}
|
|
uploadStore.files.push({
|
|
status: 'not_uploaded',
|
|
data: file,
|
|
});
|
|
presentPaths.add(file.name);
|
|
}
|
|
|
|
fileInputElement.value.value = '';
|
|
}
|
|
|
|
function removeFile(index: number) {
|
|
if (uploadStore.files.length <= index) {
|
|
return;
|
|
}
|
|
|
|
const [file] = uploadStore.files.splice(index, 1);
|
|
presentPaths.delete(file.data.name);
|
|
|
|
if (uploadStore.files.length < 1) {
|
|
uploadStore.destination = null;
|
|
uploadStore.progress = null;
|
|
}
|
|
}
|
|
|
|
function clearCompletedFiles() {
|
|
uploadStore.files = uploadStore.files.filter((f) => {
|
|
if (f.status === 'completed') {
|
|
presentPaths.delete(f.data.name);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
if (uploadStore.files.length < 1) {
|
|
uploadStore.destination = null;
|
|
uploadStore.progress = null;
|
|
}
|
|
}
|
|
|
|
function updateFileListStatus(loadedBytes: number, ended: boolean = false) {
|
|
let acc = 0;
|
|
|
|
for (let i = uploadStore.startIndex; i < uploadStore.files.length; i++) {
|
|
if (uploadStore.progress == null) {
|
|
uploadStore.files[i].status = 'not_uploaded';
|
|
continue;
|
|
}
|
|
|
|
acc += uploadStore.files[i].data.size;
|
|
|
|
if (acc > loadedBytes) {
|
|
if (ended) {
|
|
for (let j = i; j < uploadStore.files.length; j++) {
|
|
uploadStore.files[j].status = 'failed';
|
|
}
|
|
} else {
|
|
uploadStore.files[i].status = 'uploading';
|
|
}
|
|
break;
|
|
}
|
|
|
|
uploadStore.files[i].status = 'completed';
|
|
}
|
|
}
|
|
|
|
async function submit() {
|
|
if (uploadStore.destination == null) {
|
|
return;
|
|
}
|
|
|
|
const dt = new DataTransfer();
|
|
|
|
uploadStore.startIndex = uploadStore.files.findIndex(
|
|
(f) => f.status !== 'completed'
|
|
);
|
|
let calculatedTotal = 0;
|
|
|
|
for (let i = uploadStore.startIndex; i < uploadStore.files.length; i++) {
|
|
const file = uploadStore.files[i];
|
|
|
|
if (file.status !== 'completed') {
|
|
dt.items.add(file.data);
|
|
calculatedTotal += file.data.size;
|
|
}
|
|
}
|
|
|
|
uploading.value = true;
|
|
|
|
uploadStore.progress = {
|
|
loadedBytes: 0,
|
|
totalBytes: calculatedTotal,
|
|
};
|
|
|
|
const { success } = await uploadToWarren(
|
|
uploadStore.destination.warrenId,
|
|
uploadStore.destination.path,
|
|
dt.files,
|
|
(loaded, total) => {
|
|
if (uploadStore.progress != null) {
|
|
uploadStore.progress.loadedBytes = loaded;
|
|
uploadStore.progress.totalBytes = total;
|
|
|
|
updateFileListStatus(loaded, false);
|
|
}
|
|
}
|
|
);
|
|
|
|
uploading.value = false;
|
|
if (success) {
|
|
if (uploadStore.progress != null) {
|
|
uploadStore.progress.loadedBytes = uploadStore.progress.totalBytes;
|
|
}
|
|
|
|
for (const file of uploadStore.files) {
|
|
file.status = 'completed';
|
|
}
|
|
} else {
|
|
updateFileListStatus(uploadStore.progress.loadedBytes, true);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog>
|
|
<DialogTrigger as-child>
|
|
<slot />
|
|
</DialogTrigger>
|
|
<DialogContent class="!min-w-[min(50%,98vw)] sm:!min-h-[unset]">
|
|
<DialogHeader>
|
|
<DialogTitle>Upload files</DialogTitle>
|
|
<DialogDescription
|
|
>Upload files to
|
|
<span class="text-foreground">{{
|
|
currentAndUploadRouteMatch ||
|
|
uploadStore.destination == null
|
|
? 'the current directory'
|
|
: routeWithWarrenName(
|
|
uploadStore.destination!.warrenId,
|
|
uploadStore.destination!.path
|
|
)
|
|
}}</span></DialogDescription
|
|
>
|
|
</DialogHeader>
|
|
|
|
<input
|
|
ref="fileInputElement"
|
|
class="hidden"
|
|
type="file"
|
|
multiple="true"
|
|
@change="onFilesChanged"
|
|
/>
|
|
|
|
<div
|
|
class="flex min-h-[280px] w-full items-center justify-center overflow-hidden rounded-xl border sm:aspect-video sm:min-h-[unset]"
|
|
>
|
|
<ScrollArea
|
|
v-if="uploadStore.files.length > 0"
|
|
class="h-full w-full"
|
|
>
|
|
<div
|
|
class="flex h-full w-full flex-col items-stretch gap-2 p-4 text-left"
|
|
>
|
|
<UploadListEntry
|
|
v-for="(file, i) in uploadStore.files"
|
|
:key="file.data.name"
|
|
:file="file"
|
|
:uploading="uploading"
|
|
@remove-file="() => removeFile(i)"
|
|
/>
|
|
</div>
|
|
</ScrollArea>
|
|
<div
|
|
v-else
|
|
class="flex h-full w-full items-center justify-center"
|
|
:disabled="fileInputElement == null"
|
|
@click="
|
|
() =>
|
|
uploadStore.files.length < 1 &&
|
|
fileInputElement?.click()
|
|
"
|
|
>
|
|
<Icon
|
|
class="h-[25%] w-[25%] opacity-25"
|
|
name="lucide:upload"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="uploadStore.progress != null"
|
|
class="flex flex-col gap-1"
|
|
>
|
|
<Progress
|
|
:model-value="
|
|
(uploadStore.progress.loadedBytes /
|
|
uploadStore.progress.totalBytes) *
|
|
100.0
|
|
"
|
|
class="h-4 [&_*]:!transition-none"
|
|
/>
|
|
<span class="text-right">
|
|
{{ byteSize(uploadStore.progress.loadedBytes) }} /
|
|
{{ byteSize(uploadStore.progress.totalBytes) }}
|
|
</span>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
class="sm:mr-auto"
|
|
variant="outline"
|
|
:disabled="
|
|
uploadStore.files.every((f) => f.status !== 'completed')
|
|
"
|
|
@click="clearCompletedFiles"
|
|
>
|
|
Clear completed
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
:disabled="fileInputElement == null || uploading"
|
|
@click="() => fileInputElement?.click()"
|
|
>
|
|
Select files
|
|
</Button>
|
|
<Button
|
|
:disabled="
|
|
uploading ||
|
|
!uploadStore.files.some((f) => f.status !== 'completed')
|
|
"
|
|
@click="submit"
|
|
>Upload</Button
|
|
>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|