diff --git a/.editorconfig b/.editorconfig index c3ed7849..91034b63 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,34 +3,29 @@ root = true [*] indent_style = tab indent_size = tab -tab_size = 4 +tab_width = 4 charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.cs] -tab_size = 4 indent_style = space [*.{xml,config,*proj,nuspec,props,resx,targets,yml,tasks}] -tab_size = 2 +tab_width = 2 [*.{htm,html,js,jsm,ts,tsx,css,sass,scss,less,svg,vue}] -tab_size = 2 +tab_width = 2 [*.json] -tab_size = 2 - -[*.{ps1,psm1}] -tab_size = 4 +tab_width = 2 [*.sh] -tab_size = 4 end_of_line = lf [*.{yml,yaml}] indent_style = space -tab_size = 2 +tab_width = 2 [*.md] trim_trailing_whitespace = false diff --git a/Readme.md b/Readme.md index d3b1be38..55027dfe 100644 --- a/Readme.md +++ b/Readme.md @@ -58,39 +58,41 @@ The appsettings.json file has a lot of options to customize the content of the b "Shortname": "blog" }, "KofiToken": "ABC123", - "GithubSponsorName": "your-tag-here" + "GithubSponsorName": "your-tag-here", + "ShowReadingIndicator": true } ``` -| Property | Type | Description | -| ------------------------- | -------------- |----------------------------------------------------------------------------------------------------------------------------------------------------------------| -| BlogName | string | Name of your blog. Is used in the navbar and is used as the title of the page. Will not be shown when `BlogBrandUrl` is set | -| BlogBrandUrl | string | The url to an image which is used as a brand image in the navigation bar. If not set or `null` the `BlogName` will be shown | -| Social | node | Represents all possible linked social accounts | -| GithubAccountUrl | string | Url to your github account. If not set it is not shown in the introduction card | -| LinkedInAccountUrl | string | Url to your LinkedIn account. If not set it is not shown in the introduction card | -| TwitterAccountUrl | string | Url to your Twitter account. If not set it is not shown in the introduction card | -| Introduction | | Is used for the introduction part of the blog | -| Description | MarkdownString | Small introduction text for yourself. This is also used for `` tag. For this the markup will be converted to plain text | -| BackgroundUrl | string | Url or path to the background image. (Optional) | -| ProfilePictureUrl | string | Url or path to your profile picture | +| Property | Type | Description | +| ------------------------- | -------------- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| BlogName | string | Name of your blog. Is used in the navbar and is used as the title of the page. Will not be shown when `BlogBrandUrl` is set | +| BlogBrandUrl | string | The url to an image which is used as a brand image in the navigation bar. If not set or `null` the `BlogName` will be shown | +| Social | node | Represents all possible linked social accounts | +| GithubAccountUrl | string | Url to your github account. If not set it is not shown in the introduction card | +| LinkedInAccountUrl | string | Url to your LinkedIn account. If not set it is not shown in the introduction card | +| TwitterAccountUrl | string | Url to your Twitter account. If not set it is not shown in the introduction card | +| Introduction | | Is used for the introduction part of the blog | +| Description | MarkdownString | Small introduction text for yourself. This is also used for `` tag. For this the markup will be converted to plain text | +| BackgroundUrl | string | Url or path to the background image. (Optional) | +| ProfilePictureUrl | string | Url or path to your profile picture | | PersistenceProvider | string | Declares the type of the storage provider (one of the following: `SqlServer`, `Sqlite`, `RavenDb`, `InMemory`, `MySql`). More in-depth explanation down below | -| ConnectionString | string | Is used for connection to a database. Not used when `InMemoryStorageProvider` is used | -| DatabaseName | string | Name of the database. Only used with `RavenDbStorageProvider` | -| Auth0 | | Configuration for setting up Auth0 | -| Domain | string | See more details here: https://manage.auth0.com/dashboard/ | -| ClientId | string | See more details here: https://manage.auth0.com/dashboard/ | -| ClientSecret | string | See more details here: https://manage.auth0.com/dashboard/ | -| BlogPostsPerPage | int | Gives the amount of blog posts loaded and display per page. For more the user has to use the navigation | -| AboutMeProfileInformation | node | Sets information for the About Me Page. If omitted the page is disabled completely | -| Name | string | Name, which is displayed on top of the profile card | -| Heading | string | Displayed under the name. For example job title | -| ProfilePictureUrl | string | Displayed profile picture | +| ConnectionString | string | Is used for connection to a database. Not used when `InMemoryStorageProvider` is used | +| DatabaseName | string | Name of the database. Only used with `RavenDbStorageProvider` | +| Auth0 | | Configuration for setting up Auth0 | +| Domain | string | See more details here: https://manage.auth0.com/dashboard/ | +| ClientId | string | See more details here: https://manage.auth0.com/dashboard/ | +| ClientSecret | string | See more details here: https://manage.auth0.com/dashboard/ | +| BlogPostsPerPage | int | Gives the amount of blog posts loaded and display per page. For more the user has to use the navigation | +| AboutMeProfileInformation | node | Sets information for the About Me Page. If omitted the page is disabled completely | +| Name | string | Name, which is displayed on top of the profile card | +| Heading | string | Displayed under the name. For example job title | +| ProfilePictureUrl | string | Displayed profile picture | | Giscus | node | Enables the comment section via giscus. If left empty the comment secion will not be shown. For more information checkout the section about comments down below | | Disqus | node | Enables the comment section via disqus. If left empty the comment secion will not be shown. For more information checkout the section about comments down below | -| KofiToken | string | Enables the "Buy me a Coffee" button of Kofi. To aquire the token head down to the "Kofi" section | -| GithubSponsorName | string | Enables the "Github Sponsor" button which redirects to GitHub. Only pass in the user name instead of the url. | +| KofiToken | string | Enables the "Buy me a Coffee" button of Kofi. To aquire the token head down to the "Kofi" section | +| GithubSponsorName | string | Enables the "Github Sponsor" button which redirects to GitHub. Only pass in the user name instead of the url. | +| ShowReadingIndicator | boolean | If set to `true` (default) a circle indicates the progress when a user reads a blog post (without comments). | ## Storage Provider Currently, there are 5 Storage-Provider: diff --git a/src/LinkDotNet.Blog.Web/AppConfiguration.cs b/src/LinkDotNet.Blog.Web/AppConfiguration.cs index 6c9200b5..b069b1df 100644 --- a/src/LinkDotNet.Blog.Web/AppConfiguration.cs +++ b/src/LinkDotNet.Blog.Web/AppConfiguration.cs @@ -38,4 +38,6 @@ public record AppConfiguration public string GithubSponsorName { get; init; } public bool IsGithubSponsorAvailable => !string.IsNullOrEmpty(GithubSponsorName); + + public bool ShowReadingIndicator { get; init; } } diff --git a/src/LinkDotNet.Blog.Web/AppConfigurationFactory.cs b/src/LinkDotNet.Blog.Web/AppConfigurationFactory.cs index 7c7a863d..12b01022 100644 --- a/src/LinkDotNet.Blog.Web/AppConfigurationFactory.cs +++ b/src/LinkDotNet.Blog.Web/AppConfigurationFactory.cs @@ -28,6 +28,7 @@ public static AppConfiguration Create(IConfiguration config) DisqusConfiguration = disqus, KofiToken = config[nameof(AppConfiguration.KofiToken)], GithubSponsorName = config[nameof(AppConfiguration.GithubSponsorName)], + ShowReadingIndicator = config.GetValue(nameof(AppConfiguration.ShowReadingIndicator)), }; return configuration; diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/CommentSection.razor b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/CommentSection.razor index f59efe07..d3705db9 100644 --- a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/CommentSection.razor +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/CommentSection.razor @@ -18,5 +18,4 @@ @code { private bool MultipleCommentPlugins => AppConfiguration.IsDisqusEnabled && AppConfiguration.IsGiscusEnabled; - } \ No newline at end of file diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/ReadingIndicator.razor b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/ReadingIndicator.razor new file mode 100644 index 00000000..22448b63 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/ReadingIndicator.razor @@ -0,0 +1,24 @@ +@inject IJSRuntime JSRuntime + +
+ + + + +
+ +@code { + [Parameter] + public string ContainerCssSelector { get; set; } + + private ElementReference progressContainer; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await using var _ = await JSRuntime.InvokeAsync("import", "./Features/ShowBlogPost/Components/ReadingIndicator.razor.js"); + await JSRuntime.InvokeVoidAsync("initCircularReadingProgress", ContainerCssSelector, progressContainer); + } + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/ReadingIndicator.razor.css b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/ReadingIndicator.razor.css new file mode 100644 index 00000000..9a16b014 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/ReadingIndicator.razor.css @@ -0,0 +1,40 @@ +.progress-container { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 1000; + opacity: 0; + transition: opacity 1.5s; +} + +.progress-container.visible { + opacity: 1; +} + +@keyframes fadeOut { + to { + opacity: 0; + } + } + +.progress-circle { + width: 50px; + height: 50px; +} + +.progress-bg { + fill: none; + stroke: #f3f3f3; + stroke-width: 4; +} + +.progress-bar { + fill: none; + stroke: #4caf50; + stroke-width: 4; + stroke-linecap: round; + transform-origin: center; + transform: rotate(-90deg); + stroke-dasharray: 100, 100; + stroke-dashoffset: 100; +} \ No newline at end of file diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/ReadingIndicator.razor.js b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/ReadingIndicator.razor.js new file mode 100644 index 00000000..32877e31 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/ReadingIndicator.razor.js @@ -0,0 +1,52 @@ +let progressTimeout; +let rafId; + +function getContentHeight(className) { + const content = document.querySelector(className); + if (!content) { + return 0; + } + const contentRect = content.getBoundingClientRect(); + return contentRect.height; +} + +function showProgressIndicator(progressContainer) { + progressContainer.classList.add("visible"); + progressContainer.style.animation = 'none'; +} + +function hideProgressIndicator(progressContainer) { + progressContainer.style.animation = 'fadeOut 0.5s forwards'; + setTimeout(() => { + progressContainer.classList.remove('visible'); + }, 500); +} + +window.initCircularReadingProgress = (parentContainer, progressContainer) => { + const progressBar = document.getElementById('progressBar'); + + const onScroll = () => { + clearTimeout(progressTimeout); + + const contentHeight = getContentHeight(parentContainer); + const windowHeight = document.documentElement.clientHeight; + const scrollAmount = document.documentElement.scrollTop; + const maxScrollAmount = contentHeight - windowHeight; + const progress = Math.max(0, Math.min(100, (scrollAmount / maxScrollAmount) * 100)); + progressBar.style.strokeDashoffset = 100 - progress; + + showProgressIndicator(progressContainer); + + progressTimeout = setTimeout(() => { + hideProgressIndicator(progressContainer); + }, 2000); + + rafId = null; + }; + + window.addEventListener('scroll', () => { + if (!rafId) { + rafId = requestAnimationFrame(onScroll); + } + }); +}; diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor index 168b799c..7e5b7f9c 100644 --- a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor @@ -7,6 +7,7 @@ @inject IRepository BlogPostRepository @inject IJSRuntime JsRuntime @inject IUserRecordService UserRecordService +@inject AppConfiguration AppConfiguration @if (BlogPost == null) { @@ -19,40 +20,45 @@ else AbsolutePreviewImageUrl="@OgDataImage" Description="@(Markdown.ToPlainText(BlogPost.ShortDescription))" Keywords="@Tags"> -
-
-
-
-

@BlogPost.Title

-
-
@BlogPost.UpdatedDate.ToString("dd/MM/yyyy")
- @if (BlogPost.Tags != null && BlogPost.Tags.Any()) - { - - @foreach (var tag in BlogPost.Tags.Select(t => t.Content)) - { - @tag - } - - } -
+
+
+
+
+

@BlogPost.Title

+
+
@BlogPost.UpdatedDate.ToString("dd/MM/yyyy")
+ @if (BlogPost.Tags != null && BlogPost.Tags.Any()) + { + + @foreach (var tag in BlogPost.Tags.Select(t => t.Content)) + { + @tag + } + + } +
-
- -
+
+ +
-
- @(MarkdownConverter.ToMarkupString(BlogPost.Content)) -
-
-
- - -
- - -
-
+
+ @(MarkdownConverter.ToMarkupString(BlogPost.Content)) +
+
+
+ + +
+ + +
+
+ + @if (AppConfiguration.ShowReadingIndicator) + { + + } } @code { diff --git a/src/LinkDotNet.Blog.Web/appsettings.json b/src/LinkDotNet.Blog.Web/appsettings.json index fd7bd4c3..3b772c95 100644 --- a/src/LinkDotNet.Blog.Web/appsettings.json +++ b/src/LinkDotNet.Blog.Web/appsettings.json @@ -32,5 +32,6 @@ "Name": "Steven Giesel", "Heading": "Software Engineer", "ProfilePictureUrl": "assets/profile-picture.webp", - } + }, + "ShowReadingIndicator": true } \ No newline at end of file diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/AppConfigurationFactoryTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/AppConfigurationFactoryTests.cs index 3ac7a460..5a461fbd 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/AppConfigurationFactoryTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/AppConfigurationFactoryTests.cs @@ -32,6 +32,7 @@ public void ShouldMapFromAppConfiguration() { "Disqus:Shortname", "blog" }, { "KofiToken", "ABC" }, { "GithubSponsorName", "linkdotnet" }, + { "ShowReadingIndicator", "true" }, }; var configuration = new ConfigurationBuilder() .AddInMemoryCollection(inMemorySettings) @@ -64,6 +65,7 @@ public void ShouldMapFromAppConfiguration() appConfiguration.DisqusConfiguration.Shortname.Should().Be("blog"); appConfiguration.KofiToken.Should().Be("ABC"); appConfiguration.GithubSponsorName.Should().Be("linkdotnet"); + appConfiguration.ShowReadingIndicator.Should().BeTrue(); } [Theory] diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs index c1201ef8..0a60231a 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs @@ -22,9 +22,8 @@ public void ShouldShowLoadingAnimation() { const string blogPostId = "2"; var repositoryMock = new Mock>(); - JSInterop.Mode = JSRuntimeMode.Loose; + SetupMocks(); Services.AddScoped(_ => repositoryMock.Object); - Services.AddScoped(_ => Mock.Of()); repositoryMock.Setup(r => r.GetByIdAsync(blogPostId)) .Returns(async () => { @@ -80,7 +79,6 @@ public void ShouldUseFallbackAsOgDataIfAvailable(string preview, string fallback [Fact] public void ShowTagWithLinksWhenAvailable() { - JSInterop.Mode = JSRuntimeMode.Loose; var repositoryMock = new Mock>(); var blogPost = new BlogPostBuilder() .WithTags("tag1") @@ -100,7 +98,6 @@ public void ShowTagWithLinksWhenAvailable() [Fact] public void ShowNotShowTagsWhenNotSet() { - JSInterop.Mode = JSRuntimeMode.Loose; var repositoryMock = new Mock>(); var blogPost = new BlogPostBuilder() .Build(); @@ -114,8 +111,32 @@ public void ShowNotShowTagsWhenNotSet() cut.FindAll(".goto-tag").Should().BeEmpty(); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ShowReadingIndicatorWhenEnabled(bool isEnabled) + { + var appConfiguration = new AppConfiguration + { + ShowReadingIndicator = isEnabled, + }; + var repositoryMock = new Mock>(); + var blogPost = new BlogPostBuilder() + .Build(); + repositoryMock.Setup(r => r.GetByIdAsync("1")).ReturnsAsync(blogPost); + Services.AddScoped(_ => repositoryMock.Object); + SetupMocks(); + Services.AddScoped(_ => appConfiguration); + + var cut = RenderComponent( + p => p.Add(s => s.BlogPostId, "1")); + + cut.HasComponent().Should().Be(isEnabled); + } + private void SetupMocks() { + JSInterop.Mode = JSRuntimeMode.Loose; Services.AddScoped(_ => Mock.Of()); Services.AddScoped(_ => Mock.Of()); Services.AddScoped(_ => Mock.Of());