Added automatic newsletter distribution to articles on publish

This commit is contained in:
Mia Rose Winter 2024-02-14 14:43:26 +01:00
parent 9cf97fbef7
commit a94852298d
Signed by: miawinter
GPG key ID: 4B6F6A83178F595E
2 changed files with 446 additions and 423 deletions

View file

@ -8,179 +8,180 @@
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize(Policy = "ArticleEditOrReviewPermissions")] @attribute [Authorize(Policy = "ArticleEditOrReviewPermissions")]
@inject ILogger<ArticleEditor> Logger
@inject IDbContextFactory<ApplicationDbContext> ContextFactory @inject IDbContextFactory<ApplicationDbContext> ContextFactory
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject UserManager<ApplicationUser> UserManager @inject UserManager<ApplicationUser> UserManager
@inject IStringLocalizer<ArticleEditor> Localizer @inject IStringLocalizer<ArticleEditor> Localizer
@if (Article is not null) { @if (Article is not null) {
<PageTitle>@(TitlePrefix + Localizer["PageTitle_Edit"]) | @Article.Title</PageTitle> <PageTitle>@(TitlePrefix + Localizer["PageTitle_Edit"]) | @Article.Title</PageTitle>
} else { } else {
<PageTitle>@(TitlePrefix + Localizer["PageTitle_New"])</PageTitle> <PageTitle>@(TitlePrefix + Localizer["PageTitle_New"])</PageTitle>
} }
@if (Busy) { @if (Busy) {
<div class="flex place-content-center"> <div class="flex place-content-center">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
</div> </div>
} else { } else {
<ErrorBoundary> <ErrorBoundary>
<ChildContent> <ChildContent>
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["EditorTitle"]</h1> <h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["EditorTitle"]</h1>
<div class="w-full"> <div class="w-full">
<ul class="steps w-full max-w-xs"> <ul class="steps w-full max-w-xs">
<li class="step @(Article?.Status >= ArticleStatus.Draft ? "step-secondary": "")">@Localizer["Draft"]</li> <li class="step @(Article?.Status >= ArticleStatus.Draft ? "step-secondary": "")">@Localizer["Draft"]</li>
<li class="step @(Article?.Status >= ArticleStatus.InReview ? "step-secondary": "")">@Localizer["InReview"]</li> <li class="step @(Article?.Status >= ArticleStatus.InReview ? "step-secondary": "")">@Localizer["InReview"]</li>
<li class="step @(Article?.Status >= ArticleStatus.Published ? "step-secondary": "")">@Localizer["Published"]</li> <li class="step @(Article?.Status >= ArticleStatus.Published ? "step-secondary": "")">@Localizer["Published"]</li>
</ul> </ul>
</div> </div>
<EditForm method="post" FormName="article-editor" Model="@Model" OnValidSubmit="OnValidSubmit"> <EditForm method="post" FormName="article-editor" Model="@Model" OnValidSubmit="OnValidSubmit">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<input type="hidden" @bind-value="@Model.Id" /> <input type="hidden" @bind-value="@Model.Id" />
<InputLabelComponent LabelText="@Localizer["Title_Label"]" For="() => Model.Title"> <InputLabelComponent LabelText="@Localizer["Title_Label"]" For="() => Model.Title">
<InputText class="input input-bordered w-full" maxlength="256" required aria-required <InputText class="input input-bordered w-full" maxlength="256" required aria-required
@bind-Value="@Model.Title" placeholder="@Localizer["Title_Placeholder"]" autocomplete="off" /> @bind-Value="@Model.Title" placeholder="@Localizer["Title_Placeholder"]" autocomplete="off" />
</InputLabelComponent> </InputLabelComponent>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-x-8">
<InputLabelComponent LabelText="@Localizer["PublishDate_Label"]" For="() => Model.PublishDate"> <InputLabelComponent LabelText="@Localizer["PublishDate_Label"]" For="() => Model.PublishDate">
@if (Article?.Status is null or not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow) { @if (Article?.Status is null or not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow) {
<InputDate class="input input-bordered w-full" Type="InputDateType.DateTimeLocal" <InputDate class="input input-bordered w-full" Type="InputDateType.DateTimeLocal"
@bind-Value="@Model.PublishDate" placeholder="@Localizer["PublishDate_Placeholder"]" autocomplete="off" /> @bind-Value="@Model.PublishDate" placeholder="@Localizer["PublishDate_Placeholder"]" autocomplete="off" />
} else { } else {
<input class="input input-bordered w-full" <input class="input input-bordered w-full"
type="datetime-local" readonly value="@Article?.PublishDate.ToString("yyyy-MM-dd\\THH:mm:ss")" /> type="datetime-local" readonly value="@Article?.PublishDate.ToString("yyyy-MM-dd\\THH:mm:ss")" />
} }
</InputLabelComponent> </InputLabelComponent>
<InputLabelComponent LabelText="@Localizer["Categories_Label"]" For="() => Model.Categories"> <InputLabelComponent LabelText="@Localizer["Categories_Label"]" For="() => Model.Categories">
<select class="select select-bordered w-full h-32" @onchange="CategorySelectionChanged" multiple> <select class="select select-bordered w-full h-32" @onchange="CategorySelectionChanged" multiple>
@foreach (var category in Categories) { @foreach (var category in Categories) {
<option value="@category.Id" selected="@Model.Categories?.Contains(category.Id)">@category.Name</option> <option value="@category.Id" selected="@Model.Categories?.Contains(category.Id)">@category.Name</option>
} }
</select> </select>
</InputLabelComponent> </InputLabelComponent>
</div> </div>
<AdvancedMarkdownEditor Title="@Model.Title" MarkdownCallback="() => Model.Body"> <AdvancedMarkdownEditor Title="@Model.Title" MarkdownCallback="() => Model.Body">
<div class="join join-vertical min-h-96 h-full w-full" aria-role="toolbar"> <div class="join join-vertical min-h-96 h-full w-full" aria-role="toolbar">
<Toolbar> <Toolbar>
<ToolbarSection> <ToolbarSection>
<ToolbarButton onclick="window.insertBeforeSelection('# ', true);" <ToolbarButton onclick="window.insertBeforeSelection('# ', true);"
title="@Localizer["Tools_H1_Tooltip"]"> title="@Localizer["Tools_H1_Tooltip"]">
<strong>@Localizer["Tools_H1_Label"]</strong> <strong>@Localizer["Tools_H1_Label"]</strong>
</ToolbarButton> </ToolbarButton>
<ToolbarButton onclick="window.insertBeforeSelection('## ', true);" <ToolbarButton onclick="window.insertBeforeSelection('## ', true);"
title="@Localizer["Tools_H2_Tooltip"]"> title="@Localizer["Tools_H2_Tooltip"]">
<strong>@Localizer["Tools_H2_Label"]</strong> <strong>@Localizer["Tools_H2_Label"]</strong>
</ToolbarButton> </ToolbarButton>
<ToolbarButton onclick="window.insertBeforeSelection('### ', true);" <ToolbarButton onclick="window.insertBeforeSelection('### ', true);"
title="@Localizer["Tools_H3_Tooltip"]"> title="@Localizer["Tools_H3_Tooltip"]">
<strong>@Localizer["Tools_H3_Label"]</strong> <strong>@Localizer["Tools_H3_Label"]</strong>
</ToolbarButton> </ToolbarButton>
<ToolbarButton onclick="window.insertBeforeSelection('#### ', true);" <ToolbarButton onclick="window.insertBeforeSelection('#### ', true);"
title="@Localizer["Tools_H4_Tooltip"]"> title="@Localizer["Tools_H4_Tooltip"]">
@Localizer["Tools_H4_Label"] @Localizer["Tools_H4_Label"]
</ToolbarButton> </ToolbarButton>
</ToolbarSection> </ToolbarSection>
<ToolbarSection> <ToolbarSection>
<ToolbarButton onclick="window.insertBeforeAndAfterSelection('**');" <ToolbarButton onclick="window.insertBeforeAndAfterSelection('**');"
title="@Localizer["Tools_Bold_Tooltip"]"> title="@Localizer["Tools_Bold_Tooltip"]">
<strong>B</strong> <strong>B</strong>
</ToolbarButton> </ToolbarButton>
<ToolbarButton onclick="window.insertBeforeAndAfterSelection('*')" <ToolbarButton onclick="window.insertBeforeAndAfterSelection('*')"
title="@Localizer["Tools_Italic_Tooltip"]"> title="@Localizer["Tools_Italic_Tooltip"]">
<em>I</em> <em>I</em>
</ToolbarButton> </ToolbarButton>
<ToolbarButton onclick="window.insertBeforeAndAfterSelection('++')" <ToolbarButton onclick="window.insertBeforeAndAfterSelection('++')"
title="@Localizer["Tools_Underline_Tooltip"]"> title="@Localizer["Tools_Underline_Tooltip"]">
<u>U</u> <u>U</u>
</ToolbarButton> </ToolbarButton>
<ToolbarButton onclick="window.insertBeforeAndAfterSelection('~~')" <ToolbarButton onclick="window.insertBeforeAndAfterSelection('~~')"
title="@Localizer["Tools_StrikeThrough_Tooltip"]"> title="@Localizer["Tools_StrikeThrough_Tooltip"]">
<del>@Localizer["Tools_StrikeThrough_Label"]</del> <del>@Localizer["Tools_StrikeThrough_Label"]</del>
</ToolbarButton> </ToolbarButton>
<ToolbarButton onclick="window.insertBeforeAndAfterSelection('==')" <ToolbarButton onclick="window.insertBeforeAndAfterSelection('==')"
title="@Localizer["Tools_Mark_Tooltip"]"> title="@Localizer["Tools_Mark_Tooltip"]">
<mark>@Localizer["Tools_Mark_Label"]</mark> <mark>@Localizer["Tools_Mark_Label"]</mark>
</ToolbarButton> </ToolbarButton>
<ToolbarButton onclick="window.insertBeforeSelection('> ', true)" <ToolbarButton onclick="window.insertBeforeSelection('> ', true)"
title="@Localizer["Tools_Cite_Tooltip"]"> title="@Localizer["Tools_Cite_Tooltip"]">
| <em>@Localizer["Tools_Cite_Label"]</em> | <em>@Localizer["Tools_Cite_Label"]</em>
</ToolbarButton> </ToolbarButton>
</ToolbarSection> </ToolbarSection>
<ToolbarSection> <ToolbarSection>
<ToolbarButton onclick="window.insertBeforeSelection('1. ', true)"> <ToolbarButton onclick="window.insertBeforeSelection('1. ', true)">
1. 1.
</ToolbarButton> </ToolbarButton>
<ToolbarButton onclick="window.insertBeforeSelection('a. ', true)"> <ToolbarButton onclick="window.insertBeforeSelection('a. ', true)">
a. a.
</ToolbarButton> </ToolbarButton>
<ToolbarButton onclick="window.insertBeforeSelection('A. ', true)"> <ToolbarButton onclick="window.insertBeforeSelection('A. ', true)">
A. A.
</ToolbarButton> </ToolbarButton>
<ToolbarButton onclick="window.insertBeforeSelection('i. ', true)"> <ToolbarButton onclick="window.insertBeforeSelection('i. ', true)">
i. i.
</ToolbarButton> </ToolbarButton>
<ToolbarButton onclick="window.insertBeforeSelection('I. ', true)"> <ToolbarButton onclick="window.insertBeforeSelection('I. ', true)">
I. I.
</ToolbarButton> </ToolbarButton>
</ToolbarSection> </ToolbarSection>
<ToolbarSection> <ToolbarSection>
<ToolbarButton onclick="window.insertBeforeAndAfterSelection('`')" <ToolbarButton onclick="window.insertBeforeAndAfterSelection('`')"
title="@Localizer["Tools_CodeLine_Tooltip"]"> title="@Localizer["Tools_CodeLine_Tooltip"]">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M14.447 3.026a.75.75 0 0 1 .527.921l-4.5 16.5a.75.75 0 0 1-1.448-.394l4.5-16.5a.75.75 0 0 1 .921-.527ZM16.72 6.22a.75.75 0 0 1 1.06 0l5.25 5.25a.75.75 0 0 1 0 1.06l-5.25 5.25a.75.75 0 1 1-1.06-1.06L21.44 12l-4.72-4.72a.75.75 0 0 1 0-1.06Zm-9.44 0a.75.75 0 0 1 0 1.06L2.56 12l4.72 4.72a.75.75 0 0 1-1.06 1.06L.97 12.53a.75.75 0 0 1 0-1.06l5.25-5.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M14.447 3.026a.75.75 0 0 1 .527.921l-4.5 16.5a.75.75 0 0 1-1.448-.394l4.5-16.5a.75.75 0 0 1 .921-.527ZM16.72 6.22a.75.75 0 0 1 1.06 0l5.25 5.25a.75.75 0 0 1 0 1.06l-5.25 5.25a.75.75 0 1 1-1.06-1.06L21.44 12l-4.72-4.72a.75.75 0 0 1 0-1.06Zm-9.44 0a.75.75 0 0 1 0 1.06L2.56 12l4.72 4.72a.75.75 0 0 1-1.06 1.06L.97 12.53a.75.75 0 0 1 0-1.06l5.25-5.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
</svg> </svg>
</ToolbarButton> </ToolbarButton>
<ToolbarButton onclick="window.insertBeforeAndAfterSelection('\n```\n')" <ToolbarButton onclick="window.insertBeforeAndAfterSelection('\n```\n')"
title="@Localizer["Tools_CodeBlock_Tooltip"]"> title="@Localizer["Tools_CodeBlock_Tooltip"]">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M3 6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6Zm14.25 6a.75.75 0 0 1-.22.53l-2.25 2.25a.75.75 0 1 1-1.06-1.06L15.44 12l-1.72-1.72a.75.75 0 1 1 1.06-1.06l2.25 2.25c.141.14.22.331.22.53Zm-10.28-.53a.75.75 0 0 0 0 1.06l2.25 2.25a.75.75 0 1 0 1.06-1.06L8.56 12l1.72-1.72a.75.75 0 1 0-1.06-1.06l-2.25 2.25Z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M3 6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6Zm14.25 6a.75.75 0 0 1-.22.53l-2.25 2.25a.75.75 0 1 1-1.06-1.06L15.44 12l-1.72-1.72a.75.75 0 1 1 1.06-1.06l2.25 2.25c.141.14.22.331.22.53Zm-10.28-.53a.75.75 0 0 0 0 1.06l2.25 2.25a.75.75 0 1 0 1.06-1.06L8.56 12l1.72-1.72a.75.75 0 1 0-1.06-1.06l-2.25 2.25Z" clip-rule="evenodd" />
</svg> </svg>
</ToolbarButton> </ToolbarButton>
</ToolbarSection> </ToolbarSection>
</Toolbar> </Toolbar>
<textarea id="tool-target" class="textarea textarea-bordered outline-none w-full flex-1 join-item" <textarea id="tool-target" class="textarea textarea-bordered outline-none w-full flex-1 join-item"
required aria-required placeholder="@Localizer["Body_Placeholder"]" required aria-required placeholder="@Localizer["Body_Placeholder"]"
@bind="@Model.Body" @bind:event="oninput" autocomplete="off"></textarea> @bind="@Model.Body" @bind:event="oninput" autocomplete="off"></textarea>
</div> </div>
</AdvancedMarkdownEditor> </AdvancedMarkdownEditor>
<div class="flex gap-2 flex-wrap mt-3"> <div class="flex gap-2 flex-wrap mt-3">
<button type="submit" class="btn btn-primary w-full sm:btn-wide"> <button type="submit" class="btn btn-primary w-full sm:btn-wide">
@Localizer["EditorSubmit"] @Localizer["EditorSubmit"]
</button> </button>
@if (Article is not null) { @if (Article is not null) {
<a class="btn w-full sm:btn-wide" href="/article/@(Article.Id)"> <a class="btn w-full sm:btn-wide" href="/article/@(Article.Id)">
@Localizer["ViewArticle_Label"] @Localizer["ViewArticle_Label"]
</a> </a>
} }
</div> </div>
</EditForm> </EditForm>
<CategoryPicker /> <CategoryPicker />
</ChildContent> </ChildContent>
<ErrorContent> <ErrorContent>
<h1 class="text-3xl lg:text-5xl font-light mb-6">Not found</h1> <h1 class="text-3xl lg:text-5xl font-light mb-6">Not found</h1>
</ErrorContent> </ErrorContent>
</ErrorBoundary> </ErrorBoundary>
} }
<SectionContent SectionName="scripts"> <SectionContent SectionName="scripts">
<script> <script>
window.insertBeforeSelection = function(markdown, startOfLine = false) { window.insertBeforeSelection = function(markdown, startOfLine = false) {
const target = document.getElementById("tool-target"); const target = document.getElementById("tool-target");
const start = target.selectionStart; const start = target.selectionStart;
const end = target.selectionEnd; const end = target.selectionEnd;
const value = target.value; const value = target.value;
let doStart = start; let doStart = start;
if (startOfLine) { if (startOfLine) {
doStart = value.lastIndexOf("\n", start) +1; doStart = value.lastIndexOf("\n", start) +1;
} }
target.focus(); target.focus();
target.value = value.substring(0, doStart) + markdown + value.substring(doStart); target.value = value.substring(0, doStart) + markdown + value.substring(doStart);
target.selectionStart = start + markdown.length; target.selectionStart = start + markdown.length;
target.selectionEnd = end + markdown.length; target.selectionEnd = end + markdown.length;
@ -188,174 +189,181 @@
target.dispatchEvent(new Event("input", { bubbles: true })); target.dispatchEvent(new Event("input", { bubbles: true }));
} }
window.insertBeforeAndAfterSelection = function (markdown) { window.insertBeforeAndAfterSelection = function (markdown) {
const target = document.getElementById("tool-target"); const target = document.getElementById("tool-target");
const start = target.selectionStart; const start = target.selectionStart;
const end = target.selectionEnd; const end = target.selectionEnd;
const value = target.value; const value = target.value;
target.focus(); target.focus();
target.value = value.substring(0, start) + target.value = value.substring(0, start) +
markdown + value.substring(start, end) + markdown + markdown + value.substring(start, end) + markdown +
value.substring(end); value.substring(end);
target.selectionStart = start + markdown.length; target.selectionStart = start + markdown.length;
target.selectionEnd = end + markdown.length; target.selectionEnd = end + markdown.length;
target.focus(); target.focus();
target.dispatchEvent(new Event("input", { bubbles: true })); target.dispatchEvent(new Event("input", { bubbles: true }));
} }
</script> </script>
</SectionContent> </SectionContent>
@code { @code {
[CascadingParameter(Name = "TitlePrefix")] [CascadingParameter(Name = "TitlePrefix")]
private string TitlePrefix { get; set; } = default!; private string TitlePrefix { get; set; } = default!;
[CascadingParameter] [CascadingParameter]
private Task<AuthenticationState>? AuthenticationState { get; set; } private Task<AuthenticationState>? AuthenticationState { get; set; }
[Parameter] [Parameter]
public Guid? Id { get; set; } public Guid? Id { get; set; }
[SupplyParameterFromForm] [SupplyParameterFromForm]
private InputModel Model { get; set; } = new(); private InputModel Model { get; set; } = new();
private List<Category> Categories { get; set; } = new(); private List<Category> Categories { get; set; } = new();
private ApplicationUser? User { get; set; } private ApplicationUser? User { get; set; }
private Article? Article { get; set; } private Article? Article { get; set; }
private bool Busy { get; set; } = true; private bool Busy { get; set; } = true;
protected override async Task OnInitializedAsync() { protected override async Task OnInitializedAsync() {
if (AuthenticationState is null) throw new ApplicationException("???"); if (AuthenticationState is null) throw new ApplicationException("???");
var state = await AuthenticationState; var state = await AuthenticationState;
var user = await UserManager.GetUserAsync(state.User); var user = await UserManager.GetUserAsync(state.User);
User = user ?? throw new ApplicationException("???2"); User = user ?? throw new ApplicationException("???2");
await using var context = await ContextFactory.CreateDbContextAsync(); await using var context = await ContextFactory.CreateDbContextAsync();
if (Id is not null) { if (Id is not null) {
Article = await context.Set<Article>() Article = await context.Set<Article>()
.IgnoreQueryFilters().Where(a => !a.IsDeleted) .IgnoreQueryFilters().Where(a => !a.IsDeleted)
.Include(a => a.Author) .Include(a => a.Author)
.Include(a => a.Reviewer) .Include(a => a.Reviewer)
.Include(a => a.Categories) .Include(a => a.Categories)
.FirstAsync(a => a.Id == Id); .FirstAsync(a => a.Id == Id);
if (Article is null) throw new ApplicationException("Article not found."); if (Article is null) throw new ApplicationException("Article not found.");
// Check permissions // Check permissions
if (state.User.IsInRole("Admin")) { if (state.User.IsInRole("Admin")) {
// Admins always have access // Admins always have access
} else if (Article.Status is ArticleStatus.Draft && Article.Author.Id == User.Id) { } else if (Article.Status is ArticleStatus.Draft && Article.Author.Id == User.Id) {
// It's our draft // It's our draft
} else if (Article.Status is ArticleStatus.InReview && state.User.IsInRole("Reviewer")) { } else if (Article.Status is ArticleStatus.InReview && state.User.IsInRole("Reviewer")) {
// It's in reviewer, and we are reviewer // It's in reviewer, and we are reviewer
} else if (Article.Status is ArticleStatus.Published && state.User.IsInRole("Moderator")) { } else if (Article.Status is ArticleStatus.Published && state.User.IsInRole("Moderator")) {
// It's published, and we are moderator // It's published, and we are moderator
} else { } else {
throw new ApplicationException("You do not have permissions to edit this article"); throw new ApplicationException("You do not have permissions to edit this article");
} }
} }
Categories = await context.Set<Category>().OrderBy(c => c.Color).ToListAsync(); Categories = await context.Set<Category>().OrderBy(c => c.Color).ToListAsync();
if (Article is not null) { if (Article is not null) {
Model.Id ??= Article.Id; Model.Id ??= Article.Id;
Model.Title ??= Article.Title; Model.Title ??= Article.Title;
Model.Body ??= Article.Body; Model.Body ??= Article.Body;
Model.PublishDate ??= Article.PublishDate.LocalDateTime; Model.PublishDate ??= Article.PublishDate.LocalDateTime;
Model.Categories ??= Article.Categories.Select(c => c.Id).ToArray(); Model.Categories ??= Article.Categories.Select(c => c.Id).ToArray();
} }
Busy = false; Busy = false;
} }
private async Task OnValidSubmit() { private async Task OnValidSubmit() {
if (User is null) return; if (User is null) return;
Busy = true; Busy = true;
try { try {
await using var context = await ContextFactory.CreateDbContextAsync(); await using var context = await ContextFactory.CreateDbContextAsync();
context.Entry(User).State = EntityState.Unchanged; context.Entry(User).State = EntityState.Unchanged;
foreach (var category in Categories) { foreach (var category in Categories) {
context.Entry(category).State = EntityState.Unchanged; context.Entry(category).State = EntityState.Unchanged;
} }
Article article; Article article;
if (Model.Id is not null) { if (Model.Id is not null) {
article = await context.Set<Article>() article = await context.Set<Article>()
.IgnoreQueryFilters().Where(a => !a.IsDeleted) .IgnoreQueryFilters().Where(a => !a.IsDeleted)
.Include(a => a.Author) .Include(a => a.Author)
.Include(a => a.Reviewer) .Include(a => a.Reviewer)
.Include(a => a.Categories) .Include(a => a.Categories)
.FirstAsync(a => a.Id == Model.Id); .FirstAsync(a => a.Id == Model.Id);
article.Title = Model.Title!; article.Title = Model.Title!;
article.Body = Model.Body!; article.Body = Model.Body!;
} else { } else {
article = new Article { article = new Article {
Title = Model.Title!, Title = Model.Title!,
Body = Model.Body!, Body = Model.Body!,
Author = User Author = User
}; };
await context.AddAsync(article); 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); await HandleRoles(article, User);
article.LastModified = DateTimeOffset.UtcNow; article.LastModified = DateTimeOffset.UtcNow;
article.BodyHtml = MarkdownUtilities.Parse(article.Body); article.BodyHtml = MarkdownUtilities.Parse(article.Body);
foreach (var category in article.Categories.ToList()) { foreach (var category in article.Categories.ToList()) {
if (Model.Categories?.Contains(category.Id) is not true) { if (Model.Categories?.Contains(category.Id) is not true) {
article.Categories.Remove(category); article.Categories.Remove(category);
} }
} }
foreach (var categoryId in Model.Categories ?? Array.Empty<Guid>()) { foreach (var categoryId in Model.Categories ?? Array.Empty<Guid>()) {
if (article.Categories.Any(c => c.Id == categoryId) is not true) { if (article.Categories.Any(c => c.Id == categoryId) is not true) {
article.Categories.Add(Categories.First(c => c.Id == categoryId)); article.Categories.Add(Categories.First(c => c.Id == categoryId));
} }
} }
await context.SaveChangesAsync(); var newsletter = await context.Set<EmailNewsletter>().FirstOrDefaultAsync(n => n.Article == article);
Navigation.NavigateTo($"/article/{article.Id}"); if (newsletter is not null) {
} catch (Exception ex) { newsletter.DistributionDateTime = article.PublishDate;
// TODO toast }
} finally {
Busy = false; await context.SaveChangesAsync();
await InvokeAsync(StateHasChanged);
}
}
private async Task HandleRoles(Article article, ApplicationUser me) { Navigation.NavigateTo($"/article/{article.Id}");
// it's our draft } catch (Exception ex) {
if (article.Status is ArticleStatus.Draft && article.Author.Id == me.Id) return; // 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 var roles = await UserManager.GetRolesAsync(me);
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 // reviewers and admins can review articles
if (article.Status is ArticleStatus.Published && roles.Any(r => r is "Admin" or "Reviewer")) { if (article.Status is ArticleStatus.InReview && roles.Any(r => r is "Admin" or "Reviewer")) {
article.Reviewer = me; // TODO replace with editor or something? article.Reviewer = me;
return; 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) { private void CategorySelectionChanged(ChangeEventArgs args) {
Model.Categories = ((string[]?)args.Value)?.Select(Guid.Parse).ToArray(); Model.Categories = ((string[]?)args.Value)?.Select(Guid.Parse).ToArray();
} }
private sealed class InputModel { private sealed class InputModel {
public Guid? Id { get; set; } public Guid? Id { get; set; }
[Required(AllowEmptyStrings = false), MaxLength(256)] [Required(AllowEmptyStrings = false), MaxLength(256)]
public string? Title { get; set; } public string? Title { get; set; }
[Required(AllowEmptyStrings = false)] [Required(AllowEmptyStrings = false)]
public string? Body { get; set; } public string? Body { get; set; }
public Guid[]? Categories { get; set; } public Guid[]? Categories { get; set; }
public DateTimeOffset? PublishDate { get; set; } public DateTimeOffset? PublishDate { get; set; }
} }
} }

View file

@ -6,6 +6,7 @@
@using System.Diagnostics.CodeAnalysis @using System.Diagnostics.CodeAnalysis
@using Microsoft.Extensions.Options @using Microsoft.Extensions.Options
@inject ILogger<ArticleView> Logger
@inject IDbContextFactory<ApplicationDbContext> ContextFactory @inject IDbContextFactory<ApplicationDbContext> ContextFactory
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IOptions<Customization> Customizations @inject IOptions<Customization> Customizations
@ -15,62 +16,62 @@
<PageTitle>@(TitlePrefix + (Article?.Title ?? Localizer["NotFound_Title"]))</PageTitle> <PageTitle>@(TitlePrefix + (Article?.Title ?? Localizer["NotFound_Title"]))</PageTitle>
@if (Article is not null) { @if (Article is not null) {
<HeadContent> <HeadContent>
<meta name="author" content="@Article.Author.Name"> <meta name="author" content="@Article.Author.Name">
<meta name="description" content="@string.Format(Localizer["Meta_Description"], Customizations.Value.AppName, Article.Body[..Math.Min(80, Article.Body.Length)] + "... ")"> <meta name="description" content="@string.Format(Localizer["Meta_Description"], Customizations.Value.AppName, Article.Body[..Math.Min(80, Article.Body.Length)] + "... ")">
<!-- Open Graph --> <!-- Open Graph -->
<meta property="og:title" content="@Article.Title"> <meta property="og:title" content="@Article.Title">
<meta property="og:description" content="@string.Format(Localizer["Meta_Description"], Customizations.Value.AppName, Article.Body[..Math.Min(80, Article.Body.Length)] + "... ")"> <meta property="og:description" content="@string.Format(Localizer["Meta_Description"], Customizations.Value.AppName, Article.Body[..Math.Min(80, Article.Body.Length)] + "... ")">
<meta property="og:url" content="@Navigation.ToAbsoluteUri("/article/" + Article.Id)"> <meta property="og:url" content="@Navigation.ToAbsoluteUri("/article/" + Article.Id)">
<meta property="og:image" content="@Navigation.ToAbsoluteUri("/api/user/pfp/" + Article.Author.Id)"> <meta property="og:image" content="@Navigation.ToAbsoluteUri("/api/user/pfp/" + Article.Author.Id)">
<meta property="og:type" content="article"> <meta property="og:type" content="article">
<meta property="og:article:author" content="@Article.Author.Name"> <meta property="og:article:author" content="@Article.Author.Name">
<meta property="og:article:published_time" content="@Article.PublishDate.ToString("u")"> <meta property="og:article:published_time" content="@Article.PublishDate.ToString("u")">
@if (Article.LastModified.HasValue) { @if (Article.LastModified.HasValue) {
<meta property="og:article:modified_time" content="@Article.LastModified.Value.ToString("u")"> <meta property="og:article:modified_time" content="@Article.LastModified.Value.ToString("u")">
} }
<meta property="og:site_name" content="@Customizations.Value.AppName"> <meta property="og:site_name" content="@Customizations.Value.AppName">
@if (Features.Value.Rss) { @if (Features.Value.Rss) {
<link rel="alternate" type="application/rss+xml" title="RSS Feed on @Customizations.Value.AppName" href="/rss/rss.xml"> <link rel="alternate" type="application/rss+xml" title="RSS Feed on @Customizations.Value.AppName" href="/rss/rss.xml">
<link rel="alternate" type="application/atom+xml" title="Atom RSS Feed on @Customizations.Value.AppName" href="/rss/atom.xml"> <link rel="alternate" type="application/atom+xml" title="Atom RSS Feed on @Customizations.Value.AppName" href="/rss/atom.xml">
} }
</HeadContent> </HeadContent>
} }
<ErrorBoundary> <ErrorBoundary>
<ChildContent> <ChildContent>
<AuthorizeView Policy="ArticleEditOrReviewPermissions"> <AuthorizeView Policy="ArticleEditOrReviewPermissions">
<Authorized> <Authorized>
<ArticleComponent Article="@GetArticleProtected(context.User)" /> <ArticleComponent Article="@GetArticleProtected(context.User)" />
<div class="flex gap-2 mt-3 flex-wrap"> <div class="flex gap-2 mt-3 flex-wrap">
<a class="btn btn-info w-full sm:btn-wide" href="article/@Article.Id/edit" data-enhance-nav="false">@Localizer["Edit"]</a> <a class="btn btn-info w-full sm:btn-wide" href="article/@Article.Id/edit" data-enhance-nav="false">@Localizer["Edit"]</a>
@if (Article.Status is ArticleStatus.Draft) { @if (Article.Status is ArticleStatus.Draft) {
<form @formname="submit-for-review" method="post" @onsubmit="SubmitForReview" class="max-sm:w-full"> <form @formname="submit-for-review" method="post" @onsubmit="SubmitForReview" class="max-sm:w-full">
<AntiforgeryToken /> <AntiforgeryToken />
<button type="submit" class="btn btn-primary w-full sm:btn-wide">@Localizer["Review_Submit"]</button> <button type="submit" class="btn btn-primary w-full sm:btn-wide">@Localizer["Review_Submit"]</button>
</form> </form>
} else if (Article.Status is ArticleStatus.InReview) { } else if (Article.Status is ArticleStatus.InReview) {
<form @formname="submit-for-publish" method="post" @onsubmit="SubmitForPublish" class="max-sm:w-full"> <form @formname="submit-for-publish" method="post" @onsubmit="SubmitForPublish" class="max-sm:w-full">
<AntiforgeryToken /> <AntiforgeryToken />
<button type="submit" class="btn btn-primary w-full sm:btn-wide">@Localizer["Publish_Submit"]</button> <button type="submit" class="btn btn-primary w-full sm:btn-wide">@Localizer["Publish_Submit"]</button>
</form> </form>
} }
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
<ArticleComponent Article="@GetArticlePublic()" /> <ArticleComponent Article="@GetArticlePublic()" />
</NotAuthorized> </NotAuthorized>
</AuthorizeView> </AuthorizeView>
</ChildContent> </ChildContent>
<ErrorContent> <ErrorContent>
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["NotFound_Title"]</h1> <h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["NotFound_Title"]</h1>
<p class="my-3">@Localizer["NotFound_Description"]</p> <p class="my-3">@Localizer["NotFound_Description"]</p>
<a class="btn btn-primary" href="/">@Localizer["NotFound_BackToHome_Label"]</a> <a class="btn btn-primary" href="/">@Localizer["NotFound_BackToHome_Label"]</a>
@if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")?.ToLower() == "development") { @if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")?.ToLower() == "development") {
<p class="mt-3">[DEBUG] EXCEPTION MESSAGE: @context.Message</p> <p class="mt-3">[DEBUG] EXCEPTION MESSAGE: @context.Message</p>
} }
</ErrorContent> </ErrorContent>
</ErrorBoundary> </ErrorBoundary>
@code { @code {
@ -78,110 +79,124 @@
private string TitlePrefix { get; set; } = default!; private string TitlePrefix { get; set; } = default!;
#region Route Parameters #region Route Parameters
[Parameter] [Parameter]
public Guid? Id { get; set; } public Guid? Id { get; set; }
[Parameter] [Parameter]
public int? Year { get; set; } public int? Year { get; set; }
[Parameter] [Parameter]
public int? Month { get; set; } public int? Month { get; set; }
[Parameter] [Parameter]
public int? Day { get; set; } public int? Day { get; set; }
[Parameter] [Parameter]
public string? TitleEncoded { get; set; } public string? TitleEncoded { get; set; }
private DateTimeOffset? Date => private DateTimeOffset? Date =>
Year is {} y && Month is {} m && Day is {} d Year is {} y && Month is {} m && Day is {} d
? new DateTimeOffset(new DateTime(y, m, d)) : ? new DateTimeOffset(new DateTime(y, m, d)) :
null; null;
private string? Title => TitleEncoded is null ? null : Uri.UnescapeDataString(TitleEncoded.Replace("-", "%20").Replace("+", "-")); private string? Title => TitleEncoded is null ? null : Uri.UnescapeDataString(TitleEncoded.Replace("-", "%20").Replace("+", "-"));
#endregion #endregion
private Article? Article { get; set; } private Article? Article { get; set; }
[CascadingParameter] [CascadingParameter]
public HttpContext HttpContext { get; set; } = default!; public HttpContext HttpContext { get; set; } = default!;
private Article GetArticlePublic() { private Article GetArticlePublic() {
if (Article is null) throw new ApplicationException("Article not found."); if (Article is null) throw new ApplicationException("Article not found.");
if (Article.Status >= ArticleStatus.Published && Article.PublishDate <= DateTimeOffset.UtcNow) { if (Article.Status >= ArticleStatus.Published && Article.PublishDate <= DateTimeOffset.UtcNow) {
return Article; return Article;
} }
throw new ApplicationException("Article is not public."); throw new ApplicationException("Article is not public.");
} }
[SuppressMessage("ReSharper", "ConvertIfStatementToSwitchStatement")] [SuppressMessage("ReSharper", "ConvertIfStatementToSwitchStatement")]
private Article GetArticleProtected(ClaimsPrincipal principal) { private Article GetArticleProtected(ClaimsPrincipal principal) {
if (Article is null) throw new ApplicationException("Article not found."); if (Article is null) throw new ApplicationException("Article not found.");
// The Article is publicly available // The Article is publicly available
if (Article.Status >= ArticleStatus.Published && Article.PublishDate <= DateTimeOffset.UtcNow) { if (Article.Status >= ArticleStatus.Published && Article.PublishDate <= DateTimeOffset.UtcNow) {
return Article; return Article;
} }
// Admins always get access // Admins always get access
if (principal.IsInRole("Admin")) { if (principal.IsInRole("Admin")) {
return Article; return Article;
} }
// You can only access your own drafts // You can only access your own drafts
if (Article.Status is ArticleStatus.Draft) { if (Article.Status is ArticleStatus.Draft) {
if (Article.Author.Id == principal.FindFirst("Id")!.Value) { if (Article.Author.Id == principal.FindFirst("Id")!.Value) {
return Article; return Article;
} }
throw new ApplicationException("Cannot access draft article without being author or admin."); throw new ApplicationException("Cannot access draft article without being author or admin.");
} }
// InReview Articles can only be accessed by reviewers // InReview Articles can only be accessed by reviewers
if (Article.Status is ArticleStatus.InReview) { if (Article.Status is ArticleStatus.InReview) {
if (principal.IsInRole("Reviewer")) { if (principal.IsInRole("Reviewer")) {
return Article; return Article;
} }
throw new ApplicationException("Cannot access in-review article without being a reviewer or admin."); 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() { protected override void OnInitialized() {
// We need blocking calls here, bc otherwise Blazor will execute Render in parallel, // We need blocking calls here, bc otherwise Blazor will execute Render in parallel,
// running into a null pointer on the Article property and panicking // running into a null pointer on the Article property and panicking
if (Id is not null) { if (Id is not null) {
using var context = ContextFactory.CreateDbContext(); using var context = ContextFactory.CreateDbContext();
Article = context.Set<Article>() Article = context.Set<Article>()
.IgnoreQueryFilters().Where(a => !a.IsDeleted) .IgnoreQueryFilters().Where(a => !a.IsDeleted)
.Include(a => a.Author) .Include(a => a.Author)
.Include(a => a.Reviewer) .Include(a => a.Reviewer)
.Include(a => a.Categories) .Include(a => a.Categories)
.FirstOrDefault(a => a.Id == Id); .FirstOrDefault(a => a.Id == Id);
} else if (Date is { } date && Title is { } title) { } else if (Date is { } date && Title is { } title) {
using var context = ContextFactory.CreateDbContext(); using var context = ContextFactory.CreateDbContext();
Article = context.Set<Article>() Article = context.Set<Article>()
.IgnoreQueryFilters().Where(a => !a.IsDeleted) .IgnoreQueryFilters().Where(a => !a.IsDeleted)
.Include(a => a.Author) .Include(a => a.Author)
.Include(a => a.Reviewer) .Include(a => a.Reviewer)
.Include(a => a.Categories) .Include(a => a.Categories)
.FirstOrDefault(a => a.PublishDate.Date == date.Date && a.Title.ToLower() == title); .FirstOrDefault(a => a.PublishDate.Date == date.Date && a.Title.ToLower() == title);
} }
} }
private async Task SubmitForReview() { private async Task SubmitForReview() {
await using var context = await ContextFactory.CreateDbContextAsync(); await using var context = await ContextFactory.CreateDbContextAsync();
Article!.Status = ArticleStatus.InReview; Article!.Status = ArticleStatus.InReview;
context.Update(Article); context.Update(Article);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
Navigation.NavigateTo("/"); Navigation.NavigateTo("/");
} }
private async Task SubmitForPublish() { private async Task SubmitForPublish() {
await using var context = await ContextFactory.CreateDbContextAsync(); await using var context = await ContextFactory.CreateDbContextAsync();
Article!.Status = ArticleStatus.Published; Article!.Status = ArticleStatus.Published;
string userId = HttpContext.User.FindFirst("Id")!.Value; string userId = HttpContext.User.FindFirst("Id")!.Value;
if (Article.Author.Id != userId) { if (Article.Author.Id != userId) {
Article.Reviewer = await context.Users.FindAsync(userId); Article.Reviewer = await context.Users.FindAsync(userId);
} }
context.Update(Article); context.Update(Article);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
Navigation.NavigateTo("/");
} 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("/");
}
} }