diff --git a/Tests/Web/Controllers/DataAccess/SongListQueriesTests.cs b/Tests/Web/Controllers/DataAccess/SongListQueriesTests.cs index bb473e3350..587d6242a5 100644 --- a/Tests/Web/Controllers/DataAccess/SongListQueriesTests.cs +++ b/Tests/Web/Controllers/DataAccess/SongListQueriesTests.cs @@ -3,6 +3,7 @@ using System.Net.Mime; using VocaDb.Model.Database.Queries; using VocaDb.Model.DataContracts; +using VocaDb.Model.DataContracts.SongLists; using VocaDb.Model.DataContracts.Songs; using VocaDb.Model.DataContracts.Users; using VocaDb.Model.Domain; @@ -28,7 +29,7 @@ public class SongListQueriesTests private InMemoryImagePersister _imagePersister; private FakePermissionContext _permissionContext; private FakeSongListRepository _repository; - private SongListForEditContract _songListContract; + private SongListForEditForApiContract _songListContract; private SongListQueries _queries; private Song _song1; private Song _song2; @@ -60,7 +61,7 @@ public void SetUp() _repository.Add(_userWithSongList); _repository.Add(_song1, _song2); - _songListContract = new SongListForEditContract + _songListContract = new SongListForEditForApiContract { Name = "Mikunopolis Setlist", Description = "MIKUNOPOLIS in LOS ANGELES - Hatsune Miku US debut concert held at Nokia Theatre for Anime Expo 2011 on 2nd July 2011.", diff --git a/VocaDbModel/DataContracts/SongLists/SongListForEditForApiContract.cs b/VocaDbModel/DataContracts/SongLists/SongListForEditForApiContract.cs new file mode 100644 index 0000000000..5c2c899990 --- /dev/null +++ b/VocaDbModel/DataContracts/SongLists/SongListForEditForApiContract.cs @@ -0,0 +1,76 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Newtonsoft.Json.Converters; +using VocaDb.Model.DataContracts.Songs; +using VocaDb.Model.Domain; +using VocaDb.Model.Domain.Images; +using VocaDb.Model.Domain.Security; +using VocaDb.Model.Domain.Songs; + +namespace VocaDb.Model.DataContracts.SongLists; + +[DataContract(Namespace = Schemas.VocaDb)] +public sealed record SongListForEditForApiContract +{ + [DataMember] + public bool Deleted { get; init; } + + [DataMember] + public string Description { get; init; } + + [DataMember] + public DateTime? EventDate { get; init; } + + [DataMember] + [JsonConverter(typeof(StringEnumConverter))] + public SongListFeaturedCategory FeaturedCategory { get; init; } + + [DataMember] + public int Id { get; set; } + + [DataMember(EmitDefaultValue = false)] + public EntryThumbForApiContract? MainPicture { get; init; } + + [DataMember] + public string Name { get; init; } + + [DataMember] + public SongInListEditContract[] SongLinks { get; set; } + + [DataMember] + public EntryStatus Status { get; init; } + + [DataMember] + public string UpdateNotes { get; init; } + + public SongListForEditForApiContract() + { + Description = string.Empty; + Name = string.Empty; + SongLinks = Array.Empty(); + UpdateNotes = string.Empty; + } + + public SongListForEditForApiContract( + SongList songList, + IUserPermissionContext permissionContext, + IAggregatedEntryImageUrlFactory imagePersister + ) + { + Deleted = songList.Deleted; + Description = songList.Description; + EventDate = songList.EventDate; + FeaturedCategory = songList.FeaturedCategory; + Id = songList.Id; + MainPicture = songList.Thumb is not null + ? new EntryThumbForApiContract(songList.Thumb, imagePersister) + : null; + Name = songList.Name; + SongLinks = songList.SongLinks + .OrderBy(s => s.Order) + .Select(s => new SongInListEditContract(s, permissionContext.LanguagePreference)) + .ToArray(); + Status = songList.Status; + UpdateNotes = string.Empty; + } +} diff --git a/VocaDbModel/DataContracts/Songs/SongListForEditContract.cs b/VocaDbModel/DataContracts/Songs/SongListForEditContract.cs index c1939e3d8f..ecda104527 100644 --- a/VocaDbModel/DataContracts/Songs/SongListForEditContract.cs +++ b/VocaDbModel/DataContracts/Songs/SongListForEditContract.cs @@ -6,6 +6,7 @@ namespace VocaDb.Model.DataContracts.Songs { + [Obsolete] [DataContract(Namespace = Schemas.VocaDb)] public class SongListForEditContract : SongListContract { diff --git a/VocaDbModel/Database/Queries/SongListQueries.cs b/VocaDbModel/Database/Queries/SongListQueries.cs index 3de83da26b..7f4f713b14 100644 --- a/VocaDbModel/Database/Queries/SongListQueries.cs +++ b/VocaDbModel/Database/Queries/SongListQueries.cs @@ -59,8 +59,11 @@ private PartialImportedSongs FindSongs(PartialImportedSongs songs) }); } - private PartialFindResult GetSongsInList(IDatabaseContext session, SongInListQueryParams queryParams, - Func fac) + private PartialFindResult GetSongsInList( + IDatabaseContext session, + SongInListQueryParams queryParams, + Func fac + ) { var q = session.OfType().Query() .Where(a => !a.Song.Deleted && a.List.Id == queryParams.ListId) @@ -80,7 +83,7 @@ private PartialFindResult GetSongsInList(IDatabaseContext sessio return new PartialFindResult(contracts, totalCount); } - private SongList CreateSongList(IDatabaseContext ctx, SongListForEditContract contract, UploadedFileContract uploadedFile) + private SongList CreateSongList(IDatabaseContext ctx, SongListForEditForApiContract contract, UploadedFileContract uploadedFile) { var user = GetLoggedUser(ctx); var newList = new SongList(contract.Name, user); @@ -123,8 +126,14 @@ private void SetThumb(SongList list, UploadedFileContract? uploadedFile) } #nullable disable - public SongListQueries(ISongListRepository repository, IUserPermissionContext permissionContext, IEntryLinkFactory entryLinkFactory, - IEntryThumbPersister imagePersister, IAggregatedEntryImageUrlFactory thumbStore, IUserIconFactory userIconFactory) + public SongListQueries( + ISongListRepository repository, + IUserPermissionContext permissionContext, + IEntryLinkFactory entryLinkFactory, + IEntryThumbPersister imagePersister, + IAggregatedEntryImageUrlFactory thumbStore, + IUserIconFactory userIconFactory + ) : base(repository, permissionContext) { _entryLinkFactory = entryLinkFactory; @@ -220,7 +229,13 @@ public SongListForApiContract GetDetails(int listId) { return _repository.HandleQuery(ctx => { - return new SongListForApiContract(ctx.Load(listId), LanguagePreference, _userIconFactory, _thumbStore, SongListOptionalFields.Description | SongListOptionalFields.Events | SongListOptionalFields.MainPicture | SongListOptionalFields.Tags) + return new SongListForApiContract( + list: ctx.Load(listId), + languagePreference: LanguagePreference, + userIconFactory: _userIconFactory, + imagePersister: _thumbStore, + fields: SongListOptionalFields.Description | SongListOptionalFields.Events | SongListOptionalFields.MainPicture | SongListOptionalFields.Tags + ) { LatestComments = Comments(ctx).GetList(listId, 3) }; @@ -242,9 +257,9 @@ public SongListContract GetSongList(int listId) return _repository.HandleQuery(session => new SongListContract(session.Load(listId), PermissionContext)); } - public SongListForEditContract GetSongListForEdit(int listId) + public SongListForEditForApiContract GetSongListForEdit(int listId) { - return _repository.HandleQuery(session => new SongListForEditContract(session.Load(listId), PermissionContext)); + return _repository.HandleQuery(session => new SongListForEditForApiContract(session.Load(listId), PermissionContext, _thumbStore)); } [Obsolete] @@ -290,7 +305,7 @@ public async Task ImportSongs(string url, string pageToken } #nullable enable - public int UpdateSongList(SongListForEditContract contract, UploadedFileContract? uploadedFile) + public int UpdateSongList(SongListForEditForApiContract contract, UploadedFileContract? uploadedFile) { ParamIs.NotNull(() => contract); @@ -375,12 +390,15 @@ public int UpdateSongList(SongListForEditContract contract, UploadedFileContract } #nullable disable - public void DeleteComment(int commentId) => HandleTransaction(ctx => Comments(ctx).Delete(commentId)); + public void DeleteComment(int commentId) => + HandleTransaction(ctx => Comments(ctx).Delete(commentId)); - public IEnumerable GetFeaturedListNames(string query = "", + public IEnumerable GetFeaturedListNames( + string query = "", NameMatchMode nameMatchMode = NameMatchMode.Auto, SongListFeaturedCategory? featuredCategory = null, - int maxResults = 10) + int maxResults = 10 + ) { var textQuery = SearchTextQuery.Create(query, nameMatchMode); @@ -397,9 +415,11 @@ public IEnumerable GetFeaturedListNames(string query = "", }); } - public void PostEditComment(int commentId, CommentForApiContract contract) => HandleTransaction(ctx => Comments(ctx).Update(commentId, contract)); + public void PostEditComment(int commentId, CommentForApiContract contract) => + HandleTransaction(ctx => Comments(ctx).Update(commentId, contract)); - public string GetTagString(int id, string formatString) => HandleQuery(ctx => new SongListFormatter(_entryLinkFactory).ApplyFormat(ctx.Load(id), formatString, PermissionContext.LanguagePreference, true)); + public string GetTagString(int id, string formatString) => + HandleQuery(ctx => new SongListFormatter(_entryLinkFactory).ApplyFormat(ctx.Load(id), formatString, PermissionContext.LanguagePreference, true)); #nullable enable public SongListBaseContract GetOne(int id) diff --git a/VocaDbWeb/Controllers/Api/SongListApiController.cs b/VocaDbWeb/Controllers/Api/SongListApiController.cs index 44282146f2..68696eedd1 100644 --- a/VocaDbWeb/Controllers/Api/SongListApiController.cs +++ b/VocaDbWeb/Controllers/Api/SongListApiController.cs @@ -21,6 +21,7 @@ using VocaDb.Model.Service.Search; using VocaDb.Model.Service.Search.SongSearch; using VocaDb.Model.Service.SongImport; +using VocaDb.Web.Code; using VocaDb.Web.Code.Security; using VocaDb.Web.Models.Shared; using ApiController = Microsoft.AspNetCore.Mvc.ControllerBase; @@ -95,7 +96,7 @@ public void Delete(int id, string notes = "", bool hardDelete = false) [HttpGet("{id:int}/for-edit")] [ApiExplorerSettings(IgnoreApi = true)] - public SongListForEditContract GetForEdit(int id) => _queries.GetSongListForEdit(id); + public SongListForEditForApiContract GetForEdit(int id) => _queries.GetSongListForEdit(id); #nullable enable /// @@ -213,7 +214,8 @@ public PartialFindResult GetSongs( AdvancedFilters = advancedFilters?.Select(advancedFilter => advancedFilter.ToAdvancedSearchFilter()).ToArray(), SongTypes = types, }, - songInList => new SongInListForApiContract(songInList, lang, fields)); + songInList => new SongInListForApiContract(songInList, lang, fields) + ); } #nullable disable @@ -252,12 +254,12 @@ public async Task> GetImportSongs(string url, /// ID of the created list. [HttpPost("")] [Authorize] - public ActionResult Post(SongListForEditContract list) + public ActionResult Post(SongListForEditForApiContract list) { if (list == null) return BadRequest(); - return _queries.UpdateSongList(list, null); + return _queries.UpdateSongList(list, uploadedFile: null); } /// @@ -296,6 +298,38 @@ public EntryWithArchivedVersionsForApiContract GetSongLi [HttpGet("{id:int}")] [ApiExplorerSettings(IgnoreApi = true)] public SongListBaseContract GetOne(int id) => _queries.GetOne(id); + + [HttpPost("{id:int}")] + [Authorize] + [EnableCors(AuthenticationConstants.AuthenticatedCorsApiPolicy)] + [ValidateAntiForgeryToken] + [ApiExplorerSettings(IgnoreApi = true)] + public ActionResult Edit( + [ModelBinder(BinderType = typeof(JsonModelBinder))] SongListForEditForApiContract contract + ) + { + if (contract is null) + { + return BadRequest("View model was null - probably JavaScript is disabled"); + } + + var coverPicUpload = Request.Form.Files["thumbPicUpload"]; + UploadedFileContract? uploadedPicture = null; + if (coverPicUpload is not null && coverPicUpload.Length > 0) + { + ControllerBase.CheckUploadedPicture(this, coverPicUpload, "thumbPicUpload"); + uploadedPicture = new UploadedFileContract { Mime = coverPicUpload.ContentType, Stream = coverPicUpload.OpenReadStream() }; + } + + if (!ModelState.IsValid) + { + return ValidationProblem(ModelState); + } + + var listId = _queries.UpdateSongList(contract, uploadedPicture); + + return listId; + } #nullable disable } } diff --git a/VocaDbWeb/Controllers/SongController.cs b/VocaDbWeb/Controllers/SongController.cs index c2b7d9642c..aa9c413802 100644 --- a/VocaDbWeb/Controllers/SongController.cs +++ b/VocaDbWeb/Controllers/SongController.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc; using NLog; using VocaDb.Model.Database.Queries; +using VocaDb.Model.DataContracts.SongLists; using VocaDb.Model.DataContracts.Songs; using VocaDb.Model.Domain; using VocaDb.Model.Domain.PVs; @@ -72,7 +73,7 @@ public void AddSongToList(int listId, int songId, string notes = null, string ne } else if (!string.IsNullOrWhiteSpace(newListName)) { - var contract = new SongListForEditContract + var contract = new SongListForEditForApiContract { Name = newListName, SongLinks = new[] {new SongInListEditContract { diff --git a/VocaDbWeb/Controllers/SongListController.cs b/VocaDbWeb/Controllers/SongListController.cs index 8c9a7cfdc3..d635c1c880 100644 --- a/VocaDbWeb/Controllers/SongListController.cs +++ b/VocaDbWeb/Controllers/SongListController.cs @@ -1,12 +1,9 @@ #nullable disable -using System.Net; using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using VocaDb.Model.Database.Queries; -using VocaDb.Model.DataContracts; -using VocaDb.Model.DataContracts.Songs; using VocaDb.Model.Domain.Images; using VocaDb.Model.Domain.Songs; using VocaDb.Model.Service; @@ -77,43 +74,15 @@ public ActionResult Details(int id = InvalidId) return View("React/Index"); } +#nullable enable // // GET: /SongList/Edit/ [Authorize] public ActionResult Edit(int? id) { - var contract = id != null ? _queries.GetSongList(id.Value) : new SongListContract(); - var model = new SongListEditViewModel(contract, PermissionContext); - - return View(model); - } - - [HttpPost] - [Authorize] - public ActionResult Edit(SongListEditViewModel model) - { - if (model == null) - { - return HttpStatusCodeResult(HttpStatusCode.BadRequest, "View model was null - probably JavaScript is disabled"); - } - - var coverPicUpload = Request.Form.Files["thumbPicUpload"]; - UploadedFileContract uploadedPicture = null; - if (coverPicUpload != null && coverPicUpload.Length > 0) - { - CheckUploadedPicture(coverPicUpload, "thumbPicUpload"); - uploadedPicture = new UploadedFileContract { Mime = coverPicUpload.ContentType, Stream = coverPicUpload.OpenReadStream() }; - } - - if (!ModelState.IsValid) - { - return View(new SongListEditViewModel(model.ToContract(), PermissionContext)); - } - - var listId = _queries.UpdateSongList(model.ToContract(), uploadedPicture); - - return RedirectToAction("Details", new { id = listId }); + return View("React/Index"); } +#nullable disable public ActionResult Export(int id) { diff --git a/VocaDbWeb/Scripts/Components/Event/EventEdit.tsx b/VocaDbWeb/Scripts/Components/Event/EventEdit.tsx index d7e9547ff3..958e1fa92f 100644 --- a/VocaDbWeb/Scripts/Components/Event/EventEdit.tsx +++ b/VocaDbWeb/Scripts/Components/Event/EventEdit.tsx @@ -13,6 +13,7 @@ import EventCategory from '@Models/Events/EventCategory'; import ContentLanguageSelection from '@Models/Globalization/ContentLanguageSelection'; import ImageSize from '@Models/Images/ImageSize'; import LoginManager from '@Models/LoginManager'; +import SongListFeaturedCategory from '@Models/SongLists/SongListFeaturedCategory'; import ArtistRepository from '@Repositories/ArtistRepository'; import PVRepository from '@Repositories/PVRepository'; import ReleaseEventRepository from '@Repositories/ReleaseEventRepository'; @@ -22,7 +23,6 @@ import EntryUrlMapper from '@Shared/EntryUrlMapper'; import HttpClient from '@Shared/HttpClient'; import UrlMapper from '@Shared/UrlMapper'; import ReleaseEventEditStore from '@Stores/ReleaseEvent/ReleaseEventEditStore'; -import { SongListFeaturedCategory } from '@Stores/SongList/FeaturedSongListsStore'; import _ from 'lodash'; import { runInAction } from 'mobx'; import { observer } from 'mobx-react-lite'; diff --git a/VocaDbWeb/Scripts/Components/KnockoutExtensions/SongListAutoComplete.tsx b/VocaDbWeb/Scripts/Components/KnockoutExtensions/SongListAutoComplete.tsx index 43d2b57545..aca523189d 100644 --- a/VocaDbWeb/Scripts/Components/KnockoutExtensions/SongListAutoComplete.tsx +++ b/VocaDbWeb/Scripts/Components/KnockoutExtensions/SongListAutoComplete.tsx @@ -1,6 +1,6 @@ import SongListContract from '@DataContracts/Song/SongListContract'; +import SongListFeaturedCategory from '@Models/SongLists/SongListFeaturedCategory'; import functions from '@Shared/GlobalFunctions'; -import { SongListFeaturedCategory } from '@Stores/SongList/FeaturedSongListsStore'; import React from 'react'; import EntryAutoComplete, { diff --git a/VocaDbWeb/Scripts/Components/Shared/Partials/Knockout/SongListLockingAutoComplete.tsx b/VocaDbWeb/Scripts/Components/Shared/Partials/Knockout/SongListLockingAutoComplete.tsx index d560d229b6..bc16b0a2a6 100644 --- a/VocaDbWeb/Scripts/Components/Shared/Partials/Knockout/SongListLockingAutoComplete.tsx +++ b/VocaDbWeb/Scripts/Components/Shared/Partials/Knockout/SongListLockingAutoComplete.tsx @@ -1,6 +1,6 @@ import IEntryWithIdAndName from '@Models/IEntryWithIdAndName'; +import SongListFeaturedCategory from '@Models/SongLists/SongListFeaturedCategory'; import BasicEntryLinkStore from '@Stores/BasicEntryLinkStore'; -import { SongListFeaturedCategory } from '@Stores/SongList/FeaturedSongListsStore'; import { runInAction } from 'mobx'; import { observer } from 'mobx-react-lite'; import React from 'react'; diff --git a/VocaDbWeb/Scripts/Components/SongList/SongListDetails.tsx b/VocaDbWeb/Scripts/Components/SongList/SongListDetails.tsx index 1245753b7b..0bb75f97f2 100644 --- a/VocaDbWeb/Scripts/Components/SongList/SongListDetails.tsx +++ b/VocaDbWeb/Scripts/Components/SongList/SongListDetails.tsx @@ -175,8 +175,8 @@ const SongListDetailsLayout = observer( {loginManager.canEditSongList(songList) && ( <> {t('ViewRes:Shared.Edit')} diff --git a/VocaDbWeb/Scripts/Components/SongList/SongListEdit.tsx b/VocaDbWeb/Scripts/Components/SongList/SongListEdit.tsx new file mode 100644 index 0000000000..0c66d4ff96 --- /dev/null +++ b/VocaDbWeb/Scripts/Components/SongList/SongListEdit.tsx @@ -0,0 +1,491 @@ +import Breadcrumb from '@Bootstrap/Breadcrumb'; +import SafeAnchor from '@Bootstrap/SafeAnchor'; +import SongListForEditContract from '@DataContracts/Song/SongListForEditContract'; +import UrlHelper from '@Helpers/UrlHelper'; +import JQueryUIButton from '@JQueryUI/JQueryUIButton'; +import JQueryUIDatepicker from '@JQueryUI/JQueryUIDatepicker'; +import JQueryUITab from '@JQueryUI/JQueryUITab'; +import JQueryUITabs from '@JQueryUI/JQueryUITabs'; +import EntryStatus from '@Models/EntryStatus'; +import EntryType from '@Models/EntryType'; +import ImageSize from '@Models/Images/ImageSize'; +import SongListFeaturedCategory from '@Models/SongLists/SongListFeaturedCategory'; +import SongListRepository from '@Repositories/SongListRepository'; +import SongRepository from '@Repositories/SongRepository'; +import EntryUrlMapper from '@Shared/EntryUrlMapper'; +import HttpClient from '@Shared/HttpClient'; +import UrlMapper from '@Shared/UrlMapper'; +import SongListEditStore from '@Stores/SongList/SongListEditStore'; +import { runInAction } from 'mobx'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { ReactSortable } from 'react-sortablejs'; + +import Markdown from '../KnockoutExtensions/Markdown'; +import SongAutoComplete from '../KnockoutExtensions/SongAutoComplete'; +import Layout from '../Shared/Layout'; +import EntryDeletePopup from '../Shared/Partials/EntryDetails/EntryDeletePopup'; +import EntryTrashPopup from '../Shared/Partials/EntryDetails/EntryTrashPopup'; +import { EntryStatusDropdownList } from '../Shared/Partials/Knockout/DropdownList'; +import ConcurrentEditWarning from '../Shared/Partials/Shared/ConcurrentEditWarning'; +import HelpLabel from '../Shared/Partials/Shared/HelpLabel'; +import ImageUploadMessage from '../Shared/Partials/Shared/ImageUploadMessage'; +import MarkdownNotice from '../Shared/Partials/Shared/MarkdownNotice'; +import SaveAndBackBtn from '../Shared/Partials/Shared/SaveAndBackBtn'; +import ValidationSummaryPanel from '../Shared/Partials/Shared/ValidationSummaryPanel'; +import { showErrorMessage } from '../ui'; +import { useConflictingEditor } from '../useConflictingEditor'; +import useVocaDbTitle from '../useVocaDbTitle'; + +const httpClient = new HttpClient(); +const urlMapper = new UrlMapper(vdb.values.baseAddress); + +const songListRepo = new SongListRepository(httpClient, urlMapper); +const songRepo = new SongRepository(httpClient, vdb.values.baseAddress); + +interface PropertiesTabContentProps { + songListEditStore: SongListEditStore; + pictureUploadRef: React.MutableRefObject; +} + +const PropertiesTabContent = observer( + ({ + songListEditStore, + pictureUploadRef, + }: PropertiesTabContentProps): React.ReactElement => { + const { t } = useTranslation(['Resources', 'ViewRes', 'ViewRes.SongList']); + + const thumbUrl = UrlHelper.imageThumb( + songListEditStore.contract.mainPicture, + ImageSize.SmallThumb, + false, + ); + + return ( + <> +
{t('ViewRes.SongList:Edit.Name')}
+
+ + runInAction(() => { + songListEditStore.name = e.target.value; + }) + } + className="required input-xxlarge" + size={200} + required + /> +
+ +
+ {t('ViewRes.SongList:Edit.Description')} +
+
+