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