Merge pull request #1503 from umbraco/feature/temp-upload-updates

Feature: Media Collection Dropzone +  TempFile Upload Changes
This commit is contained in:
Lee Kelleher
2024-03-28 12:04:13 +00:00
committed by GitHub
11 changed files with 284 additions and 100 deletions

View File

@@ -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);

View File

@@ -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';

View File

@@ -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;
}
}

View File

@@ -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<UmbTemporaryFileModel> {
file: File;
status?: TemporaryFileStatus;
}
export class UmbTemporaryFileManager extends UmbControllerBase {
#temporaryFileRepository;
#queue = new UmbArrayState<TemporaryFileQueueItem>([], (item) => item.unique);
#queue = new UmbArrayState<UmbTemporaryFileModel>([], (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<Array<UmbTemporaryFileModel>> {
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<TemporaryFileQueueItem>) {
this.#queue.append(queueItems);
this.handleQueue();
async upload(queueItems: Array<UmbTemporaryFileQueueModel>): Promise<Array<UmbTemporaryFileModel>> {
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<UmbTemporaryFileModel> = [];
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;
}
}

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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`
<umb-media-collection-toolbar slot="header"></umb-media-collection-toolbar>
<!-- TODO: Add the Media Upload dropzone component in here. [LK] -->
<uui-file-dropzone
id="dropzone"
multiple
@file-change=${this.#onFileChange}
label="${this.localize.term('media_dragAndDropYourFilesIntoTheArea')}"
accept=""></uui-file-dropzone>
${when(this._progress >= 0, () => html`<uui-loader-bar progress=${this._progress}></uui-loader-bar>`)}
<umb-dropzone-media @change=${this.#onChange} @progress=${this.#onProgress}></umb-dropzone-media>
`;
}
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;

View File

@@ -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<UmbAllowedMediaTypeModel> = [];
#mediaDetailRepository = new UmbMediaDetailRepository(this);
@state()
private queue: Array<UmbTemporaryFileModel> = [];
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<File>) {
const queue = files.map((file): UmbTemporaryFileQueueModel => ({ file }));
const uploaded = await this.#fileManager.upload(queue);
return uploaded;
}
async #onFileUpload(event: UUIFileDropzoneEvent) {
const files: Array<File> = 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<UmbMediaDetailModel> = {
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`<uui-file-dropzone
id="dropzone"
multiple
@change=${this.#onFileUpload}
label="${this.localize.term('media_dragAndDropYourFilesIntoTheArea')}"
accept=""></uui-file-dropzone>`;
}
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;
}
}

View File

@@ -0,0 +1 @@
export * from './dropzone-media.element.js';

View File

@@ -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';

View File

@@ -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());
}