Compare commits
28 commits
c50fc079fd
...
66b14baee8
Author | SHA1 | Date | |
---|---|---|---|
Mia Rose Winter | 66b14baee8 | ||
Mia Rose Winter | fdb0e1a40e | ||
Mia Rose Winter | 4a7da5518b | ||
Mia Rose Winter | a78a420a61 | ||
Mia Rose Winter | e5fb7f391f | ||
Mia Rose Winter | 51ace95c76 | ||
Mia Rose Winter | c98293cc0a | ||
Mia Rose Winter | d30ae09b9b | ||
Mia Rose Winter | b72f47a99f | ||
Mia Rose Winter | 52ea7a5dfc | ||
Mia Rose Winter | 292433d200 | ||
Mia Rose Winter | 2c44951e13 | ||
Mia Rose Winter | e1cab9b53f | ||
Mia Rose Winter | e816b9fb43 | ||
Mia Rose Winter | 2eb1305ea5 | ||
Mia Rose Winter | 0ea1251150 | ||
Mia Rose Winter | 5c7bf8be04 | ||
Mia Rose Winter | acc2e02961 | ||
Mia Rose Winter | bc4a78382c | ||
Mia Rose Winter | 0d3111647b | ||
Mia Rose Winter | fb55641118 | ||
Mia Rose Winter | 933e0af42b | ||
Mia Rose Winter | e6117d6b06 | ||
Mia Rose Winter | 7e372791ee | ||
Mia Rose Winter | e3bec1cc8d | ||
Mia Rose Winter | bb2f1f5c92 | ||
Mia Rose Winter | 4a4110b7ae | ||
Mia Rose Winter | 3aee412a4e |
40
.github/workflows/testing.yml
vendored
Normal file
40
.github/workflows/testing.yml
vendored
Normal 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 }}
|
|
@ -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)
|
||||
|
|
68
Wave.Tests/Data/ApplicationDbContextTest.cs
Normal file
68
Wave.Tests/Data/ApplicationDbContextTest.cs
Normal 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>"));
|
||||
}
|
||||
}
|
104
Wave.Tests/Data/ArticleTest.cs
Normal file
104
Wave.Tests/Data/ArticleTest.cs
Normal 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"));
|
||||
}
|
||||
}
|
35
Wave.Tests/Utilities/ArticleUtilitiesTest.cs
Normal file
35
Wave.Tests/Utilities/ArticleUtilitiesTest.cs
Normal 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://"));
|
||||
}
|
||||
}
|
41
Wave.Tests/Wave.Tests.csproj
Normal file
41
Wave.Tests/Wave.Tests.csproj
Normal 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>
|
6
Wave.sln
6
Wave.sln
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -44,8 +44,72 @@
|
|||
<CascadingValue Value="UserTheme" Name="UserTheme">
|
||||
<Routes />
|
||||
</CascadingValue>
|
||||
<SectionOutlet SectionName="scripts" />
|
||||
<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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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("/");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
103
Wave/Components/Pages/EditSubscriber.razor
Normal file
103
Wave/Components/Pages/EditSubscriber.razor
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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" />
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
224
Wave/Components/Pages/Subscribers.razor
Normal file
224
Wave/Components/Pages/Subscribers.razor
Normal 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; }
|
||||
}
|
||||
}
|
|
@ -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()")
|
||||
|
|
|
@ -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();
|
||||
}
|
750
Wave/Data/Migrations/postgres/20240502122029_TableOfContents.Designer.cs
generated
Normal file
750
Wave/Data/Migrations/postgres/20240502122029_TableOfContents.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
125
Wave/Resources/Components/Pages/EditSubscriber.de-DE.resx
Normal file
125
Wave/Resources/Components/Pages/EditSubscriber.de-DE.resx
Normal 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>
|
101
Wave/Resources/Components/Pages/EditSubscriber.en-GB.resx
Normal file
101
Wave/Resources/Components/Pages/EditSubscriber.en-GB.resx
Normal 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>
|
125
Wave/Resources/Components/Pages/EditSubscriber.resx
Normal file
125
Wave/Resources/Components/Pages/EditSubscriber.resx
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
153
Wave/Resources/Components/Pages/Subscribers.de-DE.resx
Normal file
153
Wave/Resources/Components/Pages/Subscribers.de-DE.resx
Normal 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>
|
101
Wave/Resources/Components/Pages/Subscribers.en-GB.resx
Normal file
101
Wave/Resources/Components/Pages/Subscribers.en-GB.resx
Normal 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>
|
156
Wave/Resources/Components/Pages/Subscribers.resx
Normal file
156
Wave/Resources/Components/Pages/Subscribers.resx
Normal 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>
|
|
@ -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()
|
||||
|
|
|
@ -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")) {
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -14,6 +14,9 @@ module.exports = {
|
|||
'6xl': "2560px",
|
||||
'8xl': "3072px"
|
||||
},
|
||||
height: {
|
||||
'128': "32rem"
|
||||
},
|
||||
container: {
|
||||
'max-width': {
|
||||
'3xl': "1792px",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
2
Wave/wwwroot/css/main.min.css
vendored
2
Wave/wwwroot/css/main.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue