Compare commits
No commits in common. "69a5d51214d746a1824ea582f9dd4862097292a3" and "5c62ee40771c3c8546dd319c827ea6deb8318fc8" have entirely different histories.
69a5d51214
...
5c62ee4077
10
.github/workflows/docker.yml
vendored
|
@ -11,12 +11,6 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
image_suffix: ["", "-alpine"]
|
image_suffix: ["", "-alpine"]
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
attestations: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
@ -32,7 +26,7 @@ jobs:
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=true
|
latest=true
|
||||||
suffix=${{ matrix.image_suffix }},onlatest=true
|
suffix=${{ matrix.suffix }}, onlatest=true
|
||||||
labels: |
|
labels: |
|
||||||
maintainer=Mia Rose Winter
|
maintainer=Mia Rose Winter
|
||||||
org.opencontainers.image.title=Wave
|
org.opencontainers.image.title=Wave
|
||||||
|
@ -67,4 +61,4 @@ jobs:
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||||
BASE=${{ matrix.image_suffix }}
|
BASE=8.0${{ matrix.suffix }}
|
||||||
|
|
|
@ -1,12 +1,34 @@
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Testcontainers.PostgreSql;
|
||||||
using Wave.Data;
|
using Wave.Data;
|
||||||
using Wave.Tests.TestUtilities;
|
|
||||||
|
|
||||||
namespace Wave.Tests.Data;
|
namespace Wave.Tests.Data;
|
||||||
|
|
||||||
[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
|
[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
|
||||||
[TestOf(typeof(ApplicationDbContext))]
|
[TestOf(typeof(ApplicationDbContext))]
|
||||||
public class ApplicationDbContextTest : DbContextTest {
|
public class ApplicationDbContextTest {
|
||||||
|
private PostgreSqlContainer PostgresContainer { get; } = new PostgreSqlBuilder().WithImage("postgres:16.1-alpine").Build();
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public async Task SetUp() {
|
||||||
|
await PostgresContainer.StartAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public async Task TearDown() {
|
||||||
|
await PostgresContainer.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApplicationDbContext GetContext() {
|
||||||
|
return new ApplicationDbContext(
|
||||||
|
new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseNpgsql(PostgresContainer.GetConnectionString())
|
||||||
|
.EnableSensitiveDataLogging()
|
||||||
|
.EnableDetailedErrors()
|
||||||
|
.EnableThreadSafetyChecks()
|
||||||
|
.Options);
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Migration() {
|
public async Task Migration() {
|
||||||
await using var context = GetContext();
|
await using var context = GetContext();
|
||||||
|
|
|
@ -1,353 +0,0 @@
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Text;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Wave.Data;
|
|
||||||
using Wave.Data.Transactional;
|
|
||||||
using Wave.Tests.TestUtilities;
|
|
||||||
|
|
||||||
// ReSharper disable InconsistentNaming
|
|
||||||
|
|
||||||
namespace Wave.Tests.Data;
|
|
||||||
|
|
||||||
public abstract class ApplicationRepositoryTests : DbContextTest {
|
|
||||||
protected ApplicationRepository Repository { get; set; } = null!;
|
|
||||||
|
|
||||||
protected const string TestUserName = "testuser@example.com";
|
|
||||||
protected const string AuthorUserName = "author@example.com";
|
|
||||||
protected const string ReviewerUserName = "reviewer@example.com";
|
|
||||||
|
|
||||||
protected ClaimsPrincipal AnonymousPrincipal { get; set; } = null!;
|
|
||||||
protected ClaimsPrincipal UserPrincipal { get; set; } = null!;
|
|
||||||
protected ClaimsPrincipal AuthorPrincipal { get; set; } = null!;
|
|
||||||
protected ClaimsPrincipal ReviewerPrincipal { get; set; } = null!;
|
|
||||||
|
|
||||||
protected Guid PrimaryCategoryId { get; set; }
|
|
||||||
protected Guid SecondaryCategoryId { get; set; }
|
|
||||||
|
|
||||||
protected virtual ValueTask InitializeTestEntities(ApplicationDbContext context) {
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async ValueTask AndThenSetUp() {
|
|
||||||
Repository = new ApplicationRepository(new MockDbContextFactory(GetContext));
|
|
||||||
|
|
||||||
List<Category> categories = [
|
|
||||||
new Category {
|
|
||||||
Name = "Primary Category",
|
|
||||||
Color = CategoryColors.Primary
|
|
||||||
},
|
|
||||||
new Category {
|
|
||||||
Name = "Secondary Category",
|
|
||||||
Color = CategoryColors.Secondary
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
await using var context = GetContext();
|
|
||||||
var user = new ApplicationUser {
|
|
||||||
UserName = TestUserName
|
|
||||||
};
|
|
||||||
var author = new ApplicationUser {
|
|
||||||
UserName = AuthorUserName
|
|
||||||
};
|
|
||||||
var reviewer = new ApplicationUser {
|
|
||||||
UserName = ReviewerUserName
|
|
||||||
};
|
|
||||||
|
|
||||||
context.AddRange(categories);
|
|
||||||
context.Users.AddRange([user, author, reviewer]);
|
|
||||||
|
|
||||||
await context.Database.EnsureCreatedAsync();
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
|
|
||||||
AnonymousPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
|
||||||
UserPrincipal = new ClaimsPrincipal(new ClaimsIdentity([
|
|
||||||
new Claim(ClaimTypes.Name, user.UserName),
|
|
||||||
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
|
||||||
new Claim("Id", user.Id),
|
|
||||||
], "Mock Authentication"));
|
|
||||||
AuthorPrincipal = new ClaimsPrincipal(new ClaimsIdentity([
|
|
||||||
new Claim(ClaimTypes.Name, author.UserName),
|
|
||||||
new Claim(ClaimTypes.NameIdentifier, author.Id),
|
|
||||||
new Claim("Id", author.Id),
|
|
||||||
new Claim(ClaimTypes.Role, "Author"),
|
|
||||||
], "Mock Authentication"));
|
|
||||||
ReviewerPrincipal = new ClaimsPrincipal(new ClaimsIdentity([
|
|
||||||
new Claim(ClaimTypes.Name, reviewer.UserName),
|
|
||||||
new Claim(ClaimTypes.NameIdentifier, reviewer.Id),
|
|
||||||
new Claim("Id", reviewer.Id),
|
|
||||||
new Claim(ClaimTypes.Role, "Author"),
|
|
||||||
new Claim(ClaimTypes.Role, "Reviewer"),
|
|
||||||
], "Mock Authentication"));
|
|
||||||
|
|
||||||
PrimaryCategoryId = categories[0].Id;
|
|
||||||
SecondaryCategoryId = categories[1].Id;
|
|
||||||
|
|
||||||
await InitializeTestEntities(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class MockDbContextFactory(Func<ApplicationDbContext> supplier)
|
|
||||||
: IDbContextFactory<ApplicationDbContext> {
|
|
||||||
public ApplicationDbContext CreateDbContext() => supplier();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
|
|
||||||
[TestOf(typeof(ApplicationRepository))]
|
|
||||||
public class ApplicationRepositoryTest_GetCategories : ApplicationRepositoryTests {
|
|
||||||
|
|
||||||
#region Success Tests
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task AnonymousDefaultOneArticleOneCategory_Success() {
|
|
||||||
await Repository.CreateArticleAsync(
|
|
||||||
new ArticleCreateDto("test", "*test*", null, null, [PrimaryCategoryId], null), AuthorPrincipal);
|
|
||||||
|
|
||||||
var result = await Repository.GetCategories(AnonymousPrincipal);
|
|
||||||
Assert.Multiple(() => {
|
|
||||||
Assert.That(result, Is.Not.Empty);
|
|
||||||
Assert.That(result.First().Id, Is.EqualTo(PrimaryCategoryId));
|
|
||||||
Assert.That(result, Has.Count.EqualTo(1));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Permission Tests
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task AnonymousDefaultNoArticles_Success() {
|
|
||||||
var result = await Repository.GetCategories(AnonymousPrincipal);
|
|
||||||
|
|
||||||
Assert.Multiple(() => { Assert.That(result, Is.Empty); });
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void AnonymousNoArticlesAllCategories_ThrowsMissingPermissions() {
|
|
||||||
Assert.ThrowsAsync<ApplicationException>(async () => await Repository.GetCategories(AnonymousPrincipal, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task AuthorDefaultNoArticles_Success() {
|
|
||||||
var result = await Repository.GetCategories(AuthorPrincipal);
|
|
||||||
|
|
||||||
Assert.Multiple(() => { Assert.That(result, Is.Empty); });
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task AuthorDefaultNoArticlesAllCategories_Success() {
|
|
||||||
var result = await Repository.GetCategories(AuthorPrincipal, true);
|
|
||||||
|
|
||||||
Assert.Multiple(() => {
|
|
||||||
Assert.That(result, Is.Not.Empty);
|
|
||||||
Assert.That(result, Has.Count.EqualTo(2));
|
|
||||||
Assert.That(result.FirstOrDefault(c => c.Id == PrimaryCategoryId), Is.Not.Null);
|
|
||||||
Assert.That(result.FirstOrDefault(c => c.Id == SecondaryCategoryId), Is.Not.Null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
|
|
||||||
[TestOf(typeof(ApplicationRepository))]
|
|
||||||
public class ApplicationRepositoryTest_CreateArticle : ApplicationRepositoryTests {
|
|
||||||
private static ArticleCreateDto GetValidTestArticle(Guid[]? categories = null) {
|
|
||||||
return new ArticleCreateDto(
|
|
||||||
"Test Article",
|
|
||||||
"*Test* Body",
|
|
||||||
null, null, categories, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Success Tests
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task MinimalArticle_Success() {
|
|
||||||
var article = GetValidTestArticle();
|
|
||||||
|
|
||||||
var view = await Repository.CreateArticleAsync(article, AuthorPrincipal);
|
|
||||||
|
|
||||||
await using var context = GetContext();
|
|
||||||
Assert.Multiple(() => {
|
|
||||||
Assert.That(context.Set<Article>().IgnoreQueryFilters().ToList(), Has.Count.EqualTo(1));
|
|
||||||
Assert.That(context.Set<Article>().IgnoreQueryFilters().First().Id, Is.EqualTo(view.Id));
|
|
||||||
Assert.That(view.Status, Is.EqualTo(ArticleStatus.Draft));
|
|
||||||
Assert.That(view.BodyHtml, Is.Not.Null);
|
|
||||||
Assert.That(view.BodyPlain, Is.Not.Null);
|
|
||||||
Assert.That(view.Slug, Is.EqualTo("test-article"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task WithCategories_Success() {
|
|
||||||
var article = GetValidTestArticle([PrimaryCategoryId]);
|
|
||||||
var view = await Repository.CreateArticleAsync(article, AuthorPrincipal);
|
|
||||||
|
|
||||||
await using var context = GetContext();
|
|
||||||
Assert.Multiple(() => {
|
|
||||||
Assert.That(view.Categories, Has.Count.EqualTo(1));
|
|
||||||
Assert.That(context.Set<Article>().IgnoreQueryFilters()
|
|
||||||
.Include(a => a.Categories)
|
|
||||||
.First(a => a.Id == view.Id).Categories.First().Id, Is.EqualTo(PrimaryCategoryId));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Permission Tests
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void RegularUser_ThrowsMissingPermissions() {
|
|
||||||
var article = GetValidTestArticle();
|
|
||||||
|
|
||||||
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
|
|
||||||
async () => await Repository.CreateArticleAsync(article, UserPrincipal));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void AnonymousUser_ThrowsMissingPermissions() {
|
|
||||||
var article = GetValidTestArticle();
|
|
||||||
|
|
||||||
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
|
|
||||||
async () => await Repository.CreateArticleAsync(article, AnonymousPrincipal));
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Data Validation Tests
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void MissingTitle_ThrowsMalformed() {
|
|
||||||
var article = new ArticleCreateDto(null!, "test", null, null, null, null);
|
|
||||||
|
|
||||||
Assert.ThrowsAsync<ArticleMalformedException>(
|
|
||||||
async () => await Repository.CreateArticleAsync(article, AuthorPrincipal));
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
|
|
||||||
[TestOf(typeof(ApplicationRepository))]
|
|
||||||
public class ApplicationRepositoryTest_UpdateArticle : ApplicationRepositoryTests {
|
|
||||||
private Guid TestArticleId { get; set; }
|
|
||||||
|
|
||||||
private ArticleUpdateDto GetValidTestArticle() => new(TestArticleId);
|
|
||||||
|
|
||||||
private static string StringOfLength(int length) {
|
|
||||||
var builder = new StringBuilder();
|
|
||||||
|
|
||||||
for (int i = 0; i < length; i++) {
|
|
||||||
builder.Append('_');
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async ValueTask InitializeTestEntities(ApplicationDbContext context) {
|
|
||||||
var testArticle = new ArticleCreateDto(
|
|
||||||
"Test Article",
|
|
||||||
"Test **Article** with *formatting.",
|
|
||||||
"test-article",
|
|
||||||
DateTimeOffset.Now.AddHours(-5),
|
|
||||||
[PrimaryCategoryId], null);
|
|
||||||
|
|
||||||
var view = await Repository.CreateArticleAsync(testArticle, AuthorPrincipal);
|
|
||||||
TestArticleId = view.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Success Tests
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task UpdateTitle_Success() {
|
|
||||||
var update = new ArticleUpdateDto(TestArticleId, "New Title");
|
|
||||||
|
|
||||||
await Repository.UpdateArticleAsync(update, AuthorPrincipal);
|
|
||||||
|
|
||||||
await using var context = GetContext();
|
|
||||||
Assert.Multiple(() => {
|
|
||||||
Assert.That(context.Set<Article>().IgnoreQueryFilters().First(a => a.Id == TestArticleId).Title,
|
|
||||||
Is.EqualTo("New Title"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task UpdateBodyUpdatesHtmlAndPlain_Success() {
|
|
||||||
var update = new ArticleUpdateDto(TestArticleId, body:"Some *new* Body");
|
|
||||||
const string expectedHtml = "<p>Some <em>new</em> Body</p>";
|
|
||||||
const string expectedPlain = "Some new Body";
|
|
||||||
|
|
||||||
await Repository.UpdateArticleAsync(update, AuthorPrincipal);
|
|
||||||
|
|
||||||
await using var context = GetContext();
|
|
||||||
Assert.Multiple(() => {
|
|
||||||
var article = context.Set<Article>().IgnoreQueryFilters().First(a => a.Id == TestArticleId);
|
|
||||||
Assert.That(article.BodyHtml, Is.EqualTo(expectedHtml));
|
|
||||||
Assert.That(article.BodyPlain, Is.EqualTo(expectedPlain));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task UpdateCategories_Success() {
|
|
||||||
var update = new ArticleUpdateDto(TestArticleId, categories:[SecondaryCategoryId]);
|
|
||||||
await Repository.UpdateArticleAsync(update, AuthorPrincipal);
|
|
||||||
|
|
||||||
await using var context = GetContext();
|
|
||||||
Assert.Multiple(() => {
|
|
||||||
var article = context.Set<Article>().IgnoreQueryFilters()
|
|
||||||
.Include(a => a.Categories).First(a => a.Id == TestArticleId);
|
|
||||||
Assert.That(article.Categories, Has.Count.EqualTo(1));
|
|
||||||
Assert.That(article.Categories.First().Id, Is.EqualTo(SecondaryCategoryId));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Permission Tests
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void AnonymousUser_ThrowsMissingPermissions() {
|
|
||||||
var update = GetValidTestArticle();
|
|
||||||
|
|
||||||
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
|
|
||||||
async () => await Repository.UpdateArticleAsync(update, AnonymousPrincipal));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void RegularUser_ThrowsMissingPermissions() {
|
|
||||||
var update = GetValidTestArticle();
|
|
||||||
|
|
||||||
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
|
|
||||||
async () => await Repository.UpdateArticleAsync(update, UserPrincipal));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void UnrelatedAuthor_ThrowsMissingPermissions() {
|
|
||||||
var update = GetValidTestArticle();
|
|
||||||
|
|
||||||
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
|
|
||||||
async () => await Repository.UpdateArticleAsync(update, ReviewerPrincipal));
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Data Validation Tests
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void SlugLength65_ThrowsMalformed() {
|
|
||||||
var update = new ArticleUpdateDto(TestArticleId, slug:StringOfLength(65));
|
|
||||||
Assert.ThrowsAsync<ArticleMalformedException>(
|
|
||||||
async () => await Repository.UpdateArticleAsync(update, AuthorPrincipal));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void TitleLength257_ThrowsMalformed() {
|
|
||||||
var update = new ArticleUpdateDto(TestArticleId, slug:StringOfLength(257));
|
|
||||||
Assert.ThrowsAsync<ArticleMalformedException>(
|
|
||||||
async () => await Repository.UpdateArticleAsync(update, AuthorPrincipal));
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
}
|
|
|
@ -22,7 +22,7 @@ public class ArticleTest {
|
||||||
public void SlugWithSpecialCharacters() {
|
public void SlugWithSpecialCharacters() {
|
||||||
Article.Title = "Title with, special characters?";
|
Article.Title = "Title with, special characters?";
|
||||||
Article.UpdateSlug();
|
Article.UpdateSlug();
|
||||||
Assert.That(Article.Slug, Is.EqualTo("title-with-special-characters"));
|
Assert.That(Article.Slug, Is.EqualTo("title-with%2C-special-characters%3F"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
@ -36,14 +36,14 @@ public class ArticleTest {
|
||||||
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterEscapeSize3AtPosition55() {
|
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterEscapeSize3AtPosition55() {
|
||||||
Article.Title = "Auto generating slugs was a mistake I hate this ______ €";
|
Article.Title = "Auto generating slugs was a mistake I hate this ______ €";
|
||||||
Article.UpdateSlug();
|
Article.UpdateSlug();
|
||||||
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-______-"));
|
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-______-%E2%82%AC"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterEscapeSize2AtPosition56() {
|
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterEscapeSize2AtPosition56() {
|
||||||
Article.Title = "Auto generating slugs was a mistake I hate this _______ üa";
|
Article.Title = "Auto generating slugs was a mistake I hate this _______ üa";
|
||||||
Article.UpdateSlug();
|
Article.UpdateSlug();
|
||||||
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-_______-a"));
|
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-_______-%C3%BCa"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
@ -57,7 +57,7 @@ public class ArticleTest {
|
||||||
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterEscapeSize2AtPosition57() {
|
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterEscapeSize2AtPosition57() {
|
||||||
Article.Title = "Auto generating slugs was a mistake I hate this ________ üa";
|
Article.Title = "Auto generating slugs was a mistake I hate this ________ üa";
|
||||||
Article.UpdateSlug();
|
Article.UpdateSlug();
|
||||||
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-________-a"));
|
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-________-%C3%BCa"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
@ -71,21 +71,21 @@ public class ArticleTest {
|
||||||
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterAtPosition61() {
|
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterAtPosition61() {
|
||||||
Article.Title = "Article that ends with a special character and need special cäre";
|
Article.Title = "Article that ends with a special character and need special cäre";
|
||||||
Article.UpdateSlug();
|
Article.UpdateSlug();
|
||||||
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-need-special-cre"));
|
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-need-special-c"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterAtPosition62() {
|
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterAtPosition62() {
|
||||||
Article.Title = "Article that ends with a special character and needs special cäre";
|
Article.Title = "Article that ends with a special character and needs special cäre";
|
||||||
Article.UpdateSlug();
|
Article.UpdateSlug();
|
||||||
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-needs-special-cre"));
|
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-needs-special-c"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterAtPosition63() {
|
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterAtPosition63() {
|
||||||
Article.Title = "Article that ends with a special character and needs special caäre";
|
Article.Title = "Article that ends with a special character and needs special caäre";
|
||||||
Article.UpdateSlug();
|
Article.UpdateSlug();
|
||||||
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-needs-special-car"));
|
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-needs-special-ca"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Testcontainers.PostgreSql;
|
|
||||||
using Wave.Data;
|
|
||||||
|
|
||||||
namespace Wave.Tests.TestUtilities;
|
|
||||||
|
|
||||||
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
|
|
||||||
public abstract class DbContextTest {
|
|
||||||
private PostgreSqlContainer PostgresContainer { get; } = new PostgreSqlBuilder().WithImage("postgres:16.1-alpine").Build();
|
|
||||||
|
|
||||||
[SetUp]
|
|
||||||
public async Task SetUp() {
|
|
||||||
await PostgresContainer.StartAsync();
|
|
||||||
await AndThenSetUp();
|
|
||||||
}
|
|
||||||
protected virtual ValueTask AndThenSetUp() {
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
[TearDown]
|
|
||||||
public async Task TearDown() => await PostgresContainer.DisposeAsync();
|
|
||||||
|
|
||||||
protected ApplicationDbContext GetContext() =>
|
|
||||||
new(new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
||||||
.UseNpgsql(PostgresContainer.GetConnectionString())
|
|
||||||
.EnableSensitiveDataLogging()
|
|
||||||
.EnableDetailedErrors()
|
|
||||||
.EnableThreadSafetyChecks()
|
|
||||||
.Options);
|
|
||||||
|
|
||||||
}
|
|
|
@ -35,7 +35,6 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="TestUtilities\" />
|
|
||||||
<Folder Include="Utilities\" />
|
<Folder Include="Utilities\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
@ -1,382 +0,0 @@
|
||||||
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>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface ILabelProperties {
|
|
||||||
label: string,
|
|
||||||
className?: string,
|
|
||||||
children: React.ReactNode,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LabelInput({label, className, children} : ILabelProperties) : React.ReactElement {
|
|
||||||
return <label className={`form-control w-full ${className ?? ""}`}>
|
|
||||||
<div className="label">{label}</div>
|
|
||||||
{children}
|
|
||||||
</label>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToolBarButton({title, onClick, children}: {title?: string, onClick:React.MouseEventHandler<HTMLButtonElement>, children:any}) {
|
|
||||||
return <button type="button" className="btn btn-accent btn-sm outline-none font-normal join-item"
|
|
||||||
title={title}
|
|
||||||
onClick={onClick}>
|
|
||||||
{children ?? "err"}
|
|
||||||
</button>;
|
|
||||||
}
|
|
|
@ -1,160 +0,0 @@
|
||||||
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 />);
|
|
||||||
}
|
|
|
@ -1,40 +0,0 @@
|
||||||
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[],
|
|
||||||
}
|
|
|
@ -1,63 +0,0 @@
|
||||||
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
|
@ -1,5 +0,0 @@
|
||||||
/// <reference types="vite/client" />
|
|
||||||
/// <reference types="dom" />
|
|
||||||
/// <reference types="node" />
|
|
||||||
/// <reference types="react" />
|
|
||||||
/// <reference types="react-dom" />
|
|
|
@ -1,45 +1,37 @@
|
||||||
@using System.Globalization
|
@using System.Globalization
|
||||||
@using Microsoft.AspNetCore.Localization
|
@using Microsoft.AspNetCore.Localization
|
||||||
@using Microsoft.Extensions.Options
|
@using Microsoft.Extensions.Options
|
||||||
@using Vite.AspNetCore
|
|
||||||
@using Wave.Data
|
@using Wave.Data
|
||||||
|
|
||||||
@inject IViteManifest ViteManifest
|
|
||||||
@inject IViteDevServerStatus ViteStatus
|
|
||||||
@inject IOptions<Customization> Customizations
|
@inject IOptions<Customization> Customizations
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="@CultureInfo.CurrentUICulture.ToString()" data-theme="@(UserTheme ?? Customizations.Value.DefaultTheme)">
|
<html lang="@CultureInfo.CurrentUICulture.ToString()" data-theme="@(UserTheme ?? Customizations.Value.DefaultTheme)">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<base href="/">
|
<base href="/">
|
||||||
@if (ViteStatus.IsEnabled) {
|
<link rel="stylesheet" href="app.css">
|
||||||
<link rel="stylesheet" href="@($"{ViteStatus.ServerUrlWithBasePath}/css/main.css")">
|
<link rel="stylesheet" href="/css/main.min.css">
|
||||||
} else {
|
|
||||||
<link rel="stylesheet" href="/dist/@(ViteManifest["css/main.css"]?.File ?? "css/main.css")">
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- #region favicon + manifest -->
|
<!-- #region favicon + manifest -->
|
||||||
@if (!string.IsNullOrWhiteSpace(Customizations.Value.IconLink)) {
|
@if (!string.IsNullOrWhiteSpace(Customizations.Value.IconLink)) {
|
||||||
<link rel="icon" href="@Customizations.Value.IconLink">
|
<link rel="icon" href="@Customizations.Value.IconLink">
|
||||||
} else {
|
} else {
|
||||||
<link rel="apple-touch-icon" sizes="57x57" href="/dist/apple-icon-57x57.png">
|
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png">
|
||||||
<link rel="apple-touch-icon" sizes="60x60" href="/dist/apple-icon-60x60.png">
|
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png">
|
||||||
<link rel="apple-touch-icon" sizes="72x72" href="/dist/apple-icon-72x72.png">
|
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png">
|
||||||
<link rel="apple-touch-icon" sizes="76x76" href="/dist/apple-icon-76x76.png">
|
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png">
|
||||||
<link rel="apple-touch-icon" sizes="114x114" href="/dist/apple-icon-114x114.png">
|
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png">
|
||||||
<link rel="apple-touch-icon" sizes="120x120" href="/dist/apple-icon-120x120.png">
|
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png">
|
||||||
<link rel="apple-touch-icon" sizes="144x144" href="/dist/apple-icon-144x144.png">
|
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png">
|
||||||
<link rel="apple-touch-icon" sizes="152x152" href="/dist/apple-icon-152x152.png">
|
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/dist/apple-icon-180x180.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="/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="192x192" href="/android-icon-192x192.png">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/dist/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="/dist/favicon-96x96.png">
|
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/dist/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||||
}
|
}
|
||||||
<link rel="manifest" href="/dist/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<meta name="msapplication-TileColor" content="#ffffff">
|
<meta name="msapplication-TileColor" content="#ffffff">
|
||||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
@ -52,16 +44,6 @@
|
||||||
<CascadingValue Value="UserTheme" Name="UserTheme">
|
<CascadingValue Value="UserTheme" Name="UserTheme">
|
||||||
<Routes />
|
<Routes />
|
||||||
</CascadingValue>
|
</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>
|
<script src="_framework/blazor.web.js" defer></script>
|
||||||
<SectionOutlet SectionName="scripts" />
|
<SectionOutlet SectionName="scripts" />
|
||||||
<script>
|
<script>
|
||||||
|
@ -133,26 +115,26 @@
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
public HttpContext? HttpContext { get; set; }
|
public HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
private string? UserTheme { get; set; }
|
private string? UserTheme { get; set; }
|
||||||
|
|
||||||
protected override void OnInitialized() {
|
protected override void OnInitialized() {
|
||||||
HttpContext?.Response.Cookies.Append(
|
HttpContext?.Response.Cookies.Append(
|
||||||
CookieRequestCultureProvider.DefaultCookieName,
|
CookieRequestCultureProvider.DefaultCookieName,
|
||||||
CookieRequestCultureProvider.MakeCookieValue(
|
CookieRequestCultureProvider.MakeCookieValue(
|
||||||
new RequestCulture(
|
new RequestCulture(
|
||||||
CultureInfo.CurrentCulture,
|
CultureInfo.CurrentCulture,
|
||||||
CultureInfo.CurrentUICulture)), new CookieOptions {
|
CultureInfo.CurrentUICulture)), new CookieOptions {
|
||||||
Expires = DateTimeOffset.UtcNow.AddYears(1),
|
Expires = DateTimeOffset.UtcNow.AddYears(1),
|
||||||
IsEssential = true,
|
IsEssential = true,
|
||||||
SameSite = SameSiteMode.Strict
|
SameSite = SameSiteMode.Strict
|
||||||
});
|
});
|
||||||
|
|
||||||
if (HttpContext?.Request.Cookies.ContainsKey(".Wave.Theme") is true) {
|
if (HttpContext?.Request.Cookies.ContainsKey(".Wave.Theme") is true) {
|
||||||
HttpContext.Request.Cookies.TryGetValue(".Wave.Theme", out string? theme);
|
HttpContext.Request.Cookies.TryGetValue(".Wave.Theme", out string? theme);
|
||||||
UserTheme = theme;
|
UserTheme = theme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -6,9 +6,9 @@
|
||||||
<img class="max-h-full object-contain object-left" src="@logo" alt="" />
|
<img class="max-h-full object-contain object-left" src="@logo" alt="" />
|
||||||
} else {
|
} else {
|
||||||
<picture>
|
<picture>
|
||||||
<source type="image/jxl" srcset="/dist/img/logo.jxl" />
|
<source type="image/jxl" srcset="img/logo.jxl" />
|
||||||
<source type="image/svg+xml" srcset="/dist/img/logo.svg" />
|
<source type="image/svg+xml" srcset="img/logo.svg" />
|
||||||
<source type="image/webp" srcset="/dist/img/logo.webp" />
|
<source type="image/webp" srcset="img/logo.webp" />
|
||||||
<img class="h-full object-contain object-left" src="/dist/img/logo.png" alt="Wave" />
|
<img class="h-full object-contain object-left" src="img/logo.png" alt="Wave" />
|
||||||
</picture>
|
</picture>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,80 +1,58 @@
|
||||||
@page "/article/new"
|
@page "/article/new"
|
||||||
@page "/article/{id:guid}/edit"
|
@page "/article/{id:guid}/edit"
|
||||||
|
|
||||||
@using System.ComponentModel.DataAnnotations
|
|
||||||
@using Vite.AspNetCore
|
|
||||||
@using Wave.Data
|
@using Wave.Data
|
||||||
@using Wave.Utilities
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using System.Security.Claims
|
||||||
|
|
||||||
@rendermode InteractiveServer
|
@rendermode @(new InteractiveServerRenderMode(true))
|
||||||
@attribute [Authorize(Policy = "ArticleEditPermissions")]
|
@attribute [Authorize(Policy = "ArticleEditPermissions")]
|
||||||
|
|
||||||
@inject ILogger<ArticleEditor> Logger
|
@inject UserManager<ApplicationUser> UserManager
|
||||||
@inject NavigationManager Navigation
|
|
||||||
@inject IStringLocalizer<ArticleEditor> Localizer
|
@inject IStringLocalizer<ArticleEditor> Localizer
|
||||||
@inject IMessageDisplay Message
|
|
||||||
@inject IViteManifest ViteManifest
|
|
||||||
@inject IViteDevServerStatus ViteServer
|
|
||||||
@inject IJSRuntime JS
|
|
||||||
|
|
||||||
<PageTitle>@(Localizer["EditorTitle"] + TitlePostfix)</PageTitle>
|
<PageTitle>@(Localizer["EditorTitle"] + TitlePostfix)</PageTitle>
|
||||||
|
|
||||||
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["EditorTitle"]</h1>
|
@if (User is null) {
|
||||||
<div id="editor">
|
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["EditorTitle"]</h1>
|
||||||
|
|
||||||
<div class="flex place-content-center">
|
<div class="flex place-content-center">
|
||||||
<p>Loading Interactive Editor </p>
|
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[CascadingParameter(Name = "TitlePostfix")]
|
[CascadingParameter(Name = "TitlePostfix")]
|
||||||
private string TitlePostfix { get; set; } = default!;
|
private string TitlePostfix { get; set; } = default!;
|
||||||
|
[CascadingParameter]
|
||||||
|
private Task<AuthenticationState> AuthenticationState { get; set; } = default!;
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public Guid? Id { get; set; }
|
public Guid? Id { get; set; }
|
||||||
|
private ApplicationUser? User { get; set; }
|
||||||
|
private ClaimsPrincipal? ClaimsUser { get; set; }
|
||||||
|
|
||||||
private Data.Transactional.ArticleView? Article { get; set; }
|
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||||
private bool Saving { get; set; }
|
if (firstRender) {
|
||||||
|
if (User is not null) return;
|
||||||
private IReadOnlyList<Category> Categories { get; } = [];
|
var state = await AuthenticationState;
|
||||||
|
ClaimsUser = state.User;
|
||||||
private bool ReactImported { get; set; }
|
var user = await UserManager.GetUserAsync(state.User);
|
||||||
|
User = user ?? throw new ApplicationException("???2");
|
||||||
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);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
314
Wave/Components/Pages/Partials/ArticleEditorPartial.razor
Normal file
|
@ -0,0 +1,314 @@
|
||||||
|
@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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,107 +0,0 @@
|
||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Wave.Data;
|
|
||||||
using Wave.Data.Transactional;
|
|
||||||
|
|
||||||
namespace Wave.Controllers;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("/api/[controller]")]
|
|
||||||
public class ArticleController(ILogger<ArticleController> logger, ApplicationRepository repository) : ControllerBase {
|
|
||||||
[HttpGet("/api/categories"), AllowAnonymous]
|
|
||||||
[Produces("application/json")]
|
|
||||||
public async Task<Results<
|
|
||||||
Ok<IEnumerable<Category>>,
|
|
||||||
NoContent,
|
|
||||||
UnauthorizedHttpResult,
|
|
||||||
ProblemHttpResult>> GetCategories(bool all = false, CancellationToken cancellation = default) {
|
|
||||||
try {
|
|
||||||
var categories = await repository.GetCategories(User, all, cancellation);
|
|
||||||
|
|
||||||
if (categories.Count < 1) return TypedResults.NoContent();
|
|
||||||
return TypedResults.Ok<IEnumerable<Category>>(categories);
|
|
||||||
} catch (ApplicationException) {
|
|
||||||
logger.LogTrace("Unauthenticated user tried to access all categories. Denied.");
|
|
||||||
return TypedResults.Unauthorized();
|
|
||||||
} catch (Exception ex) {
|
|
||||||
logger.LogError(ex, "Unexpected error trying to get Categories for user {UserId}.", User.FindFirstValue("Id") ?? "unknown or anonymous");
|
|
||||||
return TypedResults.Problem();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{id:guid}"), AllowAnonymous]
|
|
||||||
[Produces("application/json")]
|
|
||||||
public async Task<Results<
|
|
||||||
Ok<ArticleView>,
|
|
||||||
NotFound,
|
|
||||||
UnauthorizedHttpResult,
|
|
||||||
ProblemHttpResult>> GetArticle(Guid id, CancellationToken cancellation = default) {
|
|
||||||
try {
|
|
||||||
return TypedResults.Ok(new ArticleView(await repository.GetArticleAsync(id, User, cancellation)));
|
|
||||||
} catch (ArticleNotFoundException) {
|
|
||||||
logger.LogWarning("Failed to look up Article with Id {ArticleId}. Not Found", id);
|
|
||||||
return TypedResults.NotFound();
|
|
||||||
} catch (ArticleMissingPermissionsException) {
|
|
||||||
logger.LogWarning(
|
|
||||||
"Failed to look up Article with Id {ArticleId}. User {UserId} Access Denied.",
|
|
||||||
id, User.FindFirstValue("Id") ?? "unknown or anonymous");
|
|
||||||
return TypedResults.Unauthorized();
|
|
||||||
} catch (Exception ex) {
|
|
||||||
logger.LogError(ex, "Unexpected Error.");
|
|
||||||
return TypedResults.Problem();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut(Name = nameof(CreateArticle)), Authorize]
|
|
||||||
[Produces("application/json")]
|
|
||||||
public async Task<Results<
|
|
||||||
CreatedAtRoute<ArticleView>,
|
|
||||||
BadRequest<string>,
|
|
||||||
UnauthorizedHttpResult,
|
|
||||||
ProblemHttpResult>> CreateArticle(ArticleCreateDto input, CancellationToken cancellation = default) {
|
|
||||||
try {
|
|
||||||
var article = new ArticleView(await repository.CreateArticleAsync(input, User, cancellation));
|
|
||||||
return TypedResults.CreatedAtRoute(article, nameof(CreateArticle), article.Id);
|
|
||||||
} catch (ArticleMissingPermissionsException) {
|
|
||||||
logger.LogWarning(
|
|
||||||
"Unauthorized User with ID {UserId} tried to create an Article.",
|
|
||||||
User.FindFirstValue("Id") ?? "unknown or anonymous");
|
|
||||||
return TypedResults.Unauthorized();
|
|
||||||
} catch (ArticleMalformedException ex) {
|
|
||||||
logger.LogWarning("User with ID {UserId} tried to create an article but submitted bad data.",
|
|
||||||
User.FindFirstValue("Id") ?? "unknown or anonymous");
|
|
||||||
return TypedResults.BadRequest($"Submitted data is not valid: {string.Join(",", ex.Errors.Select(e => e.ErrorMessage))}.");
|
|
||||||
} catch (Exception ex) {
|
|
||||||
logger.LogError(ex, "Unexpected Error.");
|
|
||||||
return TypedResults.Problem();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost, Authorize]
|
|
||||||
[Produces("application/json")]
|
|
||||||
public async Task<Results<
|
|
||||||
Ok<ArticleView>,
|
|
||||||
NotFound,
|
|
||||||
BadRequest<string>,
|
|
||||||
UnauthorizedHttpResult,
|
|
||||||
ProblemHttpResult>> UpdateArticle(ArticleUpdateDto input, CancellationToken cancellation = default)
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
return TypedResults.Ok(new ArticleView(await repository.UpdateArticleAsync(input, User, cancellation)));
|
|
||||||
} catch (ArticleNotFoundException) {
|
|
||||||
return TypedResults.NotFound();
|
|
||||||
} catch (ArticleMalformedException ex) {
|
|
||||||
logger.LogWarning("User with ID {UserId} tried to update article with ID {ArticleId} but submitted bad data.",
|
|
||||||
User.FindFirstValue("Id") ?? "unknown or anonymous", input.Id);
|
|
||||||
return TypedResults.BadRequest($"Submitted data is not valid: {string.Join(",", ex.Errors.Select(e => e.ErrorMessage))}.");
|
|
||||||
} catch (ArticleMissingPermissionsException) {
|
|
||||||
return TypedResults.Unauthorized();
|
|
||||||
} catch (ArticleException ex) {
|
|
||||||
logger.LogError(ex, "Unexpected Article Error.");
|
|
||||||
return TypedResults.Problem();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -23,12 +23,12 @@ public class UserController(ImageService imageService, IDbContextFactory<Applica
|
||||||
var user = await context.Users.Include(u => u.ProfilePicture).FirstOrDefaultAsync(u => u.Id == userId);
|
var user = await context.Users.Include(u => u.ProfilePicture).FirstOrDefaultAsync(u => u.Id == userId);
|
||||||
if (user is null) return NotFound();
|
if (user is null) return NotFound();
|
||||||
if (user.ProfilePicture is null) {
|
if (user.ProfilePicture is null) {
|
||||||
return Redirect("/dist/img/default_avatar.jpg");
|
return Redirect("/img/default_avatar.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
string? path = ImageService.GetPath(user.ProfilePicture.ImageId);
|
string? path = ImageService.GetPath(user.ProfilePicture.ImageId);
|
||||||
if (path is null) {
|
if (path is null) {
|
||||||
return Redirect("/dist/img/default_avatar.jpg");
|
return Redirect("/img/default_avatar.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (size < 800) return File(await ImageService.GetResized(path, size), ImageService.ImageMimeType);
|
if (size < 800) return File(await ImageService.GetResized(path, size), ImageService.ImageMimeType);
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
using System.Text;
|
|
||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using Wave.Data.Transactional;
|
|
||||||
|
|
||||||
namespace Wave.Data;
|
namespace Wave.Data;
|
||||||
|
|
||||||
|
@ -133,74 +131,4 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
|
||||||
key.Ignore(k => k.Claims);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,116 +0,0 @@
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Wave.Data.Transactional;
|
|
||||||
using Wave.Utilities;
|
|
||||||
|
|
||||||
namespace Wave.Data;
|
|
||||||
|
|
||||||
public class ArticleException : ApplicationException;
|
|
||||||
public class ArticleNotFoundException : ArticleException;
|
|
||||||
public class ArticleMissingPermissionsException : ArticleException;
|
|
||||||
public class ArticleMalformedException : ArticleException {
|
|
||||||
public IReadOnlyCollection<ValidationResult> Errors { get; init; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adapter for ApplicationDbContext, that enforced valid data and the permission system
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="contextFactory"></param>
|
|
||||||
public class ApplicationRepository(IDbContextFactory<ApplicationDbContext> contextFactory) {
|
|
||||||
private IDbContextFactory<ApplicationDbContext> ContextFactory { get; } = contextFactory;
|
|
||||||
|
|
||||||
public async ValueTask<IReadOnlyCollection<Category>> GetCategories(ClaimsPrincipal user, bool includeUnusedCategories = false, CancellationToken cancellation = default) {
|
|
||||||
if (includeUnusedCategories && user.Identity?.IsAuthenticated is not true)
|
|
||||||
throw new ApplicationException("Trying to access unused articles with unauthenticated user");
|
|
||||||
|
|
||||||
await using var context = await ContextFactory.CreateDbContextAsync(cancellation);
|
|
||||||
var categories = await context.Set<Category>()
|
|
||||||
.IgnoreAutoIncludes()
|
|
||||||
.IgnoreQueryFilters()
|
|
||||||
.Where(c => includeUnusedCategories || c.Articles.Any())
|
|
||||||
.OrderBy(c => c.Color)
|
|
||||||
.ToListAsync(cancellation);
|
|
||||||
|
|
||||||
return new ReadOnlyCollection<Category>(categories);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<Article> GetArticleAsync(Guid id, ClaimsPrincipal user, CancellationToken cancellation = default) {
|
|
||||||
await using var context = await ContextFactory.CreateDbContextAsync(cancellation);
|
|
||||||
var article = await context.Set<Article>()
|
|
||||||
.IgnoreQueryFilters()
|
|
||||||
.IgnoreAutoIncludes()
|
|
||||||
.Include(a => a.Author)
|
|
||||||
.Include(a => a.Reviewer)
|
|
||||||
.Include(a => a.Categories)
|
|
||||||
.FirstOrDefaultAsync(a => a.Id == id, cancellation);
|
|
||||||
|
|
||||||
if (article is null)
|
|
||||||
throw new ArticleNotFoundException();
|
|
||||||
if (article.AllowedToRead(user))
|
|
||||||
return article;
|
|
||||||
|
|
||||||
throw new ArticleMissingPermissionsException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<Article> CreateArticleAsync(ArticleCreateDto dto, ClaimsPrincipal user,
|
|
||||||
CancellationToken cancellation = default) {
|
|
||||||
if (!Permissions.AllowedToCreate(user))
|
|
||||||
throw new ArticleMissingPermissionsException();
|
|
||||||
|
|
||||||
List<ValidationResult> results = [];
|
|
||||||
if (!Validator.TryValidateObject(dto, new ValidationContext(dto), results, true)) {
|
|
||||||
throw new ArticleMalformedException() {
|
|
||||||
Errors = results
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var context = await ContextFactory.CreateDbContextAsync(cancellation);
|
|
||||||
var appUser = await context.Users.FindAsync([user.FindFirstValue("Id")], cancellation);
|
|
||||||
|
|
||||||
var article = new Article {
|
|
||||||
Author = appUser ?? throw new ArticleException(),
|
|
||||||
Title = "",
|
|
||||||
Body = ""
|
|
||||||
};
|
|
||||||
await context.UpdateArticle(dto, article, cancellation);
|
|
||||||
await context.Set<Article>().AddAsync(article, cancellation);
|
|
||||||
await context.SaveChangesAsync(cancellation);
|
|
||||||
|
|
||||||
return article;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<Article> UpdateArticleAsync(ArticleUpdateDto dto, ClaimsPrincipal user,
|
|
||||||
CancellationToken cancellation = default) {
|
|
||||||
List<ValidationResult> results = [];
|
|
||||||
if (!Validator.TryValidateObject(dto, new ValidationContext(dto), results, true)) {
|
|
||||||
throw new ArticleMalformedException() {
|
|
||||||
Errors = results
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var context = await ContextFactory.CreateDbContextAsync(cancellation);
|
|
||||||
var article = await context.Set<Article>()
|
|
||||||
.IgnoreQueryFilters()
|
|
||||||
.Include(a => a.Author)
|
|
||||||
.FirstOrDefaultAsync(a => a.Id == dto.Id, cancellation);
|
|
||||||
if (article is null)
|
|
||||||
throw new ArticleNotFoundException();
|
|
||||||
if (!article.AllowedToEdit(user))
|
|
||||||
throw new ArticleMissingPermissionsException();
|
|
||||||
var appUser = await context.Users.FindAsync([user.FindFirstValue("Id")], cancellation);
|
|
||||||
if (appUser is null)
|
|
||||||
throw new ArticleException();
|
|
||||||
|
|
||||||
if (appUser.Id != article.Author.Id) {
|
|
||||||
article.Reviewer = appUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.UpdateArticle(dto, article, cancellation);
|
|
||||||
await context.SaveChangesAsync(cancellation);
|
|
||||||
|
|
||||||
return article;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,5 +1,4 @@
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Wave.Utilities;
|
using Wave.Utilities;
|
||||||
|
|
||||||
|
@ -61,32 +60,15 @@ public partial class Article : ISoftDelete {
|
||||||
|
|
||||||
public void UpdateSlug(string? potentialNewSlug = null) {
|
public void UpdateSlug(string? potentialNewSlug = null) {
|
||||||
if (!string.IsNullOrWhiteSpace(potentialNewSlug) && Uri.IsWellFormedUriString(potentialNewSlug, UriKind.Relative)) {
|
if (!string.IsNullOrWhiteSpace(potentialNewSlug) && Uri.IsWellFormedUriString(potentialNewSlug, UriKind.Relative)) {
|
||||||
if (potentialNewSlug.Length > 64) potentialNewSlug = potentialNewSlug[..64];
|
|
||||||
Slug = potentialNewSlug;
|
Slug = potentialNewSlug;
|
||||||
return;
|
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)];
|
baseSlug = baseSlug.ToLowerInvariant()[..Math.Min(64, baseSlug.Length)];
|
||||||
string slug = Uri.EscapeDataString(baseSlug);
|
string slug = Uri.EscapeDataString(baseSlug).Replace("-", "+").Replace("%20", "-");
|
||||||
|
|
||||||
// I hate my life
|
// I hate my life
|
||||||
int escapeTrimOvershoot = 0;
|
int escapeTrimOvershoot = 0;
|
||||||
|
|
|
@ -1,81 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
namespace Wave.Data.Transactional;
|
|
||||||
|
|
||||||
public sealed record CategoryView(Guid id, string Name, CategoryColors Color) {
|
|
||||||
public CategoryView(Category category) : this(
|
|
||||||
category.Id, category.Name, category.Color) {}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record ArticleView(
|
|
||||||
Guid Id,
|
|
||||||
string Title,
|
|
||||||
string Slug,
|
|
||||||
string BodyHtml,
|
|
||||||
string Body,
|
|
||||||
string BodyPlain,
|
|
||||||
ArticleStatus Status,
|
|
||||||
DateTimeOffset PublishDate,
|
|
||||||
IReadOnlyList<CategoryView> Categories)
|
|
||||||
{
|
|
||||||
public ArticleView(Article article) : this(
|
|
||||||
article.Id,
|
|
||||||
article.Title,
|
|
||||||
article.Slug,
|
|
||||||
article.BodyHtml,
|
|
||||||
article.Body,
|
|
||||||
article.BodyPlain,
|
|
||||||
article.Status,
|
|
||||||
article.PublishDate,
|
|
||||||
article.Categories.Select(c => new CategoryView(c)).ToList()) {}
|
|
||||||
}
|
|
|
@ -1,14 +1,10 @@
|
||||||
#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.
|
#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=
|
ARG BASE=8.0
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0$BASE AS base
|
FROM mcr.microsoft.com/dotnet/aspnet:$BASE AS base
|
||||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
|
||||||
RUN mkdir -p /app/files && chown app /app/files
|
RUN mkdir -p /app/files && chown app /app/files
|
||||||
RUN if command -v apt-get; then \
|
RUN mkdir -p /configuration && chown app /configuration
|
||||||
apt-get update && apt-get install -y curl; \
|
RUN apt-get update && apt-get install -y curl
|
||||||
else \
|
|
||||||
apk add --update curl icu-libs icu-data-full tzdata; \
|
|
||||||
fi
|
|
||||||
USER app
|
USER app
|
||||||
VOLUME /app/files
|
VOLUME /app/files
|
||||||
VOLUME /configuration
|
VOLUME /configuration
|
||||||
|
@ -17,25 +13,7 @@ EXPOSE 8080
|
||||||
HEALTHCHECK --start-period=5s --start-interval=15s --interval=30s --timeout=30s --retries=3 \
|
HEALTHCHECK --start-period=5s --start-interval=15s --interval=30s --timeout=30s --retries=3 \
|
||||||
CMD curl --fail http://localhost:8080/health || exit 1
|
CMD curl --fail http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
FROM node:20-alpine AS vite-build
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS 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 BUILD_CONFIGURATION=Release
|
||||||
ARG VERSION=0.0.1
|
ARG VERSION=0.0.1
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
@ -49,7 +27,6 @@ RUN dotnet build "./Wave.csproj" \
|
||||||
-p:Version="${VERSION}"
|
-p:Version="${VERSION}"
|
||||||
|
|
||||||
FROM build AS publish
|
FROM build AS publish
|
||||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
ARG VERSION=0.0.1
|
ARG VERSION=0.0.1
|
||||||
ARG VERSION_SUFFIX=
|
ARG VERSION_SUFFIX=
|
||||||
|
@ -61,7 +38,6 @@ RUN dotnet publish "./Wave.csproj" \
|
||||||
|
|
||||||
FROM base AS final
|
FROM base AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=vite-build /src/wwwroot ./wwwroot
|
|
||||||
COPY --from=publish /app/publish .
|
COPY --from=publish /app/publish .
|
||||||
COPY LICENSE .
|
COPY LICENSE .
|
||||||
ENTRYPOINT ["dotnet", "Wave.dll"]
|
ENTRYPOINT ["dotnet", "Wave.dll"]
|
||||||
|
|
|
@ -26,7 +26,6 @@
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Serilog.Events;
|
using Serilog.Events;
|
||||||
using Serilog.Sinks.Grafana.Loki;
|
using Serilog.Sinks.Grafana.Loki;
|
||||||
using Vite.AspNetCore;
|
|
||||||
using Wave.Utilities.Metrics;
|
using Wave.Utilities.Metrics;
|
||||||
|
|
||||||
#region Version Information
|
#region Version Information
|
||||||
|
@ -82,13 +81,6 @@
|
||||||
options.OutputFormatters.Add(new SyndicationFeedFormatter());
|
options.OutputFormatters.Add(new SyndicationFeedFormatter());
|
||||||
});
|
});
|
||||||
builder.Services.AddOutputCache();
|
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
|
#region Data Protection & Redis
|
||||||
|
|
||||||
|
@ -206,7 +198,6 @@
|
||||||
.AddSignInManager()
|
.AddSignInManager()
|
||||||
.AddDefaultTokenProviders()
|
.AddDefaultTokenProviders()
|
||||||
.AddClaimsPrincipalFactory<UserClaimsFactory>();
|
.AddClaimsPrincipalFactory<UserClaimsFactory>();
|
||||||
builder.Services.AddScoped<ApplicationRepository>();
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
@ -343,22 +334,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseAuthorization();
|
|
||||||
|
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|
||||||
app.MapHealthChecks("/health");
|
|
||||||
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
|
// Add additional endpoints required by the Identity /Account Razor components.
|
||||||
app.MapAdditionalIdentityEndpoints();
|
app.MapAdditionalIdentityEndpoints();
|
||||||
|
|
||||||
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment()) {
|
|
||||||
//app.UseWebSockets();
|
|
||||||
app.UseViteDevelopmentServer(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseOutputCache();
|
app.UseOutputCache();
|
||||||
|
|
||||||
app.UseRequestLocalization();
|
app.UseRequestLocalization();
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
@ -16,8 +16,6 @@ public static class Permissions {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (principal.Identity?.IsAuthenticated is false) return false;
|
|
||||||
|
|
||||||
// Admins always get access
|
// Admins always get access
|
||||||
if (principal.IsInRole("Admin")) {
|
if (principal.IsInRole("Admin")) {
|
||||||
return true;
|
return true;
|
||||||
|
@ -36,25 +34,8 @@ public static class Permissions {
|
||||||
return false;
|
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) {
|
public static bool AllowedToEdit(this Article? article, ClaimsPrincipal principal) {
|
||||||
if (article is null || article.IsDeleted) return false;
|
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.");
|
if (article.Author is null) throw new ArgumentException("Checking permissions without loading related Author.");
|
||||||
|
|
||||||
// Admins always can edit articles
|
// Admins always can edit articles
|
||||||
|
@ -89,14 +70,12 @@ public static class Permissions {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool AllowedToRejectReview(this Article? article, ClaimsPrincipal principal) {
|
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
|
// if you can publish it, you can reject it
|
||||||
return article?.Status is ArticleStatus.InReview && article.AllowedToPublish(principal);
|
return article?.Status is ArticleStatus.InReview && article.AllowedToPublish(principal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool AllowedToSubmitForReview(this Article? article, ClaimsPrincipal principal) {
|
public static bool AllowedToSubmitForReview(this Article? article, ClaimsPrincipal principal) {
|
||||||
if (article is null || article.IsDeleted) return false;
|
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.");
|
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)
|
// Draft articles can be submitted by their authors (admins can publish them anyway, no need to submit)
|
||||||
|
@ -109,7 +88,6 @@ public static class Permissions {
|
||||||
|
|
||||||
public static bool AllowedToPublish(this Article? article, ClaimsPrincipal principal) {
|
public static bool AllowedToPublish(this Article? article, ClaimsPrincipal principal) {
|
||||||
if (article is null || article.IsDeleted) return false;
|
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.");
|
if (article.Author is null) throw new ArgumentException("Checking permissions without loading related Author.");
|
||||||
|
|
||||||
// Admins can skip review and directly publish draft articles
|
// Admins can skip review and directly publish draft articles
|
||||||
|
@ -133,7 +111,6 @@ public static class Permissions {
|
||||||
|
|
||||||
public static bool AllowedToDelete(this Article? article, ClaimsPrincipal principal) {
|
public static bool AllowedToDelete(this Article? article, ClaimsPrincipal principal) {
|
||||||
if (article is null || article.IsDeleted) return false;
|
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.");
|
if (article.Author is null) throw new ArgumentException("Checking permissions without loading related Author.");
|
||||||
|
|
||||||
// Admins can delete articles whenever
|
// Admins can delete articles whenever
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
@ -9,29 +9,25 @@
|
||||||
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
|
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<None Include="wwwroot\featured.js" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AspNetCore.Authentication.ApiKey" Version="8.0.1" />
|
<PackageReference Include="AspNetCore.Authentication.ApiKey" Version="8.0.1" />
|
||||||
<PackageReference Include="CsvHelper" Version="32.0.3" />
|
<PackageReference Include="CsvHelper" Version="31.0.4" />
|
||||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||||
<PackageReference Include="Humanizer.Core.de" Version="2.14.1" />
|
<PackageReference Include="Humanizer.Core.de" Version="2.14.1" />
|
||||||
<PackageReference Include="Humanizer.Core.uk" 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="Magick.NET-Q8-AnyCPU" Version="13.5.0" />
|
||||||
<PackageReference Include="MailKit" Version="4.6.0" />
|
<PackageReference Include="MailKit" Version="4.3.0" />
|
||||||
<PackageReference Include="Markdig" Version="0.37.0" />
|
<PackageReference Include="Markdig" Version="0.36.2" />
|
||||||
<PackageReference Include="Markdown.ColorCode" Version="2.2.2" />
|
<PackageReference Include="Markdown.ColorCode" Version="2.2.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.2" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" Version="8.0.6" />
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" Version="8.0.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="8.0.6" />
|
<PackageReference Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="8.0.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.6" />
|
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.6" />
|
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.6" />
|
||||||
<PackageReference Include="Mjml.Net" Version="3.8.0" />
|
<PackageReference Include="Mjml.Net" Version="3.8.0" />
|
||||||
|
@ -46,7 +42,10 @@
|
||||||
<PackageReference Include="Serilog.Sinks.Grafana.Loki" Version="8.3.0" />
|
<PackageReference Include="Serilog.Sinks.Grafana.Loki" Version="8.3.0" />
|
||||||
<PackageReference Include="System.ServiceModel.Syndication" Version="8.0.0" />
|
<PackageReference Include="System.ServiceModel.Syndication" Version="8.0.0" />
|
||||||
<PackageReference Include="Tomlyn.Extensions.Configuration" Version="1.0.5" />
|
<PackageReference Include="Tomlyn.Extensions.Configuration" Version="1.0.5" />
|
||||||
<PackageReference Include="Vite.AspNetCore" Version="2.0.1" />
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="wwwroot\img\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -1,3 +1,2 @@
|
||||||
{
|
{
|
||||||
"DetailedErrors": true
|
|
||||||
}
|
}
|
BIN
Wave/package-lock.json
generated
|
@ -1,38 +1,18 @@
|
||||||
{
|
{
|
||||||
"name": "wave",
|
"name": "wave",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"scripts": {
|
||||||
"scripts": {
|
"css:build": "postcss -o wwwroot/css/main.min.css wwwroot/css/main.css"
|
||||||
"dev": "vite",
|
},
|
||||||
"check": "tsc",
|
"author": "Mia Rose Winter",
|
||||||
"build": "tsc && vite build",
|
"license": "MIT",
|
||||||
"preview": "vite preview"
|
"devDependencies": {
|
||||||
},
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"author": "Mia Rose Winter",
|
"autoprefixer": "^10.4.16",
|
||||||
"license": "MIT",
|
"cssnano": "^6.0.3",
|
||||||
"devDependencies": {
|
"daisyui": "^4.6.0",
|
||||||
"@fontsource/noto-sans-display": "^5.0.20",
|
"postcss": "^8.4.33",
|
||||||
"@fontsource/nunito-sans": "^5.0.13",
|
"postcss-cli": "^11.0.0",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"tailwindcss": "^3.4.1"
|
||||||
"@types/node": "^20.14.2",
|
}
|
||||||
"@types/react": "^18.3.3",
|
|
||||||
"@types/react-dom": "^18.3.0",
|
|
||||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
|
||||||
"autoprefixer": "^10.4.16",
|
|
||||||
"cssnano": "^6.0.3",
|
|
||||||
"daisyui": "^4.6.0",
|
|
||||||
"i18next": "^23.11.5",
|
|
||||||
"markdown-it": "^14.1.0",
|
|
||||||
"markdown-it-mark": "^4.0.0",
|
|
||||||
"postcss": "^8.4.33",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-i18next": "^14.1.2",
|
|
||||||
"tailwindcss": "^3.4.1",
|
|
||||||
"typescript": "^5.4.5",
|
|
||||||
"vite": "^5.2.13"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"groupby-polyfill": "^1.0.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
9
Wave/postcss.config.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
cssnano: {
|
||||||
|
preset: "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
const defaultTheme = require('tailwindcss/defaultTheme');
|
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
const defaultTheme = require('tailwindcss/defaultTheme')
|
||||||
content: ["Assets/**/*.{ts,tsx}", "Components/**/*.razor"],
|
|
||||||
|
module.exports = {
|
||||||
|
content: ["Pages/**/*.cshtml", "Components/**/*.razor"],
|
||||||
safelist: ["youtube"],
|
safelist: ["youtube"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
|
@ -1,25 +0,0 @@
|
||||||
{
|
|
||||||
"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" }
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowSyntheticDefaultImports": true
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"vite.config.ts"
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
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"}),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 5 KiB After Width: | Height: | Size: 5 KiB |
|
@ -1,64 +1,41 @@
|
||||||
@import "@fontsource/noto-sans-display";
|
html, body {
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
@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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@tailwind base;
|
a, .btn-link {
|
||||||
@tailwind components;
|
color: #006bb7;
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
body {
|
|
||||||
@apply font-body;
|
|
||||||
}
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
@apply font-heading tracking-tight;
|
|
||||||
}
|
|
||||||
hyphens-auto {
|
|
||||||
hyphenate-limit-chars: 5 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@layer components {
|
|
||||||
.youtube {
|
|
||||||
@apply rounded p-2 bg-base-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.narrow-reading-toggle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
body:has(label[for=narrow-reading-toggle]) input.narrow-reading-toggle:checked + .reading-toggle-target {
|
|
||||||
@apply max-w-3xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-away {
|
|
||||||
-webkit-mask-image: linear-gradient(black, black 80%, rgba(0, 0, 0, 0.5) 85%, transparent 100%);
|
|
||||||
mask-image: linear-gradient(black, black 80%, rgba(0, 0, 0, 0.5) 85%, transparent 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose div > pre, .prose > pre {
|
|
||||||
@apply bg-inherit text-inherit rounded-none;
|
|
||||||
}
|
|
||||||
.prose pre:has(code) {
|
|
||||||
@apply border-2 border-current;
|
|
||||||
}
|
|
||||||
|
|
||||||
.characters-left {
|
|
||||||
@apply absolute right-6 bottom-6 select-none pointer-events-none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #1b6ec2;
|
||||||
|
border-color: #1861ac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||||
|
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding-top: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
h1:focus {
|
h1:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.valid.modified:not([type=checkbox]) {
|
||||||
|
outline: 1px solid #26b050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid {
|
||||||
|
outline: 1px solid #e50000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-message {
|
||||||
|
color: #e50000;
|
||||||
|
}
|
||||||
|
|
||||||
.blazor-error-boundary {
|
.blazor-error-boundary {
|
||||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||||
padding: 1rem 1rem 1rem 3.7rem;
|
padding: 1rem 1rem 1rem 3.7rem;
|
||||||
|
@ -68,3 +45,7 @@ .blazor-error-boundary {
|
||||||
.blazor-error-boundary::after {
|
.blazor-error-boundary::after {
|
||||||
content: "An error has occurred."
|
content: "An error has occurred."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.darker-border-checkbox.form-check-input {
|
||||||
|
border-color: #929292;
|
||||||
|
}
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
Before Width: | Height: | Size: 6 KiB After Width: | Height: | Size: 6 KiB |
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.8 KiB |
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.8 KiB |
58
Wave/wwwroot/css/main.css
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
@font-face {
|
||||||
|
font-display: swap;
|
||||||
|
font-family: 'Nunito Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url('../fonts/nunito-sans-v15-latin-700.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-display: swap;
|
||||||
|
font-family: 'Noto Sans Display';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('../fonts/noto-sans-display-v26-latin-regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply font-body;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
@apply font-heading tracking-tight;
|
||||||
|
}
|
||||||
|
hyphens-auto {
|
||||||
|
hyphenate-limit-chars: 5 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@layer components {
|
||||||
|
.youtube {
|
||||||
|
@apply rounded p-2 bg-base-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.narrow-reading-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
body:has(label[for=narrow-reading-toggle]) input.narrow-reading-toggle:checked + .reading-toggle-target {
|
||||||
|
@apply max-w-3xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-away {
|
||||||
|
-webkit-mask-image: linear-gradient(black, black 80%, rgba(0, 0, 0, 0.5) 85%, transparent 100%);
|
||||||
|
mask-image: linear-gradient(black, black 80%, rgba(0, 0, 0, 0.5) 85%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose div > pre, .prose > pre {
|
||||||
|
@apply bg-inherit text-inherit rounded-none;
|
||||||
|
}
|
||||||
|
.prose pre:has(code) {
|
||||||
|
@apply border-2 border-current;
|
||||||
|
}
|
||||||
|
|
||||||
|
.characters-left {
|
||||||
|
@apply absolute right-6 bottom-6 select-none pointer-events-none;
|
||||||
|
}
|
||||||
|
}
|
3
Wave/wwwroot/css/main.min.css
vendored
Normal file
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 5 KiB After Width: | Height: | Size: 5 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
BIN
Wave/wwwroot/fonts/noto-sans-display-v26-latin-regular.woff2
Normal file
BIN
Wave/wwwroot/fonts/nunito-sans-v15-latin-700.woff2
Normal file
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
@ -3,7 +3,7 @@ version: '3.4'
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
environment:
|
environment:
|
||||||
- ASPNETCORE_HTTP_PORTS=8080
|
|
||||||
- ASPNETCORE_ENVIRONMENT=Development
|
- ASPNETCORE_ENVIRONMENT=Development
|
||||||
|
- ASPNETCORE_HTTP_PORTS=8080
|
||||||
volumes:
|
volumes:
|
||||||
- ${APPDATA}/Microsoft/UserSecrets:/home/app/.microsoft/usersecrets:ro
|
- ${APPDATA}/Microsoft/UserSecrets:/home/app/.microsoft/usersecrets:ro
|
|
@ -1,42 +1,23 @@
|
||||||
{
|
{
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"Docker Compose": {
|
"Docker Compose": {
|
||||||
"commandName": "DockerCompose",
|
"commandName": "DockerCompose",
|
||||||
"commandVersion": "1.0",
|
"commandVersion": "1.0",
|
||||||
"serviceActions": {
|
"serviceActions": {
|
||||||
"web": "StartDebugging",
|
"web": "StartDebugging",
|
||||||
"database": "StartWithoutDebugging",
|
"database": "StartWithoutDebugging",
|
||||||
"mailhog": "DoNotStart",
|
"mailhog": "DoNotStart",
|
||||||
"redis": "StartWithoutDebugging",
|
"redis": "StartWithoutDebugging"
|
||||||
"grafana": "StartWithoutDebugging",
|
}
|
||||||
"loki": "StartWithoutDebugging",
|
},
|
||||||
"prometheus": "StartWithoutDebugging"
|
"SMTP Debugging": {
|
||||||
}
|
"commandName": "DockerCompose",
|
||||||
},
|
"commandVersion": "1.0",
|
||||||
"SMTP Debugging": {
|
"composeProfile": {
|
||||||
"commandName": "DockerCompose",
|
"includes": [
|
||||||
"commandVersion": "1.0",
|
"smtp-debug"
|
||||||
"composeProfile": {
|
]
|
||||||
"includes": [
|
}
|
||||||
"smtp-debug"
|
}
|
||||||
]
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
"Production": {
|
|
||||||
"commandName": "DockerCompose",
|
|
||||||
"commandVersion": "1.0",
|
|
||||||
"serviceActions": {
|
|
||||||
"database": "StartWithoutDebugging",
|
|
||||||
"grafana": "DoNotStart",
|
|
||||||
"loki": "DoNotStart",
|
|
||||||
"mailhog": "DoNotStart",
|
|
||||||
"prometheus": "DoNotStart",
|
|
||||||
"redis": "StartWithoutDebugging",
|
|
||||||
"web": "StartDebugging"
|
|
||||||
},
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Production"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|