From 41bb41c382d408a84b9a7f2b919dbb47fbb32e20 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sat, 7 Jun 2025 22:29:24 +0200 Subject: [PATCH] feat: Draft feature --- .../BlogPostEditor/Components/AutoSave.razor | 368 ++++++++++++++++++ .../Components/CreateNewBlogPost.razor | 6 + .../Components/CreateNewModel.cs | 13 + .../Features/Services/BlogPostDraft.cs | 88 +++++ .../Features/Services/DraftService.cs | 139 +++++++ .../Features/Services/IDraftService.cs | 12 + src/LinkDotNet.Blog.Web/ServiceExtensions.cs | 1 + .../CreateNewBlogPostPageTests.cs | 5 +- .../BlogPostEditor/UpdateBlogPostPageTests.cs | 3 + .../Components/CreateNewBlogPostTests.cs | 1 + 10 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/AutoSave.razor create mode 100644 src/LinkDotNet.Blog.Web/Features/Services/BlogPostDraft.cs create mode 100644 src/LinkDotNet.Blog.Web/Features/Services/DraftService.cs create mode 100644 src/LinkDotNet.Blog.Web/Features/Services/IDraftService.cs diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/AutoSave.razor b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/AutoSave.razor new file mode 100644 index 00000000..44f1c18a --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/AutoSave.razor @@ -0,0 +1,368 @@ +@using LinkDotNet.Blog.Web.Features.Services +@using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components +@using System.Threading +@inject IDraftService DraftService +@inject IJSRuntime JSRuntime +@implements IDisposable + +@if (showSaveStatus) +{ +
+ + @StatusText +
+} + +@if (showDraftRecovery && availableDrafts.Any()) +{ + +} + +@if (showDraftDialog) +{ + +} + +@code { + private Timer? autoSaveTimer; + private string? currentDraftId; + private SaveStatus currentStatus = SaveStatus.None; + private bool showSaveStatus; + private bool showDraftRecovery; + private bool showDraftDialog; + private List availableDrafts = new(); + + private const int AutoSaveDelaySeconds = 3; + private const int StatusDisplayDurationMs = 3000; + + [Parameter, EditorRequired] + public CreateNewModel Model { get; set; } = default!; + + [Parameter] + public string? BlogPostId { get; set; } + + private string StatusClass => currentStatus switch + { + SaveStatus.Saving => "text-warning", + SaveStatus.Saved => "text-success", + SaveStatus.Error => "text-danger", + _ => "text-muted" + }; + + private string StatusIcon => currentStatus switch + { + SaveStatus.Saving => "bi bi-arrow-repeat", + SaveStatus.Saved => "bi bi-check-circle-fill", + SaveStatus.Error => "bi bi-exclamation-triangle-fill", + _ => "" + }; + + private string StatusText => currentStatus switch + { + SaveStatus.Saving => "Saving draft...", + SaveStatus.Saved => "Draft saved", + SaveStatus.Error => "Save failed", + _ => "" + }; + + private string StatusTitle => currentStatus switch + { + SaveStatus.Saving => "Automatically saving your changes", + SaveStatus.Saved => $"Draft saved at {DateTime.Now:HH:mm:ss}", + SaveStatus.Error => "Failed to save draft to local storage", + _ => "" + }; + + protected override async Task OnInitializedAsync() + { + Model.PropertyChanged += OnModelPropertyChanged; + + await LoadAvailableDraftsAsync(); + + showDraftRecovery = availableDrafts.Any(); + + autoSaveTimer = new Timer(AutoSaveCallback, null, Timeout.Infinite, Timeout.Infinite); + } + + private async Task LoadAvailableDraftsAsync() + { + try + { + if (!string.IsNullOrEmpty(BlogPostId)) + { + var drafts = await DraftService.GetDraftsForBlogPostAsync(BlogPostId); + availableDrafts = drafts.ToList(); + } + else + { + var drafts = await DraftService.GetNewPostDraftsAsync(); + availableDrafts = drafts.ToList(); + } + } + catch (Exception ex) + { + await JSRuntime.InvokeVoidAsync("console.error", "Failed to load drafts", ex.Message); + } + } + + private void OnModelPropertyChanged(object? sender, EventArgs e) + { + autoSaveTimer?.Change(TimeSpan.FromSeconds(AutoSaveDelaySeconds), Timeout.InfiniteTimeSpan); + } + + private async void AutoSaveCallback(object? state) + { + try + { + if (!Model.HasSubstantialContent || !Model.IsDirty) + return; + + await InvokeAsync(async () => + { + await UpdateSaveStatus(SaveStatus.Saving); + + try + { + var draft = BlogPostDraft.FromCreateNewModel(Model, BlogPostId); + + if (!string.IsNullOrEmpty(currentDraftId)) + { + draft.Id = currentDraftId; + } + else + { + currentDraftId = draft.Id; + } + + await DraftService.SaveDraftAsync(draft); + await UpdateSaveStatus(SaveStatus.Saved); + } + catch (Exception ex) + { + await JSRuntime.InvokeVoidAsync("console.error", "Auto-save failed", ex.Message); + await UpdateSaveStatus(SaveStatus.Error); + } + }); + } + catch (Exception ex) + { + await JSRuntime.InvokeVoidAsync("console.error", "Auto-save callback failed", ex.Message); + } + } + + private async Task UpdateSaveStatus(SaveStatus status) + { + currentStatus = status; + showSaveStatus = status != SaveStatus.None; + StateHasChanged(); + + if (status != SaveStatus.Saving) + { + await Task.Delay(StatusDisplayDurationMs); + if (currentStatus == status) + { + showSaveStatus = false; + StateHasChanged(); + } + } + } + + private void ShowDraftRecoveryDialog() + { + showDraftDialog = true; + StateHasChanged(); + } + + private void CloseDraftDialog() + { + showDraftDialog = false; + StateHasChanged(); + } + + private void DismissDraftRecovery() + { + showDraftRecovery = false; + StateHasChanged(); + } + + private void RestoreDraft(BlogPostDraft draft) +{ + draft.UpdateCreateNewModel(Model); + Model.MarkAsClean(); + currentDraftId = draft.Id; + + showDraftDialog = false; + showDraftRecovery = false; + StateHasChanged(); + } + + private async Task DeleteDraft(BlogPostDraft draft) + { + await DraftService.DeleteDraftAsync(draft.Id); + availableDrafts.Remove(draft); + + if (!availableDrafts.Any()) + { + showDraftDialog = false; + showDraftRecovery = false; + } + + StateHasChanged(); + } + + private async Task DeleteAllDrafts() + { + var draftsToDelete = availableDrafts.ToList(); + foreach (var draft in draftsToDelete) + { + await DraftService.DeleteDraftAsync(draft.Id); + } + + availableDrafts.Clear(); + showDraftDialog = false; + showDraftRecovery = false; + StateHasChanged(); + } + + public async Task SaveDraftAsync() + { + if (!Model.HasSubstantialContent) + { + return; + } + + await UpdateSaveStatus(SaveStatus.Saving); + + try + { + var draft = BlogPostDraft.FromCreateNewModel(Model, BlogPostId); + + if (!string.IsNullOrEmpty(currentDraftId)) + { + draft.Id = currentDraftId; + } + else + { + currentDraftId = draft.Id; + } + + await DraftService.SaveDraftAsync(draft); + await UpdateSaveStatus(SaveStatus.Saved); + } + catch (Exception) + { + await UpdateSaveStatus(SaveStatus.Error); + } + } + + public async Task ClearDraftAsync() + { + if (!string.IsNullOrEmpty(currentDraftId)) + { + await DraftService.DeleteDraftAsync(currentDraftId); + currentDraftId = null; + } + } + + public void Dispose() + { + Model.PropertyChanged -= OnModelPropertyChanged; + autoSaveTimer?.Dispose(); + } + + private enum SaveStatus + { + None, + Saving, + Saved, + Error + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor index ad217d01..8f59f044 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor @@ -116,6 +116,7 @@ + @code { @@ -133,6 +134,7 @@ private FeatureInfoDialog FeatureDialog { get; set; } = default!; private ShortCodeDialog ShortCodeDialog { get; set; } = default!; + private AutoSave AutoSaveComponent { get; set; } = default!; private CreateNewModel model = new(); @@ -163,6 +165,10 @@ private async Task OnValidBlogPostCreatedAsync() { canSubmit = false; + + // Clear the draft since we're successfully submitting + await AutoSaveComponent.ClearDraftAsync(); + await OnBlogPostCreated.InvokeAsync(model.ToBlogPost()); if (model.ShouldInvalidateCache) { diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs index 0a71e1ff..a1959179 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel.DataAnnotations; using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Web.Features.Services; namespace LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components; @@ -140,5 +141,17 @@ private void SetProperty(out T backingField, T value) { backingField = value; IsDirty = true; + PropertyChanged?.Invoke(this, EventArgs.Empty); + } + + public event EventHandler? PropertyChanged; + + public bool HasSubstantialContent => !string.IsNullOrWhiteSpace(Title) || + !string.IsNullOrWhiteSpace(ShortDescription) || + !string.IsNullOrWhiteSpace(Content); + + public void MarkAsClean() + { + IsDirty = false; } } diff --git a/src/LinkDotNet.Blog.Web/Features/Services/BlogPostDraft.cs b/src/LinkDotNet.Blog.Web/Features/Services/BlogPostDraft.cs new file mode 100644 index 00000000..e1de103c --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/BlogPostDraft.cs @@ -0,0 +1,88 @@ +using System; +using System.Text.Json.Serialization; +using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components; + +namespace LinkDotNet.Blog.Web.Features.Services; + +public sealed class BlogPostDraft +{ + public string Id { get; set; } = string.Empty; + public string? BlogPostId { get; set; } + public string Title { get; set; } = string.Empty; + public string ShortDescription { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string PreviewImageUrl { get; set; } = string.Empty; + public string PreviewImageUrlFallback { get; set; } = string.Empty; + public bool IsPublished { get; set; } = true; + public bool ShouldUpdateDate { get; set; } + public bool ShouldInvalidateCache { get; set; } + public DateTime? ScheduledPublishDate { get; set; } + public string Tags { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime LastSavedAt { get; set; } = DateTime.UtcNow; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public DraftType DraftType { get; set; } = DraftType.New; + + public string? Metadata { get; set; } + + public static BlogPostDraft CreateNew(string? blogPostId = null) + { + return new BlogPostDraft + { + Id = Guid.NewGuid().ToString(), + BlogPostId = blogPostId, + CreatedAt = DateTime.UtcNow, + LastSavedAt = DateTime.UtcNow, + DraftType = string.IsNullOrEmpty(blogPostId) ? DraftType.New : DraftType.Edit + }; + } + + public static BlogPostDraft FromCreateNewModel(CreateNewModel model, string? blogPostId = null) + { + ArgumentNullException.ThrowIfNull(model); + + return new BlogPostDraft + { + Id = Guid.NewGuid().ToString(), + BlogPostId = blogPostId, + Title = model.Title, + ShortDescription = model.ShortDescription, + Content = model.Content, + PreviewImageUrl = model.PreviewImageUrl, + PreviewImageUrlFallback = model.PreviewImageUrlFallback, + IsPublished = model.IsPublished, + ShouldUpdateDate = model.ShouldUpdateDate, + ShouldInvalidateCache = model.ShouldInvalidateCache, + ScheduledPublishDate = model.ScheduledPublishDate, + Tags = model.Tags, + CreatedAt = DateTime.UtcNow, + LastSavedAt = DateTime.UtcNow, + DraftType = string.IsNullOrEmpty(blogPostId) ? DraftType.New : DraftType.Edit, + Metadata = null + }; + } + + public void UpdateCreateNewModel(CreateNewModel model) + { + ArgumentNullException.ThrowIfNull(model); + + model.Title = Title; + model.ShortDescription = ShortDescription; + model.Content = Content; + model.PreviewImageUrl = PreviewImageUrl; + model.PreviewImageUrlFallback = PreviewImageUrlFallback; + model.IsPublished = IsPublished; + model.ShouldUpdateDate = ShouldUpdateDate; + model.ShouldInvalidateCache = ShouldInvalidateCache; + model.ScheduledPublishDate = ScheduledPublishDate; + model.Tags = Tags; + } +} + +public enum DraftType +{ + New, + Edit +} diff --git a/src/LinkDotNet.Blog.Web/Features/Services/DraftService.cs b/src/LinkDotNet.Blog.Web/Features/Services/DraftService.cs new file mode 100644 index 00000000..6bd93601 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/DraftService.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace LinkDotNet.Blog.Web.Features.Services; + +public sealed class DraftService : IDraftService +{ + private readonly ILocalStorageService localStorage; + private const string DraftIndexKey = "blogpost_drafts_index"; + private const string DraftPrefix = "blogpost_draft_"; + + public DraftService(ILocalStorageService localStorage) + { + this.localStorage = localStorage; + } + + public async ValueTask SaveDraftAsync(BlogPostDraft draft) + { + ArgumentNullException.ThrowIfNull(draft); + + draft.LastSavedAt = DateTime.UtcNow; + + var draftKey = GetDraftKey(draft.Id); + await localStorage.SetItemAsync(draftKey, draft); + + await UpdateDraftIndexAsync(draft.Id); + } + + public async ValueTask GetDraftAsync(string draftId) + { + if (string.IsNullOrWhiteSpace(draftId)) + return null; + + try + { + var draftKey = GetDraftKey(draftId); + if (!await localStorage.ContainsKeyAsync(draftKey)) + return null; + + return await localStorage.GetItemAsync(draftKey); + } + catch + { + await RemoveFromDraftIndexAsync(draftId); + return null; + } + } + + public async ValueTask> GetAllDraftsAsync() + { + var draftIds = await GetDraftIndexAsync(); + var drafts = new List(); + + foreach (var draftId in draftIds) + { + var draft = await GetDraftAsync(draftId); + if (draft != null) + { + drafts.Add(draft); + } + } + + return drafts.OrderByDescending(d => d.LastSavedAt).ToArray(); + } + + public async ValueTask> GetDraftsForBlogPostAsync(string blogPostId) + { + if (string.IsNullOrWhiteSpace(blogPostId)) + return []; + + var allDrafts = await GetAllDraftsAsync(); + return allDrafts + .Where(d => d.DraftType == DraftType.Edit && d.BlogPostId == blogPostId) + .ToArray(); + } + + public async ValueTask> GetNewPostDraftsAsync() + { + var allDrafts = await GetAllDraftsAsync(); + return allDrafts + .Where(d => d.DraftType == DraftType.New) + .ToArray(); + } + + public async ValueTask DeleteDraftAsync(string draftId) + { + if (string.IsNullOrWhiteSpace(draftId)) + return; + + var draftKey = GetDraftKey(draftId); + + if (await localStorage.ContainsKeyAsync(draftKey)) + { + await localStorage.SetItemAsync(draftKey, null); + } + + await RemoveFromDraftIndexAsync(draftId); + } + + private static string GetDraftKey(string draftId) => $"{DraftPrefix}{draftId}"; + + private async ValueTask> GetDraftIndexAsync() + { + try + { + if (!await localStorage.ContainsKeyAsync(DraftIndexKey)) + return []; + + return await localStorage.GetItemAsync>(DraftIndexKey); + } + catch + { + return []; + } + } + + private async ValueTask UpdateDraftIndexAsync(string draftId) + { + var index = await GetDraftIndexAsync(); + + if (!index.Contains(draftId)) + { + index.Add(draftId); + await localStorage.SetItemAsync(DraftIndexKey, index); + } + } + + private async ValueTask RemoveFromDraftIndexAsync(string draftId) + { + var index = await GetDraftIndexAsync(); + + if (index.Remove(draftId)) + { + await localStorage.SetItemAsync(DraftIndexKey, index); + } + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/Services/IDraftService.cs b/src/LinkDotNet.Blog.Web/Features/Services/IDraftService.cs new file mode 100644 index 00000000..00d0a07a --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/IDraftService.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace LinkDotNet.Blog.Web.Features.Services; + +public interface IDraftService +{ + ValueTask SaveDraftAsync(BlogPostDraft draft); + ValueTask> GetDraftsForBlogPostAsync(string blogPostId); + ValueTask> GetNewPostDraftsAsync(); + ValueTask DeleteDraftAsync(string draftId); +} diff --git a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs index ef4a1c01..eb048e62 100644 --- a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs +++ b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs @@ -18,6 +18,7 @@ public static class ServiceExtensions public static IServiceCollection AddApplicationServices(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs index 28d6a55e..4118a7b4 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/CreateNewBlogPostPageTests.cs @@ -1,4 +1,5 @@ -using System.Threading; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Blazored.Toast.Services; using LinkDotNet.Blog.Domain; @@ -37,6 +38,8 @@ public async Task ShouldSaveBlogPostOnSave() var shortCodeRepository = Substitute.For>(); shortCodeRepository.GetAllAsync().Returns(PagedList.Empty); ctx.Services.AddScoped(_ => shortCodeRepository); + ctx.Services.AddScoped(_ => Substitute.For()); + using var cut = ctx.Render(); var newBlogPost = cut.FindComponent(); diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs index e6369e35..eb8539cf 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Admin/BlogPostEditor/UpdateBlogPostPageTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Blazored.Toast.Services; @@ -39,6 +40,8 @@ public async Task ShouldSaveBlogPostOnSave() var shortCodeRepository = Substitute.For>(); shortCodeRepository.GetAllAsync().Returns(PagedList.Empty); ctx.Services.AddScoped(_ => shortCodeRepository); + ctx.Services.AddScoped(_ => Substitute.For()); + using var cut = ctx.Render( p => p.Add(s => s.BlogPostId, blogPost.Id)); var newBlogPost = cut.FindComponent(); diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPostTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPostTests.cs index 472db955..cf9f841b 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPostTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPostTests.cs @@ -30,6 +30,7 @@ public CreateNewBlogPostTests() Services.AddScoped(_ => Substitute.For()); Services.AddScoped(_ => cacheService); Services.AddScoped(_ => Substitute.For()); + Services.AddScoped(_ => Substitute.For()); } [Fact]