-
-
Notifications
You must be signed in to change notification settings - Fork 80
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
Feature: Bookmarks #403
Changes from 19 commits
5428428
4244410
b396e1c
a5df71e
6bdb0d8
890744d
720ef5d
9f75386
4a2d9ac
cf5922a
9f3b6a1
3badad6
a697224
220eb5a
8fef145
7233cab
1910223
7548d03
3eeca43
91bc212
0304be3
71533ae
e590c40
52f3a8a
7b4c994
22fc52b
910707f
e7468e2
efa0e0f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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> |
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)); | ||
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)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
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"))) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>()); | ||
} | ||
} |
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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
StateHasChanged(); | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
<button @onclick="OnClickHandler"> | ||
linkdotnet marked this conversation as resolved.
Show resolved
Hide resolved
|
||
@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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We either could move this into |
||
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(); | ||
} | ||
} |
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would also write |
||
public Task<IReadOnlyList<string>> GetBookmarkedPostIds(); | ||
public Task ToggleBookmark(string postId); | ||
} |
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"> | ||
|
@@ -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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
|
@@ -47,6 +52,8 @@ | |
[Parameter, EditorRequired] | ||
public required BlogPost BlogPost { get; set; } | ||
|
||
private bool isBookmarked = false; | ||
|
||
[Parameter] | ||
public bool UseAlternativeStyle { get; set; } | ||
|
||
|
@@ -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 |
---|---|---|
|
@@ -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 | ||
|
@@ -14,6 +15,7 @@ | |
@inject IOptions<Introduction> Introduction | ||
@inject IOptions<ApplicationConfiguration> AppConfiguration | ||
@inject NavigationManager NavigationManager | ||
@inject IBookmarkService BookmarkService | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems not needed - can be removed |
||
|
||
<PageTitle>@AppConfiguration.Value.BlogName</PageTitle> | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -14,6 +16,7 @@ | |
@inject IOptions<ApplicationConfiguration> AppConfiguration | ||
@inject IOptions<ProfileInformation> ProfileInformation | ||
@inject IOptions<SupportMeConfiguration> SupportConfiguration | ||
@inject IBookmarkService BookmarkService | ||
|
||
@if (isLoading) | ||
{ | ||
|
@@ -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))" | ||
|
@@ -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"> | ||
|
@@ -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; } | ||
|
||
|
@@ -129,6 +134,10 @@ else if (BlogPost is not null) | |
{ | ||
await JsRuntime.InvokeVoidAsync("hljs.highlightAll"); | ||
_ = UserRecordService.StoreUserRecordAsync(); | ||
|
||
ArgumentNullException.ThrowIfNull(BlogPost); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As this isn't an argument, the |
||
isBookmarked = await BookmarkService.IsBookMarked(BlogPost.Id); | ||
StateHasChanged(); | ||
} | ||
|
||
private MarkupString EnrichWithShortCodes(string content) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.