Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@using LinkDotNet.Blog.Infrastructure
@using LinkDotNet.Blog.Infrastructure.Persistence
@using LinkDotNet.Blog.Web.Features.Services
@using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services
@using NCronJob
@using System.Threading
@inject IJSRuntime JSRuntime
Expand All @@ -10,10 +11,29 @@
@inject IRepository<ShortCode> ShortCodeRepository
@inject IOptions<ApplicationConfiguration> AppConfiguration
@inject ICurrentUserService CurrentUserService
@inject IBlogPostDraftService DraftService
@implements IDisposable

<PageTitle>Creating new Blog Post</PageTitle>

<div class="container-fluid px-4">
@if (hasSavedDraft && !draftRestored)
{
<div class="row mb-3">
<div class="col-12">
<div class="alert alert-info alert-dismissible fade show" role="alert">
<strong>Draft Available!</strong> A previously saved draft from @savedDraftTime was found.
<button type="button" class="btn btn-sm btn-primary ms-2" @onclick="RestoreDraft">
Restore Draft
</button>
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" @onclick="DiscardDraft">
Discard
</button>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close" @onclick="@(() => hasSavedDraft = false)"></button>
</div>
</div>
</div>
}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
Expand All @@ -28,6 +48,12 @@
Unsaved Changes
</span>
}
@if (lastAutoSaveTime.HasValue)
{
<span class="badge bg-success text-white px-3 py-2 fw-medium">
Auto-saved @GetTimeSinceAutoSave()
</span>
}
</div>
</div>
</div>
Expand Down Expand Up @@ -278,6 +304,12 @@
private bool IsScheduled => model.ScheduledPublishDate.HasValue;

private string? authorName;

private Timer? autoSaveTimer;
private bool hasSavedDraft;
private string savedDraftTime = string.Empty;
private bool draftRestored;
private DateTime? lastAutoSaveTime;

protected override async Task OnInitializedAsync()
{
Expand All @@ -287,6 +319,9 @@
{
authorName = await CurrentUserService.GetDisplayNameAsync();
}

await CheckForSavedDraft();
StartAutoSaveTimer();
}

protected override void OnParametersSet()
Expand Down Expand Up @@ -338,10 +373,20 @@
}

InstantJobRegistry.RunInstantJob<SimilarBlogPostJob>(parameter: true);

// Clear the auto-saved draft after successful save
await ClearAutoSavedDraft();

ClearModel();
canSubmit = true;
}

private async Task ClearAutoSavedDraft()
{
await DraftService.DiscardDraftAsync();
lastAutoSaveTime = null;
}

private void ClearModel()
{
if (ClearAfterCreated)
Expand Down Expand Up @@ -396,4 +441,71 @@
model.Content = converter.Convert(model.Content);
}
}

private async Task CheckForSavedDraft()
{
if (BlogPost != null)
{
// Don't check for drafts when editing existing posts
return;
}

var (exists, savedTime) = await DraftService.CheckForSavedDraftAsync();
hasSavedDraft = exists;
savedDraftTime = savedTime;
}

private async Task RestoreDraft()
{
var restoredModel = await DraftService.RestoreDraftAsync();
if (restoredModel != null)
{
model = restoredModel;
hasSavedDraft = false;
draftRestored = true;
StateHasChanged();
}
}

private async Task DiscardDraft()
{
await DraftService.DiscardDraftAsync();
hasSavedDraft = false;
StateHasChanged();
}

private void StartAutoSaveTimer()
{
// Auto-save every 30 seconds
autoSaveTimer = new Timer(AutoSaveTimerCallback, null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
}

private void AutoSaveTimerCallback(object? state)
{
// Fire and forget - exceptions are caught inside AutoSaveDraft
_ = InvokeAsync(AutoSaveDraft);
}

private async Task AutoSaveDraft()
{
if (!model.IsDirty || BlogPost != null)
{
// Don't auto-save if there are no changes or if editing an existing post
return;
}

await DraftService.SaveDraftAsync(model);
lastAutoSaveTime = DateTime.UtcNow;
StateHasChanged();
}

private string GetTimeSinceAutoSave()
{
return DraftService.GetTimeSinceAutoSave(lastAutoSaveTime);
}

public void Dispose()
{
autoSaveTimer?.Dispose();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,25 @@ public static CreateNewModel FromBlogPost(BlogPost blogPost)
};
}

public static CreateNewModel FromDraft(DraftBlogPostModel draft)
{
ArgumentNullException.ThrowIfNull(draft);

return new CreateNewModel
{
id = draft.Id ?? string.Empty,
Title = draft.Title,
ShortDescription = draft.ShortDescription,
Content = draft.Content,
PreviewImageUrl = draft.PreviewImageUrl,
IsPublished = draft.IsPublished,
Tags = draft.Tags,
PreviewImageUrlFallback = draft.PreviewImageUrlFallback,
scheduledPublishDate = draft.ScheduledPublishDate,
IsDirty = false,
};
}

public BlogPost ToBlogPost()
{
var tagList = string.IsNullOrWhiteSpace(Tags)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;

namespace LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components;

public sealed class DraftBlogPostModel
{
public string? Id { 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 bool IsPublished { get; set; }
public string Tags { get; set; } = string.Empty;
public string PreviewImageUrlFallback { get; set; } = string.Empty;
public DateTime? ScheduledPublishDate { get; set; }
public DateTime SavedAt { get; set; }

public static DraftBlogPostModel FromCreateNewModel(CreateNewModel model, string? id = null)
{
ArgumentNullException.ThrowIfNull(model);

return new DraftBlogPostModel
{
Id = id,
Title = model.Title,
ShortDescription = model.ShortDescription,
Content = model.Content,
PreviewImageUrl = model.PreviewImageUrl,
IsPublished = model.IsPublished,
Tags = model.Tags,
PreviewImageUrlFallback = model.PreviewImageUrlFallback,
ScheduledPublishDate = model.ScheduledPublishDate,
SavedAt = DateTime.UtcNow,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components;
using LinkDotNet.Blog.Web.Features.Services;

namespace LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services;

public sealed class BlogPostDraftService : IBlogPostDraftService
{
private const string DraftStorageKey = "blogpost-draft";
private readonly ILocalStorageService localStorageService;

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

public async ValueTask<(bool exists, string savedTime)> CheckForSavedDraftAsync()
{
try
{
if (await localStorageService.ContainsKeyAsync(DraftStorageKey))
{
var draft = await localStorageService.GetItemAsync<DraftBlogPostModel>(DraftStorageKey);
if (draft != null)
{
var savedTime = draft.SavedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
return (true, savedTime);
}
}
}
catch
{
// If there's any error reading the draft, just ignore it
}

return (false, string.Empty);
}

public async ValueTask SaveDraftAsync(CreateNewModel model)
{
ArgumentNullException.ThrowIfNull(model);

try
{
var draft = DraftBlogPostModel.FromCreateNewModel(model);
await localStorageService.SetItemAsync(DraftStorageKey, draft);
}
catch
{
// If auto-save fails, just ignore it silently
}
}

public async ValueTask<CreateNewModel?> RestoreDraftAsync()
{
try
{
var draft = await localStorageService.GetItemAsync<DraftBlogPostModel>(DraftStorageKey);
return draft != null ? CreateNewModel.FromDraft(draft) : null;
}
catch
{
// If there's any error restoring the draft, just ignore it
return null;
}
}

public async ValueTask DiscardDraftAsync()
{
try
{
await localStorageService.RemoveItemAsync(DraftStorageKey);
}
catch
{
// Ignore errors
}
}

public string GetTimeSinceAutoSave(DateTime? lastAutoSaveTime)
{
if (!lastAutoSaveTime.HasValue)
return string.Empty;

var elapsed = DateTime.UtcNow - lastAutoSaveTime.Value;
if (elapsed.TotalMinutes < 1)
return "just now";
if (elapsed.TotalMinutes < 60)
return $"{(int)elapsed.TotalMinutes}m ago";
return $"{(int)elapsed.TotalHours}h ago";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System;
using System.Threading.Tasks;
using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components;

namespace LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services;

public interface IBlogPostDraftService
{
/// <summary>
/// Checks if a saved draft exists in local storage.
/// </summary>
/// <returns>A tuple containing whether a draft exists and the saved time as a formatted string.</returns>
ValueTask<(bool exists, string savedTime)> CheckForSavedDraftAsync();

/// <summary>
/// Saves a draft to local storage.
/// </summary>
/// <param name="model">The blog post model to save as a draft.</param>
ValueTask SaveDraftAsync(CreateNewModel model);

/// <summary>
/// Restores a saved draft from local storage.
/// </summary>
/// <returns>The restored CreateNewModel, or null if no draft exists or an error occurred.</returns>
ValueTask<CreateNewModel?> RestoreDraftAsync();

/// <summary>
/// Discards the saved draft from local storage.
/// </summary>
ValueTask DiscardDraftAsync();

/// <summary>
/// Gets the time elapsed since the last auto-save.
/// </summary>
/// <param name="lastAutoSaveTime">The time of the last auto-save.</param>
/// <returns>A formatted string representing the elapsed time.</returns>
string GetTimeSinceAutoSave(DateTime? lastAutoSaveTime);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ public interface ILocalStorageService
ValueTask<T> GetItemAsync<T>(string key);

ValueTask SetItemAsync<T>(string key, T value);

ValueTask RemoveItemAsync(string key);
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,9 @@ public async ValueTask SetItemAsync<T>(string key, T value)
throw new InvalidOperationException($"Could not set value for key {key}. The key has been removed.");
}
}

public async ValueTask RemoveItemAsync(string key)
{
await localStorage.DeleteAsync(key);
}
}
1 change: 1 addition & 0 deletions src/LinkDotNet.Blog.Web/ServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
services.AddScoped<IXmlWriter, XmlWriter>();
services.AddScoped<IFileProcessor, FileProcessor>();
services.AddScoped<ICurrentUserService, CurrentUserService>();
services.AddScoped<IBlogPostDraftService, BlogPostDraftService>();

services.AddSingleton<CacheService>();
services.AddSingleton<ICacheTokenProvider>(s => s.GetRequiredService<CacheService>());
Expand Down
Loading
Loading