diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 76b1260c6d..a850a8f371 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -326,6 +326,7 @@ namespace Umbraco.Cms.Core.DependencyInjection Services.AddUnique(provider => new CultureImpactFactory(provider.GetRequiredService>())); Services.AddUnique(); + Services.AddUnique(); } } } diff --git a/src/Umbraco.Core/Services/ITemporaryMediaService.cs b/src/Umbraco.Core/Services/ITemporaryMediaService.cs new file mode 100644 index 0000000000..9c3c07acaf --- /dev/null +++ b/src/Umbraco.Core/Services/ITemporaryMediaService.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +public interface ITemporaryMediaService +{ + public IMedia Save(string temporaryLocation, Guid? startNode, string? mediaTypeAlias); +} diff --git a/src/Umbraco.Core/Services/TemporaryMediaService.cs b/src/Umbraco.Core/Services/TemporaryMediaService.cs new file mode 100644 index 0000000000..44aa555804 --- /dev/null +++ b/src/Umbraco.Core/Services/TemporaryMediaService.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Extensions; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services; + +public class TemporaryMediaService : ITemporaryMediaService +{ + private readonly IShortStringHelper _shortStringHelper; + private readonly MediaFileManager _mediaFileManager; + private readonly IMediaService _mediaService; + private readonly MediaUrlGeneratorCollection _mediaUrlGenerators; + private readonly IContentTypeBaseServiceProvider _contentTypeBaseServiceProvider; + private readonly IHostEnvironment _hostingEnvironment; + private readonly ILogger _logger; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public TemporaryMediaService( + IShortStringHelper shortStringHelper, + MediaFileManager mediaFileManager, + IMediaService mediaService, + MediaUrlGeneratorCollection mediaUrlGenerators, + IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, + IHostEnvironment hostingEnvironment, + ILogger logger, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _shortStringHelper = shortStringHelper; + _mediaFileManager = mediaFileManager; + _mediaService = mediaService; + _mediaUrlGenerators = mediaUrlGenerators; + _contentTypeBaseServiceProvider = contentTypeBaseServiceProvider; + _hostingEnvironment = hostingEnvironment; + _logger = logger; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + public IMedia Save(string temporaryLocation, Guid? startNode, string? mediaTypeAlias) + { + var userId = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? Constants.Security.SuperUserId; + var absoluteTempImagePath = _hostingEnvironment.MapPathContentRoot(temporaryLocation); + var fileName = Path.GetFileName(absoluteTempImagePath); + var safeFileName = fileName.ToSafeFileName(_shortStringHelper); + + var mediaItemName = safeFileName.ToFriendlyName(); + + IMedia mediaFile; + if (startNode is null) + { + mediaFile = _mediaService.CreateMedia(mediaItemName, Constants.System.Root, mediaTypeAlias ?? Constants.Conventions.MediaTypes.File, userId); + } + else + { + mediaFile = _mediaService.CreateMedia(mediaItemName, startNode.Value, mediaTypeAlias ?? Constants.Conventions.MediaTypes.File, userId); + } + + var fileInfo = new FileInfo(absoluteTempImagePath); + + FileStream? fileStream = fileInfo.OpenReadWithRetry(); + if (fileStream is null) + { + throw new InvalidOperationException("Could not acquire file stream"); + } + + using (fileStream) + { + mediaFile.SetValue(_mediaFileManager, _mediaUrlGenerators, _shortStringHelper, _contentTypeBaseServiceProvider, Constants.Conventions.Media.File, safeFileName, fileStream); + } + + _mediaService.Save(mediaFile, userId); + + // Delete temp file now that we have persisted it + var folderName = Path.GetDirectoryName(absoluteTempImagePath); + try + { + if (folderName is not null) + { + Directory.Delete(folderName, true); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not delete temp file or folder {FileName}", absoluteTempImagePath); + } + + return mediaFile; + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs index ed774f9215..09eb6a1f47 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs @@ -1,7 +1,9 @@ using System.Runtime.Serialization; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; @@ -37,7 +39,8 @@ public class MediaPicker3PropertyEditor : DataEditor IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper, EditorType type = EditorType.PropertyValue) - : this(dataValueEditorFactory, ioHelper, StaticServiceProvider.Instance.GetRequiredService(), type) + : this(dataValueEditorFactory, ioHelper, + StaticServiceProvider.Instance.GetRequiredService(), type) { } @@ -68,6 +71,8 @@ public class MediaPicker3PropertyEditor : DataEditor { private readonly IDataTypeService _dataTypeService; private readonly IJsonSerializer _jsonSerializer; + private readonly ITemporaryMediaService _temporaryMediaService; + public MediaPicker3PropertyValueEditor( ILocalizedTextService localizedTextService, @@ -75,11 +80,13 @@ public class MediaPicker3PropertyEditor : DataEditor IJsonSerializer jsonSerializer, IIOHelper ioHelper, DataEditorAttribute attribute, - IDataTypeService dataTypeService) + IDataTypeService dataTypeService, + ITemporaryMediaService temporaryMediaService) : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) { _jsonSerializer = jsonSerializer; _dataTypeService = dataTypeService; + _temporaryMediaService = temporaryMediaService; } /// @@ -118,6 +125,11 @@ public class MediaPicker3PropertyEditor : DataEditor { if (editorValue.Value is JArray dtos) { + if (editorValue.DataTypeConfiguration is MediaPicker3Configuration configuration) + { + dtos = PersistTempMedia(dtos, configuration); + } + // Clean up redundant/default data foreach (JObject? dto in dtos.Values()) { @@ -150,7 +162,7 @@ public class MediaPicker3PropertyEditor : DataEditor Key = Guid.NewGuid(), MediaKey = guidUdi.Guid, Crops = Enumerable.Empty(), - FocalPoint = new ImageCropperValue.ImageCropperFocalPoint { Left = 0.5m, Top = 0.5m }, + FocalPoint = new ImageCropperValue.ImageCropperFocalPoint {Left = 0.5m, Top = 0.5m}, }; } } @@ -170,6 +182,49 @@ public class MediaPicker3PropertyEditor : DataEditor } } + private JArray PersistTempMedia(JArray jArray, MediaPicker3Configuration mediaPicker3Configuration) + { + var result = new JArray(); + foreach (JObject? dto in jArray.Values()) + { + if (dto is null) + { + continue; + } + + if (!dto.TryGetValue("tmpLocation", out JToken? temporaryLocation)) + { + // If it does not have a temporary path, it can be an already saved image or not-yet uploaded temp-image, check for media-key + if (dto.TryGetValue("mediaKey", out _)) + { + result.Add(dto); + } + + continue; + } + + var temporaryLocationString = temporaryLocation.Value(); + if (temporaryLocationString is null) + { + continue; + } + + GuidUdi? startNodeGuid = mediaPicker3Configuration.StartNodeId as GuidUdi ?? null; + JToken? mediaTypeAlias = dto.GetValue("mediaTypeAlias"); + IMedia mediaFile = _temporaryMediaService.Save(temporaryLocationString, startNodeGuid?.Guid, mediaTypeAlias?.Value()); + MediaWithCropsDto? mediaDto = _jsonSerializer.Deserialize(dto.ToString()); + if (mediaDto is null) + { + continue; + } + + mediaDto.MediaKey = mediaFile.GetUdi().Guid; + result.Add(JObject.Parse(_jsonSerializer.Serialize(mediaDto))); + } + + return result; + } + /// /// Model/DTO that represents the JSON that the MediaPicker3 stores. /// diff --git a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs index 9243eb49a5..e0cd9e0459 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs @@ -529,6 +529,10 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers "propertyTypeApiBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl( controller => controller.HasValues(string.Empty)) }, + { + "mediaPickerThreeBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl( + controller => controller.UploadMedia(null!)) + }, } }, { diff --git a/src/Umbraco.Web.BackOffice/Controllers/MediaPickerThreeController.cs b/src/Umbraco.Web.BackOffice/Controllers/MediaPickerThreeController.cs new file mode 100644 index 0000000000..57b27e1df2 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Controllers/MediaPickerThreeController.cs @@ -0,0 +1,113 @@ +using System.Net; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Dictionary; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Media; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Web.Common.ActionsResults; +using Umbraco.Cms.Web.Common.Attributes; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.SectionAccessMedia)] +public class MediaPickerThreeController : ContentControllerBase +{ + private readonly IHostingEnvironment _hostingEnvironment; + private readonly ContentSettings _contentSettings; + private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly IIOHelper _ioHelper; + + public MediaPickerThreeController( + ICultureDictionary cultureDictionary, + ILoggerFactory loggerFactory, + IShortStringHelper shortStringHelper, + IEventMessagesFactory eventMessages, + ILocalizedTextService localizedTextService, + IJsonSerializer serializer, + IHostingEnvironment hostingEnvironment, + IOptionsSnapshot contentSettings, + IImageUrlGenerator imageUrlGenerator, + IIOHelper ioHelper) + : base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, serializer) + { + _hostingEnvironment = hostingEnvironment; + _contentSettings = contentSettings.Value; + _imageUrlGenerator = imageUrlGenerator; + _ioHelper = ioHelper; + } + + [HttpPost] + public async Task UploadMedia(List file) + { + // Create an unique folder path to help with concurrent users to avoid filename clash + var imageTempPath = + _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads + "/" + Guid.NewGuid()); + + // Ensure image temp path exists + if (Directory.Exists(imageTempPath) == false) + { + Directory.CreateDirectory(imageTempPath); + } + + // Must have a file + if (file.Count == 0) + { + return NotFound(); + } + + // Should only have one file + if (file.Count > 1) + { + return new UmbracoProblemResult("Only one file can be uploaded at a time", HttpStatusCode.BadRequest); + } + + // Really we should only have one file per request to this endpoint + IFormFile formFile = file.First(); + + var fileName = formFile.FileName.Trim(new[] { '\"' }).TrimEnd(); + var safeFileName = fileName.ToSafeFileName(ShortStringHelper); + var ext = safeFileName.Substring(safeFileName.LastIndexOf('.') + 1).ToLowerInvariant(); + + if (_contentSettings.IsFileAllowedForUpload(ext) == false) + { + // Throw some error - to say can't upload this IMG type + return new UmbracoProblemResult("This is not an image filetype extension that is approved", HttpStatusCode.BadRequest); + } + + var newFilePath = imageTempPath + Path.DirectorySeparatorChar + safeFileName; + var relativeNewFilePath = GetRelativePath(newFilePath); + + await using (FileStream stream = System.IO.File.Create(newFilePath)) + { + await formFile.CopyToAsync(stream); + } + + return Ok(new { tmpLocation = relativeNewFilePath }); + } + + // Use private method istead of _ioHelper.GetRelativePath as that is relative for the webroot and not the content root. + private string GetRelativePath(string path) + { + if (path.IsFullPath()) + { + var rootDirectory = _hostingEnvironment.MapPathContentRoot("~"); + var relativePath = _ioHelper.PathStartsWith(path, rootDirectory) ? path[rootDirectory.Length..] : path; + path = relativePath; + } + + return PathUtility.EnsurePathIsApplicationRootPrefixed(path); + } +} diff --git a/src/Umbraco.Web.BackOffice/Controllers/MediaTypeController.cs b/src/Umbraco.Web.BackOffice/Controllers/MediaTypeController.cs index 9582a6f032..09b24b11e4 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MediaTypeController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MediaTypeController.cs @@ -132,6 +132,35 @@ public class MediaTypeController : ContentTypeControllerBase return dto; } + /// + /// Returns a media type by alias + /// + /// /// Alias of the media type + /// + public IEnumerable GetAllFiltered([FromQuery] string[] aliases) + { + if (aliases.Length < 1) + { + return _mediaTypeService.GetAll().Select(_umbracoMapper.Map).WhereNotNull(); + } + + var mediaTypeDisplays = new List(); + + foreach (var alias in aliases) + { + IMediaType? mediaType = _mediaTypeService.Get(alias); + + MediaTypeDisplay? mediaTypeDisplay = _umbracoMapper.Map(mediaType); + + if (mediaTypeDisplay is not null) + { + mediaTypeDisplays.Add(mediaTypeDisplay); + } + } + + return mediaTypeDisplays; + } + /// /// Deletes a media type with a given ID /// diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js index 301e542ec3..989c051e03 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js @@ -129,7 +129,8 @@ Use this directive to render a button with a dropdown of alternative actions. size: "@?", icon: "@?", label: "@?", - labelKey: "@?" + labelKey: "@?", + disabled: " x.entityKey === $scope.content.key).length > 0; + })); + evts.push(eventsService.on("rte.file.uploading", function () { $scope.page.saveButtonState = "busy"; $scope.page.buttonGroupState = "busy"; diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/mediatype.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/mediatype.resource.js index 79f35d0d74..f7bba87ad5 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/mediatype.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/mediatype.resource.js @@ -121,6 +121,22 @@ function mediaTypeResource($q, $http, umbRequestHelper, umbDataFormatter, locali 'Failed to retrieve all content types'); }, + getAllFiltered: function (aliases) { + var aliasesQuery = ""; + + if (aliases && aliases.length > 0) { + aliases.forEach(alias => aliasesQuery += `aliases=${alias}&`); + } + + return umbRequestHelper.resourcePromise( + $http.get( + umbRequestHelper.getApiUrl( + "mediaTypeApiBaseUrl", + "getAllFiltered", + aliasesQuery)), + 'Failed to retrieve media types'); + }, + getScaffold: function (parentId) { return umbRequestHelper.resourcePromise( diff --git a/src/Umbraco.Web.UI.Client/src/common/services/uploadtracker.service.js b/src/Umbraco.Web.UI.Client/src/common/services/uploadtracker.service.js new file mode 100644 index 0000000000..46d5543afa --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/services/uploadtracker.service.js @@ -0,0 +1,54 @@ +/** +* @ngdoc service +* @name umbraco.services.uploadTracker +* @description a helper to keep track of uploads in progress +**/ +function uploadTracker(eventsService) { + + const uploadsInProgress = []; + const events = {}; + + /** + * @ngdoc function + * @name umbraco.services.uploadTracker#uploadStarted + * @methodOf umbraco.services.uploadTracker + * @function + * + * @description + * Called when an upload is started to inform listeners that an upload is in progress. This will raise the uploadTracker.uploadsInProgressChanged event. + * + * @param {string} entityKey The key of the entity where the upload is taking place + */ + function uploadStarted (entityKey) { + const uploadDetails = { + entityKey + }; + + uploadsInProgress.push(uploadDetails); + eventsService.emit('uploadTracker.uploadsInProgressChanged', { uploadsInProgress }); + } + + /** + * @ngdoc function + * @name umbraco.services.uploadTracker#uploadEnded + * @methodOf umbraco.services.uploadTracker + * @function + * + * @description + * Called when an upload is ended to inform listeners that an upload has stopped. This will raise the uploadTracker.uploadsInProgressChanged event. + * + * @param {string} entityKey The key of the entity where the upload has stopped. + */ + function uploadEnded (entityKey) { + const index = uploadsInProgress.findIndex(upload => upload.entityKey === entityKey); + uploadsInProgress.splice(index, 1); + eventsService.emit('uploadTracker.uploadsInProgressChanged', { uploadsInProgress }); + } + + return { + uploadStarted, + uploadEnded + }; +} + +angular.module('umbraco.services').factory('uploadTracker', uploadTracker); \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/utilities.js b/src/Umbraco.Web.UI.Client/src/utilities.js index 14e37ecb87..9ed93998b2 100644 --- a/src/Umbraco.Web.UI.Client/src/utilities.js +++ b/src/Umbraco.Web.UI.Client/src/utilities.js @@ -116,6 +116,254 @@ return obj; }; + const MediaUploader = function (Upload, mediaHelper, mediaTypeHelper, localizationService, overlayService, mediaTypeResource) { + const umbracoSettings = Umbraco.Sys.ServerVariables.umbracoSettings; + const allowedUploadFiles = mediaHelper.formatFileTypes(umbracoSettings.allowedUploadFiles); + const allowedImageFileTypes = mediaHelper.formatFileTypes(umbracoSettings.imageFileTypes); + const allowedFileTypes = `${allowedUploadFiles},${allowedImageFileTypes}`; + const disallowedFileTypes = mediaHelper.formatFileTypes(umbracoSettings.disallowedUploadFiles); + const maxFileSize = umbracoSettings.maxFileSize !== '' ? `${umbracoSettings.maxFileSize} KB` : ''; + + const events = {}; + const translations = {}; + + let initialized = false; + let uploadURL = ''; + let allowedMediaTypes = []; + let queue = []; + let invalidEntries = []; + + function init (options) { + uploadURL = options.uploadURL; + + return new Promise((resolve, reject) => { + const promises = [ + mediaTypeResource.getAllFiltered(options.allowedMediaTypeAliases), + localizationService.localizeMany(["media_disallowedFileType", "media_maxFileSize", "defaultdialogs_selectMediaType"]) + ]; + + Promise.all(promises).then(values => { + const mediaTypes = values[0]; + const translationValues = values[1]; + + allowedMediaTypes = mediaTypes; + translations.disallowedFileType = translationValues[0]; + translations.maxFileSize = translationValues[1] + " " + maxFileSize; + translations.selectMediaTypeDialogTitle = translationValues[2]; + initialized = true; + resolve(); + }, (reason) => { + reject(reason); + }); + }); + } + + function requestUpload (files) { + if (!initialized) { + throw 'MediaUploader is not initialized'; + } + + const validBatch = []; + const uploadItems = createUploadItemsFromFiles(files); + + // Validate based on server allowed file types + uploadItems.forEach(item => { + const isAllowedFileType = Upload.validatePattern(item.file, allowedFileTypes); + const isDisallowedFileType = Upload.validatePattern(item.file, disallowedFileTypes); + const underMaxFileSize = maxFileSize ? validateMaxFileSize(item.file, maxFileSize) : true; + + if (isAllowedFileType && !isDisallowedFileType && underMaxFileSize) { + _acceptMediaEntry(item.mediaEntry); + validBatch.push(item); + return; + } + + if (!isAllowedFileType || isDisallowedFileType) { + _rejectMediaEntry(item.mediaEntry, { type: 'pattern', message: translations.disallowedFileType }); + return; + } + + if (!underMaxFileSize) { + _rejectMediaEntry(item.mediaEntry, { type: 'maxSize', message: translations.maxFileSize }); + return; + } + }); + + _addItemsToQueue(validBatch); + _emit('queueStarted'); + _processQueue(); + } + + function _acceptMediaEntry (mediaEntry) { + _emit('mediaEntryAccepted', { mediaEntry }); + } + + function _rejectMediaEntry (mediaEntry, reason) { + mediaEntry.error = true; + mediaEntry.errorType = {}; + mediaEntry.errorType[reason.type] = true; + mediaEntry.errorText = reason.message; + + invalidEntries.push(mediaEntry); + _emit('mediaEntryRejected', { mediaEntry }); + } + + function createUploadItemsFromFiles (files) { + // angular complains about "Illegal invocation" if the file is part of the model.value + // so we have to keep them separate + return files.map(file => { + const mediaEntry = { + key: String.CreateGuid(), + name: file.name, + $uploadProgress: 0, + $dataURL: '' + }; + + if (file.type.includes('image')) { + Upload.base64DataUrl(file).then(function(url) { + mediaEntry.$dataURL = url; + }); + } else { + mediaEntry.$extension = mediaHelper.getFileExtension(file.name); + } + + return { + mediaEntry, + file + }; + }); + }; + + function validateMaxFileSize (file, val) { + return file.size - 0.1 <= Upload.translateScalars(val); + } + + function _upload(queueItem) { + const mediaEntry = queueItem.mediaEntry; + + _emit('uploadStarted', { mediaEntry }); + Upload.upload({ + url: uploadURL, + file: queueItem.file + }) + .progress(function(evt) { + var progressPercentage = parseInt(100.0 * evt.loaded / evt.total, 10); + mediaEntry.$uploadProgress = progressPercentage; + }) + .success(function (data) { + _emit('uploadSuccess', { mediaEntry, ...data }); + _processQueue(); + }) + .error(function(error) { + _emit('uploadError', { mediaEntry }); + _rejectMediaEntry(mediaEntry, { type: 'server', message: error.Message }); + _processQueue(); + }); + } + + function _addItemsToQueue (queueItems) { + queue = [...queue, ...queueItems]; + } + + function _processQueue () { + const nextItem = queue.shift(); + + // queue is empty + if (!nextItem) { + _emit('queueCompleted'); + return; + } + + _getMatchedMediaType(nextItem.file).then(mediaType => { + nextItem.mediaEntry.mediaTypeAlias = mediaType.alias; + nextItem.mediaEntry.$icon = mediaType.icon; + _upload(nextItem); + }, () => { + _rejectMediaEntry(nextItem.mediaEntry, { type: 'pattern', message: translations.disallowedFileType }); + _processQueue(); + }); + } + + function _getMatchedMediaType(file) { + return new Promise((resolve, reject) => { + const uploadFileExtension = mediaHelper.getFileExtension(file.name); + const matchedMediaTypes = mediaTypeHelper.getTypeAcceptingFileExtensions(allowedMediaTypes, [uploadFileExtension]); + + if (matchedMediaTypes.length === 0) { + reject(); + return; + }; + + if (matchedMediaTypes.length === 1) { + resolve(matchedMediaTypes[0]); + return; + }; + + // when we get all media types, the "File" media type will always show up because it accepts all file extensions. + // If we don't remove it from the list we will always show the picker. + const matchedMediaTypesNoFile = matchedMediaTypes.filter(mediaType => mediaType.alias !== "File"); + if (matchedMediaTypesNoFile.length === 1) { + resolve(matchedMediaTypesNoFile[0]); + return; + }; + + if (matchedMediaTypes.length > 1) { + _chooseMediaTypeDialog(matchedMediaTypes, file) + .then((selectedMediaType) => { + resolve(selectedMediaType); + }, () => { + reject(); + }); + }; + }); + } + + function _chooseMediaTypeDialog(mediaTypes, file) { + return new Promise((resolve, reject) => { + const dialog = { + view: "itempicker", + filter: mediaTypes.length > 8, + availableItems: mediaTypes, + submit: function (model) { + resolve(model.selectedItem); + overlayService.close(); + }, + close: function () { + reject(); + overlayService.close(); + } + }; + + dialog.title = translations.selectMediaTypeDialogTitle; + dialog.subtitle = file.name; + overlayService.open(dialog); + }); + } + + function _emit(name, data) { + if (!events[name]) return; + events[name].forEach(callback => callback({name}, data)); + } + + function on(name, callback) { + if (typeof callback !== 'function') return; + + const unsubscribe = function () { + events[name] = events[name].filter(cb => cb !== callback); + } + + events[name] = events[name] || []; + events[name].push(callback); + return unsubscribe; + } + + return { + init, + requestUpload, + on + } + } + let _utilities = { noop: noop, copy: copy, @@ -130,7 +378,8 @@ isObject: isObject, fromJson: fromJson, toJson: toJson, - forEach: forEach + forEach: forEach, + MediaUploader: MediaUploader }; if (typeof (window.Utilities) === 'undefined') { diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js index 732127ffa0..e777e91e29 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js @@ -44,6 +44,20 @@ angular.module("umbraco") function updateMedia() { + if (!vm.mediaEntry.mediaKey) { + vm.imageSrc = vm.mediaEntry.$dataURL; + vm.fileSrc = vm.mediaEntry.$dataURL; + vm.loading = false; + vm.hasDimensions = false; + vm.isCroppable = false; + vm.fileExtension = 'JPG'; + + localizationService.localize("mediaPicker_editMediaEntryLabel", [vm.mediaEntry.name, vm.model.documentName]).then(data => { + vm.title = data; + }); + return; + } + vm.loading = true; entityResource.getById(vm.mediaEntry.mediaKey, "Media").then(function (mediaEntity) { diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.html index a56e3aeed6..45a463f8bf 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.html @@ -93,13 +93,13 @@
- - diff --git a/src/Umbraco.Web.UI.Client/src/views/components/buttons/umb-button-group.html b/src/Umbraco.Web.UI.Client/src/views/components/buttons/umb-button-group.html index bb310441bf..2a4a43769d 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/buttons/umb-button-group.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/buttons/umb-button-group.html @@ -1,5 +1,4 @@
- + add-ellipsis={{defaultButton.addEllipsis}} + disabled="disabled"> diff --git a/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html b/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html index ab7274df6f..0c5ad35b04 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html @@ -39,7 +39,8 @@ button-style="link" label-key="general_close" shortcut="esc" - type="button"> + type="button" + disabled="page.uploadsInProgress"> + label-key="buttons_saveAndPreview" + disabled="page.uploadsInProgress"> + add-ellipsis="{{page.saveButtonEllipsis}}" + disabled="page.uploadsInProgress"> + label="More publishing options" + disabled="page.uploadsInProgress"> + type="button" + disabled="page.uploadsInProgress"> + type="button" + disabled="page.uploadsInProgress"> diff --git a/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umb-media-card.less b/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umb-media-card.less index 3d06c8b16f..c0b6cd9e79 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umb-media-card.less +++ b/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umb-media-card.less @@ -3,9 +3,7 @@ umb-media-card { position: relative; display: inline-block; width: 100%; - //background-color: white; border-radius: @baseBorderRadius; - //box-shadow: 0 1px 2px rgba(0,0,0,.2); overflow: hidden; transition: box-shadow 120ms; cursor: pointer; @@ -194,3 +192,32 @@ umb-media-card { } } } + + +.umb-media-card { + .__upload-progress-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + backdrop-filter: blur(2px); + background-color: rgba(255,255,255,.7); + display: flex; + align-items: center; + justify-content: center; + + .__upload-progress { + width: 66%; + margin-top: 13px; + } + + .__upload-progress-label { + font-size: 13px; + margin-top: 3px; + } + } +} + diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/mediapicker3.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/mediapicker3.html index c65172ab37..36e3e246b2 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/mediapicker3.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/mediapicker3.html @@ -1,4 +1,5 @@ diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umb-media-picker3-property-editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umb-media-picker3-property-editor.html index 4fe0b24320..35f480aa0e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umb-media-picker3-property-editor.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umb-media-picker3-property-editor.html @@ -2,55 +2,121 @@ -
+
-
- -
- - - - -
- - -
-
- -
+
+
- +
+
+ +
+ + + +
+
+ +
+
+ +
Waiting...
+
{{media.$uploadProgress}}%
+
+
+ + {{media.name}} + + + + + + +
+ +
+
+
+ + +
+ + +
+
+ +
+
+ + + +
+
+ +
+ {{ invalidEntry.name }} + {{ invalidEntry.errorText }}
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umb-media-picker3-property-editor.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umb-media-picker3-property-editor.less index 37e7a4ee76..7f3af3aaa5 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umb-media-picker3-property-editor.less +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umb-media-picker3-property-editor.less @@ -18,4 +18,41 @@ umb-media-picker3-property-editor[readonly] { border-color: @inputReadonlyBorderColor; } } -} \ No newline at end of file +} + +.umb-mediapicker3 { + .dropzone { + position: relative; + + .drop-overlay { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: absolute; + box-sizing: border-box; + width: 100%; + height: 100%; + padding: 12px; + background-color: rgba(27,38,79,.2); + z-index: 1; + backdrop-filter: blur(0px); + opacity: 0; + pointer-events: none; + transition: backdrop-filter 200ms ease-in-out, opacity 200ms ease-in-out; + margin: -1px; + } + + &.drag-over { + .drop-overlay { + backdrop-filter: blur(2px); + opacity: 1; + pointer-events: all; + border: 2px solid @blueExtraDark; + } + } + }; +} + + + diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umbMediaPicker3PropertyEditor.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umbMediaPicker3PropertyEditor.component.js index 25ca237bb9..e052e7c1c6 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umbMediaPicker3PropertyEditor.component.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umbMediaPicker3PropertyEditor.component.js @@ -17,7 +17,8 @@ controller: MediaPicker3Controller, controllerAs: "vm", bindings: { - model: "=" + model: "=", + node: "=" }, require: { propertyForm: "^form", @@ -28,7 +29,10 @@ } }); - function MediaPicker3Controller($scope, editorService, clipboardService, localizationService, overlayService, userService, entityResource, $attrs) { + function MediaPicker3Controller($scope, editorService, clipboardService, localizationService, overlayService, userService, entityResource, $attrs, umbRequestHelper, $injector, uploadTracker) { + + const mediaUploader = $injector.instantiate(Utilities.MediaUploader); + let uploadInProgress = false; var unsubscribe = []; @@ -47,10 +51,15 @@ vm.allowRemoveMedia = true; vm.allowEditMedia = true; + vm.handleFiles = handleFiles; + + vm.invalidEntries = []; + vm.addMediaAt = addMediaAt; vm.editMedia = editMedia; vm.removeMedia = removeMedia; vm.copyMedia = copyMedia; + vm.allowDir = true; vm.labels = {}; @@ -70,7 +79,6 @@ }); vm.$onInit = function() { - vm.validationLimit = vm.model.config.validationLimit || {}; // If single-mode we only allow 1 item as the maximum: if(vm.model.config.multiple === false) { @@ -80,6 +88,17 @@ vm.singleMode = vm.validationLimit.max === 1; vm.allowedTypes = vm.model.config.filter ? vm.model.config.filter.split(",") : null; + const uploaderOptions = { + uploadURL: umbRequestHelper.getApiUrl("mediaPickerThreeBaseUrl", "uploadMedia"), + allowedMediaTypeAliases: vm.allowedTypes + }; + + unsubscribe.push(mediaUploader.on('mediaEntryAccepted', _handleMediaEntryAccepted)); + unsubscribe.push(mediaUploader.on('mediaEntryRejected', _handleMediaEntryRejected)); + unsubscribe.push(mediaUploader.on('queueStarted', _handleMediaQueueStarted)); + unsubscribe.push(mediaUploader.on('uploadSuccess', _handleMediaUploadSuccess)); + unsubscribe.push(mediaUploader.on('queueCompleted', _handleMediaQueueCompleted)); + copyAllMediasAction = { labelKey: "clipboard_labelForCopyAllEntries", labelTokens: [vm.model.label], @@ -135,11 +154,51 @@ vm.allowEdit = hasAccessToMedia; vm.allowAdd = hasAccessToMedia; - vm.loading = false; + mediaUploader.init(uploaderOptions).then(() => { + vm.loading = false; + }); }); - }; + function handleFiles (files, invalidFiles) { + if (vm.readonly) return; + const allFiles = [...files, ...invalidFiles]; + mediaUploader.requestUpload(allFiles); + }; + + function _handleMediaEntryAccepted (event, data) { + vm.model.value.push(data.mediaEntry); + setDirty(); + } + + function _handleMediaEntryRejected (event, data) { + // we need to make sure the media entry hasn't been accepted earlier in process + const index = vm.model.value.findIndex(mediaEntry => mediaEntry.key === data.mediaEntry.key); + if (index !== -1) { + vm.model.value.splice(index, 1); + } + vm.invalidEntries.push(data.mediaEntry); + setDirty(); + } + + function _handleMediaUploadSuccess (event, data) { + const mediaEntry = vm.model.value.find(mediaEntry => mediaEntry.key === data.mediaEntry.key); + if (!mediaEntry) return; + + mediaEntry.tmpLocation = data.tmpLocation; + updateMediaEntryData(mediaEntry); + } + + function _handleMediaQueueStarted () { + uploadInProgress = true; + uploadTracker.uploadStarted(vm.node.key); + } + + function _handleMediaQueueCompleted () { + uploadInProgress = false; + uploadTracker.uploadEnded(vm.node.key); + } + function onServerValueChanged(newVal, oldVal) { if(newVal === null || !Array.isArray(newVal)) { newVal = []; @@ -430,7 +489,7 @@ vm.sortableOptions = { cursor: "grabbing", - handle: "umb-media-card", + handle: "umb-media-card, .umb-media-card", cancel: "input,textarea,select,option", classes: ".umb-media-card--dragging", distance: 5, @@ -469,6 +528,10 @@ for (const subscription of unsubscribe) { subscription(); } + + if (uploadInProgress) { + uploadTracker.uploadEnded(vm.node.key); + } }); }