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.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,25 +2,78 @@
|
||||
@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 (ShowFetchButton)
|
||||
@if (_showRestorationBanner)
|
||||
{
|
||||
<div class="editor-actions">
|
||||
<button type="button" @onclick="FetchContentAsync" class="nexus-btn">
|
||||
Fetch Markdown Content
|
||||
</button>
|
||||
<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">
|
||||
<button type="button" @onclick="FetchContentAsync" class="nexus-btn">
|
||||
Fetch Markdown Content
|
||||
</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)
|
||||
if (firstRender || _reinitializeEditor)
|
||||
{
|
||||
_dotNetHelper = DotNetObjectReference.Create(this);
|
||||
_reinitializeEditor = false;
|
||||
|
||||
if (firstRender)
|
||||
{
|
||||
_dotNetHelper = DotNetObjectReference.Create(this);
|
||||
// Retry if deferred during prerendering OnInitializedAsync
|
||||
await RunStorageSweepAndRestorationCheckAsync();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Import the isolated JavaScript module
|
||||
_module = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import",
|
||||
"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js"
|
||||
);
|
||||
|
||||
// Call the initialization function in the wrapper
|
||||
if (_module == null)
|
||||
{
|
||||
_module = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import",
|
||||
"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js"
|
||||
);
|
||||
}
|
||||
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
|
||||
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]
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
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: