Implemented Categories in Article Editor and View

This commit is contained in:
Mia Rose Winter 2024-01-26 14:15:28 +01:00
parent df7c7c7534
commit 7636585023
Signed by: miawinter
GPG key ID: 4B6F6A83178F595E
6 changed files with 217 additions and 158 deletions

View file

@ -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
@ -9,24 +10,33 @@
@Article.Title @Article.Title
</h1> </h1>
<p> <p>
<small class="text-sm"> <small class="text-sm">
<time datetime="@Article.PublishDate.ToString("u")" <time datetime="@Article.PublishDate.ToString("u")"
title="@Article.PublishDate.ToString("g")"> title="@Article.PublishDate.ToString("g")">
@Article.PublishDate.Humanize() @Article.PublishDate.Humanize()
</time> </time>
@if (Article.LastModified is not null && Article.LastModified > Article.PublishDate) { @if (Article.LastModified is not null && Article.LastModified > Article.PublishDate) {
<time datetime="@Article.LastModified.Value.ToString("u")" <time datetime="@Article.LastModified.Value.ToString("u")"
title="@Article.LastModified.Value.ToString("g")"> title="@Article.LastModified.Value.ToString("g")">
&ensp;(@Localizer["ModifiedOn"] @Article.LastModified.Humanize()) &ensp;(@Localizer["ModifiedOn"] @Article.LastModified.Humanize())
</time> </time>
} }
@if (Article.Status < ArticleStatus.Published) { @if (Article.Status < ArticleStatus.Published) {
<span class="badge badge-sm badge-outline badge-warning ml-2"> <span class="badge badge-sm badge-outline badge-warning ml-2">
@Article.Status.Humanize() @Article.Status.Humanize()
</span> </span>
} }
</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

View file

@ -36,120 +36,129 @@
</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>
<InputLabelComponent LabelText="@Localizer["PublishDate_Label"]" For="() => Model.PublishDate"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-x-8">
@if (Article?.Status is null or not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow) { <InputLabelComponent LabelText="@Localizer["PublishDate_Label"]" For="() => Model.PublishDate">
<InputDate class="input input-bordered w-full" Type="InputDateType.DateTimeLocal" @if (Article?.Status is null or not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow) {
@bind-Value="@Model.PublishDate" placeholder="@Localizer["PublishDate_Placeholder"]" autocomplete="off" /> <InputDate class="input input-bordered w-full" Type="InputDateType.DateTimeLocal"
} else { @bind-Value="@Model.PublishDate" placeholder="@Localizer["PublishDate_Placeholder"]" autocomplete="off" />
<input class="input input-bordered w-full" } else {
type="datetime-local" readonly value="@Article?.PublishDate.ToString("yyyy-MM-dd\\THH:mm:ss")" /> <input class="input input-bordered w-full"
} 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">
<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 />
</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");
await using var context = await ContextFactory.CreateDbContextAsync();
if (Id is not null) { if (Id is not null) {
await using var context = await ContextFactory.CreateDbContextAsync();
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,33 +269,56 @@
if (User is null) return; if (User is null) return;
Busy = true; Busy = true;
await using var context = await ContextFactory.CreateDbContextAsync(); try {
context.Entry(User).State = EntityState.Unchanged; 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; 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)
.FirstAsync(a => a.Id == Model.Id); .Include(a => a.Categories)
article.Title = Model.Title!; .FirstAsync(a => a.Id == Model.Id);
article.Body = Model.Body!; article.Title = Model.Title!;
} else { article.Body = Model.Body!;
article = new Article { } else {
Title = Model.Title!, article = new Article {
Body = Model.Body!, Title = Model.Title!,
Author = User Body = Model.Body!,
}; Author = User
await context.AddAsync(article); };
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<Guid>()) {
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);
} }
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);
await context.SaveChangesAsync();
Navigation.NavigateTo($"/article/{article.Id}");
} }
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 DateTimeOffset? PublishDate { get; set; } public Guid[]? Categories { get; set; }
public DateTimeOffset? PublishDate { get; set; }
} }
} }

View file

@ -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);
} }
} }

View file

@ -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),

View file

@ -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; } = [];
} }

File diff suppressed because one or more lines are too long