improved file uploads
This commit is contained in:
@@ -23,7 +23,7 @@ pub(super) async fn route(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let file_name = field.file_name().map(str::to_owned).unwrap();
|
let file_name = field.file_name().map(str::to_owned).unwrap();
|
||||||
let data = field.bytes().await.unwrap();
|
let data = field.bytes().await?;
|
||||||
|
|
||||||
warren
|
warren
|
||||||
.upload(state.serve_dir(), rest.as_deref(), &file_name, &data)
|
.upload(state.serve_dir(), rest.as_deref(), &file_name, &data)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
|
extract::multipart::MultipartError,
|
||||||
http::{StatusCode, header::InvalidHeaderValue},
|
http::{StatusCode, header::InvalidHeaderValue},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
@@ -19,6 +22,8 @@ pub enum AppError {
|
|||||||
Var(#[from] std::env::VarError),
|
Var(#[from] std::env::VarError),
|
||||||
#[error("InvalidHeaderValue: {0}")]
|
#[error("InvalidHeaderValue: {0}")]
|
||||||
InvalidHeaderValue(#[from] InvalidHeaderValue),
|
InvalidHeaderValue(#[from] InvalidHeaderValue),
|
||||||
|
#[error("Multipart: {0}")]
|
||||||
|
Multipart(#[from] MultipartError),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
@@ -34,6 +39,7 @@ impl IntoResponse for AppError {
|
|||||||
Self::DatabaseMigration(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
Self::DatabaseMigration(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Self::Var(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
Self::Var(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Self::InvalidHeaderValue(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
Self::InvalidHeaderValue(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Self::Multipart(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
};
|
};
|
||||||
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
import byteSize from 'byte-size';
|
import byteSize from 'byte-size';
|
||||||
import { uploadToWarren } from '~/lib/api/warrens';
|
import { uploadToWarren } from '~/lib/api/warrens';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
import UploadListEntry from './UploadListEntry.vue';
|
||||||
|
|
||||||
|
const uploadStore = useUploadStore();
|
||||||
|
|
||||||
const warrenRoute = useWarrenRoute();
|
const warrenRoute = useWarrenRoute();
|
||||||
|
|
||||||
const fileInputElement = ref<HTMLInputElement>(
|
const fileInputElement = ref<HTMLInputElement>(
|
||||||
@@ -19,11 +25,16 @@ const fileInputElement = ref<HTMLInputElement>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const uploading = ref(false);
|
const uploading = ref(false);
|
||||||
const open = ref(false);
|
|
||||||
const files = ref<Array<{ uploaded: boolean; data: File }>>([]);
|
|
||||||
const presentPaths = new Set<string>();
|
const presentPaths = new Set<string>();
|
||||||
|
|
||||||
function onFilesChanged(event: Event) {
|
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) {
|
if (event.target == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -34,64 +45,144 @@ function onFilesChanged(event: Event) {
|
|||||||
return;
|
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) {
|
for (const file of target.files) {
|
||||||
if (presentPaths.has(file.name)) {
|
if (presentPaths.has(file.name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
files.value.push({ uploaded: false, data: file });
|
uploadStore.files.push({
|
||||||
|
status: 'not_uploaded',
|
||||||
|
data: file,
|
||||||
|
});
|
||||||
presentPaths.add(file.name);
|
presentPaths.add(file.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
fileInputElement.value.value = '';
|
fileInputElement.value.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (files.value.length < 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
uploading.value = true;
|
|
||||||
|
|
||||||
const dt = new DataTransfer();
|
|
||||||
for (const file of files.value) {
|
|
||||||
if (!file.uploaded) {
|
|
||||||
dt.items.add(file.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { success } = await uploadToWarren(warrenRoute.value, dt.files);
|
|
||||||
|
|
||||||
uploading.value = false;
|
|
||||||
if (success) {
|
|
||||||
for (const file of files.value) {
|
|
||||||
file.uploaded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFile(index: number) {
|
function removeFile(index: number) {
|
||||||
if (files.value.length <= index) {
|
if (uploadStore.files.length <= index) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [file] = files.value.splice(index, 1);
|
const [file] = uploadStore.files.splice(index, 1);
|
||||||
presentPaths.delete(file.data.name);
|
presentPaths.delete(file.data.name);
|
||||||
|
|
||||||
|
if (uploadStore.files.length < 1) {
|
||||||
|
uploadStore.path = null;
|
||||||
|
uploadStore.progress = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCompletedFiles() {
|
function clearCompletedFiles() {
|
||||||
files.value = files.value.filter((f) => {
|
uploadStore.files = uploadStore.files.filter((f) => {
|
||||||
if (f.uploaded) {
|
if (f.status === 'completed') {
|
||||||
presentPaths.delete(f.data.name);
|
presentPaths.delete(f.data.name);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog v-model:open="open">
|
<Dialog>
|
||||||
<DialogTrigger as-child>
|
<DialogTrigger as-child>
|
||||||
<slot />
|
<slot />
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -99,7 +190,13 @@ function clearCompletedFiles() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Upload files</DialogTitle>
|
<DialogTitle>Upload files</DialogTitle>
|
||||||
<DialogDescription
|
<DialogDescription
|
||||||
>Upload files to the current directory</DialogDescription
|
>Upload files to
|
||||||
|
<span class="text-foreground">{{
|
||||||
|
uploadStore.path == null ||
|
||||||
|
warrenRoute === uploadStore.path
|
||||||
|
? 'the current directory'
|
||||||
|
: routeWithWarrenName(uploadStore.path)
|
||||||
|
}}</span></DialogDescription
|
||||||
>
|
>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -114,53 +211,32 @@ function clearCompletedFiles() {
|
|||||||
<div
|
<div
|
||||||
class="flex min-h-[280px] sm:min-h-[unset] sm:aspect-video w-full items-center justify-center rounded-xl border overflow-hidden"
|
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="files.length > 0" class="h-full w-full">
|
<ScrollArea
|
||||||
|
v-if="uploadStore.files.length > 0"
|
||||||
|
class="h-full w-full"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-full w-full flex-col items-stretch gap-1 text-left p-2"
|
class="flex h-full w-full flex-col items-stretch gap-1 text-left p-2"
|
||||||
>
|
>
|
||||||
<div
|
<UploadListEntry
|
||||||
v-for="(file, i) in files"
|
v-for="(file, i) in uploadStore.files"
|
||||||
:key="file.data.name"
|
:key="file.data.name"
|
||||||
|
:file="file"
|
||||||
|
:uploading="uploading"
|
||||||
class="flex flex-row items-center justify-between gap-4 rounded-lg border px-4 py-2"
|
class="flex flex-row items-center justify-between gap-4 rounded-lg border px-4 py-2"
|
||||||
>
|
@remove-file="() => removeFile(i)"
|
||||||
<div class="rounded-sm border p-3">
|
/>
|
||||||
<Icon
|
|
||||||
v-if="!file.uploaded"
|
|
||||||
class="h-5 w-5"
|
|
||||||
:name="getFileIcon(file.data.type)"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
v-else
|
|
||||||
class="h-5 w-5 text-green-300"
|
|
||||||
name="lucide:circle-check"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col grow overflow-hidden">
|
|
||||||
<span class="font-medium truncate">{{
|
|
||||||
file.data.name
|
|
||||||
}}</span>
|
|
||||||
<span class="text-muted-foreground">{{
|
|
||||||
byteSize(file.data.size)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row gap-2 items-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
:disabled="uploading"
|
|
||||||
@click="() => removeFile(i)"
|
|
||||||
>
|
|
||||||
<Icon name="lucide:x" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="flex h-full w-full items-center justify-center"
|
class="flex h-full w-full items-center justify-center"
|
||||||
:disabled="fileInputElement == null"
|
:disabled="fileInputElement == null"
|
||||||
@click="() => files.length < 1 && fileInputElement?.click()"
|
@click="
|
||||||
|
() =>
|
||||||
|
uploadStore.files.length < 1 &&
|
||||||
|
fileInputElement?.click()
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
class="h-[25%] w-[25%] opacity-25"
|
class="h-[25%] w-[25%] opacity-25"
|
||||||
@@ -169,11 +245,31 @@ function clearCompletedFiles() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
class="sm:mr-auto"
|
class="sm:mr-auto"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="files.every((f) => !f.uploaded)"
|
:disabled="
|
||||||
|
uploadStore.files.every((f) => f.status !== 'completed')
|
||||||
|
"
|
||||||
@click="clearCompletedFiles"
|
@click="clearCompletedFiles"
|
||||||
>
|
>
|
||||||
Clear completed
|
Clear completed
|
||||||
@@ -186,7 +282,10 @@ function clearCompletedFiles() {
|
|||||||
Select files
|
Select files
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
:disabled="uploading || !files.some((f) => !f.uploaded)"
|
:disabled="
|
||||||
|
uploading ||
|
||||||
|
!uploadStore.files.some((f) => f.status !== 'completed')
|
||||||
|
"
|
||||||
@click="submit"
|
@click="submit"
|
||||||
>Upload</Button
|
>Upload</Button
|
||||||
>
|
>
|
||||||
|
|||||||
56
frontend/components/actions/UploadListEntry.vue
Normal file
56
frontend/components/actions/UploadListEntry.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import byteSize from 'byte-size';
|
||||||
|
import type { UploadFile } from '~/types';
|
||||||
|
|
||||||
|
const emit = defineEmits(['removeFile']);
|
||||||
|
|
||||||
|
const { file, uploading } = defineProps<{
|
||||||
|
file: UploadFile;
|
||||||
|
uploading: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-row items-center justify-between gap-4 rounded-lg border px-4 py-2"
|
||||||
|
>
|
||||||
|
<div class="rounded-sm border p-3">
|
||||||
|
<Icon
|
||||||
|
v-if="file.status === 'not_uploaded'"
|
||||||
|
class="h-5 w-5"
|
||||||
|
:name="getFileIcon(file.data.type)"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else-if="file.status === 'uploading'"
|
||||||
|
class="h-5 w-5"
|
||||||
|
name="lucide:upload"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else-if="file.status === 'completed'"
|
||||||
|
class="h-5 w-5 text-green-300"
|
||||||
|
name="lucide:circle-check"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else-if="file.status === 'failed'"
|
||||||
|
class="h-5 w-5 text-destructive-foreground"
|
||||||
|
name="lucide:circle-alert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col grow overflow-hidden">
|
||||||
|
<span class="font-medium truncate">{{ file.data.name }}</span>
|
||||||
|
<span class="text-muted-foreground"
|
||||||
|
>{{ byteSize(file.data.size) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row gap-2 items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:disabled="uploading"
|
||||||
|
@click="emit('removeFile')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:x" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
38
frontend/components/ui/progress/Progress.vue
Normal file
38
frontend/components/ui/progress/Progress.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
ProgressIndicator,
|
||||||
|
ProgressRoot,
|
||||||
|
type ProgressRootProps,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<ProgressRootProps & { class?: HTMLAttributes['class'] }>(),
|
||||||
|
{
|
||||||
|
modelValue: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ProgressRoot
|
||||||
|
data-slot="progress"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ProgressIndicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
class="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
|
||||||
|
/>
|
||||||
|
</ProgressRoot>
|
||||||
|
</template>
|
||||||
1
frontend/components/ui/progress/index.ts
Normal file
1
frontend/components/ui/progress/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Progress } from './Progress.vue'
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@/components/ui/breadcrumb';
|
} from '@/components/ui/breadcrumb';
|
||||||
import CreateDirectoryDialog from '~/components/actions/CreateDirectoryDialog.vue';
|
import CreateDirectoryDialog from '~/components/actions/CreateDirectoryDialog.vue';
|
||||||
import UploadDialog from '~/components/actions/UploadDialog.vue';
|
import UploadDialog from '~/components/actions/UploadDialog.vue';
|
||||||
|
const uploadStore = useUploadStore();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
@@ -55,17 +56,28 @@ const breadcrumbs = computed(() => getBreadcrumbs(route.path));
|
|||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="route.path.startsWith('/warrens/')"
|
|
||||||
class="ml-auto flex flex-row-reverse items-center gap-2"
|
class="ml-auto flex flex-row-reverse items-center gap-2"
|
||||||
>
|
>
|
||||||
<CreateDirectoryDialog>
|
<CreateDirectoryDialog>
|
||||||
<Button variant="outline" size="icon">
|
<Button
|
||||||
|
v-if="route.path.startsWith('/warrens/')"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
<Icon name="lucide:folder-plus" />
|
<Icon name="lucide:folder-plus" />
|
||||||
</Button>
|
</Button>
|
||||||
</CreateDirectoryDialog>
|
</CreateDirectoryDialog>
|
||||||
<UploadDialog>
|
<UploadDialog>
|
||||||
<Button variant="outline" size="icon">
|
<Button
|
||||||
|
class="relative"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
<Icon name="lucide:upload" />
|
<Icon name="lucide:upload" />
|
||||||
|
<div
|
||||||
|
v-if="uploadStore.progress != null"
|
||||||
|
class="w-1.5 h-1.5 rounded-full bg-primary absolute top-1 right-1 animate-pulse"
|
||||||
|
></div>
|
||||||
</Button>
|
</Button>
|
||||||
</UploadDialog>
|
</UploadDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ export async function deleteWarrenEntry(
|
|||||||
|
|
||||||
export async function uploadToWarren(
|
export async function uploadToWarren(
|
||||||
path: string,
|
path: string,
|
||||||
files: FileList
|
files: FileList,
|
||||||
|
onProgress: ((loaded: number, total: number) => void) | undefined
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -147,16 +148,31 @@ export async function uploadToWarren(
|
|||||||
rest = '/' + rest;
|
rest = '/' + rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status } = await useFetch(
|
const xhr = new XMLHttpRequest();
|
||||||
getApiUrl(`warrens/${warrenId}/upload${rest}`),
|
xhr.open('POST', getApiUrl(`warrens/${warrenId}/upload${rest}`));
|
||||||
{
|
xhr.upload.onprogress = (e) => {
|
||||||
method: 'POST',
|
onProgress?.(e.loaded, e.total);
|
||||||
body,
|
};
|
||||||
key: 'upload-' + new Date().getTime().toString(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (status.value !== 'success') {
|
const promise = new Promise<void>((res, rej) => {
|
||||||
|
xhr.onreadystatechange = (_) => {
|
||||||
|
if (xhr.readyState !== 4) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
res();
|
||||||
|
} else {
|
||||||
|
rej();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.send(body);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch {
|
||||||
toast.error('Upload', {
|
toast.error('Upload', {
|
||||||
id: 'UPLOAD_FILE_TOAST',
|
id: 'UPLOAD_FILE_TOAST',
|
||||||
description: `Failed to upload`,
|
description: `Failed to upload`,
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import type { Warren } from '~/types/warrens';
|
import type { Warren } from '~/types/warrens';
|
||||||
|
|
||||||
export const useWarrenStore = defineStore('warrens', {
|
export const useWarrenStore = defineStore<
|
||||||
|
'warrens',
|
||||||
|
{
|
||||||
|
warrens: Record<string, Warren>;
|
||||||
|
}
|
||||||
|
>('warrens', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
warrens: {} as Record<string, Warren>,
|
warrens: {},
|
||||||
|
upload: null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
21
frontend/stores/upload.ts
Normal file
21
frontend/stores/upload.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { UploadFile } from '~/types';
|
||||||
|
|
||||||
|
export const useUploadStore = defineStore<
|
||||||
|
'warren-upload',
|
||||||
|
{
|
||||||
|
startIndex: number;
|
||||||
|
files: UploadFile[];
|
||||||
|
path: string | null;
|
||||||
|
progress: {
|
||||||
|
loadedBytes: number;
|
||||||
|
totalBytes: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
>('warren-upload', {
|
||||||
|
state: () => ({
|
||||||
|
startIndex: 0,
|
||||||
|
files: [],
|
||||||
|
path: null,
|
||||||
|
progress: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -9,3 +9,14 @@ export type DirectoryEntry = {
|
|||||||
name: string;
|
name: string;
|
||||||
fileType: FileType;
|
fileType: FileType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UploadStatus =
|
||||||
|
| 'not_uploaded'
|
||||||
|
| 'uploading'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
|
export type UploadFile = {
|
||||||
|
status: UploadStatus;
|
||||||
|
data: File;
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,3 +2,20 @@ export function getApiUrl(path: string): string {
|
|||||||
const API_BASE_URL = useRuntimeConfig().public.apiBase;
|
const API_BASE_URL = useRuntimeConfig().public.apiBase;
|
||||||
return `${API_BASE_URL}/${path}`;
|
return `${API_BASE_URL}/${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the warren's id in a warren path to its name
|
||||||
|
* @param path - The warren path (e.g. `a3f79579-9155-4492-a579-b0253c8d3bf8/my-directory/`)
|
||||||
|
* @returns A prettier path `a3f79579-9155-4492-a579-b0253c8d3bf8/my-directory` -> `my-warren/my-directory`
|
||||||
|
*/
|
||||||
|
export function routeWithWarrenName(path: string): string {
|
||||||
|
const warrens = useWarrenStore().warrens;
|
||||||
|
|
||||||
|
const id = path.split('/')[0];
|
||||||
|
|
||||||
|
if (!(id in warrens)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.replace(id, warrens[id].name);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user