Compare commits

..

7 commits

Author SHA1 Message Date
Mia Rose Winter 69a5d51214
fixed alpine image not starting 2024-06-18 13:00:34 +02:00
Mia Rose Winter c0195464c0
fixed ci/cd not building alpine images 2024-06-18 11:50:47 +02:00
Mia Rose Winter 62ea694204
fixup! fixup! Implemented hard delete on articles throug Deleted Page 2024-06-18 11:32:52 +02:00
Mia Rose Winter 1a5c5b4cd9
fixup! Implemented client side article editor (#6) 2024-06-18 11:30:40 +02:00
Mia Rose Winter 70c5c4c50f
fixed build issues 2024-06-18 11:27:48 +02:00
Mia Rose Winter 50237f0714
fixup! Implemented hard delete on articles throug Deleted Page 2024-06-18 09:27:23 +02:00
Mia Rose Winter 1d55ab23f0
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
2024-06-18 09:09:47 +02:00
74 changed files with 1926 additions and 597 deletions

View file

@ -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 }}

View file

@ -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();

View 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
}

View file

@ -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]

View 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);
}

View file

@ -35,6 +35,7 @@
</ItemGroup>
<ItemGroup>
<Folder Include="TestUtilities\" />
<Folder Include="Utilities\" />
</ItemGroup>

View 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>
</>
}
</>
);
}

View 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>;
}

View file

@ -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;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
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;
}
.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;
.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%);
}
.content {
padding-top: 1.1rem;
.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;
}
}
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;
@ -45,7 +68,3 @@ .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
View 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 />);
}

View 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[],
}

View file

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View file

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 5 KiB

View file

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View file

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 6 KiB

View file

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View file

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View file

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

View file

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View file

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 5 KiB

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View file

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View file

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View file

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View file

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View file

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View 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
View file

@ -0,0 +1,5 @@
/// <reference types="vite/client" />
/// <reference types="dom" />
/// <reference types="node" />
/// <reference types="react" />
/// <reference types="react-dom" />

View file

@ -1,8 +1,13 @@
@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)">
@ -10,28 +15,31 @@
<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">
@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>

View file

@ -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>
}

View file

@ -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>
<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);
}
}
}

View file

@ -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; }
}
}

View 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();
}
}
}

View file

@ -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);

View file

@ -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;
}
}

View 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;
}
}

View file

@ -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;

View 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;
}
}

View 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()) {}
}

View file

@ -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"]

View file

@ -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();
{

View file

@ -16,6 +16,8 @@ public static class Permissions {
return true;
}
if (principal.Identity?.IsAuthenticated is false) return false;
// Admins always get access
if (principal.IsInRole("Admin")) {
return true;
@ -34,8 +36,25 @@ 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

View file

@ -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>

View file

@ -1,2 +1,3 @@
{
"DetailedErrors": true
}

BIN
Wave/package-lock.json generated

Binary file not shown.

View file

@ -1,18 +1,38 @@
{
"name": "wave",
"version": "0.0.1",
"type": "module",
"scripts": {
"css:build": "postcss -o wwwroot/css/main.min.css wwwroot/css/main.css"
"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",
"postcss-cli": "^11.0.0",
"tailwindcss": "^3.4.1"
"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"
}
}

View file

@ -1,9 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
cssnano: {
preset: "default"
}
}
}

View file

@ -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
View 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
View 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
View 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"}),
]
}
}
});

View file

@ -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;
}
}

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -7,7 +7,10 @@
"web": "StartDebugging",
"database": "StartWithoutDebugging",
"mailhog": "DoNotStart",
"redis": "StartWithoutDebugging"
"redis": "StartWithoutDebugging",
"grafana": "StartWithoutDebugging",
"loki": "StartWithoutDebugging",
"prometheus": "StartWithoutDebugging"
}
},
"SMTP Debugging": {
@ -18,6 +21,22 @@
"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"
}
}
}
}