Merge pull request #1503 from umbraco/feature/temp-upload-updates
Feature: Media Collection Dropzone + TempFile Upload Changes
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dropzone-media.element.js';
|
||||
@@ -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';
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user