Compare commits

...

28 commits

Author SHA1 Message Date
Mia Rose Winter 66b14baee8
Implemented editing Email Subscribers 2024-05-28 13:33:09 +02:00
Mia Rose Winter fdb0e1a40e
Changed Email Signup localization to mark the name field as optional 2024-05-28 12:12:35 +02:00
Mia Rose Winter 4a7da5518b
Implemented rejecting review articles (will be returned to authors drafts) 2024-05-28 12:08:06 +02:00
Mia Rose Winter a78a420a61
fixed ToC not rendering html entities like ellipsis 2024-05-06 13:25:38 +02:00
Mia Rose Winter e5fb7f391f
Improved Subscriber Page table 2024-05-06 13:01:36 +02:00
Mia Rose Winter 51ace95c76
Implemented Table of Contents (only on newly saved articles) 2024-05-02 15:20:51 +02:00
Mia Rose Winter c98293cc0a
Added characters-left indicator to certain input fields, adjusted character limits 2024-05-02 13:57:31 +02:00
Mia Rose Winter d30ae09b9b
updated tests added more slug tests 2024-05-02 13:30:18 +02:00
Mia Rose Winter b72f47a99f
fixed slug generation for good now (hopefully) 2024-04-30 16:20:52 +02:00
Mia Rose Winter 52ea7a5dfc
updated tests, added slug generation tests 2024-04-30 15:47:49 +02:00
Mia Rose Winter 292433d200
Improved Article slug generation 2024-04-30 15:47:42 +02:00
Mia Rose Winter 2c44951e13
updated tests, added Testcontainers, added ApplicationDbContext 2024-04-30 14:49:38 +02:00
Mia Rose Winter e1cab9b53f
Improved article body formatting 2024-04-30 14:48:58 +02:00
Mia Rose Winter e816b9fb43
fixed subscribers page sometimes missing pages 2024-04-30 13:42:13 +02:00
Mia Rose Winter 2eb1305ea5
fixed back-navigation issues on newly created articles, by modifying history state on save 2024-04-30 13:32:53 +02:00
Mia Rose Winter 0ea1251150
fixed Issues with article slug generation and article lookup 2024-04-30 13:26:02 +02:00
Mia Rose Winter 5c7bf8be04
Improved Subscribers Page with better table sizing, better mobile view and items per page dropdown 2024-04-29 15:09:04 +02:00
Mia Rose Winter acc2e02961
fixed account url casing (google is weird about it) 2024-04-29 13:27:07 +02:00
Mia Rose Winter bc4a78382c
fixed missing JS functions in Article Editor 2024-04-29 12:43:43 +02:00
Mia Rose Winter 0d3111647b
updated readme added Nunit Tests status badge 2024-04-24 16:41:43 +02:00
Mia Rose Winter fb55641118
updated action fixed testing action 2024-04-24 16:36:04 +02:00
Mia Rose Winter 933e0af42b
updated github actions added testing action 2024-04-24 16:34:31 +02:00
Mia Rose Winter e6117d6b06
created test project 2024-04-24 16:18:56 +02:00
Mia Rose Winter 7e372791ee
fixed off-by-one error in subscribers page 2024-04-22 14:46:16 +02:00
Mia Rose Winter e3bec1cc8d
Merge branch 'main' of https://github.com/miawinter98/Wave 2024-04-22 14:17:46 +02:00
Mia Rose Winter bb2f1f5c92
Implemented add subscribers on Subscribers Page (CSV) 2024-04-22 14:17:11 +02:00
Mia Rose Winter 4a4110b7ae
Added Subscribers page that list newsletter subscribers (paged) 2024-04-22 13:28:42 +02:00
Mia Rose Winter 3aee412a4e
fixed can't delete own draft because Author is not loaded 2024-04-22 12:58:38 +02:00
44 changed files with 2527 additions and 110 deletions

40
.github/workflows/testing.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: NUnit Tests
on:
push:
branches:
- main
pull_request:
branches:
- main
env:
TEST_PROJECT: Wave.Tests
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
dotnet-version: [ '8.0.x' ]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup dotnet ${{ matrix.dotnet-version }}
uses: actions/setup-dotnet@v3
with:
dotnet-version: ${{ matrix.dotnet-version }}
- uses: actions/cache@v3
with:
path: ~/.nuget/packages
# Look to see if there is a cache hit for the corresponding requirements file
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget
- name: Install dependencies
run: dotnet restore ${{ env.TEST_PROJECT }}
- name: Test with the dotnet CLI
run: dotnet test ${{ env.TEST_PROJECT }}

View file

@ -3,6 +3,8 @@
# Wave - The Collaborative Open-Source Blogging Engine
## Stay afloat in a current of Information
[![NUnit Tests](https://github.com/miawinter98/Wave/actions/workflows/testing.yml/badge.svg?branch=main)](https://github.com/miawinter98/Wave/actions/workflows/testing.yml)
![Wave License](https://img.shields.io/github/license/miawinter98/Wave?color=green&style=for-the-badge)
![GitHub Forks](https://img.shields.io/github/forks/miawinter98/Wave?label=github%20forks&logo=github&style=for-the-badge)
![GitHub Stars](https://img.shields.io/github/stars/miawinter98/Wave?label=github%20stars&color=yellow&logo=github&style=for-the-badge)

View file

@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;
using Testcontainers.PostgreSql;
using Wave.Data;
namespace Wave.Tests.Data;
[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
[TestOf(typeof(ApplicationDbContext))]
public class ApplicationDbContextTest {
private PostgreSqlContainer PostgresContainer { get; } = new PostgreSqlBuilder().WithImage("postgres:16.1-alpine").Build();
[SetUp]
public async Task SetUp() {
await PostgresContainer.StartAsync();
}
[TearDown]
public async Task TearDown() {
await PostgresContainer.DisposeAsync();
}
private ApplicationDbContext GetContext() {
return new ApplicationDbContext(
new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(PostgresContainer.GetConnectionString())
.EnableSensitiveDataLogging()
.EnableDetailedErrors()
.EnableThreadSafetyChecks()
.Options);
}
[Test]
public async Task Migration() {
await using var context = GetContext();
Assert.DoesNotThrowAsync(() => context.Database.MigrateAsync());
}
[Test]
public async Task CreateArticle() {
await using var context = GetContext();
await context.Database.EnsureCreatedAsync();
var author = new ApplicationUser {
FullName = "Test User"
};
Article article = new() {
Title = "Testing Article",
Body = "This is a *test* Article",
Author = author
};
article.UpdateSlug(null);
article.UpdateBody();
await context.AddAsync(article);
Assert.DoesNotThrowAsync(() => context.SaveChangesAsync());
var dbArticle = await context.Set<Article>()
.IgnoreQueryFilters().FirstOrDefaultAsync();
Assert.That(dbArticle, Is.Not.Null);
Assert.That(dbArticle.Title, Is.EqualTo("Testing Article"));
Assert.That(dbArticle.Slug, Is.EqualTo("testing-article"));
Assert.That(dbArticle.Body, Is.EqualTo("This is a *test* Article"));
Assert.That(dbArticle.BodyPlain, Is.EqualTo("This is a test Article"));
Assert.That(dbArticle.BodyHtml, Is.EqualTo("<p>This is a <em>test</em> Article</p>"));
}
}

View file

@ -0,0 +1,104 @@
using Wave.Data;
namespace Wave.Tests.Data;
[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
[TestOf(typeof(Article))]
public class ArticleTest {
private Article Article { get; } = new() {
Author = null!,
Title = null!,
Body = null!
};
[Test]
public void SlugWithAscii() {
Article.Title = "Testing Article";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("testing-article"));
}
[Test]
public void SlugWithSpecialCharacters() {
Article.Title = "Title with, special characters?";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("title-with%2C-special-characters%3F"));
}
[Test]
public void SlugFromTitleLongerThan64Characters() {
Article.Title = "Article Title that is longer than the sixty four character limit and should be truncated";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("article-title-that-is-longer-than-the-sixty-four-character-limit"));
}
[Test]
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterEscapeSize3AtPosition55() {
Article.Title = "Auto generating slugs was a mistake I hate this ______ €";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-______-%E2%82%AC"));
}
[Test]
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterEscapeSize2AtPosition56() {
Article.Title = "Auto generating slugs was a mistake I hate this _______ üa";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-_______-%C3%BCa"));
}
[Test]
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterEscapeSize3AtPosition56() {
Article.Title = "Auto generating slugs was a mistake I hate this _______ €";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-_______-"));
}
[Test]
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterEscapeSize2AtPosition57() {
Article.Title = "Auto generating slugs was a mistake I hate this ________ üa";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-________-%C3%BCa"));
}
[Test]
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterEscapeSize3AtPosition57() {
Article.Title = "Auto generating slugs was a mistake I hate this ________ €";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-________-"));
}
[Test]
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterAtPosition61() {
Article.Title = "Article that ends with a special character and need special cäre";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-need-special-c"));
}
[Test]
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterAtPosition62() {
Article.Title = "Article that ends with a special character and needs special cäre";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-needs-special-c"));
}
[Test]
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterAtPosition63() {
Article.Title = "Article that ends with a special character and needs special caäre";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-needs-special-ca"));
}
[Test]
public void SlugProvidedValidUri() {
Article.Title = "Testing providing a slug";
Article.UpdateSlug("test-slug");
Assert.That(Article.Slug, Is.EqualTo("test-slug"));
}
[Test]
public void SlugProvidedNeedsEscaping() {
Article.Title = "Testing providing a slug";
Article.UpdateSlug("test slug");
Assert.That(Article.Slug, Is.EqualTo("test-slug"));
}
}

View file

@ -0,0 +1,35 @@
using NUnit.Framework.Constraints;
using Wave.Data;
using Wave.Utilities;
namespace Wave.Tests.Utilities;
[TestFixture]
[TestOf(typeof(ArticleUtilities))]
public class ArticleUtilitiesTest {
[Test]
public void GenerateArticleLink() {
var testArticle = new Article {
Id = Guid.Parse("e7a94905-d83a-4146-8061-de2ef7869a82"),
Title = "Test Article",
Body = "This is the body of the test Article",
Author = new ApplicationUser {
UserName = "test@example.com",
FullName = "Test User"
},
PublishDate = DateTimeOffset.MaxValue,
Slug = "test-article"
};
string linkWithoutPublishDate = ArticleUtilities.GenerateArticleLink(testArticle, null);
Assert.That(linkWithoutPublishDate, Is.EqualTo("/article/e7a94905-d83a-4146-8061-de2ef7869a82"));
testArticle.PublishDate = new DateTimeOffset(new DateOnly(2024, 4, 24), TimeOnly.MinValue, TimeSpan.Zero);
string linkWithPublishDate = ArticleUtilities.GenerateArticleLink(testArticle, null);
Assert.That(linkWithPublishDate, Is.EqualTo("/2024/04/24/test-article"));
string testHttps = ArticleUtilities.GenerateArticleLink(testArticle, new Uri("http://example.com", UriKind.Absolute));
Assert.That(testHttps, new StartsWithConstraint("https://"));
}
}

View file

@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="NUnit" Version="4.1.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.2.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="Testcontainers" Version="3.8.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.8.0" />
<PackageReference Include="Testcontainers.Redis" Version="3.8.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Wave\Wave.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
<ItemGroup>
<Folder Include="Utilities\" />
</ItemGroup>
</Project>

View file

@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wave", "Wave\Wave.csproj",
EndProject
Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{FE5DA24A-8490-4DCE-BDFB-49C9CF656F8A}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wave.Tests", "Wave.Tests\Wave.Tests.csproj", "{54BFBF0E-5918-4830-BCDD-135BAD702529}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -21,6 +23,10 @@ Global
{FE5DA24A-8490-4DCE-BDFB-49C9CF656F8A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FE5DA24A-8490-4DCE-BDFB-49C9CF656F8A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FE5DA24A-8490-4DCE-BDFB-49C9CF656F8A}.Release|Any CPU.Build.0 = Release|Any CPU
{54BFBF0E-5918-4830-BCDD-135BAD702529}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{54BFBF0E-5918-4830-BCDD-135BAD702529}.Debug|Any CPU.Build.0 = Debug|Any CPU
{54BFBF0E-5918-4830-BCDD-135BAD702529}.Release|Any CPU.ActiveCfg = Release|Any CPU
{54BFBF0E-5918-4830-BCDD-135BAD702529}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View file

@ -15,7 +15,7 @@
<HelpDropdownComponent Body="@Localizer["AboutTheAuthor_Explanation"]"/>
</span>
</div>
<InputTextArea class="textarea textarea-bordered w-full h-24" maxlength="512"
<InputTextArea class="textarea textarea-bordered w-full h-24" maxlength="500" oninput="charactersLeft_onInput(this)"
@bind-Value="@Model.AboutTheAuthor" placeholder="@Localizer["AboutTheAuthor_Placeholder"]"/>
<div class="label">
<span class="label-text-alt text-error"><ValidationMessage For="() => Model.AboutTheAuthor"/></span>
@ -29,7 +29,7 @@
<HelpDropdownComponent Body="@Localizer["Biography_Explanation"]"/>
</span>
</div>
<InputTextArea class="textarea textarea-bordered w-full h-48" maxlength="4096"
<InputTextArea class="textarea textarea-bordered w-full h-48" maxlength="4000" oninput="charactersLeft_onInput(this)"
@bind-Value="@Model.Biography" placeholder="@Localizer["Biography_Placeholder"]"/>
<div class="label">
<span class="label-text-alt text-error"><ValidationMessage For="() => Model.Biography"/></span>

View file

@ -14,18 +14,22 @@
<InputLabelComponent For="() => Model.ContactEmail" LabelText="@Localizer["Contact_Email_Label"]">
<InputText class="input input-bordered w-full" maxlength="128" type="email" autocomplete="email"
oninput="charactersLeft_onInput(this)"
@bind-Value="@Model.ContactEmail" placeholder="@Localizer["Contact_Email_Placeholder"]" />
</InputLabelComponent>
<InputLabelComponent For="() => Model.ContactPhone" LabelText="@Localizer["Contact_Phone_Label"]">
<InputText class="input input-bordered w-full" maxlength="64" type="tel" autocomplete="tel"
oninput="charactersLeft_onInput(this)"
@bind-Value="@Model.ContactPhone" placeholder="@Localizer["Contact_Phone_Placeholder"]" />
</InputLabelComponent>
<InputLabelComponent For="() => Model.ContactPhoneBusiness" LabelText="@Localizer["Contact_PhoneBusiness_Label"]">
<InputText class="input input-bordered w-full" maxlength="64" type="tel" autocomplete="tel"
oninput="charactersLeft_onInput(this)"
@bind-Value="@Model.ContactPhoneBusiness" placeholder="@Localizer["Contact_PhoneBusiness_Placeholder"]" />
</InputLabelComponent>
<InputLabelComponent For="() => Model.ContactWebsite" LabelText="@Localizer["Contact_Website_Label"]">
<InputText class="input input-bordered w-full" maxlength="128" type="url" autocomplete="url"
oninput="charactersLeft_onInput(this)"
@bind-Value="@Model.ContactWebsite" placeholder="@Localizer["Contact_Website_Placeholder"]" />
</InputLabelComponent>

View file

@ -13,7 +13,8 @@
<form id="add-user-link" @formname="add-user-link" @onsubmit="Add" method="post">
<AntiforgeryToken />
<InputLabelComponent LabelText="@Localizer["Links_Url_Label"]">
<input class="input input-bordered" maxlength="1024" autocomplete="off" type="url"
<input class="input input-bordered" maxlength="512" autocomplete="off" type="url"
oninput="charactersLeft_onInput(this)"
name="link-url" value="@Url" placeholder="@Localizer["Links_Url_Placeholder"]" />
</InputLabelComponent>
</form>
@ -50,7 +51,7 @@
public required ApplicationUser User { get; set; }
// Create
[SupplyParameterFromForm(FormName = "add-user-link", Name = "link-url")]
[SupplyParameterFromForm(FormName = "add-user-link", Name = "link-url"), MaxLength(512)]
private string Url { get; set; } = string.Empty;
// Delete
@ -62,7 +63,7 @@
Message.ShowError("Url is required.");
return;
}
if (Url.Length > 1024) {
if (Url.Length > 512) {
Message.ShowError("Url is too long.");
return;
}

View file

@ -12,7 +12,7 @@
<div class="label">
<span class="label-text">@Localizer["FullName_Label"]</span>
</div>
<InputText class="input input-bordered w-full" maxlength="64" autocomplete="name"
<InputText class="input input-bordered w-full" maxlength="64" autocomplete="name" oninput="charactersLeft_onInput(this)"
@bind-Value="@Model.FullName" placeholder="@Localizer["FullName_Placeholder"]" />
<div class="label">
<span class="label-text-alt text-error">

View file

@ -109,52 +109,6 @@
</section>
<SectionContent SectionName="scripts">
<script>
window.insertBeforeSelection = function(markdown, startOfLine = false) {
const target = document.getElementById("tool-target");
const start = target.selectionStart;
const end = target.selectionEnd;
const value = target.value;
let doStart = start;
if (startOfLine) {
doStart = value.lastIndexOf("\n", start) +1;
}
target.focus();
target.value = value.substring(0, doStart) + markdown + value.substring(doStart);
target.selectionStart = start + markdown.length;
target.selectionEnd = end + markdown.length;
target.focus();
target.dispatchEvent(new Event("input", { bubbles: true }));
}
window.insertBeforeAndAfterSelection = function (markdown) {
const target = document.getElementById("tool-target");
while (/\s/.test(target.value[target.selectionStart]) && target.selectionStart < target.value.length) {
target.selectionStart++;
}
while (/\s/.test(target.value[target.selectionEnd-1]) && target.selectionEnd > 0) {
target.selectionEnd--;
}
const start = target.selectionStart;
const end = target.selectionEnd;
const value = target.value;
target.focus();
target.value = value.substring(0, start) +
markdown + value.substring(start, end) + markdown +
value.substring(end);
target.selectionStart = start + markdown.length;
target.selectionEnd = end + markdown.length;
target.focus();
target.dispatchEvent(new Event("input", { bubbles: true }));
}
</script>
</SectionContent>
@code {

View file

@ -44,8 +44,72 @@
<CascadingValue Value="UserTheme" Name="UserTheme">
<Routes />
</CascadingValue>
<SectionOutlet SectionName="scripts" />
<script src="_framework/blazor.web.js" defer></script>
<script src="_framework/blazor.web.js" defer></script>
<SectionOutlet SectionName="scripts" />
<script>
charactersLeft_onInput = function(input) {
const maxLength = input.maxLength;
const currentLength = input.value.length;
const newLeft = maxLength - currentLength;
let elem = input.parentNode.querySelector(".characters-left");
if (elem) {
elem.innerText = newLeft;
} else {
input.parentNode.classList.add("relative");
elem = document.createElement("span");
elem.classList.add("characters-left");
elem.innerText = newLeft;
input.parentNode.appendChild(elem);
}
}
window.insertBeforeSelection = function (markdown, startOfLine = false) {
const target = document.getElementById("tool-target");
const start = target.selectionStart;
const end = target.selectionEnd;
const value = target.value;
let doStart = start;
if (startOfLine) {
doStart = value.lastIndexOf("\n", start) + 1;
}
target.focus();
target.value = value.substring(0, doStart) + markdown + value.substring(doStart);
target.selectionStart = start + markdown.length;
target.selectionEnd = end + markdown.length;
target.focus();
target.dispatchEvent(new Event("input", { bubbles: true }));
}
window.insertBeforeAndAfterSelection = function (markdown) {
const target = document.getElementById("tool-target");
while (/\s/.test(target.value[target.selectionStart]) && target.selectionStart < target.value.length) {
target.selectionStart++;
}
while (/\s/.test(target.value[target.selectionEnd - 1]) && target.selectionEnd > 0) {
target.selectionEnd--;
}
const start = target.selectionStart;
const end = target.selectionEnd;
const value = target.value;
target.focus();
target.value = value.substring(0, start) +
markdown + value.substring(start, end) + markdown +
value.substring(end);
target.selectionStart = start + markdown.length;
target.selectionEnd = end + markdown.length;
target.focus();
target.dispatchEvent(new Event("input", { bubbles: true }));
}
</script>
</body>
</html>

View file

@ -2,10 +2,11 @@
@using Humanizer
@using Wave.Utilities
@inject NavigationManager Navigation
@inject IStringLocalizer<Pages.ArticleView> Localizer
<SectionContent SectionName="GlobalHeader">
<header class="bg-secondary text-secondary-content border-b-2 border-current py-6 px-4 md:px-12">
<header class="bg-secondary text-secondary-content border-b-2 border-current py-6 px-4 md:px-12" data-nosnippet>
<h1 class="text-3xl lg:text-5xl font-light">
@Article.Title
</h1>
@ -46,6 +47,39 @@
</header>
</SectionContent>
@if (Article.Headings.Count > 0) {
<section class="mb-3 p-2 bg-base-200 rounded-box w-80 float-start mr-2 mb-2" data-nosnippet>
<h2 class="text-xl font-bold mb-3">@Localizer["TableOfContents"]</h2>
<ul class="menu p-0 [&_li>*]:rounded-none">
@{
int level = 1;
foreach (var heading in Article.Headings.OrderBy(h => h.Order)) {
int headingLevel = heading.Order % 10;
while (headingLevel < level) {
level--;
@(new MarkupString("</ul></li>"))
}
while (headingLevel > level) {
level++;
@(new MarkupString("<li><ul>"))
}
<li>
<a href="/@Navigation.ToBaseRelativePath(Navigation.Uri)#@heading.Anchor">@((MarkupString)heading.Label)</a>
</li>
}
while (level > 1) {
level--;
@(new MarkupString("<li><ul>"))
}
}
</ul>
</section>
}
<article class="mb-6">
<div class="prose prose-neutral max-w-none hyphens-auto text-justify">
@Content

View file

@ -44,6 +44,7 @@
<Authorized>
<li><NavLink href="manage/api">@Localizer["ManageApi_Label"]</NavLink></li>
<li><NavLink href="newsletter">@Localizer["Newsletter_Label"]</NavLink></li>
<li><NavLink href="subscribers">@Localizer["Subscribers_Label"]</NavLink></li>
</Authorized>
</AuthorizeView>
</ul>
@ -51,7 +52,7 @@
<AuthorizeView>
<Authorized>
<li class="flex gap-2">
<NavLink href="/Account/Manage">
<NavLink href="/account/manage">
<span class="line-clamp-2">@context.User.FindFirst("FullName")!.Value</span>
<div class="w-8">
<ProfilePictureComponent Size="100" ProfileId="@context.User.FindFirst("Id")!.Value" />
@ -59,7 +60,7 @@
</NavLink>
</li>
<li class="">
<form action="/Account/Logout" method="post">
<form action="/account/logout" method="post">
<AntiforgeryToken />
<input type="hidden" name="ReturnUrl" value="@_currentUrl" />
<button type="submit" class="flex gap-2">
@ -74,7 +75,7 @@
</Authorized>
<NotAuthorized>
<li>
<NavLink href="/Account/Login">
<NavLink href="/account/login">
@Localizer["Login_Label"]
<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="M17 4.25A2.25 2.25 0 0 0 14.75 2h-5.5A2.25 2.25 0 0 0 7 4.25v2a.75.75 0 0 0 1.5 0v-2a.75.75 0 0 1 .75-.75h5.5a.75.75 0 0 1 .75.75v11.5a.75.75 0 0 1-.75.75h-5.5a.75.75 0 0 1-.75-.75v-2a.75.75 0 0 0-1.5 0v2A2.25 2.25 0 0 0 9.25 18h5.5A2.25 2.25 0 0 0 17 15.75V4.25Z" clip-rule="evenodd" />
@ -84,7 +85,7 @@
</li>
@if (Features.Value.NativeSignup) {
<li>
<NavLink href="/Account/Register">
<NavLink href="/account/register">
@Localizer["SignUp_Label"]
</NavLink>
</li>

View file

@ -47,6 +47,7 @@
await using var context = await ContextFactory.CreateDbContextAsync();
var article = await context.Set<Article>().IgnoreQueryFilters()
.Include(a => a.Author).Include(a => a.Reviewer)
.Where(a => !a.IsDeleted).FirstOrDefaultAsync(a => a.Id == Id);
if (article.AllowedToDelete(HttpContext.User)) Article = article;
}

View file

@ -74,6 +74,14 @@
@Localizer["Delete_Submit"]
</a>
}
@if (article.AllowedToRejectReview(HttpContext.User)) {
<form @formname="reject-review" method="post" @onsubmit="RejectReview" class="max-sm:w-full">
<AntiforgeryToken />
<button type="submit" class="btn btn-error w-full sm:btn-wide">
@Localizer["Review_Reject"]
</button>
</form>
}
@if (article.AllowedToSubmitForReview(HttpContext.User)) {
<form @formname="submit-for-review" method="post" @onsubmit="SubmitForReview" class="max-sm:w-full">
<AntiforgeryToken/>
@ -186,7 +194,7 @@
if (Id is not null) {
Article = query.AsSingleQuery().FirstOrDefault(a => a.Id == Id);
} else if (Date is { } date && Title is { } title) {
string? slug = TitleEncoded == null ? null : Uri.EscapeDataString(TitleEncoded);
string? slug = TitleEncoded == null ? null : Uri.EscapeDataString(TitleEncoded.Replace("-", " ")).Replace("%20", "-");
Article = query.AsSingleQuery().FirstOrDefault(a =>
a.PublishDate.Date == date.Date
&& (slug != null && a.Slug == slug || a.Title.ToLower() == title));
@ -295,6 +303,47 @@
Navigation.NavigateTo("/");
}
private async Task RejectReview() {
if (Article.AllowedToRejectReview(HttpContext.User) is false) return;
await using var context = await ContextFactory.CreateDbContextAsync();
Article!.Status = ArticleStatus.Draft;
string userId = HttpContext.User.FindFirst("Id")!.Value;
if (Article.Author.Id != userId) {
Article.Reviewer = await context.Users.FindAsync(userId);
}
context.Update(Article);
await context.SaveChangesAsync();
try {
var author = Article.Author;
string message =
$"The Article '{Article.Title}' has been rejected by a Reviewer, you will find it in your drafts.\n" +
$"Please make appropriate changes before submitting it again.";
if (author.Id != HttpContext.User.FindFirst("Id")!.Value) {
await EmailService.ConnectAsync(CancellationToken.None);
var email = await Email.CreateDefaultEmail(
author.Email!,
author.Name,
"Review Rejected",
"Your Article has been reject",
$"<p>{message}</p>",
message);
// TODO check if they enabled email notifications (property currently not implemented)
await EmailService.SendEmailAsync(email);
await EmailService.DisconnectAsync(CancellationToken.None);
}
} catch (Exception ex) {
Logger.LogError(ex, "Failed to send mail to author about article '{title}' being rejected.", Article.Title);
}
Navigation.NavigateTo("/");
}
private async Task SubmitForPublish() {
if (Article.AllowedToPublish(HttpContext.User) is false) return;
@ -353,4 +402,5 @@
Navigation.NavigateTo("/");
}
}

View file

@ -0,0 +1,103 @@
@page "/Subscribers/edit/{id:guid}"
@using Microsoft.EntityFrameworkCore
@using Wave.Data
@using Wave.Utilities
@attribute [Authorize(Roles = "Admin")]
@inject ILogger<EditSubscriber> Logger
@inject IStringLocalizer<EditSubscriber> Localizer
@inject IDbContextFactory<ApplicationDbContext> ContextFactory
@inject IMessageDisplay Message
@inject NavigationManager Navigation
<PageTitle>@(Localizer["Title"] + TitlePostfix)</PageTitle>
<BoardComponent CenterContent="true">
<BoardCardComponent>
@if (Model is null) {
<p>not found</p>
} else {
<EditForm FormName="EditSubscriber" Model="Model" method="post" OnValidSubmit="Submit">
<h1 class="text-3xl">@Localizer["Title"]</h1>
<p class="text-xl my-3">@Email</p>
<InputLabelComponent LabelText="@Localizer["Name_Label"]" For="() => Model.Name">
<InputText @bind-Value="Model.Name" class="input input-bordered w-full" autocomplete="off"
placeholder="@Localizer["Name_Placeholder"]"/>
</InputLabelComponent>
<div class="form-control w-full mb-3">
<label class="label cursor-pointer">
<span class="label-text">@Localizer["Subscribed_Label"]</span>
<InputCheckbox @bind-Value="Model.Subscribed" class="checkbox"/>
</label>
</div>
<button type="submit" class="btn btn-primary w-full">@Localizer["Submit"]</button>
</EditForm>
}
</BoardCardComponent>
</BoardComponent>
@code {
[CascadingParameter(Name = "TitlePostfix")]
private string TitlePostfix { get; set; } = default!;
[Parameter]
public Guid Id { get; set; }
[SupplyParameterFromForm(FormName = "EditSubscriber")]
private EditModel? Model { get; set; }
private string Email { get; set; } = string.Empty;
protected override async Task OnInitializedAsync() {
await using var context = await ContextFactory.CreateDbContextAsync();
var subscriber = await context.Set<EmailSubscriber>()
.IgnoreQueryFilters()
.IgnoreAutoIncludes()
.FirstOrDefaultAsync(s => s.Id == Id);
if (subscriber is null) {
Message.ShowError(Localizer["Error_NotFound"]);
} else if (Model is null) {
Email = subscriber.Email;
Model = new EditModel {
Name = subscriber.Name ?? string.Empty,
Subscribed = !subscriber.Unsubscribed
};
await InvokeAsync(StateHasChanged);
}
}
private async Task Submit() {
if (Model is null) return;
try {
await using var context = await ContextFactory.CreateDbContextAsync();
var subscriber = await context.Set<EmailSubscriber>()
.IgnoreQueryFilters()
.IgnoreAutoIncludes()
.FirstOrDefaultAsync(s => s.Id == Id);
if (subscriber is null) {
Message.ShowError(Localizer["Error_NotFound"]);
return;
}
subscriber.Name = string.IsNullOrWhiteSpace(Model.Name) ? null : Model.Name;
subscriber.Unsubscribed = !Model.Subscribed;
context.Update(subscriber);
await context.SaveChangesAsync();
} catch (Exception ex) {
Logger.LogError(ex, "Failed to save changes to Email Subscriber {Email}.", Email);
Message.ShowError(Localizer["Submit_Error"]);
}
Message.ShowSuccess(Localizer["Submit_Success"]);
Navigation.NavigateTo("/subscribers");
}
private sealed class EditModel {
public string Name { get; set; } = string.Empty;
public bool Subscribed { get; set; } = false;
}
}

View file

@ -62,7 +62,7 @@
</a>
}
@if (Features.Value.EmailSubscriptions) {
<a class="btn btn-sm btn-primary" title="E-Mail Newsletter" href="/Email/Subscribe">
<a class="btn btn-sm btn-primary" title="E-Mail Newsletter" href="/email/subscribe">
E-Mail
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path d="M1.5 8.67v8.58a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3V8.67l-8.928 5.493a3 3 0 0 1-3.144 0L1.5 8.67Z" />

View file

@ -31,7 +31,7 @@
<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
<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>
@ -49,7 +49,7 @@
<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"
<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"
@ -188,19 +188,12 @@
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)];
} else {
Article.UpdateSlug(Model.Slug);
Model.Slug = Article.Slug;
}
Article.LastModified = DateTimeOffset.UtcNow;
Article.BodyHtml = MarkdownUtilities.Parse(Article.Body);
Article.BodyPlain = HtmlUtilities.GetPlainText(Article.BodyHtml);
await using var context = await ContextFactory.CreateDbContextAsync();
@ -219,6 +212,8 @@
.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)
@ -257,6 +252,10 @@
}
}
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.");
@ -280,7 +279,7 @@
}
// published articles may only be edited my admins or moderators
if (article.Status is ArticleStatus.Published && roles.Any(r => r is "Admin" or "Reviewer")) {
if (article.Status is ArticleStatus.Published && roles.Any(r => r is "Admin" or "Moderator")) {
article.Reviewer = me; // TODO replace with editor or something?
return;
}

View file

@ -0,0 +1,224 @@
@page "/Subscribers"
@using Microsoft.EntityFrameworkCore
@using Microsoft.Extensions.Options
@using Wave.Data
@using Wave.Utilities
@using CsvHelper.Configuration
@using System.Globalization
@using CsvHelper
@attribute [Authorize(Roles = "Admin")]
@inject IStringLocalizer<Subscribers> Localizer
@inject IDbContextFactory<ApplicationDbContext> ContextFactory
@inject ILogger<Subscribers> Logger
@inject IMessageDisplay Message
<ModalComponent Id="@ModalId">
<ChildContent>
<form id="AddSubscribers" method="post" @formname="AddSubscribers" @onsubmit="AddSubscribers">
<AntiforgeryToken/>
<h2 class="text-xl">@Localizer["AddSubscribers_Label"]</h2>
<span class="my-3"><small>Format: Email;[Name];[Exclusion Reason];</small></span>
<InputLabelComponent LabelText="@Localizer["AddSubscribers_Input_Label"]">
<InputTextArea class="textarea textarea-bordered" rows="12"
@bind-Value="@SubscribersInput"
placeholder="@Localizer["AddSubscribers_Input_Placeholder"]"
required aria-required max="8096"
autocomplete="off"/>
</InputLabelComponent>
</form>
</ChildContent>
<Actions>
<button type="submit" form="AddSubscribers" class="btn btn-primary">
@Localizer["Submit"]
</button>
</Actions>
</ModalComponent>
<PageTitle>@(Localizer["Title"] + TitlePostfix)</PageTitle>
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["Title"]</h1>
<div class="flex flex-wrap gap-2 mb-3">
<details class="dropdown">
<summary class="btn btn-sm btn-accent">
@Items /
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4" title="page">
<path d="M5.625 1.5c-1.036 0-1.875.84-1.875 1.875v17.25c0 1.035.84 1.875 1.875 1.875h12.75c1.035 0 1.875-.84 1.875-1.875V12.75A3.75 3.75 0 0 0 16.5 9h-1.875a1.875 1.875 0 0 1-1.875-1.875V5.25A3.75 3.75 0 0 0 9 1.5H5.625Z" />
<path d="M12.971 1.816A5.23 5.23 0 0 1 14.25 5.25v1.875c0 .207.168.375.375.375H16.5a5.23 5.23 0 0 1 3.434 1.279 9.768 9.768 0 0 0-6.963-6.963Z" />
</svg>
</summary>
<ul class="p-2 shadow-xl menu dropdown-content z-[1] bg-accent text-accent-content rounded-box w-52">
<li><a href="@($"/subscribers?page={Math.Floor(Items * Page / 5.0)}&items=5")">5</a></li>
<li><a href="@($"/subscribers?page={Math.Floor(Items * Page / 10.0)}")">10</a></li>
<li><a href="@($"/subscribers?page={Math.Floor(Items * Page / 25.0)}&items=25")">25</a></li>
<li><a href="@($"/subscribers?page={Math.Floor(Items * Page / 50.0)}&items=50")">50</a></li>
<li><a href="@($"/subscribers?page={Math.Floor(Items * Page / 100.0)}&items=100")">100</a></li>
<li><a href="@($"/subscribers?page={Math.Floor(Items * Page / 250.0)}&items=250")">250</a></li>
</ul>
</details>
<button class="btn btn-sm btn-primary" onclick="@(ModalId).showModal()">
@Localizer["AddSubscribers_Label"]
</button>
</div>
<section class="flex flex-col h-128">
<div class="flex-grow overflow-auto">
<table class="table relative">
<thead>
<tr>
<th class="bg-base-200 sticky top-0 text-center md:text-left md:w-48">@Localizer["Header_Email"]</th>
<th class="bg-base-200 sticky top-0 text-center md:text-left w-48 max-md:hidden">@Localizer["Header_Name"]</th>
<th class="bg-base-200 sticky top-0 text-center md:text-left w-24 max-md:hidden">@Localizer["Header_LastReceived"]</th>
<th class="bg-base-200 sticky top-0 text-center md:text-left w-24 max-md:hidden">@Localizer["Header_LastOpen"]</th>
<th class="bg-base-200 sticky top-0 text-center md:text-left w-24 max-md:hidden">@Localizer["Header_UnsubscribeReason"]</th>
<th class="bg-base-200 sticky top-0 text-center md:text-left md:w-8 z-10">@Localizer["Header_Subscribed"]</th>
<td class="bg-base-200 sticky top-0 text-center md:text-left w-24"></td>
</tr>
</thead>
<tbody class="divide-y">
<PageComponent Page="@Page" LoadCallback="LoadSubscribers" ItemsPerPage="@Items">
<tr>
<td>@context.Email</td>
<td class="max-md:hidden">@context.Name</td>
<td class="max-md:hidden">@context.LastMailReceived?.ToString("g")</td>
<td class="max-md:hidden">@context.LastMailOpened?.ToString("g")</td>
<td class="max-md:hidden">@context.UnsubscribeReason</td>
<td class="text-center md:text-left">
<input type="checkbox" class="checkbox no-animation" checked="@(!context.Unsubscribed)" disabled/>
</td>
<td>
<a class="btn btn-sm btn-info w-24" href="@($"/subscribers/edit/{context.Id}")">
@Localizer["Subscriber_Edit"]
</a>
</td>
</tr>
</PageComponent>
</tbody>
<tfoot>
<tr>
<td colspan="6" class="max-md:hidden">
@Localizer["Newsletter_Footer_Timezone"] @TimeZoneInfo.Local
</td>
</tr>
</tfoot>
</table>
</div>
<div class="grid place-content-center my-3">
<div class="join">
@if (Page < 1) {
<button class="join-item btn" disabled title="@Localizer["Paging_Previous"]">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path fill-rule="evenodd" d="M7.72 12.53a.75.75 0 0 1 0-1.06l7.5-7.5a.75.75 0 1 1 1.06 1.06L9.31 12l6.97 6.97a.75.75 0 1 1-1.06 1.06l-7.5-7.5Z" clip-rule="evenodd" />
</svg>
</button>
} else {
<a class="join-item btn" target="_top" href="@(Page < 2 ? $"/subscribers?items={Items}" : $"/subscribers?page={Page - 1}&items={Items}")" title="@Localizer["Paging_Previous"]">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path fill-rule="evenodd" d="M7.72 12.53a.75.75 0 0 1 0-1.06l7.5-7.5a.75.75 0 1 1 1.06 1.06L9.31 12l6.97 6.97a.75.75 0 1 1-1.06 1.06l-7.5-7.5Z" clip-rule="evenodd"/>
</svg>
</a>
}
<button class="join-item btn md:btn-wide no-animation">@Localizer["Paging_Page"] @(Page + 1)</button>
@if (Page >= TotalPages - 1) {
<button class="join-item btn" disabled title="@Localizer["Paging_Next"]">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path fill-rule="evenodd" d="M16.28 11.47a.75.75 0 0 1 0 1.06l-7.5 7.5a.75.75 0 0 1-1.06-1.06L14.69 12 7.72 5.03a.75.75 0 0 1 1.06-1.06l7.5 7.5Z" clip-rule="evenodd"/>
</svg>
</button>
} else {
<a class="join-item btn" target="_top" href="@($"/subscribers?page={Page + 1}&items={Items}")" title="@Localizer["Paging_Next"]">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path fill-rule="evenodd" d="M16.28 11.47a.75.75 0 0 1 0 1.06l-7.5 7.5a.75.75 0 0 1-1.06-1.06L14.69 12 7.72 5.03a.75.75 0 0 1 1.06-1.06l7.5 7.5Z" clip-rule="evenodd"/>
</svg>
</a>
}
</div>
</div>
</section>
@code {
[CascadingParameter(Name = "TitlePostfix")]
private string TitlePostfix { get; set; } = default!;
[SupplyParameterFromQuery]
public int Page { get; set; } = 0;
[SupplyParameterFromQuery]
public int Items { get; set; }
private int TotalPages { get; set; }
[SupplyParameterFromForm(FormName = "AddSubscribers")]
private string SubscribersInput { get; set; } = string.Empty;
private static string ModalId => "AddSubscribersDialog";
protected override async Task OnInitializedAsync() {
if (Items < 1) Items = 10;
await using var context = await ContextFactory.CreateDbContextAsync();
var query = context.Set<EmailSubscriber>().IgnoreQueryFilters();
TotalPages = (int)Math.Max(Math.Ceiling(await query.CountAsync() / (double)Items), 1);
}
private async ValueTask<IEnumerable<EmailSubscriber>> LoadSubscribers(int page, int count) {
try {
await using var context = await ContextFactory.CreateDbContextAsync();
return await context.Set<EmailSubscriber>()
.IgnoreAutoIncludes().IgnoreQueryFilters()
.OrderBy(s => s.Email).ThenBy(s => s.Id)
.Skip(page).Take(count).ToListAsync();
} catch (Exception ex) {
Logger.LogError(ex, "Failed to load subscribers on page {Page} with count {Count}.", page, count);
Message.ShowError(Localizer["Subscriber_Load_Error"]);
return [];
}
}
private async Task AddSubscribers() {
var config = new CsvConfiguration(CultureInfo.CurrentCulture) {
NewLine = Environment.NewLine,
HasHeaderRecord = false
};
List<SubscriberModel> list;
try {
using var reader = new CsvReader(new StringReader(SubscribersInput), config);
list = reader.GetRecords<SubscriberModel>().ToList();
} catch (Exception ex) {
Message.ShowError(string.Format(Localizer["AddSubscribers_Parse_Error"], ex.Message));
return;
}
if (list.Count < 1) return;
try {
var emailSubscribers = new List<EmailSubscriber>();
foreach (var input in list) {
emailSubscribers.Add(new EmailSubscriber {
Email = input.Email,
Name = input.Name,
Unsubscribed = !string.IsNullOrWhiteSpace(input.UnsubscribeReason),
UnsubscribeReason = input.UnsubscribeReason,
Language = "en-US"
});
}
await using var context = await ContextFactory.CreateDbContextAsync();
context.AddRange(emailSubscribers);
await context.SaveChangesAsync();
SubscribersInput = string.Empty;
} catch (Exception ex) {
Message.ShowError(string.Format(Localizer["AddSubscribers_Save_Error"], ex.InnerException?.Message ?? ex.Message));
}
}
internal sealed class SubscriberModel {
[CsvHelper.Configuration.Attributes.Index(0)]
public string Email { get; set; } = string.Empty;
[CsvHelper.Configuration.Attributes.Index(1)]
public string? Name { get; set; }
[CsvHelper.Configuration.Attributes.Index(2)]
public string? UnsubscribeReason { get; set; }
}
}

View file

@ -43,6 +43,7 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
.IsRequired().OnDelete(DeleteBehavior.Cascade);
article.HasOne(a => a.Reviewer).WithMany()
.IsRequired(false).OnDelete(DeleteBehavior.SetNull);
article.OwnsMany(a => a.Headings);
article.Property(a => a.CreationDate)
.IsRequired().HasDefaultValueSql("now()")

View file

@ -1,4 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using Wave.Utilities;
namespace Wave.Data;
@ -8,10 +10,20 @@ public enum ArticleStatus {
Published = 2
}
public class ArticleHeading {
[Key]
public int Id { get; set; }
public required int Order { get; set; }
[MaxLength(128)]
public required string Label { get; set; }
[MaxLength(256)]
public required string Anchor { get; set; }
}
// TODO:: Add tags for MVP ?
// TODO:: Archive System (Notice / Redirect to new content?) (Deprecation date?)
public class Article : ISoftDelete {
public partial class Article : ISoftDelete {
[Key]
public Guid Id { get; set; }
public bool IsDeleted { get; set; }
@ -21,6 +33,7 @@ public class Article : ISoftDelete {
[MaxLength(256)]
public required string Title { get; set; }
// ReSharper disable thrice EntityFramework.ModelValidation.UnlimitedStringLength
public required string Body { get; set; }
public string BodyHtml { get; set; } = string.Empty;
public string BodyPlain { get; set; } = string.Empty;
@ -43,4 +56,56 @@ public class Article : ISoftDelete {
public IList<Category> Categories { get; } = [];
public IList<ArticleImage> Images { get; } = [];
public IList<ArticleHeading> Headings { get; } = [];
public void UpdateSlug(string? potentialNewSlug = null) {
if (!string.IsNullOrWhiteSpace(potentialNewSlug) && Uri.IsWellFormedUriString(potentialNewSlug, UriKind.Relative)) {
Slug = potentialNewSlug;
return;
}
if (string.IsNullOrWhiteSpace(potentialNewSlug) && !string.IsNullOrWhiteSpace(Slug)) return;
string baseSlug = potentialNewSlug ?? Title;
baseSlug = baseSlug.ToLowerInvariant()[..Math.Min(64, baseSlug.Length)];
string slug = Uri.EscapeDataString(baseSlug).Replace("-", "+").Replace("%20", "-");
// I hate my life
int escapeTrimOvershoot = 0;
if (slug.Length > 64) {
// Escape sequences come with a % and two hex digits, there may be up to 3 of such sequences
// per character escaping ('?' has %3F, but € has %E2%82%AC), so we need to find the last group
// of such an escape parade and see if it's going over by less than 9, because then we need to
// remove more characters in the truncation, or we end up with a partial escape sequence.. parade
escapeTrimOvershoot = 64 - Regex.Match(slug,
@"(?<escape>(%[a-fA-F\d][a-fA-F\d])+)",
RegexOptions.None | RegexOptions.ExplicitCapture)
.Groups.Values.Last(g => g.Index < 64).Index;
if (escapeTrimOvershoot > 9) escapeTrimOvershoot = 0;
}
Slug = slug[..Math.Min(slug.Length, 64 - escapeTrimOvershoot)];
}
public void UpdateBody() {
BodyHtml = MarkdownUtilities.Parse(Body).Trim();
BodyPlain = HtmlUtilities.GetPlainText(BodyHtml).Trim();
Headings.Clear();
var headings = HeadingsRegex().Matches(BodyHtml);
foreach(Match match in headings) {
string label = match.Groups["Label"].Value;
string anchor = match.Groups["Anchor"].Value;
var h = new ArticleHeading {
Order = match.Index * 10 + int.Parse(match.Groups["Level"].Value),
Label = label[..Math.Min(128, label.Length)],
Anchor = anchor[..Math.Min(256, anchor.Length)]
};
Headings.Add(h);
}
}
[GeneratedRegex("<h(?<Level>[1-6]).*id=\"(?<Anchor>.+)\".*>(?<Label>.+)</h[1-6]>")]
private static partial Regex HeadingsRegex();
}

View file

@ -0,0 +1,750 @@
// <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("20240502122029_TableOfContents")]
partial class TableOfContents
{
/// <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.ApiClaim", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ApiKeyKey")
.HasColumnType("character varying(128)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("ApiKeyKey");
b.ToTable("ApiClaim");
});
modelBuilder.Entity("Wave.Data.ApiKey", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("OwnerName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Key");
b.ToTable("ApiKey");
});
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<bool>("CanBePublic")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasComputedColumnSql("\"IsDeleted\" = false AND \"Status\" = 2", true);
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>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("character varying(8)")
.HasDefaultValue("en-US");
b.Property<DateTimeOffset?>("LastMailOpened")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastMailReceived")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("UnsubscribeReason")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
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.ApiClaim", b =>
{
b.HasOne("Wave.Data.ApiKey", null)
.WithMany("ApiClaims")
.HasForeignKey("ApiKeyKey")
.OnDelete(DeleteBehavior.Cascade);
});
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.OwnsMany("Wave.Data.ArticleHeading", "Headings", b1 =>
{
b1.Property<Guid>("ArticleId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<string>("Anchor")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b1.Property<string>("Label")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b1.Property<int>("Order")
.HasColumnType("integer");
b1.HasKey("ArticleId", "Id");
b1.ToTable("ArticleHeading");
b1.WithOwner()
.HasForeignKey("ArticleId");
});
b.Navigation("Author");
b.Navigation("Headings");
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.ApiKey", b =>
{
b.Navigation("ApiClaims");
});
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,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Wave.Data.Migrations.postgres;
/// <inheritdoc />
public partial class TableOfContents : Migration {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) {
migrationBuilder.CreateTable(
name: "ArticleHeading",
columns: table => new {
ArticleId = table.Column<Guid>(type: "uuid", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Order = table.Column<int>(type: "integer", nullable: false),
Label = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Anchor = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false)
},
constraints: table => {
table.PrimaryKey("PK_ArticleHeading", x => new {x.ArticleId, x.Id});
table.ForeignKey(
name: "FK_ArticleHeading_Articles_ArticleId",
column: x => x.ArticleId,
principalTable: "Articles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) {
migrationBuilder.DropTable(
name: "ArticleHeading");
}
}

View file

@ -630,8 +630,42 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasForeignKey("ReviewerId")
.OnDelete(DeleteBehavior.SetNull);
b.OwnsMany("Wave.Data.ArticleHeading", "Headings", b1 =>
{
b1.Property<Guid>("ArticleId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<string>("Anchor")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b1.Property<string>("Label")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b1.Property<int>("Order")
.HasColumnType("integer");
b1.HasKey("ArticleId", "Id");
b1.ToTable("ArticleHeading");
b1.WithOwner()
.HasForeignKey("ArticleId");
});
b.Navigation("Author");
b.Navigation("Headings");
b.Navigation("Reviewer");
});

View file

@ -134,4 +134,7 @@
<data name="Deleted_Label" xml:space="preserve">
<value>Gelöscht</value>
</data>
<data name="Subscribers_Label" xml:space="preserve">
<value>Abonnenten</value>
</data>
</root>

View file

@ -137,4 +137,7 @@
<data name="Deleted_Label" xml:space="preserve">
<value>Deleted</value>
</data>
<data name="Subscribers_Label" xml:space="preserve">
<value>Subscribers</value>
</data>
</root>

View file

@ -158,4 +158,7 @@
<data name="Recommendations_Title" xml:space="preserve">
<value>Das könnte Sie auch interessieren</value>
</data>
<data name="TableOfContents" xml:space="preserve">
<value>Inhaltsübersicht</value>
</data>
</root>

View file

@ -158,4 +158,7 @@
<data name="Recommendations_Title" xml:space="preserve">
<value>This might also interest you</value>
</data>
<data name="TableOfContents" xml:space="preserve">
<value>Table of Content</value>
</data>
</root>

View file

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 1.3
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">1.3</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1">this is my long string</data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
[base64 mime encoded serialized .NET Framework object]
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
[base64 mime encoded string representing a byte array form of the .NET Framework object]
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Title" xml:space="preserve">
<value>Abonnent Bearbeiten</value>
</data>
<data name="Name_Placeholder" xml:space="preserve">
<value>Anna Muster</value>
</data>
<data name="Name_Label" xml:space="preserve">
<value>Name (Optional)</value>
</data>
<data name="Subscribed_Label" xml:space="preserve">
<value>Aktiv (erhält E-Mails)</value>
</data>
<data name="Submit" xml:space="preserve">
<value>Speichern</value>
</data>
<data name="Error_NotFound" xml:space="preserve">
<value>Abonnent nicht gefunden (Vielleicht wurde dieser gelöscht?)</value>
</data>
<data name="Submit_Error" xml:space="preserve">
<value>Unbekannter Fehler beim Speichern ihrer Änderungen.</value>
</data>
<data name="Submit_Success" xml:space="preserve">
<value>Ihre Änderungen wurden erfolgreich gespeichert</value>
</data>
</root>

View file

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 1.3
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">1.3</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1">this is my long string</data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
[base64 mime encoded serialized .NET Framework object]
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
[base64 mime encoded string representing a byte array form of the .NET Framework object]
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View file

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 1.3
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">1.3</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1">this is my long string</data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
[base64 mime encoded serialized .NET Framework object]
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
[base64 mime encoded string representing a byte array form of the .NET Framework object]
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Title" xml:space="preserve">
<value>Edit Subscriber</value>
</data>
<data name="Name_Label" xml:space="preserve">
<value>Name (optional)</value>
</data>
<data name="Name_Placeholder" xml:space="preserve">
<value>John Smith</value>
</data>
<data name="Subscribed_Label" xml:space="preserve">
<value>Active (receives Emails)</value>
</data>
<data name="Submit" xml:space="preserve">
<value>Save</value>
</data>
<data name="Error_NotFound" xml:space="preserve">
<value>Subscriber not Found (maybe it has been deleted?)</value>
</data>
<data name="Submit_Error" xml:space="preserve">
<value>Unexpected Error trying to save your changes.</value>
</data>
<data name="Submit_Success" xml:space="preserve">
<value>Changes saved Successfully</value>
</data>
</root>

View file

@ -137,4 +137,7 @@
<data name="WelcomeEmailBody" xml:space="preserve">
<value>Sie werden von nun an über neue Artikel informiert. Schauen Sie doch mal in der folgenden Sektion was Sie vielleicht verpasst haben. Sie können sich jeder Zeit in diesem oder zukünftigen E-Mails abmelden über den Link den Sie am ende finden.</value>
</data>
<data name="Name_Label" xml:space="preserve">
<value>Name (Optional)</value>
</data>
</root>

View file

@ -102,13 +102,13 @@
<value>Subscribe</value>
</data>
<data name="Name_Label" xml:space="preserve">
<value>Name</value>
<value>Name (optional)</value>
</data>
<data name="Name_Placeholder" xml:space="preserve">
<value>John Doe</value>
</data>
<data name="Email_Label" xml:space="preserve">
<value>Email</value>
<value>Email*</value>
</data>
<data name="Email_Placeholder" xml:space="preserve">
<value>john.doe@example.com</value>

View file

@ -0,0 +1,153 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 1.3
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">1.3</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1">this is my long string</data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
[base64 mime encoded serialized .NET Framework object]
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
[base64 mime encoded string representing a byte array form of the .NET Framework object]
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Title" xml:space="preserve">
<value>Abonnenten</value>
</data>
<data name="Paging_Next" xml:space="preserve">
<value>Nächste Seite</value>
</data>
<data name="Paging_Page" xml:space="preserve">
<value>Seite</value>
</data>
<data name="Paging_Previous" xml:space="preserve">
<value>Vorherige Seite</value>
</data>
<data name="Subscriber_Load_Error" xml:space="preserve">
<value>Unerwarteter Fehler beim laden der Abonnenten</value>
</data>
<data name="Header_Email" xml:space="preserve">
<value>E-Mail</value>
</data>
<data name="Header_UnsubscribeReason" xml:space="preserve">
<value>Notiz</value>
</data>
<data name="Header_Subscribed" xml:space="preserve">
<value>Angemeldet</value>
</data>
<data name="Header_LastReceived" xml:space="preserve">
<value>Zuletzt Zugestellt</value>
</data>
<data name="Header_LastOpen" xml:space="preserve">
<value>Zuletzt Geöffnet</value>
</data>
<data name="Newsletter_Footer_Timezone" xml:space="preserve">
<value>Alle Uhrzeiten sind in der folgenden Zeitzone:</value>
</data>
<data name="AddSubscribers_Parse_Error" xml:space="preserve">
<value>Fehler beim einlesen von Abonennent(en): {0}</value>
</data>
<data name="AddSubscribers_Save_Error" xml:space="preserve">
<value>Fehler beim speichern von Abonennent(en): {0}</value>
</data>
<data name="AddSubscribers_Label" xml:space="preserve">
<value>Abonnent(en) Hinzufügen</value>
</data>
<data name="AddSubscribers_Input_Label" xml:space="preserve">
<value>Abonnent(en) [CSV]</value>
</data>
<data name="AddSubscribers_Input_Placeholder" xml:space="preserve">
<value>anna.muster@example.de; Anna Muster; Spam;
peter.muster@example.de;;;</value>
</data>
<data name="Subscriber_Edit" xml:space="preserve">
<value>Bearbeiten</value>
</data>
</root>

View file

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 1.3
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">1.3</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1">this is my long string</data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
[base64 mime encoded serialized .NET Framework object]
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
[base64 mime encoded string representing a byte array form of the .NET Framework object]
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View file

@ -0,0 +1,156 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 1.3
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">1.3</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1">this is my long string</data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
[base64 mime encoded serialized .NET Framework object]
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
[base64 mime encoded string representing a byte array form of the .NET Framework object]
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Title" xml:space="preserve">
<value>Subscribers</value>
</data>
<data name="Paging_Next" xml:space="preserve">
<value>Next page</value>
</data>
<data name="Paging_Page" xml:space="preserve">
<value>Page</value>
</data>
<data name="Paging_Previous" xml:space="preserve">
<value>Previous page</value>
</data>
<data name="Subscriber_Load_Error" xml:space="preserve">
<value>Unknown error loading subscribers</value>
</data>
<data name="Header_Email" xml:space="preserve">
<value>Email</value>
</data>
<data name="Header_Name" xml:space="preserve">
<value>Name</value>
</data>
<data name="Header_UnsubscribeReason" xml:space="preserve">
<value>Note</value>
</data>
<data name="Header_Subscribed" xml:space="preserve">
<value>Subscribed</value>
</data>
<data name="Header_LastReceived" xml:space="preserve">
<value>Last Received</value>
</data>
<data name="Header_LastOpen" xml:space="preserve">
<value>Last Open</value>
</data>
<data name="Newsletter_Footer_Timezone" xml:space="preserve">
<value>All times are using this timezone:</value>
</data>
<data name="AddSubscribers_Parse_Error" xml:space="preserve">
<value>Failed to parse subscriber(s): {0}</value>
</data>
<data name="AddSubscribers_Save_Error" xml:space="preserve">
<value>Failed to save subscriber(s): {0}</value>
</data>
<data name="AddSubscribers_Label" xml:space="preserve">
<value>Add Subscriber(s)</value>
</data>
<data name="AddSubscribers_Input_Label" xml:space="preserve">
<value>Subscriber(s) [CSV]</value>
</data>
<data name="AddSubscribers_Input_Placeholder" xml:space="preserve">
<value>john.smith@example.com; John Smith; Spam;
jay.smith@example.com;;;</value>
</data>
<data name="Subscriber_Edit" xml:space="preserve">
<value>Edit</value>
</data>
</root>

View file

@ -1,5 +1,6 @@
using ColorCode.Styling;
using Markdig;
using Markdig.Extensions.AutoIdentifiers;
using Markdig.Extensions.MediaLinks;
using Microsoft.AspNetCore.Components;
using Markdown.ColorCode;
@ -10,6 +11,7 @@ namespace Wave.Utilities;
public static class MarkdownUtilities {
public static string Parse(string markdown) {
var pipeline = new MarkdownPipelineBuilder()
.UseAutoIdentifiers(AutoIdentifierOptions.GitHub)
.UsePipeTables()
.UseEmphasisExtras()
.UseListExtras()

View file

@ -9,6 +9,7 @@ namespace Wave.Utilities;
public static class Permissions {
public static bool AllowedToRead(this Article? article, ClaimsPrincipal principal) {
if (article is null || article.IsDeleted) return false;
if (article.Author is null) throw new ArgumentException("Checking permissions without loading related Author.");
// The Article is publicly available
if (article.Status >= ArticleStatus.Published && article.PublishDate <= DateTimeOffset.UtcNow) {
@ -35,6 +36,7 @@ public static class Permissions {
public static bool AllowedToEdit(this Article? article, ClaimsPrincipal principal) {
if (article is null || article.IsDeleted) return false;
if (article.Author is null) throw new ArgumentException("Checking permissions without loading related Author.");
// Admins always can edit articles
if (principal.IsInRole("Admin")) {
@ -67,8 +69,14 @@ public static class Permissions {
return false;
}
public static bool AllowedToRejectReview(this Article? article, ClaimsPrincipal principal) {
// if you can publish it, you can reject it
return article?.Status is ArticleStatus.InReview && article.AllowedToPublish(principal);
}
public static bool AllowedToSubmitForReview(this Article? article, ClaimsPrincipal principal) {
if (article is null || article.IsDeleted) return false;
if (article.Author is null) throw new ArgumentException("Checking permissions without loading related Author.");
// Draft articles can be submitted by their authors (admins can publish them anyway, no need to submit)
if (article.Status is ArticleStatus.Draft && article.Author.Id == principal.FindFirst("Id")!.Value) {
@ -80,6 +88,7 @@ public static class Permissions {
public static bool AllowedToPublish(this Article? article, ClaimsPrincipal principal) {
if (article is null || article.IsDeleted) return false;
if (article.Author is null) throw new ArgumentException("Checking permissions without loading related Author.");
// Admins can skip review and directly publish draft articles
if (article.Status is ArticleStatus.Draft && principal.IsInRole("Admin")) {
@ -102,6 +111,7 @@ public static class Permissions {
public static bool AllowedToDelete(this Article? article, ClaimsPrincipal principal) {
if (article is null || article.IsDeleted) return false;
if (article.Author is null) throw new ArgumentException("Checking permissions without loading related Author.");
// Admins can delete articles whenever
if (principal.IsInRole("Admin")) {

View file

@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="AspNetCore.Authentication.ApiKey" Version="8.0.1" />
<PackageReference Include="CsvHelper" Version="31.0.4" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Humanizer.Core.de" Version="2.14.1" />
<PackageReference Include="Humanizer.Core.uk" Version="2.14.1" />

View file

@ -14,6 +14,9 @@ module.exports = {
'6xl': "2560px",
'8xl': "3072px"
},
height: {
'128': "32rem"
},
container: {
'max-width': {
'3xl': "1792px",

View file

@ -51,4 +51,8 @@ @layer components {
.prose pre:has(code) {
@apply border-2 border-current;
}
.characters-left {
@apply absolute right-6 bottom-6 select-none pointer-events-none;
}
}

File diff suppressed because one or more lines are too long