Skip to content

Commit bbd1891

Browse files
feat: Bookmarks (#403)
feat: Added bookmarks feature (closes #367)
1 parent 74a7dce commit bbd1891

File tree

24 files changed

+409
-25
lines changed

24 files changed

+409
-25
lines changed

src/LinkDotNet.Blog.Web/App.razor

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
@using LinkDotNet.Blog.Web.Features.Home.Components
2+
23
<CascadingAuthenticationState>
3-
<Router AppAssembly="@typeof(Program).Assembly">
4-
<Found Context="routeData">
5-
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
6-
</Found>
7-
<NotFound>
8-
<LayoutView Layout="@typeof(MainLayout)">
9-
<ObjectNotFound></ObjectNotFound>
10-
</LayoutView>
11-
</NotFound>
12-
</Router>
4+
<Router AppAssembly="@typeof(Program).Assembly">
5+
<Found Context="routeData">
6+
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
7+
</Found>
8+
<NotFound>
9+
<LayoutView Layout="@typeof(MainLayout)">
10+
<ObjectNotFound></ObjectNotFound>
11+
</LayoutView>
12+
</NotFound>
13+
</Router>
1314
</CascadingAuthenticationState>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using LinkDotNet.Blog.Domain;
6+
using LinkDotNet.Blog.Infrastructure.Persistence.Sql;
7+
using LinkDotNet.Blog.Web.Features.Services;
8+
using Microsoft.EntityFrameworkCore;
9+
10+
namespace LinkDotNet.Blog.Web.Features.Bookmarks;
11+
12+
public class BookmarkService : IBookmarkService
13+
{
14+
private readonly ILocalStorageService localStorageService;
15+
16+
public BookmarkService(ILocalStorageService localStorageService)
17+
{
18+
this.localStorageService = localStorageService;
19+
}
20+
21+
public async Task<bool> IsBookmarked(string postId)
22+
{
23+
ArgumentException.ThrowIfNullOrEmpty(postId);
24+
await InitializeIfNotExists();
25+
var bookmarks = await localStorageService.GetItemAsync<HashSet<string>>("bookmarks");
26+
27+
return bookmarks.Contains(postId);
28+
}
29+
30+
public async Task<IReadOnlyList<string>> GetBookmarkedPostIds()
31+
{
32+
await InitializeIfNotExists();
33+
return await localStorageService.GetItemAsync<IReadOnlyList<string>>("bookmarks");
34+
}
35+
36+
public async Task SetBookmark(string postId, bool isBookmarked)
37+
{
38+
ArgumentException.ThrowIfNullOrEmpty(postId);
39+
await InitializeIfNotExists();
40+
41+
var bookmarks = await localStorageService.GetItemAsync<HashSet<string>>("bookmarks");
42+
43+
if (!isBookmarked)
44+
{
45+
bookmarks.Remove(postId);
46+
}
47+
else
48+
{
49+
bookmarks.Add(postId);
50+
}
51+
52+
await localStorageService.SetItemAsync("bookmarks", bookmarks);
53+
54+
}
55+
56+
private async Task InitializeIfNotExists()
57+
{
58+
if (!(await localStorageService.ContainKeyAsync("bookmarks")))
59+
{
60+
await localStorageService.SetItemAsync("bookmarks", new List<string>());
61+
}
62+
}
63+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
@page "/bookmarks"
2+
@using LinkDotNet.Blog.Domain
3+
@using LinkDotNet.Blog.Infrastructure.Persistence
4+
@inject IBookmarkService BookmarkService
5+
@inject IRepository<BlogPost> BlogPostRepository;
6+
7+
<div class="container">
8+
<h3 class="pb-3 fw-bold">Bookmarks</h3>
9+
@if (bookmarkedPosts.Count <= 0)
10+
{
11+
<div class="alert alert-info" role="alert">
12+
<h4 class="alert-heading">No bookmarks yet!</h4>
13+
<p>You can bookmark posts while browsing by clicking the bookmark icon <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi text-secondary" viewBox="0 0 16 16">
14+
<path d="M2 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v13.5a.5.5 0 0 1-.777.416L8 13.101l-5.223 2.815A.5.5 0 0 1 2 15.5V2zm2-1a1 1 0 0 0-1 1v12.566l4.723-2.482a.5.5 0 0 1 .554 0L13 14.566V2a1 1 0 0 0-1-1H4z"/>
15+
</svg> that appears on each post.</p>
16+
<hr>
17+
<p class="mb-0">Bookmarks are stored in your browser's local storage and are not synchronized across devices or browsers.</p>
18+
</div>
19+
}
20+
else
21+
{
22+
@foreach (var post in bookmarkedPosts)
23+
{
24+
<ShortBlogPost BlogPost="post" />
25+
}
26+
}
27+
</div>
28+
29+
@code {
30+
private IReadOnlyList<BlogPost> bookmarkedPosts = [];
31+
32+
protected override async Task OnAfterRenderAsync(bool firstRender)
33+
{
34+
if (firstRender)
35+
{
36+
var ids = await BookmarkService.GetBookmarkedPostIds();
37+
38+
if (ids.Any())
39+
{
40+
bookmarkedPosts = await BlogPostRepository.GetAllByProjectionAsync(post => post, post => ids.Contains(post.Id));
41+
StateHasChanged();
42+
}
43+
}
44+
}
45+
46+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<button type="button" class="btn btn-sm bg-transparent border-0" @onclick="OnBookmarkClicked" title="@(IsBookmarked ? "Remove bookmark" : "Add bookmark")">
2+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="@(IsBookmarked ? "text-warning" : "text-secondary")" viewBox="0 0 16 16">
3+
<path d="@(IsBookmarked ?
4+
"M2 2v13.5a.5.5 0 0 0 .74.439L8 13.069l5.26 2.87A.5.5 0 0 0 14 15.5V2a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2z" :
5+
"M2 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v13.5a.5.5 0 0 1-.777.416L8 13.101l-5.223 2.815A.5.5 0 0 1 2 15.5V2zm2-1a1 1 0 0 0-1 1v12.566l4.723-2.482a.5.5 0 0 1 .554 0L13 14.566V2a1 1 0 0 0-1-1H4z")"/>
6+
</svg>
7+
</button>
8+
9+
@code {
10+
[Parameter] public bool IsBookmarked { get; set; }
11+
[Parameter] public EventCallback Bookmarked { get; set; }
12+
13+
private async Task OnBookmarkClicked()
14+
{
15+
await Bookmarked.InvokeAsync();
16+
}
17+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using System.Collections.Generic;
2+
using System.Threading.Tasks;
3+
4+
namespace LinkDotNet.Blog.Web.Features.Bookmarks;
5+
6+
public interface IBookmarkService
7+
{
8+
public Task<bool> IsBookmarked(string postId);
9+
public Task<IReadOnlyList<string>> GetBookmarkedPostIds();
10+
public Task SetBookmark(string postId, bool isBookmarked);
11+
}

src/LinkDotNet.Blog.Web/Features/Components/ShortBlogPost.razor

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
@using LinkDotNet.Blog.Domain
2+
@using LinkDotNet.Blog.Web.Features.Bookmarks
3+
@using LinkDotNet.Blog.Web.Features.Bookmarks.Components
4+
@inject IBookmarkService BookmarkService
25

36
<article>
47
<div class="blog-card @AltCssClass">
@@ -33,11 +36,13 @@
3336
</ul>
3437
</div>
3538
<div class="description">
36-
<h1>@BlogPost.Title</h1>
37-
<h2></h2>
39+
<div class="header">
40+
<h1>@BlogPost.Title</h1>
41+
<BookmarkButton IsBookmarked="isBookmarked" Bookmarked="ToggleBookmark"></BookmarkButton>
42+
</div>
3843
<p>@MarkdownConverter.ToMarkupString(BlogPost.ShortDescription)</p>
3944
<p class="read-more">
40-
<a href="/blogPost/@BlogPost.Id/@BlogPost.Slug" aria-label="@BlogPost.Title">Read the whole article</a>
45+
<a href="/blogPost/@BlogPost.Id/@BlogPost.Slug" aria-label="@BlogPost.Title">Read the whole article</a>
4146
</p>
4247
</div>
4348
</div>
@@ -47,6 +52,8 @@
4752
[Parameter, EditorRequired]
4853
public required BlogPost BlogPost { get; set; }
4954

55+
private bool isBookmarked = false;
56+
5057
[Parameter]
5158
public bool UseAlternativeStyle { get; set; }
5259

@@ -55,6 +62,13 @@
5562

5663
private string AltCssClass => UseAlternativeStyle ? "alt" : string.Empty;
5764

65+
private async Task ToggleBookmark()
66+
{
67+
isBookmarked = !isBookmarked;
68+
await BookmarkService.SetBookmark(BlogPost.Id, isBookmarked);
69+
StateHasChanged();
70+
}
71+
5872
public override Task SetParametersAsync(ParameterView parameters)
5973
{
6074
foreach (var parameter in parameters)
@@ -75,4 +89,13 @@
7589

7690
return base.SetParametersAsync(ParameterView.Empty);
7791
}
92+
93+
protected override async Task OnAfterRenderAsync(bool firstRender)
94+
{
95+
if (firstRender)
96+
{
97+
isBookmarked = await BookmarkService.IsBookmarked(BlogPost.Id);
98+
StateHasChanged();
99+
}
100+
}
78101
}

src/LinkDotNet.Blog.Web/Features/Components/ShortBlogPost.razor.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@
8080
z-index: 1;
8181
}
8282

83+
.blog-card .description .header {
84+
display: flex;
85+
justify-content: space-between;
86+
}
87+
8388
.blog-card .description h1 {
8489
line-height: 1;
8590
margin: 0 0 5px 0;

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@
2323
</button>
2424
<div class="collapse navbar-collapse" id="navbarSupportedContent">
2525
<ul class="navbar-nav ms-auto mb-2 mb-lg-0 me-5">
26-
<li><a class="nav-link" href="/"><i class="home"></i> Home</a></li>
27-
<li><a class="nav-link" href="/archive"><i class="books"></i> Archive</a></li>
28-
@if (Configuration.Value.IsAboutMeEnabled)
26+
<li><a class="nav-link" href="/"><i class="home"></i> Home</a></li>
27+
<li><a class="nav-link" href="/archive"><i class="books"></i> Archive</a></li>
28+
<li><a class="nav-link" href="/bookmarks"><i class="bookmark"></i> Bookmarks</a>
29+
</li>
30+
@if (Configuration.Value.IsAboutMeEnabled)
2931
{
3032
<li class="nav-item">
3133
<a class="nav-link" href="AboutMe">
@@ -48,8 +50,8 @@
4850
<i class="rss2"></i> RSS
4951
</a>
5052
<ul class="dropdown-menu" aria-labelledby="rssDropdown">
51-
<li><a class="dropdown-item" href="/feed.rss" aria-label="RSS with All Posts">All Posts (Summary)</a></li>
52-
<li><a class="dropdown-item" href="/feed.rss?withContent=true" aria-label="RSS with Full Content">Most Recent Posts (Full Content)</a></li>
53+
<li><a class="dropdown-item" href="/feed.rss" aria-label="RSS with All Posts">All Posts (Summary)</a></li>
54+
<li><a class="dropdown-item" href="/feed.rss?withContent=true" aria-label="RSS with Full Content">Most Recent Posts (Full Content)</a></li>
5355
</ul>
5456
</li>
5557

src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
@using Markdig
44
@using LinkDotNet.Blog.Domain
55
@using LinkDotNet.Blog.Infrastructure.Persistence
6+
@using LinkDotNet.Blog.Web.Features.Bookmarks
67
@using LinkDotNet.Blog.Web.Features.Services
78
@using LinkDotNet.Blog.Web.Features.ShowBlogPost.Components
89
@using LinkDotNet.Blog.Web.Features.SupportMe.Components
10+
@using LinkDotNet.Blog.Web.Features.Bookmarks.Components
911
@inject IRepository<BlogPost> BlogPostRepository
1012
@inject IRepository<ShortCode> ShortCodeRepository
1113
@inject IJSRuntime JsRuntime
@@ -14,6 +16,7 @@
1416
@inject IOptions<ApplicationConfiguration> AppConfiguration
1517
@inject IOptions<ProfileInformation> ProfileInformation
1618
@inject IOptions<SupportMeConfiguration> SupportConfiguration
19+
@inject IBookmarkService BookmarkService
1720

1821
@if (isLoading)
1922
{
@@ -25,7 +28,7 @@ else if (!isLoading && BlogPost is null)
2528
}
2629
else if (BlogPost is not null)
2730
{
28-
<PageTitle>@BlogPost.Title</PageTitle>
31+
<PageTitle>@BlogPost.Title</PageTitle>
2932
<OgData Title="@BlogPost.Title"
3033
AbsolutePreviewImageUrl="@OgDataImage"
3134
Description="@(Markdown.ToPlainText(BlogPost.ShortDescription))"
@@ -49,7 +52,10 @@ else if (BlogPost is not null)
4952
<span class="ms-1">@BlogPost.UpdatedDate.ToShortDateString()</span>
5053
</div>
5154
<span class="read-time"></span>
52-
<span class="me-2">@BlogPost.ReadingTimeInMinutes minute read</span>
55+
<span class="me-2">@BlogPost.ReadingTimeInMinutes minute read</span>
56+
<div class="d-flex align-items-center">
57+
<BookmarkButton IsBookmarked="isBookmarked" Bookmarked="BlogPostBookmarked"></BookmarkButton>
58+
</div>
5359
@if (BlogPost.Tags is not null && BlogPost.Tags.Any())
5460
{
5561
<div class="d-flex align-items-center">
@@ -110,6 +116,7 @@ else if (BlogPost is not null)
110116
private string OgDataImage => BlogPost!.PreviewImageUrlFallback ?? BlogPost.PreviewImageUrl;
111117
private string BlogPostCanoncialUrl => $"blogPost/{BlogPost?.Id}";
112118
private IReadOnlyCollection<ShortCode> shortCodes = [];
119+
private bool isBookmarked;
113120

114121
private BlogPost? BlogPost { get; set; }
115122

@@ -129,6 +136,12 @@ else if (BlogPost is not null)
129136
{
130137
await JsRuntime.InvokeVoidAsync("hljs.highlightAll");
131138
_ = UserRecordService.StoreUserRecordAsync();
139+
140+
if (BlogPost is not null && firstRender)
141+
{
142+
isBookmarked = await BookmarkService.IsBookmarked(BlogPost.Id);
143+
StateHasChanged();
144+
}
132145
}
133146

134147
private MarkupString EnrichWithShortCodes(string content)
@@ -154,4 +167,17 @@ else if (BlogPost is not null)
154167
BlogPost.Likes = hasLiked ? BlogPost.Likes + 1 : BlogPost.Likes - 1;
155168
await BlogPostRepository.StoreAsync(BlogPost);
156169
}
170+
171+
private async Task BlogPostBookmarked()
172+
{
173+
if (BlogPost is null)
174+
{
175+
return;
176+
}
177+
178+
isBookmarked = !isBookmarked;
179+
await BookmarkService.SetBookmark(BlogPost.Id, isBookmarked);
180+
StateHasChanged();
181+
}
182+
157183
}

src/LinkDotNet.Blog.Web/Pages/_Host.cshtml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@
55
Layout = "_Layout";
66
}
77

8-
<component type="typeof(App)" render-mode="ServerPrerendered" />
8+
<component type="typeof(App)" render-mode="ServerPrerendered" />

src/LinkDotNet.Blog.Web/ServiceExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Blazorise.Bootstrap5;
55
using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services;
66
using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services;
7+
using LinkDotNet.Blog.Web.Features.Bookmarks;
78
using LinkDotNet.Blog.Web.Features.Services;
89
using LinkDotNet.Blog.Web.RegistrationExtensions;
910
using Microsoft.AspNetCore.Builder;
@@ -19,6 +20,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
1920
services.AddScoped<ILocalStorageService, LocalStorageService>();
2021
services.AddScoped<ISortOrderCalculator, SortOrderCalculator>();
2122
services.AddScoped<IUserRecordService, UserRecordService>();
23+
services.AddScoped<IBookmarkService, BookmarkService>();
2224
services.AddScoped<ISitemapService, SitemapService>();
2325
services.AddScoped<IXmlWriter, XmlWriter>();
2426
services.AddScoped<IFileProcessor, FileProcessor>();

src/LinkDotNet.Blog.Web/wwwroot/css/fonts/Blog.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1929,7 +1929,10 @@
19291929
"ligatures": "bookmark, ribbon",
19301930
"name": "bookmark",
19311931
"id": 210,
1932-
"order": 0
1932+
"order": 87,
1933+
"prevSize": 32,
1934+
"code": 59858,
1935+
"tempChar": ""
19331936
},
19341937
{
19351938
"ligatures": "bookmarks, ribbons",
@@ -3150,7 +3153,7 @@
31503153
"order": 85,
31513154
"prevSize": 32,
31523155
"code": 60061,
3153-
"tempChar": ""
3156+
"tempChar": ""
31543157
},
31553158
{
31563159
"ligatures": "youtube2, brand22",
@@ -13357,6 +13360,5 @@
1335713360
"showCodes": true,
1335813361
"gridSize": 16
1335913362
},
13360-
"uid": -1,
13361-
"time": 1731146188893
13363+
"uid": -1
1336213364
}
Binary file not shown.
Binary file not shown.

0 commit comments

Comments
 (0)