file upload drop zones
This commit is contained in:
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user