file upload drop zones

This commit is contained in:
2025-07-29 21:17:39 +02:00
parent 45368dcc9a
commit b1409b44d1
5 changed files with 162 additions and 66 deletions

View File

@@ -39,8 +39,8 @@ pub fn routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>>
.route("/files/rm", post(warren_rm)) .route("/files/rm", post(warren_rm))
.route( .route(
"/files/save", "/files/save",
// 1073741824 bytes = 1GB // 10737418240 bytes = 10GB
post(warren_save).route_layer(DefaultBodyLimit::max(1073741824)), post(warren_save).route_layer(DefaultBodyLimit::max(10737418240)),
) )
.route("/files/mv", post(warren_move)) .route("/files/mv", post(warren_move))
} }

View File

@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import type { DirectoryEntry } from '#shared/types'; import type { DirectoryEntry } from '#shared/types';
const { entries } = defineProps<{
const { entries, isOverDropZone } = defineProps<{
entries: DirectoryEntry[]; entries: DirectoryEntry[];
isOverDropZone?: boolean;
}>(); }>();
const { isLoading } = useLoadingIndicator(); const { isLoading } = useLoadingIndicator();
@@ -14,6 +16,12 @@ const sortedEntries = computed(() =>
<template> <template>
<ScrollArea class="h-full w-full"> <ScrollArea class="h-full w-full">
<div
v-if="isOverDropZone"
class="bg-background/50 pointer-events-none absolute flex h-full w-full items-center justify-center"
>
<Icon class="size-16 animate-pulse" name="lucide:upload" />
</div>
<div class="flex flex-row flex-wrap gap-2"> <div class="flex flex-row flex-wrap gap-2">
<DirectoryEntry <DirectoryEntry
v-for="entry in sortedEntries" v-for="entry in sortedEntries"

View File

@@ -15,6 +15,7 @@ import byteSize from 'byte-size';
import { uploadToWarren } from '~/lib/api/warrens'; import { uploadToWarren } from '~/lib/api/warrens';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import UploadListEntry from './UploadListEntry.vue'; import UploadListEntry from './UploadListEntry.vue';
import { useDropZone } from '@vueuse/core';
const warrenStore = useWarrenStore(); const warrenStore = useWarrenStore();
const uploadStore = useUploadStore(); const uploadStore = useUploadStore();
@@ -32,7 +33,26 @@ const fileInputElement = ref<HTMLInputElement>(
); );
const uploading = ref(false); const uploading = ref(false);
const presentPaths = new Set<string>(); const dropZoneRef = ref<HTMLElement>();
const dropZone = useDropZone(dropZoneRef, {
onDrop,
multiple: true,
});
function onDrop(files: File[] | null, event: DragEvent) {
event.preventDefault();
if (warrenStore.current == null || files == null || files.length < 1) {
return;
}
uploadStore.addFiles(
warrenStore.current.warrenId,
warrenStore.current.path,
files
);
}
function onFilesChanged(event: Event) { function onFilesChanged(event: Event) {
if (warrenStore.current == null) { if (warrenStore.current == null) {
@@ -52,58 +72,21 @@ function onFilesChanged(event: Event) {
return; return;
} }
if (uploadStore.destination == null) { uploadStore.addFiles(
uploadStore.destination = warrenStore.current; warrenStore.current.warrenId,
} else if (!currentAndUploadRouteMatch.value) { warrenStore.current.path,
toast.warning('Upload', { [...target.files]
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 = ''; fileInputElement.value.value = '';
} }
function removeFile(index: number) { function removeFile(index: number) {
if (uploadStore.files.length <= index) { uploadStore.removeFile(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() { function clearCompletedFiles() {
uploadStore.files = uploadStore.files.filter((f) => { uploadStore.clearCompletedFiles();
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) { function updateFileListStatus(loadedBytes: number, ended: boolean = false) {
@@ -190,7 +173,7 @@ async function submit() {
</script> </script>
<template> <template>
<Dialog> <Dialog v-model:open="uploadStore.dialogOpen">
<DialogTrigger as-child> <DialogTrigger as-child>
<slot /> <slot />
</DialogTrigger> </DialogTrigger>
@@ -220,6 +203,7 @@ async function submit() {
/> />
<div <div
ref="dropZoneRef"
class="flex min-h-[280px] w-full items-center justify-center overflow-hidden rounded-xl border sm:aspect-video sm:min-h-[unset]" class="flex min-h-[280px] w-full items-center justify-center overflow-hidden rounded-xl border sm:aspect-video sm:min-h-[unset]"
> >
<ScrollArea <ScrollArea

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDropZone } from '@vueuse/core';
import { toast } from 'vue-sonner';
import DirectoryListContextMenu from '~/components/DirectoryListContextMenu.vue'; import DirectoryListContextMenu from '~/components/DirectoryListContextMenu.vue';
import RenameEntryDialog from '~/components/actions/RenameEntryDialog.vue'; import RenameEntryDialog from '~/components/actions/RenameEntryDialog.vue';
import { getWarrenDirectory } from '~/lib/api/warrens'; import { getWarrenDirectory } from '~/lib/api/warrens';
@@ -9,8 +11,16 @@ definePageMeta({
const warrenStore = useWarrenStore(); const warrenStore = useWarrenStore();
const loadingIndicator = useLoadingIndicator(); const loadingIndicator = useLoadingIndicator();
const uploadStore = useUploadStore();
const warrenPath = computed(() => useWarrenPath()); const warrenPath = computed(() => useWarrenPath());
const dropZoneRef = ref<HTMLElement>();
const dropZone = useDropZone(dropZoneRef, {
onDrop,
multiple: true,
});
if (warrenStore.current == null) { if (warrenStore.current == null) {
await navigateTo({ await navigateTo({
path: '/warrens', path: '/warrens',
@@ -39,12 +49,41 @@ const entries = useAsyncData(
}, },
{ watch: [warrenPath] } { watch: [warrenPath] }
).data; ).data;
function onDrop(files: File[] | null, e: DragEvent) {
if (files == null) {
return;
}
e.preventDefault();
if (warrenStore.current == null) {
toast.warning('Upload', {
description: 'Enter a warren before attempting to upload files',
});
return;
}
const added = uploadStore.addFiles(
warrenStore.current.warrenId,
warrenStore.current.path,
files
);
if (added) {
uploadStore.dialogOpen = true;
}
}
</script> </script>
<template> <template>
<div class="grow"> <div ref="dropZoneRef" class="grow">
<DirectoryListContextMenu class="w-full grow"> <DirectoryListContextMenu class="w-full grow">
<DirectoryList v-if="entries != null" :entries="entries" /> <DirectoryList
v-if="entries != null"
:is-over-drop-zone="dropZone.isOverDropZone.value"
:entries="entries"
/>
</DirectoryListContextMenu> </DirectoryListContextMenu>
<RenameEntryDialog /> <RenameEntryDialog />
</div> </div>

View File

@@ -1,21 +1,86 @@
import type { UploadFile } from '#shared/types'; import type { UploadFile } from '#shared/types';
import { toast } from 'vue-sonner';
export const useUploadStore = defineStore< export const useUploadStore = defineStore('warren-upload', {
'warren-upload', state: () => ({
{ dialogOpen: false as boolean,
startIndex: number; startIndex: 0 as number,
files: UploadFile[]; files: [] as UploadFile[],
destination: { warrenId: string; path: string } | null; destination: null as { warrenId: string; path: string } | null,
progress: { progress: null as {
loadedBytes: number; loadedBytes: number;
totalBytes: number; totalBytes: number;
} | null; } | null,
} presentPaths: new Set() as Set<string>,
>('warren-upload', {
state: () => ({
startIndex: 0,
files: [],
destination: null,
progress: null,
}), }),
actions: {
addFiles(
warrenId: string,
destination: string,
files: File[]
): boolean {
if (this.destination == null) {
this.destination = {
warrenId: warrenId,
path: destination,
};
} else if (
this.destination.warrenId != warrenId ||
this.destination.path !== destination
) {
toast.warning('Upload', {
description:
'The unfinished items belong to a different directory. Remove them before attempting to upload to a different directory.',
});
return false;
}
let added = 0;
for (const file of files) {
if (this.presentPaths.has(file.name)) {
continue;
}
this.files.push({ status: 'not_uploaded', data: file });
this.presentPaths.add(file.name);
added++;
}
return added > 0;
},
removeFile(index: number) {
if (this.files.length <= index) {
return;
}
if (this.files.length <= 1) {
this.clearFiles();
return;
}
const [file] = this.files.splice(index, 1);
this.presentPaths.delete(file.data.name);
},
clearCompletedFiles() {
this.files = this.files.filter((f) => {
if (f.status === 'completed') {
this.presentPaths.delete(f.data.name);
return false;
}
return true;
});
if (this.files.length < 1) {
this.clearFiles();
}
},
clearFiles() {
this.files = [];
this.destination = null;
this.progress = null;
this.presentPaths.clear();
},
},
}); });