feat(creator): overhaul Creator flow, editor duplication, and staging setup #83
@@ -21,6 +21,8 @@ namespace NexusReader.Application.Common;
|
|||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterRequest))]
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterRequest))]
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterResponse))]
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterResponse))]
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.UploadResultDto))]
|
[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
|
public partial class AppJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,3 +16,18 @@ public record ValidateChapterResponse(string SanitizedContent);
|
|||||||
/// Response DTO containing the uploaded media file URL.
|
/// Response DTO containing the uploaded media file URL.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record UploadResultDto(string Url);
|
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
|
@implements IAsyncDisposable
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@inject HttpClient Http
|
@inject HttpClient Http
|
||||||
|
@inject NexusReader.Application.Abstractions.Services.INativeStorageService StorageService
|
||||||
|
|
||||||
<div class="markdown-editor-container" style="height: @Height; width: @Width;">
|
<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)
|
@if (ShowFetchButton)
|
||||||
{
|
{
|
||||||
<div class="editor-actions">
|
<div class="editor-actions">
|
||||||
@@ -13,14 +31,49 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@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 readonly CancellationTokenSource _cts = new();
|
||||||
private IJSObjectReference? _module;
|
private IJSObjectReference? _module;
|
||||||
private DotNetObjectReference<MarkdownEditor>? _dotNetHelper;
|
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]
|
[Parameter]
|
||||||
public bool ShowFetchButton { get; set; } = true;
|
public bool ShowFetchButton { get; set; } = true;
|
||||||
|
|
||||||
@@ -36,37 +89,183 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public string Width { get; set; } = "100%";
|
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)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
|
if (firstRender || _reinitializeEditor)
|
||||||
|
{
|
||||||
|
_reinitializeEditor = false;
|
||||||
|
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
_dotNetHelper = DotNetObjectReference.Create(this);
|
_dotNetHelper = DotNetObjectReference.Create(this);
|
||||||
|
// Retry if deferred during prerendering OnInitializedAsync
|
||||||
|
await RunStorageSweepAndRestorationCheckAsync();
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Import the isolated JavaScript module
|
if (_module == null)
|
||||||
|
{
|
||||||
_module = await JS.InvokeAsync<IJSObjectReference>(
|
_module = await JS.InvokeAsync<IJSObjectReference>(
|
||||||
"import",
|
"import",
|
||||||
"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js"
|
"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
// Call the initialization function in the wrapper
|
|
||||||
await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown);
|
await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Log the exception gracefully and do not crash the component
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Error initializing Milkdown editor: {ex.Message}");
|
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()
|
public async Task FetchContentAsync()
|
||||||
{
|
{
|
||||||
if (_module is not null)
|
if (_module is not null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Retrieve the updated markdown from JS
|
|
||||||
var markdown = await _module.InvokeAsync<string>("getMarkdownContent", EditorId);
|
var markdown = await _module.InvokeAsync<string>("getMarkdownContent", EditorId);
|
||||||
|
|
||||||
if (OnSave.HasDelegate)
|
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
|
|||||||
|
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
Antigravity
commented
🟢 Minor/Suggestion: Missing component disposal check
Suggested Fix: 🟢 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]
|
[JSInvokable]
|
||||||
public async Task<string> UploadImageFromJs(string filename, string contentType, IJSStreamReference streamRef)
|
public async Task<string> UploadImageFromJs(string filename, string contentType, IJSStreamReference streamRef)
|
||||||
{
|
{
|
||||||
@@ -127,14 +446,36 @@
|
|||||||
}
|
}
|
||||||
catch
|
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
|
try
|
||||||
{
|
{
|
||||||
if (_module is not null)
|
if (_module is not null)
|
||||||
{
|
{
|
||||||
// Clean up the JS editor instance to prevent memory leaks
|
|
||||||
await _module.InvokeVoidAsync("destroyEditor", EditorId);
|
await _module.InvokeVoidAsync("destroyEditor", EditorId);
|
||||||
await _module.DisposeAsync();
|
await _module.DisposeAsync();
|
||||||
}
|
}
|
||||||
@@ -149,7 +490,6 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Log other unexpected errors
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Unexpected error during JS cleanup: {ex.Message}");
|
Console.WriteLine($"[MarkdownEditor] Unexpected error during JS cleanup: {ex.Message}");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -84,3 +84,114 @@
|
|||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
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
|
// Store the editor instance in the map
|
||||||
editorCache.set(elementId, crepe);
|
editorCache.set(elementId, crepe);
|
||||||
|
|
||||||
@@ -131,3 +145,21 @@ export async function destroyEditor(elementId) {
|
|||||||
editorCache.delete(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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -827,6 +827,16 @@ app.MapPost("/api/chapters/validate", (
|
|||||||
return Results.Ok(new NexusReader.Application.DTOs.Media.ValidateChapterResponse(sanitized));
|
return Results.Ok(new NexusReader.Application.DTOs.Media.ValidateChapterResponse(sanitized));
|
||||||
}).DisableAntiforgery();
|
}).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>()
|
app.MapRazorComponents<App>()
|
||||||
.AddInteractiveServerRenderMode()
|
.AddInteractiveServerRenderMode()
|
||||||
.AddInteractiveWebAssemblyRenderMode()
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user
🔴 Blocking: Race Condition in Debounce Timer
The background task reads
_debounceCts.Tokenfrom 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.Runand use the captured token directly inside the task: