Skip to content

Feature: Bookmarks #403

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Mar 7, 2025
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5428428
implement bookmarks
Feb 23, 2025
4244410
clear storage on startup when in development
Feb 23, 2025
b396e1c
add conditional bookmark buttons & use onAfterRender lifecycle hook
Feb 23, 2025
a5df71e
move bookmarks link to rss
Feb 25, 2025
6bdb0d8
add bookmark button to post page
Feb 25, 2025
890744d
remove bookmark icon and change position
Feb 25, 2025
720ef5d
remove localStorage.js
Feb 25, 2025
9f75386
remove unused dependencies
Feb 25, 2025
4a2d9ac
Fix CI/CD build issues
Feb 26, 2025
cf5922a
Create & use BookmarkButton, update bookmarkService
Feb 26, 2025
9f3b6a1
move bookmarks nav out of rss
Feb 27, 2025
3badad6
refactor BookmarkService
Feb 27, 2025
a697224
refactor bookmarkservice
Feb 28, 2025
220eb5a
update navmenu
Feb 28, 2025
8fef145
restyle bookmark button and add icon to navMenu
Feb 28, 2025
7233cab
add extra styling to bookmark button
Feb 28, 2025
1910223
add padding to bookmarks page
Feb 28, 2025
7548d03
add unit tests for BookmarkService
Feb 28, 2025
3eeca43
change exception type
Feb 28, 2025
91bc212
refactor code
Mar 1, 2025
0304be3
move BookmarkButton css to seperate file
Mar 2, 2025
71533ae
refactor
Mar 2, 2025
e590c40
Only update initially
linkdotnet Mar 7, 2025
52f3a8a
fix: Tests
linkdotnet Mar 7, 2025
7b4c994
Added tests
linkdotnet Mar 7, 2025
22fc52b
Updated bookmark implementation and icons
linkdotnet Mar 7, 2025
910707f
refactor: Remove unused objects
linkdotnet Mar 7, 2025
e7468e2
fix: Styling alignments
linkdotnet Mar 7, 2025
efa0e0f
fix: Remove debug stuff
linkdotnet Mar 7, 2025
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
21 changes: 11 additions & 10 deletions src/LinkDotNet.Blog.Web/App.razor
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
@using LinkDotNet.Blog.Web.Features.Home.Components

<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<ObjectNotFound></ObjectNotFound>
</LayoutView>
</NotFound>
</Router>
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<ObjectNotFound></ObjectNotFound>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
64 changes: 64 additions & 0 deletions src/LinkDotNet.Blog.Web/Features/Bookmarks/BookmarkService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LinkDotNet.Blog.Domain;
using LinkDotNet.Blog.Infrastructure.Persistence.Sql;
using LinkDotNet.Blog.Web.Features.Services;
using Microsoft.EntityFrameworkCore;

namespace LinkDotNet.Blog.Web.Features.Bookmarks;

public class BookmarkService : IBookmarkService
{
private readonly ILocalStorageService localStorageService;

public BookmarkService(ILocalStorageService localStorageService)
{
this.localStorageService = localStorageService;
}

public async Task<bool> IsBookMarked(string postId)
{
if(string.IsNullOrEmpty(postId)) throw new ArgumentException(nameof(postId));
Copy link
Owner

Choose a reason for hiding this comment

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

Suggested change
if(string.IsNullOrEmpty(postId)) throw new ArgumentException(nameof(postId));
ArgumentException.ThrowIfNullOrEmpty(postId);

await InitializeIfNotExists();
var bookmarks = await localStorageService.GetItemAsync<HashSet<string>>("bookmarks");

return bookmarks.Contains(postId);
}

public async Task<IReadOnlyList<string>> GetBookmarkedPostIds()
{
await InitializeIfNotExists();
return await localStorageService.GetItemAsync<IReadOnlyList<string>>("bookmarks");
}

public async Task ToggleBookmark(string postId)
{
await InitializeIfNotExists();
if(string.IsNullOrEmpty(postId)) throw new ArgumentException(nameof(postId));
Copy link
Owner

Choose a reason for hiding this comment

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

The same here - plus: The sanity check should be the first line in this code. No need to call InitializeIfNotExists when postId is not set.


if (await IsBookMarked(postId))
{
var bookmarks = await localStorageService.GetItemAsync<HashSet<string>>("bookmarks");

bookmarks.Remove(postId);

await localStorageService.SetItemAsync("bookmarks", bookmarks);
}
else
{
var bookmarks = await localStorageService.GetItemAsync<HashSet<string>>("bookmarks");

bookmarks.Add(postId);

await localStorageService.SetItemAsync("bookmarks", bookmarks);
}
}

private async Task InitializeIfNotExists()
{
if (!(await localStorageService.ContainKeyAsync("bookmarks")))
Copy link
Owner

Choose a reason for hiding this comment

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

Just for consistency reasons: Can you wrap the if condition with brackets?

if (...) 
{
    ...
}

await localStorageService.SetItemAsync("bookmarks", new List<string>());
}
}
36 changes: 36 additions & 0 deletions src/LinkDotNet.Blog.Web/Features/Bookmarks/Bookmarks.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
@page "/bookmarks"
@using LinkDotNet.Blog.Domain
@using LinkDotNet.Blog.Infrastructure.Persistence
@inject IBookmarkService BookmarkService
@inject IRepository<BlogPost> BlogPostRepository;

<section>
Copy link
Owner

Choose a reason for hiding this comment

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

Could we align this a bit better to other pages like the archive?

<div class="container">
	<h3 class="pb-3 fw-bold">Bookmarks</h3>
    ...

@if (bookmarkedPosts.Count <= 0)
{
<p>You have no bookmarks</p>
}
else
{
@foreach (var post in bookmarkedPosts)
{
<ShortBlogPost BlogPost="post" />
}
}
</section>
<style>
section {
Copy link
Owner

Choose a reason for hiding this comment

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

Given the idea from above, this could be deleted

padding: 4rem;
}
</style>

@code {
private IReadOnlyList<BlogPost> bookmarkedPosts = [];

protected override async Task OnAfterRenderAsync(bool firstRender)
{
var ids = await BookmarkService.GetBookmarkedPostIds();
bookmarkedPosts = await BlogPostRepository.GetAllByProjectionAsync(post => post, post => ids.Contains(post.Id));
Copy link
Owner

Choose a reason for hiding this comment

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

nitpick: We could make a small sanity check if there are any ids. No need to go to the database if ids.Count == 0

StateHasChanged();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<button @onclick="OnClickHandler">
@if (!IsBookmarked)
{
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m17 21-5-4-5 4V3.889a.92.92 0 0 1 .244-.629.808.808 0 0 1 .59-.26h8.333a.81.81 0 0 1 .589.26.92.92 0 0 1 .244.63V21Z"/>
</svg>
}
else
{
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path d="M7.833 2c-.507 0-.98.216-1.318.576A1.92 1.92 0 0 0 6 3.89V21a1 1 0 0 0 1.625.78L12 18.28l4.375 3.5A1 1 0 0 0 18 21V3.889c0-.481-.178-.954-.515-1.313A1.808 1.808 0 0 0 16.167 2H7.833Z"/>
</svg>
}
</button>

<style>
Copy link
Owner

Choose a reason for hiding this comment

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

We either could move this into BookmarkButton.razor.css or on the global css file.

svg :hover {
fill: grey;
}
button {
border: none;
background: var(--background);
}
</style>

@code {
[Parameter] public bool IsBookmarked { get; set; }
[Parameter] public EventCallback OnClick { get; set; }

private async Task OnClickHandler()
{
await OnClick.InvokeAsync();
}
}
12 changes: 12 additions & 0 deletions src/LinkDotNet.Blog.Web/Features/Bookmarks/IBookmarkService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using LinkDotNet.Blog.Domain;

namespace LinkDotNet.Blog.Web.Features.Bookmarks;

public interface IBookmarkService
{
public Task<bool> IsBookMarked(string postId);
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 also write IsBookmarked as you did not use capital M for the other methods.

public Task<IReadOnlyList<string>> GetBookmarkedPostIds();
public Task ToggleBookmark(string postId);
}
19 changes: 16 additions & 3 deletions src/LinkDotNet.Blog.Web/Features/Components/ShortBlogPost.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
@using LinkDotNet.Blog.Domain
@using LinkDotNet.Blog.Web.Features.Bookmarks
@using LinkDotNet.Blog.Web.Features.Bookmarks.Components
@inject IBookmarkService BookmarkService

<article>
<div class="blog-card @AltCssClass">
Expand Down Expand Up @@ -33,11 +36,13 @@
</ul>
</div>
<div class="description">
<h1>@BlogPost.Title</h1>
<h2></h2>
<div class="header">
<h1>@BlogPost.Title</h1>
<BookmarkButton IsBookmarked="isBookmarked" OnClick="async () => { await BookmarkService.ToggleBookmark(BlogPost.Id); StateHasChanged(); }"></BookmarkButton>
Copy link
Owner

Choose a reason for hiding this comment

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

As this does more than one thing, it might be nice to have it inside a function rather than a lambda here.

</div>
<p>@MarkdownConverter.ToMarkupString(BlogPost.ShortDescription)</p>
<p class="read-more">
<a href="/blogPost/@BlogPost.Id/@BlogPost.Slug" aria-label="@BlogPost.Title">Read the whole article</a>
<a href="/blogPost/@BlogPost.Id/@BlogPost.Slug" aria-label="@BlogPost.Title">Read the whole article</a>
</p>
</div>
</div>
Expand All @@ -47,6 +52,8 @@
[Parameter, EditorRequired]
public required BlogPost BlogPost { get; set; }

private bool isBookmarked = false;

[Parameter]
public bool UseAlternativeStyle { get; set; }

Expand Down Expand Up @@ -75,4 +82,10 @@

return base.SetParametersAsync(ParameterView.Empty);
}

protected override async Task OnAfterRenderAsync(bool firstRender)
{
isBookmarked = await BookmarkService.IsBookMarked(BlogPost.Id);
StateHasChanged();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@
z-index: 1;
}

.blog-card .description .header {
display: flex;
justify-content: space-between;
}

.blog-card .description h1 {
line-height: 1;
margin: 0 0 5px 0;
Expand Down
12 changes: 7 additions & 5 deletions src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ms-auto mb-2 mb-lg-0 me-5">
<li><a class="nav-link" href="/"><i class="home"></i> Home</a></li>
<li><a class="nav-link" href="/archive"><i class="books"></i> Archive</a></li>
@if (Configuration.Value.IsAboutMeEnabled)
<li><a class="nav-link" href="/"><i class="home"></i> Home</a></li>
<li><a class="nav-link" href="/archive"><i class="books"></i> Archive</a></li>
<li><a class="nav-link" href="/bookmarks"><i class="list"></i> Bookmarks</a>
</li>
@if (Configuration.Value.IsAboutMeEnabled)
{
<li class="nav-item">
<a class="nav-link" href="AboutMe">
Expand All @@ -48,8 +50,8 @@
<i class="rss2"></i> RSS
</a>
<ul class="dropdown-menu" aria-labelledby="rssDropdown">
<li><a class="dropdown-item" href="/feed.rss" aria-label="RSS with All Posts">All Posts (Summary)</a></li>
<li><a class="dropdown-item" href="/feed.rss?withContent=true" aria-label="RSS with Full Content">Most Recent Posts (Full Content)</a></li>
<li><a class="dropdown-item" href="/feed.rss" aria-label="RSS with All Posts">All Posts (Summary)</a></li>
<li><a class="dropdown-item" href="/feed.rss?withContent=true" aria-label="RSS with Full Content">Most Recent Posts (Full Content)</a></li>
</ul>
</li>

Expand Down
2 changes: 2 additions & 0 deletions src/LinkDotNet.Blog.Web/Features/Home/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
@using LinkDotNet.Blog.Domain
@using LinkDotNet.Blog.Infrastructure
@using LinkDotNet.Blog.Infrastructure.Persistence
@using LinkDotNet.Blog.Web.Features.Bookmarks
@using LinkDotNet.Blog.Web.Features.Home.Components
@using LinkDotNet.Blog.Web.Features.Services
@using Microsoft.Extensions.Caching.Memory
Expand All @@ -14,6 +15,7 @@
@inject IOptions<Introduction> Introduction
@inject IOptions<ApplicationConfiguration> AppConfiguration
@inject NavigationManager NavigationManager
@inject IBookmarkService BookmarkService
Copy link
Owner

Choose a reason for hiding this comment

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

This seems not needed - can be removed


<PageTitle>@AppConfiguration.Value.BlogName</PageTitle>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
@using Markdig
@using LinkDotNet.Blog.Domain
@using LinkDotNet.Blog.Infrastructure.Persistence
@using LinkDotNet.Blog.Web.Features.Bookmarks
@using LinkDotNet.Blog.Web.Features.Services
@using LinkDotNet.Blog.Web.Features.ShowBlogPost.Components
@using LinkDotNet.Blog.Web.Features.SupportMe.Components
@using LinkDotNet.Blog.Web.Features.Bookmarks.Components
@inject IRepository<BlogPost> BlogPostRepository
@inject IRepository<ShortCode> ShortCodeRepository
@inject IJSRuntime JsRuntime
Expand All @@ -14,6 +16,7 @@
@inject IOptions<ApplicationConfiguration> AppConfiguration
@inject IOptions<ProfileInformation> ProfileInformation
@inject IOptions<SupportMeConfiguration> SupportConfiguration
@inject IBookmarkService BookmarkService

@if (isLoading)
{
Expand All @@ -25,7 +28,7 @@ else if (!isLoading && BlogPost is null)
}
else if (BlogPost is not null)
{
<PageTitle>@BlogPost.Title</PageTitle>
<PageTitle>@BlogPost.Title</PageTitle>
<OgData Title="@BlogPost.Title"
AbsolutePreviewImageUrl="@OgDataImage"
Description="@(Markdown.ToPlainText(BlogPost.ShortDescription))"
Expand All @@ -49,7 +52,8 @@ else if (BlogPost is not null)
<span class="ms-1">@BlogPost.UpdatedDate.ToShortDateString()</span>
</div>
<span class="read-time"></span>
<span class="me-2">@BlogPost.ReadingTimeInMinutes minute read</span>
<span class="me-2">@BlogPost.ReadingTimeInMinutes minute read</span>
<BookmarkButton IsBookmarked="isBookmarked" OnClick="async () => { await BookmarkService.ToggleBookmark(BlogPost.Id); StateHasChanged(); }"></BookmarkButton>
@if (BlogPost.Tags is not null && BlogPost.Tags.Any())
{
<div class="d-flex align-items-center">
Expand Down Expand Up @@ -110,6 +114,7 @@ else if (BlogPost is not null)
private string OgDataImage => BlogPost!.PreviewImageUrlFallback ?? BlogPost.PreviewImageUrl;
private string BlogPostCanoncialUrl => $"blogPost/{BlogPost?.Id}";
private IReadOnlyCollection<ShortCode> shortCodes = [];
private bool isBookmarked;

private BlogPost? BlogPost { get; set; }

Expand All @@ -129,6 +134,10 @@ else if (BlogPost is not null)
{
await JsRuntime.InvokeVoidAsync("hljs.highlightAll");
_ = UserRecordService.StoreUserRecordAsync();

ArgumentNullException.ThrowIfNull(BlogPost);
Copy link
Owner

Choose a reason for hiding this comment

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

As this isn't an argument, the ArgumentNullException doesn't really apply.
Rather an if(BlogPost is not null) - if the user navigates to a page with a non-existing ID then we get an exception

isBookmarked = await BookmarkService.IsBookMarked(BlogPost.Id);
StateHasChanged();
}

private MarkupString EnrichWithShortCodes(string content)
Expand Down
2 changes: 1 addition & 1 deletion src/LinkDotNet.Blog.Web/Pages/_Host.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
Layout = "_Layout";
}

<component type="typeof(App)" render-mode="ServerPrerendered" />
<component type="typeof(App)" render-mode="ServerPrerendered" />
2 changes: 2 additions & 0 deletions src/LinkDotNet.Blog.Web/ServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Blazorise.Bootstrap5;
using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services;
using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services;
using LinkDotNet.Blog.Web.Features.Bookmarks;
using LinkDotNet.Blog.Web.Features.Services;
using LinkDotNet.Blog.Web.RegistrationExtensions;
using Microsoft.AspNetCore.Builder;
Expand All @@ -19,6 +20,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
services.AddScoped<ILocalStorageService, LocalStorageService>();
services.AddScoped<ISortOrderCalculator, SortOrderCalculator>();
services.AddScoped<IUserRecordService, UserRecordService>();
services.AddScoped<IBookmarkService, BookmarkService>();
services.AddScoped<ISitemapService, SitemapService>();
services.AddScoped<IXmlWriter, XmlWriter>();
services.AddScoped<IFileProcessor, FileProcessor>();
Expand Down
Loading