Implemented client side article editor (#6)

* started implementing article API, missing lots of tests to validate feature

* made tests more pretty

* re-structured tests

* refactored dto contracts

* tested and fixed updating categories

* added permission tests, fixed bug in Permissions system

* added data validation tests for update article

* refactored repository interface

* Added ArticleView dto, fixed bug in requesting articles over repository

* updated dependencies

* optimized program.cs, added repo service

* Removed all interactivity from ArticleEditor, merged files

* added vite, tailwind working, dev server is not, js is not yet

* added fontsource for font management using vite's bundling

* moved vite output to wwwroot/dist
reorganized stuff that will never need processing or needs to be at site root

* fixed heading font weight not being 700 anymore

* implemented react in ArticleEditor

* added article status steps to react component
noticed I need to figure out client side localization

* fixed vite dev server thingies, tailwind and react refresh works now

* added article form skeletton to react

* more editor implementations

* minor typescript fixes

* implemented proper editor functions

* added all missing toolbar buttons

* fixed error, made open article work

* improved article editor structure

* implemented article editor taking id from the url

* Implemented categories endpoint

* implemented categories in article editor

* fixed minor TS issues

* implemented localization in article editor

* completed localization

* implemented loading selected categories

* minor code improvements and maybe a regex fix

* fixed bug with not getting unpublished articles

* implemented form state

* fixed validation issues

* implemented saving (missing creation)

* fixed minor bug with status display

* organized models

* added live markdown preview (incomplete)

* fixed issues in article create api endpoint

* improved article saving, implemented creating

* fixed publish date not being set correctly when creating article

* fixed slugs once more

* added run config for production (without vite dev)

* removed unused code

* updated dockerfile to build Assets

* fixed slug generation

* updated tests to validate new slug generator

* savsdSACAVSD

* fixed validation issues and tests
This commit is contained in:
Mia Rose Winter 2024-06-18 09:09:47 +02:00 committed by GitHub
parent 5c62ee4077
commit 1d55ab23f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 1903 additions and 590 deletions

View file

@ -1,34 +1,12 @@
using Microsoft.EntityFrameworkCore;
using Testcontainers.PostgreSql;
using Wave.Data;
using Wave.Tests.TestUtilities;
namespace Wave.Tests.Data;
[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
[TestOf(typeof(ApplicationDbContext))]
public class ApplicationDbContextTest {
private PostgreSqlContainer PostgresContainer { get; } = new PostgreSqlBuilder().WithImage("postgres:16.1-alpine").Build();
[SetUp]
public async Task SetUp() {
await PostgresContainer.StartAsync();
}
[TearDown]
public async Task TearDown() {
await PostgresContainer.DisposeAsync();
}
private ApplicationDbContext GetContext() {
return new ApplicationDbContext(
new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(PostgresContainer.GetConnectionString())
.EnableSensitiveDataLogging()
.EnableDetailedErrors()
.EnableThreadSafetyChecks()
.Options);
}
public class ApplicationDbContextTest : DbContextTest {
[Test]
public async Task Migration() {
await using var context = GetContext();

View file

@ -0,0 +1,353 @@
using System.Security.Claims;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Wave.Data;
using Wave.Data.Transactional;
using Wave.Tests.TestUtilities;
// ReSharper disable InconsistentNaming
namespace Wave.Tests.Data;
public abstract class ApplicationRepositoryTests : DbContextTest {
protected ApplicationRepository Repository { get; set; } = null!;
protected const string TestUserName = "testuser@example.com";
protected const string AuthorUserName = "author@example.com";
protected const string ReviewerUserName = "reviewer@example.com";
protected ClaimsPrincipal AnonymousPrincipal { get; set; } = null!;
protected ClaimsPrincipal UserPrincipal { get; set; } = null!;
protected ClaimsPrincipal AuthorPrincipal { get; set; } = null!;
protected ClaimsPrincipal ReviewerPrincipal { get; set; } = null!;
protected Guid PrimaryCategoryId { get; set; }
protected Guid SecondaryCategoryId { get; set; }
protected virtual ValueTask InitializeTestEntities(ApplicationDbContext context) {
return ValueTask.CompletedTask;
}
protected override async ValueTask AndThenSetUp() {
Repository = new ApplicationRepository(new MockDbContextFactory(GetContext));
List<Category> categories = [
new Category {
Name = "Primary Category",
Color = CategoryColors.Primary
},
new Category {
Name = "Secondary Category",
Color = CategoryColors.Secondary
}
];
await using var context = GetContext();
var user = new ApplicationUser {
UserName = TestUserName
};
var author = new ApplicationUser {
UserName = AuthorUserName
};
var reviewer = new ApplicationUser {
UserName = ReviewerUserName
};
context.AddRange(categories);
context.Users.AddRange([user, author, reviewer]);
await context.Database.EnsureCreatedAsync();
await context.SaveChangesAsync();
AnonymousPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
UserPrincipal = new ClaimsPrincipal(new ClaimsIdentity([
new Claim(ClaimTypes.Name, user.UserName),
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim("Id", user.Id),
], "Mock Authentication"));
AuthorPrincipal = new ClaimsPrincipal(new ClaimsIdentity([
new Claim(ClaimTypes.Name, author.UserName),
new Claim(ClaimTypes.NameIdentifier, author.Id),
new Claim("Id", author.Id),
new Claim(ClaimTypes.Role, "Author"),
], "Mock Authentication"));
ReviewerPrincipal = new ClaimsPrincipal(new ClaimsIdentity([
new Claim(ClaimTypes.Name, reviewer.UserName),
new Claim(ClaimTypes.NameIdentifier, reviewer.Id),
new Claim("Id", reviewer.Id),
new Claim(ClaimTypes.Role, "Author"),
new Claim(ClaimTypes.Role, "Reviewer"),
], "Mock Authentication"));
PrimaryCategoryId = categories[0].Id;
SecondaryCategoryId = categories[1].Id;
await InitializeTestEntities(context);
}
private sealed class MockDbContextFactory(Func<ApplicationDbContext> supplier)
: IDbContextFactory<ApplicationDbContext> {
public ApplicationDbContext CreateDbContext() => supplier();
}
}
[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
[TestOf(typeof(ApplicationRepository))]
public class ApplicationRepositoryTest_GetCategories : ApplicationRepositoryTests {
#region Success Tests
[Test]
public async Task AnonymousDefaultOneArticleOneCategory_Success() {
await Repository.CreateArticleAsync(
new ArticleCreateDto("test", "*test*", null, null, [PrimaryCategoryId], null), AuthorPrincipal);
var result = await Repository.GetCategories(AnonymousPrincipal);
Assert.Multiple(() => {
Assert.That(result, Is.Not.Empty);
Assert.That(result.First().Id, Is.EqualTo(PrimaryCategoryId));
Assert.That(result, Has.Count.EqualTo(1));
});
}
#endregion
#region Permission Tests
[Test]
public async Task AnonymousDefaultNoArticles_Success() {
var result = await Repository.GetCategories(AnonymousPrincipal);
Assert.Multiple(() => { Assert.That(result, Is.Empty); });
}
[Test]
public void AnonymousNoArticlesAllCategories_ThrowsMissingPermissions() {
Assert.ThrowsAsync<ApplicationException>(async () => await Repository.GetCategories(AnonymousPrincipal, true));
}
[Test]
public async Task AuthorDefaultNoArticles_Success() {
var result = await Repository.GetCategories(AuthorPrincipal);
Assert.Multiple(() => { Assert.That(result, Is.Empty); });
}
[Test]
public async Task AuthorDefaultNoArticlesAllCategories_Success() {
var result = await Repository.GetCategories(AuthorPrincipal, true);
Assert.Multiple(() => {
Assert.That(result, Is.Not.Empty);
Assert.That(result, Has.Count.EqualTo(2));
Assert.That(result.FirstOrDefault(c => c.Id == PrimaryCategoryId), Is.Not.Null);
Assert.That(result.FirstOrDefault(c => c.Id == SecondaryCategoryId), Is.Not.Null);
});
}
#endregion
}
[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
[TestOf(typeof(ApplicationRepository))]
public class ApplicationRepositoryTest_CreateArticle : ApplicationRepositoryTests {
private static ArticleCreateDto GetValidTestArticle(Guid[]? categories = null) {
return new ArticleCreateDto(
"Test Article",
"*Test* Body",
null, null, categories, null);
}
#region Success Tests
[Test]
public async Task MinimalArticle_Success() {
var article = GetValidTestArticle();
var view = await Repository.CreateArticleAsync(article, AuthorPrincipal);
await using var context = GetContext();
Assert.Multiple(() => {
Assert.That(context.Set<Article>().IgnoreQueryFilters().ToList(), Has.Count.EqualTo(1));
Assert.That(context.Set<Article>().IgnoreQueryFilters().First().Id, Is.EqualTo(view.Id));
Assert.That(view.Status, Is.EqualTo(ArticleStatus.Draft));
Assert.That(view.BodyHtml, Is.Not.Null);
Assert.That(view.BodyPlain, Is.Not.Null);
Assert.That(view.Slug, Is.EqualTo("test-article"));
});
}
[Test]
public async Task WithCategories_Success() {
var article = GetValidTestArticle([PrimaryCategoryId]);
var view = await Repository.CreateArticleAsync(article, AuthorPrincipal);
await using var context = GetContext();
Assert.Multiple(() => {
Assert.That(view.Categories, Has.Count.EqualTo(1));
Assert.That(context.Set<Article>().IgnoreQueryFilters()
.Include(a => a.Categories)
.First(a => a.Id == view.Id).Categories.First().Id, Is.EqualTo(PrimaryCategoryId));
});
}
#endregion
#region Permission Tests
[Test]
public void RegularUser_ThrowsMissingPermissions() {
var article = GetValidTestArticle();
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
async () => await Repository.CreateArticleAsync(article, UserPrincipal));
}
[Test]
public void AnonymousUser_ThrowsMissingPermissions() {
var article = GetValidTestArticle();
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
async () => await Repository.CreateArticleAsync(article, AnonymousPrincipal));
}
#endregion
#region Data Validation Tests
[Test]
public void MissingTitle_ThrowsMalformed() {
var article = new ArticleCreateDto(null!, "test", null, null, null, null);
Assert.ThrowsAsync<ArticleMalformedException>(
async () => await Repository.CreateArticleAsync(article, AuthorPrincipal));
}
#endregion
}
[TestFixture, FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
[TestOf(typeof(ApplicationRepository))]
public class ApplicationRepositoryTest_UpdateArticle : ApplicationRepositoryTests {
private Guid TestArticleId { get; set; }
private ArticleUpdateDto GetValidTestArticle() => new(TestArticleId);
private static string StringOfLength(int length) {
var builder = new StringBuilder();
for (int i = 0; i < length; i++) {
builder.Append('_');
}
return builder.ToString();
}
protected override async ValueTask InitializeTestEntities(ApplicationDbContext context) {
var testArticle = new ArticleCreateDto(
"Test Article",
"Test **Article** with *formatting.",
"test-article",
DateTimeOffset.Now.AddHours(-5),
[PrimaryCategoryId], null);
var view = await Repository.CreateArticleAsync(testArticle, AuthorPrincipal);
TestArticleId = view.Id;
}
#region Success Tests
[Test]
public async Task UpdateTitle_Success() {
var update = new ArticleUpdateDto(TestArticleId, "New Title");
await Repository.UpdateArticleAsync(update, AuthorPrincipal);
await using var context = GetContext();
Assert.Multiple(() => {
Assert.That(context.Set<Article>().IgnoreQueryFilters().First(a => a.Id == TestArticleId).Title,
Is.EqualTo("New Title"));
});
}
[Test]
public async Task UpdateBodyUpdatesHtmlAndPlain_Success() {
var update = new ArticleUpdateDto(TestArticleId, body:"Some *new* Body");
const string expectedHtml = "<p>Some <em>new</em> Body</p>";
const string expectedPlain = "Some new Body";
await Repository.UpdateArticleAsync(update, AuthorPrincipal);
await using var context = GetContext();
Assert.Multiple(() => {
var article = context.Set<Article>().IgnoreQueryFilters().First(a => a.Id == TestArticleId);
Assert.That(article.BodyHtml, Is.EqualTo(expectedHtml));
Assert.That(article.BodyPlain, Is.EqualTo(expectedPlain));
});
}
[Test]
public async Task UpdateCategories_Success() {
var update = new ArticleUpdateDto(TestArticleId, categories:[SecondaryCategoryId]);
await Repository.UpdateArticleAsync(update, AuthorPrincipal);
await using var context = GetContext();
Assert.Multiple(() => {
var article = context.Set<Article>().IgnoreQueryFilters()
.Include(a => a.Categories).First(a => a.Id == TestArticleId);
Assert.That(article.Categories, Has.Count.EqualTo(1));
Assert.That(article.Categories.First().Id, Is.EqualTo(SecondaryCategoryId));
});
}
#endregion
#region Permission Tests
[Test]
public void AnonymousUser_ThrowsMissingPermissions() {
var update = GetValidTestArticle();
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
async () => await Repository.UpdateArticleAsync(update, AnonymousPrincipal));
}
[Test]
public void RegularUser_ThrowsMissingPermissions() {
var update = GetValidTestArticle();
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
async () => await Repository.UpdateArticleAsync(update, UserPrincipal));
}
[Test]
public void UnrelatedAuthor_ThrowsMissingPermissions() {
var update = GetValidTestArticle();
Assert.ThrowsAsync<ArticleMissingPermissionsException>(
async () => await Repository.UpdateArticleAsync(update, ReviewerPrincipal));
}
#endregion
#region Data Validation Tests
[Test]
public void SlugLength65_ThrowsMalformed() {
var update = new ArticleUpdateDto(TestArticleId, slug:StringOfLength(65));
Assert.ThrowsAsync<ArticleMalformedException>(
async () => await Repository.UpdateArticleAsync(update, AuthorPrincipal));
}
[Test]
public void TitleLength257_ThrowsMalformed() {
var update = new ArticleUpdateDto(TestArticleId, slug:StringOfLength(257));
Assert.ThrowsAsync<ArticleMalformedException>(
async () => await Repository.UpdateArticleAsync(update, AuthorPrincipal));
}
#endregion
}

View file

@ -22,7 +22,7 @@ public class ArticleTest {
public void SlugWithSpecialCharacters() {
Article.Title = "Title with, special characters?";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("title-with%2C-special-characters%3F"));
Assert.That(Article.Slug, Is.EqualTo("title-with-special-characters"));
}
[Test]
@ -36,14 +36,14 @@ public class ArticleTest {
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterEscapeSize3AtPosition55() {
Article.Title = "Auto generating slugs was a mistake I hate this ______ €";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-______-%E2%82%AC"));
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-______-"));
}
[Test]
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterEscapeSize2AtPosition56() {
Article.Title = "Auto generating slugs was a mistake I hate this _______ üa";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-_______-%C3%BCa"));
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-_______-a"));
}
[Test]
@ -57,7 +57,7 @@ public class ArticleTest {
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterEscapeSize2AtPosition57() {
Article.Title = "Auto generating slugs was a mistake I hate this ________ üa";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-________-%C3%BCa"));
Assert.That(Article.Slug, Is.EqualTo("auto-generating-slugs-was-a-mistake-i-hate-this-________-a"));
}
[Test]
@ -71,21 +71,21 @@ public class ArticleTest {
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterAtPosition61() {
Article.Title = "Article that ends with a special character and need special cäre";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-need-special-c"));
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-need-special-cre"));
}
[Test]
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterAtPosition62() {
Article.Title = "Article that ends with a special character and needs special cäre";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-needs-special-c"));
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-needs-special-cre"));
}
[Test]
public void SlugFromTitleLongerThan64CharacterWithSpecialCharacterAtPosition63() {
Article.Title = "Article that ends with a special character and needs special caäre";
Article.UpdateSlug();
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-needs-special-ca"));
Assert.That(Article.Slug, Is.EqualTo("article-that-ends-with-a-special-character-and-needs-special-car"));
}
[Test]

View file

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Testcontainers.PostgreSql;
using Wave.Data;
namespace Wave.Tests.TestUtilities;
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
public abstract class DbContextTest {
private PostgreSqlContainer PostgresContainer { get; } = new PostgreSqlBuilder().WithImage("postgres:16.1-alpine").Build();
[SetUp]
public async Task SetUp() {
await PostgresContainer.StartAsync();
await AndThenSetUp();
}
protected virtual ValueTask AndThenSetUp() {
return ValueTask.CompletedTask;
}
[TearDown]
public async Task TearDown() => await PostgresContainer.DisposeAsync();
protected ApplicationDbContext GetContext() =>
new(new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(PostgresContainer.GetConnectionString())
.EnableSensitiveDataLogging()
.EnableDetailedErrors()
.EnableThreadSafetyChecks()
.Options);
}

View file

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

View file

@ -0,0 +1,382 @@
import React, { useState, useEffect, useRef } from "react";
import { updateCharactersLeft, insertBeforeSelection, insertBeforeAndAfterSelection } from "../utilities/md_functions";
import { LabelInput, ToolBarButton } from "./Forms";
import { CategoryColor, Category, ArticleStatus, ArticleView, ArticleDto } from "../model/Models";
import { useTranslation } from 'react-i18next';
import markdownit from "markdown-it";
import markdownitmark from "markdown-it-mark";
import "groupby-polyfill/lib/polyfill.js";
const nameof = function<T>(name: keyof T) { return name; }
async function get<T>(url: string): Promise<T> {
let response = await fetch(url, {
method: "GET"
});
if (!response.ok)
throw new Error(response.statusText);
return response.json();
}
async function post<T, J>(url: string, data: T) : Promise<J> {
let response = await fetch(url, {
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json"
}
});
if (!response.ok)
throw new Error(response.statusText);
return response.json();
}
async function put<T, J>(url: string, data: T) : Promise<J> {
let response = await fetch(url, {
method: "PUT",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json"
}
});
if (!response.ok)
throw new Error(response.statusText);
return response.json();
}
function Loading(message: string) {
return <div className="grid place-items-center h-64">
<div className="flex flex-col place-items-center">
<p>{message}</p>
<span className="loading loading-bars loading-lg"></span>
</div>
</div>
}
export default function Editor() {
const {t} = useTranslation();
const [notice, setNotice] = useState<string>("");
const [dirty, setDirty] = useState(false);
const [isPublished, setIsPublished] = useState(false);
const [article, setArticle] = useState<ArticleView|null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [model, setModel] = useState<ArticleDto>({
body: "",
categories: [],
id: "",
publishDate: new Date(),
slug: "",
title: ""
});
const md = markdownit({
html: false,
linkify: true,
typographer: true,
})
.use(markdownitmark)
;
function onChangeModel(event: React.ChangeEvent) {
const {name, value} = event.target as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement);
if (name == nameof<ArticleDto>("publishDate")) {
setModel((m : ArticleDto) => ({...m, publishDate: new Date(value)}));
} else if (event.target instanceof HTMLSelectElement) {
const select = event.target as HTMLSelectElement;
setModel((m : ArticleDto) => ({...m, [name]: [...select.selectedOptions].map(o => o.value)}));
} else {
setModel((m : ArticleDto) => ({...m, [name]: value}));
}
setDirty(true);
}
function onSubmit(event: React.FormEvent) {
event.preventDefault();
if (!article || !article.id || article.id.length < 1) {
put<ArticleDto, ArticleView>("/api/article", model)
.then(result => {
// we just reload the editor, to make sure everything is updated properly
window.location.pathname = `/article/${result.id}/edit`;
}).catch(err => setNotice(`Error creating article: ${err}`));
} else {
post<ArticleDto, ArticleView>("/api/article", model)
.then(result => {
setDirty(false)
setArticle(result)
if (model.slug != result.slug) {
setModel(m => ({...m, slug: result.slug}));
}
})
.catch(err => setNotice(`Error trying to save article: ${err}`));
}
}
const location = window.location.pathname;
useEffect(() => {
if (categories.length < 1) {
get<Category[]>("/api/categories?all=true").then(result => {
setCategories(result);
}).catch(error => {
setNotice(`Error loading Categories: ${error.Message}`);
console.log(`Error loading Categories: ${error.message}`);
});
}
const id = location.match(/article\/([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})\/edit/i);
if (!id) {
const publishDate = new Date();
publishDate.setDate(publishDate.getDate() + 7);
const article : ArticleView = {
body: "",
id: "",
publishDate: publishDate.toString(),
slug: "",
status: 0,
title: "",
categories: []
};
setArticle(article);
setModel(m => ({...m, publishDate: publishDate}));
} else if (!article) {
get<ArticleView>(`/api/article/${id[1]}`)
.then(result => {
setNotice("");
setIsPublished(result.status >= ArticleStatus.Published && (new Date(result.publishDate) <= new Date()));
setModel(m => ({
...m,
id: result.id,
title: result.title,
slug: result.slug,
body: result.body,
publishDate: new Date(result.publishDate),
categories: result.categories.map(c => c.id),
}));
setArticle(result);
console.log("Article loaded");
})
.catch(error => {
setNotice(`Error loading Article: ${error.message}`);
console.log(`Error loading Article: ${error.message}`);
setArticle(null);
});
}
}, ([setArticle, setNotice, console, location]) as any[]);
const markdownArea = useRef<HTMLTextAreaElement>(null);
return (
<>
{
dirty &&
<div role="alert" className="alert alert-warning sticky left-4 right-4 top-4 mb-4 z-50 rounded-sm">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="size-6">
<path fillRule="evenodd" clipRule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" />
</svg>
<p>{t("editor.unsaved_changes_notice")}</p>
</div>
}
{
notice.length > 0 &&
<div role="alert" className="alert alert-error sticky left-4 right-4 top-4 mb-4 z-50 rounded-sm">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="size-6">
<path fillRule="evenodd" clipRule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" />
</svg>
<p>{notice}</p>
</div>
}
{
article === null ?
Loading(t("loading.article")) :
<>
<div className="w-full">
<ul className="steps steps-vertical md:steps-horizontal">
<li className={`step w-24 ${(article.status ?? -1) >= ArticleStatus.Draft ? "step-primary" : ""}`}>{t("Draft")}</li>
<li className={`step w-24 ${(article.status ?? -1) >= ArticleStatus.InReview ? "step-primary" : ""}`}>{t("InReview")}</li>
<li className={`step w-24 ${article.status === ArticleStatus.Published ? "step-primary" : ""}`}>{t("Published")}</li>
</ul>
<form method="post" onSubmit={onSubmit}>
<fieldset className="grid grid-cols-1 lg:grid-cols-2 gap-x-8">
<LabelInput label={t("Title_Label")}>
<input className="input input-bordered w-full"
maxLength={256} required aria-required autoComplete="off"
onInput={(event: React.FormEvent<HTMLInputElement>) => updateCharactersLeft(event.target as HTMLInputElement)}
placeholder={t("Title_Placeholder")}
name={nameof<ArticleDto>("title")} value={model.title} onChange={onChangeModel} />
</LabelInput>
<LabelInput label={t("Category_Label")}
className="row-span-3 order-first md:order-none">
<select className="select select-bordered w-full" size={10} multiple={true}
onChange={onChangeModel} name={nameof<ArticleDto>("categories")}
defaultValue={article.categories.map(c => c.id)}>
{
Array.from(Map.groupBy(categories, (c: Category) => c.color) as Map<CategoryColor, Category[]>)
.map((value, _) =>
<optgroup className="font-bold not-italic my-3"
label={t(`Category.${CategoryColor[value[0]]}`)}>
{value[1].map(c => <option key={c.id} value={c.id}>{c.name ?? "err"}</option>)}
</optgroup>)
}
</select>
</LabelInput>
<fieldset disabled={isPublished} title={isPublished ? t("article_published_cant_edit_date_or_slug_tooltip") : undefined}>
<LabelInput label={t("Slug_Label")}>
<input className="input input-bordered w-full"
maxLength={64} autoComplete="off"
onInput={(event: React.FormEvent<HTMLInputElement>) => updateCharactersLeft(event.target as HTMLInputElement)}
placeholder={t("Slug_Placeholder")}
name={nameof<ArticleDto>("slug")} value={model.slug} onChange={onChangeModel} />
</LabelInput>
<LabelInput label={t("PublishDate_Label")}>
<input className="input input-bordered w-full"
type="datetime-local" autoComplete="off"
name={nameof<ArticleDto>("publishDate")}
defaultValue={(new Date(article.publishDate).toLocaleString("sv", {timeZoneName: "short"}).substring(0, 16))}
onChange={onChangeModel} />
</LabelInput>
</fieldset>
</fieldset>
<fieldset className="my-6 grid grid-cols-1 lg:grid-cols-2 gap-x-8 gap-y-4">
<div className="join join-vertical min-h-96 h-full w-full">
<div className="flex flex-wrap gap-1 p-2 z-50 bg-base-200 sticky top-0"
role="toolbar">
<div className="join join-horizontal">
<ToolBarButton title={t("Tools.H1_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "# ", true)}>
<strong>{t("Tools.H1_Label")}</strong>
</ToolBarButton>
<ToolBarButton title={t("Tools.H2_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "## ", true)}>
<strong>{t("Tools.H2_Label")}</strong>
</ToolBarButton>
<ToolBarButton title={t("Tools.H3_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "### ", true)}>
<strong>{t("Tools.H3_Label")}</strong>
</ToolBarButton>
<ToolBarButton title={t("Tools.H4_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "#### ", true)}>
<strong>{t("Tools.H4_Label")}</strong>
</ToolBarButton>
</div>
<div className="join join-horizontal">
<ToolBarButton title={t("Tools.Bold_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "**")}>
<strong>B</strong>
</ToolBarButton>
<ToolBarButton title={t("Tools.Italic_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "*")}>
<em>I</em>
</ToolBarButton>
<ToolBarButton title={t("Tools.Underline_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "++")}>
<span className="underline">U</span>
</ToolBarButton>
<ToolBarButton title={t("Tools.StrikeThrough_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "~~")}>
<del>{t("Tools.StrikeThrough_Label")}</del>
</ToolBarButton>
<ToolBarButton title={t("Tools.Mark_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "==")}>
<mark>{t("Tools.Mark_Label")}</mark>
</ToolBarButton>
<ToolBarButton title={t("Tools.Mark_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "> ", true)}>
| <em>{t("Tools.Cite_Label")}</em>
</ToolBarButton>
</div>
<div className="join join-horizontal">
<ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "1. ", true)}>
1.
</ToolBarButton>
<ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "a. ", true)}>
a.
</ToolBarButton>
<ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "A. ", true)}>
A.
</ToolBarButton>
<ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "i. ", true)}>
i.
</ToolBarButton>
<ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "I. ", true)}>
I.
</ToolBarButton>
</div>
<div className="join join-horizontal">
<ToolBarButton title={t("Tools.CodeLine_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "`")}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4">
<path fillRule="evenodd"
d="M14.447 3.026a.75.75 0 0 1 .527.921l-4.5 16.5a.75.75 0 0 1-1.448-.394l4.5-16.5a.75.75 0 0 1 .921-.527ZM16.72 6.22a.75.75 0 0 1 1.06 0l5.25 5.25a.75.75 0 0 1 0 1.06l-5.25 5.25a.75.75 0 1 1-1.06-1.06L21.44 12l-4.72-4.72a.75.75 0 0 1 0-1.06Zm-9.44 0a.75.75 0 0 1 0 1.06L2.56 12l4.72 4.72a.75.75 0 0 1-1.06 1.06L.97 12.53a.75.75 0 0 1 0-1.06l5.25-5.25a.75.75 0 0 1 1.06 0Z"
clipRule="evenodd"/>
</svg>
</ToolBarButton>
<ToolBarButton title={t("Tools.CodeBlock_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "```")}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4">
<path fillRule="evenodd"
d="M3 6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6Zm14.25 6a.75.75 0 0 1-.22.53l-2.25 2.25a.75.75 0 1 1-1.06-1.06L15.44 12l-1.72-1.72a.75.75 0 1 1 1.06-1.06l2.25 2.25c.141.14.22.331.22.53Zm-10.28-.53a.75.75 0 0 0 0 1.06l2.25 2.25a.75.75 0 1 0 1.06-1.06L8.56 12l1.72-1.72a.75.75 0 1 0-1.06-1.06l-2.25 2.25Z"
clipRule="evenodd"/>
</svg>
</ToolBarButton>
</div>
</div>
<textarea ref={markdownArea} id="tool-target"
className="resize-none textarea textarea-bordered outline-none w-full flex-1 join-item"
required aria-required placeholder={t("Body_Placeholder")}
autoComplete="off"
name={nameof<ArticleDto>("body")} value={model.body} onChange={onChangeModel}/>
</div>
<div className="bg-base-200 p-2">
<h2 className="text-2xl lg:text-4xl font-bold mb-6 hyphens-auto">
{model.title.length < 1 ? t("Title_Placeholder") : model.title}
</h2>
{
model.body.length < 1 ?
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-32 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
</div> :
<div className="prose prose-neutral max-w-none hyphens-auto text-justify"
dangerouslySetInnerHTML={{__html: md.render(model.body)}}>
</div>
}
</div>
</fieldset>
<div className="flex gap-2 flex-wrap mt-3">
<button type="submit" className="btn btn-primary w-full sm:btn-wide"
disabled={!dirty}>
{t("EditorSubmit")}
</button>
<a className="btn w-full sm:btn-wide"
href={`/article/${article?.id}`} // TODO disable when article not exists
>
{t("ViewArticle_Label")}
</a>
</div>
</form>
</div>
</>
}
</>
);
}

View file

@ -0,0 +1,22 @@
import React from "react";
interface ILabelProperties {
label: string,
className?: string,
children: React.ReactNode,
}
export function LabelInput({label, className, children} : ILabelProperties) : React.ReactElement {
return <label className={`form-control w-full ${className ?? ""}`}>
<div className="label">{label}</div>
{children}
</label>;
}
export function ToolBarButton({title, onClick, children}: {title?: string, onClick:React.MouseEventHandler<HTMLButtonElement>, children:any}) {
return <button type="button" className="btn btn-accent btn-sm outline-none font-normal join-item"
title={title}
onClick={onClick}>
{children ?? "err"}
</button>;
}

View file

@ -1,41 +1,64 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
@import "@fontsource/noto-sans-display";
@font-face {
font-display: block;
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 700;
src:
url('@fontsource/nunito-sans/files/nunito-sans-latin-700-normal.woff') format('woff'),
url('@fontsource/nunito-sans/files/nunito-sans-latin-700-normal.woff2') format('woff2');
}
a, .btn-link {
color: #006bb7;
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply font-body;
}
h1, h2, h3, h4, h5, h6 {
@apply font-heading tracking-tight;
}
hyphens-auto {
hyphenate-limit-chars: 5 3;
}
}
@layer components {
.youtube {
@apply rounded p-2 bg-base-200;
}
input.narrow-reading-toggle {
display: none;
}
body:has(label[for=narrow-reading-toggle]) input.narrow-reading-toggle:checked + .reading-toggle-target {
@apply max-w-3xl;
}
.fade-away {
-webkit-mask-image: linear-gradient(black, black 80%, rgba(0, 0, 0, 0.5) 85%, transparent 100%);
mask-image: linear-gradient(black, black 80%, rgba(0, 0, 0, 0.5) 85%, transparent 100%);
}
.prose div > pre, .prose > pre {
@apply bg-inherit text-inherit rounded-none;
}
.prose pre:has(code) {
@apply border-2 border-current;
}
.characters-left {
@apply absolute right-6 bottom-6 select-none pointer-events-none;
}
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
@ -44,8 +67,4 @@ .blazor-error-boundary {
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
}

160
Wave/Assets/main.tsx Normal file
View file

@ -0,0 +1,160 @@
import "vite/modulepreload-polyfill";
import i18n, { LanguageDetectorModule } from 'i18next';
import { initReactI18next } from 'react-i18next';
import { createRoot } from "react-dom/client";
import Editor from "./React/ArticleEditor";
const domNode = document.getElementById("editor");
if (domNode) {
const aspnetCookieLanguageDetector : LanguageDetectorModule = {
type: "languageDetector", detect(): string | readonly string[] | undefined {
let cookies = document.cookie.split(";");
let cultureCookie = cookies.find(s => s.startsWith(".AspNetCore.Culture"));
if (cultureCookie) {
let parts = cultureCookie.split("=", 2);
if (parts[1]) {
let info = parts[1]
.replace("%7C", "|")
.replace("%3D", "=")
.replace("%3D", "=");
let cultureInfo = info.split("|", 2);
let ui_culture = cultureInfo[1]?.split("=", 2)[1];
if(ui_culture && ui_culture.match(/^\w\w(-\w\w)?$/))
return ui_culture;
}
}
return undefined;
}
}
i18n
.use(aspnetCookieLanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
resources: {
en: {
translation: {
Draft: "Draft",
InReview: "In Review",
Published: "Published",
Title_Label: "Title",
Title_Placeholder: "My new cheese cake recipe",
Slug_Label: "Article Url Part (autogenerated)",
Slug_Placeholder: "my-new-cheese-cake-recipe",
PublishDate_Label: "Publish Date",
PublishDate_Placeholder: "When to release this Article after approval",
Category_Label: "Category",
EditorSubmit: "Save",
ViewArticle_Label: "Open",
Body_Label: "Content",
Body_Placeholder: "First you preheat the oven...",
Tools: {
H1_Label: "H1",
H1_Tooltip: "First level heading",
H2_Label: "H2",
H2_Tooltip: "Second level heading",
H3_Label: "H3",
H3_Tooltip: "Third level heading",
H4_Label: "H4",
H4_Tooltip: "Fourth level heading",
Bold_Tooltip: "Make text bold",
Italic_Tooltip: "Make text italic",
StrikeThrough_Label: "del",
StrikeThrough_Tooltip: "Stroke through text",
Mark_Label: "txt",
Mark_Tooltip: "Mark the selected text",
Cite_Label: "Cite",
Cite_Tooltip: "Make text a citation",
CodeLine_Tooltip: "Mark selected text as programming code",
CodeBlock_Tooltip: "Insert program code block",
},
Category: {
Primary: "Primary Category",
Dangerous: "",
Important: "Important",
Informative: "Informative",
Secondary: "Secondary Category",
Default: "Regular Category",
Extra: "Additional Category",
},
loading: {
article: "Loading article...",
},
editor: {
unsaved_changes_notice: "You have unsaved changes, save now so you don't loose them!",
}
}
},
de: {
translation: {
Draft: "Entwurf",
InReview: "In Rezension",
Published: "Veröffentlicht",
Title_Label: "Überschrift",
Title_Placeholder: "Mein neues Käsekuchenrezept",
Slug_Label: "Artikel URL (autogeneriert)",
Slug_Placeholder: "mein-neues-kaesekuchenrezept",
PublishDate_Label: "Erscheinungsdatum",
PublishDate_Placeholder: "Wann dieser Artikel veröffentlicht werden soll nachdem er geprüft wurde",
Category_Label: "Kategorie",
EditorSubmit: "Speichern",
ViewArticle_Label: "Öffnen",
Body_Label: "Inhalt",
Body_Placeholder: "Zuerst heizt man den ofen vor...",
Tools: {
H1_Label: "Ü1",
H1_Tooltip: "Primärüberschrift",
H2_Label: "Ü2",
H2_Tooltip: "Sekundärüberschrift",
H3_Label: "Ü3",
H3_Tooltip: "Level 3 Überschrift",
H4_Label: "Ü4",
H4_Tooltip: "Level 4 Überschrift",
Bold_Tooltip: "Text hervorheben",
Italic_Tooltip: "Text kursiv stellen",
// StrikeThrough_Label -> take from en
StrikeThrough_Tooltip: "Text durchstreichen",
// Mark_Label -> take from en
Mark_Tooltip: "Den selektierten Text markieren",
Cite_Label: "Zitat",
Cite_Tooltip: "Text als Zitat formatieren",
CodeLine_Tooltip: "Selektierten text als programmcode markieren",
CodeBlock_Tooltip: "Programmierblock einfügen",
},
Category: {
Primary: "Hauptkategorie",
Dangerous: "",
Important: "Wichtig",
Informative: "Informativ",
Secondary: "Sekundäre Kategorie",
Default: "Reguläre Kategorie",
Extra: "Zusätzliche Kategorie",
},
loading: {
article: "Lade Artikel...",
},
editor: {
unsaved_changes_notice: "Sie haben ungesicherte Änderungen, speichern Sie jetzt um diese nicht zu verlieren!",
}
}
}
}
});
const root = createRoot(domNode);
root.render(<Editor />);
}

View file

@ -0,0 +1,40 @@
export type guid = string;
export enum CategoryColor {
Primary = 1,
Dangerous = 5,
Important = 10,
Informative = 15,
Secondary = 20,
Default = 25,
Extra = 50,
}
export enum ArticleStatus {
Draft = 0,
InReview = 1,
Published = 2,
}
export type Category = {
id: guid,
name: string,
color: CategoryColor,
}
export type ArticleView = {
id: guid,
title: string,
slug: string,
body: string,
status: ArticleStatus,
publishDate: string,
categories: Category[],
}
export type ArticleDto = {
id: guid,
title: string,
slug: string,
body: string,
publishDate: Date,
categories: guid[],
}

View file

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View file

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 5 KiB

View file

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View file

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 6 KiB

View file

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View file

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View file

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

View file

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB