From 039131bb23c09d3458e3499263138d5ceba743c4 Mon Sep 17 00:00:00 2001 From: Aigamo <51428094+ycanardeau@users.noreply.github.com> Date: Mon, 13 Jun 2022 23:16:55 +1000 Subject: [PATCH] Convert album edit to React --- .../DataAccess/AlbumQueriesTests.cs | 20 +- .../Albums/AlbumForEditForApiContract.cs | 141 ++ .../UseCases/AlbumForEditContract.cs | 1 + VocaDbModel/Database/Queries/AlbumQueries.cs | 17 +- VocaDbWeb/Controllers/AlbumController.cs | 73 +- .../Controllers/Api/AlbumApiController.cs | 106 +- .../Controllers/Api/ArtistApiController.cs | 1 - VocaDbWeb/Models/Shared/GlobalValues.cs | 13 +- .../Scripts/Components/Album/AlbumDetails.tsx | 4 +- .../Scripts/Components/Album/AlbumEdit.tsx | 1173 +++++++++++++++++ .../Scripts/Components/Album/AlbumRoutes.tsx | 2 + .../Album/Partials/ArtistForAlbumEdit.tsx | 106 ++ .../Album/Partials/SongInAlbumEdit.tsx | 106 ++ .../Album/Partials/TrackProperties.tsx | 117 ++ .../Scripts/Components/Artist/ArtistEdit.tsx | 144 +- .../Scripts/Components/Event/EventEdit.tsx | 29 +- .../KnockoutExtensions/ArtistAutoComplete.tsx | 7 +- .../ReleaseEventAutoComplete.tsx | 3 - .../Activityfeed/ActivityEntryKnockout.tsx | 5 +- .../Partials/ArtistRolesEditViewModel.tsx | 10 +- .../Shared/Partials/CustomNameEdit.tsx | 73 + .../Partials/Knockout/ArtistFilters.tsx | 2 +- .../Knockout/ArtistLockingAutoComplete.tsx | 8 +- .../Shared/Partials/Knockout/DropdownList.tsx | 16 + .../Knockout/EntryValidationMessage.tsx | 5 +- .../Components/SongList/SongListEdit.tsx | 4 +- .../Album/AlbumDiscPropertiesContract.ts | 2 +- .../Album/AlbumForEditContract.ts | 19 +- .../Scripts/Repositories/AlbumRepository.ts | 24 + .../Scripts/Repositories/ArtistRepository.ts | 2 +- VocaDbWeb/Scripts/Shared/GlobalValues.ts | 3 + .../Album/AlbumDiscPropertiesListEditStore.ts | 33 + .../Scripts/Stores/Album/AlbumEditStore.ts | 563 ++++++++ .../Artist/AlbumArtistRolesEditStore.ts | 8 +- .../Scripts/Stores/Artist/ArtistEditStore.ts | 37 +- .../Scripts/Stores/ArtistForAlbumEditStore.ts | 2 +- .../Scripts/Stores/CustomNameEditStore.ts | 28 + .../ReleaseEvent/ReleaseEventEditStore.ts | 9 +- .../Scripts/Stores/Search/ArtistFilters.ts | 4 - .../Scripts/Stores/SongInAlbumEditStore.ts | 46 + .../Stores/SongList/SongListEditStore.ts | 7 +- .../ViewModels/Artist/ArtistEditViewModel.ts | 4 +- VocaDbWeb/wwwroot/locales/en/AjaxRes.json | 2 +- 43 files changed, 2720 insertions(+), 259 deletions(-) create mode 100644 VocaDbModel/DataContracts/Albums/AlbumForEditForApiContract.cs create mode 100644 VocaDbWeb/Scripts/Components/Album/AlbumEdit.tsx create mode 100644 VocaDbWeb/Scripts/Components/Album/Partials/ArtistForAlbumEdit.tsx create mode 100644 VocaDbWeb/Scripts/Components/Album/Partials/SongInAlbumEdit.tsx create mode 100644 VocaDbWeb/Scripts/Components/Album/Partials/TrackProperties.tsx create mode 100644 VocaDbWeb/Scripts/Components/Shared/Partials/CustomNameEdit.tsx create mode 100644 VocaDbWeb/Scripts/Stores/Album/AlbumDiscPropertiesListEditStore.ts create mode 100644 VocaDbWeb/Scripts/Stores/Album/AlbumEditStore.ts create mode 100644 VocaDbWeb/Scripts/Stores/CustomNameEditStore.ts create mode 100644 VocaDbWeb/Scripts/Stores/SongInAlbumEditStore.ts diff --git a/Tests/Web/Controllers/DataAccess/AlbumQueriesTests.cs b/Tests/Web/Controllers/DataAccess/AlbumQueriesTests.cs index 41a0373998..6182efc022 100644 --- a/Tests/Web/Controllers/DataAccess/AlbumQueriesTests.cs +++ b/Tests/Web/Controllers/DataAccess/AlbumQueriesTests.cs @@ -77,14 +77,14 @@ private ArtistForAlbumContract CreateArtistForAlbumContract(int artistId = 0, st return new ArtistForAlbumContract { Name = customArtistName, Roles = roles }; } - private Task CallUpdate(AlbumForEditContract contract) + private Task CallUpdate(AlbumForEditForApiContract contract) { return _queries.UpdateBasicProperties(contract, null); } - private async Task CallUpdate(Stream image) + private async Task CallUpdate(Stream image) { - var contract = new AlbumForEditContract(_album, ContentLanguagePreference.English, new InMemoryImagePersister()); + var contract = new AlbumForEditForApiContract(_album, ContentLanguagePreference.English, new InMemoryImagePersister(), _permissionContext); using (var stream = image) { return await _queries.UpdateBasicProperties(contract, new EntryPictureFileContract(stream, MediaTypeNames.Image.Jpeg, purpose: ImagePurpose.Main)); @@ -378,7 +378,7 @@ public void Revert() // Arrange _album.Description.English = "Original"; var oldVer = _repository.HandleTransaction(ctx => _queries.Archive(ctx, _album, AlbumArchiveReason.PropertiesUpdated)); - var contract = new AlbumForEditContract(_album, ContentLanguagePreference.English, new InMemoryImagePersister()); + var contract = new AlbumForEditForApiContract(_album, ContentLanguagePreference.English, new InMemoryImagePersister(), _permissionContext); contract.Description.English = "Updated"; CallUpdate(contract); @@ -425,7 +425,7 @@ public async Task Revert_RemoveImage() [TestMethod] public async Task Update_Names() { - var contract = new AlbumForEditContract(_album, ContentLanguagePreference.English, new InMemoryImagePersister()); + var contract = new AlbumForEditForApiContract(_album, ContentLanguagePreference.English, new InMemoryImagePersister(), _permissionContext); contract.Names.First().Value = "Replaced name"; contract.UpdateNotes = "Updated album"; @@ -476,7 +476,7 @@ public void MoveToTrash() [TestMethod] public async Task Update_Tracks() { - var contract = new AlbumForEditContract(_album, ContentLanguagePreference.English, new InMemoryImagePersister()); + var contract = new AlbumForEditForApiContract(_album, ContentLanguagePreference.English, new InMemoryImagePersister(), _permissionContext); var existingSong = CreateEntry.Song(name: "Nebula"); _repository.Save(existingSong); @@ -506,7 +506,7 @@ public async Task Update_Tracks() [TestMethod] public async Task Update_Discs() { - var contract = new AlbumForEditContract(_album, ContentLanguagePreference.English, new InMemoryImagePersister()); + var contract = new AlbumForEditForApiContract(_album, ContentLanguagePreference.English, new InMemoryImagePersister(), _permissionContext); _repository.Save(CreateEntry.AlbumDisc(_album)); contract.Discs = new[] { @@ -557,7 +557,7 @@ public async Task Update_CoverPicture() [TestMethod] public async Task Update_Artists() { - var contract = new AlbumForEditContract(_album, ContentLanguagePreference.English, new InMemoryImagePersister()); + var contract = new AlbumForEditForApiContract(_album, ContentLanguagePreference.English, new InMemoryImagePersister(), _permissionContext); contract.ArtistLinks = new[] { CreateArtistForAlbumContract(artistId: _producer.Id), CreateArtistForAlbumContract(artistId: _vocalist.Id) @@ -582,7 +582,7 @@ public async Task Update_Artists() [TestMethod] public async Task Update_Artists_CustomArtist() { - var contract = new AlbumForEditContract(_album, ContentLanguagePreference.English, new InMemoryImagePersister()); + var contract = new AlbumForEditForApiContract(_album, ContentLanguagePreference.English, new InMemoryImagePersister(), _permissionContext); contract.ArtistLinks = new[] { CreateArtistForAlbumContract(customArtistName: "Custom artist", roles: ArtistRoles.Composer) }; @@ -606,7 +606,7 @@ public async Task Update_Artists_Notify() { Save(_user2.AddArtist(_vocalist)); - var contract = new AlbumForEditContract(_album, ContentLanguagePreference.Default, new InMemoryImagePersister()); + var contract = new AlbumForEditForApiContract(_album, ContentLanguagePreference.Default, new InMemoryImagePersister(), _permissionContext); contract.ArtistLinks = contract.ArtistLinks.Concat(new[] { CreateArtistForAlbumContract(_vocalist.Id) }).ToArray(); await _queries.UpdateBasicProperties(contract, null); diff --git a/VocaDbModel/DataContracts/Albums/AlbumForEditForApiContract.cs b/VocaDbModel/DataContracts/Albums/AlbumForEditForApiContract.cs new file mode 100644 index 0000000000..b285025201 --- /dev/null +++ b/VocaDbModel/DataContracts/Albums/AlbumForEditForApiContract.cs @@ -0,0 +1,141 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using VocaDb.Model.DataContracts.Globalization; +using VocaDb.Model.DataContracts.PVs; +using VocaDb.Model.DataContracts.Songs; +using VocaDb.Model.Domain; +using VocaDb.Model.Domain.Albums; +using VocaDb.Model.Domain.Globalization; +using VocaDb.Model.Domain.Images; +using VocaDb.Model.Domain.Security; + +namespace VocaDb.Model.DataContracts.Albums; + +[DataContract(Namespace = Schemas.VocaDb)] +public sealed record AlbumForEditForApiContract +{ + [DataMember] + public ArtistForAlbumContract[] ArtistLinks { get; set; } + + [DataMember] + public bool CanDelete { get; init; } + + [DataMember] + public string? CoverPictureMime { get; init; } + + [DataMember] + public ContentLanguageSelection DefaultNameLanguage { get; init; } + + [DataMember] + public bool Deleted { get; init; } + + [DataMember] + public EnglishTranslatedStringContract Description { get; init; } + + [DataMember] + public AlbumDiscPropertiesContract[] Discs { get; set; } + + [DataMember] + [JsonConverter(typeof(StringEnumConverter))] + public DiscType DiscType { get; init; } + + [DataMember] + public int Id { get; set; } + + [DataMember] + public string[] Identifiers { get; init; } + + [DataMember] + public string Name { get; init; } + + [DataMember] + public LocalizedStringWithIdContract[] Names { get; init; } + + [DataMember] + public AlbumReleaseContract OriginalRelease { get; init; } + + [DataMember] + public IList Pictures { get; init; } + + [DataMember(Name = "pvs")] + public PVContract[] PVs { get; init; } + + [DataMember] + public SongInAlbumEditContract[] Songs { get; set; } + + [DataMember] + public EntryStatus Status { get; init; } + + [DataMember] + public string UpdateNotes { get; set; } + + [DataMember] + public WebLinkForApiContract[] WebLinks { get; init; } + + public AlbumForEditForApiContract() + { + ArtistLinks = Array.Empty(); + Description = new EnglishTranslatedStringContract(); + Discs = Array.Empty(); + Identifiers = Array.Empty(); + Name = string.Empty; + Names = Array.Empty(); + OriginalRelease = new(); + Pictures = Array.Empty(); + PVs = Array.Empty(); + Songs = Array.Empty(); + UpdateNotes = string.Empty; + WebLinks = Array.Empty(); + } + + public AlbumForEditForApiContract( + Album album, + ContentLanguagePreference languagePreference, + IAggregatedEntryImageUrlFactory imageStore, + IUserPermissionContext permissionContext + ) + { + ArtistLinks = album.Artists + .Select(a => new ArtistForAlbumContract(a, languagePreference)) + .OrderBy(a => a.Name) + .ToArray(); + CanDelete = EntryPermissionManager.CanDelete(permissionContext, album); + CoverPictureMime = album.CoverPictureMime; + DefaultNameLanguage = album.TranslatedName.DefaultLanguage; + Deleted = album.Deleted; + Description = new EnglishTranslatedStringContract(album.Description); + Discs = album.Discs + .Select(d => new AlbumDiscPropertiesContract(d)) + .ToArray(); + DiscType = album.DiscType; + Id = album.Id; + Identifiers = album.Identifiers + .Select(i => i.Value) + .ToArray(); + Name = album.TranslatedName[languagePreference]; + Names = album.Names + .Select(n => new LocalizedStringWithIdContract(n)) + .ToArray(); + OriginalRelease = album.OriginalRelease is not null + ? new(album.OriginalRelease, languagePreference) + : new(); + Pictures = album.Pictures + .Select(p => new EntryPictureFileContract(p, imageStore)) + .ToList(); + PVs = album.PVs + .Select(p => new PVContract(p)) + .ToArray(); + Songs = album.Songs + .OrderBy(s => s.DiscNumber) + .ThenBy(s => s.TrackNumber) + .Select(s => new SongInAlbumEditContract(s, languagePreference)) + .ToArray(); + Status = album.Status; + UpdateNotes = string.Empty; + WebLinks = album.WebLinks + .Select(w => new WebLinkForApiContract(w)) + .OrderBy(w => w.DescriptionOrUrl) + .ToArray(); + } +} diff --git a/VocaDbModel/DataContracts/UseCases/AlbumForEditContract.cs b/VocaDbModel/DataContracts/UseCases/AlbumForEditContract.cs index 1d68f7ac3d..2b965938cc 100644 --- a/VocaDbModel/DataContracts/UseCases/AlbumForEditContract.cs +++ b/VocaDbModel/DataContracts/UseCases/AlbumForEditContract.cs @@ -11,6 +11,7 @@ namespace VocaDb.Model.DataContracts.UseCases { + [Obsolete] [DataContract(Namespace = Schemas.VocaDb)] public class AlbumForEditContract : AlbumContract { diff --git a/VocaDbModel/Database/Queries/AlbumQueries.cs b/VocaDbModel/Database/Queries/AlbumQueries.cs index 9c1542690a..3bc10e019f 100644 --- a/VocaDbModel/Database/Queries/AlbumQueries.cs +++ b/VocaDbModel/Database/Queries/AlbumQueries.cs @@ -598,11 +598,16 @@ public EntryForPictureDisplayContract GetCoverPictureThumb(int albumId) }); } - public AlbumForEditContract GetForEdit(int id) + public AlbumForEditForApiContract GetForEdit(int id) { - return - HandleQuery(session => - new AlbumForEditContract(session.Load(id), PermissionContext.LanguagePreference, _imageUrlFactory)); + return HandleQuery(session => + new AlbumForEditForApiContract( + album: session.Load(id), + languagePreference: PermissionContext.LanguagePreference, + imageStore: _imageUrlFactory, + permissionContext: PermissionContext + ) + ); } public RelatedAlbumsContract GetRelatedAlbums(int albumId) @@ -947,7 +952,7 @@ public EntryRevertedContract RevertToVersion(int archivedAlbumVersionId) } #nullable enable - public async Task UpdateBasicProperties(AlbumForEditContract properties, EntryPictureFileContract? pictureData) + public async Task UpdateBasicProperties(AlbumForEditForApiContract properties, EntryPictureFileContract? pictureData) { ParamIs.NotNull(() => properties); @@ -1140,7 +1145,7 @@ public async Task UpdateBasicProperties(AlbumForEditContra } } - return new AlbumForEditContract(album, PermissionContext.LanguagePreference, _imageUrlFactory); + return new AlbumForEditForApiContract(album, PermissionContext.LanguagePreference, _imageUrlFactory, PermissionContext); }); } #nullable disable diff --git a/VocaDbWeb/Controllers/AlbumController.cs b/VocaDbWeb/Controllers/AlbumController.cs index bd8251dd27..6f95e1819d 100644 --- a/VocaDbWeb/Controllers/AlbumController.cs +++ b/VocaDbWeb/Controllers/AlbumController.cs @@ -1,7 +1,6 @@ #nullable disable using System.Globalization; -using System.Net; using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -11,16 +10,13 @@ using VocaDb.Model.DataContracts.UseCases; using VocaDb.Model.Domain; using VocaDb.Model.Domain.Albums; -using VocaDb.Model.Domain.Images; using VocaDb.Model.Domain.Security; using VocaDb.Model.Helpers; -using VocaDb.Model.Resources; using VocaDb.Model.Service; using VocaDb.Model.Service.ExtSites; using VocaDb.Model.Service.TagFormatting; using VocaDb.Model.Utils.Search; using VocaDb.Web.Code; -using VocaDb.Web.Code.Exceptions; using VocaDb.Web.Code.Markdown; using VocaDb.Web.Code.WebApi; using VocaDb.Web.Helpers; @@ -244,78 +240,13 @@ public async Task Create(Create model) return RedirectToAction("Edit", new { id = album.Id }); } +#nullable enable // // GET: /Album/Edit/5 [Authorize] public ActionResult Edit(int id) { - CheckConcurrentEdit(EntryType.Album, id); - - return View(CreateAlbumEditViewModel(id, null)); - } - -#nullable enable - // - // POST: /Album/Edit/5 - - [HttpPost] - [Authorize] - public async Task Edit(AlbumEditViewModel viewModel) - { - // Unable to continue if viewmodel is null because we need the ID at least - if (viewModel is null || viewModel.EditedAlbum is null) - { - s_log.Warn("Viewmodel was null"); - return HttpStatusCodeResult(HttpStatusCode.BadRequest, "Viewmodel was null - probably JavaScript is disabled"); - } - - try - { - viewModel.CheckModel(); - } - catch (InvalidFormException x) - { - AddFormSubmissionError(x.Message); - } - - var model = viewModel.EditedAlbum; - - // Note: name is allowed to be whitespace, but not empty. - if (model.Names is not null && model.Names.All(n => n is null || string.IsNullOrEmpty(n.Value))) - { - ModelState.AddModelError("Names", AlbumValidationErrors.UnspecifiedNames); - } - - if (model.OriginalRelease is not null && model.OriginalRelease.ReleaseDate is not null && !OptionalDateTime.IsValid(model.OriginalRelease.ReleaseDate.Year, model.OriginalRelease.ReleaseDate.Day, model.OriginalRelease.ReleaseDate.Month)) - ModelState.AddModelError("ReleaseYear", "Invalid date"); - - var coverPicUpload = Request.Form.Files["coverPicUpload"]; - var pictureData = ParsePicture(coverPicUpload, "CoverPicture", ImagePurpose.Main); - - if (model.Pictures is null) - { - AddFormSubmissionError("List of pictures was null"); - } - - if (model.Pictures is not null) - ParseAdditionalPictures(coverPicUpload, model.Pictures); - - if (!ModelState.IsValid) - { - return View(CreateAlbumEditViewModel(model.Id, model)); - } - - try - { - await _queries.UpdateBasicProperties(model, pictureData); - } - catch (InvalidPictureException) - { - ModelState.AddModelError("ImageError", "The uploaded image could not processed, it might be broken. Please check the file and try again."); - return View(CreateAlbumEditViewModel(model.Id, model)); - } - - return RedirectToAction("Details", new { id = model.Id }); + return View("React/Index"); } #nullable disable diff --git a/VocaDbWeb/Controllers/Api/AlbumApiController.cs b/VocaDbWeb/Controllers/Api/AlbumApiController.cs index e6684a6963..3b54d241b9 100644 --- a/VocaDbWeb/Controllers/Api/AlbumApiController.cs +++ b/VocaDbWeb/Controllers/Api/AlbumApiController.cs @@ -15,9 +15,13 @@ using VocaDb.Model.Domain.Albums; using VocaDb.Model.Domain.Globalization; using VocaDb.Model.Domain.Images; +using VocaDb.Model.Helpers; +using VocaDb.Model.Resources; using VocaDb.Model.Service; using VocaDb.Model.Service.Search; using VocaDb.Model.Service.Search.AlbumSearch; +using VocaDb.Web.Code; +using VocaDb.Web.Code.Exceptions; using VocaDb.Web.Code.Security; using VocaDb.Web.Helpers; using VocaDb.Web.Models.Shared; @@ -41,8 +45,12 @@ public class AlbumApiController : ApiController private readonly AlbumQueries _queries; private readonly AlbumService _service; - public AlbumApiController(AlbumQueries queries, AlbumService service, - OtherService otherService, IAggregatedEntryImageUrlFactory thumbPersister) + public AlbumApiController( + AlbumQueries queries, + AlbumService service, + OtherService otherService, + IAggregatedEntryImageUrlFactory thumbPersister + ) { _queries = queries; _service = service; @@ -84,7 +92,7 @@ public AlbumApiController(AlbumQueries queries, AlbumService service, [HttpGet("{id:int}/for-edit")] [ApiExplorerSettings(IgnoreApi = true)] - public AlbumForEditContract GetForEdit(int id) => _queries.GetForEdit(id); + public AlbumForEditForApiContract GetForEdit(int id) => _queries.GetForEdit(id); /// /// Gets an album by Id. @@ -365,6 +373,98 @@ public AlbumDetailsForApiContract GetDetails(int id) [ApiExplorerSettings(IgnoreApi = true)] public EntryWithArchivedVersionsForApiContract GetAlbumWithArchivedVersions(int id) => _queries.GetAlbumWithArchivedVersionsForApi(id); + + [HttpPost("{id:int}")] + [Authorize] + [EnableCors(AuthenticationConstants.AuthenticatedCorsApiPolicy)] + [ValidateAntiForgeryToken] + [ApiExplorerSettings(IgnoreApi = true)] + public async Task> Edit( + [ModelBinder(BinderType = typeof(JsonModelBinder))] AlbumForEditForApiContract contract + ) + { + // Unable to continue if viewmodel is null because we need the ID at least + if (contract is null) + { + return BadRequest("Viewmodel was null - probably JavaScript is disabled"); + } + + try + { + static void CheckModel(AlbumForEditForApiContract contract) + { + if (contract is null) + throw new InvalidFormException("Model was null"); + + if (contract.ArtistLinks is null) + throw new InvalidFormException("Artists list was null"); + + if (contract.Identifiers is null) + throw new InvalidFormException("Identifiers list was null"); + + if (contract.Names is null) + throw new InvalidFormException("Names list was null"); + + if (contract.PVs is null) + throw new InvalidFormException("PVs list was null"); + + if (contract.Songs is null) + throw new InvalidFormException("Tracks list was null"); + + if (contract.WebLinks is null) + throw new InvalidFormException("WebLinks list was null"); + } + + CheckModel(contract); + } + catch (InvalidFormException x) + { + ControllerBase.AddFormSubmissionError(this, x.Message); + } + + // Note: name is allowed to be whitespace, but not empty. + if (contract.Names is not null && contract.Names.All(n => n is null || string.IsNullOrEmpty(n.Value))) + { + ModelState.AddModelError("Names", AlbumValidationErrors.UnspecifiedNames); + } + + if ( + contract.OriginalRelease is not null && + contract.OriginalRelease.ReleaseDate is not null && + !OptionalDateTime.IsValid(contract.OriginalRelease.ReleaseDate.Year, contract.OriginalRelease.ReleaseDate.Day, contract.OriginalRelease.ReleaseDate.Month) + ) + { + ModelState.AddModelError("ReleaseYear", "Invalid date"); + } + + var coverPicUpload = Request.Form.Files["coverPicUpload"]; + var pictureData = ControllerBase.ParsePicture(this, coverPicUpload, "CoverPicture", ImagePurpose.Main); + + if (contract.Pictures is null) + { + ControllerBase.AddFormSubmissionError(this, "List of pictures was null"); + } + + if (contract.Pictures is not null) + ControllerBase.ParseAdditionalPictures(this, coverPicUpload, contract.Pictures); + + if (!ModelState.IsValid) + { + return ValidationProblem(ModelState); + } + + try + { + await _queries.UpdateBasicProperties(contract, pictureData); + } + catch (InvalidPictureException) + { + ModelState.AddModelError("ImageError", "The uploaded image could not processed, it might be broken. Please check the file and try again."); + return ValidationProblem(ModelState); + } + + return contract.Id; + } #nullable disable } } diff --git a/VocaDbWeb/Controllers/Api/ArtistApiController.cs b/VocaDbWeb/Controllers/Api/ArtistApiController.cs index bde8f6f7a7..e5aece787d 100644 --- a/VocaDbWeb/Controllers/Api/ArtistApiController.cs +++ b/VocaDbWeb/Controllers/Api/ArtistApiController.cs @@ -274,7 +274,6 @@ public ArtistDetailsForApiContract GetDetails(int id) public EntryWithArchivedVersionsForApiContract GetArtistWithArchivedVersions(int id) => _queries.GetArtistWithArchivedVersionsForApi(id); - [HttpPost("{id:int}")] [Authorize] [EnableCors(AuthenticationConstants.AuthenticatedCorsApiPolicy)] diff --git a/VocaDbWeb/Models/Shared/GlobalValues.cs b/VocaDbWeb/Models/Shared/GlobalValues.cs index 0bf383e5d1..b06945e812 100644 --- a/VocaDbWeb/Models/Shared/GlobalValues.cs +++ b/VocaDbWeb/Models/Shared/GlobalValues.cs @@ -27,19 +27,19 @@ public MenuPageLink(MenuPage.Link model) } } + public bool AllowCustomArtistName { get; init; } [JsonProperty(ItemConverterType = typeof(StringEnumConverter))] public DiscType[] AlbumTypes { get; init; } - + public bool AllowCustomTracks { get; init; } [JsonProperty(ItemConverterType = typeof(StringEnumConverter))] public ArtistType[] ArtistTypes { get; init; } - + [JsonProperty(ItemConverterType = typeof(StringEnumConverter))] + public ArtistRoles[] ArtistRoles { get; init; } public string? ExternalHelpPath { get; init; } public string? HostAddress { get; init; } public string? LockdownMessage { get; init; } - [JsonProperty(ItemConverterType = typeof(StringEnumConverter))] public SongType[] SongTypes { get; init; } - public string StaticContentHost { get; init; } public string SiteName { get; init; } @@ -57,10 +57,8 @@ public MenuPageLink(MenuPage.Link model) public int InstrumentalTagId { get; init; } public string? BaseAddress { get; init; } - [JsonConverter(typeof(StringEnumConverter))] public ContentLanguagePreference LanguagePreference { get; init; } - public bool IsLoggedIn { get; init; } public int LoggedUserId { get; init; } public SanitizedUserWithPermissionsContract? LoggedUser { get; init; } @@ -78,8 +76,11 @@ public MenuPageLink(MenuPage.Link model) public GlobalValues(VocaDbPage model) { + AllowCustomArtistName = AppConfig.AllowCustomArtistName; AlbumTypes = AppConfig.AlbumTypes; + AllowCustomTracks = AppConfig.AllowCustomTracks; ArtistTypes = AppConfig.ArtistTypes; + ArtistRoles = AppConfig.ArtistRoles; ExternalHelpPath = AppConfig.ExternalHelpPath; HostAddress = AppConfig.HostAddress; LockdownMessage = AppConfig.LockdownMessage; diff --git a/VocaDbWeb/Scripts/Components/Album/AlbumDetails.tsx b/VocaDbWeb/Scripts/Components/Album/AlbumDetails.tsx index 7f9c16a896..14f01347b1 100644 --- a/VocaDbWeb/Scripts/Components/Album/AlbumDetails.tsx +++ b/VocaDbWeb/Scripts/Components/Album/AlbumDetails.tsx @@ -115,8 +115,8 @@ const AlbumDetailsLayout = observer( )}{' '} ; +} + +const BasicInfoTabContent = observer( + ({ + albumEditStore, + coverPicUploadRef, + }: BasicInfoTabContentProps): React.ReactElement => { + const { t } = useTranslation([ + 'Resources', + 'ViewRes', + 'ViewRes.Album', + 'VocaDb.Model.Resources', + 'VocaDb.Model.Resources.Albums', + ]); + + const discTypeDescriptions = React.useMemo( + () => + `${t( + 'ViewRes.Album:Edit.BaDiscTypeExplanation', + )}

    ${Object.values(AlbumType) + .filter((value) => value !== AlbumType.Unknown) + .map( + (value) => + `
  • ${t( + `VocaDb.Model.Resources.Albums:DiscTypeNames.${value}`, + )}: ${t( + `Resources:DiscTypeDescriptions.${value}`, + )}
  • `, + ) + .join('')}
`, + [t], + ); + + return ( + <> +
+ +
+
+ + runInAction(() => { + albumEditStore.defaultNameLanguage = e.target.value; + }) + } + /> +
+ +
+ {' '} + + {albumEditStore.validationError_unspecifiedNames && ( + <> + {' '} + + + )} +
+
+ +
+ +
+ +
+
+ + + + + + + +
+ {t('ViewRes.Album:Edit.ImagePreview')} + +

+ {t('ViewRes.Album:Edit.BaPictureInfo', { + 0: ImageHelper.allowedExtensions.join(', '), + 1: ImageHelper.maxImageSizeMB, + })} +

+ +
+
+ +
+ {' '} + +
+
+ +
+ +
+ +
+
+
+ + runInAction(() => { + albumEditStore.discType = e.target.value as AlbumType; + }) + } + /> + {albumEditStore.validationError_needType && ( + <> + {' '} + + + )} +
+
+ +
+ +
+
+ +
+ + + + + + + + + + +
{t('ViewRes.Album:Edit.BaReleaseDate')} +
+ +
+
+ + runInAction(() => { + albumEditStore.releaseYear = e.target.value + ? Number(e.target.value) + : undefined; + }) + } + className="input-small" + size={10} + maxLength={4} + min={39} + max={3939} + /> + {albumEditStore.validationError_needReleaseYear && ( + <> + {' '} + + + )} +
+
+
+ +
+
+ + runInAction(() => { + albumEditStore.releaseMonth = e.target.value + ? Number(e.target.value) + : undefined; + }) + } + className="input-mini" + size={4} + maxLength={2} + min={1} + max={12} + /> +
+
+
+ +
+
+ + runInAction(() => { + albumEditStore.releaseDay = e.target.value + ? Number(e.target.value) + : undefined; + }) + } + className="input-mini" + size={4} + maxLength={2} + min={1} + max={31} + /> +
+
+ + {!albumEditStore.releaseDate && albumEditStore.eventDate && ( + + )} + +
+ +
+
+ + runInAction(() => { + albumEditStore.catalogNumber = e.target.value; + }) + } + /> +
+ +
+
`} /* TODO: localize */ + /> +
+
+
+ {albumEditStore.identifiers.map((identifier, index) => ( +
+ + {identifier} + + + albumEditStore.removeIdentifier(identifier) + } + > + {t('ViewRes:Shared.Delete')} + +
+ ))} +
+ + runInAction(() => { + albumEditStore.newIdentifier = e.target.value; + }) + } + onBlur={albumEditStore.createNewIdentifier} + > +
+ +
+ +
+
+ +
+ +
+ +
+
+ + runInAction(() => { + albumEditStore.status = e.target.value; + }) + } + /> +
+ + ); + }, +); + +interface ArtistsTabContentProps { + albumEditStore: AlbumEditStore; +} + +const ArtistsTabContent = observer( + ({ albumEditStore }: ArtistsTabContentProps): React.ReactElement => { + const { t } = useTranslation([ + 'AjaxRes', + 'ViewRes', + 'VocaDb.Model.Resources', + ]); + + return ( + <> + {albumEditStore.validationError_needArtist && ( + + + {t('VocaDb.Model.Resources:AlbumValidationErrors.NeedArtist')} + + + )} + + {albumEditStore.artistLinks.length > 0 && ( + + + + + + + + + + + {albumEditStore.artistLinks.map((artistLink, index) => ( + + ))} + +
Artist{/* TODO: localize */}Support{/* TODO: localize */}Roles{/* TODO: localize */}Actions{/* TODO: localize */}
+ )} + +
+

{t('ViewRes.Album:Edit.ArAddArtist')}

+ + + ); + }, +); + +interface DiscsTabContentProps { + albumEditStore: AlbumEditStore; +} + +const DiscsTabContent = observer( + ({ albumEditStore }: DiscsTabContentProps): React.ReactElement => { + const { t } = useTranslation(['ViewRes', 'ViewRes.Album']); + + return ( + <> + {t('ViewRes.Album:Edit.DiNote')} + + + + + + + + + + {albumEditStore.discs.items.map((item, index) => ( + + + + + + + ))} + +
+ {t('ViewRes.Album:Edit.DiName')}{t('ViewRes.Album:Edit.DiType')} +
+ {index + 1} + + + runInAction(() => { + item.name = e.target.value; + }) + } + placeholder="Name" + maxLength={50} + /> + + + + albumEditStore.discs.remove(item)} + href="#" + className="iconLink removeLink" + > + {t('ViewRes:Shared.Remove')} + +
+ + + {t('ViewRes.Album:Edit.DiAddRow')} + + + ); + }, +); + +interface TracksTabContentProps { + albumEditStore: AlbumEditStore; +} + +const TracksTabContent = observer( + ({ albumEditStore }: TracksTabContentProps): React.ReactElement => { + const { t } = useTranslation([ + 'ViewRes', + 'ViewRes.Album', + 'VocaDb.Model.Resources', + ]); + + return ( + <> + {albumEditStore.validationError_needTracks && ( + + + {t('VocaDb.Model.Resources:AlbumValidationErrors.NeedTracks')} + + + )} + + {albumEditStore.tracks.length > 0 && ( + <> +

{t('ViewRes.Album:Edit.TrTrackNameHelp')}

+ + + + + + + + + + + + runInAction(() => { + albumEditStore.tracks = tracks; + }) + } + handle=".handle" + > + {albumEditStore.tracks.map((track, index) => ( + + ))} + +
+ + + runInAction(() => { + albumEditStore.allTracksSelected = e.target.checked; + }) + } + /> + {t('ViewRes.Album:Edit.TrDiscHead')}{t('ViewRes.Album:Edit.TrTrackHead')}{t('ViewRes.Album:Edit.TrNameHead')} +
+ +
+ + {t('ViewRes.Album:Edit.TrSetArtists')} + +
+
+ + )} + +

{t('ViewRes.Album:Edit.AddNew')}

+ +

{t('ViewRes.Album:Edit.TrAddTrackHelp')}

+ + ); + }, +); + +interface PicturesTabContentProps { + albumEditStore: AlbumEditStore; +} + +const PicturesTabContent = observer( + ({ albumEditStore }: PicturesTabContentProps): React.ReactElement => { + const { t } = useTranslation(['ViewRes.Album']); + + return ( + <> +

{t('ViewRes.Album:Edit.PiPicturesGuide')}

+

+ {t('ViewRes.Album:Edit.BaPictureInfo', { + 0: ImageHelper.allowedExtensions.join(', '), + 1: ImageHelper.maxImageSizeMB, + })} +

+ + + + {albumEditStore.pictures.pictures.map((picture, index) => ( + + ))} + +
+ + + {t('ViewRes.Album:Edit.PiCreateNew')} + + + ); + }, +); + +interface MediaTabContentProps { + albumEditStore: AlbumEditStore; +} + +const MediaTabContent = observer( + ({ albumEditStore }: MediaTabContentProps): React.ReactElement => { + const { t } = useTranslation(['ViewRes', 'ViewRes.Album']); + + return ( + <> +

{t('ViewRes.Album:Edit.PvIntro')}

+ + {albumEditStore.pvs.pvs.length > 0 && ( + + + + + + + + + + + + {albumEditStore.pvs.pvs.map((pv, index) => ( + + ))} + +
{t('ViewRes.Album:Edit.PvService')}{t('ViewRes.Album:Edit.PvType')}{t('ViewRes.Album:Edit.PvName')}Date{/* TODO: localize */}{t('ViewRes.Album:Edit.PvAuthor')} +
+ )} + +
+

{t('ViewRes.Album:Edit.PvAdd')}

+

{t('ViewRes.Album:Edit.PvSupportedServices')}

+

+ {t('ViewRes.Album:Edit.PvURL')}{' '} + + runInAction(() => { + albumEditStore.pvs.newPvUrl = e.target.value; + }) + } + maxLength={255} + size={40} + className="input-xlarge" + /> +

+ + + {t('ViewRes:Shared.Add')} + + {/* TODO */} + + ); + }, +); + +interface AlbumEditLayoutProps { + albumEditStore: AlbumEditStore; +} + +const AlbumEditLayout = observer( + ({ albumEditStore }: AlbumEditLayoutProps): React.ReactElement => { + const { t, ready } = useTranslation([ + 'ViewRes', + 'ViewRes.Album', + 'VocaDb.Model.Resources', + ]); + + const contract = albumEditStore.contract; + + const title = t('ViewRes.Album:Edit.EditTitle', { 0: contract.name }); + + useVocaDbTitle(title, ready); + + const conflictingEditor = useConflictingEditor(EntryType.Album); + + const navigate = useNavigate(); + + const coverPicUploadRef = React.useRef(undefined!); + + return ( + + + {t('ViewRes:Shared.Albums')} + + + {contract.name} + + + } + toolbar={ + <> + {contract.canDelete && + (contract.deleted ? ( + <> + + {t('ViewRes:EntryEdit.Restore')} + + {loginManager.canMoveToTrash && ( + <> + {' '} +  {' '} + { + if ( + !window.confirm( + t('ViewRes.Album:Edit.ConfirmMoveToTrash'), + ) + ) { + e.preventDefault(); + } + }} + icons={{ primary: 'ui-icon-trash' }} + > + {t('ViewRes.Album:Edit.MoveToTrash')} + + + )} + + ) : ( + + {t('ViewRes:Shared.Delete')} + + ))} + {loginManager.canMergeEntries && ( + <> + {' '} +  {' '} + + {t('ViewRes:EntryEdit.Merge')} + + + )} + + } + > + {conflictingEditor && conflictingEditor.userId !== 0 && ( + + )} + + {albumEditStore.errors && ( + + )} + + + +
+
=> { + e.preventDefault(); + + try { + const coverPicUpload = + coverPicUploadRef.current.files?.item(0) ?? undefined; + + // TODO: Use useRef. + const pictureUpload = _.chain( + document.getElementsByName('pictureUpload'), + ) + .map((element) => (element as HTMLInputElement).files?.[0]) + .filter((file) => file !== undefined) + .map((file) => file as File) + .value(); + + const id = await albumEditStore.submit( + coverPicUpload, + pictureUpload, + ); + + navigate(EntryUrlMapper.details(EntryType.Album, id)); + } catch (e) { + showErrorMessage( + 'Unable to save properties.' /* TODO: localize */, + ); + + throw e; + } + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +

{t('ViewRes:EntryEdit.UpdateNotes')}

+