Implemented custom slugs for articles
Some checks failed
Build, Tag, Push Docker Image / build (push) Has been cancelled
Create Release / Generate Release (push) Has been cancelled

This commit is contained in:
Mia Rose Winter 2024-02-28 12:20:22 +01:00
parent f1cb3d730b
commit 1e10d41cad
Signed by: miawinter
GPG key ID: 4B6F6A83178F595E
9 changed files with 718 additions and 12 deletions

View file

@ -1,6 +1,7 @@
@using Wave.Data @using Wave.Data
@using System.Net @using System.Net
@using System.Web @using System.Web
@using Wave.Utilities
<a href="@Link" target="_top" @attributes="AdditionalAttributes"> <a href="@Link" target="_top" @attributes="AdditionalAttributes">
@ChildContent @ChildContent
@ -13,10 +14,7 @@
public RenderFragment? ChildContent { get; set; } public RenderFragment? ChildContent { get; set; }
private string TitleEncoded => Uri.EscapeDataString(Article.Title.ToLowerInvariant()).Replace("-", "+").Replace("%20", "-"); private string TitleEncoded => Uri.EscapeDataString(Article.Title.ToLowerInvariant()).Replace("-", "+").Replace("%20", "-");
private string Link => private string Link => ArticleUtilities.GenerateArticleLink(Article, null);
Article.PublishDate.Year >= 9999
? $"/article/{Article.Id}"
: $"/{Article.PublishDate.Year}/{Article.PublishDate.Month:D2}/{Article.PublishDate.Day:D2}/{TitleEncoded}";
[Parameter(CaptureUnmatchedValues = true)] [Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; } public Dictionary<string, object>? AdditionalAttributes { get; set; }

View file

@ -5,6 +5,7 @@
@using System.Security.Claims @using System.Security.Claims
@using System.Diagnostics.CodeAnalysis @using System.Diagnostics.CodeAnalysis
@using System.Globalization @using System.Globalization
@using System.Net
@using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.Identity
@using Microsoft.Extensions.Options @using Microsoft.Extensions.Options
@using Wave.Services @using Wave.Services
@ -176,12 +177,13 @@
.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>() string? slug = TitleEncoded == null ? null : Uri.EscapeDataString(TitleEncoded);
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 && (slug != null && a.Slug == slug || a.Title.ToLower() == title));
} }
} }

View file

@ -1,6 +1,7 @@
@using Wave.Data @using Wave.Data
@using System.ComponentModel.DataAnnotations @using System.ComponentModel.DataAnnotations
@using System.Diagnostics.CodeAnalysis @using System.Diagnostics.CodeAnalysis
@using System.Net
@using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.Identity
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using Wave.Utilities @using Wave.Utilities
@ -24,11 +25,22 @@
<DataAnnotationsValidator/> <DataAnnotationsValidator/>
<input type="hidden" @bind-value="@Model.Id"/> <input type="hidden" @bind-value="@Model.Id"/>
<InputLabelComponent LabelText="@Localizer["Title_Label"]" For="() => Model.Title">
<InputText class="input input-bordered w-full" maxlength="256" required aria-required
@bind-Value="@Model.Title" placeholder="@Localizer["Title_Placeholder"]" autocomplete="off"/>
</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["Title_Label"]" For="() => Model.Title">
<InputText class="input input-bordered w-full" maxlength="256" required aria-required
@bind-Value="@Model.Title" placeholder="@Localizer["Title_Placeholder"]" autocomplete="off"/>
</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"
@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"> <InputLabelComponent LabelText="@Localizer["PublishDate_Label"]" For="() => Model.PublishDate">
@if (Article.Status is not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow) { @if (Article.Status is 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"
@ -137,7 +149,7 @@
@Localizer["EditorSubmit"] @Localizer["EditorSubmit"]
</button> </button>
@if (Article.Id != Guid.Empty) { @if (Article.Id != Guid.Empty) {
<a class="btn w-full sm:btn-wide" href="/article/@(Article.Id)"> <a class="btn w-full sm:btn-wide" href="@ArticleUtilities.GenerateArticleLink(Article, null)">
@Localizer["ViewArticle_Label"] @Localizer["ViewArticle_Label"]
</a> </a>
} }
@ -247,6 +259,7 @@
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.Slug ??= article.Slug;
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();
@ -267,6 +280,18 @@
if (Model.PublishDate is not null && if (Model.PublishDate is not null &&
(Article.Status is not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow)) (Article.Status is not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow))
Article.PublishDate = Model.PublishDate.Value; 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 if (!string.IsNullOrWhiteSpace(Model.Slug)) {
Article.Slug = WebUtility.UrlEncode(Model.Slug);
} else if (string.IsNullOrWhiteSpace(Article.Slug)) {
Article.Slug = Uri.EscapeDataString(Article.Title.ToLowerInvariant())
.Replace("-", "+")
.Replace("%20", "-");
Article.Slug = Article.Slug[..Math.Min(64, Article.Slug.Length)];
Model.Slug = Article.Slug;
}
Article.LastModified = DateTimeOffset.UtcNow; Article.LastModified = DateTimeOffset.UtcNow;
Article.BodyHtml = MarkdownUtilities.Parse(Article.Body); Article.BodyHtml = MarkdownUtilities.Parse(Article.Body);
Article.BodyPlain = HtmlUtilities.GetPlainText(Article.BodyHtml); Article.BodyPlain = HtmlUtilities.GetPlainText(Article.BodyHtml);
@ -361,6 +386,8 @@
[Required(AllowEmptyStrings = false), MaxLength(256)] [Required(AllowEmptyStrings = false), MaxLength(256)]
public string? Title { get; set; } public string? Title { get; set; }
[MaxLength(64)]
public string? Slug { get; set; }
[Required(AllowEmptyStrings = false)] [Required(AllowEmptyStrings = false)]
public string? Body { get; set; } public string? Body { get; set; }

View file

@ -37,6 +37,7 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
article.HasKey(a => a.Id); article.HasKey(a => a.Id);
article.Property(a => a.Title) article.Property(a => a.Title)
.IsRequired().HasMaxLength(256); .IsRequired().HasMaxLength(256);
article.Property(a => a.Slug).HasMaxLength(64).IsRequired().HasDefaultValue("");
article.HasOne(a => a.Author).WithMany(a => a.Articles) article.HasOne(a => a.Author).WithMany(a => a.Articles)
.IsRequired().OnDelete(DeleteBehavior.Cascade); .IsRequired().OnDelete(DeleteBehavior.Cascade);

View file

@ -22,6 +22,9 @@ public class Article : ISoftDelete {
public string BodyHtml { get; set; } = string.Empty; public string BodyHtml { get; set; } = string.Empty;
public string BodyPlain { get; set; } = string.Empty; public string BodyPlain { get; set; } = string.Empty;
[MaxLength(64)]
public string Slug { get; set; } = string.Empty;
public required ApplicationUser Author { get; set; } public required ApplicationUser Author { get; set; }
public ApplicationUser? Reviewer { get; set; } public ApplicationUser? Reviewer { get; set; }

View file

@ -0,0 +1,637 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Wave.Data;
#nullable disable
namespace Wave.Data.Migrations.postgres
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240228110154_ArticleCustomSlugs")]
partial class ArticleCustomSlugs
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("Npgsql:CollationDefinition:default-case-insensitive", "und-u-kf-upper-ks-level1,und-u-kf-upper-ks-level1,icu,False")
.HasAnnotation("ProductVersion", "8.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Wave.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("AboutTheAuthor")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("Biography")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("BiographyHtml")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("ContactEmail")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("ContactPhone")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ContactPhoneBusiness")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ContactWebsite")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("FullName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Wave.Data.Article", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AuthorId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BodyHtml")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BodyPlain")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("");
b.Property<DateTimeOffset>("CreationDate")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("LastModified")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset>("PublishDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReviewerId")
.HasColumnType("text");
b.Property<string>("Slug")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasDefaultValue("");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("ReviewerId");
b.ToTable("Articles", (string)null);
});
modelBuilder.Entity("Wave.Data.ArticleCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid>("ArticleId")
.HasColumnType("uuid");
b.Property<Guid>("CategoryId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ArticleId");
b.HasIndex("CategoryId");
b.ToTable("ArticleCategories", (string)null);
});
modelBuilder.Entity("Wave.Data.ArticleImage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("ArticleId")
.HasColumnType("uuid");
b.Property<string>("ImageDescription")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.HasIndex("ArticleId");
b.ToTable("Images", (string)null);
});
modelBuilder.Entity("Wave.Data.Category", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("Color")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(25);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.UseCollation("default-case-insensitive");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Categories", (string)null);
});
modelBuilder.Entity("Wave.Data.EmailNewsletter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid>("ArticleId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("DistributionDateTime")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSend")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("ArticleId")
.IsUnique();
b.ToTable("Newsletter", (string)null);
});
modelBuilder.Entity("Wave.Data.EmailSubscriber", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.UseCollation("default-case-insensitive");
b.Property<string>("Name")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<bool>("Unsubscribed")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("Unsubscribed");
b.ToTable("NewsletterSubscribers", (string)null);
});
modelBuilder.Entity("Wave.Data.ProfilePicture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ApplicationUserId")
.HasColumnType("text");
b.Property<Guid>("ImageId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("ProfilePictures", (string)null);
});
modelBuilder.Entity("Wave.Data.UserLink", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ApplicationUserId")
.HasColumnType("text");
b.Property<string>("UrlString")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Id");
b.HasIndex("ApplicationUserId");
b.ToTable("UserLink");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Wave.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Wave.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Wave.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Wave.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Wave.Data.Article", b =>
{
b.HasOne("Wave.Data.ApplicationUser", "Author")
.WithMany("Articles")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Wave.Data.ApplicationUser", "Reviewer")
.WithMany()
.HasForeignKey("ReviewerId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Author");
b.Navigation("Reviewer");
});
modelBuilder.Entity("Wave.Data.ArticleCategory", b =>
{
b.HasOne("Wave.Data.Article", "Article")
.WithMany()
.HasForeignKey("ArticleId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.HasOne("Wave.Data.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.Navigation("Article");
b.Navigation("Category");
});
modelBuilder.Entity("Wave.Data.ArticleImage", b =>
{
b.HasOne("Wave.Data.Article", null)
.WithMany("Images")
.HasForeignKey("ArticleId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Wave.Data.EmailNewsletter", b =>
{
b.HasOne("Wave.Data.Article", "Article")
.WithOne()
.HasForeignKey("Wave.Data.EmailNewsletter", "ArticleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Article");
});
modelBuilder.Entity("Wave.Data.ProfilePicture", b =>
{
b.HasOne("Wave.Data.ApplicationUser", null)
.WithOne("ProfilePicture")
.HasForeignKey("Wave.Data.ProfilePicture", "ApplicationUserId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("Wave.Data.UserLink", b =>
{
b.HasOne("Wave.Data.ApplicationUser", null)
.WithMany("Links")
.HasForeignKey("ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Wave.Data.ApplicationUser", b =>
{
b.Navigation("Articles");
b.Navigation("Links");
b.Navigation("ProfilePicture");
});
modelBuilder.Entity("Wave.Data.Article", b =>
{
b.Navigation("Images");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Wave.Data.Migrations.postgres;
/// <inheritdoc />
public partial class ArticleCustomSlugs : Migration {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) {
migrationBuilder.AddColumn<string>(
name: "Slug",
table: "Articles",
type: "character varying(64)",
maxLength: 64,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) {
migrationBuilder.DropColumn(
name: "Slug",
table: "Articles");
}
}

View file

@ -300,6 +300,13 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<string>("ReviewerId") b.Property<string>("ReviewerId")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Slug")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasDefaultValue("");
b.Property<int>("Status") b.Property<int>("Status")
.HasColumnType("integer"); .HasColumnType("integer");

View file

@ -7,10 +7,15 @@ public static class ArticleUtilities {
string link; string link;
if (article.PublishDate.Year >= 9999) { if (article.PublishDate.Year >= 9999) {
link = $"/article/{article.Id}"; link = $"/article/{article.Id}";
} else if (!string.IsNullOrWhiteSpace(article.Slug)) {
link = $"/{article.PublishDate.Year}/{article.PublishDate.Month:D2}/{article.PublishDate.Day:D2}/{article.Slug}";
} else { } else {
string titleEncoded = Uri.EscapeDataString(article.Title.ToLowerInvariant()).Replace("-", "+").Replace("%20", "-"); string titleEncoded = Uri.EscapeDataString(article.Title.ToLowerInvariant()).Replace("-", "+").Replace("%20", "-");
link = $"/{article.PublishDate.Year}/{article.PublishDate.Month:D2}/{article.PublishDate.Day:D2}/{titleEncoded}"; link = $"/{article.PublishDate.Year}/{article.PublishDate.Month:D2}/{article.PublishDate.Day:D2}/{titleEncoded}";
} }
return (host != null ? new Uri(host, link) : new Uri(link)).AbsoluteUri;
if (host is null) return link;
return new Uri(host, link).AbsoluteUri;
} }
} }