Files
warren/frontend/components/actions/UploadDialog.vue
2025-07-13 13:33:14 +02:00

295 lines
8.5 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 uploadStore = useUploadStore();
const warrenRoute = useWarrenRoute();
const fileInputElement = ref<HTMLInputElement>(
null as unknown as HTMLInputElement
);
const uploading = ref(false);
const presentPaths = new Set<string>();
function onFilesChanged(event: Event) {
if (warrenRoute.value == 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.path == null) {
uploadStore.path = warrenRoute.value;
} else if (uploadStore.path !== warrenRoute.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.path = 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.path = 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.path == 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.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">{{
uploadStore.path == null ||
warrenRoute === uploadStore.path
? 'the current directory'
: routeWithWarrenName(uploadStore.path)
}}</span></DialogDescription
>
</DialogHeader>
<input
ref="fileInputElement"
class="hidden"
type="file"
multiple="true"
@change="onFilesChanged"
/>
<div
class="flex min-h-[280px] sm:min-h-[unset] sm:aspect-video w-full items-center justify-center rounded-xl border overflow-hidden"
>
<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 text-left p-4"
>
<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>