314 lines
12 KiB
Plaintext
314 lines
12 KiB
Plaintext
@using Wave.Data
|
|
@using System.ComponentModel.DataAnnotations
|
|
@using System.Diagnostics.CodeAnalysis
|
|
@using System.Net
|
|
@using System.Security.Claims
|
|
@using Humanizer
|
|
@using Microsoft.AspNetCore.Identity
|
|
@using Microsoft.EntityFrameworkCore
|
|
@using Wave.Services
|
|
@using Wave.Utilities
|
|
|
|
@inject ILogger<ArticleEditor> Logger
|
|
@inject NavigationManager Navigation
|
|
@inject UserManager<ApplicationUser> UserManager
|
|
@inject IDbContextFactory<ApplicationDbContext> ContextFactory
|
|
@inject IStringLocalizer<ArticleEditor> Localizer
|
|
@inject IMessageDisplay Message
|
|
@inject ImageService Images
|
|
|
|
<div class="w-full">
|
|
<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.InReview ? "step-secondary" : "")">@Localizer["InReview"]</li>
|
|
<li class="step @(Article.Status >= ArticleStatus.Published ? "step-secondary" : "")">@Localizer["Published"]</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<EditForm method="post" FormName="article-editor" Model="@Model" OnValidSubmit="OnValidSubmit">
|
|
<DataAnnotationsValidator/>
|
|
<input type="hidden" @bind-value="@Model.Id"/>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-8">
|
|
<InputLabelComponent LabelText="@Localizer["Title_Label"]" For="() => Model.Title">
|
|
<InputText class="input input-bordered w-full" maxlength="256" required aria-required oninput="charactersLeft_onInput(this)"
|
|
@bind-Value="@Model.Title" placeholder="@Localizer["Title_Placeholder"]" autocomplete="off"/>
|
|
</InputLabelComponent>
|
|
|
|
<InputLabelComponent class="row-span-3" LabelText="@Localizer["Categories_Label"]" For="() => Model.Categories">
|
|
<InputSelect class="select select-bordered w-full" @bind-Value="@Model.Categories" multiple size="10">
|
|
@foreach (var group in Categories.GroupBy(c => c.Color)) {
|
|
<optgroup class="font-bold not-italic my-3" label="@group.Key.Humanize()">
|
|
@foreach (var category in group) {
|
|
<option value="@category.Id" selected="@Model.Categories?.Contains(category.Id)">@category.Name</option>
|
|
}
|
|
</optgroup>
|
|
}
|
|
</InputSelect>
|
|
</InputLabelComponent>
|
|
|
|
<InputLabelComponent LabelText="@Localizer["Slug_Label"]" For="() => Model.Slug">
|
|
@if (Article.Status is not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow) {
|
|
<InputText class="input input-bordered w-full" maxlength="64" oninput="charactersLeft_onInput(this)"
|
|
@bind-Value="@Model.Slug" placeholder="@Localizer["Slug_Placeholder"]" autocomplete="off"/>
|
|
} else {
|
|
<input class="input input-bordered w-full" readonly value="@Model.Slug"
|
|
placeholder="@Localizer["Slug_Placeholder"]" autocomplete="off" />
|
|
}
|
|
</InputLabelComponent>
|
|
|
|
<InputLabelComponent LabelText="@Localizer["PublishDate_Label"]" For="() => Model.PublishDate">
|
|
@if (Article.Status is not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow) {
|
|
<InputDate class="input input-bordered w-full" Type="InputDateType.DateTimeLocal"
|
|
@bind-Value="@Model.PublishDate" placeholder="@Localizer["PublishDate_Placeholder"]" autocomplete="off"/>
|
|
} else {
|
|
<input class="input input-bordered w-full"
|
|
type="datetime-local" readonly value="@Article.PublishDate.ToString("yyyy-MM-dd\\THH:mm:ss")"/>
|
|
}
|
|
</InputLabelComponent>
|
|
</div>
|
|
|
|
<AdvancedMarkdownEditor Title="@Model.Title" MarkdownCallback="() => Model.Body">
|
|
<textarea id="tool-target" class="textarea textarea-bordered outline-none w-full flex-1 join-item"
|
|
required aria-required placeholder="@Localizer["Body_Placeholder"]"
|
|
@bind="@Model.Body" @bind:event="oninput" autocomplete="off"></textarea>
|
|
</AdvancedMarkdownEditor>
|
|
|
|
<div class="flex gap-2 flex-wrap mt-3">
|
|
<button type="submit" class="btn btn-primary w-full sm:btn-wide @(Saving ? "btn-loading" : "")" disabled="@Saving">
|
|
@Localizer["EditorSubmit"]
|
|
</button>
|
|
@if (Article.Id != Guid.Empty) {
|
|
<a class="btn w-full sm:btn-wide" href="@ArticleUtilities.GenerateArticleLink(Article, null)">
|
|
@Localizer["ViewArticle_Label"]
|
|
</a>
|
|
}
|
|
</div>
|
|
</EditForm>
|
|
|
|
<ImageModal Id="@ImageModal" ImageAdded="ImageAdded" />
|
|
<div class="my-3 flex flex-wrap gap-4 min-h-24">
|
|
@foreach (var image in Article.Images) {
|
|
<figure class="p-2 bg-base-200 relative">
|
|
<img class="w-40" src="/images/@(image.Id)?size=400" width="400"
|
|
title="@image.ImageDescription" alt="@image.ImageDescription"/>
|
|
<figcaption>
|
|
<button type="button" class="btn btn-info w-full mt-3"
|
|
onclick="navigator.clipboard.writeText(')?size=400)')">
|
|
@Localizer["Image_CopyLink"]
|
|
</button>
|
|
</figcaption>
|
|
<button type="button" class="btn btn-square btn-sm btn-error absolute top-0 right-0"
|
|
title="@Localizer["Image_Delete_Label"]"
|
|
@onclick="async () => await ImageDelete(image)">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
|
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
</figure>
|
|
}
|
|
<button type="button" class="btn" onclick="@(ImageModal).showModal()">@Localizer["Image_Add_Label"]</button>
|
|
</div>
|
|
|
|
@code {
|
|
private const string ImageModal = "AddImage";
|
|
|
|
[Parameter]
|
|
public Guid? Id { get; set; }
|
|
[Parameter]
|
|
public required ApplicationUser User { get; set; }
|
|
[Parameter]
|
|
public required ClaimsPrincipal ClaimsUser { get; set; }
|
|
|
|
[SupplyParameterFromForm]
|
|
private InputModel Model { get; set; } = new();
|
|
|
|
private Article Article { get; set; } = default!;
|
|
private List<Category> 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();
|
|
if (Categories.Count < 1)
|
|
Categories = await context.Set<Category>().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<Article>()
|
|
.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.");
|
|
}
|
|
|
|
if (article is not null) {
|
|
if (!article.AllowedToEdit(ClaimsUser))
|
|
throw new ApplicationException("You are not allowed to edit this article");
|
|
|
|
Model.Id ??= article.Id;
|
|
Model.Title ??= article.Title;
|
|
Model.Slug ??= article.Slug;
|
|
Model.Body ??= article.Body;
|
|
Model.PublishDate ??= article.PublishDate.LocalDateTime;
|
|
if (Model.Categories?.Length < 1) {
|
|
Model.Categories = article.Categories.Select(c => c.Id).ToArray();
|
|
}
|
|
Article = article;
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
}
|
|
|
|
private async Task OnValidSubmit() {
|
|
if (Article.AllowedToEdit(ClaimsUser) is false) {
|
|
Message.ShowError("Permission denied.");
|
|
return;
|
|
}
|
|
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;
|
|
if (Article.Status is ArticleStatus.Published && Article.PublishDate < DateTimeOffset.Now) {
|
|
// can't change slugs when the article is public
|
|
} else {
|
|
Article.UpdateSlug(Model.Slug);
|
|
Model.Slug = Article.Slug;
|
|
}
|
|
|
|
Article.LastModified = DateTimeOffset.UtcNow;
|
|
|
|
await using var context = await ContextFactory.CreateDbContextAsync();
|
|
|
|
// Update Newsletter distribution if exists
|
|
var newsletter = await context.Set<EmailNewsletter>()
|
|
.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<ArticleCategory>().IgnoreQueryFilters().IgnoreAutoIncludes()
|
|
.Where(ac => ac.Article.Id == Article.Id).LoadAsync();
|
|
|
|
context.Update(Article);
|
|
context.RemoveRange(Article.Headings);
|
|
Article.UpdateBody();
|
|
|
|
var existingImages = await context.Set<Article>()
|
|
.IgnoreQueryFilters().Where(a => a.Id == Article.Id)
|
|
.AsNoTrackingWithIdentityResolution()
|
|
.SelectMany(a => a.Images).ToListAsync();
|
|
foreach (var image in Article.Images) {
|
|
int index = existingImages.FindIndex(a => a.Id == image.Id);
|
|
context.Entry(image).State = index > -1 ? EntityState.Modified : EntityState.Added;
|
|
if(index > -1) existingImages.RemoveAt(index);
|
|
}
|
|
foreach (var image in existingImages) {
|
|
context.Entry(image).State = EntityState.Deleted;
|
|
}
|
|
|
|
var relations = await context.Set<ArticleCategory>()
|
|
.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();
|
|
foreach (var image in existingImages) {
|
|
try {
|
|
Images.Delete(image.Id);
|
|
} catch (Exception ex) {
|
|
Logger.LogWarning(ex, "Failed to delete image: {image}", image.Id);
|
|
}
|
|
}
|
|
Message.ShowSuccess(Localizer["Save_Success"]);
|
|
|
|
if (Navigation.Uri.EndsWith("/article/new")) {
|
|
Navigation.NavigateTo($"/article/{Article.Id}/edit", false, true);
|
|
}
|
|
} 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 "Moderator")) {
|
|
article.Reviewer = me; // TODO replace with editor or something?
|
|
return;
|
|
}
|
|
|
|
throw new ApplicationException("You do not have permissions to edit this article");
|
|
}
|
|
|
|
private async Task ImageAdded(ArticleImage image) {
|
|
Article.Images.Add(image);
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
|
|
private async Task ImageDelete(ArticleImage image) {
|
|
Article.Images.Remove(image);
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
|
|
private sealed class InputModel {
|
|
public Guid? Id { get; set; }
|
|
|
|
[Required(AllowEmptyStrings = false), MaxLength(256)]
|
|
public string? Title { get; set; }
|
|
[MaxLength(64)]
|
|
public string? Slug { get; set; }
|
|
[Required(AllowEmptyStrings = false)]
|
|
public string? Body { get; set; }
|
|
|
|
public Guid[]? Categories { get; set; } = [];
|
|
public DateTimeOffset? PublishDate { get; set; }
|
|
}
|
|
|
|
} |