diff --git a/Wave/Components/Pages/ArticleEditor.razor b/Wave/Components/Pages/ArticleEditor.razor index 136a208..2bbe3aa 100644 --- a/Wave/Components/Pages/ArticleEditor.razor +++ b/Wave/Components/Pages/ArticleEditor.razor @@ -8,179 +8,180 @@ @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 + @(TitlePrefix + Localizer["PageTitle_Edit"]) | @Article.Title } else { - @(TitlePrefix + Localizer["PageTitle_New"]) + @(TitlePrefix + Localizer["PageTitle_New"]) } @if (Busy) { -
- -
+
+ +
} else { - - -

@Localizer["EditorTitle"]

+ + +

@Localizer["EditorTitle"]

-
-
    -
  • @Localizer["Draft"]
  • -
  • @Localizer["InReview"]
  • -
  • @Localizer["Published"]
  • -
-
+
+
    +
  • @Localizer["Draft"]
  • +
  • @Localizer["InReview"]
  • +
  • @Localizer["Published"]
  • +
+
- - - + + + - - - -
- - @if (Article?.Status is null or not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow) { - - } else { - - } - + + + +
+ + @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. - - - - - - - - - - - - - - - - -
-
+ +
+ + + + @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

-
-
+
+ + @if (Article is not null) { + + @Localizer["ViewArticle_Label"] + + } +
+ + +
+ +

Not found

+
+
} @code { - [CascadingParameter(Name = "TitlePrefix")] - private string TitlePrefix { get; set; } = default!; - [CascadingParameter] - private Task? AuthenticationState { get; set; } + [CascadingParameter(Name = "TitlePrefix")] + private string TitlePrefix { get; set; } = default!; + [CascadingParameter] + private Task? AuthenticationState { get; set; } - [Parameter] - public Guid? Id { get; set; } - [SupplyParameterFromForm] - private InputModel Model { get; set; } = new(); + [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; + 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"); + 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."); + 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(); + // 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; - } + 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; + 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; - } + 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); - } + 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; + 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); - } - } + 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)); - } - } + 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)); + } + } - await context.SaveChangesAsync(); - Navigation.NavigateTo($"/article/{article.Id}"); - } catch (Exception ex) { - // TODO toast - } finally { - Busy = false; - await InvokeAsync(StateHasChanged); - } - } + var newsletter = await context.Set().FirstOrDefaultAsync(n => n.Article == article); + if (newsletter is not null) { + newsletter.DistributionDateTime = article.PublishDate; + } + + await context.SaveChangesAsync(); - private async Task HandleRoles(Article article, ApplicationUser me) { - // it's our draft - if (article.Status is ArticleStatus.Draft && article.Author.Id == me.Id) return; + Navigation.NavigateTo($"/article/{article.Id}"); + } catch (Exception ex) { + // TODO toast + Logger.LogError(ex, "Failed to save article."); + } finally { + Busy = false; + await InvokeAsync(StateHasChanged); + } + } - var roles = await UserManager.GetRolesAsync(me); + private async Task HandleRoles(Article article, ApplicationUser me) { + // it's our draft + if (article.Status is ArticleStatus.Draft && article.Author.Id == me.Id) return; - // reviewers and admins can review articles - if (article.Status is ArticleStatus.InReview && roles.Any(r => r is "Admin" or "Reviewer")) { - article.Reviewer = me; - return; - } + var roles = await UserManager.GetRolesAsync(me); - // 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; - } + // reviewers and admins can review articles + if (article.Status is ArticleStatus.InReview && roles.Any(r => r is "Admin" or "Reviewer")) { + article.Reviewer = me; + return; + } - throw new ApplicationException("You do not have permissions to edit this article"); - } + // 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 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; } + 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/ArticleView.razor b/Wave/Components/Pages/ArticleView.razor index 3f3de1c..cdce8dc 100644 --- a/Wave/Components/Pages/ArticleView.razor +++ b/Wave/Components/Pages/ArticleView.razor @@ -6,6 +6,7 @@ @using System.Diagnostics.CodeAnalysis @using Microsoft.Extensions.Options +@inject ILogger Logger @inject IDbContextFactory ContextFactory @inject NavigationManager Navigation @inject IOptions Customizations @@ -15,62 +16,62 @@ @(TitlePrefix + (Article?.Title ?? Localizer["NotFound_Title"])) @if (Article is not null) { - - - - - - - - - - - - - @if (Article.LastModified.HasValue) { - - } - - @if (Features.Value.Rss) { - - - } - + + + + + + + + + + + + + @if (Article.LastModified.HasValue) { + + } + + @if (Features.Value.Rss) { + + + } + } - - - - -
- @Localizer["Edit"] - @if (Article.Status is ArticleStatus.Draft) { -
- - - - } else if (Article.Status is ArticleStatus.InReview) { -
- - - - } -
-
- - - -
-
- -

@Localizer["NotFound_Title"]

-

@Localizer["NotFound_Description"]

- @Localizer["NotFound_BackToHome_Label"] - @if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")?.ToLower() == "development") { -

[DEBUG] EXCEPTION MESSAGE: @context.Message

- } -
+ + + + +
+ @Localizer["Edit"] + @if (Article.Status is ArticleStatus.Draft) { +
+ + + + } else if (Article.Status is ArticleStatus.InReview) { +
+ + + + } +
+
+ + + +
+
+ +

@Localizer["NotFound_Title"]

+

@Localizer["NotFound_Description"]

+ @Localizer["NotFound_BackToHome_Label"] + @if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")?.ToLower() == "development") { +

[DEBUG] EXCEPTION MESSAGE: @context.Message

+ } +
@code { @@ -78,110 +79,124 @@ private string TitlePrefix { get; set; } = default!; #region Route Parameters - + [Parameter] - public Guid? Id { get; set; } + public Guid? Id { get; set; } [Parameter] - public int? Year { get; set; } + public int? Year { get; set; } [Parameter] - public int? Month { get; set; } + public int? Month { get; set; } [Parameter] - public int? Day { get; set; } - [Parameter] - public string? TitleEncoded { get; set; } - - private DateTimeOffset? Date => - Year is {} y && Month is {} m && Day is {} d - ? new DateTimeOffset(new DateTime(y, m, d)) : - null; - private string? Title => TitleEncoded is null ? null : Uri.UnescapeDataString(TitleEncoded.Replace("-", "%20").Replace("+", "-")); + public int? Day { get; set; } + [Parameter] + public string? TitleEncoded { get; set; } + + private DateTimeOffset? Date => + Year is {} y && Month is {} m && Day is {} d + ? new DateTimeOffset(new DateTime(y, m, d)) : + null; + private string? Title => TitleEncoded is null ? null : Uri.UnescapeDataString(TitleEncoded.Replace("-", "%20").Replace("+", "-")); #endregion - private Article? Article { get; set; } + private Article? Article { get; set; } - [CascadingParameter] - public HttpContext HttpContext { get; set; } = default!; + [CascadingParameter] + public HttpContext HttpContext { get; set; } = default!; - private Article GetArticlePublic() { - if (Article is null) throw new ApplicationException("Article not found."); - if (Article.Status >= ArticleStatus.Published && Article.PublishDate <= DateTimeOffset.UtcNow) { - return Article; - } - throw new ApplicationException("Article is not public."); - } + private Article GetArticlePublic() { + if (Article is null) throw new ApplicationException("Article not found."); + if (Article.Status >= ArticleStatus.Published && Article.PublishDate <= DateTimeOffset.UtcNow) { + return Article; + } + throw new ApplicationException("Article is not public."); + } - [SuppressMessage("ReSharper", "ConvertIfStatementToSwitchStatement")] - private Article GetArticleProtected(ClaimsPrincipal principal) { - if (Article is null) throw new ApplicationException("Article not found."); + [SuppressMessage("ReSharper", "ConvertIfStatementToSwitchStatement")] + private Article GetArticleProtected(ClaimsPrincipal principal) { + if (Article is null) throw new ApplicationException("Article not found."); - // The Article is publicly available - if (Article.Status >= ArticleStatus.Published && Article.PublishDate <= DateTimeOffset.UtcNow) { - return Article; - } + // The Article is publicly available + if (Article.Status >= ArticleStatus.Published && Article.PublishDate <= DateTimeOffset.UtcNow) { + return Article; + } - // Admins always get access - if (principal.IsInRole("Admin")) { - return Article; - } - - // You can only access your own drafts - if (Article.Status is ArticleStatus.Draft) { - if (Article.Author.Id == principal.FindFirst("Id")!.Value) { - return Article; - } - throw new ApplicationException("Cannot access draft article without being author or admin."); - } - // InReview Articles can only be accessed by reviewers - if (Article.Status is ArticleStatus.InReview) { - if (principal.IsInRole("Reviewer")) { - return Article; - } - throw new ApplicationException("Cannot access in-review article without being a reviewer or admin."); - } + // Admins always get access + if (principal.IsInRole("Admin")) { + return Article; + } + + // You can only access your own drafts + if (Article.Status is ArticleStatus.Draft) { + if (Article.Author.Id == principal.FindFirst("Id")!.Value) { + return Article; + } + throw new ApplicationException("Cannot access draft article without being author or admin."); + } + // InReview Articles can only be accessed by reviewers + if (Article.Status is ArticleStatus.InReview) { + if (principal.IsInRole("Reviewer")) { + return Article; + } + throw new ApplicationException("Cannot access in-review article without being a reviewer or admin."); + } - throw new ApplicationException("User does not have access to this article."); - } + throw new ApplicationException("User does not have access to this article."); + } - protected override void OnInitialized() { - // We need blocking calls here, bc otherwise Blazor will execute Render in parallel, - // running into a null pointer on the Article property and panicking - if (Id is not null) { - using var context = ContextFactory.CreateDbContext(); - Article = context.Set
() - .IgnoreQueryFilters().Where(a => !a.IsDeleted) - .Include(a => a.Author) - .Include(a => a.Reviewer) - .Include(a => a.Categories) - .FirstOrDefault(a => a.Id == Id); - } else if (Date is { } date && Title is { } title) { - using var context = ContextFactory.CreateDbContext(); - Article = context.Set
() - .IgnoreQueryFilters().Where(a => !a.IsDeleted) - .Include(a => a.Author) - .Include(a => a.Reviewer) - .Include(a => a.Categories) - .FirstOrDefault(a => a.PublishDate.Date == date.Date && a.Title.ToLower() == title); - } - } + protected override void OnInitialized() { + // We need blocking calls here, bc otherwise Blazor will execute Render in parallel, + // running into a null pointer on the Article property and panicking + if (Id is not null) { + using var context = ContextFactory.CreateDbContext(); + Article = context.Set
() + .IgnoreQueryFilters().Where(a => !a.IsDeleted) + .Include(a => a.Author) + .Include(a => a.Reviewer) + .Include(a => a.Categories) + .FirstOrDefault(a => a.Id == Id); + } else if (Date is { } date && Title is { } title) { + using var context = ContextFactory.CreateDbContext(); + Article = context.Set
() + .IgnoreQueryFilters().Where(a => !a.IsDeleted) + .Include(a => a.Author) + .Include(a => a.Reviewer) + .Include(a => a.Categories) + .FirstOrDefault(a => a.PublishDate.Date == date.Date && a.Title.ToLower() == title); + } + } - private async Task SubmitForReview() { - await using var context = await ContextFactory.CreateDbContextAsync(); - Article!.Status = ArticleStatus.InReview; - context.Update(Article); - await context.SaveChangesAsync(); - Navigation.NavigateTo("/"); - } + private async Task SubmitForReview() { + await using var context = await ContextFactory.CreateDbContextAsync(); + Article!.Status = ArticleStatus.InReview; + context.Update(Article); + await context.SaveChangesAsync(); + Navigation.NavigateTo("/"); + } - private async Task SubmitForPublish() { - await using var context = await ContextFactory.CreateDbContextAsync(); - Article!.Status = ArticleStatus.Published; - string userId = HttpContext.User.FindFirst("Id")!.Value; - if (Article.Author.Id != userId) { - Article.Reviewer = await context.Users.FindAsync(userId); - } - context.Update(Article); - await context.SaveChangesAsync(); - Navigation.NavigateTo("/"); - } + private async Task SubmitForPublish() { + await using var context = await ContextFactory.CreateDbContextAsync(); + Article!.Status = ArticleStatus.Published; + string userId = HttpContext.User.FindFirst("Id")!.Value; + if (Article.Author.Id != userId) { + Article.Reviewer = await context.Users.FindAsync(userId); + } + context.Update(Article); + await context.SaveChangesAsync(); + + if (Features.Value.EmailSubscriptions) { + try { + var newsletter = new EmailNewsletter { + Article = Article, + DistributionDateTime = Article.PublishDate + }; + context.Add(newsletter); + await context.SaveChangesAsync(); + } catch (Exception ex) { + Logger.LogError(ex, "Failed to schedule article {name} for E-Mail distribution.", Article.Title); + } + } + + Navigation.NavigateTo("/"); + } }