mirror of
https://github.com/directus/directus.git
synced 2026-01-28 07:48:04 -05:00
Global file upload (#422)
* Add hover style for file drop in private view * Add uploadFile / uploadFiles utils * Show drag notification on file dragover * Finish global file uploads * Fix warning in drawer detail test * Use correct terminology * Ignore drop for non file drops
This commit is contained in:
@@ -218,6 +218,11 @@
|
||||
"has": "Contains some of these keys"
|
||||
},
|
||||
|
||||
"drop_to_upload": "Drop to Upload",
|
||||
"upload_file_indeterminate": "Uploading File | Uploading files {done}/{total}",
|
||||
"upload_file_success": "File Uploaded | {count} Files Uploaded",
|
||||
"upload_file_failed": "Couldn't Upload File | Couldn't Upload Files",
|
||||
|
||||
"about_directus": "About Directus",
|
||||
"activity_log": "Activity Log",
|
||||
"add_field_filter": "Add a field filter",
|
||||
|
||||
@@ -39,6 +39,20 @@ export const useNotificationsStore = createStore({
|
||||
this.state.queue = this.state.queue.filter((n) => n.id !== id);
|
||||
this.state.previous = [...this.state.previous, toBeRemoved];
|
||||
},
|
||||
update(id: string, updates: Partial<Notification>) {
|
||||
this.state.queue = this.state.queue.map(updateIfNeeded);
|
||||
this.state.previous = this.state.queue.map(updateIfNeeded);
|
||||
|
||||
function updateIfNeeded(notification: Notification) {
|
||||
if (notification.id === id) {
|
||||
return {
|
||||
...notification,
|
||||
...updates,
|
||||
};
|
||||
}
|
||||
return notification;
|
||||
}
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
lastFour(state) {
|
||||
|
||||
@@ -7,6 +7,9 @@ export interface NotificationRaw {
|
||||
text?: string | VueI18n.TranslateResult;
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
icon?: string | null;
|
||||
closeable?: boolean;
|
||||
progress?: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export interface Notification extends NotificationRaw {
|
||||
|
||||
4
src/utils/upload-file/index.ts
Normal file
4
src/utils/upload-file/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import uploadFile from './upload-file';
|
||||
|
||||
export { uploadFile };
|
||||
export default uploadFile;
|
||||
37
src/utils/upload-file/upload-file.ts
Normal file
37
src/utils/upload-file/upload-file.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import api from '@/api';
|
||||
import useProjectsStore from '@/stores/projects';
|
||||
import notify from '@/utils/notify';
|
||||
import i18n from '@/lang';
|
||||
|
||||
export default async function uploadFile(
|
||||
file: File,
|
||||
onProgressChange?: (percentage: number) => void,
|
||||
showNotifications = true
|
||||
) {
|
||||
const progressHandler = onProgressChange || (() => undefined);
|
||||
const currentProjectKey = useProjectsStore().state.currentProjectKey;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
await api.post(`/${currentProjectKey}/files`, formData, { onUploadProgress });
|
||||
|
||||
if (showNotifications) {
|
||||
notify({
|
||||
title: i18n.tc('upload_file_success', 0),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (showNotifications) {
|
||||
notify({
|
||||
title: i18n.tc('upload_file_failed', 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onUploadProgress(progressEvent: { loaded: number; total: number }) {
|
||||
const percentCompleted = Math.floor((progressEvent.loaded * 100) / progressEvent.total);
|
||||
progressHandler(percentCompleted);
|
||||
}
|
||||
}
|
||||
4
src/utils/upload-files/index.ts
Normal file
4
src/utils/upload-files/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import uploadFiles from './upload-files';
|
||||
|
||||
export { uploadFiles };
|
||||
export default uploadFiles;
|
||||
36
src/utils/upload-files/upload-files.ts
Normal file
36
src/utils/upload-files/upload-files.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import uploadFile from '@/utils/upload-file';
|
||||
import notify from '@/utils/notify';
|
||||
import i18n from '@/lang';
|
||||
|
||||
export default async function uploadFiles(
|
||||
files: File[],
|
||||
onProgressChange?: (percentages: number[]) => void
|
||||
) {
|
||||
const progressHandler = onProgressChange || (() => undefined);
|
||||
|
||||
const progressForFiles = files.map(() => 0);
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
uploadFile(
|
||||
file,
|
||||
(percentage: number) => {
|
||||
progressForFiles[index] = percentage;
|
||||
progressHandler(progressForFiles);
|
||||
},
|
||||
false
|
||||
)
|
||||
)
|
||||
);
|
||||
notify({
|
||||
title: i18n.tc('upload_file_success', files.length, { count: files.length }),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: i18n.tc('upload_file_failed', files.length, { count: files.length }),
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import * as GroupableComposition from '@/compositions/groupable/groupable';
|
||||
import VIcon from '@/components/v-icon';
|
||||
import VDivider from '@/components/v-divider';
|
||||
import TransitionExpand from '@/components/transition/expand';
|
||||
import VBadge from '@/components/v-badge/';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.component('v-icon', VIcon);
|
||||
localVue.component('transition-expand', TransitionExpand);
|
||||
localVue.component('v-divider', VDivider);
|
||||
localVue.component('v-badge', VBadge);
|
||||
|
||||
describe('Drawer Detail', () => {
|
||||
it('Uses the useGroupable composition', () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div class="notification-item" :class="[type, { tail, dense }]" @click="close">
|
||||
<div class="icon" v-if="icon">
|
||||
<v-icon :name="icon" />
|
||||
<div class="icon" v-if="loading || progress || icon">
|
||||
<v-progress-circular indeterminate small v-if="loading" />
|
||||
<v-progress-circular small v-else-if="progress" :value="progress" />
|
||||
<v-icon v-else :name="icon" />
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
@@ -52,6 +54,14 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
progress: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const notificationsStore = useNotificationsStore();
|
||||
@@ -185,4 +195,9 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.v-progress-circular {
|
||||
--v-progress-circular-color: var(--foreground-inverted);
|
||||
--v-progress-circular-background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<transition-group class="notifications-group" name="slide-fade" tag="div">
|
||||
<slot />
|
||||
<notification-item
|
||||
v-for="(notification, index) in queue"
|
||||
:key="notification.id"
|
||||
v-bind="notification"
|
||||
:tail="index === queue.length - 1"
|
||||
dense
|
||||
:show-close="notification.persist === true"
|
||||
:show-close="notification.persist === true && notification.closeable !== false"
|
||||
/>
|
||||
</transition-group>
|
||||
</template>
|
||||
@@ -39,7 +40,8 @@ export default defineComponent({
|
||||
width: 280px;
|
||||
direction: rtl;
|
||||
|
||||
> * {
|
||||
> *,
|
||||
::v-deep > * {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<template>
|
||||
<div class="private-view" :class="theme">
|
||||
<div
|
||||
class="private-view"
|
||||
:class="{ theme, 'drop-effect': showDropEffect }"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<aside
|
||||
role="navigation"
|
||||
aria-label="Module Navigation"
|
||||
@@ -74,6 +81,10 @@ import DrawerButton from './components/drawer-button/';
|
||||
import NotificationsGroup from './components/notifications-group/';
|
||||
import NotificationsPreview from './components/notifications-preview/';
|
||||
import useUserStore from '@/stores/user';
|
||||
import NotificationItem from './components/notification-item';
|
||||
import useNotificationsStore from '@/stores/notifications';
|
||||
import uploadFiles from '@/utils/upload-files';
|
||||
import i18n from '@/lang';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -84,6 +95,7 @@ export default defineComponent({
|
||||
DrawerButton,
|
||||
NotificationsGroup,
|
||||
NotificationsPreview,
|
||||
NotificationItem,
|
||||
},
|
||||
props: {
|
||||
title: {
|
||||
@@ -97,6 +109,7 @@ export default defineComponent({
|
||||
const contentEl = ref<Element>();
|
||||
const navigationsInline = ref(false);
|
||||
const userStore = useUserStore();
|
||||
const notificationsStore = useNotificationsStore();
|
||||
|
||||
const theme = computed(() => {
|
||||
return userStore.state.currentUser?.theme || 'auto';
|
||||
@@ -111,13 +124,96 @@ export default defineComponent({
|
||||
provide('drawer-open', drawerOpen);
|
||||
provide('main-element', contentEl);
|
||||
|
||||
const { onDragEnter, onDragLeave, onDrop, showDropEffect } = useFileUpload();
|
||||
|
||||
return {
|
||||
navOpen,
|
||||
drawerOpen,
|
||||
contentEl,
|
||||
navigationsInline,
|
||||
theme,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
showDropEffect,
|
||||
onDrop,
|
||||
};
|
||||
|
||||
function useFileUpload() {
|
||||
const showDropEffect = ref(false);
|
||||
|
||||
let dragNotificationID: string;
|
||||
let fileUploadNotificationID: string;
|
||||
|
||||
return { onDragEnter, onDragLeave, onDrop, showDropEffect };
|
||||
|
||||
function onDragEnter(event: DragEvent) {
|
||||
if (
|
||||
event.dataTransfer?.types.indexOf('Files') !== -1 &&
|
||||
showDropEffect.value === false
|
||||
) {
|
||||
showDropEffect.value = true;
|
||||
|
||||
dragNotificationID = notificationsStore.add({
|
||||
title: i18n.t('drop_to_upload'),
|
||||
icon: 'cloud_upload',
|
||||
type: 'info',
|
||||
persist: true,
|
||||
closeable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
showDropEffect.value = false;
|
||||
|
||||
if (dragNotificationID) {
|
||||
notificationsStore.remove(dragNotificationID);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrop(event: DragEvent) {
|
||||
showDropEffect.value = false;
|
||||
|
||||
if (!event.dataTransfer) return;
|
||||
if (event.dataTransfer?.types.indexOf('Files') === -1) return;
|
||||
|
||||
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, (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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -243,6 +339,17 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
&.drop-effect::after {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 500;
|
||||
width: calc(100% - 8px);
|
||||
height: calc(100% - 8px);
|
||||
border: 4px solid var(--primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
@include breakpoint(small) {
|
||||
--content-padding: 32px 32px 160px 32px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user