feat(creator): overhaul Creator flow, editor duplication, and staging setup #83

Merged
mjasin merged 15 commits from feature/stage3-book-versioning into develop 2026-06-15 17:15:43 +00:00
7 changed files with 592 additions and 21 deletions
Showing only changes of commit 978485e8ff - Show all commits
@@ -21,6 +21,8 @@ namespace NexusReader.Application.Common;
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterRequest))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterResponse))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.UploadResultDto))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.LocalBackupEnvelope))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.AutosaveChapterRequest))]
public partial class AppJsonContext : JsonSerializerContext
{
}
@@ -16,3 +16,18 @@ public record ValidateChapterResponse(string SanitizedContent);
/// Response DTO containing the uploaded media file URL.
/// </summary>
public record UploadResultDto(string Url);
/// <summary>
/// Represents a structured JSON backup envelope stored in LocalStorage.
/// </summary>
public class LocalBackupEnvelope
{
public Guid ChapterId { get; set; }
public DateTime Timestamp { get; set; }
public string MarkdownContent { get; set; } = string.Empty;
}
/// <summary>
/// Request DTO for chapter autosaving.
/// </summary>
public record AutosaveChapterRequest(string MarkdownContent);
@@ -2,9 +2,27 @@
@implements IAsyncDisposable
@inject IJSRuntime JS
@inject HttpClient Http
@inject NexusReader.Application.Abstractions.Services.INativeStorageService StorageService
<div class="markdown-editor-container" style="height: @Height; width: @Width;">
<div id="@EditorId" class="milkdown-editor-wrapper"></div>
@if (_showRestorationBanner)
{
<div class="restoration-banner">
<span class="banner-text">You have unsaved changes from an interrupted session.</span>
<div class="banner-actions">
<button type="button" class="banner-btn restore-btn" @onclick="RestoreBackupAsync">Restore</button>
<button type="button" class="banner-btn dismiss-btn" @onclick="DismissBackupAsync">Dismiss</button>
</div>
</div>
}
<div @key="_editorRenderKey" id="@EditorId" class="milkdown-editor-wrapper"></div>
<div class="editor-footer">
<div class="status-indicator">
<span class="status-dot @StatusClass"></span>
<span class="status-text">@StatusText</span>
</div>
@if (ShowFetchButton)
{
<div class="editor-actions">
@@ -13,14 +31,49 @@
</button>
</div>
}
</div>
</div>
@code {
private readonly string EditorId = $"milkdown-editor-{Guid.NewGuid():N}";
private string EditorId { get; set; } = $"milkdown-editor-{Guid.NewGuid():N}";
private Guid _editorRenderKey = Guid.NewGuid();
private readonly CancellationTokenSource _cts = new();
private IJSObjectReference? _module;
private DotNetObjectReference<MarkdownEditor>? _dotNetHelper;
private enum SaveStatus
{
SavedToCloud,
Saving,
OfflineLocalBackup
}
private SaveStatus _status = SaveStatus.SavedToCloud;
private string _currentMarkdown = string.Empty;
private CancellationTokenSource? _debounceCts;
private readonly object _timerLock = new();
private bool _showRestorationBanner = false;
private NexusReader.Application.DTOs.Media.LocalBackupEnvelope? _pendingBackup;
private bool _hasRunStorageInit = false;
private bool _reinitializeEditor = false;
private string StatusClass => _status switch
{
SaveStatus.SavedToCloud => "saved",
SaveStatus.Saving => "saving",
SaveStatus.OfflineLocalBackup => "offline",
_ => "saved"
};
private string StatusText => _status switch
{
SaveStatus.SavedToCloud => "Saved to Cloud",
SaveStatus.Saving => "Saving...",
SaveStatus.OfflineLocalBackup => "Offline - Local Backup Only",
_ => "Saved to Cloud"
};
[Parameter]
public bool ShowFetchButton { get; set; } = true;
@@ -36,37 +89,183 @@
[Parameter]
public string Width { get; set; } = "100%";
[Parameter]
public Guid ChapterId { get; set; } = Guid.Empty;
[Parameter]
public DateTime? ServerTimestamp { get; set; }
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Sweep keys and check restoration on init
await RunStorageSweepAndRestorationCheckAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender || _reinitializeEditor)
{
_reinitializeEditor = false;
if (firstRender)
{
_dotNetHelper = DotNetObjectReference.Create(this);
// Retry if deferred during prerendering OnInitializedAsync
await RunStorageSweepAndRestorationCheckAsync();
}
try
{
// Import the isolated JavaScript module
if (_module == null)
{
_module = await JS.InvokeAsync<IJSObjectReference>(
"import",
"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js"
);
// Call the initialization function in the wrapper
}
await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown);
}
catch (Exception ex)
{
// Log the exception gracefully and do not crash the component
Console.WriteLine($"[MarkdownEditor] Error initializing Milkdown editor: {ex.Message}");
}
}
}
private async Task RunStorageSweepAndRestorationCheckAsync()
{
if (_hasRunStorageInit) return;
try
{
_hasRunStorageInit = true;
// Import wrapper module if not already loaded to access helper
if (_module == null)
{
_module = await JS.InvokeAsync<IJSObjectReference>(
"import",
"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js"
);
}
// Sweep and filter backup keys defensively
var keys = await _module.InvokeAsync<List<string>>("getBackupKeys");
if (keys != null)
{
var now = DateTime.UtcNow;
foreach (var key in keys)
{
// Strict defensive check before doing any JSON deserialization
if (!key.StartsWith("nexus-bkp-")) continue;
try
{
var backupResult = await StorageService.GetStringAsync(key);
if (backupResult.IsSuccess && !string.IsNullOrEmpty(backupResult.Value))
{
var envelope = System.Text.Json.JsonSerializer.Deserialize<NexusReader.Application.DTOs.Media.LocalBackupEnvelope>(
backupResult.Value,
NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope
);
if (envelope != null)
{
// Remove expired backups
if ((now - envelope.Timestamp).TotalDays > 7)
{
await StorageService.RemoveAsync(key);
Console.WriteLine($"[MarkdownEditor] Boot-up Eviction: Deleted expired backup key {key}");
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Error sweeping key {key}: {ex.Message}");
}
}
}
// Restoration guard for this specific Chapter ID
var currentBackupKey = $"nexus-bkp-{ChapterId}";
var currentBackupResult = await StorageService.GetStringAsync(currentBackupKey);
if (currentBackupResult.IsSuccess && !string.IsNullOrEmpty(currentBackupResult.Value))
{
var envelope = System.Text.Json.JsonSerializer.Deserialize<NexusReader.Application.DTOs.Media.LocalBackupEnvelope>(
currentBackupResult.Value,
NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope
);
if (envelope != null)
{
var serverTime = ServerTimestamp ?? DateTime.MinValue;
if (envelope.Timestamp > serverTime && envelope.MarkdownContent != InitialMarkdown)
{
_pendingBackup = envelope;
_showRestorationBanner = true;
StateHasChanged();
}
}
}
}
catch (Exception ex)
{
_hasRunStorageInit = false; // Reset to allow retry on client render
Console.WriteLine($"[MarkdownEditor] Storage initialization deferred/failed: {ex.Message}");
}
}
private async Task RestoreBackupAsync()
{
if (_pendingBackup != null)
{
if (_module != null)
{
try
{
// Prevent memory leak by cleaning up old instance in JS
await _module.InvokeVoidAsync("destroyEditor", EditorId);
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Error destroying old editor during restore: {ex.Message}");
}
}
InitialMarkdown = _pendingBackup.MarkdownContent;
_showRestorationBanner = false;
_pendingBackup = null;
// Regenerate render key and ID to trigger clean Blazor element-level re-initialization
_reinitializeEditor = true;
EditorId = $"milkdown-editor-{Guid.NewGuid():N}";
_editorRenderKey = Guid.NewGuid();
StateHasChanged();
}
}
private async Task DismissBackupAsync()
{
_showRestorationBanner = false;
_pendingBackup = null;
try
{
await StorageService.RemoveAsync($"nexus-bkp-{ChapterId}");
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Failed to dismiss backup from LocalStorage: {ex.Message}");
}
StateHasChanged();
}
public async Task FetchContentAsync()
{
if (_module is not null)
{
try
{
// Retrieve the updated markdown from JS
var markdown = await _module.InvokeAsync<string>("getMarkdownContent", EditorId);
if (OnSave.HasDelegate)
@@ -81,6 +280,126 @@
}
}
[JSInvokable]
public async Task OnEditorContentChanged(string currentMarkdown)
{
_currentMarkdown = currentMarkdown;
// Structured JSON Envelope Pattern
var envelope = new NexusReader.Application.DTOs.Media.LocalBackupEnvelope
{
ChapterId = ChapterId,
Timestamp = DateTime.UtcNow,
MarkdownContent = currentMarkdown
};
try
{
var envelopeJson = System.Text.Json.JsonSerializer.Serialize(
envelope,
NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope
);
await StorageService.SaveStringAsync($"nexus-bkp-{ChapterId}", envelopeJson);
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Failed to save backup to LocalStorage: {ex.Message}");
}
// Status indicator to Offline - Local Backup Only
_status = SaveStatus.OfflineLocalBackup;
await InvokeAsync(StateHasChanged);
// Cancel pending timers thread-safely
CancellationTokenSource? ctsToCancel = null;
lock (_timerLock)
{
if (_debounceCts != null)
{
ctsToCancel = _debounceCts;
_debounceCts = null;
}
_debounceCts = new CancellationTokenSource();
}
if (ctsToCancel != null)
{
try
{
await ctsToCancel.CancelAsync();
ctsToCancel.Dispose();
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Error cancelling debounce timer: {ex.Message}");
}
}
// Start 5-second idle debounce timer
_ = Task.Run(async () =>
{
CancellationToken token;
lock (_timerLock)
{
if (_debounceCts == null) return;
token = _debounceCts.Token;
}
try
{
await Task.Delay(5000, token);
await TriggerAutosaveAsync(currentMarkdown, token);
}
catch (TaskCanceledException)
{
// Task cancelled on new keystroke
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Debounce timer exception: {ex.Message}");
}
});
}
private async Task TriggerAutosaveAsync(string markdown, CancellationToken token)
{
if (token.IsCancellationRequested) return;
_status = SaveStatus.Saving;
await InvokeAsync(StateHasChanged);
try
{
var request = new NexusReader.Application.DTOs.Media.AutosaveChapterRequest(markdown);
var response = await Http.PutAsJsonAsync(
$"/api/chapters/{ChapterId}/autosave",
request,
NexusReader.Application.Common.AppJsonContext.Default.AutosaveChapterRequest,
mjasin marked this conversation as resolved
Review

🔴 Blocking: Race Condition in Debounce Timer

The background task reads _debounceCts.Token from the shared component state after the task starts executing on the thread pool. If another keystroke happens in the meantime, it will read the new token.

Suggested Fix:
Capture the token synchronously on the UI thread before calling Task.Run and use the captured token directly inside the task:

CancellationToken token;
lock (_timerLock)
{
    token = _debounceCts.Token;
}

// Start 5-second idle debounce timer
_ = Task.Run(async () =>
{
    try
    {
        await Task.Delay(5000, token);
        await TriggerAutosaveAsync(currentMarkdown, token);
    }
    catch (TaskCanceledException) { }
    catch (Exception ex)
    {
        Console.WriteLine($"[MarkdownEditor] Debounce timer exception: {ex.Message}");
    }
});
🔴 Blocking: Race Condition in Debounce Timer The background task reads `_debounceCts.Token` from the shared component state *after* the task starts executing on the thread pool. If another keystroke happens in the meantime, it will read the *new* token. **Suggested Fix:** Capture the token synchronously on the UI thread before calling `Task.Run` and use the captured token directly inside the task: ```csharp CancellationToken token; lock (_timerLock) { token = _debounceCts.Token; } // Start 5-second idle debounce timer _ = Task.Run(async () => { try { await Task.Delay(5000, token); await TriggerAutosaveAsync(currentMarkdown, token); } catch (TaskCanceledException) { } catch (Exception ex) { Console.WriteLine($"[MarkdownEditor] Debounce timer exception: {ex.Message}"); } }); ```
token
);
if (response.IsSuccessStatusCode)
{
// Purge LocalStorage backup key on HTTP success
await StorageService.RemoveAsync($"nexus-bkp-{ChapterId}");
_status = SaveStatus.SavedToCloud;
}
else
{
_status = SaveStatus.OfflineLocalBackup;
var errorMsg = await response.Content.ReadAsStringAsync(token);
Console.WriteLine($"[MarkdownEditor] Autosave HTTP error: {response.StatusCode} - {errorMsg}");
}
}
catch (Exception ex)
{
_status = SaveStatus.OfflineLocalBackup;
Console.WriteLine($"[MarkdownEditor] Autosave HTTP exception: {ex.Message}");
}
await InvokeAsync(StateHasChanged);
}
mjasin marked this conversation as resolved
Review

🟢 Minor/Suggestion: Missing component disposal check

TriggerAutosaveAsync runs on a background thread and calls await InvokeAsync(StateHasChanged). If the component has been disposed before or during the save operation, calling StateHasChanged may result in runtime warnings/exceptions.

Suggested Fix:
Define a _disposed boolean flag, set it to true in DisposeAsync, and check it before updating state:

private bool _disposed;

// In DisposeAsync:
_disposed = true;

// In TriggerAutosaveAsync:
if (_disposed) return;
_status = SaveStatus.Saving;
await InvokeAsync(StateHasChanged);
🟢 Minor/Suggestion: Missing component disposal check `TriggerAutosaveAsync` runs on a background thread and calls `await InvokeAsync(StateHasChanged)`. If the component has been disposed before or during the save operation, calling `StateHasChanged` may result in runtime warnings/exceptions. **Suggested Fix:** Define a `_disposed` boolean flag, set it to `true` in `DisposeAsync`, and check it before updating state: ```csharp private bool _disposed; // In DisposeAsync: _disposed = true; // In TriggerAutosaveAsync: if (_disposed) return; _status = SaveStatus.Saving; await InvokeAsync(StateHasChanged); ```
[JSInvokable]
public async Task<string> UploadImageFromJs(string filename, string contentType, IJSStreamReference streamRef)
{
@@ -127,14 +446,36 @@
}
catch
{
// Fail silently if cancellation token disposal fails
// Fail silently
}
CancellationTokenSource? ctsToCancel = null;
lock (_timerLock)
{
if (_debounceCts != null)
{
ctsToCancel = _debounceCts;
_debounceCts = null;
}
}
if (ctsToCancel != null)
{
try
{
ctsToCancel.Cancel();
ctsToCancel.Dispose();
}
catch
{
// Fail silently
}
}
try
{
if (_module is not null)
{
// Clean up the JS editor instance to prevent memory leaks
await _module.InvokeVoidAsync("destroyEditor", EditorId);
await _module.DisposeAsync();
}
@@ -149,7 +490,6 @@
}
catch (Exception ex)
{
// Log other unexpected errors
Console.WriteLine($"[MarkdownEditor] Unexpected error during JS cleanup: {ex.Message}");
}
finally
@@ -84,3 +84,114 @@
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Stateful Status Indicator Footer */
.editor-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background: var(--bg-surface-low, rgba(255, 255, 255, 0.02));
border-radius: var(--radius-sm, 6px);
border: 1px solid var(--border);
margin-top: -0.5rem;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-muted, #888888);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
position: relative;
box-shadow: 0 0 8px currentColor;
}
.status-dot.saved {
color: #10B981; /* Green */
background-color: #10B981;
}
.status-dot.saving {
color: #F59E0B; /* Amber */
background-color: #F59E0B;
animation: status-pulse 1s infinite alternate;
}
.status-dot.offline {
color: #EF4444; /* Red */
background-color: #EF4444;
}
/* Orange Restoration Warning Banner */
.restoration-banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.25rem;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: var(--radius-md, 8px);
color: var(--text-main);
font-size: 0.9rem;
gap: 1rem;
margin-bottom: 0.75rem;
animation: banner-fadeIn 0.3s ease-out;
}
.banner-text {
font-weight: 500;
}
.banner-actions {
display: flex;
gap: 0.75rem;
}
.banner-btn {
padding: 6px 12px;
border-radius: var(--radius-sm, 4px);
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
.restore-btn {
background: #F59E0B;
color: #000;
}
.restore-btn:hover {
background: #D97706;
transform: translateY(-1px);
}
.dismiss-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-main);
}
.dismiss-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
@keyframes status-pulse {
0% { opacity: 0.4; transform: scale(0.9); }
100% { opacity: 1; transform: scale(1.1); }
}
@keyframes banner-fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
@@ -92,6 +92,20 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
}
});
// Hook into the Crepe content update listener system with 300ms JS debounce
let debounceTimeout = null;
crepe.on((listener) => {
listener.markdownUpdated((ctx, markdown, prevMarkdown) => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
debounceTimeout = setTimeout(() => {
dotNetHelper.invokeMethodAsync('OnEditorContentChanged', markdown)
.catch(err => console.error("[Milkdown] Failed to notify editor content changed:", err));
}, 300);
});
});
// Store the editor instance in the map
editorCache.set(elementId, crepe);
@@ -131,3 +145,21 @@ export async function destroyEditor(elementId) {
editorCache.delete(elementId);
}
}
/**
* Safely retrieves all localStorage keys starting with the "nexus-bkp-" prefix.
*/
export function getBackupKeys() {
const keys = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('nexus-bkp-')) {
keys.push(key);
}
}
} catch (err) {
console.error("[Milkdown] Error listing localStorage keys:", err);
}
return keys;
}
+10
View File
@@ -827,6 +827,16 @@ app.MapPost("/api/chapters/validate", (
return Results.Ok(new NexusReader.Application.DTOs.Media.ValidateChapterResponse(sanitized));
}).DisableAntiforgery();
app.MapPut("/api/chapters/{id:guid}/autosave", (
Guid id,
[Microsoft.AspNetCore.Mvc.FromBody] NexusReader.Application.DTOs.Media.AutosaveChapterRequest request,
ILoggerFactory loggerFactory) =>
{
var logger = loggerFactory.CreateLogger("ChaptersApi");
logger.LogInformation("Autosaving chapter {ChapterId} with content length {Length}", id, request?.MarkdownContent?.Length ?? 0);
return Results.Ok(new { Success = true });
}).DisableAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
@@ -0,0 +1,61 @@
using System.Text.Json;
using FluentAssertions;
using NexusReader.Application.Common;
using NexusReader.Application.DTOs.Media;
using Xunit;
namespace NexusReader.Application.Tests.Services;
public class AutosaveEngineTests
{
[Fact]
public void SerializeAndDeserialize_LocalBackupEnvelope_Succeeds()
{
// Arrange
var envelope = new LocalBackupEnvelope
{
ChapterId = Guid.NewGuid(),
Timestamp = DateTime.UtcNow.AddMinutes(-10),
MarkdownContent = "# Hello Autosave"
};
// Act
var json = JsonSerializer.Serialize(envelope, AppJsonContext.Default.LocalBackupEnvelope);
var deserialized = JsonSerializer.Deserialize(json, AppJsonContext.Default.LocalBackupEnvelope);
// Assert
deserialized.Should().NotBeNull();
deserialized!.ChapterId.Should().Be(envelope.ChapterId);
deserialized.MarkdownContent.Should().Be(envelope.MarkdownContent);
// Truncate milliseconds to avoid precision discrepancies in text representation
deserialized.Timestamp.ToUniversalTime().Date.Should().Be(envelope.Timestamp.ToUniversalTime().Date);
}
[Fact]
public void SerializeAndDeserialize_AutosaveChapterRequest_Succeeds()
{
// Arrange
var request = new AutosaveChapterRequest("# Content to Autosave");
// Act
var json = JsonSerializer.Serialize(request, AppJsonContext.Default.AutosaveChapterRequest);
var deserialized = JsonSerializer.Deserialize(json, AppJsonContext.Default.AutosaveChapterRequest);
// Assert
deserialized.Should().NotBeNull();
deserialized!.MarkdownContent.Should().Be(request.MarkdownContent);
}
[Fact]
public void BackupEviction_CheckAgeLogic_EvictsCorrectly()
{
// Arrange
var now = DateTime.UtcNow;
var freshTimestamp = now.AddDays(-6);
var expiredTimestamp = now.AddDays(-8);
// Act & Assert
(now - freshTimestamp).TotalDays.Should().BeLessThanOrEqualTo(7.0);
(now - expiredTimestamp).TotalDays.Should().BeGreaterThan(7.0);
}
}