diff --git a/app/src/main.ts b/app/src/main.ts
index e4f2fdfb4a..dcdd12aa82 100644
--- a/app/src/main.ts
+++ b/app/src/main.ts
@@ -52,12 +52,7 @@ import { registerDisplays } from './displays/register';
import App from './app.vue';
async function init() {
- await Promise.all([
- registerInterfaces(),
- registerDisplays(),
- registerLayouts(),
- loadModules(),
- ]);
+ await Promise.all([registerInterfaces(), registerDisplays(), registerLayouts(), loadModules()]);
Vue.config.productionTip = false;
@@ -75,4 +70,8 @@ async function init() {
console.groupEnd();
}
+// Prevent the browser from opening files that are dragged on the window
+window.addEventListener('dragover', (e) => e.preventDefault(), false);
+window.addEventListener('drop', (e) => e.preventDefault(), false);
+
init();
diff --git a/app/src/modules/files/routes/collection.vue b/app/src/modules/files/routes/collection.vue
index d920efec74..a45217e135 100644
--- a/app/src/modules/files/routes/collection.vue
+++ b/app/src/modules/files/routes/collection.vue
@@ -1,5 +1,5 @@
-
+
@@ -133,6 +133,13 @@
+
+
+
+
+
+
+
@@ -152,9 +159,11 @@ import FolderPicker from '../components/folder-picker.vue';
import emitter, { Events } from '@/events';
import router from '@/router';
import Vue from 'vue';
-import { useUserStore } from '@/stores';
+import { useNotificationsStore, useUserStore } from '@/stores';
import { subDays } from 'date-fns';
import useFolders from '../composables/use-folders';
+import useEventListener from '@/composables/use-event-listener';
+import uploadFiles from '@/utils/upload-files';
type Item = {
[field: string]: any;
@@ -179,6 +188,7 @@ export default defineComponent({
const selection = ref- ([]);
const userStore = useUserStore();
+ const notificationsStore = useNotificationsStore();
const { layout, layoutOptions, layoutQuery, filters, searchQuery } = usePreset(ref('directus_files'));
const { batchLink } = useLinks();
@@ -242,6 +252,13 @@ export default defineComponent({
onMounted(() => emitter.on(Events.upload, refresh));
onUnmounted(() => emitter.off(Events.upload, refresh));
+ const { onDragEnter, onDragLeave, onDrop, onDragOver, showDropEffect, dragging } = useFileUpload();
+
+ useEventListener(window, 'dragenter', onDragEnter);
+ useEventListener(window, 'dragover', onDragOver);
+ useEventListener(window, 'dragleave', onDragLeave);
+ useEventListener(window, 'drop', onDrop);
+
return {
batchDelete,
batchLink,
@@ -264,6 +281,11 @@ export default defineComponent({
selectedFolder,
refresh,
clearFilters,
+ onDragEnter,
+ onDragLeave,
+ showDropEffect,
+ onDrop,
+ dragging,
};
function useBatchDelete() {
@@ -381,6 +403,133 @@ export default defineComponent({
filters.value = [];
searchQuery.value = null;
}
+
+ function useFileUpload() {
+ const showDropEffect = ref(false);
+
+ let dragNotificationID: string;
+ let fileUploadNotificationID: string;
+
+ const dragCounter = ref(0);
+
+ const dragging = computed(() => dragCounter.value > 0);
+
+ return { onDragEnter, onDragLeave, onDrop, onDragOver, showDropEffect, dragging };
+
+ function enableDropEffect() {
+ showDropEffect.value = true;
+
+ dragNotificationID = notificationsStore.add({
+ title: i18n.t('drop_to_upload'),
+ icon: 'cloud_upload',
+ type: 'info',
+ persist: true,
+ closeable: false,
+ });
+ }
+
+ function disableDropEffect() {
+ showDropEffect.value = false;
+
+ if (dragNotificationID) {
+ notificationsStore.remove(dragNotificationID);
+ }
+ }
+
+ function onDragEnter(event: DragEvent) {
+ if (!event.dataTransfer) return;
+ if (event.dataTransfer?.types.indexOf('Files') === -1) return;
+
+ event.preventDefault();
+ dragCounter.value++;
+
+ const isDropzone = event.target && (event.target as HTMLElement).getAttribute?.('data-dropzone') === '';
+
+ if (dragCounter.value === 1 && showDropEffect.value === false && isDropzone === false) {
+ enableDropEffect();
+ }
+
+ if (isDropzone) {
+ disableDropEffect();
+ dragCounter.value = 0;
+ }
+ }
+
+ function onDragOver(event: DragEvent) {
+ if (!event.dataTransfer) return;
+ if (event.dataTransfer?.types.indexOf('Files') === -1) return;
+
+ event.preventDefault();
+ }
+
+ function onDragLeave(event: DragEvent) {
+ if (!event.dataTransfer) return;
+ if (event.dataTransfer?.types.indexOf('Files') === -1) return;
+
+ event.preventDefault();
+ dragCounter.value--;
+
+ if (dragCounter.value === 0) {
+ disableDropEffect();
+ }
+
+ if (event.target && (event.target as HTMLElement).getAttribute?.('data-dropzone') === '') {
+ enableDropEffect();
+ dragCounter.value = 1;
+ }
+ }
+
+ async function onDrop(event: DragEvent) {
+ if (!event.dataTransfer) return;
+ if (event.dataTransfer?.types.indexOf('Files') === -1) return;
+
+ event.preventDefault();
+ showDropEffect.value = false;
+
+ dragCounter.value = 0;
+
+ if (dragNotificationID) {
+ notificationsStore.remove(dragNotificationID);
+ }
+
+ const files = [...event.dataTransfer.files];
+
+ fileUploadNotificationID = notificationsStore.add({
+ title: i18n.tc('upload_file_indeterminate', files.length, {
+ done: 0,
+ total: files.length,
+ }),
+ type: 'info',
+ persist: true,
+ closeable: false,
+ loading: true,
+ });
+
+ await uploadFiles(files, {
+ preset: {
+ folder: props.queryFilters?.folder || null,
+ },
+ onProgressChange: (progress) => {
+ const percentageDone = progress.reduce((val, cur) => (val += cur)) / progress.length;
+
+ const total = files.length;
+ const done = progress.filter((p) => p === 100).length;
+
+ notificationsStore.update(fileUploadNotificationID, {
+ title: i18n.tc('upload_file_indeterminate', files.length, {
+ done,
+ total,
+ }),
+ loading: false,
+ progress: percentageDone,
+ });
+ },
+ });
+
+ notificationsStore.remove(fileUploadNotificationID);
+ emitter.emit(Events.upload);
+ }
+ }
},
});
@@ -414,4 +563,52 @@ export default defineComponent({
.layout {
--layout-offset-top: 64px;
}
+
+.drop-border {
+ position: fixed;
+ z-index: 500;
+ background-color: var(--primary);
+
+ &.top,
+ &.bottom {
+ width: 100%;
+ height: 4px;
+ }
+
+ &.left,
+ &.right {
+ width: 4px;
+ height: 100%;
+ }
+
+ &.top {
+ top: 0;
+ left: 0;
+ }
+
+ &.right {
+ top: 0;
+ right: 0;
+ }
+
+ &.bottom {
+ bottom: 0;
+ left: 0;
+ }
+
+ &.left {
+ top: 0;
+ left: 0;
+ }
+}
+
+.dragging {
+ ::v-deep * {
+ pointer-events: none;
+ }
+
+ ::v-deep [data-dropzone] {
+ pointer-events: all;
+ }
+}
diff --git a/app/src/utils/upload-file/upload-file.ts b/app/src/utils/upload-file/upload-file.ts
index d293cdcf10..138b4e8e3b 100644
--- a/app/src/utils/upload-file/upload-file.ts
+++ b/app/src/utils/upload-file/upload-file.ts
@@ -10,7 +10,7 @@ export default async function uploadFile(
options?: {
onProgressChange?: (percentage: number) => void;
notifications?: boolean;
- preset?: Record;
+ preset?: Record;
}
) {
const progressHandler = options?.onProgressChange || (() => undefined);
diff --git a/app/src/utils/upload-files/upload-files.ts b/app/src/utils/upload-files/upload-files.ts
index 27064096f8..018554e184 100644
--- a/app/src/utils/upload-files/upload-files.ts
+++ b/app/src/utils/upload-files/upload-files.ts
@@ -7,7 +7,7 @@ export default async function uploadFiles(
options?: {
onProgressChange?: (percentages: number[]) => void;
notifications?: boolean;
- preset?: Record;
+ preset?: Record;
}
) {
const progressHandler = options?.onProgressChange || (() => undefined);
diff --git a/app/src/views/private/private-view.vue b/app/src/views/private/private-view.vue
index 0b03b0dc15..6c2ff97460 100644
--- a/app/src/views/private/private-view.vue
+++ b/app/src/views/private/private-view.vue
@@ -1,5 +1,5 @@
-
+