Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
@inject IOptions<ApplicationConfiguration> AppConfiguration
@inject ICurrentUserService CurrentUserService
@inject IBlogPostVersionService BlogPostVersionService
@inject ITagQueryService TagQueryService

<PageTitle>Creating new Blog Post</PageTitle>

Expand Down Expand Up @@ -154,22 +155,17 @@
<h6 class="card-title mb-0 text-primary">Tags</h6>
</div>
<div class="card-body">
<div class="form-floating">
<InputText type="text" class="form-control" id="tags" @bind-Value="model.Tags" />
<label for="tags">Tags (comma separated)</label>
<div class="position-relative">
<label for="tags" class="form-label fw-bold">Tags</label>
<TagInput Id="tags"
Suggestions="availableTags"
Placeholder="Add or select tags"
@bind-Value="model.Tags">
</TagInput>
</div>
<small class="form-text text-muted mt-2">
Add relevant tags to improve content discoverability and SEO. Use specific, searchable terms that describe your content.
</small>
@if (!string.IsNullOrWhiteSpace(model.Tags))
{
<div class="mt-2">
@foreach (var tag in model.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
<span class="badge bg-secondary me-1 mb-1">@tag.Trim()</span>
}
</div>
}
</div>
</div>

Expand Down Expand Up @@ -335,6 +331,8 @@
private bool canSubmit = true;
private IPagedList<ShortCode> shortCodes = PagedList<ShortCode>.Empty;
private IPagedList<BlogPostTemplate> blogPostTemplates = PagedList<BlogPostTemplate>.Empty;
private IReadOnlyList<string> availableTags = [];
private IReadOnlyList<string> originalTags = [];

private bool IsScheduled => model.ScheduledPublishDate.HasValue;

Expand All @@ -344,6 +342,7 @@
{
shortCodes = await ShortCodeRepository.GetAllAsync();
blogPostTemplates = await TemplateRepository.GetAllAsync();
await LoadAvailableTagsAsync();

if (AppConfiguration.Value.UseMultiAuthorMode)
{
Expand All @@ -359,6 +358,7 @@
}

model = CreateNewModel.FromBlogPost(BlogPost);
originalTags = BlogPost.Tags.ToList();

if (!string.IsNullOrEmpty(BlogPostId))
{
Expand Down Expand Up @@ -398,14 +398,23 @@
{
canSubmit = false;
model.AuthorName = authorName;
await OnBlogPostCreated.InvokeAsync(model.ToBlogPost());
var blogPost = model.ToBlogPost();
var tagsChanged = !HaveSameTagSet(originalTags, blogPost.Tags);
await OnBlogPostCreated.InvokeAsync(blogPost);
if (model.ShouldInvalidateCache)
{
await CacheInvalidator.ClearCacheAsync();
}

if (tagsChanged)
{
await TagQueryService.ClearTagCacheAsync();
await LoadAvailableTagsAsync();
}

_ = InstantJobRegistry.RunInstantJob<SimilarBlogPostJob>(parameter: true);
ClearModel();
originalTags = ClearAfterCreated ? [] : blogPost.Tags.ToList();
canSubmit = true;
}

Expand Down Expand Up @@ -434,7 +443,15 @@
return;
}

var tagsChanged = BlogPost is not null && !HaveSameTagSet(BlogPost.Tags, version.Tags);
await OnVersionRestored.InvokeAsync(version);
if (tagsChanged)
{
await TagQueryService.ClearTagCacheAsync();
await LoadAvailableTagsAsync();
originalTags = version.Tags.ToList();
}

versionHistory = await BlogPostVersionService.GetVersionHistoryAsync(BlogPostId!);
StateHasChanged();
}
Expand Down Expand Up @@ -519,4 +536,26 @@
model.Content = converter.Convert(model.Content);
}
}

private async Task LoadAvailableTagsAsync()
{
availableTags = (await TagQueryService.GetAllOrderedByUsageAsync())
.Select(tag => tag.Name)
.ToList();
}

private static bool HaveSameTagSet(IEnumerable<string>? left, IEnumerable<string>? right)
{
var leftTags = ToTagSet(left);
var rightTags = ToTagSet(right);
return leftTags.SetEquals(rightTags);
}

private static HashSet<string> ToTagSet(IEnumerable<string>? tags)
{
return (tags ?? [])
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
@using Microsoft.AspNetCore.Components.Web

<div class="tag-input">
<div class="border rounded p-2 bg-body">
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var tag in selectedTags)
{
<span class="badge rounded-pill bg-secondary d-inline-flex align-items-center gap-1 py-2">
@tag
<button type="button"
class="btn-close btn-close-white tag-input-remove"
aria-label="Remove @tag"
@onclick="() => RemoveTagAsync(tag)">
</button>
</span>
}

<input id="@Id"
class="form-control border-0 shadow-none flex-grow-1 tag-input-field"
value="@currentInput"
@oninput="HandleInputAsync"
@onfocus="HandleFocus"
@onkeydown="HandleKeyDownAsync"
@onblur="HandleBlurAsync"
placeholder="@Placeholder"
autocomplete="off" />
</div>
</div>

@if (isOpen)
{
var filteredSuggestions = GetFilteredSuggestions();

if (filteredSuggestions.Count > 0)
{
<div class="list-group position-absolute w-100 shadow mt-1 tag-input-suggestions">
@foreach (var (suggestion, index) in filteredSuggestions.Select((tag, i) => (tag, i)))
{
<button type="button"
class="list-group-item list-group-item-action @(selectedIndex == index ? "active" : "")"
@onmousedown="() => SelectSuggestionAsync(suggestion)"
@onmousedown:preventDefault="true"
@onmouseenter="() => selectedIndex = index">
@suggestion
</button>
}
</div>
}
}
</div>

@code {
[Parameter]
public string Id { get; set; } = "tags";

[Parameter]
public string Placeholder { get; set; } = "Add tags";

[Parameter]
public string Value { get; set; } = string.Empty;

[Parameter]
public EventCallback<string> ValueChanged { get; set; }

[Parameter]
public IReadOnlyList<string> Suggestions { get; set; } = [];

private readonly List<string> selectedTags = [];
private string currentInput = string.Empty;
private string? lastValue;
private bool isOpen;
private int selectedIndex = -1;

private IReadOnlyList<string> GetFilteredSuggestions()
{
var searchTerm = currentInput.Trim();
return Suggestions
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Where(tag => selectedTags.All(selected => !selected.Equals(tag, StringComparison.OrdinalIgnoreCase)))
.Where(tag => string.IsNullOrWhiteSpace(searchTerm) || tag.Contains(searchTerm, StringComparison.OrdinalIgnoreCase))
.ToList();
}

protected override void OnParametersSet()
{
if (lastValue == Value)
{
return;
}

selectedTags.Clear();
AddTags(ParseTags(Value), notifyChanged: false);
lastValue = Value;
}

private async Task HandleInputAsync(ChangeEventArgs args)
{
currentInput = args.Value?.ToString() ?? string.Empty;

if (currentInput.Contains(','))
{
await AddCurrentInputAsync();
return;
}

OpenSuggestions();
}

private void HandleFocus()
{
OpenSuggestions();
}

private async Task HandleBlurAsync(FocusEventArgs _)
{
await AddCurrentInputAsync();
isOpen = false;
}

private async Task HandleKeyDownAsync(KeyboardEventArgs args)
{
switch (args.Key)
{
case "ArrowDown":
MoveSelection(1);
break;
case "ArrowUp":
MoveSelection(-1);
break;
case "Enter":
var suggestions = GetFilteredSuggestions();
if (isOpen && selectedIndex >= 0 && selectedIndex < suggestions.Count)
{
await SelectSuggestionAsync(suggestions[selectedIndex]);
}
else
{
await AddCurrentInputAsync();
}
break;
case ",":
await AddCurrentInputAsync();
break;
case "Escape":
isOpen = false;
selectedIndex = -1;
break;
}
}

private void MoveSelection(int offset)
{
var suggestions = GetFilteredSuggestions();
if (suggestions.Count == 0)
{
isOpen = false;
selectedIndex = -1;
return;
}

isOpen = true;
selectedIndex = selectedIndex < 0
? 0
: Math.Clamp(selectedIndex + offset, 0, suggestions.Count - 1);
}

private async Task SelectSuggestionAsync(string suggestion)
{
if (AddTags([suggestion], notifyChanged: true))
{
await NotifyValueChangedAsync();
}

currentInput = string.Empty;
isOpen = false;
selectedIndex = -1;
}

private async Task AddCurrentInputAsync()
{
if (AddTags(ParseTags(currentInput), notifyChanged: true))
{
await NotifyValueChangedAsync();
}

currentInput = string.Empty;
isOpen = false;
selectedIndex = -1;
}

private async Task RemoveTagAsync(string tag)
{
selectedTags.RemoveAll(selected => selected.Equals(tag, StringComparison.OrdinalIgnoreCase));
await NotifyValueChangedAsync();
OpenSuggestions();
}

private bool AddTags(IEnumerable<string> tags, bool notifyChanged)
{
var changed = false;
foreach (var tag in tags)
{
if (selectedTags.Any(selected => selected.Equals(tag, StringComparison.OrdinalIgnoreCase)))
{
continue;
}

selectedTags.Add(tag);
changed = true;
}

if (notifyChanged && changed)
{
lastValue = string.Join(",", selectedTags);
}

return changed;
}

private async Task NotifyValueChangedAsync()
{
var value = string.Join(",", selectedTags);
lastValue = value;
await ValueChanged.InvokeAsync(value);
}

private void OpenSuggestions()
{
var suggestions = GetFilteredSuggestions();
isOpen = suggestions.Count > 0;
selectedIndex = isOpen ? Math.Clamp(selectedIndex, 0, suggestions.Count - 1) : -1;
}

private static IEnumerable<string> ParseTags(string? value)
{
return (value ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(tag => !string.IsNullOrWhiteSpace(tag));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ namespace LinkDotNet.Blog.Web.Features.Services.Tags;
public interface ITagQueryService
{
Task<IReadOnlyList<TagCount>> GetAllOrderedByUsageAsync();

Task ClearTagCacheAsync();
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public async Task<IReadOnlyList<TagCount>> GetAllOrderedByUsageAsync()
});
}

public async Task ClearTagCacheAsync()
{
await fusionCache.RemoveAsync(TagCacheKey);
}

private async Task<IReadOnlyList<TagCount>> LoadTagsAsync()
{
var tagLists = await blogPostRepository.GetAllByProjectionAsync(
Expand All @@ -46,5 +51,4 @@ private async Task<IReadOnlyList<TagCount>> LoadTagsAsync()

return tagCounts;
}

}
Loading
Loading