Implemented Categories in Article Editor and View
This commit is contained in:
parent
df7c7c7534
commit
7636585023
|
@ -1,5 +1,6 @@
|
||||||
@using Wave.Data
|
@using Wave.Data
|
||||||
@using Humanizer
|
@using Humanizer
|
||||||
|
@using Wave.Utilities
|
||||||
|
|
||||||
@inject IStringLocalizer<Pages.ArticleView> Localizer
|
@inject IStringLocalizer<Pages.ArticleView> Localizer
|
||||||
|
|
||||||
|
@ -27,6 +28,15 @@
|
||||||
}
|
}
|
||||||
</small>
|
</small>
|
||||||
</p>
|
</p>
|
||||||
|
@if (Article.Categories.Count > 0) {
|
||||||
|
<p class="flex flex-wrap gap-2 my-3">
|
||||||
|
@foreach (var category in Article.Categories) {
|
||||||
|
<span class="badge badge-@CategoryUtilities.GetCssClassPostfixForColor(category.Color)">
|
||||||
|
@category.Name
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
</header>
|
</header>
|
||||||
<div class="prose prose-neutral max-w-none hyphens-auto text-justify">
|
<div class="prose prose-neutral max-w-none hyphens-auto text-justify">
|
||||||
@Content
|
@Content
|
||||||
|
|
|
@ -43,6 +43,7 @@
|
||||||
<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">
|
||||||
<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"
|
||||||
|
@ -52,6 +53,14 @@
|
||||||
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">
|
||||||
|
<select class="select select-bordered w-full h-32" @onchange="CategorySelectionChanged" multiple>
|
||||||
|
@foreach (var category in Categories) {
|
||||||
|
<option value="@category.Id" selected="@Model.Categories?.Contains(category.Id)">@category.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</InputLabelComponent>
|
||||||
|
</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">
|
||||||
|
@ -142,14 +151,14 @@
|
||||||
<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 />
|
||||||
</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>
|
||||||
|
@ -210,6 +219,7 @@
|
||||||
[SupplyParameterFromForm]
|
[SupplyParameterFromForm]
|
||||||
private InputModel Model { get; set; } = new();
|
private InputModel Model { 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;
|
||||||
|
@ -220,11 +230,12 @@
|
||||||
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");
|
||||||
|
|
||||||
if (Id is not null) {
|
|
||||||
await using var context = await ContextFactory.CreateDbContextAsync();
|
await using var context = await ContextFactory.CreateDbContextAsync();
|
||||||
|
if (Id is not null) {
|
||||||
Article = await context.Set<Article>()
|
Article = await context.Set<Article>()
|
||||||
.Include(a => a.Author)
|
.Include(a => a.Author)
|
||||||
.Include(a => a.Reviewer)
|
.Include(a => a.Reviewer)
|
||||||
|
.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.");
|
||||||
|
|
||||||
|
@ -241,10 +252,16 @@
|
||||||
throw new ApplicationException("You do not have permissions to edit this article");
|
throw new ApplicationException("You do not have permissions to edit this article");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Model.Id ??= Article?.Id;
|
Categories = await context.Set<Category>().OrderBy(c => c.Color).ToListAsync();
|
||||||
Model.Title ??= Article?.Title;
|
|
||||||
Model.Body ??= Article?.Body;
|
if (Article is not null) {
|
||||||
Model.PublishDate ??= Article?.PublishDate.LocalDateTime;
|
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;
|
Busy = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -252,14 +269,19 @@
|
||||||
if (User is null) return;
|
if (User is null) return;
|
||||||
Busy = true;
|
Busy = true;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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>()
|
||||||
.Include(a => a.Author)
|
.Include(a => a.Author)
|
||||||
.Include(a => a.Reviewer)
|
.Include(a => a.Reviewer)
|
||||||
|
.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!;
|
||||||
|
@ -271,14 +293,32 @@
|
||||||
};
|
};
|
||||||
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()) {
|
||||||
|
if (Model.Categories?.Contains(category.Id) is not true) {
|
||||||
|
article.Categories.Remove(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var categoryId in Model.Categories ?? Array.Empty<Guid>()) {
|
||||||
|
if (article.Categories.Any(c => c.Id == categoryId) is not true) {
|
||||||
|
article.Categories.Add(Categories.First(c => c.Id == categoryId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
Navigation.NavigateTo($"/article/{article.Id}");
|
Navigation.NavigateTo($"/article/{article.Id}");
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// TODO toast
|
||||||
|
} finally {
|
||||||
|
Busy = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleRoles(Article article, ApplicationUser me) {
|
private async Task HandleRoles(Article article, ApplicationUser me) {
|
||||||
|
@ -302,6 +342,10 @@
|
||||||
throw new ApplicationException("You do not have permissions to edit this article");
|
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 {
|
private sealed class InputModel {
|
||||||
public Guid? Id { get; set; }
|
public Guid? Id { get; set; }
|
||||||
[Required(AllowEmptyStrings = false), MaxLength(256)]
|
[Required(AllowEmptyStrings = false), MaxLength(256)]
|
||||||
|
@ -309,6 +353,7 @@
|
||||||
[Required(AllowEmptyStrings = false)]
|
[Required(AllowEmptyStrings = false)]
|
||||||
public string? Body { get; set; }
|
public string? Body { get; set; }
|
||||||
|
|
||||||
|
public Guid[]? Categories { get; set; }
|
||||||
public DateTimeOffset? PublishDate { get; set; }
|
public DateTimeOffset? PublishDate { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -140,12 +140,14 @@
|
||||||
Article = context.Set<Article>()
|
Article = context.Set<Article>()
|
||||||
.Include(a => a.Author)
|
.Include(a => a.Author)
|
||||||
.Include(a => a.Reviewer)
|
.Include(a => a.Reviewer)
|
||||||
|
.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>()
|
||||||
.Include(a => a.Author)
|
.Include(a => a.Author)
|
||||||
.Include(a => a.Reviewer)
|
.Include(a => a.Reviewer)
|
||||||
|
.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,7 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
|
||||||
category.Property(c => c.Name).IsRequired().HasMaxLength(128);
|
category.Property(c => c.Name).IsRequired().HasMaxLength(128);
|
||||||
category.Property(c => c.Color).IsRequired().HasDefaultValue(CategoryColors.Default);
|
category.Property(c => c.Color).IsRequired().HasDefaultValue(CategoryColors.Default);
|
||||||
|
|
||||||
category.HasMany<Article>().WithMany()
|
category.HasMany<Article>().WithMany(a => a.Categories)
|
||||||
.UsingEntity<ArticleCategory>(
|
.UsingEntity<ArticleCategory>(
|
||||||
ac => ac.HasOne(a => a.Article).WithMany().OnDelete(DeleteBehavior.NoAction),
|
ac => ac.HasOne(a => a.Article).WithMany().OnDelete(DeleteBehavior.NoAction),
|
||||||
ac => ac.HasOne(a => a.Category).WithMany().OnDelete(DeleteBehavior.NoAction),
|
ac => ac.HasOne(a => a.Category).WithMany().OnDelete(DeleteBehavior.NoAction),
|
||||||
|
|
|
@ -29,4 +29,6 @@ public class Article : ISoftDelete {
|
||||||
public DateTimeOffset CreationDate { get; set; } = DateTimeOffset.Now;
|
public DateTimeOffset CreationDate { get; set; } = DateTimeOffset.Now;
|
||||||
public DateTimeOffset PublishDate { get; set; } = DateTimeOffset.MaxValue;
|
public DateTimeOffset PublishDate { get; set; } = DateTimeOffset.MaxValue;
|
||||||
public DateTimeOffset? LastModified { get; set; }
|
public DateTimeOffset? LastModified { get; set; }
|
||||||
|
|
||||||
|
public IList<Category> Categories { get; } = [];
|
||||||
}
|
}
|
2
Wave/wwwroot/css/main.min.css
vendored
2
Wave/wwwroot/css/main.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue