feat(creator): overhaul Creator flow, editor duplication, and staging setup (#83)
This pull request completely overhauls the Creator editor flow, resolves the editor duplication race condition, aligns layout/styling themes in light and dark mode, and adds Docker staging setups. ### Key Changes 1. **Creator Flow Polish**: Redesigned the editor canvas to prevent double scrolling by delegating overflow to the editor canvas layer, updated styles to a premium aesthetic. 2. **Race Condition Prevention**: Resolved Crepe editor duplication when loading or switching chapters by tracking state via shared window maps (`window.editorCache`, `window.editorStates`) and checking `_lastInitializedEditorId` synchronously in Blazor. 3. **Theme Synchronization**: Integrated explicit theme initialization (`ThemeService.InitializeAsync()`) and anchored CSS isolation selectors to correctly sync with Light (Soft Sepia) and Deep Dark theme preferences. 4. **Staging Automation**: Created staging docker configurations with `--nexus-only` flag to allow iterative development without resetting PG/Neo4j database containers. --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #83 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #83.
This commit is contained in:
@@ -2,24 +2,81 @@
|
||||
@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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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 string? _lastInitializedEditorId;
|
||||
private bool _disposed;
|
||||
|
||||
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 +93,218 @@
|
||||
[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();
|
||||
}
|
||||
|
||||
private Guid _prevChapterId = Guid.Empty;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await base.OnParametersSetAsync();
|
||||
if (ChapterId != Guid.Empty && ChapterId != _prevChapterId)
|
||||
{
|
||||
_prevChapterId = ChapterId;
|
||||
_hasRunStorageInit = false;
|
||||
|
||||
if (_module != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _module.InvokeVoidAsync("destroyEditor", EditorId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[MarkdownEditor] Error destroying old editor on chapter switch: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_reinitializeEditor = true;
|
||||
EditorId = $"milkdown-editor-{Guid.NewGuid():N}";
|
||||
_editorRenderKey = Guid.NewGuid();
|
||||
|
||||
await RunStorageSweepAndRestorationCheckAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
var shouldInit = (firstRender || _reinitializeEditor) && (EditorId != _lastInitializedEditorId);
|
||||
if (shouldInit)
|
||||
{
|
||||
_dotNetHelper = DotNetObjectReference.Create(this);
|
||||
_reinitializeEditor = false;
|
||||
_lastInitializedEditorId = EditorId; // Set immediately before any async yield to prevent concurrent triggers
|
||||
|
||||
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?v={Guid.NewGuid():N}"
|
||||
);
|
||||
}
|
||||
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?v={Guid.NewGuid():N}"
|
||||
);
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// Trigger an immediate background API autosave to synchronize the database with the restored content
|
||||
_ = TriggerAutosaveAsync(InitialMarkdown, _cts.Token);
|
||||
|
||||
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)
|
||||
@@ -82,12 +320,137 @@
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task<string> UploadImageFromJs(string filename, string contentType, byte[] fileBytes)
|
||||
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;
|
||||
CancellationToken token;
|
||||
lock (_timerLock)
|
||||
{
|
||||
if (_debounceCts != null)
|
||||
{
|
||||
ctsToCancel = _debounceCts;
|
||||
_debounceCts = null;
|
||||
}
|
||||
_debounceCts = new CancellationTokenSource();
|
||||
token = _debounceCts.Token; // Capture token synchronously under lock on UI thread
|
||||
}
|
||||
|
||||
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 () =>
|
||||
{
|
||||
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 || _disposed) 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,
|
||||
token
|
||||
);
|
||||
|
||||
if (_disposed) return;
|
||||
|
||||
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)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_status = SaveStatus.OfflineLocalBackup;
|
||||
Console.WriteLine($"[MarkdownEditor] Autosave HTTP exception: {ex.Message}");
|
||||
}
|
||||
|
||||
if (_disposed) return;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task<string> UploadImageFromJs(string filename, string contentType, IJSStreamReference streamRef)
|
||||
{
|
||||
try
|
||||
{
|
||||
const long maxFileSize = 5 * 1024 * 1024; // 5MB limit
|
||||
using var stream = await streamRef.OpenReadStreamAsync(maxFileSize, _cts.Token);
|
||||
using var memoryStream = new MemoryStream();
|
||||
await stream.CopyToAsync(memoryStream, _cts.Token);
|
||||
var fileBytes = memoryStream.ToArray();
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(fileBytes);
|
||||
using var fileContent = new ByteArrayContent(fileBytes);
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
|
||||
content.Add(fileContent, "file", filename);
|
||||
|
||||
@@ -96,24 +459,25 @@
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<NexusReader.Application.DTOs.Media.UploadResultDto>(
|
||||
NexusReader.Application.Common.AppJsonContext.Default.UploadResultDto, _cts.Token);
|
||||
return result?.Url ?? string.Empty;
|
||||
return result?.Url ?? "https://placehold.co/600x400?text=Upload+Failed";
|
||||
}
|
||||
else
|
||||
{
|
||||
var errorMsg = await response.Content.ReadAsStringAsync();
|
||||
var errorMsg = await response.Content.ReadAsStringAsync(_cts.Token);
|
||||
Console.WriteLine($"[MarkdownEditor] Image upload failed: {response.StatusCode} - {errorMsg}");
|
||||
return string.Empty;
|
||||
return "https://placehold.co/600x400?text=Upload+Failed";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[MarkdownEditor] Exception during image upload: {ex.Message}");
|
||||
return string.Empty;
|
||||
return "https://placehold.co/600x400?text=Upload+Failed";
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_disposed = true;
|
||||
try
|
||||
{
|
||||
_cts.Cancel();
|
||||
@@ -121,15 +485,57 @@
|
||||
}
|
||||
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
|
||||
{
|
||||
// Always try to destroy via global window registration first to handle null _module
|
||||
await JS.InvokeVoidAsync("milkdownWrapper.destroyEditor", EditorId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to module if global is not set
|
||||
if (_module is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _module.InvokeVoidAsync("destroyEditor", EditorId);
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -143,7 +549,6 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log other unexpected errors
|
||||
Console.WriteLine($"[MarkdownEditor] Unexpected error during JS cleanup: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
|
||||
Reference in New Issue
Block a user