From a5358dcfebb638ee40fb66d275e1347e45e14e8a Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Mon, 9 Nov 2026 14:58:49 +0100 Subject: [PATCH 1/2] feat: Shared segmented control One global .segmented style and a SegmentedControl component replace the four per-page copies (topic discovery, search, bookmarks, shortcodes). The About Me tab list reuses the same style, so all toggles look alike. --- .../AboutMe/Components/TabbedNavigation.razor | 2 +- .../Components/TabbedNavigation.razor.css | 10 +---- .../Admin/ShortCodes/ShortCodesPage.razor | 7 +--- .../Admin/ShortCodes/ShortCodesPage.razor.css | 31 +------------- .../Features/Bookmarks/Bookmarks.razor | 7 +--- .../Features/Bookmarks/Bookmarks.razor.css | 41 +----------------- .../Components/SegmentedControl.razor | 27 ++++++++++++ .../Features/Search/SearchResultsView.razor | 14 ++++--- .../Search/SearchResultsView.razor.css | 29 +------------ .../TagDiscovery/TagDiscoveryPanel.razor | 10 +---- .../TagDiscovery/TagDiscoveryPanel.razor.css | 26 ------------ src/LinkDotNet.Blog.Web/wwwroot/css/basic.css | 42 +++++++++++++++++++ .../Web/Features/Bookmarks/BookmarksTests.cs | 2 +- 13 files changed, 89 insertions(+), 159 deletions(-) create mode 100644 src/LinkDotNet.Blog.Web/Features/Components/SegmentedControl.razor diff --git a/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/TabbedNavigation.razor b/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/TabbedNavigation.razor index ddcda9e5..14b5a89f 100644 --- a/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/TabbedNavigation.razor +++ b/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/TabbedNavigation.razor @@ -2,7 +2,7 @@ @using LinkDotNet.Blog.Web.Features.AboutMe.Components.Talk
- var visibleShortCodes = GetVisibleShortCodes(); diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/ShortCodes/ShortCodesPage.razor.css b/src/LinkDotNet.Blog.Web/Features/Admin/ShortCodes/ShortCodesPage.razor.css index d08c87d9..5e203211 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/ShortCodes/ShortCodesPage.razor.css +++ b/src/LinkDotNet.Blog.Web/Features/Admin/ShortCodes/ShortCodesPage.razor.css @@ -110,34 +110,6 @@ font-size: 1rem; } -.segmented-control { - display: flex; - padding: 0.125rem; - border-radius: 0.75rem; - background: var(--tag-background); -} - -.segment { - flex: 1 1 auto; - min-height: 2.5rem; - padding-inline: 0.875rem; - border: 0; - border-radius: 0.625rem; - background: transparent; - color: inherit; - font-size: 0.9375rem; - font-weight: 500; - white-space: nowrap; - cursor: pointer; - transition: background-color 0.15s ease, box-shadow 0.15s ease; -} - -.segment[aria-pressed="true"] { - background: var(--surface); - font-weight: 600; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); -} - .no-results { padding: 3rem 1rem; text-align: center; @@ -547,7 +519,6 @@ } .sheet-action:focus-visible, -.segment:focus-visible, .token-chip:focus-visible, .card-action:focus-visible, .template-chip:focus-visible, @@ -566,7 +537,7 @@ grid-template-columns: 1fr; } - .segmented-control { + .toolbar ::deep .segmented { flex: 1 1 100%; } diff --git a/src/LinkDotNet.Blog.Web/Features/Bookmarks/Bookmarks.razor b/src/LinkDotNet.Blog.Web/Features/Bookmarks/Bookmarks.razor index 01fbdd4b..ab7117a9 100644 --- a/src/LinkDotNet.Blog.Web/Features/Bookmarks/Bookmarks.razor +++ b/src/LinkDotNet.Blog.Web/Features/Bookmarks/Bookmarks.razor @@ -47,12 +47,7 @@ -
- @foreach (var option in Enum.GetValues()) - { - - } -
+
var visiblePosts = GetVisiblePosts(); diff --git a/src/LinkDotNet.Blog.Web/Features/Bookmarks/Bookmarks.razor.css b/src/LinkDotNet.Blog.Web/Features/Bookmarks/Bookmarks.razor.css index 1aaed15d..42ed7377 100644 --- a/src/LinkDotNet.Blog.Web/Features/Bookmarks/Bookmarks.razor.css +++ b/src/LinkDotNet.Blog.Web/Features/Bookmarks/Bookmarks.razor.css @@ -55,39 +55,6 @@ font-size: 1rem; } -.segmented-control { - display: flex; - padding: 0.125rem; - border-radius: 0.75rem; - background: var(--tag-background); -} - -.segment { - flex: 1 1 auto; - min-height: 2.5rem; - padding-inline: 0.875rem; - border: 0; - border-radius: 0.625rem; - background: transparent; - color: inherit; - font-size: 0.9375rem; - font-weight: 500; - white-space: nowrap; - cursor: pointer; - transition: background-color 0.15s ease, box-shadow 0.15s ease; -} - -.segment[aria-pressed="true"] { - background: var(--surface); - font-weight: 600; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); -} - -.segment:focus-visible { - outline: 2px solid var(--bs-primary-base, #0a84ff); - outline-offset: 1px; -} - /* Inset-grouped list */ .list-group-inset { margin: 0; @@ -226,11 +193,11 @@ font-size: 2rem; } - .segmented-control { + .toolbar ::deep .segmented { flex: 1 1 100%; } - .segment { + .toolbar ::deep .segmented > button { padding-inline: 0.5rem; } @@ -246,8 +213,4 @@ .skeleton-text { animation: none; } - - .segment { - transition: none; - } } diff --git a/src/LinkDotNet.Blog.Web/Features/Components/SegmentedControl.razor b/src/LinkDotNet.Blog.Web/Features/Components/SegmentedControl.razor new file mode 100644 index 00000000..5c87d19f --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Components/SegmentedControl.razor @@ -0,0 +1,27 @@ +@typeparam TValue + +
+ @foreach (var option in Options) + { + + } +
+ +@code { + [Parameter, EditorRequired] + public required IReadOnlyList Options { get; set; } + + [Parameter] + public TValue? Value { get; set; } + + [Parameter] + public EventCallback ValueChanged { get; set; } + + [Parameter, EditorRequired] + public required Func Label { get; set; } + + [Parameter, EditorRequired] + public required string AriaLabel { get; set; } +} diff --git a/src/LinkDotNet.Blog.Web/Features/Search/SearchResultsView.razor b/src/LinkDotNet.Blog.Web/Features/Search/SearchResultsView.razor index e5ae35ed..1bfb86f5 100644 --- a/src/LinkDotNet.Blog.Web/Features/Search/SearchResultsView.razor +++ b/src/LinkDotNet.Blog.Web/Features/Search/SearchResultsView.razor @@ -44,11 +44,8 @@ { } -
- - - -
+ } @@ -161,7 +158,12 @@ private string IsSelected(string tag) => ToAriaBoolean(selectedTags.Contains(tag)); - private string IsSortedBy(PostSortOrder order) => ToAriaBoolean(sortOrder == order); + private static string SortLabel(PostSortOrder order) => order switch + { + PostSortOrder.Oldest => "Oldest", + PostSortOrder.ShortestRead => "Quick reads", + _ => "Newest", + }; private static string ToAriaBoolean(bool value) => value ? "true" : "false"; diff --git a/src/LinkDotNet.Blog.Web/Features/Search/SearchResultsView.razor.css b/src/LinkDotNet.Blog.Web/Features/Search/SearchResultsView.razor.css index dd91843d..b93554c7 100644 --- a/src/LinkDotNet.Blog.Web/Features/Search/SearchResultsView.razor.css +++ b/src/LinkDotNet.Blog.Web/Features/Search/SearchResultsView.razor.css @@ -100,7 +100,6 @@ } .results-view ::deep .chip:focus-visible, -.segmented button:focus-visible, .clear-filters:focus-visible { outline: 2px solid var(--bs-primary-base, #0a84ff); outline-offset: 2px; @@ -142,31 +141,8 @@ font-weight: 600; } -.segmented { - display: inline-grid; - grid-auto-flow: column; +.toolbar ::deep .segmented { margin-inline-start: auto; - padding: 2px; - border-radius: 0.625rem; - background: var(--tag-background); -} - -.segmented button { - min-height: 2rem; - padding: 0 0.875rem; - border: 0; - border-radius: 0.5rem; - background: transparent; - color: inherit; - font-size: 0.8125rem; - font-weight: 500; - transition: background-color 0.15s, box-shadow 0.15s; -} - -.segmented button[aria-pressed="true"] { - background: light-dark(#fff, #636366); - font-weight: 600; - box-shadow: 0 1px 3px rgb(0 0 0 / 0.2); } .empty-state { @@ -186,9 +162,8 @@ } @media (width < 576px) { - .segmented { + .toolbar ::deep .segmented { width: 100%; - grid-auto-columns: 1fr; margin-inline-start: 0; } } diff --git a/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor index a292d4b4..38e497f9 100644 --- a/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor +++ b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor @@ -28,12 +28,8 @@ @bind="_searchTerm" @bind:event="oninput" /> -
- - -
+
@@ -109,8 +105,6 @@ } } - private string IsSorted(TagSortOrder sortOrder) => (_sortOrder == sortOrder).ToString().ToLowerInvariant(); - private IReadOnlyList GetGroups() { var matches = _tags diff --git a/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor.css b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor.css index c817202e..f3e25067 100644 --- a/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor.css +++ b/src/LinkDotNet.Blog.Web/Features/TagDiscovery/TagDiscoveryPanel.razor.css @@ -54,31 +54,6 @@ padding: 0.5rem 1rem 0.75rem; } -.segmented { - display: grid; - grid-template-columns: 1fr 1fr; - padding: 2px; - border-radius: 0.625rem; - background: var(--tag-background); -} - -.segmented button { - min-height: 2rem; - border: 0; - border-radius: 0.5rem; - background: transparent; - color: inherit; - font-size: 0.8125rem; - font-weight: 500; - transition: background-color 0.15s, box-shadow 0.15s; -} - -.segmented button[aria-pressed="true"] { - background: light-dark(#fff, #636366); - font-weight: 600; - box-shadow: 0 1px 3px rgb(0 0 0 / 0.2); -} - .sheet-scroll { flex: 1 1 auto; min-height: 0; @@ -164,7 +139,6 @@ section:first-child .group:first-child { } .sheet-done:focus-visible, -.segmented button:focus-visible, .tag-row:focus-visible { outline: 2px solid var(--bs-primary-base, #0a84ff); outline-offset: -2px; diff --git a/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css b/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css index 4e1f48ab..c4e5cd39 100644 --- a/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css +++ b/src/LinkDotNet.Blog.Web/wwwroot/css/basic.css @@ -534,3 +534,45 @@ section { justify-content: center; } /*endregion */ + +/*#region SegmentedControl.razor */ +.segmented { + display: inline-grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + padding: 2px; + border-radius: 0.625rem; + background: var(--tag-background); +} + +.segmented > button { + min-height: 2rem; + padding: 0 0.875rem; + border: 0; + border-radius: 0.5rem; + background: transparent; + color: inherit; + font-size: 0.8125rem; + font-weight: 500; + white-space: nowrap; + transition: background-color 0.15s, box-shadow 0.15s; +} + +.segmented > [aria-pressed="true"], +.segmented > [aria-selected="true"] { + background: light-dark(#fff, #636366); + font-weight: 600; + box-shadow: 0 1px 3px rgb(0 0 0 / 0.2); +} + +.segmented > button:focus-visible { + outline: 2px solid var(--bs-primary-base, #0a84ff); + outline-offset: -2px; +} + +@media (prefers-reduced-motion: reduce) { + .segmented > button { + transition: none; + } +} +/*endregion */ diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Bookmarks/BookmarksTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Bookmarks/BookmarksTests.cs index ffea0df4..f839867f 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Bookmarks/BookmarksTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Bookmarks/BookmarksTests.cs @@ -186,7 +186,7 @@ public async Task ShouldSortBookmarksByTitle() cut.FindAll(".row-title").Select(e => e.TextContent).ShouldBe(["Zebra", "Apple"]); // Act - await cut.FindAll(".segment").Single(s => s.TextContent == "Title").ClickAsync(); + await cut.FindAll(".segmented button").Single(s => s.TextContent == "Title").ClickAsync(); // Assert cut.FindAll(".row-title").Select(e => e.TextContent).ShouldBe(["Apple", "Zebra"]); From 8716323a0e8bd283be2c7f7056a279865c975d05 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Mon, 9 Nov 2026 14:58:50 +0100 Subject: [PATCH 2/2] perf: Load data once per visit and stop hydration flashes - Paging skips the COUNT query when the page comes back short, halving queries for every get-all call (SQL, RavenDB, MongoDB) - Prerendered data is handed to the interactive render via [PersistentState] for blog post short codes, About Me and the archive, so the circuit no longer re-queries and renders empty in between - Preload only the woff2 icon font, load the highlight.js theme non-blocking and preconnect CORS for the jsdelivr fonts --- .../PagedList.cs | 6 ++ .../MongoDB/PaginatedListQueryExtensions.cs | 17 ++---- .../RavenDb/PaginatedListQueryExtensions.cs | 17 ++---- .../Sql/PaginatedListQueryExtensions.cs | 23 +++---- src/LinkDotNet.Blog.Web/App.razor | 4 +- .../Features/AboutMe/Components/Profile.razor | 5 +- .../AboutMe/Components/ProfileStats.razor | 12 +++- .../AboutMe/Components/Skill/SkillTable.razor | 5 +- .../AboutMe/Components/Talk/Talks.razor | 5 +- .../Features/Archive/ArchivePage.razor | 13 ++-- .../Features/PersistedStateSerializer.cs | 61 +++++++++++++++++++ .../ShowBlogPost/ShowBlogPostPage.razor | 16 +++-- src/LinkDotNet.Blog.Web/ServiceExtensions.cs | 3 + src/LinkDotNet.Blog.Web/wwwroot/css/icons.css | 1 + .../Sql/BlogPostRepositoryTests.cs | 27 ++++++++ .../Features/PersistedStateSerializerTests.cs | 52 ++++++++++++++++ 16 files changed, 214 insertions(+), 53 deletions(-) create mode 100644 src/LinkDotNet.Blog.Web/Features/PersistedStateSerializer.cs create mode 100644 tests/LinkDotNet.Blog.UnitTests/Web/Features/PersistedStateSerializerTests.cs diff --git a/src/LinkDotNet.Blog.Infrastructure/PagedList.cs b/src/LinkDotNet.Blog.Infrastructure/PagedList.cs index 8d81b256..a16d8423 100644 --- a/src/LinkDotNet.Blog.Infrastructure/PagedList.cs +++ b/src/LinkDotNet.Blog.Infrastructure/PagedList.cs @@ -24,6 +24,12 @@ public PagedList(IReadOnlyList items, int totalCount, int pageNumber, int pag public int PageNumber { get; } + internal static int? TotalCountFromPage(int itemCount, int page, int pageSize) + => itemCount < pageSize && (itemCount > 0 || page == 1) ? ((page - 1) * pageSize) + itemCount : null; + + internal static IPagedList Create(IReadOnlyList items, int totalCount, int page, int pageSize) + => totalCount == 0 ? Empty : new PagedList(items, totalCount, page, pageSize); + public bool IsFirstPage => PageNumber == 1; public bool IsLastPage => PageNumber == totalPages; diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/MongoDB/PaginatedListQueryExtensions.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/MongoDB/PaginatedListQueryExtensions.cs index 99cf5a81..1d410951 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/MongoDB/PaginatedListQueryExtensions.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/MongoDB/PaginatedListQueryExtensions.cs @@ -9,16 +9,11 @@ public static class PaginatedListQueryExtensions { public static async Task> ToPagedListAsync(this IQueryable source, int pageIndex, int pageSize, CancellationToken token = default) { - var count = await source.CountAsync(token); - if (count > 0) - { - var items = await source - .Skip((pageIndex - 1) * pageSize) - .Take(pageSize) - .ToListAsync(token); - return new PagedList(items, count, pageIndex, pageSize); - } - - return PagedList.Empty; + var items = await source + .Skip((pageIndex - 1) * pageSize) + .Take(pageSize) + .ToListAsync(token); + var count = PagedList.TotalCountFromPage(items.Count, pageIndex, pageSize) ?? await source.CountAsync(token); + return PagedList.Create(items, count, pageIndex, pageSize); } } diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/RavenDb/PaginatedListQueryExtensions.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/RavenDb/PaginatedListQueryExtensions.cs index 635bb797..855480ed 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/RavenDb/PaginatedListQueryExtensions.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/RavenDb/PaginatedListQueryExtensions.cs @@ -9,16 +9,11 @@ public static class PaginatedListQueryExtensions { public static async Task> ToPagedListAsync(this IRavenQueryable source, int pageIndex, int pageSize, CancellationToken token = default) { - var count = await source.CountAsync(token); - if (count > 0) - { - var items = await source - .Skip((pageIndex - 1) * pageSize) - .Take(pageSize) - .ToArrayAsync(token); - return new PagedList(items, count, pageIndex, pageSize); - } - - return PagedList.Empty; + var items = await source + .Skip((pageIndex - 1) * pageSize) + .Take(pageSize) + .ToArrayAsync(token); + var count = PagedList.TotalCountFromPage(items.Length, pageIndex, pageSize) ?? await source.CountAsync(token); + return PagedList.Create(items, count, pageIndex, pageSize); } } diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/PaginatedListQueryExtensions.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/PaginatedListQueryExtensions.cs index 265ca92c..2d712ad3 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/PaginatedListQueryExtensions.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/PaginatedListQueryExtensions.cs @@ -9,21 +9,16 @@ public static class PaginatedListQueryExtensions { public static async Task> ToPagedListAsync(this IQueryable source, int page, int pageSize, CancellationToken token = default) { - var count = await source.CountAsync(token); - if (count > 0) - { - // I tried ToListAsync and it performed just poorly! - // Mainly because we have a VARCHAR(max) column - // See here: https://stackoverflow.com/questions/28543293/entity-framework-async-operation-takes-ten-times-as-long-to-complete/28619983 + // I tried ToListAsync and it performed just poorly! + // Mainly because we have a VARCHAR(max) column + // See here: https://stackoverflow.com/questions/28543293/entity-framework-async-operation-takes-ten-times-as-long-to-complete/28619983 #pragma warning disable S6966 - var items = source - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToArray(); + var items = source + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToArray(); #pragma warning restore S6966 - return new PagedList(items, count, page, pageSize); - } - - return PagedList.Empty; + var count = PagedList.TotalCountFromPage(items.Length, page, pageSize) ?? await source.CountAsync(token); + return PagedList.Create(items, count, page, pageSize); } } diff --git a/src/LinkDotNet.Blog.Web/App.razor b/src/LinkDotNet.Blog.Web/App.razor index b3dec827..57c8cf97 100644 --- a/src/LinkDotNet.Blog.Web/App.razor +++ b/src/LinkDotNet.Blog.Web/App.razor @@ -26,14 +26,14 @@ + - - + diff --git a/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/Profile.razor b/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/Profile.razor index 10878663..ea2b8baf 100644 --- a/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/Profile.razor +++ b/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/Profile.razor @@ -55,13 +55,16 @@ public RenderFragment? Stats { get; set; } private List profileInformationEntries = []; + + [PersistentState] + public List? PersistedEntries { get; set; } private ConfirmDialog Dialog { get; set; } = default!; private string? currentDeleteKey; private ProfileInformationEntry? currentDragItem; protected override async Task OnInitializedAsync() { - profileInformationEntries = (await Repository.GetAllAsync(orderBy: d => d.SortOrder, descending: false)).ToList(); + profileInformationEntries = PersistedEntries ??= (await Repository.GetAllAsync(orderBy: d => d.SortOrder, descending: false)).ToList(); } private void ShowDeleteDialog(string key) diff --git a/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/ProfileStats.razor b/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/ProfileStats.razor index f289c8b7..91137283 100644 --- a/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/ProfileStats.razor +++ b/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/ProfileStats.razor @@ -48,6 +48,12 @@ private bool isUpcoming; private DateTime today; + [PersistentState] + public List? PersistedTalks { get; set; } + + [PersistentState] + public List? PersistedSkills { get; set; } + private string SpotlightDetail => (spotlightTalk!.PublishedDate.Date - today).Days switch { _ when !isUpcoming => spotlightTalk.PublishedDate.Year.ToString(CultureInfo.InvariantCulture), @@ -60,6 +66,8 @@ public async Task RefreshAsync() { + PersistedTalks = null; + PersistedSkills = null; await LoadAsync(); StateHasChanged(); } @@ -67,8 +75,8 @@ private async Task LoadAsync() { today = TimeProvider.GetLocalNow().Date; - var talks = await TalkRepository.GetAllAsync(orderBy: t => t.PublishedDate, descending: false); - var skills = await SkillRepository.GetAllAsync(); + var talks = PersistedTalks ??= (await TalkRepository.GetAllAsync(orderBy: t => t.PublishedDate, descending: false)).ToList(); + var skills = PersistedSkills ??= (await SkillRepository.GetAllAsync()).ToList(); talkCount = talks.Count; firstTalkYear = talks.FirstOrDefault()?.PublishedDate.Year ?? 0; diff --git a/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/Skill/SkillTable.razor b/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/Skill/SkillTable.razor index bad28a0c..ced1bf4f 100644 --- a/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/Skill/SkillTable.razor +++ b/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/Skill/SkillTable.razor @@ -72,11 +72,14 @@ private List skills = []; + [PersistentState] + public List? PersistedSkills { get; set; } + private Skill? currentDragItem; protected override async Task OnInitializedAsync() { - skills = (await Repository.GetAllAsync()).ToList(); + skills = PersistedSkills ??= (await Repository.GetAllAsync()).ToList(); } private async Task AddSkill(Skill skillToAdd) diff --git a/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/Talk/Talks.razor b/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/Talk/Talks.razor index 2a1d0976..4e91af5e 100644 --- a/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/Talk/Talks.razor +++ b/src/LinkDotNet.Blog.Web/Features/AboutMe/Components/Talk/Talks.razor @@ -42,6 +42,9 @@ else private List talks = []; + [PersistentState] + public List? PersistedTalks { get; set; } + private AddTalkEntryDialog AddTalkEntryDialog { get; set; } = default!; private RenderFragment<(string Title, List Talks, bool IsUpcoming)> TalkGroup => group => @@ -57,7 +60,7 @@ else protected override async Task OnInitializedAsync() { - talks = (await Repository.GetAllAsync(orderBy: t => t.PublishedDate, descending: true)).ToList(); + talks = PersistedTalks ??= (await Repository.GetAllAsync(orderBy: t => t.PublishedDate, descending: true)).ToList(); } private async Task DeleteTalk(Talk talk) diff --git a/src/LinkDotNet.Blog.Web/Features/Archive/ArchivePage.razor b/src/LinkDotNet.Blog.Web/Features/Archive/ArchivePage.razor index 92a43d20..fbdda449 100644 --- a/src/LinkDotNet.Blog.Web/Features/Archive/ArchivePage.razor +++ b/src/LinkDotNet.Blog.Web/Features/Archive/ArchivePage.razor @@ -46,14 +46,17 @@ private IReadOnlyCollection>? blogPostsPerYear; private int blogPostCount; + [PersistentState] + public List? BlogPosts { get; set; } + protected override async Task OnInitializedAsync() { - var blogPosts = await Repository.GetAllByProjectionAsync( + BlogPosts ??= (await Repository.GetAllByProjectionAsync( p => new BlogPostPerYear(p.Id, p.Slug, p.Title, p.UpdatedDate), - p => p.IsPublished); + p => p.IsPublished)).ToList(); - blogPostCount = blogPosts.Count; - blogPostsPerYear = blogPosts + blogPostCount = BlogPosts.Count; + blogPostsPerYear = BlogPosts .GroupBy(r => r.UpdatedDate.Year) .OrderByDescending(r => r.Key) .ToImmutableArray(); @@ -61,5 +64,5 @@ private static string HeadingKey(int year) => $"heading{year}"; - private sealed record BlogPostPerYear(string Id, string Slug, string Title, DateTime UpdatedDate); + public sealed record BlogPostPerYear(string Id, string Slug, string Title, DateTime UpdatedDate); } diff --git a/src/LinkDotNet.Blog.Web/Features/PersistedStateSerializer.cs b/src/LinkDotNet.Blog.Web/Features/PersistedStateSerializer.cs new file mode 100644 index 00000000..49885784 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/PersistedStateSerializer.cs @@ -0,0 +1,61 @@ +using System; +using System.Buffers; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using LinkDotNet.Blog.Domain; +using Microsoft.AspNetCore.Components; + +namespace LinkDotNet.Blog.Web.Features; + +public sealed class PersistedStateSerializer : PersistentComponentStateSerializer +{ + public override void Persist(T value, IBufferWriter writer) + { + using var json = new Utf8JsonWriter(writer); + JsonSerializer.Serialize(json, value, PersistedStateJson.Options); + } + + public override T Restore(ReadOnlySequence data) + { + var reader = new Utf8JsonReader(data); + return JsonSerializer.Deserialize(ref reader, PersistedStateJson.Options)!; + } +} + +internal static class PersistedStateJson +{ + public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver { Modifiers = { AllowNonPublicConstructionOfEntities } }, + Converters = { new ProficiencyLevelConverter() }, + }; + + private static void AllowNonPublicConstructionOfEntities(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object || !typeof(Entity).IsAssignableFrom(typeInfo.Type)) + { + return; + } + + typeInfo.CreateObject ??= () => RuntimeHelpers.GetUninitializedObject(typeInfo.Type); + foreach (var property in typeInfo.Properties) + { + if (property.Set is null && property.AttributeProvider is PropertyInfo info && info.GetSetMethod(nonPublic: true) is { } setter) + { + property.Set = (target, value) => setter.Invoke(target, [value]); + } + } + } + + private sealed class ProficiencyLevelConverter : JsonConverter + { + public override ProficiencyLevel Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => ProficiencyLevel.Create(reader.GetString()!); + + public override void Write(Utf8JsonWriter writer, ProficiencyLevel value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Key); + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor index d8502214..fbc5de25 100644 --- a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor @@ -133,22 +133,28 @@ else if (BlogPost is not null) private string BlogPostCanoncialUrl => string.IsNullOrEmpty(BlogPost?.Slug) ? $"blogPost/{BlogPost?.Id}" : $"blogPost/{BlogPost.Id}/{BlogPost.Slug}"; - private IReadOnlyCollection shortCodes = []; private bool isBookmarked; private string? spiedBlogPostId; private MarkupString renderedContent; private BlogPost? BlogPost { get; set; } + [PersistentState] + public IReadOnlyCollection? ShortCodes { get; set; } + protected override async Task OnInitializedAsync() { - shortCodes = await ShortCodeRepository.GetAllAsync(); + ShortCodes ??= await ShortCodeRepository.GetAllAsync(); } protected override async Task OnParametersSetAsync() { - isLoading = true; - BlogPost = await BlogPostRepository.GetByIdAsync(BlogPostId); + if (BlogPost?.Id != BlogPostId) + { + isLoading = true; + BlogPost = await BlogPostRepository.GetByIdAsync(BlogPostId); + } + renderedContent = BlogPost is null ? default : EnrichWithShortCodes(BlogPost.Content); isLoading = false; } @@ -180,7 +186,7 @@ else if (BlogPost is not null) private MarkupString EnrichWithShortCodes(string content) { - var expandedContent = MarkdownConverter.ExpandShortCodes(content, shortCodes); + var expandedContent = MarkdownConverter.ExpandShortCodes(content, ShortCodes ?? []); return MarkdownConverter.ToMarkupStringWithHeadingAnchors(expandedContent, NavigationManager.Uri, AppConfiguration.Value.ShowCodeBlockLanguage); } diff --git a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs index 9a4dbfdf..2b76c399 100644 --- a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs +++ b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Threading.RateLimiting; +using LinkDotNet.Blog.Web.Features; using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services; using LinkDotNet.Blog.Web.Features.Admin.BrokenLinks.Services; using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services; @@ -8,6 +9,7 @@ using LinkDotNet.Blog.Web.Features.Services.Tags; using LinkDotNet.Blog.Web.RegistrationExtensions; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -34,6 +36,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection client.DefaultRequestHeaders.UserAgent.ParseAdd("LinkDotNet.Blog-LinkChecker"); }).RemoveAllLoggers(); + services.AddSingleton(typeof(PersistentComponentStateSerializer<>), typeof(PersistedStateSerializer<>)); services.AddSingleton(); services.AddSingleton(s => s.GetRequiredService()); diff --git a/src/LinkDotNet.Blog.Web/wwwroot/css/icons.css b/src/LinkDotNet.Blog.Web/wwwroot/css/icons.css index f744258f..564b754c 100644 --- a/src/LinkDotNet.Blog.Web/wwwroot/css/icons.css +++ b/src/LinkDotNet.Blog.Web/wwwroot/css/icons.css @@ -1,6 +1,7 @@ @font-face { font-family: 'icons'; src: + url('fonts/icons.woff2') format('woff2'), url('fonts/icons.woff') format('woff'); font-weight: normal; font-style: normal; diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs index 197fd4d4..4137790e 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs @@ -188,4 +188,31 @@ public async Task GivenBlogPostWithTags_WhenLoadingAndDeleting_ThenShouldBeUpdat bpFromDb.ShouldNotBeNull(); bpFromDb.Tags.Single().ShouldBe("tag 2"); } + + [Theory] + [InlineData(1, 2, false)] + [InlineData(2, 2, false)] + [InlineData(3, 1, true)] + [InlineData(4, 0, false)] + public async Task ShouldPageWithCorrectLastPage(int page, int expectedCount, bool expectedLastPage) + { + for (var i = 0; i < 5; i++) + { + await Repository.StoreAsync(new BlogPostBuilder().Build()); + } + + var result = await Repository.GetAllAsync(page: page, pageSize: 2); + + result.Count.ShouldBe(expectedCount); + result.IsLastPage.ShouldBe(expectedLastPage); + } + + [Fact] + public async Task ShouldReturnEmptyPageWhenNothingStored() + { + var result = await Repository.GetAllAsync(page: 1, pageSize: 2); + + result.Count.ShouldBe(0); + result.IsLastPage.ShouldBeTrue(); + } } diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/PersistedStateSerializerTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/PersistedStateSerializerTests.cs new file mode 100644 index 00000000..a9d7c5ab --- /dev/null +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/PersistedStateSerializerTests.cs @@ -0,0 +1,52 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.TestUtilities; +using LinkDotNet.Blog.Web.Features; + +namespace LinkDotNet.Blog.UnitTests.Web.Features; + +public class PersistedStateSerializerTests +{ + [Fact] + public void ShouldRoundTripEntitiesWithPrivateMembers() + { + var blogPost = new BlogPostBuilder().WithTitle("Title").WithTags("a", "b").WithLikes(3).Build(); + blogPost.Id = "post"; + var skill = Skill.Create("C#", null, "Backend", "Expert"); + var talk = Talk.Create("Talk", "Place", "Desc", new DateTime(2025, 1, 2)); + var entry = ProfileInformationEntry.Create("Key", 1000); + var shortCode = ShortCode.Create("name", "content"); + + var restored = RoundTrip(new Snapshot([blogPost], [skill], [talk], [entry], [shortCode])); + + restored.Posts[0].Id.ShouldBe("post"); + restored.Posts[0].Title.ShouldBe("Title"); + restored.Posts[0].Tags.ShouldBe(["a", "b"]); + restored.Posts[0].Likes.ShouldBe(3); + restored.Posts[0].Slug.ShouldBe(blogPost.Slug); + restored.Skills[0].ProficiencyLevel.ShouldBe(ProficiencyLevel.Expert); + restored.Skills[0].Capability.ShouldBe("Backend"); + restored.Talks[0].PublishedDate.ShouldBe(talk.PublishedDate); + restored.Talks[0].PresentationTitle.ShouldBe("Talk"); + restored.Entries[0].Content.ShouldBe("Key"); + restored.Entries[0].SortOrder.ShouldBe(1000); + restored.ShortCodes[0].MarkdownContent.ShouldBe("content"); + } + + private static T RoundTrip(T value) + { + var serializer = new PersistedStateSerializer(); + var buffer = new ArrayBufferWriter(); + serializer.Persist(value, buffer); + return serializer.Restore(new ReadOnlySequence(buffer.WrittenMemory)); + } + + private sealed record Snapshot( + List Posts, + List Skills, + List Talks, + List Entries, + List ShortCodes); +}