From 334764f5ca98fb610cdb7c21ad7d9c4f710de474 Mon Sep 17 00:00:00 2001 From: Aigamo <51428094+ycanardeau@users.noreply.github.com> Date: Mon, 4 Jul 2022 23:43:29 +1000 Subject: [PATCH] Convert song edit to React --- .../Queries/SongQueriesDatabaseTests.cs | 19 +- .../DataAccess/SongQueriesTests.cs | 46 +- .../ReleaseEventForApiContract.cs | 17 +- .../Songs/SongForEditForApiContract.cs | 176 ++++ .../UseCases/SongForEditContract.cs | 1 + VocaDbModel/Database/Queries/SongQueries.cs | 10 +- .../Globalization/EnglishTranslatedString.cs | 15 +- VocaDbModel/Domain/Songs/Song.cs | 6 + .../Controllers/Api/SongApiController.cs | 73 +- VocaDbWeb/Controllers/SongController.cs | 55 +- VocaDbWeb/Scripts/Bootstrap/Accordion.tsx | 98 ++ VocaDbWeb/Scripts/Bootstrap/AccordionBody.tsx | 56 ++ .../Scripts/Bootstrap/AccordionButton.tsx | 107 ++ .../Scripts/Bootstrap/AccordionCollapse.tsx | 64 ++ .../Scripts/Bootstrap/AccordionContext.ts | 29 + .../Scripts/Bootstrap/AccordionHeader.tsx | 58 ++ VocaDbWeb/Scripts/Bootstrap/AccordionItem.tsx | 71 ++ .../Scripts/Bootstrap/AccordionItemContext.ts | 13 + .../Shared/Partials/Knockout/DropdownList.tsx | 35 + .../Shared/Partials/Song/SongLink.tsx | 4 + .../Song/Partials/ArtistForSongEdit.tsx | 107 ++ .../Song/Partials/LyricsForSongEdit.tsx | 201 ++++ .../Scripts/Components/Song/SongDetails.tsx | 4 +- .../Scripts/Components/Song/SongEdit.tsx | 942 ++++++++++++++++++ .../Scripts/Components/Song/SongRoutes.tsx | 2 + .../DataContracts/Song/SongForEditContract.ts | 30 +- VocaDbWeb/Scripts/Models/LoginManager.ts | 4 + VocaDbWeb/Scripts/Models/Songs/SongType.ts | 12 - .../Scripts/Repositories/SongRepository.ts | 16 + .../Scripts/Stores/PVs/PVListEditStore.ts | 23 +- .../Stores/Song/LyricsForSongListEditStore.ts | 112 +++ .../Scripts/Stores/Song/SongEditStore.ts | 441 ++++++++ 32 files changed, 2712 insertions(+), 135 deletions(-) create mode 100644 VocaDbModel/DataContracts/Songs/SongForEditForApiContract.cs create mode 100644 VocaDbWeb/Scripts/Bootstrap/Accordion.tsx create mode 100644 VocaDbWeb/Scripts/Bootstrap/AccordionBody.tsx create mode 100644 VocaDbWeb/Scripts/Bootstrap/AccordionButton.tsx create mode 100644 VocaDbWeb/Scripts/Bootstrap/AccordionCollapse.tsx create mode 100644 VocaDbWeb/Scripts/Bootstrap/AccordionContext.ts create mode 100644 VocaDbWeb/Scripts/Bootstrap/AccordionHeader.tsx create mode 100644 VocaDbWeb/Scripts/Bootstrap/AccordionItem.tsx create mode 100644 VocaDbWeb/Scripts/Bootstrap/AccordionItemContext.ts create mode 100644 VocaDbWeb/Scripts/Components/Song/Partials/ArtistForSongEdit.tsx create mode 100644 VocaDbWeb/Scripts/Components/Song/Partials/LyricsForSongEdit.tsx create mode 100644 VocaDbWeb/Scripts/Components/Song/SongEdit.tsx create mode 100644 VocaDbWeb/Scripts/Stores/Song/LyricsForSongListEditStore.ts create mode 100644 VocaDbWeb/Scripts/Stores/Song/SongEditStore.ts diff --git a/Tests/DatabaseTests/Queries/SongQueriesDatabaseTests.cs b/Tests/DatabaseTests/Queries/SongQueriesDatabaseTests.cs index e52f0c365f..3643a830b1 100644 --- a/Tests/DatabaseTests/Queries/SongQueriesDatabaseTests.cs +++ b/Tests/DatabaseTests/Queries/SongQueriesDatabaseTests.cs @@ -3,6 +3,7 @@ using VocaDb.Model.Database.Queries; using VocaDb.Model.Database.Repositories; using VocaDb.Model.DataContracts.ReleaseEvents; +using VocaDb.Model.DataContracts.Songs; using VocaDb.Model.DataContracts.UseCases; using VocaDb.Model.DataContracts.Users; using VocaDb.Model.Domain.Globalization; @@ -47,10 +48,11 @@ private SongQueries Queries(ISongRepository repository) new VdbConfigManager(), new EntrySubTypeNameFactory(), new FollowedArtistNotifier(new FakeEntryLinkFactory(), new FakeUserMessageMailer(), new EnumTranslations(), new EntrySubTypeNameFactory()), - new FakeDiscordWebhookNotifier()); + new FakeDiscordWebhookNotifier() + ); } - private async Task Update(SongForEditContract contract) + private async Task Update(SongForEditForApiContract contract) { return await _context.RunTestAsync(async repository => { @@ -71,7 +73,7 @@ public async Task Update_ReleaseEvent_Remove() Db.ReleaseEvent.AllSongs.Contains(Db.Song).Should().BeTrue("Release event has song"); // Act - var contract = new SongForEditContract(Db.Song, ContentLanguagePreference.English) + var contract = new SongForEditForApiContract(Db.Song, ContentLanguagePreference.English, _userContext) { ReleaseEvent = null }; @@ -97,10 +99,15 @@ await _context.RunTestAsync(async repository => { var queries = Queries(repository); - var newEvent = repository.HandleTransaction(ctx => new ReleaseEventContract(ctx.Save(CreateEntry.ReleaseEvent("Mikumas")), ContentLanguagePreference.English, false)); + var newEvent = repository.HandleTransaction(ctx => new ReleaseEventForApiContract( + rel: ctx.Save(CreateEntry.ReleaseEvent("Mikumas")), + languagePreference: ContentLanguagePreference.English, + fields: ReleaseEventOptionalFields.None, + thumbPersister: null + )); // Act - var contract = new SongForEditContract(Db.Song, ContentLanguagePreference.English) + var contract = new SongForEditForApiContract(Db.Song, ContentLanguagePreference.English, _userContext) { ReleaseEvent = newEvent }; @@ -118,7 +125,7 @@ await _context.RunTestAsync(async repository => [TestCategory(TestCategories.Database)] public async Task Update_Lyrics() { - var contract = new SongForEditContract(Db.Song2, ContentLanguagePreference.English) + var contract = new SongForEditForApiContract(Db.Song2, ContentLanguagePreference.English, _userContext) { Lyrics = new[] { CreateEntry.LyricsForSongContract(TranslationType.Original) } }; diff --git a/Tests/Web/Controllers/DataAccess/SongQueriesTests.cs b/Tests/Web/Controllers/DataAccess/SongQueriesTests.cs index 6e43b6dd72..a6bece489a 100644 --- a/Tests/Web/Controllers/DataAccess/SongQueriesTests.cs +++ b/Tests/Web/Controllers/DataAccess/SongQueriesTests.cs @@ -73,9 +73,9 @@ private Task CallFindDuplicates(string[] anyName = n return (result.created, report); } - private SongForEditContract EditContract() + private SongForEditForApiContract EditContract() { - return new SongForEditContract(_song, ContentLanguagePreference.English); + return new SongForEditForApiContract(_song, ContentLanguagePreference.English, _permissionContext); } private void AssertHasArtist(Song song, Artist artist, ArtistRoles? roles = null) @@ -783,9 +783,9 @@ public async Task Revert() { _user.GroupId = UserGroupId.Moderator; _permissionContext.RefreshLoggedUser(_repository); - SongForEditContract Contract() + SongForEditForApiContract Contract() { - return new SongForEditContract(_song, ContentLanguagePreference.English); + return new SongForEditForApiContract(_song, ContentLanguagePreference.English, _permissionContext); } await _queries.UpdateBasicProperties(Contract()); @@ -813,7 +813,7 @@ SongForEditContract Contract() [TestMethod] public async Task Update_Names() { - var contract = new SongForEditContract(_song, ContentLanguagePreference.English); + var contract = new SongForEditForApiContract(_song, ContentLanguagePreference.English, _permissionContext); contract.Names.First().Value = "Replaced name"; contract.UpdateNotes = "Updated song"; @@ -849,7 +849,7 @@ public async Task Update_Artists() foreach (var name in newSong.Names) _repository.Save(name); - var contract = new SongForEditContract(newSong, ContentLanguagePreference.English); + var contract = new SongForEditForApiContract(newSong, ContentLanguagePreference.English, _permissionContext); contract.Artists = new[] { CreateArtistForSongContract(artistId: _producer.Id), CreateArtistForSongContract(artistId: _vocalist.Id), @@ -878,7 +878,7 @@ public async Task Update_Artists_Notify() _repository.Save(_user2.AddArtist(_vocalist2)); _repository.Save(_vocalist2); - var contract = new SongForEditContract(_song, ContentLanguagePreference.English); + var contract = new SongForEditForApiContract(_song, ContentLanguagePreference.English, _permissionContext); contract.Artists = contract.Artists.Concat(new[] { CreateArtistForSongContract(_vocalist2.Id) }).ToArray(); await _queries.UpdateBasicProperties(contract); @@ -896,7 +896,7 @@ public async Task Update_Artists_RemoveDeleted() _repository.Save(_song.AddArtist(_vocalist2)); _vocalist2.Deleted = true; - var contract = new SongForEditContract(_song, ContentLanguagePreference.English); + var contract = new SongForEditForApiContract(_song, ContentLanguagePreference.English, _permissionContext); await _queries.UpdateBasicProperties(contract); @@ -921,7 +921,7 @@ public async Task Update_Lyrics() [TestMethod] public async Task Update_PublishDate_From_PVs() { - var contract = new SongForEditContract(_song, ContentLanguagePreference.English); + var contract = new SongForEditForApiContract(_song, ContentLanguagePreference.English, _permissionContext); contract.PVs = new[] { CreateEntry.PVContract(id: 1, pvId: "hoLu7c2XZYU", pvType: PVType.Reprint, publishDate: new DateTime(2015, 3, 9, 10, 0, 0)), CreateEntry.PVContract(id: 2, pvId: "mikumikumiku", pvType: PVType.Original, publishDate: new DateTime(2015, 4, 9, 16, 0, 0)) @@ -937,9 +937,15 @@ public async Task Update_PublishDate_From_PVs() [TestMethod] public async Task Update_Weblinks() { - var contract = new SongForEditContract(_song, ContentLanguagePreference.English); + var contract = new SongForEditForApiContract(_song, ContentLanguagePreference.English, _permissionContext); contract.WebLinks = new[] { - new WebLinkContract("http://vocadb.net", "VocaDB", WebLinkCategory.Reference, disabled: false) + new WebLinkForApiContract + { + Url = "http://vocadb.net", + Description = "VocaDB", + Category = WebLinkCategory.Reference, + Disabled = false, + } }; contract = await _queries.UpdateBasicProperties(contract); @@ -951,9 +957,15 @@ public async Task Update_Weblinks() [TestMethod] public async Task Update_Weblinks_SkipWhitespace() { - var contract = new SongForEditContract(_song, ContentLanguagePreference.English); + var contract = new SongForEditForApiContract(_song, ContentLanguagePreference.English, _permissionContext); contract.WebLinks = new[] { - new WebLinkContract(" ", "VocaDB", WebLinkCategory.Reference, disabled: false) + new WebLinkForApiContract + { + Url = " ", + Description = "VocaDB", + Category = WebLinkCategory.Reference, + Disabled = false, + } }; contract = await _queries.UpdateBasicProperties(contract); @@ -968,7 +980,7 @@ public async Task Update_Weblinks_SkipWhitespace() public async Task Update_ReleaseEvent_ExistingEvent_Selected() { var contract = EditContract(); - contract.ReleaseEvent = new ReleaseEventContract(_releaseEvent, ContentLanguagePreference.English); + contract.ReleaseEvent = new ReleaseEventForApiContract(_releaseEvent, ContentLanguagePreference.English, ReleaseEventOptionalFields.None, thumbPersister: null); await _queries.UpdateBasicProperties(contract); @@ -982,7 +994,7 @@ public async Task Update_ReleaseEvent_ExistingEvent_Selected() public async Task Update_ReleaseEvent_ExistingEvent_MatchByName() { var contract = EditContract(); - contract.ReleaseEvent = new ReleaseEventContract { Name = _releaseEvent.DefaultName }; + contract.ReleaseEvent = new ReleaseEventForApiContract { Name = _releaseEvent.DefaultName }; await _queries.UpdateBasicProperties(contract); @@ -993,7 +1005,7 @@ public async Task Update_ReleaseEvent_ExistingEvent_MatchByName() public async Task Update_ReleaseEvent_NewEvent_Standalone() { var contract = EditContract(); - contract.ReleaseEvent = new ReleaseEventContract { Name = "Comiket 40" }; + contract.ReleaseEvent = new ReleaseEventForApiContract { Name = "Comiket 40" }; await _queries.UpdateBasicProperties(contract); @@ -1008,7 +1020,7 @@ public async Task Update_ReleaseEvent_NewEvent_SeriesEvent() { var series = _repository.Save(CreateEntry.EventSeries("Comiket")); var contract = EditContract(); - contract.ReleaseEvent = new ReleaseEventContract { Name = "Comiket 40" }; + contract.ReleaseEvent = new ReleaseEventForApiContract { Name = "Comiket 40" }; await _queries.UpdateBasicProperties(contract); diff --git a/VocaDbModel/DataContracts/ReleaseEvents/ReleaseEventForApiContract.cs b/VocaDbModel/DataContracts/ReleaseEvents/ReleaseEventForApiContract.cs index 603f2d2ff7..2f1e90b041 100644 --- a/VocaDbModel/DataContracts/ReleaseEvents/ReleaseEventForApiContract.cs +++ b/VocaDbModel/DataContracts/ReleaseEvents/ReleaseEventForApiContract.cs @@ -20,7 +20,13 @@ public class ReleaseEventForApiContract : IReleaseEvent, IEntryBase public ReleaseEventForApiContract() { } - public ReleaseEventForApiContract(ReleaseEvent rel, ContentLanguagePreference languagePreference, ReleaseEventOptionalFields fields, IAggregatedEntryImageUrlFactory thumbPersister) +#nullable enable + public ReleaseEventForApiContract( + ReleaseEvent rel, + ContentLanguagePreference languagePreference, + ReleaseEventOptionalFields fields, + IAggregatedEntryImageUrlFactory? thumbPersister + ) { Category = rel.Category; Date = rel.Date; @@ -54,7 +60,7 @@ public ReleaseEventForApiContract(ReleaseEvent rel, ContentLanguagePreference la Description = rel.Description; } - if (thumbPersister != null && fields.HasFlag(ReleaseEventOptionalFields.MainPicture)) + if (thumbPersister is not null && fields.HasFlag(ReleaseEventOptionalFields.MainPicture)) { MainPicture = EntryThumbForApiContract.Create(EntryThumb.Create(rel) ?? EntryThumb.Create(rel.Series), thumbPersister); } @@ -69,7 +75,7 @@ public ReleaseEventForApiContract(ReleaseEvent rel, ContentLanguagePreference la Series = new ReleaseEventSeriesContract(rel.Series, languagePreference); } - if (fields.HasFlag(ReleaseEventOptionalFields.SongList) && rel.SongList != null) + if (fields.HasFlag(ReleaseEventOptionalFields.SongList) && rel.SongList is not null) { SongList = new SongListBaseContract(rel.SongList); } @@ -79,7 +85,7 @@ public ReleaseEventForApiContract(ReleaseEvent rel, ContentLanguagePreference la Tags = rel.Tags.ActiveUsages.Select(t => new TagUsageForApiContract(t, languagePreference)).ToArray(); } - if (fields.HasFlag(ReleaseEventOptionalFields.Venue) && rel.Venue != null) + if (fields.HasFlag(ReleaseEventOptionalFields.Venue) && rel.Venue is not null) { Venue = new VenueForApiContract(rel.Venue, languagePreference, VenueOptionalFields.None); } @@ -89,6 +95,7 @@ public ReleaseEventForApiContract(ReleaseEvent rel, ContentLanguagePreference la WebLinks = rel.WebLinks.Select(w => new WebLinkForApiContract(w)).ToArray(); } } +#nullable disable /// /// Comma-separated list of all other names that aren't the display name. @@ -118,7 +125,7 @@ public ReleaseEventForApiContract(ReleaseEvent rel, ContentLanguagePreference la [DataMember] public DateTime? EndDate { get; init; } - public bool HasVenueOrVenueName => Venue != null || !string.IsNullOrEmpty(VenueName); + public bool HasVenueOrVenueName => Venue is not null || !string.IsNullOrEmpty(VenueName); [DataMember] public int Id { get; set; } diff --git a/VocaDbModel/DataContracts/Songs/SongForEditForApiContract.cs b/VocaDbModel/DataContracts/Songs/SongForEditForApiContract.cs new file mode 100644 index 0000000000..ada33a29cd --- /dev/null +++ b/VocaDbModel/DataContracts/Songs/SongForEditForApiContract.cs @@ -0,0 +1,176 @@ +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.ReleaseEvents; +using VocaDb.Model.Domain; +using VocaDb.Model.Domain.Globalization; +using VocaDb.Model.Domain.Security; +using VocaDb.Model.Domain.Songs; + +namespace VocaDb.Model.DataContracts.Songs; + +[DataContract(Namespace = Schemas.VocaDb)] +public sealed record SongForEditForApiContract +{ + /// + /// ID of the first album's release event. + /// Used for validation warnings. + /// + [DataMember] + public int? AlbumEventId { get; init; } + + [DataMember] + public DateTime? AlbumReleaseDate { get; init; } + + [DataMember] + public ArtistForSongContract[] Artists { get; set; } + + [DataMember] + public bool CanDelete { get; set; } + + [DataMember] + public ContentLanguageSelection DefaultNameLanguage { get; init; } + + [DataMember(EmitDefaultValue = false)] + public bool Deleted { get; init; } + + /// + /// Song is on one or more albums + /// + [DataMember] + public bool HasAlbums { get; init; } + + [DataMember] + public int Id { get; init; } + + [DataMember] + public int LengthSeconds { get; init; } + + [DataMember] + public LyricsForSongContract[] Lyrics { get; set; } + + [DataMember] + public int? MaxMilliBpm { get; init; } + + [DataMember] + public int? MinMilliBpm { get; init; } + + /// + /// Display name (primary name in selected language, or default language). + /// + [DataMember] + public string Name { get; init; } + + [DataMember] + public LocalizedStringWithIdContract[] Names { get; init; } + + [DataMember] + public EnglishTranslatedStringContract Notes { get; init; } + + [DataMember] + public SongForApiContract? OriginalVersion { get; init; } + + [DataMember] + public DateTime? PublishDate { get; init; } + + [DataMember(Name = "pvs")] + public PVContract[] PVs { get; set; } + + [DataMember] + public ReleaseEventForApiContract? ReleaseEvent { get; set; } + + [DataMember] + [JsonConverter(typeof(StringEnumConverter))] + public SongType SongType { get; init; } + + [DataMember] + [JsonConverter(typeof(StringEnumConverter))] + public EntryStatus Status { get; init; } + + // Required here for validation + [DataMember] + public int[] Tags { get; init; } + + [DataMember] + public string UpdateNotes { get; set; } + + [DataMember] + public WebLinkForApiContract[] WebLinks { get; set; } + + public SongForEditForApiContract() + { + Artists = Array.Empty(); + Lyrics = Array.Empty(); + Name = string.Empty; + Names = Array.Empty(); + Notes = new EnglishTranslatedStringContract(); + PVs = Array.Empty(); + Tags = Array.Empty(); + UpdateNotes = string.Empty; + WebLinks = Array.Empty(); + } + + public SongForEditForApiContract(Song song, ContentLanguagePreference languagePreference, IUserPermissionContext permissionContext) + { + var firstAlbum = song.Albums + .Where(a => a.Album.OriginalReleaseDate.IsFullDate) + .OrderBy(a => a.Album.OriginalReleaseDate) + .FirstOrDefault(); + + AlbumEventId = firstAlbum?.Album.OriginalReleaseEvent?.Id; + AlbumReleaseDate = song.FirstAlbumDate is not null + ? DateTime.SpecifyKind(song.FirstAlbumDate.Value, DateTimeKind.Utc) + : null; + Artists = song.Artists + .Select(a => new ArtistForSongContract(a, languagePreference)) + .OrderBy(a => a.Name) + .ToArray(); + CanDelete = EntryPermissionManager.CanDelete(permissionContext, song); + DefaultNameLanguage = song.TranslatedName.DefaultLanguage; + Deleted = song.Deleted; + HasAlbums = song.Albums.Any(); + Id = song.Id; + LengthSeconds = song.LengthSeconds; + Lyrics = song.Lyrics + .Select(l => new LyricsForSongContract(l)) + .ToArray(); + MaxMilliBpm = song.MaxMilliBpm; + MinMilliBpm = song.MinMilliBpm; + Name = song.Names.SortNames[languagePreference]; + Names = song.Names + .Select(n => new LocalizedStringWithIdContract(n)) + .ToArray(); + Notes = new EnglishTranslatedStringContract(song.Notes); + OriginalVersion = song.OriginalVersion is not null && !song.OriginalVersion.Deleted + ? new SongForApiContract( + song: song.OriginalVersion, + languagePreference: languagePreference, + fields: SongOptionalFields.None + ) + : null; + PublishDate = song.PublishDate.DateTime; + PVs = song.PVs + .Select(p => new PVContract(p)) + .ToArray(); + ReleaseEvent = song.ReleaseEvent is not null + ? new ReleaseEventForApiContract( + rel: song.ReleaseEvent, + languagePreference: languagePreference, + fields: ReleaseEventOptionalFields.None, + thumbPersister: null + ) + : null; + SongType = song.SongType; + Status = song.Status; + Tags = song.Tags.Tags + .Select(t => t.Id) + .ToArray(); + UpdateNotes = string.Empty; + WebLinks = song.WebLinks + .Select(w => new WebLinkForApiContract(w)) + .OrderBy(w => w.DescriptionOrUrl) + .ToArray(); + } +} diff --git a/VocaDbModel/DataContracts/UseCases/SongForEditContract.cs b/VocaDbModel/DataContracts/UseCases/SongForEditContract.cs index d846bb7cee..f1fcc2ab0e 100644 --- a/VocaDbModel/DataContracts/UseCases/SongForEditContract.cs +++ b/VocaDbModel/DataContracts/UseCases/SongForEditContract.cs @@ -10,6 +10,7 @@ namespace VocaDb.Model.DataContracts.UseCases { + [Obsolete] [DataContract(Namespace = Schemas.VocaDb)] public class SongForEditContract : SongContract { diff --git a/VocaDbModel/Database/Queries/SongQueries.cs b/VocaDbModel/Database/Queries/SongQueries.cs index 98f06f3f3c..31e745d1a1 100644 --- a/VocaDbModel/Database/Queries/SongQueries.cs +++ b/VocaDbModel/Database/Queries/SongQueries.cs @@ -700,10 +700,12 @@ public T GetSong(int id, Func fac) }); } - public SongForEditContract GetSongForEdit(int songId) +#nullable enable + public SongForEditForApiContract GetSongForEdit(int songId) { - return HandleQuery(session => new SongForEditContract(session.Load(songId), PermissionContext.LanguagePreference)); + return HandleQuery(session => new SongForEditForApiContract(session.Load(songId), PermissionContext.LanguagePreference, PermissionContext)); } +#nullable disable public T GetSongWithMergeRecord(int id, Func fac) { @@ -1285,7 +1287,7 @@ public async Task> UpdatePVs(IData } #nullable enable - public async Task UpdateBasicProperties(SongForEditContract properties) + public async Task UpdateBasicProperties(SongForEditForApiContract properties) { ParamIs.NotNull(() => properties); @@ -1414,7 +1416,7 @@ public async Task UpdateBasicProperties(SongForEditContract } } - return new SongForEditContract(song, PermissionContext.LanguagePreference); + return new SongForEditForApiContract(song, PermissionContext.LanguagePreference, PermissionContext); }); } #nullable disable diff --git a/VocaDbModel/Domain/Globalization/EnglishTranslatedString.cs b/VocaDbModel/Domain/Globalization/EnglishTranslatedString.cs index fa8c3e7690..9e1585756a 100644 --- a/VocaDbModel/Domain/Globalization/EnglishTranslatedString.cs +++ b/VocaDbModel/Domain/Globalization/EnglishTranslatedString.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System.Diagnostics.CodeAnalysis; using VocaDb.Model.DataContracts.Globalization; namespace VocaDb.Model.Domain.Globalization @@ -12,8 +11,8 @@ public class EnglishTranslatedString private string _english; private string _original; - public EnglishTranslatedString() : - this(string.Empty) + public EnglishTranslatedString() + : this(string.Empty) { } @@ -26,6 +25,7 @@ public EnglishTranslatedString(string original, string english = "") public virtual string English { get => _english; + [MemberNotNull(nameof(_english))] set { ParamIs.NotNull(() => value); @@ -45,6 +45,7 @@ public virtual string English public virtual string Original { get => _original; + [MemberNotNull(nameof(_original))] set { ParamIs.NotNull(() => value); @@ -52,13 +53,12 @@ public virtual string Original } } -#nullable enable public virtual bool CopyFrom(EnglishTranslatedStringContract contract) { ParamIs.NotNull(() => contract); var changed = false; - var newOriginal = contract.Original?.Trim(); + var newOriginal = contract.Original.Trim(); if (Original != newOriginal) { @@ -66,7 +66,7 @@ public virtual bool CopyFrom(EnglishTranslatedStringContract contract) changed = true; } - var newEnglish = contract.English?.Trim(); + var newEnglish = contract.English.Trim(); if (English != newEnglish) { @@ -76,7 +76,6 @@ public virtual bool CopyFrom(EnglishTranslatedStringContract contract) return changed; } -#nullable disable public virtual bool CopyIfEmpty(EnglishTranslatedString source) { diff --git a/VocaDbModel/Domain/Songs/Song.cs b/VocaDbModel/Domain/Songs/Song.cs index d352f31312..20d4e558d8 100644 --- a/VocaDbModel/Domain/Songs/Song.cs +++ b/VocaDbModel/Domain/Songs/Song.cs @@ -1,5 +1,6 @@ #nullable disable +using System.Diagnostics.CodeAnalysis; using System.Xml.Linq; using VocaDb.Model.DataContracts.Artists; using VocaDb.Model.DataContracts.PVs; @@ -56,7 +57,9 @@ public class Song : private IList _lists = new List(); private IList _lyrics = new List(); private NameManager _names = new(); +#nullable enable private EnglishTranslatedString _notes; +#nullable disable private PVManager _pvs = new(); private TagManager _tags = new(); private IList _userFavorites = new List(); @@ -353,15 +356,18 @@ public virtual NameManager Names INameManager IEntryWithNames.Names => Names; +#nullable enable public virtual EnglishTranslatedString Notes { get => _notes; + [MemberNotNull(nameof(_notes))] set { ParamIs.NotNull(() => value); _notes = value; } } +#nullable disable /// /// List of albums this song appears on. diff --git a/VocaDbWeb/Controllers/Api/SongApiController.cs b/VocaDbWeb/Controllers/Api/SongApiController.cs index 053631f7b5..8cd2ce52b0 100644 --- a/VocaDbWeb/Controllers/Api/SongApiController.cs +++ b/VocaDbWeb/Controllers/Api/SongApiController.cs @@ -18,12 +18,15 @@ using VocaDb.Model.Domain.PVs; using VocaDb.Model.Domain.Security; using VocaDb.Model.Domain.Songs; +using VocaDb.Model.Resources; using VocaDb.Model.Service; using VocaDb.Model.Service.QueryableExtensions; using VocaDb.Model.Service.Search; using VocaDb.Model.Service.Search.AlbumSearch; using VocaDb.Model.Service.Search.SongSearch; using VocaDb.Model.Service.VideoServices; +using VocaDb.Web.Code; +using VocaDb.Web.Code.Exceptions; using VocaDb.Web.Code.Security; using VocaDb.Web.Helpers; using VocaDb.Web.Models.Shared; @@ -146,10 +149,12 @@ public IEnumerable GetDerived( ) => _queries.GetDerived(id, fields, lang); +#nullable enable [HttpGet("{id:int}/for-edit")] [ApiExplorerSettings(IgnoreApi = true)] - public SongForEditContract GetForEdit(int id) => + public SongForEditForApiContract GetForEdit(int id) => _queries.GetSongForEdit(id); +#nullable disable /// /// Gets a song by Id. @@ -559,6 +564,72 @@ public SongDetailsForApiContract GetDetails(int id, int albumId = 0) [ApiExplorerSettings(IgnoreApi = true)] public EntryWithArchivedVersionsForApiContract GetSongWithArchivedVersions(int id) => _queries.GetSongWithArchivedVersionsForApi(id); + + [HttpPost("{id:int}")] + [Authorize] + [EnableCors(AuthenticationConstants.AuthenticatedCorsApiPolicy)] + [ValidateAntiForgeryToken] + [ApiExplorerSettings(IgnoreApi = true)] + public async Task> Edit( + [ModelBinder(BinderType = typeof(JsonModelBinder))] SongForEditForApiContract 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(SongForEditForApiContract contract) + { + if (contract is null) + throw new InvalidFormException("Model was null"); + + if (contract.Artists is null) + throw new InvalidFormException("ArtistLinks list was null"); // Shouldn't be null + + if (contract.Lyrics is null) + throw new InvalidFormException("Lyrics list was null"); // Shouldn't be null + + if (contract.Names is null) + throw new InvalidFormException("Names list was null"); // Shouldn't be null + + if (contract.PVs is null) + throw new InvalidFormException("PVs list was null"); // Shouldn't be null + + if (contract.WebLinks is null) + throw new InvalidFormException("WebLinks list was null"); // Shouldn't be 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 null || contract.Names.All(n => n is null || string.IsNullOrEmpty(n.Value))) + { + ModelState.AddModelError("Names", SongValidationErrors.UnspecifiedNames); + } + + if (contract.Lyrics is not null && contract.Lyrics.Any(n => string.IsNullOrEmpty(n.Value))) + { + ModelState.AddModelError("Lyrics", "Lyrics cannot be empty"); + } + + if (!ModelState.IsValid) + { + return ValidationProblem(ModelState); + } + + await _queries.UpdateBasicProperties(contract); + + return contract.Id; + } #nullable disable } } diff --git a/VocaDbWeb/Controllers/SongController.cs b/VocaDbWeb/Controllers/SongController.cs index aa9c413802..ca3005e5fb 100644 --- a/VocaDbWeb/Controllers/SongController.cs +++ b/VocaDbWeb/Controllers/SongController.cs @@ -13,7 +13,6 @@ using VocaDb.Model.Domain.Security; using VocaDb.Model.Domain.Songs; using VocaDb.Model.Helpers; -using VocaDb.Model.Resources; using VocaDb.Model.Service; using VocaDb.Model.Service.BrandableStrings; using VocaDb.Model.Service.ExtSites; @@ -23,7 +22,6 @@ using VocaDb.Model.Utils; using VocaDb.Model.Utils.Search; using VocaDb.Web.Code; -using VocaDb.Web.Code.Exceptions; using VocaDb.Web.Code.Feeds; using VocaDb.Web.Code.Markdown; using VocaDb.Web.Code.WebApi; @@ -225,58 +223,7 @@ public async Task Create(Create model) [Authorize] public ActionResult Edit(int id, int? albumId = null) { - CheckConcurrentEdit(EntryType.Song, id); - - var model = Service.GetSong(id, song => new SongEditViewModel(new SongContract(song, PermissionContext.LanguagePreference, false), - PermissionContext, EntryPermissionManager.CanDelete(PermissionContext, song), InstrumentalTagId, albumId: albumId)); - - return View(model); - } - - // - // POST: /Song/Edit/5 - [HttpPost] - [Authorize] - public async Task Edit(SongEditViewModel viewModel) - { - // Unable to continue if viewmodel is null because we need the ID at least - if (viewModel?.EditedSong == 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.EditedSong; - - // Note: name is allowed to be whitespace, but not empty. - if (model.Names == null || model.Names.All(n => n == null || string.IsNullOrEmpty(n.Value))) - { - ModelState.AddModelError("Names", SongValidationErrors.UnspecifiedNames); - } - - if (model.Lyrics != null && model.Lyrics.Any(n => string.IsNullOrEmpty(n.Value))) - { - ModelState.AddModelError("Lyrics", "Lyrics cannot be empty"); - } - - if (!ModelState.IsValid) - { - return View(Service.GetSong(model.Id, song => new SongEditViewModel(new SongContract(song, PermissionContext.LanguagePreference, false), - PermissionContext, EntryPermissionManager.CanDelete(PermissionContext, song), InstrumentalTagId, model))); - } - - await _queries.UpdateBasicProperties(model); - - return RedirectToAction("Details", new { id = model.Id, albumId = viewModel.AlbumId }); + return View("React/Index"); } [HttpPost] diff --git a/VocaDbWeb/Scripts/Bootstrap/Accordion.tsx b/VocaDbWeb/Scripts/Bootstrap/Accordion.tsx new file mode 100644 index 0000000000..87f08d5b1b --- /dev/null +++ b/VocaDbWeb/Scripts/Bootstrap/Accordion.tsx @@ -0,0 +1,98 @@ +// Code from: https://github.com/react-bootstrap/react-bootstrap/blob/f62da57493a63e40bd67b74f1414ac025c54d553/src/Accordion.tsx. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { useMemo } from 'react'; +import { useUncontrolled } from 'uncontrollable'; + +import AccordionBody from './AccordionBody'; +import AccordionButton from './AccordionButton'; +import AccordionCollapse from './AccordionCollapse'; +import AccordionContext, { + AccordionSelectCallback, + AccordionEventKey, +} from './AccordionContext'; +import AccordionHeader from './AccordionHeader'; +import AccordionItem from './AccordionItem'; +import { useBootstrapPrefix } from './ThemeProvider'; +import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers'; + +export interface AccordionProps + extends Omit, 'onSelect'>, + BsPrefixProps { + activeKey?: AccordionEventKey; + defaultActiveKey?: AccordionEventKey; + onSelect?: AccordionSelectCallback; + flush?: boolean; + alwaysOpen?: boolean; +} + +const propTypes = { + /** Set a custom element for this component */ + as: PropTypes.elementType, + + /** @default 'accordion' */ + bsPrefix: PropTypes.string, + + /** The current active key that corresponds to the currently expanded card */ + activeKey: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), + + /** The default active key that is expanded on start */ + defaultActiveKey: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), + + /** Renders accordion edge-to-edge with its parent container */ + flush: PropTypes.bool, + + /** Allow accordion items to stay open when another item is opened */ + alwaysOpen: PropTypes.bool, +}; + +const Accordion: BsPrefixRefForwardingComponent< + 'div', + AccordionProps +> = React.forwardRef((props, ref) => { + const { + // Need to define the default "as" during prop destructuring to be compatible with styled-components github.com/react-bootstrap/react-bootstrap/issues/3595 + as: Component = 'div', + activeKey, + bsPrefix, + className, + onSelect, + flush, + alwaysOpen, + ...controlledProps + } = useUncontrolled(props, { + activeKey: 'onSelect', + }); + + const prefix = useBootstrapPrefix(bsPrefix, 'accordion'); + const contextValue = useMemo( + () => ({ + activeEventKey: activeKey, + onSelect, + alwaysOpen, + }), + [activeKey, onSelect, alwaysOpen], + ); + + return ( + + + + ); +}); + +Accordion.displayName = 'Accordion'; +Accordion.propTypes = propTypes; + +export default Object.assign(Accordion, { + Button: AccordionButton, + Collapse: AccordionCollapse, + Item: AccordionItem, + Header: AccordionHeader, + Body: AccordionBody, +}); diff --git a/VocaDbWeb/Scripts/Bootstrap/AccordionBody.tsx b/VocaDbWeb/Scripts/Bootstrap/AccordionBody.tsx new file mode 100644 index 0000000000..96dd7cf0ca --- /dev/null +++ b/VocaDbWeb/Scripts/Bootstrap/AccordionBody.tsx @@ -0,0 +1,56 @@ +// Code from: https://github.com/react-bootstrap/react-bootstrap/blob/f62da57493a63e40bd67b74f1414ac025c54d553/src/AccordionBody.tsx. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { useContext } from 'react'; + +import AccordionCollapse from './AccordionCollapse'; +import AccordionItemContext from './AccordionItemContext'; +import { useBootstrapPrefix } from './ThemeProvider'; +import { BsPrefixRefForwardingComponent, BsPrefixProps } from './helpers'; + +export interface AccordionBodyProps + extends BsPrefixProps, + React.HTMLAttributes {} + +const propTypes = { + /** Set a custom element for this component */ + as: PropTypes.elementType, + + /** @default 'accordion-body' */ + bsPrefix: PropTypes.string, +}; + +const AccordionBody: BsPrefixRefForwardingComponent< + 'div', + AccordionBodyProps +> = React.forwardRef( + ( + { + // Need to define the default "as" during prop destructuring to be compatible with styled-components github.com/react-bootstrap/react-bootstrap/issues/3595 + as: Component = 'div', + bsPrefix, + className, + ...props + }, + ref, + ) => { + bsPrefix = useBootstrapPrefix(bsPrefix, 'accordion-body'); + const { eventKey } = useContext(AccordionItemContext); + + return ( + + + + ); + }, +); + +AccordionBody.propTypes = propTypes; +AccordionBody.displayName = 'AccordionBody'; + +export default AccordionBody; diff --git a/VocaDbWeb/Scripts/Bootstrap/AccordionButton.tsx b/VocaDbWeb/Scripts/Bootstrap/AccordionButton.tsx new file mode 100644 index 0000000000..87264276bb --- /dev/null +++ b/VocaDbWeb/Scripts/Bootstrap/AccordionButton.tsx @@ -0,0 +1,107 @@ +// Code from: https://github.com/react-bootstrap/react-bootstrap/blob/f62da57493a63e40bd67b74f1414ac025c54d553/src/AccordionButton.tsx. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { useContext } from 'react'; + +import AccordionContext, { + isAccordionItemSelected, + AccordionEventKey, +} from './AccordionContext'; +import AccordionItemContext from './AccordionItemContext'; +import SafeAnchor from './SafeAnchor'; +import { useBootstrapPrefix } from './ThemeProvider'; +import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers'; + +type EventHandler = React.EventHandler; + +export interface AccordionButtonProps + extends React.ButtonHTMLAttributes, + BsPrefixProps {} + +const propTypes = { + /** Set a custom element for this component */ + as: PropTypes.elementType, + + /** @default 'accordion-button' */ + bsPrefix: PropTypes.string, + + /** A callback function for when this component is clicked */ + onClick: PropTypes.func, +}; + +export function useAccordionButton( + eventKey: string, + onClick?: EventHandler, +): EventHandler { + const { activeEventKey, onSelect, alwaysOpen } = useContext(AccordionContext); + + return (e): void => { + /* + Compare the event key in context with the given event key. + If they are the same, then collapse the component. + */ + let eventKeyPassed: AccordionEventKey = + eventKey === activeEventKey ? null : eventKey; + if (alwaysOpen) { + if (Array.isArray(activeEventKey)) { + if (activeEventKey.includes(eventKey)) { + eventKeyPassed = activeEventKey.filter((k) => k !== eventKey); + } else { + eventKeyPassed = [...activeEventKey, eventKey]; + } + } else { + // activeEventKey is undefined. + eventKeyPassed = [eventKey]; + } + } + + onSelect?.(eventKeyPassed, e); + onClick?.(e); + }; +} + +const AccordionButton: BsPrefixRefForwardingComponent< + 'div', + AccordionButtonProps +> = React.forwardRef( + ( + { + // Need to define the default "as" during prop destructuring to be compatible with styled-components github.com/react-bootstrap/react-bootstrap/issues/3595 + as: Component = SafeAnchor, + bsPrefix, + className, + onClick, + ...props + }, + ref, + ) => { + bsPrefix = useBootstrapPrefix(bsPrefix, 'accordion-toggle'); + const { eventKey } = useContext(AccordionItemContext); + const accordionOnClick = useAccordionButton(eventKey, onClick); + const { activeEventKey } = useContext(AccordionContext); + + if (Component === 'button') { + props.type = 'button'; + } + + return ( + + ); + }, +); + +AccordionButton.propTypes = propTypes; +AccordionButton.displayName = 'AccordionButton'; + +export default AccordionButton; diff --git a/VocaDbWeb/Scripts/Bootstrap/AccordionCollapse.tsx b/VocaDbWeb/Scripts/Bootstrap/AccordionCollapse.tsx new file mode 100644 index 0000000000..a55b344596 --- /dev/null +++ b/VocaDbWeb/Scripts/Bootstrap/AccordionCollapse.tsx @@ -0,0 +1,64 @@ +// Code from: https://github.com/react-bootstrap/react-bootstrap/blob/f62da57493a63e40bd67b74f1414ac025c54d553/src/AccordionCollapse.tsx. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { useContext } from 'react'; +import { Transition } from 'react-transition-group'; + +import AccordionContext, { isAccordionItemSelected } from './AccordionContext'; +import Collapse, { CollapseProps } from './Collapse'; +import { useBootstrapPrefix } from './ThemeProvider'; +import { BsPrefixRefForwardingComponent, BsPrefixProps } from './helpers'; + +export interface AccordionCollapseProps extends BsPrefixProps, CollapseProps { + eventKey: string; +} + +const propTypes = { + /** Set a custom element for this component */ + as: PropTypes.elementType, + + /** + * A key that corresponds to the toggler that triggers this collapse's expand or collapse. + */ + eventKey: PropTypes.string.isRequired, + + /** Children prop should only contain a single child, and is enforced as such */ + children: PropTypes.element.isRequired, +}; + +const AccordionCollapse: BsPrefixRefForwardingComponent< + 'div', + AccordionCollapseProps +> = React.forwardRef, AccordionCollapseProps>( + ( + { + as: Component = 'div', + bsPrefix, + className, + children, + eventKey, + ...props + }, + ref, + ) => { + const { activeEventKey } = useContext(AccordionContext); + bsPrefix = useBootstrapPrefix(bsPrefix, 'accordion-collapse'); + + return ( + + {React.Children.only(children)} + + ); + }, +) as any; + +AccordionCollapse.propTypes = propTypes; +AccordionCollapse.displayName = 'AccordionCollapse'; + +export default AccordionCollapse; diff --git a/VocaDbWeb/Scripts/Bootstrap/AccordionContext.ts b/VocaDbWeb/Scripts/Bootstrap/AccordionContext.ts new file mode 100644 index 0000000000..3357e67cee --- /dev/null +++ b/VocaDbWeb/Scripts/Bootstrap/AccordionContext.ts @@ -0,0 +1,29 @@ +// Code from: https://github.com/react-bootstrap/react-bootstrap/blob/f62da57493a63e40bd67b74f1414ac025c54d553/src/AccordionContext.ts. +import * as React from 'react'; + +export type AccordionEventKey = string | string[] | null | undefined; + +export declare type AccordionSelectCallback = ( + eventKey: AccordionEventKey, + e: React.SyntheticEvent, +) => void; + +export interface AccordionContextValue { + activeEventKey?: AccordionEventKey; + onSelect?: AccordionSelectCallback; + alwaysOpen?: boolean; +} + +export function isAccordionItemSelected( + activeEventKey: AccordionEventKey, + eventKey: string, +): boolean { + return Array.isArray(activeEventKey) + ? activeEventKey.includes(eventKey) + : activeEventKey === eventKey; +} + +const context = React.createContext({}); +context.displayName = 'AccordionContext'; + +export default context; diff --git a/VocaDbWeb/Scripts/Bootstrap/AccordionHeader.tsx b/VocaDbWeb/Scripts/Bootstrap/AccordionHeader.tsx new file mode 100644 index 0000000000..5ff4b0ad98 --- /dev/null +++ b/VocaDbWeb/Scripts/Bootstrap/AccordionHeader.tsx @@ -0,0 +1,58 @@ +// Code from: https://github.com/react-bootstrap/react-bootstrap/blob/f62da57493a63e40bd67b74f1414ac025c54d553/src/AccordionHeader.tsx. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import * as React from 'react'; + +import AccordionButton from './AccordionButton'; +import { useBootstrapPrefix } from './ThemeProvider'; +import { BsPrefixRefForwardingComponent, BsPrefixProps } from './helpers'; + +export interface AccordionHeaderProps + extends BsPrefixProps, + React.HTMLAttributes {} + +const propTypes = { + /** Set a custom element for this component */ + as: PropTypes.elementType, + + /** @default 'accordion-header' */ + bsPrefix: PropTypes.string, + + /** Click handler for the `AccordionButton` element */ + onClick: PropTypes.func, +}; + +const AccordionHeader: BsPrefixRefForwardingComponent< + 'div', + AccordionHeaderProps +> = React.forwardRef( + ( + { + // Need to define the default "as" during prop destructuring to be compatible with styled-components github.com/react-bootstrap/react-bootstrap/issues/3595 + as: Component = 'div', + bsPrefix, + className, + children, + onClick, + ...props + }, + ref, + ) => { + bsPrefix = useBootstrapPrefix(bsPrefix, 'accordion-heading'); + + return ( + + {children} + + ); + }, +); + +AccordionHeader.propTypes = propTypes; +AccordionHeader.displayName = 'AccordionHeader'; + +export default AccordionHeader; diff --git a/VocaDbWeb/Scripts/Bootstrap/AccordionItem.tsx b/VocaDbWeb/Scripts/Bootstrap/AccordionItem.tsx new file mode 100644 index 0000000000..d0fef50823 --- /dev/null +++ b/VocaDbWeb/Scripts/Bootstrap/AccordionItem.tsx @@ -0,0 +1,71 @@ +// Code from: https://github.com/react-bootstrap/react-bootstrap/blob/f62da57493a63e40bd67b74f1414ac025c54d553/src/AccordionItem.tsx. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { useMemo } from 'react'; + +import AccordionItemContext, { + AccordionItemContextValue, +} from './AccordionItemContext'; +import { useBootstrapPrefix } from './ThemeProvider'; +import { BsPrefixRefForwardingComponent, BsPrefixProps } from './helpers'; + +export interface AccordionItemProps + extends BsPrefixProps, + React.HTMLAttributes { + eventKey: string; +} + +const propTypes = { + /** Set a custom element for this component */ + as: PropTypes.elementType, + + /** @default 'accordion-item' */ + bsPrefix: PropTypes.string, + + /** + * A unique key used to control this item's collapse/expand. + * @required + */ + eventKey: PropTypes.string.isRequired, +}; + +const AccordionItem: BsPrefixRefForwardingComponent< + 'div', + AccordionItemProps +> = React.forwardRef( + ( + { + // Need to define the default "as" during prop destructuring to be compatible with styled-components github.com/react-bootstrap/react-bootstrap/issues/3595 + as: Component = 'div', + bsPrefix, + className, + eventKey, + ...props + }, + ref, + ) => { + bsPrefix = useBootstrapPrefix(bsPrefix, 'accordion-group'); + const contextValue = useMemo( + () => ({ + eventKey, + }), + [eventKey], + ); + + return ( + + + + ); + }, +); + +AccordionItem.propTypes = propTypes; +AccordionItem.displayName = 'AccordionItem'; + +export default AccordionItem; diff --git a/VocaDbWeb/Scripts/Bootstrap/AccordionItemContext.ts b/VocaDbWeb/Scripts/Bootstrap/AccordionItemContext.ts new file mode 100644 index 0000000000..3b7f795cda --- /dev/null +++ b/VocaDbWeb/Scripts/Bootstrap/AccordionItemContext.ts @@ -0,0 +1,13 @@ +// Code from: https://github.com/react-bootstrap/react-bootstrap/blob/f62da57493a63e40bd67b74f1414ac025c54d553/src/AccordionItemContext.ts. +import * as React from 'react'; + +export interface AccordionItemContextValue { + eventKey: string; +} + +const context = React.createContext({ + eventKey: '', +}); +context.displayName = 'AccordionItemContext'; + +export default context; diff --git a/VocaDbWeb/Scripts/Components/Shared/Partials/Knockout/DropdownList.tsx b/VocaDbWeb/Scripts/Components/Shared/Partials/Knockout/DropdownList.tsx index 192eb86b05..3028e69c21 100644 --- a/VocaDbWeb/Scripts/Components/Shared/Partials/Knockout/DropdownList.tsx +++ b/VocaDbWeb/Scripts/Components/Shared/Partials/Knockout/DropdownList.tsx @@ -3,6 +3,7 @@ import EntryStatus from '@Models/EntryStatus'; import EntryType from '@Models/EntryType'; import EventCategory from '@Models/Events/EventCategory'; import ContentLanguageSelection from '@Models/Globalization/ContentLanguageSelection'; +import PVType from '@Models/PVs/PVType'; import SongListFeaturedCategory from '@Models/SongLists/SongListFeaturedCategory'; import UserGroup from '@Models/Users/UserGroup'; import React from 'react'; @@ -215,3 +216,37 @@ export const AlbumTypeDropdownList = React.memo( ); }, ); + +export const SongTypeDropdownList = React.memo( + (props: DropdownListProps): React.ReactElement => { + const { t } = useTranslation(['VocaDb.Model.Resources.Songs']); + + return ( + + ); + }, +); + +export const PVTypeDescriptionsDropdownList = React.memo( + (props: DropdownListProps): React.ReactElement => { + const { t } = useTranslation(['Resources']); + + return ( + + ); + }, +); diff --git a/VocaDbWeb/Scripts/Components/Shared/Partials/Song/SongLink.tsx b/VocaDbWeb/Scripts/Components/Shared/Partials/Song/SongLink.tsx index 6512a5a471..ab5468541f 100644 --- a/VocaDbWeb/Scripts/Components/Shared/Partials/Song/SongLink.tsx +++ b/VocaDbWeb/Scripts/Components/Shared/Partials/Song/SongLink.tsx @@ -10,6 +10,7 @@ interface SongLinkProps { albumId?: number; tooltip?: boolean; toolTipDomain?: string; + target?: string; } const SongLink = React.memo( @@ -18,6 +19,7 @@ const SongLink = React.memo( albumId, tooltip = false, toolTipDomain, + target, }: SongLinkProps): React.ReactElement => { return tooltip ? ( {song.name} @@ -37,6 +40,7 @@ const SongLink = React.memo( albumId: albumId, })}`} title={song.additionalNames} + target={target} > {song.name} diff --git a/VocaDbWeb/Scripts/Components/Song/Partials/ArtistForSongEdit.tsx b/VocaDbWeb/Scripts/Components/Song/Partials/ArtistForSongEdit.tsx new file mode 100644 index 0000000000..9a4deb33bf --- /dev/null +++ b/VocaDbWeb/Scripts/Components/Song/Partials/ArtistForSongEdit.tsx @@ -0,0 +1,107 @@ +import SafeAnchor from '@Bootstrap/SafeAnchor'; +import ArtistRoles from '@Models/Artists/ArtistRoles'; +import ArtistForAlbumEditStore from '@Stores/ArtistForAlbumEditStore'; +import SongEditStore from '@Stores/Song/SongEditStore'; +import { runInAction } from 'mobx'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import ArtistLink from '../../Shared/Partials/Artist/ArtistLink'; + +interface ArtistForSongEditProps { + songEditStore: SongEditStore; + artistForAlbumEditStore: ArtistForAlbumEditStore; +} + +const ArtistForSongEdit = observer( + ({ + songEditStore, + artistForAlbumEditStore, + }: ArtistForSongEditProps): React.ReactElement => { + const { t } = useTranslation(['ViewRes', 'ViewRes.Album']); + + return ( + + + {artistForAlbumEditStore.artist ? ( +
+ +
+ + {artistForAlbumEditStore.artist.additionalNames} + +
+ ) : ( +
+ {artistForAlbumEditStore.name} +
+ )} + {vdb.values.allowCustomArtistName && ( + + songEditStore.customizeName(artistForAlbumEditStore) + } + > + {t('ViewRes.Album:ArtistForAlbumEditRow.Customize')} + + )} + + + + + + +
+ {artistForAlbumEditStore.rolesArray + .filter((role) => role !== ArtistRoles[ArtistRoles.Default]) + .map((role) => ( +
+ {t(`Resources:ArtistRoleNames.${role}`)} +
+
+ ))} +
+ + {artistForAlbumEditStore.isCustomizable && ( + + songEditStore.editArtistRoles(artistForAlbumEditStore) + } + > + {t('ViewRes.Album:ArtistForAlbumEditRow.Customize')} + + )} + + + { + songEditStore.removeArtist(artistForAlbumEditStore); + }} + href="#" + className="textLink removeLink" + > + {t('ViewRes:Shared.Remove')} + + + + ); + }, +); + +export default ArtistForSongEdit; diff --git a/VocaDbWeb/Scripts/Components/Song/Partials/LyricsForSongEdit.tsx b/VocaDbWeb/Scripts/Components/Song/Partials/LyricsForSongEdit.tsx new file mode 100644 index 0000000000..b3dc7b8519 --- /dev/null +++ b/VocaDbWeb/Scripts/Components/Song/Partials/LyricsForSongEdit.tsx @@ -0,0 +1,201 @@ +import Accordion from '@Bootstrap/Accordion'; +import SafeAnchor from '@Bootstrap/SafeAnchor'; +import LyricsForSongListEditStore, { + LyricsForSongEditStore, +} from '@Stores/Song/LyricsForSongListEditStore'; +import { runInAction } from 'mobx'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { UserLanguageCultureDropdownList } from '../../Shared/Partials/Knockout/DropdownList'; +import HelpLabel from '../../Shared/Partials/Shared/HelpLabel'; +import { userLanguageCultures } from '../../userLanguageCultures'; + +interface LyricsForSongEditProps { + lyricsForSongListEditStore: LyricsForSongListEditStore; + lyricsForSongEditStore: LyricsForSongEditStore; + eventKey: string; +} + +const LyricsForSongEdit = observer( + ({ + lyricsForSongListEditStore, + lyricsForSongEditStore, + eventKey, + }: LyricsForSongEditProps): React.ReactElement => { + const { t } = useTranslation([ + 'ViewRes', + 'ViewRes.Song', + 'VocaDb.Web.Resources.Domain.Globalization', + ]); + + return ( + + + {' '} + {lyricsForSongEditStore.translationType} + {lyricsForSongEditStore.showLanguageSelection && ( + <> + {' '} + + ( + { + userLanguageCultures[lyricsForSongEditStore.cultureCode] + ? `${ + userLanguageCultures[lyricsForSongEditStore.cultureCode] + .nativeName + } (${ + userLanguageCultures[lyricsForSongEditStore.cultureCode] + .englishName + })` + : 'Other/Unknown' /* TODO: localize */ + } + ) + + + )} + {(lyricsForSongEditStore.source || lyricsForSongEditStore.url) && ( + <> + {' '} + + from{' '} + {lyricsForSongEditStore.source || lyricsForSongEditStore.url} + + + )} + + +
+
+ {lyricsForSongEditStore.showLanguageSelection && ( +

+ {' '} + + runInAction(() => { + lyricsForSongEditStore.cultureCode = e.target.value; + }) + } + /> +

+ )} +
+ {' '} +
+ + + + + runInAction(() => { + lyricsForSongEditStore.source = e.target.value; + }) + } + className="input-large" + size={45} + maxLength={255} + placeholder="Label" /* TODO: localize */ + /> +
{' '} +
+ + + + + runInAction(() => { + lyricsForSongEditStore.url = e.target.value; + }) + } + className="input-xlarge" + size={45} + maxLength={500} + placeholder="URL" /* TODO: localize */ + /> +
+
+
+ +