diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index 3a51ab3741..42b2aa56f6 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -20,6 +20,7 @@ "element-internals-polyfill": "^1.3.10", "lit": "^3.1.2", "marked": "^12.0.0", + "mime-types": "^2.1.35", "monaco-editor": "^0.46.0", "rxjs": "^7.8.1", "tinymce": "^6.8.3", @@ -15849,7 +15850,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -15858,7 +15858,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 73eef9f83d..62eb0b7644 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -96,6 +96,7 @@ "./external/dompurify": "./dist-cms/external/dompurify/index.js", "./external/lit": "./dist-cms/external/lit/index.js", "./external/marked": "./dist-cms/external/marked/index.js", + "./external/mime-types": "./dist-cms/external/mime-types/index.js", "./external/monaco-editor": "./dist-cms/external/monaco-editor/index.js", "./external/openid": "./dist-cms/external/openid/index.js", "./external/router-slot": "./dist-cms/external/router-slot/index.js", @@ -179,6 +180,7 @@ "element-internals-polyfill": "^1.3.10", "lit": "^3.1.2", "marked": "^12.0.0", + "mime-types": "^2.1.35", "monaco-editor": "^0.46.0", "rxjs": "^7.8.1", "tinymce": "^6.8.3", diff --git a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/models.ts b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/models.ts index 1bcb75c327..1987ac421e 100644 --- a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/models.ts +++ b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/models.ts @@ -625,8 +625,7 @@ updater?: string | null }; export type DocumentConfigurationResponseModel = { - sanitizeTinyMce: boolean -disableDeleteWhenReferenced: boolean + disableDeleteWhenReferenced: boolean disableUnpublishWhenReferenced: boolean allowEditInvariantFromNonDefault: boolean allowNonExistingSegmentsCreation: boolean @@ -1124,7 +1123,6 @@ mediaType: MediaTypeCollectionReferenceResponseModel export type MediaConfigurationResponseModel = { disableDeleteWhenReferenced: boolean disableUnpublishWhenReferenced: boolean -sanitizeTinyMce: boolean reservedFieldNames: Array }; @@ -1788,6 +1786,11 @@ export type PagedSearcherResponseModel = { items: Array }; +export type PagedSegmentResponseModel = { + total: number +items: Array + }; + export type PagedTagResponseModel = { total: number items: Array @@ -1894,6 +1897,13 @@ memberUserNames: Array memberGroupNames: Array }; +export type PublicAccessResponseModel = { + loginDocument: ReferenceByIdModel +errorDocument: ReferenceByIdModel +members: Array +groups: Array + }; + export type PublishDocumentRequestModel = { publishSchedules: Array }; @@ -2052,6 +2062,11 @@ export type SecurityConfigurationResponseModel = { passwordConfiguration: PasswordConfigurationResponseModel }; +export type SegmentResponseModel = { + name: string +alias: string + }; + export type ServerConfigurationItemResponseModel = { name: string data: string @@ -3397,7 +3412,7 @@ take?: number ,PutDocumentByIdNotifications: string ,PostDocumentByIdPublicAccess: string ,DeleteDocumentByIdPublicAccess: string - ,GetDocumentByIdPublicAccess: void + ,GetDocumentByIdPublicAccess: PublicAccessResponseModel ,PutDocumentByIdPublicAccess: string ,PutDocumentByIdPublish: string ,PutDocumentByIdPublishWithDescendants: string @@ -3583,6 +3598,7 @@ requestBody?: UpdateLanguageRequestModel responses: { GetItemLanguage: Array + ,GetItemLanguageDefault: LanguageItemResponseModel ,GetLanguage: PagedLanguageResponseModel ,PostLanguage: string ,GetLanguageByIsoCode: LanguageResponseModel @@ -3681,6 +3697,12 @@ export type MediaTypeData = { GetItemMediaType: { id?: Array + }; +GetItemMediaTypeAllowed: { + fileExtension?: string +skip?: number +take?: number + }; GetItemMediaTypeSearch: { query?: string @@ -3773,6 +3795,7 @@ take?: number responses: { GetItemMediaType: Array + ,GetItemMediaTypeAllowed: PagedModelMediaTypeItemResponseModel ,GetItemMediaTypeSearch: PagedModelMediaTypeItemResponseModel ,PostMediaType: string ,GetMediaTypeById: MediaTypeResponseModel @@ -3996,10 +4019,10 @@ take?: number responses: { GetItemMemberGroup: Array ,GetMemberGroup: PagedMemberGroupResponseModel - ,PostMemberGroup: MemberGroupResponseModel + ,PostMemberGroup: string ,GetMemberGroupById: MemberGroupResponseModel ,DeleteMemberGroupById: string - ,PutMemberGroupById: MemberGroupResponseModel + ,PutMemberGroupById: string ,GetTreeMemberGroupRoot: PagedNamedEntityTreeItemResponseModel } @@ -4591,6 +4614,24 @@ PostSecurityForgotPasswordVerify: { } +export type SegmentData = { + + payloads: { + GetSegment: { + skip?: number +take?: number + + }; + } + + + responses: { + GetSegment: PagedSegmentResponseModel + + } + + } + export type ServerData = { @@ -4879,10 +4920,6 @@ export type UserDataData = { PostUserData: { requestBody?: CreateUserDataRequestModel - }; -PutUserData: { - requestBody?: UpdateUserDataRequestModel - }; GetUserData: { groups?: Array @@ -4890,6 +4927,10 @@ identifiers?: Array skip?: number take?: number + }; +PutUserData: { + requestBody?: UpdateUserDataRequestModel + }; GetUserDataById: { id: string @@ -4900,8 +4941,8 @@ GetUserDataById: { responses: { PostUserData: string - ,PutUserData: string ,GetUserData: PagedUserDataResponseModel + ,PutUserData: string ,GetUserDataById: UserDataModel } @@ -5152,7 +5193,11 @@ PostUserUnlock: { export type WebhookData = { payloads: { - GetWebhook: { + GetItemWebhook: { + id?: Array + + }; +GetWebhook: { skip?: number take?: number @@ -5173,21 +5218,17 @@ requestBody?: UpdateWebhookRequestModel DeleteWebhookById: { id: string - }; -GetWebhookItem: { - ids?: Array - }; } responses: { - GetWebhook: PagedWebhookResponseModel + GetItemWebhook: Array + ,GetWebhook: PagedWebhookResponseModel ,PostWebhook: string ,GetWebhookById: WebhookResponseModel ,PutWebhookById: string ,DeleteWebhookById: string - ,GetWebhookItem: Array } diff --git a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/services.ts b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/services.ts index b00b6aad71..64e967c0c7 100644 --- a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/services.ts +++ b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/services.ts @@ -1,7 +1,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { AuditLogData, CultureData, DataTypeData, DictionaryData, DocumentBlueprintData, DocumentTypeData, DocumentVersionData, DocumentData, DynamicRootData, HealthCheckData, HelpData, IndexerData, InstallData, LanguageData, LogViewerData, ManifestData, MediaTypeData, MediaData, MemberGroupData, MemberTypeData, MemberData, ModelsBuilderData, ObjectTypesData, PackageData, PartialViewData, PreviewData, ProfilingData, PropertyTypeData, PublishedCacheData, RedirectManagementData, RelationTypeData, RelationData, ScriptData, SearcherData, SecurityData, ServerData, StaticFileData, StylesheetData, TagData, TelemetryData, TemplateData, TemporaryFileData, UpgradeData, UserDataData, UserGroupData, UserData, WebhookData } from './models'; +import type { AuditLogData, CultureData, DataTypeData, DictionaryData, DocumentBlueprintData, DocumentTypeData, DocumentVersionData, DocumentData, DynamicRootData, HealthCheckData, HelpData, IndexerData, InstallData, LanguageData, LogViewerData, ManifestData, MediaTypeData, MediaData, MemberGroupData, MemberTypeData, MemberData, ModelsBuilderData, ObjectTypesData, PackageData, PartialViewData, PreviewData, ProfilingData, PropertyTypeData, PublishedCacheData, RedirectManagementData, RelationTypeData, RelationData, ScriptData, SearcherData, SecurityData, SegmentData, ServerData, StaticFileData, StylesheetData, TagData, TelemetryData, TemplateData, TemporaryFileData, UpgradeData, UserDataData, UserGroupData, UserData, WebhookData } from './models'; export class AuditLogService { @@ -2220,6 +2220,7 @@ requestBody } /** + * @returns unknown Success * @throws ApiError */ public static getDocumentByIdPublicAccess(data: DocumentData['payloads']['GetDocumentByIdPublicAccess']): CancelablePromise { @@ -3129,6 +3130,22 @@ export class LanguageService { }); } + /** + * @returns unknown Success + * @throws ApiError + */ + public static getItemLanguageDefault(): CancelablePromise { + + return __request(OpenAPI, { + method: 'GET', + url: '/umbraco/management/api/v1/item/language/default', + errors: { + 401: `The resource is protected and requires an authentication token`, + 403: `The authenticated user do not have access to this resource`, + }, + }); + } + /** * @returns unknown Success * @throws ApiError @@ -3546,6 +3563,30 @@ export class MediaTypeService { }); } + /** + * @returns unknown Success + * @throws ApiError + */ + public static getItemMediaTypeAllowed(data: MediaTypeData['payloads']['GetItemMediaTypeAllowed'] = {}): CancelablePromise { + const { + + fileExtension, +skip, +take + } = data; + return __request(OpenAPI, { + method: 'GET', + url: '/umbraco/management/api/v1/item/media-type/allowed', + query: { + fileExtension, skip, take + }, + errors: { + 401: `The resource is protected and requires an authentication token`, + 403: `The authenticated user do not have access to this resource`, + }, + }); + } + /** * @returns unknown Success * @throws ApiError @@ -4679,7 +4720,7 @@ take } /** - * @returns unknown Success + * @returns string Created * @throws ApiError */ public static postMemberGroup(data: MemberGroupData['payloads']['PostMemberGroup'] = {}): CancelablePromise { @@ -4692,6 +4733,7 @@ take url: '/umbraco/management/api/v1/member-group', body: requestBody, mediaType: 'application/json', + responseHeader: 'Umb-Generated-Resource', errors: { 400: `Bad Request`, 401: `The resource is protected and requires an authentication token`, @@ -4749,7 +4791,7 @@ take } /** - * @returns unknown Success + * @returns string Success * @throws ApiError */ public static putMemberGroupById(data: MemberGroupData['payloads']['PutMemberGroupById']): CancelablePromise { @@ -4766,6 +4808,7 @@ requestBody }, body: requestBody, mediaType: 'application/json', + responseHeader: 'Umb-Notifications', errors: { 400: `Bad Request`, 401: `The resource is protected and requires an authentication token`, @@ -6756,6 +6799,34 @@ export class SecurityService { } +export class SegmentService { + + /** + * @returns unknown Success + * @throws ApiError + */ + public static getSegment(data: SegmentData['payloads']['GetSegment'] = {}): CancelablePromise { + const { + + skip, +take + } = data; + return __request(OpenAPI, { + method: 'GET', + url: '/umbraco/management/api/v1/segment', + query: { + skip, take + }, + errors: { + 400: `Bad Request`, + 401: `The resource is protected and requires an authentication token`, + 403: `The authenticated user do not have access to this resource`, + }, + }); + } + +} + export class ServerService { /** @@ -7726,29 +7797,6 @@ export class UserDataService { }); } - /** - * @returns string Success - * @throws ApiError - */ - public static putUserData(data: UserDataData['payloads']['PutUserData'] = {}): CancelablePromise { - const { - - requestBody - } = data; - return __request(OpenAPI, { - method: 'PUT', - url: '/umbraco/management/api/v1/user-data', - body: requestBody, - mediaType: 'application/json', - responseHeader: 'Umb-Notifications', - errors: { - 400: `Bad Request`, - 401: `The resource is protected and requires an authentication token`, - 404: `Not Found`, - }, - }); - } - /** * @returns unknown Success * @throws ApiError @@ -7773,6 +7821,29 @@ take }); } + /** + * @returns string Success + * @throws ApiError + */ + public static putUserData(data: UserDataData['payloads']['PutUserData'] = {}): CancelablePromise { + const { + + requestBody + } = data; + return __request(OpenAPI, { + method: 'PUT', + url: '/umbraco/management/api/v1/user-data', + body: requestBody, + mediaType: 'application/json', + responseHeader: 'Umb-Notifications', + errors: { + 400: `Bad Request`, + 401: `The resource is protected and requires an authentication token`, + 404: `Not Found`, + }, + }); + } + /** * @returns unknown Success * @throws ApiError @@ -8829,6 +8900,28 @@ requestBody export class WebhookService { + /** + * @returns unknown Success + * @throws ApiError + */ + public static getItemWebhook(data: WebhookData['payloads']['GetItemWebhook'] = {}): CancelablePromise { + const { + + id + } = data; + return __request(OpenAPI, { + method: 'GET', + url: '/umbraco/management/api/v1/item/webhook', + query: { + id + }, + errors: { + 401: `The resource is protected and requires an authentication token`, + 403: `The authenticated user do not have access to this resource`, + }, + }); + } + /** * @returns unknown Success * @throws ApiError @@ -8950,26 +9043,4 @@ requestBody }); } - /** - * @returns unknown Success - * @throws ApiError - */ - public static getWebhookItem(data: WebhookData['payloads']['GetWebhookItem'] = {}): CancelablePromise { - const { - - ids - } = data; - return __request(OpenAPI, { - method: 'GET', - url: '/umbraco/management/api/v1/webhook/item', - query: { - ids - }, - errors: { - 401: `The resource is protected and requires an authentication token`, - 403: `The authenticated user do not have access to this resource`, - }, - }); - } - } \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/external/mime-types/index.ts b/src/Umbraco.Web.UI.Client/src/external/mime-types/index.ts new file mode 100644 index 0000000000..38a5df465f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/mime-types/index.ts @@ -0,0 +1 @@ +export * as mime from 'mime-types'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.repository.ts index 0db7b421b7..86733391e0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.repository.ts @@ -4,8 +4,22 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContentTypeStructureRepositoryBase } from '@umbraco-cms/backoffice/content-type'; export class UmbMediaTypeStructureRepository extends UmbContentTypeStructureRepositoryBase { + #dataSource; constructor(host: UmbControllerHost) { super(host, UmbMediaTypeStructureServerDataSource); + this.#dataSource = new UmbMediaTypeStructureServerDataSource(host); + } + + async requestMediaTypesOf({ + fileExtension, + skip = 0, + take = 100, + }: { + fileExtension: string; + skip?: number; + take?: number; + }) { + return this.#dataSource.getMediaTypesOfFileExtension({ fileExtension, skip, take }); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.server.data-source.ts index 22b833b379..acdfad98f0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.server.data-source.ts @@ -17,6 +17,10 @@ export class UmbMediaTypeStructureServerDataSource extends UmbContentTypeStructu constructor(host: UmbControllerHost) { super(host, { getAllowedChildrenOf, mapper }); } + + getMediaTypesOfFileExtension({ fileExtension, skip, take }: { fileExtension: string; skip: number; take: number }) { + return getAllowedMediaTypesOfExtension({ fileExtension, skip, take }); + } } const getAllowedChildrenOf = (unique: string | null) => { @@ -37,3 +41,17 @@ const mapper = (item: AllowedMediaTypeModel): UmbAllowedMediaTypeModel => { icon: item.icon || null, }; }; + +const getAllowedMediaTypesOfExtension = async ({ + fileExtension, + skip, + take, +}: { + fileExtension: string; + skip: number; + take: number; +}) => { + // eslint-disable-next-line local-rules/no-direct-api-import + const { items } = await MediaTypeService.getItemMediaTypeAllowed({ fileExtension, skip, take }); + return items.map((item) => mapper(item)); +}; 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 index b67055a38e..2557bafb9c 100644 --- 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 @@ -1,28 +1,7 @@ -export enum UmbMediaTypeFileType { - SVG = 'Vector Graphics (SVG)', - IMAGE = 'Image', - AUDIO = 'Audio', - VIDEO = 'Video', - ARTICLE = 'Article', - FILE = 'File', -} +import { mime } from '@umbraco-cms/backoffice/external/mime-types'; -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; +export function getExtensionFromMime(mimeType: string): string | undefined { + const extension = mime.extension(mimeType); + if (!extension) return; // extension doesn't exist. + return extension; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/dropzone-manager.class.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/dropzone-manager.class.ts new file mode 100644 index 0000000000..6abcd410ba --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/dropzone-manager.class.ts @@ -0,0 +1,46 @@ +import type { UmbContentTypeUploadableStructureRepositoryBase } from './repository/content-type-uploadable-structure-repository-base.js'; +import { UmbId } from '@umbraco-cms/backoffice/id'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbMediaTypeItemModel } from '@umbraco-cms/backoffice/media-type'; +import { UmbTemporaryFileManager } from '@umbraco-cms/backoffice/temporary-file'; + +export class UmbDropzoneManager extends UmbControllerBase { + #init!: Promise; + + #fileManager = new UmbTemporaryFileManager(this); + #repository: UmbContentTypeUploadableStructureRepositoryBase; + + #parentUnique: string | null = null; + + constructor( + host: UmbControllerHost, + typeRepository: UmbContentTypeUploadableStructureRepositoryBase, + parentUnique: string | null, + ) { + super(host); + this.#repository = typeRepository; + this.#parentUnique = parentUnique; + } + + public async dropOneFile(file: File) { + const matchingMediaTypes = await this.#repository.requestAllowedMediaTypesOf(file.type); + //const options = this.#allowedMediaTypes.filter((allowedMediaType) => matchingMediaTypes.includes(allowedMediaType)); + } + + public async dropFiles(files: Array) {} + + async #requestAllowedMediaTypesOf(fileExtension: string) { + const { data } = await this.#repository.requestAllowedMediaTypesOf(fileExtension); + //const mediaTypes = data?.filter((option) => this.#allowedMediaTypes.includes(option)); + //return { fileExtension, mediaTypes }; + } + + private _reset() { + // + } + + public destroy() { + super.destroy(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/dropzone.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/dropzone.element.ts new file mode 100644 index 0000000000..d4885ccbec --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/dropzone.element.ts @@ -0,0 +1,302 @@ +import { UmbMediaDetailRepository } from '../../repository/index.js'; +import type { UmbMediaDetailModel } from '../../types.js'; +import { css, html, customElement, state, property } from '@umbraco-cms/backoffice/external/lit'; +import type { UUIFileDropzoneElement, UUIFileDropzoneEvent } from '@umbraco-cms/backoffice/external/uui'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { + type UmbAllowedMediaTypeModel, + UmbMediaTypeStructureRepository, + UmbMediaTypeDetailRepository, + getExtensionFromMime, +} from '@umbraco-cms/backoffice/media-type'; +import { + UmbTemporaryFileManager, + type UmbTemporaryFileQueueModel, + type UmbTemporaryFileModel, +} from '@umbraco-cms/backoffice/temporary-file'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; + +interface MediaTypeOptions { + fileExtension: string; + mediaTypes: Array; +} + +interface UploadableFile { + file: File; + mediaType: UmbAllowedMediaTypeModel; + regularUploadField?: boolean; + temporaryUnique?: string; +} + +@customElement('umb-dropzone') +export class UmbDropzoneElement extends UmbLitElement { + #fileManager = new UmbTemporaryFileManager(this); + #mediaTypeStructure = new UmbMediaTypeStructureRepository(this); + #mediaDetailRepository = new UmbMediaDetailRepository(this); + #mediaTypeDetailRepository = new UmbMediaTypeDetailRepository(this); + + #allowedMediaTypes: Array = []; + + @state() + private queue: Array = []; + + @property({ attribute: false }) + parentUnique: string | null = null; + + public browse() { + const element = this.shadowRoot?.querySelector('#dropzone') as UUIFileDropzoneElement; + return element.browse(); + } + + async #getAllowedMediaTypes(): Promise { + const { data } = await this.#mediaTypeStructure.requestAllowedChildrenOf(this.parentUnique); + return data?.items ?? []; + } + + async #getAllowedMediaTypesOf(fileExtension: string): Promise { + const options = await this.#mediaTypeStructure.requestMediaTypesOf({ fileExtension }); + const mediaTypes = options.filter((option) => this.#allowedMediaTypes.includes(option)); + return { fileExtension, mediaTypes }; + } + + constructor() { + super(); + 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 #onDropFiles(event: UUIFileDropzoneEvent) { + // TODO Handle of folder uploads. + + const files: Array = event.detail.files; + if (!files.length) return; + + this.#allowedMediaTypes = await this.#getAllowedMediaTypes(); + if (!this.#allowedMediaTypes.length) return; + // If we have files that are not allowed to be uploaded, we show those in a dialog to the user? + + if (files.length === 1) { + this.#handleOneFile(files[0]); + } else { + this.#handleMultipleFiles(files); + } + } + + async #handleOneFile(file: File) { + const extension = getExtensionFromMime(file.type); + if (!extension) return; // Extension doesn't exist. + + const options = await this.#getAllowedMediaTypesOf(extension); + if (!options.mediaTypes.length) return; // File type not allowed in current dropzone. + + if (options.mediaTypes.length === 1) { + this.#uploadFiles([{ file, mediaType: options.mediaTypes[0] }]); + return; + } + + // Multiple options, show a dialog to the user to pick one. + //TODO: Implement dialog. + } + + async #handleMultipleFiles(files: Array) { + // removes duplicate file types so we don't call the endpoint unnecessarily for every file. + const types = [...new Set(files.map((file) => file.type))]; + const options: Array = []; + + for (const type of types) { + const extension = getExtensionFromMime(type); + if (!extension) return; // Extension doesn't exist. + + options.push(await this.#getAllowedMediaTypesOf(extension)); + } + + // We are just going to automatically pick the first possible media type option for now, but consider an option dialog in the future. + const uploadable: Array = []; + files.forEach((file) => { + const mediaType = options.find((option) => option.fileExtension === file.type)?.mediaTypes[0] ?? undefined; + if (mediaType) uploadable.push({ file, mediaType }); + }); + + this.#uploadFiles(uploadable); + } + + async #uploadFiles(uploadable: Array) { + const queue = uploadable.map( + (item): UmbTemporaryFileQueueModel => ({ file: item.file, unique: item.temporaryUnique }), + ); + + const uploaded = await this.#fileManager.upload(queue); + + for (const upload of uploaded) { + const mediaType = uploadable.find((item) => item.temporaryUnique === upload.unique)?.mediaType; + const value = mediaType?.unique === UmbMediaTypeFileType.IMAGE ? { src: upload.unique } : upload.unique; + + if (!mediaType) return; + + const preset: Partial = { + mediaType: { + unique: mediaType.unique, + collection: null, + }, + variants: [ + { + culture: null, + segment: null, + name: upload.file.name, + createDate: null, + updateDate: null, + }, + ], + values: upload.file.type + ? [ + { + alias: 'umbracoFile', + value, + culture: null, + segment: null, + }, + ] + : [], + }; + + const { data } = await this.#mediaDetailRepository.createScaffold(preset); + if (!data) return; + + await this.#mediaDetailRepository.create(data, this.parentUnique); + + this.dispatchEvent(new UmbChangeEvent()); + } + } + + async #uploadHandler(files: Array) { + //TODO: Folders uploaded via UUIDropzone are always empty. Investigate why. + + const folders = files.filter((item) => !item.type).map((file): UmbTemporaryFileQueueModel => ({ file })); + const mediaItems = files.filter((item) => item.type); + + const queue = mediaItems.map((file): UmbTemporaryFileQueueModel => ({ file })); + + const uploaded = await this.#fileManager.upload(queue); + return [...folders, ...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); */ '' as any; + const value = mediaType.unique === UmbMediaTypeFileType.IMAGE ? { src: upload.unique } : upload.unique; + + const preset: Partial = { + mediaType: { + unique: mediaType.unique, + collection: null, + }, + variants: [ + { + culture: null, + segment: null, + name: upload.file.name, + createDate: null, + updateDate: null, + }, + ], + values: upload.file.type + ? [ + { + alias: 'umbracoFile', + value, + culture: null, + segment: null, + }, + ] + : [], + }; + + const { data } = await this.#mediaDetailRepository.createScaffold(preset); + if (!data) return; + + await this.#mediaDetailRepository.create(data, this.parentUnique); + + 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 UmbDropzoneElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-dropzone': UmbDropzoneElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/index.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/index.ts new file mode 100644 index 0000000000..38624dcb34 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/index.ts @@ -0,0 +1,2 @@ +export * from './dropzone.element.js'; +export * from './dropzone-manager.class.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/repository/content-type-uploadable-structure-data-source.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/repository/content-type-uploadable-structure-data-source.interface.ts new file mode 100644 index 0000000000..35422dfc25 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/repository/content-type-uploadable-structure-data-source.interface.ts @@ -0,0 +1,16 @@ +import type { + UmbContentTypeStructureDataSource, + UmbContentTypeStructureDataSourceConstructor, +} from '@umbraco-cms/backoffice/content-type'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository'; + +export interface UmbContentTypeUploadableStructureDataSourceConstructor + extends UmbContentTypeStructureDataSourceConstructor { + new (host: UmbControllerHost): UmbContentTypeUploadableStructureDataSource; +} + +export interface UmbContentTypeUploadableStructureDataSource + extends UmbContentTypeStructureDataSource { + getAllowedMediaTypesOf(fileExtension: string | null): Promise>; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/repository/content-type-uploadable-structure-repository-base.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/repository/content-type-uploadable-structure-repository-base.ts new file mode 100644 index 0000000000..60805f94cf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/repository/content-type-uploadable-structure-repository-base.ts @@ -0,0 +1,32 @@ +import type { + UmbContentTypeUploadableStructureDataSource, + UmbContentTypeUploadableStructureDataSourceConstructor, +} from './content-type-uploadable-structure-data-source.interface.js'; +import type { UmbContentTypeUploadableStructureRepository } from './content-type-uploadable-structure-repository.interface.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbContentTypeStructureRepositoryBase } from '@umbraco-cms/backoffice/content-type'; + +export abstract class UmbContentTypeUploadableStructureRepositoryBase + extends UmbContentTypeStructureRepositoryBase + implements UmbContentTypeUploadableStructureRepository +{ + #structureSource: UmbContentTypeUploadableStructureDataSource; + + constructor( + host: UmbControllerHost, + structureSource: UmbContentTypeUploadableStructureDataSourceConstructor, + ) { + super(host, structureSource); + this.#structureSource = new structureSource(host); + } + + /** + * Returns a promise with the allowed media-types of a uploadable content type. + * @param {string} unique + * @return {*} + * @memberof UmbContentTypeUploadableStructureRepositoryBase + */ + requestAllowedMediaTypesOf(fileExtension: string | null) { + return this.#structureSource.getAllowedMediaTypesOf(fileExtension); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/repository/content-type-uploadable-structure-repository.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/repository/content-type-uploadable-structure-repository.interface.ts new file mode 100644 index 0000000000..b25083e1ae --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/repository/content-type-uploadable-structure-repository.interface.ts @@ -0,0 +1,7 @@ +import type { UmbContentTypeStructureRepository } from '@umbraco-cms/backoffice/content-type'; +import type { UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository'; + +export interface UmbContentTypeUploadableStructureRepository + extends UmbContentTypeStructureRepository { + requestAllowedMediaTypesOf(unique: string): Promise>; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/repository/content-type-uploadable-structure-server-data-source-base.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/repository/content-type-uploadable-structure-server-data-source-base.ts new file mode 100644 index 0000000000..31e110ee31 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/dropzone/repository/content-type-uploadable-structure-server-data-source-base.ts @@ -0,0 +1,66 @@ +import { + type UmbContentTypeStructureDataSource, + UmbContentTypeStructureServerDataSourceBase, + type UmbContentTypeStructureServerDataSourceBaseArgs, +} from '@umbraco-cms/backoffice/content-type'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources'; + +// Temp type +type AllowedContentTypeModel = { + id: string; + name: string; + description?: string | null; + icon?: string | null; +}; + +export interface UmbContentTypeUploadableStructureServerDataSourceBaseArgs< + ServerItemType extends AllowedContentTypeModel, + ClientItemType extends { unique: string }, +> extends UmbContentTypeStructureServerDataSourceBaseArgs { + getAllowedMediaTypesOf: (fileExtension: string | null) => Promise; +} + +export abstract class UmbContentTypeUploadableStructureServerDataSourceBase< + ServerItemType extends AllowedContentTypeModel, + ClientItemType extends { unique: string }, + > + extends UmbContentTypeStructureServerDataSourceBase + implements UmbContentTypeStructureDataSource +{ + #host; + #getAllowedChildrenOf; + #mapper; + + /** + * Creates an instance of UmbContentTypeStructureServerDataSourceBase. + * @param {UmbControllerHost} host + * @memberof UmbItemServerDataSourceBase + */ + constructor( + host: UmbControllerHost, + args: UmbContentTypeUploadableStructureServerDataSourceBaseArgs, + ) { + super(host, args); + this.#host = host; + this.#getAllowedChildrenOf = args.getAllowedChildrenOf; + this.#mapper = args.mapper; + } + + /** + * Returns a promise with the allowed content types for the given unique + * @param {string} unique + * @return {*} + * @memberof UmbContentTypeStructureServerDataSourceBase + */ + async getAllowedMediaTypesOf(fileExtension: string | null) { + const { data, error } = await tryExecuteAndNotify(this.#host, this.#getAllowedChildrenOf(fileExtension)); + + if (data) { + const items = data.items.map((item) => this.#mapper(item)); + return { data: { items, total: data.total } }; + } + + return { error }; + } +} diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json index af06940c67..03f8acb7a5 100644 --- a/src/Umbraco.Web.UI.Client/tsconfig.json +++ b/src/Umbraco.Web.UI.Client/tsconfig.json @@ -114,6 +114,7 @@ "@umbraco-cms/backoffice/external/dompurify": ["./src/external/dompurify/index.ts"], "@umbraco-cms/backoffice/external/lit": ["./src/external/lit/index.ts"], "@umbraco-cms/backoffice/external/marked": ["./src/external/marked/index.ts"], + "@umbraco-cms/backoffice/external/mime-types": ["./src/external/mime-types/index.ts"], "@umbraco-cms/backoffice/external/monaco-editor": ["./src/external/monaco-editor/index.ts"], "@umbraco-cms/backoffice/external/openid": ["./src/external/openid/index.ts"], "@umbraco-cms/backoffice/external/router-slot": ["./src/external/router-slot/index.ts"],