Skip to content

Commit c46bdb8

Browse files
committed
feat: adding TagDiscovery panel feature (#494)
1 parent 6c0a805 commit c46bdb8

12 files changed

Lines changed: 352 additions & 4 deletions

File tree

src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,8 @@ public sealed record ApplicationConfiguration
2727
public bool ShowBuildInformation { get; init; } = true;
2828

2929
public bool UseMultiAuthorMode { get; init; }
30+
31+
public bool EnableTagDiscoveryPanel { get; set; }
32+
33+
public bool ShowTagsWithCountInTagDiscovery { get; set; }
3034
}

src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
@using LinkDotNet.Blog.Web.Features.SupportMe.Components
2+
@using LinkDotNet.Blog.Web.Features.TagDiscovery
23
@inject IOptions<ApplicationConfiguration> Configuration
34
@inject IOptions<SupportMeConfiguration> SupportConfiguration
45
@inject NavigationManager NavigationManager
@@ -57,6 +58,16 @@
5758

5859
<AccessControl CurrentUri="@currentUri"></AccessControl>
5960
<li class="nav-item d-flex align-items-center"><ThemeToggler Class="nav-link"></ThemeToggler></li>
61+
62+
@if (Configuration.Value.EnableTagDiscoveryPanel)
63+
{
64+
<li class="nav-item d-flex align-items-center">
65+
<a class="tag-discovery-btn" @onclick="ToggleTagDiscoveryPanel"
66+
title="Discover new topics"> &#xE936; </a>
67+
<TagDiscoveryPanel IsOpen="@_isOpen" OnClose="CloseTagDiscoveryPanel" />
68+
</li>
69+
}
70+
6071
<li class="d-flex">
6172
<SearchInput SearchEntered="NavigateToSearchPage"></SearchInput>
6273
</li>
@@ -68,6 +79,8 @@
6879
@code {
6980
private string currentUri = string.Empty;
7081

82+
private bool _isOpen;
83+
7184
protected override void OnInitialized()
7285
{
7386
NavigationManager.LocationChanged += UpdateUri;
@@ -90,4 +103,14 @@
90103
currentUri = e.Location;
91104
StateHasChanged();
92105
}
106+
107+
private void ToggleTagDiscoveryPanel()
108+
{
109+
_isOpen = !_isOpen;
110+
}
111+
112+
private void CloseTagDiscoveryPanel()
113+
{
114+
_isOpen = false;
115+
}
93116
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using System.Collections.Generic;
2+
using System.Threading.Tasks;
3+
4+
namespace LinkDotNet.Blog.Web.Features.Services.Tags;
5+
6+
public interface ITagQueryService
7+
{
8+
Task<IReadOnlyList<TagCount>> GetAllOrderedByUsageAsync();
9+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace LinkDotNet.Blog.Web.Features.Services.Tags;
2+
3+
public sealed record TagCount(string Name, int Count);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using Azure.Storage.Blobs.Models;
2+
using LinkDotNet.Blog.Domain;
3+
using LinkDotNet.Blog.Infrastructure.Persistence;
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Linq;
7+
using System.Threading.Tasks;
8+
9+
namespace LinkDotNet.Blog.Web.Features.Services.Tags;
10+
11+
public sealed class TagQueryService(IRepository<BlogPost> blogPostRepository) : ITagQueryService
12+
{
13+
public async Task<IReadOnlyList<TagCount>> GetAllOrderedByUsageAsync()
14+
{
15+
var posts = await blogPostRepository.GetAllAsync();
16+
17+
var tagCounts = posts
18+
// Flatten the collection of tag lists into a single sequence.
19+
.SelectMany(p => p.Tags ?? Enumerable.Empty<string>())
20+
21+
// Defensive guard against invalid tag values.
22+
.Where(tag => !string.IsNullOrEmpty(tag))
23+
24+
.GroupBy(tag => tag.Trim())
25+
26+
// Transform each group into a TagCount DTO.
27+
// group.Key = tag name
28+
// group.Count() = number of occurrences
29+
.Select(group => new TagCount(
30+
group.Key,
31+
group.Count()))
32+
33+
// Sort descending by usage count (most popular first).
34+
.OrderByDescending(tc => tc.Count)
35+
.ThenBy(tc => tc.Name)
36+
.ToList();
37+
38+
return tagCounts;
39+
}
40+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
@inject ITagQueryService TagQueryService
2+
@inject IOptions<ApplicationConfiguration> AppConfiguration
3+
@inject NavigationManager Navigation
4+
5+
@if (!AppConfiguration.Value.EnableTagDiscoveryPanel || !IsOpen) { return; }
6+
7+
<div class="tag-overlay" @onclick="Close"></div>
8+
9+
<div class="tag-panel">
10+
<div class="tag-discovery-container">
11+
@foreach (var tag in _tags)
12+
{
13+
<span class="tag-badge" @onclick="() => Navigate(tag.Name)">
14+
@tag.Name
15+
16+
@if (AppConfiguration.Value.ShowTagsWithCountInTagDiscovery)
17+
{
18+
<span class="tag-count">@tag.Count</span>
19+
}
20+
</span>
21+
}
22+
</div>
23+
</div>
24+
25+
@code {
26+
[Parameter] public bool IsOpen { get; set; }
27+
[Parameter] public EventCallback OnClose { get; set; }
28+
29+
private IReadOnlyList<TagCount> _tags = [];
30+
31+
protected override async Task OnParametersSetAsync()
32+
{
33+
if (IsOpen && _tags.Count == 0)
34+
{
35+
_tags = await TagQueryService.GetAllOrderedByUsageAsync();
36+
}
37+
}
38+
39+
private async Task Close()
40+
{
41+
await OnClose.InvokeAsync();
42+
}
43+
44+
private async Task Navigate(string tag)
45+
{
46+
var encoded = Uri.EscapeDataString(tag);
47+
Navigation.NavigateTo($"/searchByTag/{encoded}");
48+
await Close();
49+
}
50+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
.tag-overlay {
2+
position: fixed;
3+
inset: 0;
4+
background: rgba(0, 0, 0, 0.35);
5+
backdrop-filter: blur(2px);
6+
z-index: 1000;
7+
}
8+
9+
.tag-panel {
10+
position: fixed;
11+
top: 50%;
12+
left: 50%;
13+
transform: translate(-50%, -50%);
14+
width: min(340px, 92vw);
15+
max-height: 70vh;
16+
background: var(--background-color, #ffffff);
17+
color: var(--text-color, #222);
18+
border-radius: 14px;
19+
padding: 1.2rem;
20+
overflow-y: auto;
21+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
22+
z-index: 1001;
23+
}
24+
25+
.tag-discovery-container {
26+
display: flex;
27+
flex-wrap: wrap;
28+
gap: 10px;
29+
}
30+
31+
.tag-badge {
32+
display: inline-flex;
33+
align-items: center;
34+
gap: 6px;
35+
padding: 6px 12px;
36+
border-radius: 999px;
37+
font-size: 0.85rem;
38+
font-weight: 500;
39+
background-color: #4f83cc;
40+
color: white;
41+
cursor: pointer;
42+
transition: transform 0.1s ease, background-color 0.1s ease, box-shadow 0.1s ease;
43+
}
44+
45+
.tag-badge:hover {
46+
background-color: #3c6fb3;
47+
transform: translateY(-1px);
48+
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
49+
}
50+
51+
.tag-count {
52+
background: rgba(0, 0, 0, 0.25);
53+
border-radius: 999px;
54+
padding: 2px 7px;
55+
font-size: 0.7rem;
56+
font-weight: 600;
57+
}
58+
59+
.tag-panel::-webkit-scrollbar {
60+
width: 6px;
61+
}
62+
63+
.tag-panel::-webkit-scrollbar-thumb {
64+
background: rgba(0, 0, 0, 0.25);
65+
border-radius: 6px;
66+
}
67+
68+
.no-scroll {
69+
overflow: hidden;
70+
}

src/LinkDotNet.Blog.Web/ServiceExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services;
77
using LinkDotNet.Blog.Web.Features.Bookmarks;
88
using LinkDotNet.Blog.Web.Features.Services;
9+
using LinkDotNet.Blog.Web.Features.Services.Tags;
910
using LinkDotNet.Blog.Web.RegistrationExtensions;
1011
using Microsoft.AspNetCore.Builder;
1112
using Microsoft.AspNetCore.Http;
@@ -26,6 +27,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
2627
services.AddScoped<IXmlWriter, XmlWriter>();
2728
services.AddScoped<IFileProcessor, FileProcessor>();
2829
services.AddScoped<ICurrentUserService, CurrentUserService>();
30+
services.AddScoped<ITagQueryService, TagQueryService>();
2931

3032
services.AddSingleton<CacheService>();
3133
services.AddSingleton<ICacheInvalidator>(s => s.GetRequiredService<CacheService>());

src/LinkDotNet.Blog.Web/_Imports.razor

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
@using System.Net.Http
1+
@using System.Net.Http
22
@using Microsoft.AspNetCore.Authorization
33
@using Microsoft.AspNetCore.Components.Authorization
44
@using Microsoft.AspNetCore.Components.Forms
@@ -11,3 +11,4 @@
1111
@using LinkDotNet.Blog.Web
1212
@using LinkDotNet.Blog.Web.Features.Components
1313
@using Microsoft.Extensions.Options
14+
@using LinkDotNet.Blog.Web.Features.Services.Tags

src/LinkDotNet.Blog.Web/appsettings.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"ProfilePictureUrl": "assets/profile-picture.webp"
4040
},
4141
"ImageStorageProvider": "<Provider>",
42-
"ImageStorage" : {
42+
"ImageStorage": {
4343
"AuthenticationMode": "Default",
4444
"ConnectionString": "",
4545
"ServiceUrl": "",
@@ -49,5 +49,7 @@
4949
"ShowReadingIndicator": true,
5050
"ShowSimilarPosts": true,
5151
"ShowBuildInformation": true,
52-
"UseMultiAuthorMode": false
52+
"UseMultiAuthorMode": false,
53+
"EnableTagDiscoveryPanel": true,
54+
"ShowTagsWithCountInTagDiscovery": true
5355
}

0 commit comments

Comments
 (0)