Skip to content

Commit cad2562

Browse files
authored
feat: adding TagDiscovery panel feature (#494) (#502)
* feat: adding TagDiscovery panel feature (#494) * fix: address review feedback * fix: addressing review feedback; fixing tests and improve TagDiscoveryPanel behaviour * fix: extending 11To12 Migration to include EnableTagDiscoveryPanel setting. * fix: adding migration 12To13 to include EnableTagDiscoveryPanel setting. * refactor: Changing TagQueryService to use projection when loading tags; Changing tests accordingly.
1 parent 65b9a74 commit cad2562

17 files changed

Lines changed: 442 additions & 12 deletions

File tree

docs/Setup/Configuration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ The appsettings.json file has a lot of options to customize the content of the b
6767
"ContainerName": "",
6868
"CdnEndpoint": ""
6969
},
70-
"UseMultiAuthorMode": false
70+
"UseMultiAuthorMode": false,
71+
"EnableTagDiscoveryPanel": true
7172
}
7273
```
7374

@@ -113,3 +114,4 @@ The appsettings.json file has a lot of options to customize the content of the b
113114
| ContainerName | string | The container name for the image storage provider |
114115
| 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. |
115116
| 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. |
117+
| EnableTagDiscoveryPanel | boolean | The default value is `true`. Enables the Tag Discovery Panel, which helps users discover topics by browsing popular tags. |

src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,6 @@ 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; }
3032
}

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

Lines changed: 24 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,17 +58,30 @@
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 me-lg-2 mb-2 mb-lg-0">
65+
<a class="nav-link d-flex align-items-center justify-content-center" @onclick="ToggleTagDiscoveryPanel"
66+
style="font-family: 'icons'; font-weight: 900; cursor: pointer;"
67+
title="Discover new topics"> &#xE936; </a>
68+
</li>
69+
}
70+
6071
<li class="d-flex">
6172
<SearchInput SearchEntered="NavigateToSearchPage"></SearchInput>
6273
</li>
6374
</ul>
6475
</div>
6576
</div>
6677
</nav>
78+
<TagDiscoveryPanel IsOpen="@_isOpen" OnClose="CloseTagDiscoveryPanel" />
6779

6880
@code {
6981
private string currentUri = string.Empty;
7082

83+
private bool _isOpen;
84+
7185
protected override void OnInitialized()
7286
{
7387
NavigationManager.LocationChanged += UpdateUri;
@@ -90,4 +104,14 @@
90104
currentUri = e.Location;
91105
StateHasChanged();
92106
}
107+
108+
private void ToggleTagDiscoveryPanel()
109+
{
110+
_isOpen = !_isOpen;
111+
}
112+
113+
private void CloseTagDiscoveryPanel()
114+
{
115+
_isOpen = false;
116+
}
93117
}
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: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using LinkDotNet.Blog.Domain;
2+
using LinkDotNet.Blog.Infrastructure.Persistence;
3+
using Microsoft.Extensions.Options;
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Linq;
7+
using System.Threading.Tasks;
8+
using ZiggyCreatures.Caching.Fusion;
9+
10+
namespace LinkDotNet.Blog.Web.Features.Services.Tags;
11+
12+
public sealed class TagQueryService(
13+
IRepository<BlogPost> blogPostRepository,
14+
IFusionCache fusionCache,
15+
IOptions<ApplicationConfiguration> appConfiguration) : ITagQueryService
16+
{
17+
private const string TagCacheKey = "TagUsageList";
18+
19+
public async Task<IReadOnlyList<TagCount>> GetAllOrderedByUsageAsync()
20+
{
21+
return await fusionCache.GetOrSetAsync(
22+
TagCacheKey,
23+
async _ => await LoadTagsAsync(),
24+
options =>
25+
{
26+
options.SetDuration(TimeSpan.FromMinutes(
27+
appConfiguration.Value.FirstPageCacheDurationInMinutes));
28+
});
29+
}
30+
31+
private async Task<IReadOnlyList<TagCount>> LoadTagsAsync()
32+
{
33+
var tagLists = await blogPostRepository.GetAllByProjectionAsync(
34+
p => p.Tags);
35+
36+
var tagCounts = tagLists
37+
.SelectMany(tags => tags ?? Enumerable.Empty<string>())
38+
.Where(tag => !string.IsNullOrWhiteSpace(tag))
39+
.GroupBy(tag => tag.Trim())
40+
.Select(group => new TagCount(
41+
group.Key,
42+
group.Count()))
43+
.OrderByDescending(tc => tc.Count)
44+
.ThenBy(tc => tc.Name)
45+
.ToList();
46+
47+
return tagCounts;
48+
}
49+
50+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
@using Microsoft.AspNetCore.Components.Web;
2+
@inject ITagQueryService TagQueryService
3+
@inject IOptions<ApplicationConfiguration> AppConfiguration
4+
@inject NavigationManager Navigation
5+
6+
7+
@if (AppConfiguration.Value.EnableTagDiscoveryPanel && IsOpen)
8+
{
9+
<div class="position-fixed top-0 start-0 w-100 h-100 bg-dark bg-opacity-25"
10+
style="z-index:1040; backdrop-filter: blur(2px);"
11+
@onclick="Close">
12+
</div>
13+
14+
<div class="position-fixed top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center"
15+
style="z-index:1050; pointer-events:none;">
16+
17+
<div @ref="_panelRef"
18+
class="bg-body border rounded shadow p-3"
19+
style="max-width: 400px; max-height: 70vh; overflow-y:auto; pointer-events:auto;"
20+
tabindex="0"
21+
@onkeydown="HandleKeyDown">
22+
23+
@if (_tags.Count == 0)
24+
{
25+
<div class="text-muted text-center py-2">
26+
No tags available yet.
27+
</div>
28+
}
29+
else
30+
{
31+
<div class="d-flex flex-wrap gap-2">
32+
@foreach (var tag in _tags)
33+
{
34+
<span class="badge bg-secondary d-flex align-items-center gap-1"
35+
style="cursor: pointer;"
36+
@onclick="() => Navigate(tag.Name)">
37+
@tag.Name
38+
<span class="badge bg-light text-dark">@tag.Count</span>
39+
</span>
40+
}
41+
</div>
42+
}
43+
</div>
44+
</div>
45+
}
46+
47+
@code {
48+
[Parameter] public bool IsOpen { get; set; }
49+
[Parameter] public EventCallback OnClose { get; set; }
50+
51+
private IReadOnlyList<TagCount> _tags = [];
52+
private ElementReference _panelRef;
53+
54+
protected override async Task OnParametersSetAsync()
55+
{
56+
if (IsOpen)
57+
{
58+
_tags = await TagQueryService.GetAllOrderedByUsageAsync();
59+
}
60+
}
61+
62+
protected override async Task OnAfterRenderAsync(bool firstRender)
63+
{
64+
if (IsOpen)
65+
{
66+
await _panelRef.FocusAsync();
67+
}
68+
}
69+
70+
private async Task Close()
71+
{
72+
await OnClose.InvokeAsync();
73+
}
74+
75+
private async Task Navigate(string tag)
76+
{
77+
var encoded = Uri.EscapeDataString(tag);
78+
Navigation.NavigateTo($"/searchByTag/{encoded}");
79+
await Close();
80+
}
81+
82+
private async Task HandleKeyDown(KeyboardEventArgs e)
83+
{
84+
if (e.Key == "Escape")
85+
{
86+
await Close();
87+
}
88+
}
89+
}

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: 3 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,6 @@
4949
"ShowReadingIndicator": true,
5050
"ShowSimilarPosts": true,
5151
"ShowBuildInformation": true,
52-
"UseMultiAuthorMode": false
52+
"UseMultiAuthorMode": false,
53+
"EnableTagDiscoveryPanel": true
5354
}

0 commit comments

Comments
 (0)