diff --git a/Wave/Components/Pages/ArticleEditor.razor b/Wave/Components/Pages/ArticleEditor.razor index 9655aa8..6bf3372 100644 --- a/Wave/Components/Pages/ArticleEditor.razor +++ b/Wave/Components/Pages/ArticleEditor.razor @@ -1,25 +1,20 @@ @page "/article/new" @page "/article/{id:guid}/edit" -@using Wave.Data -@using Microsoft.EntityFrameworkCore -@using System.ComponentModel.DataAnnotations -@using Microsoft.AspNetCore.Identity -@using Wave.Utilities -@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@using Wave.Data +@using Microsoft.AspNetCore.Identity + +@rendermode InteractiveServer @attribute [Authorize(Policy = "ArticleEditOrReviewPermissions")] -@inject ILogger Logger -@inject IDbContextFactory ContextFactory -@inject NavigationManager Navigation + @inject UserManager UserManager @inject IStringLocalizer Localizer -@if (Article is not null) { - @(TitlePrefix + Localizer["PageTitle_Edit"]) | @Article.Title -} else { - @(TitlePrefix + Localizer["PageTitle_New"]) -} -@if (Busy) { +@(TitlePrefix + Localizer["EditorTitle"]) + +@if (User is null) { +

@Localizer["EditorTitle"]

+
@@ -27,139 +22,8 @@

@Localizer["EditorTitle"]

- -
-
    -
  • @Localizer["Draft"]
  • -
  • @Localizer["InReview"]
  • -
  • @Localizer["Published"]
  • -
-
- - - - - - - - -
- - @if (Article?.Status is null or not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow) { - - } else { - - } - - - - -
- - -
- - - - @Localizer["Tools_H1_Label"] - - - @Localizer["Tools_H2_Label"] - - - @Localizer["Tools_H3_Label"] - - - @Localizer["Tools_H4_Label"] - - - - - B - - - I - - - U - - - @Localizer["Tools_StrikeThrough_Label"] - - - @Localizer["Tools_Mark_Label"] - - - | @Localizer["Tools_Cite_Label"] - - - - - 1. - - - a. - - - A. - - - i. - - - I. - - - - - - - - - - - - - - - - -
-
- -
- - @if (Article is not null) { - - @Localizer["ViewArticle_Label"] - - } -
-
- + +

Not found

@@ -167,47 +31,6 @@
} - - - @code { [CascadingParameter(Name = "TitlePrefix")] @@ -217,154 +40,12 @@ [Parameter] public Guid? Id { get; set; } - [SupplyParameterFromForm] - private InputModel Model { get; set; } = new(); - - private List Categories { get; set; } = new(); private ApplicationUser? User { get; set; } - private Article? Article { get; set; } - private bool Busy { get; set; } = true; - + protected override async Task OnInitializedAsync() { if (AuthenticationState is null) throw new ApplicationException("???"); var state = await AuthenticationState; var user = await UserManager.GetUserAsync(state.User); User = user ?? throw new ApplicationException("???2"); - - await using var context = await ContextFactory.CreateDbContextAsync(); - if (Id is not null) { - Article = await context.Set
() - .IgnoreQueryFilters().Where(a => !a.IsDeleted) - .Include(a => a.Author) - .Include(a => a.Reviewer) - .Include(a => a.Categories) - .FirstAsync(a => a.Id == Id); - if (Article is null) throw new ApplicationException("Article not found."); - - // Check permissions - if (state.User.IsInRole("Admin")) { - // Admins always have access - } else if (Article.Status is ArticleStatus.Draft && Article.Author.Id == User.Id) { - // It's our draft - } else if (Article.Status is ArticleStatus.InReview && state.User.IsInRole("Reviewer")) { - // It's in reviewer, and we are reviewer - } else if (Article.Status is ArticleStatus.Published && state.User.IsInRole("Moderator")) { - // It's published, and we are moderator - } else { - throw new ApplicationException("You do not have permissions to edit this article"); - } - } - Categories = await context.Set().OrderBy(c => c.Color).ToListAsync(); - - if (Article is not null) { - Model.Id ??= Article.Id; - Model.Title ??= Article.Title; - Model.Body ??= Article.Body; - Model.PublishDate ??= Article.PublishDate.LocalDateTime; - Model.Categories ??= Article.Categories.Select(c => c.Id).ToArray(); - } - - Busy = false; - } - - private async Task OnValidSubmit() { - if (User is null) return; - Busy = true; - - try { - await using var context = await ContextFactory.CreateDbContextAsync(); - context.Entry(User).State = EntityState.Unchanged; - foreach (var category in Categories) { - context.Entry(category).State = EntityState.Unchanged; - } - - Article article; - if (Model.Id is not null) { - article = await context.Set
() - .IgnoreQueryFilters().Where(a => !a.IsDeleted) - .Include(a => a.Author) - .Include(a => a.Reviewer) - .Include(a => a.Categories) - .FirstAsync(a => a.Id == Model.Id); - article.Title = Model.Title!; - article.Body = Model.Body!; - } else { - article = new Article { - Title = Model.Title!, - Body = Model.Body!, - Author = User - }; - await context.AddAsync(article); - } - - if (Model.PublishDate is not null) article.PublishDate = Model.PublishDate.Value; - - await HandleRoles(article, User); - article.LastModified = DateTimeOffset.UtcNow; - article.BodyHtml = MarkdownUtilities.Parse(article.Body); - foreach (var category in article.Categories.ToList()) { - if (Model.Categories?.Contains(category.Id) is not true) { - article.Categories.Remove(category); - } - } - - foreach (var categoryId in Model.Categories ?? Array.Empty()) { - if (article.Categories.Any(c => c.Id == categoryId) is not true) { - article.Categories.Add(Categories.First(c => c.Id == categoryId)); - } - } - - var newsletter = await context.Set() - .IgnoreQueryFilters().FirstOrDefaultAsync(n => n.Article == article); - if (newsletter is not null) { - newsletter.DistributionDateTime = article.PublishDate; - } - - await context.SaveChangesAsync(); - - Navigation.NavigateTo($"/article/{article.Id}"); - } catch (Exception ex) { - // TODO toast - Logger.LogError(ex, "Failed to save article."); - } finally { - Busy = false; - await InvokeAsync(StateHasChanged); - } - } - - private async Task HandleRoles(Article article, ApplicationUser me) { - // it's our draft - if (article.Status is ArticleStatus.Draft && article.Author.Id == me.Id) return; - - var roles = await UserManager.GetRolesAsync(me); - - // reviewers and admins can review articles - if (article.Status is ArticleStatus.InReview && roles.Any(r => r is "Admin" or "Reviewer")) { - article.Reviewer = me; - return; - } - - // published articles may only be edited my admins or moderators - if (article.Status is ArticleStatus.Published && roles.Any(r => r is "Admin" or "Reviewer")) { - article.Reviewer = me; // TODO replace with editor or something? - return; - } - - throw new ApplicationException("You do not have permissions to edit this article"); - } - - private void CategorySelectionChanged(ChangeEventArgs args) { - Model.Categories = ((string[]?)args.Value)?.Select(Guid.Parse).ToArray(); - } - - private sealed class InputModel { - public Guid? Id { get; set; } - [Required(AllowEmptyStrings = false), MaxLength(256)] - public string? Title { get; set; } - [Required(AllowEmptyStrings = false)] - public string? Body { get; set; } - - public Guid[]? Categories { get; set; } - public DateTimeOffset? PublishDate { get; set; } } } diff --git a/Wave/Components/Pages/Partials/ArticleEditorPartial.razor b/Wave/Components/Pages/Partials/ArticleEditorPartial.razor new file mode 100644 index 0000000..7b6da36 --- /dev/null +++ b/Wave/Components/Pages/Partials/ArticleEditorPartial.razor @@ -0,0 +1,350 @@ +@using Wave.Data +@using System.ComponentModel.DataAnnotations +@using System.Diagnostics.CodeAnalysis +@using Microsoft.AspNetCore.Identity +@using Microsoft.EntityFrameworkCore +@using Wave.Utilities + +@inject ILogger Logger +@inject NavigationManager Navigation +@inject UserManager UserManager +@inject IDbContextFactory ContextFactory +@inject IStringLocalizer Localizer +@inject IMessageDisplay Message + +
+
    +
  • @Localizer["Draft"]
  • +
  • @Localizer["InReview"]
  • +
  • @Localizer["Published"]
  • +
+
+ +
+ @foreach (var image in Article.Images) { +
+ @image.ImageDescription +
+ @image.ImageDescription +
+
+ } +
+ + + + + + + + +
+ + @if (Article.Status is not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow) { + + } else { + + } + + + + +
+ + +
+ + + + @Localizer["Tools_H1_Label"] + + + @Localizer["Tools_H2_Label"] + + + @Localizer["Tools_H3_Label"] + + + @Localizer["Tools_H4_Label"] + + + + + B + + + I + + + U + + + @Localizer["Tools_StrikeThrough_Label"] + + + @Localizer["Tools_Mark_Label"] + + + | @Localizer["Tools_Cite_Label"] + + + + + 1. + + + a. + + + A. + + + i. + + + I. + + + + + + + + + + + + + + + + +
+
+ +
+ + @if (Article.Id != Guid.Empty) { + + @Localizer["ViewArticle_Label"] + + } +
+
+ + + + + + +@code { + [Parameter] + public Guid? Id { get; set; } + [Parameter] + public required ApplicationUser User { get; set; } + [SupplyParameterFromForm] + private InputModel Model { get; set; } = new(); + + private Article Article { get; set; } = default!; + private List Categories { get; set; } = []; + private bool Saving { get; set; } + + protected override void OnInitialized() { + // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract + Article ??= new Article {Author = User, Title = "", Body = ""}; + } + + protected override async Task OnInitializedAsync() { + await using var context = await ContextFactory.CreateDbContextAsync(); + Categories = await context.Set().IgnoreQueryFilters().OrderBy(c => c.Color).ToListAsync(); + + Article? article = null; + if (Id is not null) { + // Ensure we are not double-tracking User on existing articles, since + // a different context loaded this user originally + context.Entry(User).State = EntityState.Unchanged; + + article = await context.Set
() + .IgnoreQueryFilters().Where(a => !a.IsDeleted) + .Include(a => a.Author) + .Include(a => a.Reviewer) + .Include(a => a.Categories) + .Include(a => a.Images) + .FirstAsync(a => a.Id == Id); + if (article is null) throw new ApplicationException("Article not found."); + + await HandleRoles(article, User); + } + + if (article is not null) { + Model.Id ??= article.Id; + Model.Title ??= article.Title; + Model.Body ??= article.Body; + Model.PublishDate ??= article.PublishDate.LocalDateTime; + Model.Categories ??= article.Categories.Select(c => c.Id).ToArray(); + Article = article; + await InvokeAsync(StateHasChanged); + } + } + + private async Task OnValidSubmit() { + try { + Saving = true; + + // Double check user permissions + await HandleRoles(Article, User); + + if (Model.Title is not null) Article.Title = Model.Title; + if (Model.Body is not null) Article.Body = Model.Body; + if (Model.PublishDate is not null && + (Article.Status is not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow)) + Article.PublishDate = Model.PublishDate.Value; + Article.LastModified = DateTimeOffset.UtcNow; + Article.BodyHtml = MarkdownUtilities.Parse(Article.Body); + + await using var context = await ContextFactory.CreateDbContextAsync(); + + // Update Newsletter distribution if exists + var newsletter = await context.Set() + .IgnoreQueryFilters().IgnoreAutoIncludes() + .FirstOrDefaultAsync(n => n.Article == Article); + if (newsletter is not null) { + newsletter.DistributionDateTime = Article.PublishDate; + } + + // Avoid unnecessary updates + context.Entry(User).State = EntityState.Unchanged; + Categories.ForEach(c => context.Entry(c).State = EntityState.Unchanged); + await context.Set().IgnoreQueryFilters().IgnoreAutoIncludes() + .Where(ac => ac.Article.Id == Article.Id).LoadAsync(); + + Model.Categories ??= []; + context.Update(Article); + + var relations = await context.Set() + .IgnoreQueryFilters().IgnoreAutoIncludes() + .Where(ac => ac.Article == Article && !Model.Categories.Contains(ac.Category.Id)) + .ToListAsync(); + context.RemoveRange(relations); + + foreach (var category in Model.Categories) { + if (Article.Categories.Any(c => c.Id == category) is not true) { + context.Add(new ArticleCategory { + Article = Article, + Category = Categories.First(c => c.Id == category) + }); + } + } + + await context.SaveChangesAsync(); + Message.ShowSuccess(Localizer["Save_Success"]); + } catch (Exception ex) { + Message.ShowError(Localizer["Save_Error"]); + Logger.LogError(ex, "Failed to save article."); + } finally { + Saving = false; + await InvokeAsync(StateHasChanged); + } + } + + [SuppressMessage("ReSharper", "ConvertIfStatementToSwitchStatement")] + private async Task HandleRoles(Article article, ApplicationUser me) { + // it's our draft + if (article.Status is ArticleStatus.Draft && article.Author.Id == me.Id) return; + + var roles = await UserManager.GetRolesAsync(me); + + // reviewers and admins can review articles + if (article.Status is ArticleStatus.InReview && roles.Any(r => r is "Admin" or "Reviewer")) { + article.Reviewer = me; + return; + } + + // published articles may only be edited my admins or moderators + if (article.Status is ArticleStatus.Published && roles.Any(r => r is "Admin" or "Reviewer")) { + article.Reviewer = me; // TODO replace with editor or something? + return; + } + + throw new ApplicationException("You do not have permissions to edit this article"); + } + + private void CategorySelectionChanged(ChangeEventArgs args) { + string[]? selected = (string[]?) args.Value; + Model.Categories = selected?.Select(Guid.Parse).ToArray(); + } + + private sealed class InputModel { + public Guid? Id { get; set; } + + [Required(AllowEmptyStrings = false), MaxLength(256)] + public string? Title { get; set; } + [Required(AllowEmptyStrings = false)] + public string? Body { get; set; } + + public Guid[]? Categories { get; set; } + public DateTimeOffset? PublishDate { get; set; } + } + +} \ No newline at end of file diff --git a/Wave/Data/Category.cs b/Wave/Data/Category.cs index 1c82767..17a88b5 100644 --- a/Wave/Data/Category.cs +++ b/Wave/Data/Category.cs @@ -21,4 +21,6 @@ public class Category { public CategoryColors Color { get; set; } = CategoryColors.Default; public IList
Articles { get; set; } = []; + + public override string ToString() => $"[{Color} {Name}]"; } \ No newline at end of file