Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
23 changes: 23 additions & 0 deletions src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@using LinkDotNet.Blog.Web.Features.SupportMe.Components
@using LinkDotNet.Blog.Web.Features.TagDiscovery
@inject IOptions<ApplicationConfiguration> Configuration
@inject IOptions<SupportMeConfiguration> SupportConfiguration
@inject NavigationManager NavigationManager
Expand Down Expand Up @@ -57,6 +58,16 @@

<AccessControl CurrentUri="@currentUri"></AccessControl>
<li class="nav-item d-flex align-items-center"><ThemeToggler Class="nav-link"></ThemeToggler></li>

@if (Configuration.Value.EnableTagDiscoveryPanel)
{
<li class="nav-item d-flex align-items-center">
<a class="tag-discovery-btn" @onclick="ToggleTagDiscoveryPanel"
title="Discover new topics"> &#xE936; </a>
<TagDiscoveryPanel IsOpen="@_isOpen" OnClose="CloseTagDiscoveryPanel" />
</li>
}

<li class="d-flex">
<SearchInput SearchEntered="NavigateToSearchPage"></SearchInput>
</li>
Expand All @@ -68,6 +79,8 @@
@code {
private string currentUri = string.Empty;

private bool _isOpen;

protected override void OnInitialized()
{
NavigationManager.LocationChanged += UpdateUri;
Expand All @@ -90,4 +103,14 @@
currentUri = e.Location;
StateHasChanged();
}

private void ToggleTagDiscoveryPanel()
{
_isOpen = !_isOpen;
}

private void CloseTagDiscoveryPanel()
{
_isOpen = false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Collections.Generic;
using System.Threading.Tasks;

namespace LinkDotNet.Blog.Web.Features.Services.Tags;

public interface ITagQueryService
{
Task<IReadOnlyList<TagCount>> GetAllOrderedByUsageAsync();
}
3 changes: 3 additions & 0 deletions src/LinkDotNet.Blog.Web/Features/Services/Tags/TagCount.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace LinkDotNet.Blog.Web.Features.Services.Tags;

public sealed record TagCount(string Name, int Count);
40 changes: 40 additions & 0 deletions src/LinkDotNet.Blog.Web/Features/Services/Tags/TagQueryService.cs
Original file line number Diff line number Diff line change
@@ -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<BlogPost> blogPostRepository) : ITagQueryService
{
public async Task<IReadOnlyList<TagCount>> GetAllOrderedByUsageAsync()
{
var posts = await blogPostRepository.GetAllAsync();

var tagCounts = posts
// Flatten the collection of tag lists into a single sequence.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those comments are so obvious they can be removed. I assume copilot/... did create them

.SelectMany(p => p.Tags ?? Enumerable.Empty<string>())

// 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
@inject ITagQueryService TagQueryService
@inject IOptions<ApplicationConfiguration> AppConfiguration
@inject NavigationManager Navigation

@if (!AppConfiguration.Value.EnableTagDiscoveryPanel || !IsOpen) { return; }

<div class="tag-overlay" @onclick="Close"></div>

<div class="tag-panel">
<div class="tag-discovery-container">
@foreach (var tag in _tags)
{
<span class="tag-badge" @onclick="() => Navigate(tag.Name)">
@tag.Name

@if (AppConfiguration.Value.ShowTagsWithCountInTagDiscovery)
{
<span class="tag-count">@tag.Count</span>
}
</span>
}
</div>
</div>

@code {
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback OnClose { get; set; }

private IReadOnlyList<TagCount> _tags = [];

protected override async Task OnParametersSetAsync()
{
if (IsOpen && _tags.Count == 0)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge case: But that leads to stale updates as a creator. If I create an entry and open the dialog, it will show only the initially set version.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably we are better off with a cached version with timing or other means (like we do with the frontpage)

{
_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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
.tag-overlay {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is to have as little as possible custom css. Either by using bottstrap 5 itself (which to a big extend should be possible here) or at least move it into the central basic.css

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;
}
2 changes: 2 additions & 0 deletions src/LinkDotNet.Blog.Web/ServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +27,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
services.AddScoped<IXmlWriter, XmlWriter>();
services.AddScoped<IFileProcessor, FileProcessor>();
services.AddScoped<ICurrentUserService, CurrentUserService>();
services.AddScoped<ITagQueryService, TagQueryService>();

services.AddSingleton<CacheService>();
services.AddSingleton<ICacheInvalidator>(s => s.GetRequiredService<CacheService>());
Expand Down
3 changes: 2 additions & 1 deletion src/LinkDotNet.Blog.Web/_Imports.razor
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
6 changes: 4 additions & 2 deletions src/LinkDotNet.Blog.Web/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"ProfilePictureUrl": "assets/profile-picture.webp"
},
"ImageStorageProvider": "<Provider>",
"ImageStorage" : {
"ImageStorage": {
"AuthenticationMode": "Default",
"ConnectionString": "",
"ServiceUrl": "",
Expand All @@ -49,5 +49,7 @@
"ShowReadingIndicator": true,
"ShowSimilarPosts": true,
"ShowBuildInformation": true,
"UseMultiAuthorMode": false
"UseMultiAuthorMode": false,
"EnableTagDiscoveryPanel": true,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also needs some documentation in the docs/ directory: There is a part where we describes all the settings

"ShowTagsWithCountInTagDiscovery": true
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove that - we can always show them

}
13 changes: 12 additions & 1 deletion src/LinkDotNet.Blog.Web/wwwroot/css/basic.css
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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%;
Expand Down
Loading