Implemented client side article editor (#6)
* started implementing article API, missing lots of tests to validate feature * made tests more pretty * re-structured tests * refactored dto contracts * tested and fixed updating categories * added permission tests, fixed bug in Permissions system * added data validation tests for update article * refactored repository interface * Added ArticleView dto, fixed bug in requesting articles over repository * updated dependencies * optimized program.cs, added repo service * Removed all interactivity from ArticleEditor, merged files * added vite, tailwind working, dev server is not, js is not yet * added fontsource for font management using vite's bundling * moved vite output to wwwroot/dist reorganized stuff that will never need processing or needs to be at site root * fixed heading font weight not being 700 anymore * implemented react in ArticleEditor * added article status steps to react component noticed I need to figure out client side localization * fixed vite dev server thingies, tailwind and react refresh works now * added article form skeletton to react * more editor implementations * minor typescript fixes * implemented proper editor functions * added all missing toolbar buttons * fixed error, made open article work * improved article editor structure * implemented article editor taking id from the url * Implemented categories endpoint * implemented categories in article editor * fixed minor TS issues * implemented localization in article editor * completed localization * implemented loading selected categories * minor code improvements and maybe a regex fix * fixed bug with not getting unpublished articles * implemented form state * fixed validation issues * implemented saving (missing creation) * fixed minor bug with status display * organized models * added live markdown preview (incomplete) * fixed issues in article create api endpoint * improved article saving, implemented creating * fixed publish date not being set correctly when creating article * fixed slugs once more * added run config for production (without vite dev) * removed unused code * updated dockerfile to build Assets * fixed slug generation * updated tests to validate new slug generator * savsdSACAVSD * fixed validation issues and tests
|
@ -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 |