Compare commits
7 commits
5c62ee4077
...
69a5d51214
Author | SHA1 | Date | |
---|---|---|---|
Mia Rose Winter | 69a5d51214 | ||
Mia Rose Winter | c0195464c0 | ||
Mia Rose Winter | 62ea694204 | ||
Mia Rose Winter | 1a5c5b4cd9 | ||
Mia Rose Winter | 70c5c4c50f | ||
Mia Rose Winter | 50237f0714 | ||
1d55ab23f0 |
10
.github/workflows/docker.yml
vendored
|
@ -11,6 +11,12 @@ jobs:
|
|||
matrix:
|
||||
image_suffix: ["", "-alpine"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
@ -26,7 +32,7 @@ jobs:
|
|||
type=semver,pattern={{version}}
|
||||
flavor: |
|
||||
latest=true
|
||||
suffix=${{ matrix.suffix }}, onlatest=true
|
||||
suffix=${{ matrix.image_suffix }},onlatest=true
|
||||
labels: |
|
||||
maintainer=Mia Rose Winter
|
||||
org.opencontainers.image.title=Wave
|
||||
|
@ -61,4 +67,4 @@ jobs:
|
|||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
BASE=8.0${{ matrix.suffix }}
|
||||
BASE=${{ matrix.image_suffix }}
|
||||
|
|
|
@ -1,34 +1,12 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Wave.Data;
|
||||
using Wave.Tests.TestUtilities;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public class ApplicationDbContextTest : DbContextTest {
|
||||
[Test]
|
||||
public async Task Migration() {
|
||||
await using var context = GetContext();
|
||||
|
|
353
Wave.Tests/Data/ApplicationRepositoryTest.cs
Normal file
|
@ -0,0 +1,353 @@
|
|||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Wave.Data;
|
||||
using Wave.Data.Transactional;
|
||||
using Wave.Tests.TestUtilities;
|
||||
|
||||
// ReSharper disable InconsistentNaming
|
||||
|
||||
namespace Wave.Tests.Data;
|
||||
|
||||
public abstract class ApplicationRepositoryTests : DbContextTest {
|
||||
protected ApplicationRepository Repository { get; set; } = null!;
|
||||
|
||||
protected const string TestUserName = "testuser@example.com";
|
||||
protected const string AuthorUserName = "author@example.com";
|
||||
protected const string ReviewerUserName = "reviewer@example.com";
|
||||
|
||||
protected ClaimsPrincipal AnonymousPrincipal { get; set; } = null!;
|
||||
protected ClaimsPrincipal UserPrincipal { get; set; } = null!;
|
||||
protected ClaimsPrincipal AuthorPrincipal { get; set; } = null!;
|
||||
protected ClaimsPrincipal ReviewerPrincipal { get; set; } = null!;
|
||||
|
||||
protected Guid PrimaryCategoryId { get; set; }
|
||||
protected Guid SecondaryCategoryId { get; set; }
|
||||
|
||||
protected virtual ValueTask InitializeTestEntities(ApplicationDbContext context) {
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async ValueTask AndThenSetUp() {
|
||||
Repository = new ApplicationRepository(new MockDbContextFactory(GetContext));
|
||||
|
||||
List<Category> categories = [
|
||||
new Category {
|
||||
Name = "Primary Category",
|
||||
Color = CategoryColors.Primary
|
||||
},
|
||||
new Category {
|
||||
Name = "Secondary Category",
|
||||
Color = CategoryColors.Secondary
|
||||
}
|
||||
];
|
||||
|
||||
await using var context = GetContext();
|
||||
var user = new ApplicationUser {
|
||||
UserName = TestUserName
|
||||
};
|
||||
var author = new ApplicationUser {
|
||||
UserName = AuthorUserName
|
||||
};
|
||||
var reviewer = new ApplicationUser {
|
||||
UserName = ReviewerUserName
|
||||
};
|
||||
|
||||
context.AddRange(categories);
|
||||
context.Users.AddRange([user, author, reviewer]);
|
||||
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
AnonymousPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
UserPrincipal = new ClaimsPrincipal(new ClaimsIdentity([
|
||||
new Claim(ClaimTypes.Name, user.UserName),
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
||||
new Claim("Id", user.Id),
|
||||
], "Mock Authentication"));
|
||||
AuthorPrincipal = new ClaimsPrincipal(new ClaimsIdentity([
|
||||
new Claim(ClaimTypes.Name, author.UserName),
|
||||
new Claim(ClaimTypes.NameIdentifier, author.Id),
|
||||
new Claim("Id", author.Id),
|
||||
new Claim(ClaimTypes.Role, "Author"),
|
||||
], "Mock Authentication"));
|
||||
ReviewerPrincipal = new ClaimsPrincipal(new ClaimsIdentity([
|
||||
new Claim(ClaimTypes.Name, reviewer.UserName),
|
||||
new Claim(ClaimTypes.NameIdentifier, reviewer.Id),
|
||||
new Claim("Id", reviewer.Id),
|
||||
new Claim(ClaimTypes.Role, "Author"),
|
||||
new Claim(ClaimTypes.Role, "Reviewer"),
|
||||
], "Mock Authentication"));
|
||||
|
||||
PrimaryCategoryId = categories[0].Id;
|
||||
SecondaryCategoryId = categories[1].Id;
|
||||
|
||||
await InitializeTestEntities(context);
|
||||
}
|
||||
|
||||
private sealed class MockDbContextFactory(Func<ApplicationDbContext> supplier)
|
||||
: IDbContextFactory<ApplicationDbContext> {
|
||||
public ApplicationDbContext CreateDbContext() => supplier();
|
||||
}
|
||||
}
|
||||
|
||||
[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
|
||||
[TestOf(typeof(ApplicationRepository))]
|
||||
public class ApplicationRepositoryTest_GetCategories : ApplicationRepositoryTests {
|
||||
|
||||
#region Success Tests
|
||||
|
||||
[Test]
|
||||
public async Task AnonymousDefaultOneArticleOneCategory_Success() {
|
||||
await Repository.CreateArticleAsync(
|
||||
new ArticleCreateDto("test", "*test*", null, null, [PrimaryCategoryId], null), AuthorPrincipal);
|
||||
|
||||
var result = await Repository.GetCategories(AnonymousPrincipal);
|
||||
Assert.Multiple(() => {
|
||||
Assert.That(result, Is.Not.Empty);
|
||||
Assert.That(result.First().Id, Is.EqualTo(PrimaryCategoryId));
|
||||
Assert.That(result, Has.Count.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Permission Tests
|
||||
|
||||
[Test]
|
||||
public async Task AnonymousDefaultNoArticles_Success() {
|
||||
var result = await Repository.GetCategories(AnonymousPrincipal);
|
||||
|
||||
Assert.Multiple(() => { Assert.That(result, Is.Empty); });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnonymousNoArticlesAllCategories_ThrowsMissingPermissions() {
|
||||
Assert.ThrowsAsync<ApplicationException>(async () => await Repository.GetCategories(AnonymousPrincipal, true));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AuthorDefaultNoArticles_Success() {
|
||||
var result = await Repository.GetCategories(AuthorPrincipal);
|
||||
|
||||
Assert.Multiple(() => { Assert.That(result, Is.Empty); });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AuthorDefaultNoArticlesAllCategories_Success() {
|
||||
var result = await Repository.GetCategories(AuthorPrincipal, true);
|
||||
|
||||
Assert.Multiple(() => {
|
||||
Assert.That(result, Is.Not.Empty);
|
||||
Assert.That(result, Has.Count.EqualTo(2));
|
||||
Assert.That(result.FirstOrDefault(c => c.Id == PrimaryCategoryId), Is.Not.Null);
|
||||
Assert.That(result.FirstOrDefault(c => c.Id == SecondaryCategoryId), Is.Not.Null);
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
|
||||
[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
|
||||
[TestOf(typeof(ApplicationRepository))]
|
||||
public class ApplicationRepositoryTest_CreateArticle : ApplicationRepositoryTests {
|
||||
private static ArticleCreateDto GetValidTestArticle(Guid[]? categories = null) {
|
||||
return new ArticleCreateDto(
|
||||
"Test Article",
|
||||
"*Test* Body",
|
||||
null, null, categories, null);
|
||||
}
|
||||
|
||||
#region Success Tests
|
||||
|
||||
[Test]
|
||||
public async Task MinimalArticle_Success() {
|
||||
var article = GetValidTestArticle();
|
||||
|
||||
var view = await Repository.CreateArticleAsync(article, AuthorPrincipal);
|
||||
|
||||
await using var context = GetContext();
|
||||
Assert.Multiple(() => {
|
||||
Assert.That(context.Set<Article>().IgnoreQueryFilters().ToList(), Has.Count.EqualTo(1));
|
||||
Assert.That(context.Set<Article>().IgnoreQueryFilters().First().Id, Is.EqualTo(view.Id));
|
||||
Assert.That(view.Status, Is.EqualTo(ArticleStatus.Draft));
|
||||
Assert.That(view.BodyHtml, Is.Not.Null);
|
||||
Assert.That(view.BodyPlain, Is.Not.Null);
|
||||
Assert.That(view.Slug, Is.EqualTo("test-article"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WithCategories_Success() {
|
||||
var article = GetValidTestArticle([PrimaryCategoryId]);
|
||||
var view = await Repository.CreateArticleAsync(article, AuthorPrincipal);
|
||||
|
||||
await using var context = GetContext();
|
||||
Assert.Multiple(() => {
|
||||
Assert.That(view.Categories, Has.Count.EqualTo(1));
|
||||
Assert.That(context.Set<Article>().IgnoreQueryFilters()
|
||||
.Include(a => a.Categories)
|
||||
.First(a => a.Id == view.Id).Categories.First().Id, Is.EqualTo(PrimaryCategoryId));
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Permission Tests
|
||||
|
||||
[Test]
|
||||
public void RegularUser_ThrowsMissingPermissions() {
|
||||
var article = GetValidTestArticle();
|
||||
|
||||
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
|
||||
async () => await Repository.CreateArticleAsync(article, UserPrincipal));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnonymousUser_ThrowsMissingPermissions() {
|
||||
var article = GetValidTestArticle();
|
||||
|
||||
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
|
||||
async () => await Repository.CreateArticleAsync(article, AnonymousPrincipal));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Data Validation Tests
|
||||
|
||||
[Test]
|
||||
public void MissingTitle_ThrowsMalformed() {
|
||||
var article = new ArticleCreateDto(null!, "test", null, null, null, null);
|
||||
|
||||
Assert.ThrowsAsync<ArticleMalformedException>(
|
||||
async () => await Repository.CreateArticleAsync(article, AuthorPrincipal));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
|
||||
[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
|
||||
[TestOf(typeof(ApplicationRepository))]
|
||||
public class ApplicationRepositoryTest_UpdateArticle : ApplicationRepositoryTests {
|
||||
private Guid TestArticleId { get; set; }
|
||||
|
||||
private ArticleUpdateDto GetValidTestArticle() => new(TestArticleId);
|
||||
|
||||
private static string StringOfLength(int length) {
|
||||
var builder = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < length; i++) {
|
||||
builder.Append('_');
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
protected override async ValueTask InitializeTestEntities(ApplicationDbContext context) {
|
||||
var testArticle = new ArticleCreateDto(
|
||||
"Test Article",
|
||||
"Test **Article** with *formatting.",
|
||||
"test-article",
|
||||
DateTimeOffset.Now.AddHours(-5),
|
||||
[PrimaryCategoryId], null);
|
||||
|
||||
var view = await Repository.CreateArticleAsync(testArticle, AuthorPrincipal);
|
||||
TestArticleId = view.Id;
|
||||
}
|
||||
|
||||
#region Success Tests
|
||||
|
||||
[Test]
|
||||
public async Task UpdateTitle_Success() {
|
||||
var update = new ArticleUpdateDto(TestArticleId, "New Title");
|
||||
|
||||
await Repository.UpdateArticleAsync(update, AuthorPrincipal);
|
||||
|
||||
await using var context = GetContext();
|
||||
Assert.Multiple(() => {
|
||||
Assert.That(context.Set<Article>().IgnoreQueryFilters().First(a => a.Id == TestArticleId).Title,
|
||||
Is.EqualTo("New Title"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateBodyUpdatesHtmlAndPlain_Success() {
|
||||
var update = new ArticleUpdateDto(TestArticleId, body:"Some *new* Body");
|
||||
const string expectedHtml = "<p>Some <em>new</em> Body</p>";
|
||||
const string expectedPlain = "Some new Body";
|
||||
|
||||
await Repository.UpdateArticleAsync(update, AuthorPrincipal);
|
||||
|
||||
await using var context = GetContext();
|
||||
Assert.Multiple(() => {
|
||||
var article = context.Set<Article>().IgnoreQueryFilters().First(a => a.Id == TestArticleId);
|
||||
Assert.That(article.BodyHtml, Is.EqualTo(expectedHtml));
|
||||
Assert.That(article.BodyPlain, Is.EqualTo(expectedPlain));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateCategories_Success() {
|
||||
var update = new ArticleUpdateDto(TestArticleId, categories:[SecondaryCategoryId]);
|
||||
await Repository.UpdateArticleAsync(update, AuthorPrincipal);
|
||||
|
||||
await using var context = GetContext();
|
||||
Assert.Multiple(() => {
|
||||
var article = context.Set<Article>().IgnoreQueryFilters()
|
||||
.Include(a => a.Categories).First(a => a.Id == TestArticleId);
|
||||
Assert.That(article.Categories, Has.Count.EqualTo(1));
|
||||
Assert.That(article.Categories.First().Id, Is.EqualTo(SecondaryCategoryId));
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Permission Tests
|
||||
|
||||
[Test]
|
||||
public void AnonymousUser_ThrowsMissingPermissions() {
|
||||
var update = GetValidTestArticle();
|
||||
|
||||
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
|
||||
async () => await Repository.UpdateArticleAsync(update, AnonymousPrincipal));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RegularUser_ThrowsMissingPermissions() {
|
||||
var update = GetValidTestArticle();
|
||||
|
||||
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
|
||||
async () => await Repository.UpdateArticleAsync(update, UserPrincipal));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UnrelatedAuthor_ThrowsMissingPermissions() {
|
||||
var update = GetValidTestArticle();
|
||||
|
||||
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
|
||||
async () => await Repository.UpdateArticleAsync(update, ReviewerPrincipal));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Data Validation Tests
|
||||
|
||||
[Test]
|
||||
public void SlugLength65_ThrowsMalformed() {
|
||||
var update = new ArticleUpdateDto(TestArticleId, slug:StringOfLength(65));
|
||||
Assert.ThrowsAsync<ArticleMalformedException>(
|
||||
async () => await Repository.UpdateArticleAsync(update, AuthorPrincipal));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TitleLength257_ThrowsMalformed() {
|
||||
var update = new ArticleUpdateDto(TestArticleId, slug:StringOfLength(257));
|
||||
Assert.ThrowsAsync<ArticleMalformedException>(
|
||||
async () => await Repository.UpdateArticleAsync(update, AuthorPrincipal));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
|
@ -22,7 +22,7 @@ public class ArticleTest {
|
|||
public void SlugWithSpecialCharacters() {
|
||||
Article.Title = "Title with, special characters?";
|
||||
Article.UpdateSlug();
|
||||
Assert.That(Article.Slug, Is.EqualTo("title-with%2C-special-characters%3F"));
|
||||
Assert.That(Article.Slug, Is.EqualTo("title-with-special-characters"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
@ -36,14 +36,14 @@ public class ArticleTest {
|
|||
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"));
|
||||
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-______-"));
|
||||
}
|
||||
|
||||
[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"));
|
||||
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-_______-a"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
@ -57,7 +57,7 @@ public class ArticleTest {
|
|||
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"));
|
||||
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-________-a"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
@ -71,21 +71,21 @@ public class ArticleTest {
|
|||
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"));
|
||||
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-need-special-cre"));
|
||||
}
|
||||
|
||||
[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"));
|
||||
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-needs-special-cre"));
|
||||
}
|
||||
|
||||
[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"));
|
||||
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-needs-special-car"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
31
Wave.Tests/TestUtilities/DbContextTest.cs
Normal file
|
@ -0,0 +1,31 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Wave.Data;
|
||||
|
||||
namespace Wave.Tests.TestUtilities;
|
||||
|
||||
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
|
||||
public abstract class DbContextTest {
|
||||
private PostgreSqlContainer PostgresContainer { get; } = new PostgreSqlBuilder().WithImage("postgres:16.1-alpine").Build();
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() {
|
||||
await PostgresContainer.StartAsync();
|
||||
await AndThenSetUp();
|
||||
}
|
||||
protected virtual ValueTask AndThenSetUp() {
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await PostgresContainer.DisposeAsync();
|
||||
|
||||
protected ApplicationDbContext GetContext() =>
|
||||
new(new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseNpgsql(PostgresContainer.GetConnectionString())
|
||||
.EnableSensitiveDataLogging()
|
||||
.EnableDetailedErrors()
|
||||
.EnableThreadSafetyChecks()
|
||||
.Options);
|
||||
|
||||
}
|
|
@ -35,6 +35,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="TestUtilities\" />
|
||||
<Folder Include="Utilities\" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
382
Wave/Assets/React/ArticleEditor.tsx
Normal file
|
@ -0,0 +1,382 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { updateCharactersLeft, insertBeforeSelection, insertBeforeAndAfterSelection } from "../utilities/md_functions";
|
||||
import { LabelInput, ToolBarButton } from "./Forms";
|
||||
import { CategoryColor, Category, ArticleStatus, ArticleView, ArticleDto } from "../model/Models";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import markdownit from "markdown-it";
|
||||
import markdownitmark from "markdown-it-mark";
|
||||
import "groupby-polyfill/lib/polyfill.js";
|
||||
|
||||
const nameof = function<T>(name: keyof T) { return name; }
|
||||
|
||||
async function get<T>(url: string): Promise<T> {
|
||||
let response = await fetch(url, {
|
||||
method: "GET"
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(response.statusText);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function post<T, J>(url: string, data: T) : Promise<J> {
|
||||
let response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(response.statusText);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
async function put<T, J>(url: string, data: T) : Promise<J> {
|
||||
let response = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(response.statusText);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function Loading(message: string) {
|
||||
return <div className="grid place-items-center h-64">
|
||||
<div className="flex flex-col place-items-center">
|
||||
<p>{message}</p>
|
||||
<span className="loading loading-bars loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default function Editor() {
|
||||
const {t} = useTranslation();
|
||||
const [notice, setNotice] = useState<string>("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
const [article, setArticle] = useState<ArticleView|null>(null);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [model, setModel] = useState<ArticleDto>({
|
||||
body: "",
|
||||
categories: [],
|
||||
id: "",
|
||||
publishDate: new Date(),
|
||||
slug: "",
|
||||
title: ""
|
||||
});
|
||||
|
||||
const md = markdownit({
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
.use(markdownitmark)
|
||||
;
|
||||
|
||||
function onChangeModel(event: React.ChangeEvent) {
|
||||
const {name, value} = event.target as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement);
|
||||
if (name == nameof<ArticleDto>("publishDate")) {
|
||||
setModel((m : ArticleDto) => ({...m, publishDate: new Date(value)}));
|
||||
} else if (event.target instanceof HTMLSelectElement) {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
setModel((m : ArticleDto) => ({...m, [name]: [...select.selectedOptions].map(o => o.value)}));
|
||||
} else {
|
||||
setModel((m : ArticleDto) => ({...m, [name]: value}));
|
||||
}
|
||||
setDirty(true);
|
||||
}
|
||||
function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!article || !article.id || article.id.length < 1) {
|
||||
put<ArticleDto, ArticleView>("/api/article", model)
|
||||
.then(result => {
|
||||
// we just reload the editor, to make sure everything is updated properly
|
||||
window.location.pathname = `/article/${result.id}/edit`;
|
||||
}).catch(err => setNotice(`Error creating article: ${err}`));
|
||||
} else {
|
||||
post<ArticleDto, ArticleView>("/api/article", model)
|
||||
.then(result => {
|
||||
setDirty(false)
|
||||
setArticle(result)
|
||||
|
||||
if (model.slug != result.slug) {
|
||||
setModel(m => ({...m, slug: result.slug}));
|
||||
}
|
||||
})
|
||||
.catch(err => setNotice(`Error trying to save article: ${err}`));
|
||||
}
|
||||
}
|
||||
|
||||
const location = window.location.pathname;
|
||||
useEffect(() => {
|
||||
if (categories.length < 1) {
|
||||
get<Category[]>("/api/categories?all=true").then(result => {
|
||||
setCategories(result);
|
||||
}).catch(error => {
|
||||
setNotice(`Error loading Categories: ${error.Message}`);
|
||||
console.log(`Error loading Categories: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
const id = location.match(/article\/([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})\/edit/i);
|
||||
if (!id) {
|
||||
const publishDate = new Date();
|
||||
publishDate.setDate(publishDate.getDate() + 7);
|
||||
const article : ArticleView = {
|
||||
body: "",
|
||||
id: "",
|
||||
publishDate: publishDate.toString(),
|
||||
slug: "",
|
||||
status: 0,
|
||||
title: "",
|
||||
categories: []
|
||||
};
|
||||
setArticle(article);
|
||||
setModel(m => ({...m, publishDate: publishDate}));
|
||||
} else if (!article) {
|
||||
get<ArticleView>(`/api/article/${id[1]}`)
|
||||
.then(result => {
|
||||
setNotice("");
|
||||
setIsPublished(result.status >= ArticleStatus.Published && (new Date(result.publishDate) <= new Date()));
|
||||
setModel(m => ({
|
||||
...m,
|
||||
id: result.id,
|
||||
title: result.title,
|
||||
slug: result.slug,
|
||||
body: result.body,
|
||||
publishDate: new Date(result.publishDate),
|
||||
categories: result.categories.map(c => c.id),
|
||||
}));
|
||||
setArticle(result);
|
||||
console.log("Article loaded");
|
||||
})
|
||||
.catch(error => {
|
||||
setNotice(`Error loading Article: ${error.message}`);
|
||||
console.log(`Error loading Article: ${error.message}`);
|
||||
setArticle(null);
|
||||
});
|
||||
}
|
||||
}, ([setArticle, setNotice, console, location]) as any[]);
|
||||
|
||||
const markdownArea = useRef<HTMLTextAreaElement>(null);
|
||||
return (
|
||||
<>
|
||||
{
|
||||
dirty &&
|
||||
<div role="alert" className="alert alert-warning sticky left-4 right-4 top-4 mb-4 z-50 rounded-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="size-6">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" />
|
||||
</svg>
|
||||
<p>{t("editor.unsaved_changes_notice")}</p>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
notice.length > 0 &&
|
||||
<div role="alert" className="alert alert-error sticky left-4 right-4 top-4 mb-4 z-50 rounded-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="size-6">
|
||||
<path fillRule="evenodd" clipRule="evenodd"
|
||||
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" />
|
||||
</svg>
|
||||
<p>{notice}</p>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
article === null ?
|
||||
Loading(t("loading.article")) :
|
||||
<>
|
||||
<div className="w-full">
|
||||
<ul className="steps steps-vertical md:steps-horizontal">
|
||||
<li className={`step w-24 ${(article.status ?? -1) >= ArticleStatus.Draft ? "step-primary" : ""}`}>{t("Draft")}</li>
|
||||
<li className={`step w-24 ${(article.status ?? -1) >= ArticleStatus.InReview ? "step-primary" : ""}`}>{t("InReview")}</li>
|
||||
<li className={`step w-24 ${article.status === ArticleStatus.Published ? "step-primary" : ""}`}>{t("Published")}</li>
|
||||
</ul>
|
||||
|
||||
<form method="post" onSubmit={onSubmit}>
|
||||
<fieldset className="grid grid-cols-1 lg:grid-cols-2 gap-x-8">
|
||||
<LabelInput label={t("Title_Label")}>
|
||||
<input className="input input-bordered w-full"
|
||||
maxLength={256} required aria-required autoComplete="off"
|
||||
onInput={(event: React.FormEvent<HTMLInputElement>) => updateCharactersLeft(event.target as HTMLInputElement)}
|
||||
placeholder={t("Title_Placeholder")}
|
||||
name={nameof<ArticleDto>("title")} value={model.title} onChange={onChangeModel} />
|
||||
</LabelInput>
|
||||
<LabelInput label={t("Category_Label")}
|
||||
className="row-span-3 order-first md:order-none">
|
||||
<select className="select select-bordered w-full" size={10} multiple={true}
|
||||
onChange={onChangeModel} name={nameof<ArticleDto>("categories")}
|
||||
defaultValue={article.categories.map(c => c.id)}>
|
||||
{
|
||||
Array.from(Map.groupBy(categories, (c: Category) => c.color) as Map<CategoryColor, Category[]>)
|
||||
.map((value, _) =>
|
||||
<optgroup className="font-bold not-italic my-3"
|
||||
label={t(`Category.${CategoryColor[value[0]]}`)}>
|
||||
{value[1].map(c => <option key={c.id} value={c.id}>{c.name ?? "err"}</option>)}
|
||||
</optgroup>)
|
||||
}
|
||||
</select>
|
||||
</LabelInput>
|
||||
<fieldset disabled={isPublished} title={isPublished ? t("article_published_cant_edit_date_or_slug_tooltip") : undefined}>
|
||||
<LabelInput label={t("Slug_Label")}>
|
||||
<input className="input input-bordered w-full"
|
||||
maxLength={64} autoComplete="off"
|
||||
onInput={(event: React.FormEvent<HTMLInputElement>) => updateCharactersLeft(event.target as HTMLInputElement)}
|
||||
placeholder={t("Slug_Placeholder")}
|
||||
name={nameof<ArticleDto>("slug")} value={model.slug} onChange={onChangeModel} />
|
||||
</LabelInput>
|
||||
<LabelInput label={t("PublishDate_Label")}>
|
||||
<input className="input input-bordered w-full"
|
||||
type="datetime-local" autoComplete="off"
|
||||
name={nameof<ArticleDto>("publishDate")}
|
||||
defaultValue={(new Date(article.publishDate).toLocaleString("sv", {timeZoneName: "short"}).substring(0, 16))}
|
||||
onChange={onChangeModel} />
|
||||
</LabelInput>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="my-6 grid grid-cols-1 lg:grid-cols-2 gap-x-8 gap-y-4">
|
||||
<div className="join join-vertical min-h-96 h-full w-full">
|
||||
<div className="flex flex-wrap gap-1 p-2 z-50 bg-base-200 sticky top-0"
|
||||
role="toolbar">
|
||||
<div className="join join-horizontal">
|
||||
<ToolBarButton title={t("Tools.H1_Tooltip")}
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "# ", true)}>
|
||||
<strong>{t("Tools.H1_Label")}</strong>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.H2_Tooltip")}
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "## ", true)}>
|
||||
<strong>{t("Tools.H2_Label")}</strong>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.H3_Tooltip")}
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "### ", true)}>
|
||||
<strong>{t("Tools.H3_Label")}</strong>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.H4_Tooltip")}
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "#### ", true)}>
|
||||
<strong>{t("Tools.H4_Label")}</strong>
|
||||
</ToolBarButton>
|
||||
</div>
|
||||
<div className="join join-horizontal">
|
||||
<ToolBarButton title={t("Tools.Bold_Tooltip")}
|
||||
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "**")}>
|
||||
<strong>B</strong>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.Italic_Tooltip")}
|
||||
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "*")}>
|
||||
<em>I</em>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.Underline_Tooltip")}
|
||||
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "++")}>
|
||||
<span className="underline">U</span>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.StrikeThrough_Tooltip")}
|
||||
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "~~")}>
|
||||
<del>{t("Tools.StrikeThrough_Label")}</del>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.Mark_Tooltip")}
|
||||
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "==")}>
|
||||
<mark>{t("Tools.Mark_Label")}</mark>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.Mark_Tooltip")}
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "> ", true)}>
|
||||
| <em>{t("Tools.Cite_Label")}</em>
|
||||
</ToolBarButton>
|
||||
</div>
|
||||
<div className="join join-horizontal">
|
||||
<ToolBarButton
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "1. ", true)}>
|
||||
1.
|
||||
</ToolBarButton>
|
||||
<ToolBarButton
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "a. ", true)}>
|
||||
a.
|
||||
</ToolBarButton>
|
||||
<ToolBarButton
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "A. ", true)}>
|
||||
A.
|
||||
</ToolBarButton>
|
||||
<ToolBarButton
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "i. ", true)}>
|
||||
i.
|
||||
</ToolBarButton>
|
||||
<ToolBarButton
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "I. ", true)}>
|
||||
I.
|
||||
</ToolBarButton>
|
||||
</div>
|
||||
<div className="join join-horizontal">
|
||||
<ToolBarButton title={t("Tools.CodeLine_Tooltip")}
|
||||
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "`")}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-4 h-4">
|
||||
<path fillRule="evenodd"
|
||||
d="M14.447 3.026a.75.75 0 0 1 .527.921l-4.5 16.5a.75.75 0 0 1-1.448-.394l4.5-16.5a.75.75 0 0 1 .921-.527ZM16.72 6.22a.75.75 0 0 1 1.06 0l5.25 5.25a.75.75 0 0 1 0 1.06l-5.25 5.25a.75.75 0 1 1-1.06-1.06L21.44 12l-4.72-4.72a.75.75 0 0 1 0-1.06Zm-9.44 0a.75.75 0 0 1 0 1.06L2.56 12l4.72 4.72a.75.75 0 0 1-1.06 1.06L.97 12.53a.75.75 0 0 1 0-1.06l5.25-5.25a.75.75 0 0 1 1.06 0Z"
|
||||
clipRule="evenodd"/>
|
||||
</svg>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.CodeBlock_Tooltip")}
|
||||
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "```")}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-4 h-4">
|
||||
<path fillRule="evenodd"
|
||||
d="M3 6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6Zm14.25 6a.75.75 0 0 1-.22.53l-2.25 2.25a.75.75 0 1 1-1.06-1.06L15.44 12l-1.72-1.72a.75.75 0 1 1 1.06-1.06l2.25 2.25c.141.14.22.331.22.53Zm-10.28-.53a.75.75 0 0 0 0 1.06l2.25 2.25a.75.75 0 1 0 1.06-1.06L8.56 12l1.72-1.72a.75.75 0 1 0-1.06-1.06l-2.25 2.25Z"
|
||||
clipRule="evenodd"/>
|
||||
</svg>
|
||||
</ToolBarButton>
|
||||
</div>
|
||||
</div>
|
||||
<textarea ref={markdownArea} id="tool-target"
|
||||
className="resize-none textarea textarea-bordered outline-none w-full flex-1 join-item"
|
||||
required aria-required placeholder={t("Body_Placeholder")}
|
||||
autoComplete="off"
|
||||
name={nameof<ArticleDto>("body")} value={model.body} onChange={onChangeModel}/>
|
||||
</div>
|
||||
<div className="bg-base-200 p-2">
|
||||
<h2 className="text-2xl lg:text-4xl font-bold mb-6 hyphens-auto">
|
||||
{model.title.length < 1 ? t("Title_Placeholder") : model.title}
|
||||
</h2>
|
||||
{
|
||||
model.body.length < 1 ?
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
<div className="skeleton h-32 w-full"></div>
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
</div> :
|
||||
<div className="prose prose-neutral max-w-none hyphens-auto text-justify"
|
||||
dangerouslySetInnerHTML={{__html: md.render(model.body)}}>
|
||||
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex gap-2 flex-wrap mt-3">
|
||||
<button type="submit" className="btn btn-primary w-full sm:btn-wide"
|
||||
disabled={!dirty}>
|
||||
{t("EditorSubmit")}
|
||||
</button>
|
||||
|
||||
<a className="btn w-full sm:btn-wide"
|
||||
href={`/article/${article?.id}`} // TODO disable when article not exists
|
||||
>
|
||||
{t("ViewArticle_Label")}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
22
Wave/Assets/React/Forms.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import React from "react";
|
||||
|
||||
interface ILabelProperties {
|
||||
label: string,
|
||||
className?: string,
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
export function LabelInput({label, className, children} : ILabelProperties) : React.ReactElement {
|
||||
return <label className={`form-control w-full ${className ?? ""}`}>
|
||||
<div className="label">{label}</div>
|
||||
{children}
|
||||
</label>;
|
||||
}
|
||||
|
||||
export function ToolBarButton({title, onClick, children}: {title?: string, onClick:React.MouseEventHandler<HTMLButtonElement>, children:any}) {
|
||||
return <button type="button" className="btn btn-accent btn-sm outline-none font-normal join-item"
|
||||
title={title}
|
||||
onClick={onClick}>
|
||||
{children ?? "err"}
|
||||
</button>;
|
||||
}
|
|
@ -1,41 +1,64 @@
|
|||
html, body {
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
@import "@fontsource/noto-sans-display";
|
||||
|
||||
@font-face {
|
||||
font-display: block;
|
||||
font-family: 'Nunito Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url('@fontsource/nunito-sans/files/nunito-sans-latin-700-normal.woff') format('woff'),
|
||||
url('@fontsource/nunito-sans/files/nunito-sans-latin-700-normal.woff2') format('woff2');
|
||||
}
|
||||
|
||||
a, .btn-link {
|
||||
color: #006bb7;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply font-body;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-heading tracking-tight;
|
||||
}
|
||||
hyphens-auto {
|
||||
hyphenate-limit-chars: 5 3;
|
||||
}
|
||||
}
|
||||
@layer components {
|
||||
.youtube {
|
||||
@apply rounded p-2 bg-base-200;
|
||||
}
|
||||
|
||||
input.narrow-reading-toggle {
|
||||
display: none;
|
||||
}
|
||||
body:has(label[for=narrow-reading-toggle]) input.narrow-reading-toggle:checked + .reading-toggle-target {
|
||||
@apply max-w-3xl;
|
||||
}
|
||||
|
||||
.fade-away {
|
||||
-webkit-mask-image: linear-gradient(black, black 80%, rgba(0, 0, 0, 0.5) 85%, transparent 100%);
|
||||
mask-image: linear-gradient(black, black 80%, rgba(0, 0, 0, 0.5) 85%, transparent 100%);
|
||||
}
|
||||
|
||||
.prose div > pre, .prose > pre {
|
||||
@apply bg-inherit text-inherit rounded-none;
|
||||
}
|
||||
.prose pre:has(code) {
|
||||
@apply border-2 border-current;
|
||||
}
|
||||
|
||||
.characters-left {
|
||||
@apply absolute right-6 bottom-6 select-none pointer-events-none;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 1.1rem;
|
||||
}
|
||||
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: 1px solid #e50000;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: #e50000;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
|
@ -44,8 +67,4 @@ .blazor-error-boundary {
|
|||
|
||||
.blazor-error-boundary::after {
|
||||
content: "An error has occurred."
|
||||
}
|
||||
|
||||
.darker-border-checkbox.form-check-input {
|
||||
border-color: #929292;
|
||||
}
|
||||
}
|
160
Wave/Assets/main.tsx
Normal file
|
@ -0,0 +1,160 @@
|
|||
import "vite/modulepreload-polyfill";
|
||||
import i18n, { LanguageDetectorModule } from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
import Editor from "./React/ArticleEditor";
|
||||
|
||||
const domNode = document.getElementById("editor");
|
||||
if (domNode) {
|
||||
const aspnetCookieLanguageDetector : LanguageDetectorModule = {
|
||||
type: "languageDetector", detect(): string | readonly string[] | undefined {
|
||||
let cookies = document.cookie.split(";");
|
||||
let cultureCookie = cookies.find(s => s.startsWith(".AspNetCore.Culture"));
|
||||
|
||||
if (cultureCookie) {
|
||||
let parts = cultureCookie.split("=", 2);
|
||||
if (parts[1]) {
|
||||
let info = parts[1]
|
||||
.replace("%7C", "|")
|
||||
.replace("%3D", "=")
|
||||
.replace("%3D", "=");
|
||||
let cultureInfo = info.split("|", 2);
|
||||
let ui_culture = cultureInfo[1]?.split("=", 2)[1];
|
||||
if(ui_culture && ui_culture.match(/^\w\w(-\w\w)?$/))
|
||||
return ui_culture;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
i18n
|
||||
.use(aspnetCookieLanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
Draft: "Draft",
|
||||
InReview: "In Review",
|
||||
Published: "Published",
|
||||
Title_Label: "Title",
|
||||
Title_Placeholder: "My new cheese cake recipe",
|
||||
Slug_Label: "Article Url Part (autogenerated)",
|
||||
Slug_Placeholder: "my-new-cheese-cake-recipe",
|
||||
PublishDate_Label: "Publish Date",
|
||||
PublishDate_Placeholder: "When to release this Article after approval",
|
||||
Category_Label: "Category",
|
||||
EditorSubmit: "Save",
|
||||
ViewArticle_Label: "Open",
|
||||
|
||||
Body_Label: "Content",
|
||||
Body_Placeholder: "First you preheat the oven...",
|
||||
Tools: {
|
||||
H1_Label: "H1",
|
||||
H1_Tooltip: "First level heading",
|
||||
H2_Label: "H2",
|
||||
H2_Tooltip: "Second level heading",
|
||||
H3_Label: "H3",
|
||||
H3_Tooltip: "Third level heading",
|
||||
H4_Label: "H4",
|
||||
H4_Tooltip: "Fourth level heading",
|
||||
|
||||
Bold_Tooltip: "Make text bold",
|
||||
Italic_Tooltip: "Make text italic",
|
||||
StrikeThrough_Label: "del",
|
||||
StrikeThrough_Tooltip: "Stroke through text",
|
||||
Mark_Label: "txt",
|
||||
Mark_Tooltip: "Mark the selected text",
|
||||
Cite_Label: "Cite",
|
||||
Cite_Tooltip: "Make text a citation",
|
||||
|
||||
CodeLine_Tooltip: "Mark selected text as programming code",
|
||||
CodeBlock_Tooltip: "Insert program code block",
|
||||
},
|
||||
Category: {
|
||||
Primary: "Primary Category",
|
||||
Dangerous: "",
|
||||
Important: "Important",
|
||||
Informative: "Informative",
|
||||
Secondary: "Secondary Category",
|
||||
Default: "Regular Category",
|
||||
Extra: "Additional Category",
|
||||
},
|
||||
loading: {
|
||||
article: "Loading article...",
|
||||
},
|
||||
editor: {
|
||||
unsaved_changes_notice: "You have unsaved changes, save now so you don't loose them!",
|
||||
}
|
||||
}
|
||||
},
|
||||
de: {
|
||||
translation: {
|
||||
Draft: "Entwurf",
|
||||
InReview: "In Rezension",
|
||||
Published: "Veröffentlicht",
|
||||
Title_Label: "Überschrift",
|
||||
Title_Placeholder: "Mein neues Käsekuchenrezept",
|
||||
Slug_Label: "Artikel URL (autogeneriert)",
|
||||
Slug_Placeholder: "mein-neues-kaesekuchenrezept",
|
||||
PublishDate_Label: "Erscheinungsdatum",
|
||||
PublishDate_Placeholder: "Wann dieser Artikel veröffentlicht werden soll nachdem er geprüft wurde",
|
||||
Category_Label: "Kategorie",
|
||||
EditorSubmit: "Speichern",
|
||||
ViewArticle_Label: "Öffnen",
|
||||
|
||||
Body_Label: "Inhalt",
|
||||
Body_Placeholder: "Zuerst heizt man den ofen vor...",
|
||||
Tools: {
|
||||
H1_Label: "Ü1",
|
||||
H1_Tooltip: "Primärüberschrift",
|
||||
H2_Label: "Ü2",
|
||||
H2_Tooltip: "Sekundärüberschrift",
|
||||
H3_Label: "Ü3",
|
||||
H3_Tooltip: "Level 3 Überschrift",
|
||||
H4_Label: "Ü4",
|
||||
H4_Tooltip: "Level 4 Überschrift",
|
||||
|
||||
Bold_Tooltip: "Text hervorheben",
|
||||
Italic_Tooltip: "Text kursiv stellen",
|
||||
// StrikeThrough_Label -> take from en
|
||||
StrikeThrough_Tooltip: "Text durchstreichen",
|
||||
// Mark_Label -> take from en
|
||||
Mark_Tooltip: "Den selektierten Text markieren",
|
||||
Cite_Label: "Zitat",
|
||||
Cite_Tooltip: "Text als Zitat formatieren",
|
||||
|
||||
CodeLine_Tooltip: "Selektierten text als programmcode markieren",
|
||||
CodeBlock_Tooltip: "Programmierblock einfügen",
|
||||
},
|
||||
Category: {
|
||||
Primary: "Hauptkategorie",
|
||||
Dangerous: "",
|
||||
Important: "Wichtig",
|
||||
Informative: "Informativ",
|
||||
Secondary: "Sekundäre Kategorie",
|
||||
Default: "Reguläre Kategorie",
|
||||
Extra: "Zusätzliche Kategorie",
|
||||
},
|
||||
loading: {
|
||||
article: "Lade Artikel...",
|
||||
},
|
||||
editor: {
|
||||
unsaved_changes_notice: "Sie haben ungesicherte Änderungen, speichern Sie jetzt um diese nicht zu verlieren!",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const root = createRoot(domNode);
|
||||
root.render(<Editor />);
|
||||
}
|
40
Wave/Assets/model/Models.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
export type guid = string;
|
||||
|
||||
export enum CategoryColor {
|
||||
Primary = 1,
|
||||
Dangerous = 5,
|
||||
Important = 10,
|
||||
Informative = 15,
|
||||
Secondary = 20,
|
||||
Default = 25,
|
||||
Extra = 50,
|
||||
}
|
||||
export enum ArticleStatus {
|
||||
Draft = 0,
|
||||
InReview = 1,
|
||||
Published = 2,
|
||||
}
|
||||
|
||||
export type Category = {
|
||||
id: guid,
|
||||
name: string,
|
||||
color: CategoryColor,
|
||||
}
|
||||
|
||||
export type ArticleView = {
|
||||
id: guid,
|
||||
title: string,
|
||||
slug: string,
|
||||
body: string,
|
||||
status: ArticleStatus,
|
||||
publishDate: string,
|
||||
categories: Category[],
|
||||
}
|
||||
export type ArticleDto = {
|
||||
id: guid,
|
||||
title: string,
|
||||
slug: string,
|
||||
body: string,
|
||||
publishDate: Date,
|
||||
categories: guid[],
|
||||
}
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 5 KiB After Width: | Height: | Size: 5 KiB |
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
Before Width: | Height: | Size: 6 KiB After Width: | Height: | Size: 6 KiB |
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.8 KiB |
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.8 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 5 KiB After Width: | Height: | Size: 5 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
63
Wave/Assets/utilities/md_functions.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
export function updateCharactersLeft(input : HTMLInputElement) {
|
||||
const maxLength = input.maxLength;
|
||||
const currentLength = input.value.length;
|
||||
|
||||
const newLeft = maxLength - currentLength;
|
||||
|
||||
const parent = input.parentNode as HTMLElement;
|
||||
if (!parent) return;
|
||||
|
||||
let elem = parent.querySelector(".characters-left") as HTMLElement;
|
||||
if (elem) {
|
||||
elem.innerText = `${newLeft}`;
|
||||
} else {
|
||||
parent.classList.add("relative");
|
||||
elem = document.createElement("span");
|
||||
elem.classList.add("characters-left");
|
||||
elem.innerText = `${newLeft}`;
|
||||
parent.appendChild(elem);
|
||||
}
|
||||
}
|
||||
|
||||
export function insertBeforeSelection(target: HTMLTextAreaElement|null, markdown : string, startOfLine = false) {
|
||||
if (!target) return;
|
||||
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 }));
|
||||
}
|
||||
|
||||
export function insertBeforeAndAfterSelection(target: HTMLTextAreaElement|null, markdown : string) {
|
||||
if (!target) return;
|
||||
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 }));
|
||||
}
|
5
Wave/Assets/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="dom" />
|
||||
/// <reference types="node" />
|
||||
/// <reference types="react" />
|
||||
/// <reference types="react-dom" />
|
|
@ -1,37 +1,45 @@
|
|||
@using System.Globalization
|
||||
@using Microsoft.AspNetCore.Localization
|
||||
@using Microsoft.Extensions.Options
|
||||
@using Vite.AspNetCore
|
||||
@using Wave.Data
|
||||
|
||||
@inject IViteManifest ViteManifest
|
||||
@inject IViteDevServerStatus ViteStatus
|
||||
@inject IOptions<Customization> Customizations
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="@CultureInfo.CurrentUICulture.ToString()" data-theme="@(UserTheme ?? Customizations.Value.DefaultTheme)">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<base href="/">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
<link rel="stylesheet" href="/css/main.min.css">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<base href="/">
|
||||
@if (ViteStatus.IsEnabled) {
|
||||
<link rel="stylesheet" href="@($"{ViteStatus.ServerUrlWithBasePath}/css/main.css")">
|
||||
} else {
|
||||
<link rel="stylesheet" href="/dist/@(ViteManifest["css/main.css"]?.File ?? "css/main.css")">
|
||||
}
|
||||
|
||||
<!-- #region favicon + manifest -->
|
||||
@if (!string.IsNullOrWhiteSpace(Customizations.Value.IconLink)) {
|
||||
<link rel="icon" href="@Customizations.Value.IconLink">
|
||||
} else {
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/dist/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/dist/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/dist/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/dist/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/dist/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/dist/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/dist/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/dist/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/dist/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/dist/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/dist/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/dist/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/dist/favicon-16x16.png">
|
||||
}
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="manifest" href="/dist/manifest.json">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
@ -44,6 +52,16 @@
|
|||
<CascadingValue Value="UserTheme" Name="UserTheme">
|
||||
<Routes />
|
||||
</CascadingValue>
|
||||
@if (ViteStatus.IsEnabled) {
|
||||
<script type="module">
|
||||
import RefreshRuntime from 'http://localhost:5173/dist/@@react-refresh'
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => { }
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
</script>
|
||||
<script type="module" src="@(ViteStatus.ServerUrlWithBasePath + "/@vite/client")"></script>
|
||||
}
|
||||
<script src="_framework/blazor.web.js" defer></script>
|
||||
<SectionOutlet SectionName="scripts" />
|
||||
<script>
|
||||
|
@ -115,26 +133,26 @@
|
|||
</html>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
public HttpContext? HttpContext { get; set; }
|
||||
[CascadingParameter]
|
||||
public HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? UserTheme { get; set; }
|
||||
|
||||
protected override void OnInitialized() {
|
||||
HttpContext?.Response.Cookies.Append(
|
||||
CookieRequestCultureProvider.DefaultCookieName,
|
||||
CookieRequestCultureProvider.MakeCookieValue(
|
||||
new RequestCulture(
|
||||
CultureInfo.CurrentCulture,
|
||||
CultureInfo.CurrentUICulture)), new CookieOptions {
|
||||
Expires = DateTimeOffset.UtcNow.AddYears(1),
|
||||
|
||||
protected override void OnInitialized() {
|
||||
HttpContext?.Response.Cookies.Append(
|
||||
CookieRequestCultureProvider.DefaultCookieName,
|
||||
CookieRequestCultureProvider.MakeCookieValue(
|
||||
new RequestCulture(
|
||||
CultureInfo.CurrentCulture,
|
||||
CultureInfo.CurrentUICulture)), new CookieOptions {
|
||||
Expires = DateTimeOffset.UtcNow.AddYears(1),
|
||||
IsEssential = true,
|
||||
SameSite = SameSiteMode.Strict
|
||||
});
|
||||
});
|
||||
|
||||
if (HttpContext?.Request.Cookies.ContainsKey(".Wave.Theme") is true) {
|
||||
HttpContext.Request.Cookies.TryGetValue(".Wave.Theme", out string? theme);
|
||||
UserTheme = theme;
|
||||
}
|
||||
}
|
||||
if (HttpContext?.Request.Cookies.ContainsKey(".Wave.Theme") is true) {
|
||||
HttpContext.Request.Cookies.TryGetValue(".Wave.Theme", out string? theme);
|
||||
UserTheme = theme;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,9 +6,9 @@
|
|||
<img class="max-h-full object-contain object-left" src="@logo" alt="" />
|
||||
} else {
|
||||
<picture>
|
||||
<source type="image/jxl" srcset="img/logo.jxl" />
|
||||
<source type="image/svg+xml" srcset="img/logo.svg" />
|
||||
<source type="image/webp" srcset="img/logo.webp" />
|
||||
<img class="h-full object-contain object-left" src="img/logo.png" alt="Wave" />
|
||||
<source type="image/jxl" srcset="/dist/img/logo.jxl" />
|
||||
<source type="image/svg+xml" srcset="/dist/img/logo.svg" />
|
||||
<source type="image/webp" srcset="/dist/img/logo.webp" />
|
||||
<img class="h-full object-contain object-left" src="/dist/img/logo.png" alt="Wave" />
|
||||
</picture>
|
||||
}
|
||||
|
|
|
@ -1,58 +1,80 @@
|
|||
@page "/article/new"
|
||||
@page "/article/{id:guid}/edit"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Vite.AspNetCore
|
||||
@using Wave.Data
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using System.Security.Claims
|
||||
@using Wave.Utilities
|
||||
|
||||
@rendermode @(new InteractiveServerRenderMode(true))
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize(Policy = "ArticleEditPermissions")]
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject ILogger<ArticleEditor> Logger
|
||||
@inject NavigationManager Navigation
|
||||
@inject IStringLocalizer<ArticleEditor> Localizer
|
||||
@inject IMessageDisplay Message
|
||||
@inject IViteManifest ViteManifest
|
||||
@inject IViteDevServerStatus ViteServer
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<PageTitle>@(Localizer["EditorTitle"] + TitlePostfix)</PageTitle>
|
||||
|
||||
@if (User is null) {
|
||||
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["EditorTitle"]</h1>
|
||||
|
||||
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["EditorTitle"]</h1>
|
||||
<div id="editor">
|
||||
<div class="flex place-content-center">
|
||||
<p>Loading Interactive Editor </p>
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
} else {
|
||||
<ErrorBoundary>
|
||||
<ChildContent>
|
||||
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["EditorTitle"]</h1>
|
||||
|
||||
<Wave.Components.Pages.Partials.ArticleEditorPartial Id="@Id" User="@User" ClaimsUser="@ClaimsUser" />
|
||||
</ChildContent>
|
||||
<ErrorContent>
|
||||
<h1 class="text-3xl lg:text-5xl font-light mb-6">Not found</h1>
|
||||
</ErrorContent>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[CascadingParameter(Name = "TitlePostfix")]
|
||||
private string TitlePostfix { get; set; } = default!;
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState> AuthenticationState { get; set; } = default!;
|
||||
|
||||
[Parameter]
|
||||
public Guid? Id { get; set; }
|
||||
private ApplicationUser? User { get; set; }
|
||||
private ClaimsPrincipal? ClaimsUser { get; set; }
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (firstRender) {
|
||||
if (User is not null) return;
|
||||
var state = await AuthenticationState;
|
||||
ClaimsUser = state.User;
|
||||
var user = await UserManager.GetUserAsync(state.User);
|
||||
User = user ?? throw new ApplicationException("???2");
|
||||
private Data.Transactional.ArticleView? Article { get; set; }
|
||||
private bool Saving { get; set; }
|
||||
|
||||
private IReadOnlyList<Category> Categories { get; } = [];
|
||||
|
||||
private bool ReactImported { get; set; }
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool first) {
|
||||
if (!first) return;
|
||||
|
||||
if (!ReactImported) {
|
||||
ReactImported = true;
|
||||
|
||||
string mainModule = ViteServer.IsEnabled
|
||||
? $"{ViteServer.ServerUrlWithBasePath}/main.tsx"
|
||||
: $"/dist/{ViteManifest["main.tsx"]!.File}";
|
||||
await JS.InvokeVoidAsync("import", mainModule);
|
||||
}
|
||||
|
||||
Article = new(Guid.NewGuid(), "", "", "", "", "", ArticleStatus.Draft, DateTimeOffset.MaxValue, []);
|
||||
}
|
||||
|
||||
private async Task OnValidSubmit() {
|
||||
try {
|
||||
Saving = true;
|
||||
|
||||
if (false is false) {
|
||||
Message.ShowError("Permission denied.");
|
||||
return;
|
||||
}
|
||||
|
||||
Message.ShowSuccess(Localizer["Save_Success"]);
|
||||
|
||||
if (Navigation.Uri.EndsWith("/article/new")) {
|
||||
Navigation.NavigateTo($"/article/{Id!.Value}/edit", false, true);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Message.ShowError(Localizer["Save_Error"]);
|
||||
Logger.LogError(ex, "Failed to save article.");
|
||||
} finally {
|
||||
Saving = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,314 +0,0 @@
|
|||
@using Wave.Data
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@using System.Net
|
||||
@using System.Security.Claims
|
||||
@using Humanizer
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using Wave.Services
|
||||
@using Wave.Utilities
|
||||
|
||||
@inject ILogger<ArticleEditor> Logger
|
||||
@inject NavigationManager Navigation
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IDbContextFactory<ApplicationDbContext> ContextFactory
|
||||
@inject IStringLocalizer<ArticleEditor> Localizer
|
||||
@inject IMessageDisplay Message
|
||||
@inject ImageService Images
|
||||
|
||||
<div class="w-full">
|
||||
<ul class="steps w-full max-w-xs">
|
||||
<li class="step @(Article.Status >= ArticleStatus.Draft ? "step-secondary" : "")">@Localizer["Draft"]</li>
|
||||
<li class="step @(Article.Status >= ArticleStatus.InReview ? "step-secondary" : "")">@Localizer["InReview"]</li>
|
||||
<li class="step @(Article.Status >= ArticleStatus.Published ? "step-secondary" : "")">@Localizer["Published"]</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<EditForm method="post" FormName="article-editor" Model="@Model" OnValidSubmit="OnValidSubmit">
|
||||
<DataAnnotationsValidator/>
|
||||
<input type="hidden" @bind-value="@Model.Id"/>
|
||||
|
||||
<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 oninput="charactersLeft_onInput(this)"
|
||||
@bind-Value="@Model.Title" placeholder="@Localizer["Title_Placeholder"]" autocomplete="off"/>
|
||||
</InputLabelComponent>
|
||||
|
||||
<InputLabelComponent class="row-span-3" LabelText="@Localizer["Categories_Label"]" For="() => Model.Categories">
|
||||
<InputSelect class="select select-bordered w-full" @bind-Value="@Model.Categories" multiple size="10">
|
||||
@foreach (var group in Categories.GroupBy(c => c.Color)) {
|
||||
<optgroup class="font-bold not-italic my-3" label="@group.Key.Humanize()">
|
||||
@foreach (var category in group) {
|
||||
<option value="@category.Id" selected="@Model.Categories?.Contains(category.Id)">@category.Name</option>
|
||||
}
|
||||
</optgroup>
|
||||
}
|
||||
</InputSelect>
|
||||
</InputLabelComponent>
|
||||
|
||||
<InputLabelComponent LabelText="@Localizer["Slug_Label"]" For="() => Model.Slug">
|
||||
@if (Article.Status is not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow) {
|
||||
<InputText class="input input-bordered w-full" maxlength="64" 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"
|
||||
placeholder="@Localizer["Slug_Placeholder"]" autocomplete="off" />
|
||||
}
|
||||
</InputLabelComponent>
|
||||
|
||||
<InputLabelComponent LabelText="@Localizer["PublishDate_Label"]" For="() => Model.PublishDate">
|
||||
@if (Article.Status is not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow) {
|
||||
<InputDate class="input input-bordered w-full" Type="InputDateType.DateTimeLocal"
|
||||
@bind-Value="@Model.PublishDate" placeholder="@Localizer["PublishDate_Placeholder"]" autocomplete="off"/>
|
||||
} else {
|
||||
<input class="input input-bordered w-full"
|
||||
type="datetime-local" readonly value="@Article.PublishDate.ToString("yyyy-MM-dd\\THH:mm:ss")"/>
|
||||
}
|
||||
</InputLabelComponent>
|
||||
</div>
|
||||
|
||||
<AdvancedMarkdownEditor Title="@Model.Title" MarkdownCallback="() => Model.Body">
|
||||
<textarea id="tool-target" class="textarea textarea-bordered outline-none w-full flex-1 join-item"
|
||||
required aria-required placeholder="@Localizer["Body_Placeholder"]"
|
||||
@bind="@Model.Body" @bind:event="oninput" autocomplete="off"></textarea>
|
||||
</AdvancedMarkdownEditor>
|
||||
|
||||
<div class="flex gap-2 flex-wrap mt-3">
|
||||
<button type="submit" class="btn btn-primary w-full sm:btn-wide @(Saving ? "btn-loading" : "")" disabled="@Saving">
|
||||
@Localizer["EditorSubmit"]
|
||||
</button>
|
||||
@if (Article.Id != Guid.Empty) {
|
||||
<a class="btn w-full sm:btn-wide" href="@ArticleUtilities.GenerateArticleLink(Article, null)">
|
||||
@Localizer["ViewArticle_Label"]
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
<ImageModal Id="@ImageModal" ImageAdded="ImageAdded" />
|
||||
<div class="my-3 flex flex-wrap gap-4 min-h-24">
|
||||
@foreach (var image in Article.Images) {
|
||||
<figure class="p-2 bg-base-200 relative">
|
||||
<img class="w-40" src="/images/@(image.Id)?size=400" width="400"
|
||||
title="@image.ImageDescription" alt="@image.ImageDescription"/>
|
||||
<figcaption>
|
||||
<button type="button" class="btn btn-info w-full mt-3"
|
||||
onclick="navigator.clipboard.writeText('![@image.ImageDescription](@(Navigation.ToAbsoluteUri("/images/" + image.Id))?size=400)')">
|
||||
@Localizer["Image_CopyLink"]
|
||||
</button>
|
||||
</figcaption>
|
||||
<button type="button" class="btn btn-square btn-sm btn-error absolute top-0 right-0"
|
||||
title="@Localizer["Image_Delete_Label"]"
|
||||
@onclick="async () => await ImageDelete(image)">
|
||||
<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="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</figure>
|
||||
}
|
||||
<button type="button" class="btn" onclick="@(ImageModal).showModal()">@Localizer["Image_Add_Label"]</button>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private const string ImageModal = "AddImage";
|
||||
|
||||
[Parameter]
|
||||
public Guid? Id { get; set; }
|
||||
[Parameter]
|
||||
public required ApplicationUser User { get; set; }
|
||||
[Parameter]
|
||||
public required ClaimsPrincipal ClaimsUser { get; set; }
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Model { get; set; } = new();
|
||||
|
||||
private Article Article { get; set; } = default!;
|
||||
private List<Category> Categories { get; set; } = [];
|
||||
private bool Saving { get; set; }
|
||||
|
||||
protected override void OnInitialized() {
|
||||
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
|
||||
Article ??= new Article {Author = User, Title = "", Body = ""};
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
await using var context = await ContextFactory.CreateDbContextAsync();
|
||||
if (Categories.Count < 1)
|
||||
Categories = await context.Set<Category>().IgnoreQueryFilters().OrderBy(c => c.Color).ToListAsync();
|
||||
|
||||
Article? article = null;
|
||||
if (Id is not null) {
|
||||
// Ensure we are not double-tracking User on existing articles, since
|
||||
// a different context loaded this user originally
|
||||
context.Entry(User).State = EntityState.Unchanged;
|
||||
|
||||
article = await context.Set<Article>()
|
||||
.IgnoreQueryFilters().Where(a => !a.IsDeleted)
|
||||
.Include(a => a.Author)
|
||||
.Include(a => a.Reviewer)
|
||||
.Include(a => a.Categories)
|
||||
.Include(a => a.Images)
|
||||
.FirstAsync(a => a.Id == Id);
|
||||
if (article is null) throw new ApplicationException("Article not found.");
|
||||
}
|
||||
|
||||
if (article is not null) {
|
||||
if (!article.AllowedToEdit(ClaimsUser))
|
||||
throw new ApplicationException("You are not allowed to edit this article");
|
||||
|
||||
Model.Id ??= article.Id;
|
||||
Model.Title ??= article.Title;
|
||||
Model.Slug ??= article.Slug;
|
||||
Model.Body ??= article.Body;
|
||||
Model.PublishDate ??= article.PublishDate.LocalDateTime;
|
||||
if (Model.Categories?.Length < 1) {
|
||||
Model.Categories = article.Categories.Select(c => c.Id).ToArray();
|
||||
}
|
||||
Article = article;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnValidSubmit() {
|
||||
if (Article.AllowedToEdit(ClaimsUser) is false) {
|
||||
Message.ShowError("Permission denied.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Saving = true;
|
||||
|
||||
// Double check user permissions
|
||||
await HandleRoles(Article, User);
|
||||
|
||||
if (Model.Title is not null) Article.Title = Model.Title;
|
||||
if (Model.Body is not null) Article.Body = Model.Body;
|
||||
if (Model.PublishDate is not null &&
|
||||
(Article.Status is not ArticleStatus.Published || Article.PublishDate >= DateTimeOffset.UtcNow))
|
||||
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 {
|
||||
Article.UpdateSlug(Model.Slug);
|
||||
Model.Slug = Article.Slug;
|
||||
}
|
||||
|
||||
Article.LastModified = DateTimeOffset.UtcNow;
|
||||
|
||||
await using var context = await ContextFactory.CreateDbContextAsync();
|
||||
|
||||
// Update Newsletter distribution if exists
|
||||
var newsletter = await context.Set<EmailNewsletter>()
|
||||
.IgnoreQueryFilters().IgnoreAutoIncludes()
|
||||
.FirstOrDefaultAsync(n => n.Article == Article);
|
||||
if (newsletter is not null) {
|
||||
newsletter.DistributionDateTime = Article.PublishDate;
|
||||
}
|
||||
|
||||
// Avoid unnecessary updates
|
||||
context.Entry(User).State = EntityState.Unchanged;
|
||||
Categories.ForEach(c => context.Entry(c).State = EntityState.Unchanged);
|
||||
await context.Set<ArticleCategory>().IgnoreQueryFilters().IgnoreAutoIncludes()
|
||||
.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)
|
||||
.AsNoTrackingWithIdentityResolution()
|
||||
.SelectMany(a => a.Images).ToListAsync();
|
||||
foreach (var image in Article.Images) {
|
||||
int index = existingImages.FindIndex(a => a.Id == image.Id);
|
||||
context.Entry(image).State = index > -1 ? EntityState.Modified : EntityState.Added;
|
||||
if(index > -1) existingImages.RemoveAt(index);
|
||||
}
|
||||
foreach (var image in existingImages) {
|
||||
context.Entry(image).State = EntityState.Deleted;
|
||||
}
|
||||
|
||||
var relations = await context.Set<ArticleCategory>()
|
||||
.IgnoreQueryFilters().IgnoreAutoIncludes()
|
||||
.Where(ac => ac.Article == Article && !Model.Categories.Contains(ac.Category.Id))
|
||||
.ToListAsync();
|
||||
context.RemoveRange(relations);
|
||||
|
||||
foreach (var category in Model.Categories) {
|
||||
if (Article.Categories.Any(c => c.Id == category) is not true) {
|
||||
context.Add(new ArticleCategory {
|
||||
Article = Article,
|
||||
Category = Categories.First(c => c.Id == category)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
foreach (var image in existingImages) {
|
||||
try {
|
||||
Images.Delete(image.Id);
|
||||
} catch (Exception ex) {
|
||||
Logger.LogWarning(ex, "Failed to delete image: {image}", image.Id);
|
||||
}
|
||||
}
|
||||
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.");
|
||||
} finally {
|
||||
Saving = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "ConvertIfStatementToSwitchStatement")]
|
||||
private async Task HandleRoles(Article article, ApplicationUser me) {
|
||||
// it's our draft
|
||||
if (article.Status is ArticleStatus.Draft && article.Author.Id == me.Id) return;
|
||||
|
||||
var roles = await UserManager.GetRolesAsync(me);
|
||||
|
||||
// reviewers and admins can review articles
|
||||
if (article.Status is ArticleStatus.InReview && roles.Any(r => r is "Admin" or "Reviewer")) {
|
||||
article.Reviewer = me;
|
||||
return;
|
||||
}
|
||||
|
||||
// published articles may only be edited my admins or moderators
|
||||
if (article.Status is ArticleStatus.Published && roles.Any(r => r is "Admin" or "Moderator")) {
|
||||
article.Reviewer = me; // TODO replace with editor or something?
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ApplicationException("You do not have permissions to edit this article");
|
||||
}
|
||||
|
||||
private async Task ImageAdded(ArticleImage image) {
|
||||
Article.Images.Add(image);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task ImageDelete(ArticleImage image) {
|
||||
Article.Images.Remove(image);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private sealed class InputModel {
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
[Required(AllowEmptyStrings = false), MaxLength(256)]
|
||||
public string? Title { get; set; }
|
||||
[MaxLength(64)]
|
||||
public string? Slug { get; set; }
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string? Body { get; set; }
|
||||
|
||||
public Guid[]? Categories { get; set; } = [];
|
||||
public DateTimeOffset? PublishDate { get; set; }
|
||||
}
|
||||
|
||||
}
|
107
Wave/Controllers/ArticleController.cs
Normal file
|
@ -0,0 +1,107 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Wave.Data;
|
||||
using Wave.Data.Transactional;
|
||||
|
||||
namespace Wave.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/[controller]")]
|
||||
public class ArticleController(ILogger<ArticleController> logger, ApplicationRepository repository) : ControllerBase {
|
||||
[HttpGet("/api/categories"), AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
public async Task<Results<
|
||||
Ok<IEnumerable<Category>>,
|
||||
NoContent,
|
||||
UnauthorizedHttpResult,
|
||||
ProblemHttpResult>> GetCategories(bool all = false, CancellationToken cancellation = default) {
|
||||
try {
|
||||
var categories = await repository.GetCategories(User, all, cancellation);
|
||||
|
||||
if (categories.Count < 1) return TypedResults.NoContent();
|
||||
return TypedResults.Ok<IEnumerable<Category>>(categories);
|
||||
} catch (ApplicationException) {
|
||||
logger.LogTrace("Unauthenticated user tried to access all categories. Denied.");
|
||||
return TypedResults.Unauthorized();
|
||||
} catch (Exception ex) {
|
||||
logger.LogError(ex, "Unexpected error trying to get Categories for user {UserId}.", User.FindFirstValue("Id") ?? "unknown or anonymous");
|
||||
return TypedResults.Problem();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}"), AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
public async Task<Results<
|
||||
Ok<ArticleView>,
|
||||
NotFound,
|
||||
UnauthorizedHttpResult,
|
||||
ProblemHttpResult>> GetArticle(Guid id, CancellationToken cancellation = default) {
|
||||
try {
|
||||
return TypedResults.Ok(new ArticleView(await repository.GetArticleAsync(id, User, cancellation)));
|
||||
} catch (ArticleNotFoundException) {
|
||||
logger.LogWarning("Failed to look up Article with Id {ArticleId}. Not Found", id);
|
||||
return TypedResults.NotFound();
|
||||
} catch (ArticleMissingPermissionsException) {
|
||||
logger.LogWarning(
|
||||
"Failed to look up Article with Id {ArticleId}. User {UserId} Access Denied.",
|
||||
id, User.FindFirstValue("Id") ?? "unknown or anonymous");
|
||||
return TypedResults.Unauthorized();
|
||||
} catch (Exception ex) {
|
||||
logger.LogError(ex, "Unexpected Error.");
|
||||
return TypedResults.Problem();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut(Name = nameof(CreateArticle)), Authorize]
|
||||
[Produces("application/json")]
|
||||
public async Task<Results<
|
||||
CreatedAtRoute<ArticleView>,
|
||||
BadRequest<string>,
|
||||
UnauthorizedHttpResult,
|
||||
ProblemHttpResult>> CreateArticle(ArticleCreateDto input, CancellationToken cancellation = default) {
|
||||
try {
|
||||
var article = new ArticleView(await repository.CreateArticleAsync(input, User, cancellation));
|
||||
return TypedResults.CreatedAtRoute(article, nameof(CreateArticle), article.Id);
|
||||
} catch (ArticleMissingPermissionsException) {
|
||||
logger.LogWarning(
|
||||
"Unauthorized User with ID {UserId} tried to create an Article.",
|
||||
User.FindFirstValue("Id") ?? "unknown or anonymous");
|
||||
return TypedResults.Unauthorized();
|
||||
} catch (ArticleMalformedException ex) {
|
||||
logger.LogWarning("User with ID {UserId} tried to create an article but submitted bad data.",
|
||||
User.FindFirstValue("Id") ?? "unknown or anonymous");
|
||||
return TypedResults.BadRequest($"Submitted data is not valid: {string.Join(",", ex.Errors.Select(e => e.ErrorMessage))}.");
|
||||
} catch (Exception ex) {
|
||||
logger.LogError(ex, "Unexpected Error.");
|
||||
return TypedResults.Problem();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost, Authorize]
|
||||
[Produces("application/json")]
|
||||
public async Task<Results<
|
||||
Ok<ArticleView>,
|
||||
NotFound,
|
||||
BadRequest<string>,
|
||||
UnauthorizedHttpResult,
|
||||
ProblemHttpResult>> UpdateArticle(ArticleUpdateDto input, CancellationToken cancellation = default)
|
||||
{
|
||||
try {
|
||||
return TypedResults.Ok(new ArticleView(await repository.UpdateArticleAsync(input, User, cancellation)));
|
||||
} catch (ArticleNotFoundException) {
|
||||
return TypedResults.NotFound();
|
||||
} catch (ArticleMalformedException ex) {
|
||||
logger.LogWarning("User with ID {UserId} tried to update article with ID {ArticleId} but submitted bad data.",
|
||||
User.FindFirstValue("Id") ?? "unknown or anonymous", input.Id);
|
||||
return TypedResults.BadRequest($"Submitted data is not valid: {string.Join(",", ex.Errors.Select(e => e.ErrorMessage))}.");
|
||||
} catch (ArticleMissingPermissionsException) {
|
||||
return TypedResults.Unauthorized();
|
||||
} catch (ArticleException ex) {
|
||||
logger.LogError(ex, "Unexpected Article Error.");
|
||||
return TypedResults.Problem();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -23,12 +23,12 @@ public class UserController(ImageService imageService, IDbContextFactory<Applica
|
|||
var user = await context.Users.Include(u => u.ProfilePicture).FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (user is null) return NotFound();
|
||||
if (user.ProfilePicture is null) {
|
||||
return Redirect("/img/default_avatar.jpg");
|
||||
return Redirect("/dist/img/default_avatar.jpg");
|
||||
}
|
||||
|
||||
string? path = ImageService.GetPath(user.ProfilePicture.ImageId);
|
||||
if (path is null) {
|
||||
return Redirect("/img/default_avatar.jpg");
|
||||
return Redirect("/dist/img/default_avatar.jpg");
|
||||
}
|
||||
|
||||
if (size < 800) return File(await ImageService.GetResized(path, size), ImageService.ImageMimeType);
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
using System.Text;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Wave.Data.Transactional;
|
||||
|
||||
namespace Wave.Data;
|
||||
|
||||
|
@ -131,4 +133,74 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
|
|||
key.Ignore(k => k.Claims);
|
||||
});
|
||||
}
|
||||
|
||||
internal async ValueTask UpdateArticle(ArticleDto dto, Article article,
|
||||
CancellationToken cancellation) {
|
||||
if (dto is ArticleCreateDto cDto) {
|
||||
article.Title = cDto.Title;
|
||||
article.Body = cDto.Body;
|
||||
} else if (dto is ArticleUpdateDto uDto) {
|
||||
if (!string.IsNullOrWhiteSpace(uDto.Title)) article.Title = uDto.Title;
|
||||
if (!string.IsNullOrWhiteSpace(uDto.Body)) article.Body = uDto.Body;
|
||||
}
|
||||
article.LastModified = DateTimeOffset.UtcNow;
|
||||
article.UpdateBody();
|
||||
|
||||
// We can't use CanBePublic here since when we create a new article, that isn't initialized yet
|
||||
if (article.Status != ArticleStatus.Published || article.PublishDate > DateTimeOffset.UtcNow) {
|
||||
// Update publish date, if it exists and article isn't public yet
|
||||
if (dto.PublishDate is {} date) article.PublishDate = date;
|
||||
// Can only change slugs when the article is not public
|
||||
article.UpdateSlug(dto.Slug);
|
||||
}
|
||||
|
||||
await UpdateCategories(dto, article, cancellation);
|
||||
await UpdateImages(dto, article, cancellation);
|
||||
await UpdateNewsletter(article, cancellation);
|
||||
}
|
||||
|
||||
private async ValueTask UpdateCategories(ArticleDto dto, Article article, CancellationToken cancellation) {
|
||||
if (dto.Categories is null) return;
|
||||
|
||||
// Retrieve all existing links between this article and categories
|
||||
var relationships = await Set<ArticleCategory>()
|
||||
.IgnoreQueryFilters()
|
||||
.IgnoreAutoIncludes()
|
||||
.Include(ac => ac.Category)
|
||||
.Where(ac => ac.Article == article)
|
||||
.ToListAsync(cancellation);
|
||||
|
||||
// check which Category is not in the DTO and needs its relationship removed
|
||||
foreach (var ac in relationships.Where(ac => !dto.Categories.Contains(ac.Category.Id))) {
|
||||
article.Categories.Remove(ac.Category);
|
||||
Remove(ac);
|
||||
}
|
||||
|
||||
// check which Category in the DTO is absent from the article's relationships, and add them
|
||||
var added = dto.Categories.Where(cId => relationships.All(ac => ac.Category.Id != cId)).ToList();
|
||||
if (added.Count > 0) {
|
||||
var categories = await Set<Category>()
|
||||
.IgnoreAutoIncludes().IgnoreQueryFilters()
|
||||
.Where(c => added.Contains(c.Id))
|
||||
.ToListAsync(cancellation);
|
||||
|
||||
await AddRangeAsync(categories.Select(c => new ArticleCategory {
|
||||
Article = article, Category = c
|
||||
}).ToList(), cancellation);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask UpdateImages(ArticleDto dto, Article article, CancellationToken cancellation) {
|
||||
if (dto.Images is null) return;
|
||||
|
||||
// TODO:: implement
|
||||
}
|
||||
|
||||
private async ValueTask UpdateNewsletter(Article article, CancellationToken cancellation) {
|
||||
// Update Newsletter distribution if it exists
|
||||
var newsletter = await Set<EmailNewsletter>()
|
||||
.IgnoreQueryFilters().IgnoreAutoIncludes()
|
||||
.FirstOrDefaultAsync(n => n.Article == article, cancellation);
|
||||
if (newsletter is not null) newsletter.DistributionDateTime = article.PublishDate;
|
||||
}
|
||||
}
|
116
Wave/Data/ApplicationRepository.cs
Normal file
|
@ -0,0 +1,116 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Wave.Data.Transactional;
|
||||
using Wave.Utilities;
|
||||
|
||||
namespace Wave.Data;
|
||||
|
||||
public class ArticleException : ApplicationException;
|
||||
public class ArticleNotFoundException : ArticleException;
|
||||
public class ArticleMissingPermissionsException : ArticleException;
|
||||
public class ArticleMalformedException : ArticleException {
|
||||
public IReadOnlyCollection<ValidationResult> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adapter for ApplicationDbContext, that enforced valid data and the permission system
|
||||
/// </summary>
|
||||
/// <param name="contextFactory"></param>
|
||||
public class ApplicationRepository(IDbContextFactory<ApplicationDbContext> contextFactory) {
|
||||
private IDbContextFactory<ApplicationDbContext> ContextFactory { get; } = contextFactory;
|
||||
|
||||
public async ValueTask<IReadOnlyCollection<Category>> GetCategories(ClaimsPrincipal user, bool includeUnusedCategories = false, CancellationToken cancellation = default) {
|
||||
if (includeUnusedCategories && user.Identity?.IsAuthenticated is not true)
|
||||
throw new ApplicationException("Trying to access unused articles with unauthenticated user");
|
||||
|
||||
await using var context = await ContextFactory.CreateDbContextAsync(cancellation);
|
||||
var categories = await context.Set<Category>()
|
||||
.IgnoreAutoIncludes()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => includeUnusedCategories || c.Articles.Any())
|
||||
.OrderBy(c => c.Color)
|
||||
.ToListAsync(cancellation);
|
||||
|
||||
return new ReadOnlyCollection<Category>(categories);
|
||||
}
|
||||
|
||||
public async ValueTask<Article> GetArticleAsync(Guid id, ClaimsPrincipal user, CancellationToken cancellation = default) {
|
||||
await using var context = await ContextFactory.CreateDbContextAsync(cancellation);
|
||||
var article = await context.Set<Article>()
|
||||
.IgnoreQueryFilters()
|
||||
.IgnoreAutoIncludes()
|
||||
.Include(a => a.Author)
|
||||
.Include(a => a.Reviewer)
|
||||
.Include(a => a.Categories)
|
||||
.FirstOrDefaultAsync(a => a.Id == id, cancellation);
|
||||
|
||||
if (article is null)
|
||||
throw new ArticleNotFoundException();
|
||||
if (article.AllowedToRead(user))
|
||||
return article;
|
||||
|
||||
throw new ArticleMissingPermissionsException();
|
||||
}
|
||||
|
||||
public async ValueTask<Article> CreateArticleAsync(ArticleCreateDto dto, ClaimsPrincipal user,
|
||||
CancellationToken cancellation = default) {
|
||||
if (!Permissions.AllowedToCreate(user))
|
||||
throw new ArticleMissingPermissionsException();
|
||||
|
||||
List<ValidationResult> results = [];
|
||||
if (!Validator.TryValidateObject(dto, new ValidationContext(dto), results, true)) {
|
||||
throw new ArticleMalformedException() {
|
||||
Errors = results
|
||||
};
|
||||
}
|
||||
|
||||
await using var context = await ContextFactory.CreateDbContextAsync(cancellation);
|
||||
var appUser = await context.Users.FindAsync([user.FindFirstValue("Id")], cancellation);
|
||||
|
||||
var article = new Article {
|
||||
Author = appUser ?? throw new ArticleException(),
|
||||
Title = "",
|
||||
Body = ""
|
||||
};
|
||||
await context.UpdateArticle(dto, article, cancellation);
|
||||
await context.Set<Article>().AddAsync(article, cancellation);
|
||||
await context.SaveChangesAsync(cancellation);
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
public async ValueTask<Article> UpdateArticleAsync(ArticleUpdateDto dto, ClaimsPrincipal user,
|
||||
CancellationToken cancellation = default) {
|
||||
List<ValidationResult> results = [];
|
||||
if (!Validator.TryValidateObject(dto, new ValidationContext(dto), results, true)) {
|
||||
throw new ArticleMalformedException() {
|
||||
Errors = results
|
||||
};
|
||||
}
|
||||
|
||||
await using var context = await ContextFactory.CreateDbContextAsync(cancellation);
|
||||
var article = await context.Set<Article>()
|
||||
.IgnoreQueryFilters()
|
||||
.Include(a => a.Author)
|
||||
.FirstOrDefaultAsync(a => a.Id == dto.Id, cancellation);
|
||||
if (article is null)
|
||||
throw new ArticleNotFoundException();
|
||||
if (!article.AllowedToEdit(user))
|
||||
throw new ArticleMissingPermissionsException();
|
||||
var appUser = await context.Users.FindAsync([user.FindFirstValue("Id")], cancellation);
|
||||
if (appUser is null)
|
||||
throw new ArticleException();
|
||||
|
||||
if (appUser.Id != article.Author.Id) {
|
||||
article.Reviewer = appUser;
|
||||
}
|
||||
|
||||
await context.UpdateArticle(dto, article, cancellation);
|
||||
await context.SaveChangesAsync(cancellation);
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Wave.Utilities;
|
||||
|
||||
|
@ -60,15 +61,32 @@ public partial class Article : ISoftDelete {
|
|||
|
||||
public void UpdateSlug(string? potentialNewSlug = null) {
|
||||
if (!string.IsNullOrWhiteSpace(potentialNewSlug) && Uri.IsWellFormedUriString(potentialNewSlug, UriKind.Relative)) {
|
||||
if (potentialNewSlug.Length > 64) potentialNewSlug = potentialNewSlug[..64];
|
||||
Slug = potentialNewSlug;
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(potentialNewSlug) && !string.IsNullOrWhiteSpace(Slug)) return;
|
||||
// if (string.IsNullOrWhiteSpace(potentialNewSlug) && !string.IsNullOrWhiteSpace(Slug)) return;
|
||||
|
||||
string baseSlug = string.IsNullOrWhiteSpace(potentialNewSlug) ? Title.ToLower() : potentialNewSlug;
|
||||
|
||||
{
|
||||
baseSlug = Regex.Replace(Uri.EscapeDataString(Encoding.ASCII.GetString(
|
||||
Encoding.Convert(
|
||||
Encoding.UTF8,
|
||||
Encoding.GetEncoding(
|
||||
Encoding.ASCII.EncodingName,
|
||||
new EncoderReplacementFallback(string.Empty),
|
||||
new DecoderExceptionFallback()),
|
||||
Encoding.UTF8.GetBytes(baseSlug))
|
||||
).Replace("-", "+").Replace(" ", "-")), @"(%[\dA-F]{2})", string.Empty);
|
||||
if (baseSlug.Length > 64) baseSlug = baseSlug[..64];
|
||||
Slug = baseSlug;
|
||||
return;
|
||||
}
|
||||
|
||||
string baseSlug = potentialNewSlug ?? Title;
|
||||
baseSlug = baseSlug.ToLowerInvariant()[..Math.Min(64, baseSlug.Length)];
|
||||
string slug = Uri.EscapeDataString(baseSlug).Replace("-", "+").Replace("%20", "-");
|
||||
string slug = Uri.EscapeDataString(baseSlug);
|
||||
|
||||
// I hate my life
|
||||
int escapeTrimOvershoot = 0;
|
||||
|
|
81
Wave/Data/Transactional/ArticleDto.cs
Normal file
|
@ -0,0 +1,81 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Wave.Data.Transactional;
|
||||
|
||||
public abstract class ArticleDto(
|
||||
string? slug,
|
||||
DateTimeOffset? publishDate,
|
||||
Guid[]? categories,
|
||||
Guid[]? images)
|
||||
{
|
||||
[MaxLength(64)]
|
||||
public string? Slug { get; init; } = slug;
|
||||
|
||||
public DateTimeOffset? PublishDate { get; init; } = publishDate;
|
||||
public Guid[]? Categories { get; init; } = categories;
|
||||
public Guid[]? Images { get; init; } = images;
|
||||
|
||||
public void Deconstruct(
|
||||
out string? slug, out DateTimeOffset? publishDate, out Guid[]? categories, out Guid[]? images) {
|
||||
slug = Slug;
|
||||
publishDate = PublishDate;
|
||||
categories = Categories;
|
||||
images = Images;
|
||||
}
|
||||
}
|
||||
|
||||
public class ArticleCreateDto(
|
||||
string title,
|
||||
[Required(AllowEmptyStrings = false)] string body,
|
||||
string? slug,
|
||||
DateTimeOffset? publishDate,
|
||||
Guid[]? categories,
|
||||
Guid[]? images) : ArticleDto(slug, publishDate, categories, images)
|
||||
{
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
[MaxLength(256)]
|
||||
public string Title { get; init; } = title;
|
||||
|
||||
public string Body { get; init; } = body;
|
||||
|
||||
public void Deconstruct(
|
||||
out string title, out string body, out string? slug, out DateTimeOffset? publishDate, out Guid[]? categories,
|
||||
out Guid[]? images) {
|
||||
title = Title;
|
||||
body = Body;
|
||||
slug = Slug;
|
||||
publishDate = PublishDate;
|
||||
categories = Categories;
|
||||
images = Images;
|
||||
}
|
||||
}
|
||||
|
||||
public class ArticleUpdateDto(
|
||||
Guid id,
|
||||
string? title = null,
|
||||
string? body = null,
|
||||
string? slug = null,
|
||||
DateTimeOffset? publishDate = null,
|
||||
Guid[]? categories = null,
|
||||
Guid[]? images = null) : ArticleDto(slug, publishDate, categories, images)
|
||||
{
|
||||
[Required]
|
||||
public Guid Id { get; init; } = id;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string? Title { get; init; } = title;
|
||||
|
||||
public string? Body { get; init; } = body;
|
||||
|
||||
public void Deconstruct(
|
||||
out Guid id, out string? title, out string? body, out string? slug, out DateTimeOffset? publishDate,
|
||||
out Guid[]? categories, out Guid[]? images) {
|
||||
id = Id;
|
||||
title = Title;
|
||||
body = Body;
|
||||
slug = Slug;
|
||||
publishDate = PublishDate;
|
||||
categories = Categories;
|
||||
images = Images;
|
||||
}
|
||||
}
|
30
Wave/Data/Transactional/ArticleView.cs
Normal file
|
@ -0,0 +1,30 @@
|
|||
namespace Wave.Data.Transactional;
|
||||
|
||||
public sealed record CategoryView(Guid id, string Name, CategoryColors Color) {
|
||||
public CategoryView(Category category) : this(
|
||||
category.Id, category.Name, category.Color) {}
|
||||
|
||||
}
|
||||
|
||||
public sealed record ArticleView(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string Slug,
|
||||
string BodyHtml,
|
||||
string Body,
|
||||
string BodyPlain,
|
||||
ArticleStatus Status,
|
||||
DateTimeOffset PublishDate,
|
||||
IReadOnlyList<CategoryView> Categories)
|
||||
{
|
||||
public ArticleView(Article article) : this(
|
||||
article.Id,
|
||||
article.Title,
|
||||
article.Slug,
|
||||
article.BodyHtml,
|
||||
article.Body,
|
||||
article.BodyPlain,
|
||||
article.Status,
|
||||
article.PublishDate,
|
||||
article.Categories.Select(c => new CategoryView(c)).ToList()) {}
|
||||
}
|
|
@ -1,10 +1,14 @@
|
|||
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
|
||||
|
||||
ARG BASE=8.0
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:$BASE AS base
|
||||
ARG BASE=
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0$BASE AS base
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
RUN mkdir -p /app/files && chown app /app/files
|
||||
RUN mkdir -p /configuration && chown app /configuration
|
||||
RUN apt-get update && apt-get install -y curl
|
||||
RUN if command -v apt-get; then \
|
||||
apt-get update && apt-get install -y curl; \
|
||||
else \
|
||||
apk add --update curl icu-libs icu-data-full tzdata; \
|
||||
fi
|
||||
USER app
|
||||
VOLUME /app/files
|
||||
VOLUME /configuration
|
||||
|
@ -13,7 +17,25 @@ EXPOSE 8080
|
|||
HEALTHCHECK --start-period=5s --start-interval=15s --interval=30s --timeout=30s --retries=3 \
|
||||
CMD curl --fail http://localhost:8080/health || exit 1
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
FROM node:20-alpine AS vite-build
|
||||
WORKDIR /src
|
||||
RUN mkdir -p "wwwroot"
|
||||
COPY ["Wave/package.json", "Wave/package-lock.json", "./"]
|
||||
RUN npm install
|
||||
COPY [ \
|
||||
"Wave/tsconfig.json", \
|
||||
"Wave/tsconfig.node.json", \
|
||||
"Wave/tailwind.config.ts", \
|
||||
"Wave/vite.config.ts", \
|
||||
"./"]
|
||||
COPY ["Wave/Assets/", "./Assets/"]
|
||||
# need to copy website files, otherwise tailwind doesn't compile
|
||||
# the required classes
|
||||
COPY ["Wave/Components/", "./Components/"]
|
||||
RUN npx vite build
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0$BASE AS build
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
ARG VERSION=0.0.1
|
||||
WORKDIR /src
|
||||
|
@ -27,6 +49,7 @@ RUN dotnet build "./Wave.csproj" \
|
|||
-p:Version="${VERSION}"
|
||||
|
||||
FROM build AS publish
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
ARG VERSION=0.0.1
|
||||
ARG VERSION_SUFFIX=
|
||||
|
@ -38,6 +61,7 @@ RUN dotnet publish "./Wave.csproj" \
|
|||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=vite-build /src/wwwroot ./wwwroot
|
||||
COPY --from=publish /app/publish .
|
||||
COPY LICENSE .
|
||||
ENTRYPOINT ["dotnet", "Wave.dll"]
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Sinks.Grafana.Loki;
|
||||
using Vite.AspNetCore;
|
||||
using Wave.Utilities.Metrics;
|
||||
|
||||
#region Version Information
|
||||
|
@ -81,6 +82,13 @@
|
|||
options.OutputFormatters.Add(new SyndicationFeedFormatter());
|
||||
});
|
||||
builder.Services.AddOutputCache();
|
||||
builder.Services.AddViteServices(options => {
|
||||
// options.Server.AutoRun = true;
|
||||
options.Server.ScriptName = "dev";
|
||||
options.Server.Https = false;
|
||||
options.Server.UseReactRefresh = true;
|
||||
options.Base = "/dist/";
|
||||
});
|
||||
|
||||
#region Data Protection & Redis
|
||||
|
||||
|
@ -198,6 +206,7 @@
|
|||
.AddSignInManager()
|
||||
.AddDefaultTokenProviders()
|
||||
.AddClaimsPrincipalFactory<UserClaimsFactory>();
|
||||
builder.Services.AddScoped<ApplicationRepository>();
|
||||
|
||||
#endregion
|
||||
|
||||
|
@ -334,18 +343,22 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||
|
||||
// Add additional endpoints required by the Identity /Account Razor components.
|
||||
app.MapAdditionalIdentityEndpoints();
|
||||
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||
app.MapAdditionalIdentityEndpoints();
|
||||
app.MapControllers();
|
||||
app.UseOutputCache();
|
||||
|
||||
if (app.Environment.IsDevelopment()) {
|
||||
//app.UseWebSockets();
|
||||
app.UseViteDevelopmentServer(true);
|
||||
}
|
||||
|
||||
app.UseOutputCache();
|
||||
app.UseRequestLocalization();
|
||||
|
||||
{
|
||||
|
|
|
@ -15,7 +15,9 @@ public static class Permissions {
|
|||
if (article.Status >= ArticleStatus.Published && article.PublishDate <= DateTimeOffset.UtcNow) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (principal.Identity?.IsAuthenticated is false) return false;
|
||||
|
||||
// Admins always get access
|
||||
if (principal.IsInRole("Admin")) {
|
||||
return true;
|
||||
|
@ -33,9 +35,26 @@ public static class Permissions {
|
|||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool AllowedToCreate(ClaimsPrincipal principal) {
|
||||
if (principal.Identity?.IsAuthenticated is false) return false;
|
||||
|
||||
// Admins always get access
|
||||
if (principal.IsInRole("Admin")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Authors can author articles (duh)
|
||||
if (principal.IsInRole("Author")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool AllowedToEdit(this Article? article, ClaimsPrincipal principal) {
|
||||
if (article is null || article.IsDeleted) return false;
|
||||
if (principal.Identity?.IsAuthenticated is false) return false; // anon users can't edit ever
|
||||
if (article.Author is null) throw new ArgumentException("Checking permissions without loading related Author.");
|
||||
|
||||
// Admins always can edit articles
|
||||
|
@ -70,12 +89,14 @@ public static class Permissions {
|
|||
}
|
||||
|
||||
public static bool AllowedToRejectReview(this Article? article, ClaimsPrincipal principal) {
|
||||
if (principal.Identity?.IsAuthenticated is false) return false; // anon users can't review ever
|
||||
// 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 (principal.Identity?.IsAuthenticated is false) return false; // anon users can't edit ever
|
||||
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)
|
||||
|
@ -88,6 +109,7 @@ public static class Permissions {
|
|||
|
||||
public static bool AllowedToPublish(this Article? article, ClaimsPrincipal principal) {
|
||||
if (article is null || article.IsDeleted) return false;
|
||||
if (principal.Identity?.IsAuthenticated is false) return false; // anon users can't publish ever
|
||||
if (article.Author is null) throw new ArgumentException("Checking permissions without loading related Author.");
|
||||
|
||||
// Admins can skip review and directly publish draft articles
|
||||
|
@ -111,6 +133,7 @@ public static class Permissions {
|
|||
|
||||
public static bool AllowedToDelete(this Article? article, ClaimsPrincipal principal) {
|
||||
if (article is null || article.IsDeleted) return false;
|
||||
if (principal.Identity?.IsAuthenticated is false) return false; // anon users can't delete ever
|
||||
if (article.Author is null) throw new ArgumentException("Checking permissions without loading related Author.");
|
||||
|
||||
// Admins can delete articles whenever
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
@ -9,25 +9,29 @@
|
|||
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="wwwroot\featured.js" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCore.Authentication.ApiKey" Version="8.0.1" />
|
||||
<PackageReference Include="CsvHelper" Version="31.0.4" />
|
||||
<PackageReference Include="CsvHelper" Version="32.0.3" />
|
||||
<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" />
|
||||
<PackageReference Include="Magick.NET-Q8-AnyCPU" Version="13.5.0" />
|
||||
<PackageReference Include="MailKit" Version="4.3.0" />
|
||||
<PackageReference Include="Markdig" Version="0.36.2" />
|
||||
<PackageReference Include="Markdown.ColorCode" Version="2.2.1" />
|
||||
<PackageReference Include="MailKit" Version="4.6.0" />
|
||||
<PackageReference Include="Markdig" Version="0.37.0" />
|
||||
<PackageReference Include="Markdown.ColorCode" Version="2.2.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.6" />
|
||||
<PackageReference Include="Mjml.Net" Version="3.8.0" />
|
||||
|
@ -42,10 +46,7 @@
|
|||
<PackageReference Include="Serilog.Sinks.Grafana.Loki" Version="8.3.0" />
|
||||
<PackageReference Include="System.ServiceModel.Syndication" Version="8.0.0" />
|
||||
<PackageReference Include="Tomlyn.Extensions.Configuration" Version="1.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="wwwroot\img\" />
|
||||
<PackageReference Include="Vite.AspNetCore" Version="2.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
{
|
||||
"DetailedErrors": true
|
||||
}
|
BIN
Wave/package-lock.json
generated
|
@ -1,18 +1,38 @@
|
|||
{
|
||||
"name": "wave",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"css:build": "postcss -o wwwroot/css/main.min.css wwwroot/css/main.css"
|
||||
},
|
||||
"author": "Mia Rose Winter",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"cssnano": "^6.0.3",
|
||||
"daisyui": "^4.6.0",
|
||||
"postcss": "^8.4.33",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"tailwindcss": "^3.4.1"
|
||||
}
|
||||
"name": "wave",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"check": "tsc",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"author": "Mia Rose Winter",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@fontsource/noto-sans-display": "^5.0.20",
|
||||
"@fontsource/nunito-sans": "^5.0.13",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/node": "^20.14.2",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"cssnano": "^6.0.3",
|
||||
"daisyui": "^4.6.0",
|
||||
"i18next": "^23.11.5",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-mark": "^4.0.0",
|
||||
"postcss": "^8.4.33",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^14.1.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"groupby-polyfill": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
cssnano: {
|
||||
preset: "default"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,9 +1,8 @@
|
|||
const defaultTheme = require('tailwindcss/defaultTheme');
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
const defaultTheme = require('tailwindcss/defaultTheme')
|
||||
|
||||
module.exports = {
|
||||
content: ["Pages/**/*.cshtml", "Components/**/*.razor"],
|
||||
export default {
|
||||
content: ["Assets/**/*.{ts,tsx}", "Components/**/*.razor"],
|
||||
safelist: ["youtube"],
|
||||
theme: {
|
||||
extend: {
|
25
Wave/tsconfig.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [ "ES2020", "ES2020.Promise", "DOM", "DOM.Iterable" ],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"typeRoots": [ "node_modules/@types" ],
|
||||
|
||||
"strict": true
|
||||
},
|
||||
"include": [
|
||||
"Assets/**/*.ts", "Assets/**/*.tsx"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
12
Wave/tsconfig.node.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
52
Wave/vite.config.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { defineConfig } from "vite";
|
||||
import tailwindcss from "tailwindcss";
|
||||
import autoprefixer from "autoprefixer";
|
||||
import cssnano from "cssnano";
|
||||
import react from "@vitejs/plugin-react-swc"
|
||||
|
||||
export default defineConfig({
|
||||
appType: "custom",
|
||||
base: "/dist/",
|
||||
root: "Assets",
|
||||
publicDir: "public",
|
||||
build: {
|
||||
emptyOutDir: true,
|
||||
manifest: true,
|
||||
outDir: "../wwwroot/dist",
|
||||
assetsDir: "",
|
||||
rollupOptions: {
|
||||
input: [
|
||||
"Assets/css/main.css",
|
||||
"Assets/main.tsx"
|
||||
],
|
||||
output: {
|
||||
entryFileNames: "js/[name].[hash].js",
|
||||
chunkFileNames: "js/[name]-chunk.js",
|
||||
assetFileNames: (info) => {
|
||||
if (info.name) {
|
||||
if (/\.css$/.test(info.name)) {
|
||||
return "css/[name].[hash][extname]";
|
||||
}
|
||||
|
||||
return "[name][extname]";
|
||||
} else {
|
||||
return "[name][extname]";
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: []
|
||||
},
|
||||
plugins: [react()],
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
autoprefixer(),
|
||||
cssnano({preset: "default"}),
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
|
@ -1,58 +0,0 @@
|
|||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Nunito Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/nunito-sans-v15-latin-700.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Noto Sans Display';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/noto-sans-display-v26-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply font-body;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-heading tracking-tight;
|
||||
}
|
||||
hyphens-auto {
|
||||
hyphenate-limit-chars: 5 3;
|
||||
}
|
||||
}
|
||||
@layer components {
|
||||
.youtube {
|
||||
@apply rounded p-2 bg-base-200;
|
||||
}
|
||||
|
||||
input.narrow-reading-toggle {
|
||||
display: none;
|
||||
}
|
||||
body:has(label[for=narrow-reading-toggle]) input.narrow-reading-toggle:checked + .reading-toggle-target {
|
||||
@apply max-w-3xl;
|
||||
}
|
||||
|
||||
.fade-away {
|
||||
-webkit-mask-image: linear-gradient(black, black 80%, rgba(0, 0, 0, 0.5) 85%, transparent 100%);
|
||||
mask-image: linear-gradient(black, black 80%, rgba(0, 0, 0, 0.5) 85%, transparent 100%);
|
||||
}
|
||||
|
||||
.prose div > pre, .prose > pre {
|
||||
@apply bg-inherit text-inherit rounded-none;
|
||||
}
|
||||
.prose pre:has(code) {
|
||||
@apply border-2 border-current;
|
||||
}
|
||||
|
||||
.characters-left {
|
||||
@apply absolute right-6 bottom-6 select-none pointer-events-none;
|
||||
}
|
||||
}
|
3
Wave/wwwroot/css/main.min.css
vendored
|
@ -3,7 +3,7 @@ version: '3.4'
|
|||
services:
|
||||
web:
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- ASPNETCORE_HTTP_PORTS=8080
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
volumes:
|
||||
- ${APPDATA}/Microsoft/UserSecrets:/home/app/.microsoft/usersecrets:ro
|
|
@ -1,23 +1,42 @@
|
|||
{
|
||||
"profiles": {
|
||||
"Docker Compose": {
|
||||
"commandName": "DockerCompose",
|
||||
"commandVersion": "1.0",
|
||||
"serviceActions": {
|
||||
"web": "StartDebugging",
|
||||
"database": "StartWithoutDebugging",
|
||||
"mailhog": "DoNotStart",
|
||||
"redis": "StartWithoutDebugging"
|
||||
}
|
||||
},
|
||||
"SMTP Debugging": {
|
||||
"commandName": "DockerCompose",
|
||||
"commandVersion": "1.0",
|
||||
"composeProfile": {
|
||||
"includes": [
|
||||
"smtp-debug"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"profiles": {
|
||||
"Docker Compose": {
|
||||
"commandName": "DockerCompose",
|
||||
"commandVersion": "1.0",
|
||||
"serviceActions": {
|
||||
"web": "StartDebugging",
|
||||
"database": "StartWithoutDebugging",
|
||||
"mailhog": "DoNotStart",
|
||||
"redis": "StartWithoutDebugging",
|
||||
"grafana": "StartWithoutDebugging",
|
||||
"loki": "StartWithoutDebugging",
|
||||
"prometheus": "StartWithoutDebugging"
|
||||
}
|
||||
},
|
||||
"SMTP Debugging": {
|
||||
"commandName": "DockerCompose",
|
||||
"commandVersion": "1.0",
|
||||
"composeProfile": {
|
||||
"includes": [
|
||||
"smtp-debug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"Production": {
|
||||
"commandName": "DockerCompose",
|
||||
"commandVersion": "1.0",
|
||||
"serviceActions": {
|
||||
"database": "StartWithoutDebugging",
|
||||
"grafana": "DoNotStart",
|
||||
"loki": "DoNotStart",
|
||||
"mailhog": "DoNotStart",
|
||||
"prometheus": "DoNotStart",
|
||||
"redis": "StartWithoutDebugging",
|
||||
"web": "StartDebugging"
|
||||
},
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Production"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|