diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-upload-field/input-upload-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-upload-field/input-upload-field.element.ts index 391fd80510..ad87b45ca5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-upload-field/input-upload-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-upload-field/input-upload-field.element.ts @@ -1,4 +1,4 @@ -import type { TemporaryFileQueueItem } from '../../temporary-file/temporary-file-manager.class.js'; +import type { UmbTemporaryFileModel } from '../../temporary-file/temporary-file-manager.class.js'; import { UmbTemporaryFileManager } from '../../temporary-file/temporary-file-manager.class.js'; import { UMB_PROPERTY_DATASET_CONTEXT } from '../../property/property-dataset/property-dataset-context.token.js'; import { UmbId } from '@umbraco-cms/backoffice/id'; @@ -60,7 +60,7 @@ export class UmbInputUploadFieldElement extends FormControlMixin(UmbLitElement) _files: Array<{ path: string; unique: string; - queueItem?: TemporaryFileQueueItem; + queueItem?: UmbTemporaryFileModel; file?: File; }> = []; @@ -93,8 +93,8 @@ export class UmbInputUploadFieldElement extends FormControlMixin(UmbLitElement) this.#serverUrl = instance.getServerUrl(); }).asPromise(); - this.observe(this.#manager.isReady, (value) => (this.error = !value)); this.observe(this.#manager.queue, (value) => { + this.error = !value.length; this._files = this._files.map((file) => { const queueItem = value.find((item) => item.unique === file.unique); if (queueItem) { @@ -144,7 +144,7 @@ export class UmbInputUploadFieldElement extends FormControlMixin(UmbLitElement) #setFiles(files: File[]) { const items = files.map( - (file): TemporaryFileQueueItem => ({ + (file): UmbTemporaryFileModel => ({ unique: UmbId.new(), file, status: 'waiting', @@ -216,7 +216,7 @@ export class UmbInputUploadFieldElement extends FormControlMixin(UmbLitElement) ); } - #renderFile(file: { path: string; unique: string; queueItem?: TemporaryFileQueueItem; file?: File }) { + #renderFile(file: { path: string; unique: string; queueItem?: UmbTemporaryFileModel; file?: File }) { // TODO: Get the mime type from the server and use that to determine the file type. const type = this.#getFileTypeFromPath(file.path); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/event/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/event/index.ts index af975b4ddc..7863291fc0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/event/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/event/index.ts @@ -3,6 +3,7 @@ export * from './change.event.js'; export * from './delete.event.js'; export * from './deselected.event.js'; export * from './input.event.js'; +export * from './progress.event.js'; export * from './selected.event.js'; export * from './selection-change.event.js'; export * from './request-reload-structure-for-entity.event.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/event/progress.event.ts b/src/Umbraco.Web.UI.Client/src/packages/core/event/progress.event.ts new file mode 100644 index 0000000000..8d83b5d99a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/event/progress.event.ts @@ -0,0 +1,9 @@ +export class UmbProgressEvent extends Event { + public static readonly TYPE = 'progress'; + public progress: number; + + public constructor(progress: number) { + super(UmbProgressEvent.TYPE, { bubbles: true, composed: false, cancelable: false }); + this.progress = progress; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file-manager.class.ts b/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file-manager.class.ts index d14ce52a35..2f349c4bbf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file-manager.class.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file-manager.class.ts @@ -1,38 +1,54 @@ import { UmbTemporaryFileRepository } from './temporary-file.repository.js'; -import { UmbArrayState, UmbBooleanState } from '@umbraco-cms/backoffice/observable-api'; +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'; +import { UmbId } from '@umbraco-cms/backoffice/id'; -export type TemporaryFileStatus = 'complete' | 'waiting' | 'error'; +export type TemporaryFileStatus = 'success' | 'waiting' | 'error'; -export interface TemporaryFileQueueItem { - unique: string; +export interface UmbTemporaryFileModel { + file: File; + unique: string; + status: TemporaryFileStatus; +} + +export interface UmbTemporaryFileQueueModel extends Partial { file: File; - status?: TemporaryFileStatus; } export class UmbTemporaryFileManager extends UmbControllerBase { #temporaryFileRepository; - #queue = new UmbArrayState([], (item) => item.unique); + #queue = new UmbArrayState([], (item) => item.unique); public readonly queue = this.#queue.asObservable(); - #isReady = new UmbBooleanState(true); - public readonly isReady = this.#isReady.asObservable(); - constructor(host: UmbControllerHost) { super(host); this.#temporaryFileRepository = new UmbTemporaryFileRepository(host); } - uploadOne(unique: string, file: File, status: TemporaryFileStatus = 'waiting') { - this.#queue.appendOne({ unique, file, status }); - this.handleQueue(); + async uploadOne(queueItem: UmbTemporaryFileQueueModel): Promise> { + this.#queue.setValue([]); + const item: UmbTemporaryFileModel = { + file: queueItem.file, + unique: queueItem.unique ?? UmbId.new(), + status: queueItem.status ?? 'waiting', + }; + this.#queue.appendOne(item); + return this.handleQueue(); } - upload(queueItems: Array) { - this.#queue.append(queueItems); - this.handleQueue(); + async upload(queueItems: Array): Promise> { + this.#queue.setValue([]); + const items = queueItems.map( + (item): UmbTemporaryFileModel => ({ + file: item.file, + unique: item.unique ?? UmbId.new(), + status: item.status ?? 'waiting', + }), + ); + this.#queue.append(items); + return this.handleQueue(); } removeOne(unique: string) { @@ -44,31 +60,29 @@ export class UmbTemporaryFileManager extends UmbControllerBase { } private async handleQueue() { + const filesCompleted: Array = []; const queue = this.#queue.getValue(); - if (!queue.length && this.getIsReady()) return; + if (!queue.length) return filesCompleted; - this.#isReady.setValue(false); - - queue.forEach(async (item) => { - if (item.status !== 'waiting') return; + for (const item of queue) { + if (!item.unique) throw new Error(`Unique is missing for item ${item}`); const { error } = await this.#temporaryFileRepository.upload(item.unique, item.file); await new Promise((resolve) => setTimeout(resolve, (Math.random() + 0.5) * 1000)); // simulate small delay so that the upload badge is properly shown + let status: TemporaryFileStatus; if (error) { - this.#queue.updateOne(item.unique, { ...item, status: 'error' }); + status = 'error'; + this.#queue.updateOne(item.unique, { ...item, status }); } else { - this.#queue.updateOne(item.unique, { ...item, status: 'complete' }); + status = 'success'; + this.#queue.updateOne(item.unique, { ...item, status }); } - }); - if (!queue.find((item) => item.status === 'waiting') && !this.getIsReady()) { - this.#isReady.setValue(true); + filesCompleted.push({ ...item, status }); } - } - getIsReady() { - return this.#queue.getValue(); + return filesCompleted; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/index.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/index.ts index 4a54461e21..60142d5186 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/index.ts @@ -6,3 +6,5 @@ export * from './workspace/index.js'; export * from './repository/index.js'; export * from './tree/types.js'; export * from './types.js'; + +export * from './utils/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/utils/index.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/utils/index.ts new file mode 100644 index 0000000000..b67055a38e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/utils/index.ts @@ -0,0 +1,28 @@ +export enum UmbMediaTypeFileType { + SVG = 'Vector Graphics (SVG)', + IMAGE = 'Image', + AUDIO = 'Audio', + VIDEO = 'Video', + ARTICLE = 'Article', + FILE = 'File', +} + +export function getMediaTypeByFileExtension(extension: string) { + if (extension === 'svg') return UmbMediaTypeFileType.SVG; + if (['jpg', 'jpeg', 'gif', 'bmp', 'png', 'tiff', 'tif', 'webp'].includes(extension)) + return UmbMediaTypeFileType.IMAGE; + if (['mp3', 'weba', 'oga', 'opus'].includes(extension)) return UmbMediaTypeFileType.AUDIO; + if (['mp4', 'webm', 'ogv'].includes(extension)) return UmbMediaTypeFileType.VIDEO; + if (['pdf', 'docx', 'doc'].includes(extension)) return UmbMediaTypeFileType.ARTICLE; + return UmbMediaTypeFileType.FILE; +} + +export function getMediaTypeByFileMimeType(mimetype: string) { + if (mimetype === 'image/svg+xml') return UmbMediaTypeFileType.SVG; + const [type, extension] = mimetype.split('/'); + if (type === 'image') return UmbMediaTypeFileType.IMAGE; + if (type === 'audio') return UmbMediaTypeFileType.AUDIO; + if (type === 'video') return UmbMediaTypeFileType.VIDEO; + if (['pdf', 'docx', 'doc'].includes(extension)) return UmbMediaTypeFileType.ARTICLE; + return UmbMediaTypeFileType.FILE; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/collection/media-collection.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/collection/media-collection.element.ts index 79ca338c7a..dd74cf2241 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/collection/media-collection.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/collection/media-collection.element.ts @@ -1,87 +1,40 @@ -import { css, customElement, html } from '@umbraco-cms/backoffice/external/lit'; -import { UmbCollectionDefaultElement } from '@umbraco-cms/backoffice/collection'; +import type { UmbMediaCollectionContext } from './media-collection.context.js'; +import { customElement, html, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { UMB_DEFAULT_COLLECTION_CONTEXT, UmbCollectionDefaultElement } from '@umbraco-cms/backoffice/collection'; +import type { UmbProgressEvent } from '@umbraco-cms/backoffice/event'; import './media-collection-toolbar.element.js'; @customElement('umb-media-collection') export class UmbMediaCollectionElement extends UmbCollectionDefaultElement { + #mediaCollection?: UmbMediaCollectionContext; + + @state() + private _progress = -1; + constructor() { super(); - document.addEventListener('dragenter', this.#handleDragEnter.bind(this)); - document.addEventListener('dragleave', this.#handleDragLeave.bind(this)); - document.addEventListener('drop', this.#handleDrop.bind(this)); + this.consumeContext(UMB_DEFAULT_COLLECTION_CONTEXT, (instance) => { + this.#mediaCollection = instance as UmbMediaCollectionContext; + }); } - disconnectedCallback(): void { - super.disconnectedCallback(); - document.removeEventListener('dragenter', this.#handleDragEnter.bind(this)); - document.removeEventListener('dragleave', this.#handleDragLeave.bind(this)); - document.removeEventListener('drop', this.#handleDrop.bind(this)); + #onChange() { + this._progress = -1; + this.#mediaCollection?.requestCollection(); } - #handleDragEnter() { - this.toggleAttribute('dragging', true); - } - - #handleDragLeave() { - this.toggleAttribute('dragging', false); - } - - #handleDrop(event: DragEvent) { - event.preventDefault(); - console.log('#handleDrop', event); - this.toggleAttribute('dragging', false); - } - - #onFileChange(event: Event) { - console.log('#onFileChange', event); + #onProgress(event: UmbProgressEvent) { + this._progress = event.progress; } protected renderToolbar() { return html` - - + ${when(this._progress >= 0, () => html``)} + `; } - - static styles = [ - css` - :host([dragging]) #dropzone { - opacity: 1; - pointer-events: all; - } - [dropzone] { - opacity: 0; - } - #dropzone { - opacity: 0; - pointer-events: none; - display: block; - position: absolute; - inset: 0px; - z-index: 100; - backdrop-filter: opacity(1); /* Removes the built in blur effect */ - border-radius: var(--uui-border-radius); - overflow: clip; - border: 1px solid var(--uui-color-focus); - } - #dropzone:after { - content: ''; - display: block; - position: absolute; - inset: 0; - border-radius: var(--uui-border-radius); - background-color: var(--uui-color-focus); - opacity: 0.2; - } - `, - ]; } export default UmbMediaCollectionElement; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone-media/dropzone-media.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone-media/dropzone-media.element.ts new file mode 100644 index 0000000000..6a03dc5e19 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone-media/dropzone-media.element.ts @@ -0,0 +1,175 @@ +import { UmbMediaDetailRepository } from '../../repository/index.js'; +import type { UmbMediaDetailModel } from '../../types.js'; +import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; +import type { UUIFileDropzoneEvent } from '@umbraco-cms/backoffice/external/uui'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { + type UmbAllowedMediaTypeModel, + UmbMediaTypeStructureRepository, + getMediaTypeByFileMimeType, +} from '@umbraco-cms/backoffice/media-type'; +import { + UmbTemporaryFileManager, + type UmbTemporaryFileQueueModel, + type UmbTemporaryFileModel, +} from '@umbraco-cms/backoffice/temporary-file'; +import { UmbChangeEvent, UmbProgressEvent } from '@umbraco-cms/backoffice/event'; + +@customElement('umb-dropzone-media') +export class UmbDropzoneMediaElement extends UmbLitElement { + #fileManager = new UmbTemporaryFileManager(this); + #mediaTypeStructure = new UmbMediaTypeStructureRepository(this); + #allowedMediaTypes: Array = []; + #mediaDetailRepository = new UmbMediaDetailRepository(this); + + @state() + private queue: Array = []; + + constructor() { + super(); + + this.observe(this.#fileManager.queue, (queue) => { + this.queue = queue; + const completed = queue.filter((item) => item.status !== 'waiting'); + const progress = Math.round((completed.length / queue.length) * 100); + this.dispatchEvent(new UmbProgressEvent(progress)); + }); + + this.#getAllowedMediaTypes(); + document.addEventListener('dragenter', this.#handleDragEnter.bind(this)); + document.addEventListener('dragleave', this.#handleDragLeave.bind(this)); + document.addEventListener('drop', this.#handleDrop.bind(this)); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener('dragenter', this.#handleDragEnter.bind(this)); + document.removeEventListener('dragleave', this.#handleDragLeave.bind(this)); + document.removeEventListener('drop', this.#handleDrop.bind(this)); + } + + #handleDragEnter() { + this.toggleAttribute('dragging', true); + } + + #handleDragLeave() { + this.toggleAttribute('dragging', false); + } + + #handleDrop(event: DragEvent) { + event.preventDefault(); + this.toggleAttribute('dragging', false); + } + + async #getAllowedMediaTypes() { + const { data } = await this.#mediaTypeStructure.requestAllowedChildrenOf(null); + if (!data) return; + this.#allowedMediaTypes = data.items; + } + + #getMediaTypeFromMime(mimetype: string): UmbAllowedMediaTypeModel { + const mediaTypeName = getMediaTypeByFileMimeType(mimetype); + return this.#allowedMediaTypes.find((type) => type.name === mediaTypeName)!; + } + + async #uploadHandler(files: Array) { + const queue = files.map((file): UmbTemporaryFileQueueModel => ({ file })); + const uploaded = await this.#fileManager.upload(queue); + return uploaded; + } + + async #onFileUpload(event: UUIFileDropzoneEvent) { + const files: Array = event.detail.files; + if (!files.length) return; + const uploads = await this.#uploadHandler(files); + + for (const upload of uploads) { + const mediaType = this.#getMediaTypeFromMime(upload.file.type); + + const preset: Partial = { + mediaType: { + unique: mediaType.unique, + collection: null, + }, + variants: [ + { + culture: null, + segment: null, + name: upload.file.name, + createDate: null, + updateDate: null, + }, + ], + values: [ + { + alias: 'umbracoFile', + value: { src: upload.unique }, + culture: null, + segment: null, + }, + ], + }; + + const { data } = await this.#mediaDetailRepository.createScaffold(preset); + if (!data) return; + + await this.#mediaDetailRepository.create(data, null); + + this.dispatchEvent(new UmbChangeEvent()); + } + } + + render() { + return html``; + } + + static styles = [ + css` + :host([dragging]) #dropzone { + opacity: 1; + pointer-events: all; + } + + [dropzone] { + opacity: 0; + } + + #dropzone { + opacity: 0; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; + position: absolute; + inset: 0px; + z-index: 100; + backdrop-filter: opacity(1); /* Removes the built in blur effect */ + border-radius: var(--uui-border-radius); + overflow: clip; + border: 1px solid var(--uui-color-focus); + } + #dropzone:after { + content: ''; + display: block; + position: absolute; + inset: 0; + border-radius: var(--uui-border-radius); + background-color: var(--uui-color-focus); + opacity: 0.2; + } + `, + ]; +} + +export default UmbDropzoneMediaElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-dropzone-media': UmbDropzoneMediaElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone-media/index.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone-media/index.ts new file mode 100644 index 0000000000..a6c28ed09c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone-media/index.ts @@ -0,0 +1 @@ +export * from './dropzone-media.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/index.ts index 3c48dc1e29..d884d15b41 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/index.ts @@ -1,4 +1,5 @@ import './input-media/index.js'; +export * from './dropzone-media/index.js'; export * from './input-media/index.js'; export * from './input-image-cropper/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts index 902f1f082a..673440b690 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts @@ -55,7 +55,7 @@ export class UmbInputImageCropperElement extends UmbLitElement { this.value = assignToFrozenObject(this.value, { src: unique }); - this.#manager?.uploadOne(unique, file, 'waiting'); + this.#manager?.uploadOne({ unique, file }); this.dispatchEvent(new UmbChangeEvent()); }