From c46bdb89bc9a0b2aea0e6802f3d861ae5669838e Mon Sep 17 00:00:00 2001 From: EmanuelGF Date: Sun, 1 Mar 2026 19:52:02 +0000 Subject: [PATCH 1/4] feat: adding TagDiscovery panel feature (#494) --- .../ApplicationConfiguration.cs | 4 + .../Features/Home/Components/NavMenu.razor | 23 +++ .../Services/Tags/ITagQueryService.cs | 9 ++ .../Features/Services/Tags/TagCount.cs | 3 + .../Features/Services/Tags/TagQueryService.cs | 40 ++++++ .../TagDiscovery/TagDiscoveryPanel.razor | 50 +++++++ .../TagDiscovery/TagDiscoveryPanel.razor.css | 70 +++++++++ src/LinkDotNet.Blog.Web/ServiceExtensions.cs | 2 + src/LinkDotNet.Blog.Web/_Imports.razor | 3 +- src/LinkDotNet.Blog.Web/appsettings.json | 6 +- src/LinkDotNet.Blog.Web/wwwroot/css/basic.css | 13 +- .../Services/Tags/TagQueryServiceTests.cs | 133 ++++++++++++++++++ 12 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 src/LinkDotNet.Blog.Web/Features/Services/Tags/ITagQueryService.cs create mode 100644 src/LinkDotNet.Blog.Web/Features/Services/Tags/TagCount.cs create mode 100644 src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs create mode 100644 src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor create mode 100644 src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor.css create mode 100644 tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs diff --git a/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs b/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs index 6a866d49..91f6a60a 100644 --- a/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs +++ b/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs @@ -27,4 +27,8 @@ public sealed record ApplicationConfiguration public bool ShowBuildInformation { get; init; } = true; public bool UseMultiAuthorMode { get; init; } + + public bool EnableTagDiscoveryPanel { get; set; } + + public bool ShowTagsWithCountInTagDiscovery { get; set; } } diff --git a/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor b/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor index be2bee74..69f0e1b2 100644 --- a/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor +++ b/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor @@ -1,4 +1,5 @@ @using LinkDotNet.Blog.Web.Features.SupportMe.Components +@using LinkDotNet.Blog.Web.Features.TagDiscovery @inject IOptions Configuration @inject IOptions SupportConfiguration @inject NavigationManager NavigationManager @@ -57,6 +58,16 @@ + + @if (Configuration.Value.EnableTagDiscoveryPanel) + { + + } +
  • @@ -68,6 +79,8 @@ @code { private string currentUri = string.Empty; + private bool _isOpen; + protected override void OnInitialized() { NavigationManager.LocationChanged += UpdateUri; @@ -90,4 +103,14 @@ currentUri = e.Location; StateHasChanged(); } + + private void ToggleTagDiscoveryPanel() + { + _isOpen = !_isOpen; + } + + private void CloseTagDiscoveryPanel() + { + _isOpen = false; + } } diff --git a/src/LinkDotNet.Blog.Web/Features/Services/Tags/ITagQueryService.cs b/src/LinkDotNet.Blog.Web/Features/Services/Tags/ITagQueryService.cs new file mode 100644 index 00000000..bcacf3ef --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/Tags/ITagQueryService.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace LinkDotNet.Blog.Web.Features.Services.Tags; + +public interface ITagQueryService +{ + Task> GetAllOrderedByUsageAsync(); +} diff --git a/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagCount.cs b/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagCount.cs new file mode 100644 index 00000000..ce0bb8c7 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagCount.cs @@ -0,0 +1,3 @@ +namespace LinkDotNet.Blog.Web.Features.Services.Tags; + +public sealed record TagCount(string Name, int Count); diff --git a/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs b/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs new file mode 100644 index 00000000..bf5f1d91 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs @@ -0,0 +1,40 @@ +using Azure.Storage.Blobs.Models; +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Infrastructure.Persistence; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace LinkDotNet.Blog.Web.Features.Services.Tags; + +public sealed class TagQueryService(IRepository blogPostRepository) : ITagQueryService +{ + public async Task> GetAllOrderedByUsageAsync() + { + var posts = await blogPostRepository.GetAllAsync(); + + var tagCounts = posts + // Flatten the collection of tag lists into a single sequence. + .SelectMany(p => p.Tags ?? Enumerable.Empty()) + + // Defensive guard against invalid tag values. + .Where(tag => !string.IsNullOrEmpty(tag)) + + .GroupBy(tag => tag.Trim()) + + // Transform each group into a TagCount DTO. + // group.Key = tag name + // group.Count() = number of occurrences + .Select(group => new TagCount( + group.Key, + group.Count())) + + // Sort descending by usage count (most popular first). + .OrderByDescending(tc => tc.Count) + .ThenBy(tc => tc.Name) + .ToList(); + + return tagCounts; + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor new file mode 100644 index 00000000..39d8f574 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor @@ -0,0 +1,50 @@ +@inject ITagQueryService TagQueryService +@inject IOptions AppConfiguration +@inject NavigationManager Navigation + +@if (!AppConfiguration.Value.EnableTagDiscoveryPanel || !IsOpen) { return; } + +
    + +
    +
    + @foreach (var tag in _tags) + { + + @tag.Name + + @if (AppConfiguration.Value.ShowTagsWithCountInTagDiscovery) + { + @tag.Count + } + + } +
    +
    + +@code { + [Parameter] public bool IsOpen { get; set; } + [Parameter] public EventCallback OnClose { get; set; } + + private IReadOnlyList _tags = []; + + protected override async Task OnParametersSetAsync() + { + if (IsOpen && _tags.Count == 0) + { + _tags = await TagQueryService.GetAllOrderedByUsageAsync(); + } + } + + private async Task Close() + { + await OnClose.InvokeAsync(); + } + + private async Task Navigate(string tag) + { + var encoded = Uri.EscapeDataString(tag); + Navigation.NavigateTo($"/searchByTag/{encoded}"); + await Close(); + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor.css b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor.css new file mode 100644 index 00000000..c8c03461 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor.css @@ -0,0 +1,70 @@ +.tag-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(2px); + z-index: 1000; +} + +.tag-panel { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(340px, 92vw); + max-height: 70vh; + background: var(--background-color, #ffffff); + color: var(--text-color, #222); + border-radius: 14px; + padding: 1.2rem; + overflow-y: auto; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25); + z-index: 1001; +} + +.tag-discovery-container { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.tag-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 500; + background-color: #4f83cc; + color: white; + cursor: pointer; + transition: transform 0.1s ease, background-color 0.1s ease, box-shadow 0.1s ease; +} + + .tag-badge:hover { + background-color: #3c6fb3; + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); + } + +.tag-count { + background: rgba(0, 0, 0, 0.25); + border-radius: 999px; + padding: 2px 7px; + font-size: 0.7rem; + font-weight: 600; +} + +.tag-panel::-webkit-scrollbar { + width: 6px; +} + +.tag-panel::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.25); + border-radius: 6px; +} + +.no-scroll { + overflow: hidden; +} diff --git a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs index 9ed92a1b..3fedb781 100644 --- a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs +++ b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs @@ -6,6 +6,7 @@ using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services; using LinkDotNet.Blog.Web.Features.Bookmarks; using LinkDotNet.Blog.Web.Features.Services; +using LinkDotNet.Blog.Web.Features.Services.Tags; using LinkDotNet.Blog.Web.RegistrationExtensions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -26,6 +27,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(s => s.GetRequiredService()); diff --git a/src/LinkDotNet.Blog.Web/_Imports.razor b/src/LinkDotNet.Blog.Web/_Imports.razor index fa2e19ea..f5cd8f88 100644 --- a/src/LinkDotNet.Blog.Web/_Imports.razor +++ b/src/LinkDotNet.Blog.Web/_Imports.razor @@ -1,4 +1,4 @@ -@using System.Net.Http +@using System.Net.Http @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Forms @@ -11,3 +11,4 @@ @using LinkDotNet.Blog.Web @using LinkDotNet.Blog.Web.Features.Components @using Microsoft.Extensions.Options +@using LinkDotNet.Blog.Web.Features.Services.Tags diff --git a/src/LinkDotNet.Blog.Web/appsettings.json b/src/LinkDotNet.Blog.Web/appsettings.json index bbddcc7d..6defd3f7 100644 --- a/src/LinkDotNet.Blog.Web/appsettings.json +++ b/src/LinkDotNet.Blog.Web/appsettings.json @@ -39,7 +39,7 @@ "ProfilePictureUrl": "assets/profile-picture.webp" }, "ImageStorageProvider": "", - "ImageStorage" : { + "ImageStorage": { "AuthenticationMode": "Default", "ConnectionString": "", "ServiceUrl": "", @@ -49,5 +49,7 @@ "ShowReadingIndicator": true, "ShowSimilarPosts": true, "ShowBuildInformation": true, - "UseMultiAuthorMode": false + "UseMultiAuthorMode": false, + "EnableTagDiscoveryPanel": true, + "ShowTagsWithCountInTagDiscovery": true } diff --git a/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css b/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css index 6a1033d3..44f10d7b 100644 --- a/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css +++ b/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css @@ -1,4 +1,4 @@ -:root, html[data-bs-theme='light'] { +:root, html[data-bs-theme='light'] { /* Fonts */ --default-font: 'Calibri'; --code-font: 'Lucida Console', 'Courier New'; @@ -645,6 +645,17 @@ code { object-fit: cover; } +.tag-discovery-btn { + font-family: 'icons'; + font-weight: 900; + content: "\e936"; + cursor: pointer; + padding: 6px; + margin: 6px; + text-decoration: none; + color: var(--bs-navbar-color); +} + @media only screen and (max-width: 700px) { .blog-outer-box .blog-container { width: 90%; diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs new file mode 100644 index 00000000..97de9d5d --- /dev/null +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs @@ -0,0 +1,133 @@ +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Infrastructure; +using LinkDotNet.Blog.Infrastructure.Persistence; +using LinkDotNet.Blog.Web.Features.Services.Tags; +using MongoDB.Driver; +using NSubstitute; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace LinkDotNet.Blog.UnitTests.Web.Features.Services.Tags; +public sealed class TagQueryServiceTests +{ + + private readonly IRepository repository; + private readonly TagQueryService tagQueryService; + + public TagQueryServiceTests() + { + repository = Substitute.For>(); + tagQueryService = new TagQueryService(repository); + } + + [Fact] + public async Task ShouldReturnEmptyWhenNoPosts() + { + // Arrange + repository.GetAllAsync() + .Returns(PagedList.Empty); + + // Act + var result = await tagQueryService.GetAllOrderedByUsageAsync(); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task AggregatesAndSortsTagsByUsage() + { + // Arrange + var posts = new List + { + CreatePost(["CSharp", "Blazor", "DotNet"]), + CreatePost(["CSharp", "Blazor"]), + CreatePost(["CSharp"]) + }; + + repository.GetAllAsync() + .Returns(CreatePagedList(posts)); + + // Act + var result = await tagQueryService.GetAllOrderedByUsageAsync(); + + // Assert + result.Count.ShouldBe(3); + + result[0].Name.ShouldBe("CSharp"); + result[0].Count.ShouldBe(3); + + result[1].Name.ShouldBe("Blazor"); + result[1].Count.ShouldBe(2); + + result[2].Name.ShouldBe("DotNet"); + result[2].Count.ShouldBe(1); + } + + [Fact] + public async Task ShouldIgnoreNullOrWhitespaceTags() + { + // Arrange + var posts = new List + { + CreatePost(["CSharp", " "]), + CreatePost(null!) + }; + + repository.GetAllAsync() + .Returns(CreatePagedList(posts)); + + // Act + var result = await tagQueryService.GetAllOrderedByUsageAsync(); + + // Assert + result.Count.ShouldBe(1); + result[0].Name.ShouldBe("CSharp"); + result[0].Count.ShouldBe(1); + } + + [Fact] + public async Task ShouldSortAlphabeticallyWhenCountsAreEqual() + { + // Arrange + var posts = new List + { + CreatePost(["CSharp"]), + CreatePost(["Blazor"]) + }; + + repository.GetAllAsync() + .Returns(CreatePagedList(posts)); + + // Act + var result = await tagQueryService.GetAllOrderedByUsageAsync(); + + // Assert + result[0].Name.ShouldBe("Blazor"); + result[1].Name.ShouldBe("CSharp"); + } + + private static BlogPost CreatePost(List tags) + { + var unique = Guid.NewGuid().ToString("N"); + + return BlogPost.Create( + $"Post-{unique}", + $"Slug-{unique}", + $"Excerpt-{unique}", + "#", + false, + tags: tags); + } + + private static PagedList CreatePagedList(List posts) + { + return new PagedList( + posts, + posts.Count, + 1, + posts.Count == 0 ? 1 : posts.Count); + } +} From b4dc0a118cdb39fc1d44ab083d9f631c7a6dd5f7 Mon Sep 17 00:00:00 2001 From: EmanuelGF Date: Wed, 4 Mar 2026 19:39:21 +0000 Subject: [PATCH 2/4] fix: address review feedback --- docs/Setup/Configuration.md | 4 ++- .../ApplicationConfiguration.cs | 2 -- .../Features/Home/Components/NavMenu.razor | 7 ++-- .../Features/Services/Tags/TagQueryService.cs | 35 ++++++++++++------- .../TagDiscovery/TagDiscoveryPanel.razor | 22 ++++++------ src/LinkDotNet.Blog.Web/appsettings.json | 3 +- src/LinkDotNet.Blog.Web/wwwroot/css/basic.css | 11 ------ .../Services/Tags/TagQueryServiceTests.cs | 28 +++++---------- 8 files changed, 50 insertions(+), 62 deletions(-) diff --git a/docs/Setup/Configuration.md b/docs/Setup/Configuration.md index 6b96e17a..c3743447 100644 --- a/docs/Setup/Configuration.md +++ b/docs/Setup/Configuration.md @@ -67,7 +67,8 @@ The appsettings.json file has a lot of options to customize the content of the b "ContainerName": "", "CdnEndpoint": "" }, - "UseMultiAuthorMode": false + "UseMultiAuthorMode": false, + "EnableTagDiscoveryPanel": true } ``` @@ -113,3 +114,4 @@ The appsettings.json file has a lot of options to customize the content of the b | ContainerName | string | The container name for the image storage provider | | CdnEndpoint | string | Optional CDN endpoint to use for uploaded images. If set, the blog will return this URL instead of the storage account URL for uploaded assets. | | UseMultiAuthorMode | boolean | The default value is `false`. If set to `true` then author name will be associated with blog posts at the time of creation. This author name will be fetched from the identity provider's `name` or `nickname` or `preferred_username` claim property. | +| EnableTagDiscoveryPanel | boolean | The default value is `true`. Enables the Tag Discovery Panel, which helps users discover topics by browsing popular tags. | diff --git a/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs b/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs index 91f6a60a..fae0c6bf 100644 --- a/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs +++ b/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs @@ -29,6 +29,4 @@ public sealed record ApplicationConfiguration public bool UseMultiAuthorMode { get; init; } public bool EnableTagDiscoveryPanel { get; set; } - - public bool ShowTagsWithCountInTagDiscovery { get; set; } } diff --git a/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor b/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor index 69f0e1b2..99efa4e0 100644 --- a/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor +++ b/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor @@ -61,9 +61,10 @@ @if (Configuration.Value.EnableTagDiscoveryPanel) { - } diff --git a/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs b/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs index bf5f1d91..fc37333d 100644 --- a/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs +++ b/src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs @@ -1,40 +1,49 @@ -using Azure.Storage.Blobs.Models; using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.Infrastructure.Persistence; +using Microsoft.Extensions.Options; +using Raven.Client.Documents.Operations.AI; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using ZiggyCreatures.Caching.Fusion; namespace LinkDotNet.Blog.Web.Features.Services.Tags; -public sealed class TagQueryService(IRepository blogPostRepository) : ITagQueryService +public sealed class TagQueryService( + IRepository blogPostRepository, + IFusionCache fusionCache, + IOptions appConfiguration) : ITagQueryService { + private const string TagCacheKey = "TagUsageList"; + public async Task> GetAllOrderedByUsageAsync() + { + return await fusionCache.GetOrSetAsync( + TagCacheKey, + async _ => await LoadTagsAsync(), + options => + { + options.SetDuration(TimeSpan.FromMinutes( + appConfiguration.Value.FirstPageCacheDurationInMinutes)); + }); + } + + private async Task> LoadTagsAsync() { var posts = await blogPostRepository.GetAllAsync(); var tagCounts = posts - // Flatten the collection of tag lists into a single sequence. .SelectMany(p => p.Tags ?? Enumerable.Empty()) - - // Defensive guard against invalid tag values. .Where(tag => !string.IsNullOrEmpty(tag)) - .GroupBy(tag => tag.Trim()) - - // Transform each group into a TagCount DTO. - // group.Key = tag name - // group.Count() = number of occurrences .Select(group => new TagCount( group.Key, group.Count())) - - // Sort descending by usage count (most popular first). .OrderByDescending(tc => tc.Count) .ThenBy(tc => tc.Name) .ToList(); - return tagCounts; } + } diff --git a/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor index 39d8f574..9cd2318d 100644 --- a/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor +++ b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor @@ -4,19 +4,21 @@ @if (!AppConfiguration.Value.EnableTagDiscoveryPanel || !IsOpen) { return; } -
    +
    +
    -
    -
    +
    +
    @foreach (var tag in _tags) { - + @tag.Name - - @if (AppConfiguration.Value.ShowTagsWithCountInTagDiscovery) - { - @tag.Count - } + @tag.Count }
    @@ -30,7 +32,7 @@ protected override async Task OnParametersSetAsync() { - if (IsOpen && _tags.Count == 0) + if (IsOpen) { _tags = await TagQueryService.GetAllOrderedByUsageAsync(); } diff --git a/src/LinkDotNet.Blog.Web/appsettings.json b/src/LinkDotNet.Blog.Web/appsettings.json index 6defd3f7..28cde6a3 100644 --- a/src/LinkDotNet.Blog.Web/appsettings.json +++ b/src/LinkDotNet.Blog.Web/appsettings.json @@ -50,6 +50,5 @@ "ShowSimilarPosts": true, "ShowBuildInformation": true, "UseMultiAuthorMode": false, - "EnableTagDiscoveryPanel": true, - "ShowTagsWithCountInTagDiscovery": true + "EnableTagDiscoveryPanel": true } diff --git a/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css b/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css index 44f10d7b..f867f357 100644 --- a/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css +++ b/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css @@ -645,17 +645,6 @@ code { object-fit: cover; } -.tag-discovery-btn { - font-family: 'icons'; - font-weight: 900; - content: "\e936"; - cursor: pointer; - padding: 6px; - margin: 6px; - text-decoration: none; - color: var(--bs-navbar-color); -} - @media only screen and (max-width: 700px) { .blog-outer-box .blog-container { width: 90%; diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs index 97de9d5d..9064ef3e 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs @@ -1,6 +1,7 @@ using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.Infrastructure; using LinkDotNet.Blog.Infrastructure.Persistence; +using LinkDotNet.Blog.TestUtilities; using LinkDotNet.Blog.Web.Features.Services.Tags; using MongoDB.Driver; using NSubstitute; @@ -42,9 +43,9 @@ public async Task AggregatesAndSortsTagsByUsage() // Arrange var posts = new List { - CreatePost(["CSharp", "Blazor", "DotNet"]), - CreatePost(["CSharp", "Blazor"]), - CreatePost(["CSharp"]) + new BlogPostBuilder().WithTags("CSharp", "Blazor", "DotNet").Build(), + new BlogPostBuilder().WithTags("CSharp", "Blazor").Build(), + new BlogPostBuilder().WithTags("CSharp").Build(), }; repository.GetAllAsync() @@ -72,8 +73,8 @@ public async Task ShouldIgnoreNullOrWhitespaceTags() // Arrange var posts = new List { - CreatePost(["CSharp", " "]), - CreatePost(null!) + new BlogPostBuilder().WithTags("CSharp", " ").Build(), + new BlogPostBuilder().Build(), }; repository.GetAllAsync() @@ -94,8 +95,8 @@ public async Task ShouldSortAlphabeticallyWhenCountsAreEqual() // Arrange var posts = new List { - CreatePost(["CSharp"]), - CreatePost(["Blazor"]) + new BlogPostBuilder().WithTags("CSharp").Build(), + new BlogPostBuilder().WithTags("Blazor").Build(), }; repository.GetAllAsync() @@ -109,19 +110,6 @@ public async Task ShouldSortAlphabeticallyWhenCountsAreEqual() result[1].Name.ShouldBe("CSharp"); } - private static BlogPost CreatePost(List tags) - { - var unique = Guid.NewGuid().ToString("N"); - - return BlogPost.Create( - $"Post-{unique}", - $"Slug-{unique}", - $"Excerpt-{unique}", - "#", - false, - tags: tags); - } - private static PagedList CreatePagedList(List posts) { return new PagedList( From 1b1190ffa73c070aada12be14e18f13010b94744 Mon Sep 17 00:00:00 2001 From: EmanuelGF Date: Fri, 6 Mar 2026 20:40:25 +0000 Subject: [PATCH 3/4] fix: addressing review feedback; fixing tests and improve TagDiscoveryPanel behaviour --- .../Features/Home/Components/NavMenu.razor | 2 +- .../TagDiscovery/TagDiscoveryPanel.razor | 73 ++++++++++++++----- .../TagDiscovery/TagDiscoveryPanel.razor.css | 70 ------------------ .../Web/Shared/NavMenuTests.cs | 10 ++- .../Services/Tags/TagQueryServiceTests.cs | 17 ++++- 5 files changed, 81 insertions(+), 91 deletions(-) delete mode 100644 src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor.css diff --git a/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor b/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor index 99efa4e0..ba2497d2 100644 --- a/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor +++ b/src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor @@ -65,7 +65,6 @@ - } @@ -76,6 +75,7 @@
    + @code { private string currentUri = string.Empty; diff --git a/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor index 9cd2318d..67039e8d 100644 --- a/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor +++ b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor @@ -1,34 +1,55 @@ +@using Microsoft.AspNetCore.Components.Web; @inject ITagQueryService TagQueryService @inject IOptions AppConfiguration @inject NavigationManager Navigation -@if (!AppConfiguration.Value.EnableTagDiscoveryPanel || !IsOpen) { return; } -
    -
    +@if (AppConfiguration.Value.EnableTagDiscoveryPanel && IsOpen) +{ +
    +
    -
    -
    - @foreach (var tag in _tags) - { - - @tag.Name - @tag.Count - - } +
    + +
    + + @if (_tags.Count == 0) + { +
    + No tags available yet. +
    + } + else + { +
    + @foreach (var tag in _tags) + { + + @tag.Name + @tag.Count + + } +
    + } +
    -
    +} @code { [Parameter] public bool IsOpen { get; set; } [Parameter] public EventCallback OnClose { get; set; } private IReadOnlyList _tags = []; + private ElementReference _panelRef; protected override async Task OnParametersSetAsync() { @@ -38,6 +59,14 @@ } } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (IsOpen) + { + await _panelRef.FocusAsync(); + } + } + private async Task Close() { await OnClose.InvokeAsync(); @@ -49,4 +78,12 @@ Navigation.NavigateTo($"/searchByTag/{encoded}"); await Close(); } + + private async Task HandleKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Escape") + { + await Close(); + } + } } diff --git a/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor.css b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor.css deleted file mode 100644 index c8c03461..00000000 --- a/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor.css +++ /dev/null @@ -1,70 +0,0 @@ -.tag-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.35); - backdrop-filter: blur(2px); - z-index: 1000; -} - -.tag-panel { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: min(340px, 92vw); - max-height: 70vh; - background: var(--background-color, #ffffff); - color: var(--text-color, #222); - border-radius: 14px; - padding: 1.2rem; - overflow-y: auto; - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25); - z-index: 1001; -} - -.tag-discovery-container { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - -.tag-badge { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - border-radius: 999px; - font-size: 0.85rem; - font-weight: 500; - background-color: #4f83cc; - color: white; - cursor: pointer; - transition: transform 0.1s ease, background-color 0.1s ease, box-shadow 0.1s ease; -} - - .tag-badge:hover { - background-color: #3c6fb3; - transform: translateY(-1px); - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); - } - -.tag-count { - background: rgba(0, 0, 0, 0.25); - border-radius: 999px; - padding: 2px 7px; - font-size: 0.7rem; - font-weight: 600; -} - -.tag-panel::-webkit-scrollbar { - width: 6px; -} - -.tag-panel::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.25); - border-radius: 6px; -} - -.no-scroll { - overflow: hidden; -} diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/NavMenuTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/NavMenuTests.cs index 010f11f8..26edc49c 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/NavMenuTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/NavMenuTests.cs @@ -1,11 +1,12 @@ -using System.Linq; using AngleSharp.Html.Dom; using LinkDotNet.Blog.TestUtilities; using LinkDotNet.Blog.Web.Features.Home.Components; using LinkDotNet.Blog.Web.Features.Services; +using LinkDotNet.Blog.Web.Features.Services.Tags; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using System.Linq; namespace LinkDotNet.Blog.IntegrationTests.Web.Shared; @@ -14,6 +15,13 @@ public class NavMenuTests : BunitContext public NavMenuTests() { ComponentFactories.Add(); + + var tagQueryService = Substitute.For(); + Services.AddSingleton(tagQueryService); + + Services.AddSingleton( + Options.Create(new ApplicationConfigurationBuilder().Build()) + ); } [Fact] diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs index 9064ef3e..67a1dc3a 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Services/Tags/TagQueryServiceTests.cs @@ -2,13 +2,18 @@ using LinkDotNet.Blog.Infrastructure; using LinkDotNet.Blog.Infrastructure.Persistence; using LinkDotNet.Blog.TestUtilities; +using LinkDotNet.Blog.Web; using LinkDotNet.Blog.Web.Features.Services.Tags; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using MongoDB.Driver; using NSubstitute; using System; using System.Collections.Generic; using System.Text; using System.Threading.Tasks; +using ZiggyCreatures.Caching.Fusion; + namespace LinkDotNet.Blog.UnitTests.Web.Features.Services.Tags; public sealed class TagQueryServiceTests @@ -16,11 +21,21 @@ public sealed class TagQueryServiceTests private readonly IRepository repository; private readonly TagQueryService tagQueryService; + private readonly IFusionCache fusionCache; public TagQueryServiceTests() { repository = Substitute.For>(); - tagQueryService = new TagQueryService(repository); + + fusionCache = new FusionCache( + new FusionCacheOptions(), + logger: null, + memoryCache: new MemoryCache(new MemoryCacheOptions()) + ); + + var config = Options.Create(new ApplicationConfigurationBuilder().Build()); + + tagQueryService = new TagQueryService(repository, fusionCache, config); } [Fact] From 4d7c0f4cdaaf5eb9ad47ace54f3305bd571f0a12 Mon Sep 17 00:00:00 2001 From: EmanuelGF Date: Sun, 8 Mar 2026 11:45:57 +0000 Subject: [PATCH 4/4] fix: extending 11To12 Migration to include EnableTagDiscoveryPanel setting. --- .../Migrations/Migration_11_To_12.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tools/LinkDotNet.Blog.UpgradeAssistant/Migrations/Migration_11_To_12.cs b/tools/LinkDotNet.Blog.UpgradeAssistant/Migrations/Migration_11_To_12.cs index be03312a..91b048ae 100644 --- a/tools/LinkDotNet.Blog.UpgradeAssistant/Migrations/Migration_11_To_12.cs +++ b/tools/LinkDotNet.Blog.UpgradeAssistant/Migrations/Migration_11_To_12.cs @@ -6,6 +6,7 @@ namespace LinkDotNet.Blog.UpgradeAssistant.Migrations; /// /// Migration from version 11.0 to 12.0. /// Adds ShowBuildInformation setting. +/// Adds EnableTagDiscoveryPanel setting. /// public sealed class Migration11To12 : IMigration { @@ -27,6 +28,12 @@ public bool Apply(JsonDocument document, ref string jsonContent) rootObject["ShowBuildInformation"] = true; hasChanges = true; ConsoleOutput.WriteInfo("Added 'ShowBuildInformation' setting. Controls display of build information in the footer."); + + if (!rootObject.ContainsKey("EnableTagDiscoveryPanel")) + { + rootObject["EnableTagDiscoveryPanel"] = true; + ConsoleOutput.WriteInfo("Added 'EnableTagDiscoveryPanel' setting. Controls whether the Tag Discovery panel is enabled in the UI."); + } } if (hasChanges) @@ -40,6 +47,7 @@ public bool Apply(JsonDocument document, ref string jsonContent) public string GetDescription() { - return "Adds ShowBuildInformation setting to control build information display."; + return "Adds ShowBuildInformation setting to control build information display and also the" + + " EnableTagDiscoveryPanel setting that controls whether the Tag Discovery panel is enabled in the UI."; } }