diff --git a/src/assets/lang/da-dk.ts b/src/assets/lang/da-dk.ts index 0250bfb286..1683d3f056 100644 --- a/src/assets/lang/da-dk.ts +++ b/src/assets/lang/da-dk.ts @@ -353,6 +353,9 @@ export default { uploadNotAllowed: 'Upload er ikke tiladt på denne lokation', disallowedMediaType: "Cannot upload this file, the media type with alias '%0%' is not allowed here", invalidFileName: 'Cannot upload this file, it does not have a valid file name', + selectMediaTypeForUpload: 'Vælg medie type for uploadet filer.', + selectMediaTypeForUploadHelp: 'Se påvirket filer.', + selectMediaTypeAutoPick: 'Automatisk', }, member: { createNewMember: 'Opret et nyt medlem', diff --git a/src/assets/lang/en-us.ts b/src/assets/lang/en-us.ts index 26331a339d..b32f69fcea 100644 --- a/src/assets/lang/en-us.ts +++ b/src/assets/lang/en-us.ts @@ -359,6 +359,9 @@ export default { renameFolderFailed: 'Failed to rename the folder with id %0%', dragAndDropYourFilesIntoTheArea: 'Drag and drop your file(s) into the area', uploadNotAllowed: 'Upload is not allowed in this location.', + selectMediaTypeForUpload: 'Select media type for uploaded files.', + selectMediaTypeForUploadHelp: 'See files affected files.', + selectMediaTypeAutoPick: 'Auto pick', }, member: { createNewMember: 'Create a new member', diff --git a/src/assets/lang/en.ts b/src/assets/lang/en.ts index 532405afd9..8c8c91500b 100644 --- a/src/assets/lang/en.ts +++ b/src/assets/lang/en.ts @@ -368,6 +368,9 @@ export default { fileSecurityValidationFailure: 'One or more file security validations have failed', moveToSameFolderFailed: 'Parent and destination folders cannot be the same', uploadNotAllowed: 'Upload is not allowed in this location.', + selectMediaTypeForUpload: 'Select media type for uploaded files.', + selectMediaTypeForUploadHelp: 'See files affected files.', + selectMediaTypeAutoPick: 'Auto pick', }, member: { createNewMember: 'Create a new member', diff --git a/src/packages/core/notification/notification-handler.ts b/src/packages/core/notification/notification-handler.ts index 09ff5d168d..09b8ef4255 100644 --- a/src/packages/core/notification/notification-handler.ts +++ b/src/packages/core/notification/notification-handler.ts @@ -31,10 +31,10 @@ export class UmbNotificationHandler { */ constructor(options: UmbNotificationOptions) { this.key = UmbId.new(); - this.color = options.color || this._defaultColor; + this.color = options.color ?? this._defaultColor; this.duration = options.duration !== undefined ? options.duration : this._defaultDuration; - this._elementName = options.elementName || this._defaultLayout; + this._elementName = options.elementName ?? this._defaultLayout; this._data = options.data; this._closePromise = new Promise((res) => { @@ -65,6 +65,27 @@ export class UmbNotificationHandler { this.element = notification; } + /** + * Updates the notification + * @param {UmbNotificationOptions} options - The new options + */ + public Update(options: UmbNotificationOptions) { + this.color = options.color ?? this.color; + this.duration = options.duration !== undefined ? options.duration : this.duration; + + this._elementName = options.elementName ?? this._elementName; + + this._data = options.data; + + this.element.color = this.color; + this.element.autoClose = this.duration; + + const notificationChild = this.element.children?.[0]; + if (notificationChild?.data) { + notificationChild.data = { ...this.element.children[0].data, ...this._data }; + } + } + /** * @param {...any} args * @memberof UmbNotificationHandler diff --git a/src/packages/core/notification/notification.context.ts b/src/packages/core/notification/notification.context.ts index 5bdbf98670..227893e8d8 100644 --- a/src/packages/core/notification/notification.context.ts +++ b/src/packages/core/notification/notification.context.ts @@ -92,6 +92,18 @@ export class UmbNotificationContext extends UmbContextBase notification.key === handler.key); + if (notification) { + notification.Update(options); + } + } } export const UMB_NOTIFICATION_CONTEXT = new UmbContextToken('UmbNotificationContext'); diff --git a/src/packages/core/repository/detail/detail-repository-base.ts b/src/packages/core/repository/detail/detail-repository-base.ts index 1b6fb877a1..8e5122c5cb 100644 --- a/src/packages/core/repository/detail/detail-repository-base.ts +++ b/src/packages/core/repository/detail/detail-repository-base.ts @@ -87,8 +87,8 @@ export abstract class UmbDetailRepositoryBase< this.#detailStore?.append(createdData); // TODO: how do we handle generic notifications? Is this the correct place to do it? - const notification = { data: { message: `Created` } }; - this.#notificationContext!.peek('positive', notification); + // const notification = { data: { message: `Created` } }; + // this.#notificationContext!.peek('positive', notification); } return { data: createdData, error }; diff --git a/src/packages/core/temporary-file/temporary-file-manager.class.ts b/src/packages/core/temporary-file/temporary-file-manager.class.ts index 9fcf08824d..055ab9305f 100644 --- a/src/packages/core/temporary-file/temporary-file-manager.class.ts +++ b/src/packages/core/temporary-file/temporary-file-manager.class.ts @@ -3,8 +3,6 @@ import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; -///export type TemporaryFileStatus = 'success' | 'waiting' | 'error'; - export enum TemporaryFileStatus { SUCCESS = 'success', WAITING = 'waiting', @@ -17,6 +15,13 @@ export interface UmbTemporaryFileModel { status?: TemporaryFileStatus; } +type QueueHandlerCallback = (item: TItem) => Promise; + +type UploadOptions = { + chunkSize?: number; + callback?: QueueHandlerCallback; +}; + export class UmbTemporaryFileManager< UploadableItem extends UmbTemporaryFileModel = UmbTemporaryFileModel, > extends UmbControllerBase { @@ -30,7 +35,7 @@ export class UmbTemporaryFileManager< this.#temporaryFileRepository = new UmbTemporaryFileRepository(host); } - async uploadOne(uploadableItem: UploadableItem): Promise { + async uploadOne(uploadableItem: UploadableItem, options?: UploadOptions): Promise { this.#queue.setValue([]); const item: UploadableItem = { @@ -39,15 +44,18 @@ export class UmbTemporaryFileManager< }; this.#queue.appendOne(item); - return (await this.#handleQueue())[0]; + return (await this.#handleQueue({ ...options, chunkSize: 1 }))[0]; } - async upload(queueItems: Array): Promise> { + async upload( + queueItems: Array, + options?: UploadOptions, + ): Promise> { this.#queue.setValue([]); const items = queueItems.map((item): UploadableItem => ({ status: TemporaryFileStatus.WAITING, ...item })); this.#queue.append(items); - return this.#handleQueue(); + return this.#handleQueue(options); } removeOne(unique: string) { @@ -58,30 +66,44 @@ export class UmbTemporaryFileManager< this.#queue.remove(uniques); } - async #handleQueue() { + async #handleQueue(options?: UploadOptions): Promise> { const filesCompleted: Array = []; const queue = this.#queue.getValue(); + const chunkSize = options?.chunkSize ?? 5; if (!queue.length) return filesCompleted; - for (const item of queue) { - if (!item.temporaryUnique) throw new Error(`Unique is missing for item ${item}`); + const chunks = Math.ceil(queue.length / chunkSize); - const { error } = await this.#temporaryFileRepository.upload(item.temporaryUnique, item.file); - //await new Promise((resolve) => setTimeout(resolve, (Math.random() + 0.5) * 1000)); // simulate small delay so that the upload badge is properly shown + const handler: QueueHandlerCallback = async (item) => { + const completedUpload = await this.#handleUpload(item); + filesCompleted.push(completedUpload); - let status: TemporaryFileStatus; - if (error) { - status = TemporaryFileStatus.ERROR; - this.#queue.updateOne(item.temporaryUnique, { ...item, status }); - } else { - status = TemporaryFileStatus.SUCCESS; - this.#queue.updateOne(item.temporaryUnique, { ...item, status }); - } + if (options?.callback) await options.callback(completedUpload); + }; - filesCompleted.push({ ...item, status }); + for (let i = 0; i < chunks; i++) { + const chunk = queue.slice(i * chunkSize, i * chunkSize + chunkSize); + await Promise.all(chunk.map(handler)); } return filesCompleted; } + + async #handleUpload(item: UploadableItem) { + if (!item.temporaryUnique) throw new Error(`Unique is missing for item ${item}`); + + const { error } = await this.#temporaryFileRepository.upload(item.temporaryUnique, item.file); + + let status: TemporaryFileStatus; + if (error) { + status = TemporaryFileStatus.ERROR; + this.#queue.updateOne(item.temporaryUnique, { ...item, status }); + } else { + status = TemporaryFileStatus.SUCCESS; + this.#queue.updateOne(item.temporaryUnique, { ...item, status }); + } + + return { ...item, status }; + } } diff --git a/src/packages/documents/document-types/entity-actions/import/modal/document-type-import-modal.element.ts b/src/packages/documents/document-types/entity-actions/import/modal/document-type-import-modal.element.ts index 9bfef4ee36..5e812eaab6 100644 --- a/src/packages/documents/document-types/entity-actions/import/modal/document-type-import-modal.element.ts +++ b/src/packages/documents/document-types/entity-actions/import/modal/document-type-import-modal.element.ts @@ -45,7 +45,7 @@ export class UmbDocumentTypeImportModalLayout extends UmbModalBaseElement< #onFileDropped() { const data = this.dropzone?.getFiles()[0]; - if (!data) return; + if (!data || !('temporaryUnique' in data)) return; this.#temporaryUnique = data.temporaryUnique; this.#fileReader.readAsText(data.file); diff --git a/src/packages/media/media-types/entity-actions/import/modal/media-type-import-modal.element.ts b/src/packages/media/media-types/entity-actions/import/modal/media-type-import-modal.element.ts index 76e40e994f..cb465850d3 100644 --- a/src/packages/media/media-types/entity-actions/import/modal/media-type-import-modal.element.ts +++ b/src/packages/media/media-types/entity-actions/import/modal/media-type-import-modal.element.ts @@ -42,7 +42,7 @@ export class UmbMediaTypeImportModalLayout extends UmbModalBaseElement< #onFileDropped() { const data = this.dropzone?.getFiles()[0]; - if (!data) return; + if (!data || !('temporaryUnique' in data)) return; this.#temporaryUnique = data.temporaryUnique; this.#fileReader.readAsText(data.file); diff --git a/src/packages/media/media-types/repository/structure/media-type-structure.repository.ts b/src/packages/media/media-types/repository/structure/media-type-structure.repository.ts index 86733391e0..2cbbce075c 100644 --- a/src/packages/media/media-types/repository/structure/media-type-structure.repository.ts +++ b/src/packages/media/media-types/repository/structure/media-type-structure.repository.ts @@ -21,6 +21,10 @@ export class UmbMediaTypeStructureRepository extends UmbContentTypeStructureRepo }) { return this.#dataSource.getMediaTypesOfFileExtension({ fileExtension, skip, take }); } + + async requestFolderMediaTypes({ skip = 0, take = 100 }: { skip?: number; take?: number }) { + return this.#dataSource.getMediaTypesFolders({ skip, take }); + } } export default UmbMediaTypeStructureRepository; diff --git a/src/packages/media/media-types/repository/structure/media-type-structure.server.data-source.ts b/src/packages/media/media-types/repository/structure/media-type-structure.server.data-source.ts index df88ed9ec2..c18130b24e 100644 --- a/src/packages/media/media-types/repository/structure/media-type-structure.server.data-source.ts +++ b/src/packages/media/media-types/repository/structure/media-type-structure.server.data-source.ts @@ -21,6 +21,10 @@ export class UmbMediaTypeStructureServerDataSource extends UmbContentTypeStructu getMediaTypesOfFileExtension({ fileExtension, skip, take }: { fileExtension: string; skip: number; take: number }) { return getAllowedMediaTypesOfExtension({ fileExtension, skip, take }); } + + getMediaTypesFolders({ skip, take }: { skip: number; take: number }) { + return getItemMediaTypeFolders({ skip, take }); + } } const getAllowedChildrenOf = (unique: string | null) => { @@ -55,3 +59,9 @@ const getAllowedMediaTypesOfExtension = async ({ const { items } = await MediaTypeService.getItemMediaTypeAllowed({ fileExtension, skip, take }); return items.map((item) => mapper(item)); }; + +const getItemMediaTypeFolders = async ({ skip, take }: { skip: number; take: number }) => { + // eslint-disable-next-line local-rules/no-direct-api-import + const { items } = await MediaTypeService.getItemMediaTypeFolders({ skip, take }); + return items.map((item) => mapper(item)); +}; diff --git a/src/packages/media/media/collection/media-collection.element.ts b/src/packages/media/media/collection/media-collection.element.ts index d830224a7c..e11111b3df 100644 --- a/src/packages/media/media/collection/media-collection.element.ts +++ b/src/packages/media/media/collection/media-collection.element.ts @@ -13,6 +13,8 @@ import { UmbRequestReloadChildrenOfEntityEvent } from '@umbraco-cms/backoffice/e @customElement('umb-media-collection') export class UmbMediaCollectionElement extends UmbCollectionDefaultElement { #mediaCollection?: UmbMediaCollectionContext; + #refreshInCooldown: boolean = false; + #shouldRefreshCollection: boolean = false; @state() private _progress = -1; @@ -33,8 +35,7 @@ export class UmbMediaCollectionElement extends UmbCollectionDefaultElement { } async #onChange() { - this._progress = -1; - this.#mediaCollection?.requestCollection(); + this.#refreshCollection(); const eventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT); const event = new UmbRequestReloadChildrenOfEntityEvent({ @@ -48,12 +49,29 @@ export class UmbMediaCollectionElement extends UmbCollectionDefaultElement { this._progress = event.progress; } + #refreshCollection() { + if (!this.#refreshInCooldown) { + this.#mediaCollection?.requestCollection(); + this.#refreshInCooldown = true; + setTimeout(() => { + this.#refreshInCooldown = false; + if (this.#shouldRefreshCollection) { + this.#refreshCollection(); + this.#shouldRefreshCollection = false; + } + }, 350); + } else { + this.#shouldRefreshCollection = true; + } + } + protected override renderToolbar() { return html` ${when(this._progress >= 0, () => html``)} `; diff --git a/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts b/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts index 1e705d6de7..91b459200f 100644 --- a/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts +++ b/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts @@ -346,7 +346,7 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, #renderDropzone() { if (this.readonly) return nothing; if (this._cards && this._cards.length >= this.max) return; - return html``; + return html``; } #renderItems() { diff --git a/src/packages/media/media/dropzone/dropzone-manager.class.ts b/src/packages/media/media/dropzone/dropzone-manager.class.ts index 8e397f05cd..5efa9b0ab1 100644 --- a/src/packages/media/media/dropzone/dropzone-manager.class.ts +++ b/src/packages/media/media/dropzone/dropzone-manager.class.ts @@ -1,5 +1,6 @@ import type { UmbMediaDetailModel } from '../types.js'; import { UmbMediaDetailRepository } from '../repository/index.js'; +import { UmbSortChildrenOfMediaServerDataSource } from '../entity-actions/sort-children-of/repository/sort-children-of.server.data.js'; import { UMB_DROPZONE_MEDIA_TYPE_PICKER_MODAL } from './modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.token.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; @@ -12,12 +13,21 @@ import { import { UmbId } from '@umbraco-cms/backoffice/id'; import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; +import type { UmbNotificationHandler, UmbNotificationContext } from '@umbraco-cms/backoffice/notification'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import type { UUIFileFolder } from '@umbraco-cms/backoffice/external/uui'; export interface UmbUploadableFileModel extends UmbTemporaryFileModel { unique: string; mediaTypeUnique: string; } +export interface UmbUploadableFolderModel { + folderName: string; + mediaTypeUnique: string; + unique: string; +} + export interface UmbUploadableExtensionModel { fileExtension: string; mediaTypes: Array; @@ -32,20 +42,30 @@ export interface UmbUploadableExtensionModel { export class UmbDropzoneManager extends UmbControllerBase { #host; - #tempFileManager = new UmbTemporaryFileManager(this); + #init: Promise; + + #tempFileManager = new UmbTemporaryFileManager(this); + + #notificationContext?: UmbNotificationContext; #mediaTypeStructure = new UmbMediaTypeStructureRepository(this); #mediaDetailRepository = new UmbMediaDetailRepository(this); + #sortMediaDataSource = new UmbSortChildrenOfMediaServerDataSource(this); - #completed = new UmbArrayState( - [], - (upload) => upload.temporaryUnique, - ); + #completed = new UmbArrayState([], (upload) => upload.unique); public readonly completed = this.#completed.asObservable(); + public notificationHandler?: UmbNotificationHandler; + constructor(host: UmbControllerHost) { super(host); this.#host = host; + + this.#init = Promise.all([ + this.consumeContext(UMB_NOTIFICATION_CONTEXT, (instance) => { + this.#notificationContext = instance; + }).asPromise(), + ]); } /** @@ -54,11 +74,15 @@ export class UmbDropzoneManager extends UmbControllerBase { * @returns Promise> */ public async createFilesAsTemporary(files: Array): Promise> { - this.#completed.setValue([]); const temporaryFiles: Array = []; for (const file of files) { - const uploaded = await this.#tempFileManager.uploadOne({ temporaryUnique: UmbId.new(), file }); + const uploaded = await this.#tempFileManager.uploadOne({ + temporaryUnique: UmbId.new(), + file, + mediaTypeUnique: 'TEMPORARY', + unique: UmbId.new(), + }); this.#completed.setValue([...this.#completed.getValue(), uploaded]); temporaryFiles.push(uploaded); } @@ -66,136 +90,169 @@ export class UmbDropzoneManager extends UmbControllerBase { return temporaryFiles; } + public async createFoldersAsMedia(folders: Array, parentUnique: string | null): Promise { + if (!folders.length) return; + await this.#init; + + await this.#createFoldersAsMedia(folders, parentUnique); + } + + async #createFoldersAsMedia( + folders: Array, + parentUnique: string | null, + mediaTypeUnique?: string | null, + ): Promise { + const folderOptions = await this.#buildAllowedFolderTypes(parentUnique); + if (!folderOptions?.length) { + this.#notifyInvalidFolders(folders); + return; + } + + const pickedMediaTypeUnique = mediaTypeUnique ?? (await this.#pickFolderMediaType(folderOptions, folders)); + const uploadableFolders = folders.map((folder) => ({ folder, mediaTypeUnique: pickedMediaTypeUnique })); + + for (const uploadableFolder of uploadableFolders) { + const folderUnique = UmbId.new(); + await this.#createFolderItem( + { + folderName: uploadableFolder.folder.folderName, + mediaTypeUnique: uploadableFolder.mediaTypeUnique, + unique: folderUnique, + }, + parentUnique, + ); + + if (uploadableFolder.folder.files.length) { + await this.#createFilesAsMedia(uploadableFolder.folder.files, folderUnique); + } + + if (uploadableFolder.folder.folders.length) { + await this.#createFoldersAsMedia( + uploadableFolder.folder.folders, + folderUnique, + uploadableFolder.mediaTypeUnique, + ); + } + + this.#completed.setValue([ + ...this.#completed.getValue(), + { + folderName: uploadableFolder.folder.folderName, + mediaTypeUnique: uploadableFolder.mediaTypeUnique, + unique: folderUnique, + } satisfies UmbUploadableFolderModel, + ]); + } + } + + #notifyInvalidFolders(folders: UUIFileFolder[]) { + folders.forEach((folder) => { + this.#notificationContext?.peek('danger', { + data: { + headline: 'Upload failed', + message: `Folder ${folder.folderName} is not allowed here.`, + }, + }); + }); + } + /** * Uploads files to the server and creates the items with corresponding media type. * Allows the user to pick a media type option if multiple types are allowed. - * @param files - * @param parentUnique - * @returns Promise + * @param {Array} files - Files to upload + * @param {string | null} parentUnique - Unique of the parent media item + * @returns {Promise} */ - public async createFilesAsMedia(files: Array, parentUnique: string | null) { + public async createFilesAsMedia(files: Array, parentUnique: string | null): Promise { if (!files.length) return; - if (files.length === 1) return this.#handleOneOneFile(files[0], parentUnique); - - // Handler for multiple files dropped + await this.#init; - this.#completed.setValue([]); - // removes duplicate file types so we don't call endpoints unnecessarily when building options. - const mimeTypes = [...new Set(files.map((file) => file.type))]; - const optionsArray = await this.#buildOptionsArrayFrom( - mimeTypes.map((mimetype) => this.#getExtensionFromMime(mimetype)), - parentUnique, - ); - - if (!optionsArray.length) return; // None of the files are allowed in current dropzone. + await this.#createFilesAsMedia(files, parentUnique); + } - // Building an array of uploadable files. Do we want to build an array of failed files to let the user know which ones? + async #createFilesAsMedia(files: Array, parentUnique: string | null): Promise { const uploadableFiles: Array = []; const notAllowedFiles: Array = []; + const filesByExtension: Record> = {}; for (const file of files) { - const extension = this.#getExtensionFromMime(file.type); - if (!extension) { - // Folders have no extension on file drop. We assume it is a folder being uploaded. + const fileNameParts = file.name.split('.'); + if (fileNameParts.length < 2) { + // File has no extension + notAllowedFiles.push(file); continue; } + + const extension = fileNameParts[fileNameParts.length - 1]; + filesByExtension[extension] = filesByExtension[extension] || []; + filesByExtension[extension].push(file); + } + + const optionsArray = await this.#buildOptionsArrayFrom(Object.keys(filesByExtension), parentUnique); + if (!optionsArray.length) return; // None of the files are allowed in current dropzone. + + for (const extension of Object.keys(filesByExtension)) { const options = optionsArray.find((option) => option.fileExtension === extension)?.mediaTypes; - if (!options || !options.length) { + if (!options?.length) { // TODO Current dropped file not allowed in this area. Find a good way to show this to the user after we finish uploading the rest of the files. - notAllowedFiles.push(file); + notAllowedFiles.push(...filesByExtension[extension]); continue; } - // Since we are uploading multiple files, we will pick first allowed option. - // Consider a way we can handle this differently in the future to let the user choose. Maybe a list of all files with an allowed media type dropdown? - const mediaType = options[0]; - uploadableFiles.push({ - temporaryUnique: UmbId.new(), - file, - mediaTypeUnique: mediaType.unique, - unique: UmbId.new(), - }); + const files = filesByExtension[extension]; + + const mediaTypeUnique = await this.#pickFolderMediaType(options, files); + + for (const file of files) { + uploadableFiles.push({ + temporaryUnique: UmbId.new(), + file, + mediaTypeUnique, + unique: UmbId.new(), + }); + } } notAllowedFiles.forEach((file) => { - // TODO: It seems like some implementation(user feedback) is missing here? [NL] - console.error(`File ${file.name} of type ${file.type} is not allowed here.`); + this.#notificationContext?.peek('danger', { + data: { + headline: 'Upload failed', + message: `File ${file.name} of type ${file.type ?? 'Unknown'} is not allowed here.`, + }, + }); }); if (!uploadableFiles.length) return; await this.#handleUpload(uploadableFiles, parentUnique); - } - - async #handleOneOneFile(file: File, parentUnique: string | null) { - this.#completed.setValue([]); - const extension = this.#getExtensionFromMime(file.type); - - if (!extension) { - // TODO Folders have no extension on file drop. Assume it is a folder being uploaded. - return; - } - - const optionsArray = await this.#buildOptionsArrayFrom([extension], parentUnique); - if (!optionsArray.length || !optionsArray[0].mediaTypes.length) { - throw new Error(`File ${file.name} of type ${file.type} is not allowed here.`); // Parent does not allow this file type here. - } - - const mediaTypes = optionsArray[0].mediaTypes; - if (mediaTypes.length === 1) { - // Only one allowed option, upload file using that option. - const uploadableFile: UmbUploadableFileModel = { - unique: UmbId.new(), - temporaryUnique: UmbId.new(), - file, - mediaTypeUnique: mediaTypes[0].unique, - }; - await this.#handleUpload([uploadableFile], parentUnique); - return; - } - - // Multiple options, show a dialog for the user to pick one. - const mediaType = await this.#showDialogMediaTypePicker(mediaTypes); - if (!mediaType) return; // Upload cancelled. - - const uploadableFile: UmbUploadableFileModel = { - unique: UmbId.new(), - temporaryUnique: UmbId.new(), - file, - mediaTypeUnique: mediaType.unique, - }; - await this.#handleUpload([uploadableFile], parentUnique); + await this.#sortUploadedFiles(uploadableFiles, parentUnique); } - #getExtensionFromMime(mime: string): string { - //TODO Temporary solution. - if (!mime) return ''; //folders - const extension = mime.split('/')[1]; - switch (extension) { - case 'svg+xml': - return 'svg'; - default: - return extension; - } + async #sortUploadedFiles(uploadableFiles: UmbUploadableFileModel[], parentUnique: string | null) { + let sortOrder = uploadableFiles.length - 1; + await this.#sortMediaDataSource.sortChildrenOf({ + unique: parentUnique, + sorting: uploadableFiles.map((file) => { + return { + unique: file.unique, + sortOrder: sortOrder--, + }; + }), + }); } async #buildOptionsArrayFrom( fileExtensions: Array, parentUnique: string | null, ): Promise> { - let parentMediaType: string | null = null; - if (parentUnique) { - const { data } = await this.#mediaDetailRepository.requestByUnique(parentUnique); - parentMediaType = data?.mediaType.unique ?? null; - } + const parentMediaType = await this.#getParentMediaType(parentUnique); // Getting all media types allowed in our current position based on parent's media type. const { data: allAllowedMediaTypes } = await this.#mediaTypeStructure.requestAllowedChildrenOf(parentMediaType); if (!allAllowedMediaTypes?.items.length) return []; - const allowedByParent = allAllowedMediaTypes.items; // Building an array of options the files can be uploaded as. @@ -211,50 +268,121 @@ export class UmbDropzoneManager extends UmbControllerBase { return options; } - async #showDialogMediaTypePicker(options: Array) { + async #buildAllowedFolderTypes(parentUnique: string | null) { + const allFolderTypes = await this.#mediaTypeStructure.requestFolderMediaTypes({}); + + const parentMediaType = await this.#getParentMediaType(parentUnique); + + const { data: allAllowedMediaTypes } = await this.#mediaTypeStructure.requestAllowedChildrenOf(parentMediaType); + if (!allAllowedMediaTypes?.items.length) return []; + const allowedByParent = allAllowedMediaTypes.items; + + return allFolderTypes.filter((folderType) => { + return allowedByParent.find((allowed) => folderType.unique === allowed.unique); + }); + } + + async #getParentMediaType(parentUnique: string | null): Promise { + let parentMediaType: string | null = null; + if (parentUnique) { + const { data } = await this.#mediaDetailRepository.requestByUnique(parentUnique); + parentMediaType = data?.mediaType.unique ?? null; + } + return parentMediaType; + } + + async #pickFolderMediaType(options: Array, elements: Array) { + if (options.length === 1) return options[0].unique; + const selectedMediaType = await this.#showDialogMediaTypePicker( + elements.map((e) => ({ name: 'folderName' in e ? e.folderName : e.name })), + options, + ); + return selectedMediaType?.unique ?? options[0].unique; + } + + async #showDialogMediaTypePicker(files: { name: string }[], options: Array) { const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT); - const modalContext = modalManager.open(this.#host, UMB_DROPZONE_MEDIA_TYPE_PICKER_MODAL, { data: { options } }); + const modalContext = modalManager.open(this.#host, UMB_DROPZONE_MEDIA_TYPE_PICKER_MODAL, { + data: { options, files }, + }); const value = await modalContext.onSubmit().catch(() => undefined); return value ? { unique: value.mediaTypeUnique ?? options[0].unique } : null; } async #handleUpload(files: Array, parentUnique: string | null) { - for (const file of files) { - const upload = (await this.#tempFileManager.uploadOne(file)) as UmbUploadableFileModel; + await this.#tempFileManager.upload(files, { + callback: async (upload) => { + switch (upload.status) { + case TemporaryFileStatus.SUCCESS: + await this.#createMediaItem(upload, parentUnique); + break; + case TemporaryFileStatus.ERROR: + // TODO Find a good way to show files that ended up as TemporaryFileStatus.ERROR. Notice that they were allowed in current area + break; + } + + this.#completed.setValue([...this.#completed.getValue(), upload]); + }, + }); + } - if (upload.status === TemporaryFileStatus.SUCCESS) { - // Upload successful. Create media item. - const preset: Partial = { - unique: file.unique, - mediaType: { - unique: upload.mediaTypeUnique, - collection: null, - }, - variants: [ - { - culture: null, - segment: null, - name: upload.file.name, - createDate: null, - updateDate: null, - }, - ], - values: [ - { - alias: 'umbracoFile', - value: { temporaryFileId: upload.temporaryUnique }, - culture: null, - segment: null, - }, - ], - }; - const { data } = await this.#mediaDetailRepository.createScaffold(preset); - await this.#mediaDetailRepository.create(data!, parentUnique); - } - // TODO Find a good way to show files that ended up as TemporaryFileStatus.ERROR. Notice that they were allowed in current area + async #createMediaItem(upload: UmbUploadableFileModel, parentUnique: string | null) { + const preset: Partial = { + unique: upload.unique, + mediaType: { + unique: upload.mediaTypeUnique, + collection: null, + }, + variants: [ + { + culture: null, + segment: null, + name: upload.file.name, + createDate: null, + updateDate: null, + }, + ], + values: [ + { + alias: 'umbracoFile', + value: { temporaryFileId: upload.temporaryUnique }, + culture: null, + segment: null, + }, + ], + }; - this.#completed.setValue([...this.#completed.getValue(), upload]); - } + const { data } = await this.#mediaDetailRepository.createScaffold(preset); + await this.#mediaDetailRepository.create(data!, parentUnique); + } + + async #createFolderItem( + upload: { + folderName: string; + mediaTypeUnique: string; + unique: string; + }, + parentUnique: string | null, + ) { + const preset: Partial = { + unique: upload.unique, + mediaType: { + unique: upload.mediaTypeUnique, + collection: null, + }, + variants: [ + { + culture: null, + segment: null, + name: upload.folderName, + createDate: null, + updateDate: null, + }, + ], + }; + + const { data } = await this.#mediaDetailRepository.createScaffold(preset); + await this.#mediaDetailRepository.create(data!, parentUnique); } private _reset() { diff --git a/src/packages/media/media/dropzone/dropzone.element.ts b/src/packages/media/media/dropzone/dropzone.element.ts index f54bd1cbeb..a6d35df21f 100644 --- a/src/packages/media/media/dropzone/dropzone.element.ts +++ b/src/packages/media/media/dropzone/dropzone.element.ts @@ -1,9 +1,10 @@ -import { UmbDropzoneManager, type UmbUploadableFileModel } from './dropzone-manager.class.js'; +import type { UmbUploadableFolderModel, UmbUploadableFileModel } from './dropzone-manager.class.js'; +import { UmbDropzoneManager } from './dropzone-manager.class.js'; import { UmbProgressEvent } from '@umbraco-cms/backoffice/event'; import { css, html, customElement, property } from '@umbraco-cms/backoffice/external/lit'; -import type { UUIFileDropzoneElement, UUIFileDropzoneEvent } from '@umbraco-cms/backoffice/external/uui'; +import type { UUIFileDropzoneElement, UUIFileDropzoneEvent, UUIFileFolder } from '@umbraco-cms/backoffice/external/uui'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import type { UmbTemporaryFileModel } from '@umbraco-cms/backoffice/temporary-file'; +import { UMB_NOTIFICATION_CONTEXT, type UmbNotificationContext } from '@umbraco-cms/backoffice/notification'; @customElement('umb-dropzone') export class UmbDropzoneElement extends UmbLitElement { @@ -11,7 +12,7 @@ export class UmbDropzoneElement extends UmbLitElement { parentUnique: string | null = null; @property({ type: Boolean }) - multiple: boolean = true; + multiple: boolean = false; @property({ type: Boolean }) createAsTemporary: boolean = false; @@ -21,7 +22,11 @@ export class UmbDropzoneElement extends UmbLitElement { //TODO: logic to disable the dropzone? - #files: Array = []; + #init: Promise; + + #notificationContext?: UmbNotificationContext; + + #files: Array = []; public getFiles() { return this.#files; @@ -37,6 +42,12 @@ export class UmbDropzoneElement extends UmbLitElement { document.addEventListener('dragenter', this.#handleDragEnter.bind(this)); document.addEventListener('dragleave', this.#handleDragLeave.bind(this)); document.addEventListener('drop', this.#handleDrop.bind(this)); + + this.#init = Promise.all([ + this.consumeContext(UMB_NOTIFICATION_CONTEXT, (instance) => { + this.#notificationContext = instance; + }).asPromise(), + ]); } override disconnectedCallback(): void { @@ -63,36 +74,77 @@ export class UmbDropzoneElement extends UmbLitElement { } async #onDropFiles(event: UUIFileDropzoneEvent) { - // TODO Handle of folder uploads. + if (!event.detail.folders && !event.detail.files) return; - const files: Array = event.detail.files; - if (!files.length) return; + await this.#init; + + const folderFileCount = this.#countFilesInFolders(event.detail.folders); + const fileCount = folderFileCount + (event.detail.files?.length ?? 0); const dropzoneManager = new UmbDropzoneManager(this); this.observe( dropzoneManager.completed, (completed) => { - if (!completed.length) return; + this.#displayProgress(dropzoneManager, completed.length, fileCount); - const progress = Math.floor(completed.length / files.length); + const progress = Math.floor(completed.length / fileCount); this.dispatchEvent(new UmbProgressEvent(progress)); - if (completed.length === files.length) { - this.#files = completed; - this.dispatchEvent(new CustomEvent('change', { detail: { completed } })); + this.#files = completed; + this.dispatchEvent(new CustomEvent('change', { detail: { completed } })); + + if (completed.length === fileCount) { + this.dispatchEvent(new UmbProgressEvent(-1)); dropzoneManager.destroy(); } }, - '_observeCompleted', + '_observeCompleted' + Date.now(), // Unique key to avoid multiple drops overriding each other ); - //TODO Create some placeholder items while files are being uploaded? Could update them as they get completed. - if (this.createAsTemporary) { - await dropzoneManager.createFilesAsTemporary(files); - } else { - await dropzoneManager.createFilesAsMedia(files, this.parentUnique); + + if (event.detail.folders) { + await dropzoneManager.createFoldersAsMedia(event.detail.folders, this.parentUnique); + } + + if (event.detail.files) { + if (this.createAsTemporary) { + await dropzoneManager.createFilesAsTemporary(event.detail.files); + } else { + await dropzoneManager.createFilesAsMedia(event.detail.files, this.parentUnique); + } } } + #displayProgress(dropzoneManager: UmbDropzoneManager, progress: number, total: number) { + if (dropzoneManager.notificationHandler) { + this.#notificationContext?.update(dropzoneManager.notificationHandler, { + data: { + message: `${progress}/${total} items uploaded.`, + }, + color: progress === total ? 'positive' : 'default', + duration: progress === total ? 6000 : null, + }); + return; + } + + dropzoneManager.notificationHandler = this.#notificationContext?.stay('default', { + data: { + headline: 'Uploading files', + message: `${progress}/${total} items uploaded.`, + }, + color: progress === total ? 'positive' : 'default', + duration: progress === total ? 6000 : null, + }); + } + + #countFilesInFolders(folders: UUIFileFolder[] | null): number { + if (!folders) return 0; + + return ( + folders.reduce((count, folder) => count + folder.files.length + this.#countFilesInFolders(folder.folders), 0) + + folders.length + ); + } + override render() { return html` = []; + @state() + private _filesShown: boolean = false; + @query('#auto') private _buttonAuto!: UUIButtonElement; @@ -31,36 +34,88 @@ export class UmbDropzoneMediaTypePickerModalElement extends UmbModalBaseElement< } override render() { - return html`
- this.#onMediaTypePick(undefined)} - label="Automatically" - compact> - Auto pick - - ${repeat( - this._options, - (option) => option.unique, - (option) => - html` this.#onMediaTypePick(option.unique)} - label=${option.name} - compact> - ${option.name} - `, - )} + return html` `; } static override styles = [ UmbTextStyles, css` - #options { + .modal { display: flex; + flex-direction: column; + margin: var(--uui-size-layout-1); + gap: var(--uui-size-3); + } + .info-box { + display: none; + position: absolute; + background: var(--uui-color-background); + padding: var(--uui-size-4); + border: 1px solid var(--uui-color-border); + border-radius: var(--uui-size-1); + box-shadow: var(--uui-shadow-1); margin: var(--uui-size-layout-1); + margin-bottom: 0; + font-size: var(--uui-size-4); + z-index: 100; + left: 0; + right: 0; + top: 25px; + bottom: 0; + overflow-y: auto; + overflow-x: hidden; + } + .info-box.shown { + display: flex; + flex-direction: column; + } + #info-icon { + cursor: pointer; + } + #options { + display: flex; gap: var(--uui-size-3); } uui-button { diff --git a/src/packages/media/media/dropzone/modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.token.ts b/src/packages/media/media/dropzone/modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.token.ts index e8437bf919..c339022348 100644 --- a/src/packages/media/media/dropzone/modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.token.ts +++ b/src/packages/media/media/dropzone/modals/dropzone-media-type-picker/dropzone-media-type-picker-modal.token.ts @@ -3,7 +3,9 @@ import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; export interface UmbDropzoneMediaTypePickerModalData { options: Array; - files?: Array; + files?: Array<{ + name: string; + }>; } export type UmbDropzoneMediaTypePickerModalValue = { diff --git a/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts b/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts index b83579178f..4103119c6e 100644 --- a/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts +++ b/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts @@ -168,7 +168,7 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement< #renderBody() { return html`${this.#renderToolbar()} - this.#loadMediaFolder()} .parentUnique=${this._currentMediaEntity.unique}> + this.#loadMediaFolder()} .parentUnique=${this._currentMediaEntity.unique}> ${ !this._mediaFilteredList.length ? html`

${this.localize.term('content_listViewNoItems')}

`