Files
warren/frontend/components/actions/UploadDialog.vue
409 23fdd55612 refactor file system operations
the most notable improvement is that uploads are now using streams so
they no longer require the entire file to be stored in memory
2025-07-28 22:38:28 +02:00

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>