1 Commits

Author SHA1 Message Date
Antigravity c94e8f0acb 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>
2026-06-15 17:15:42 +00:00
22 changed files with 890 additions and 557 deletions
+1
View File
@@ -26,6 +26,7 @@ RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseApp
# Stage 2: Runtime # Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=build /app/publish . COPY --from=build /app/publish .
+1 -1
View File
@@ -50,5 +50,5 @@ version: 1.0
> [!IMPORTANT] > [!IMPORTANT]
> **Docker Lifecycle Management** > **Docker Lifecycle Management**
> Before starting work, the Docker instance of the application must be stopped. After finishing work, a new version from the current branch should be pushed to Docker and the instance restarted. > Before starting work, only the web (nexus) container needs to be stopped to prevent port/application conflicts (e.g., `./run-stage.sh --stop --nexus-only` or `-s -n`); database containers (PostgreSQL, Neo4j, Qdrant) should continue to run to support local development/debugging. After finishing work, a new version of the web container from the current branch should be rebuilt and restarted via `./run-stage.sh --nexus-only` (or `-n`).
+1
View File
@@ -30,6 +30,7 @@ services:
- ASPNETCORE_ENVIRONMENT=Staging - ASPNETCORE_ENVIRONMENT=Staging
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_stage_db};Username=${POSTGRES_USER:-nexus_user_stage};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} - ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_stage_db};Username=${POSTGRES_USER:-nexus_user_stage};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
- ConnectionStrings__QdrantConnection=http://qdrant:6334 - ConnectionStrings__QdrantConnection=http://qdrant:6334
- Qdrant__ApiKey=${QDRANT_API_KEY:-}
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687 - ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
- Neo4j__Username=${NEO4J_USERNAME:-neo4j} - Neo4j__Username=${NEO4J_USERNAME:-neo4j}
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required} - Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
+60 -9
View File
@@ -4,11 +4,45 @@
# ------------------------------------------------------------- # -------------------------------------------------------------
set -e set -e
NEXUS_ONLY=false
STOP=false
for arg in "$@"; do
case $arg in
--nexus-only|-n)
NEXUS_ONLY=true
;;
--stop|-s)
STOP=true
;;
esac
done
ENV_FILE=".env.stage" ENV_FILE=".env.stage"
TEMPLATE_FILE=".env.stage.template" TEMPLATE_FILE=".env.stage.template"
COMPOSE_FILE="docker-compose.stage.yml" COMPOSE_FILE="docker-compose.stage.yml"
if [ "$STOP" = true ]; then
echo "🛑 Stopping staging environment..."
if [ ! -f "$ENV_FILE" ] && [ -f "$TEMPLATE_FILE" ]; then
cp "$TEMPLATE_FILE" "$ENV_FILE"
fi
if [ "$NEXUS_ONLY" = true ]; then
echo "🧹 Stopping and removing only the web (nexus) container..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" stop web || true
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" rm -f web || true
else
echo "🧹 Stopping all containers..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans || true
docker compose down --remove-orphans 2>/dev/null || true
fi
echo "✅ Staging environment stopped."
exit 0
fi
echo "🏁 Starting staging environment orchestration..." echo "🏁 Starting staging environment orchestration..."
if [ "$NEXUS_ONLY" = true ]; then
echo "️ Mode: --nexus-only (only the web/nexus application container will be modified)"
fi
# 1. Create .env.stage if it doesn't exist # 1. Create .env.stage if it doesn't exist
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ]; then
@@ -37,6 +71,12 @@ if grep -q "CHANGE_ME_TO_SECURE_ADMIN_PASSWORD" "$ENV_FILE"; then
sed -i "s/NEXUS_ADMIN_PASSWORD=CHANGE_ME_TO_SECURE_ADMIN_PASSWORD/NEXUS_ADMIN_PASSWORD=$ADMIN_PASS/g" "$ENV_FILE" sed -i "s/NEXUS_ADMIN_PASSWORD=CHANGE_ME_TO_SECURE_ADMIN_PASSWORD/NEXUS_ADMIN_PASSWORD=$ADMIN_PASS/g" "$ENV_FILE"
fi fi
if grep -q "^QDRANT_API_KEY=$" "$ENV_FILE" || grep -q "^QDRANT_API_KEY=[[:space:]]*$" "$ENV_FILE"; then
echo "🔐 Generating secure random Qdrant API key in $ENV_FILE..."
QD_KEY=$(openssl rand -hex 16)
sed -i "s/^QDRANT_API_KEY=.*/QDRANT_API_KEY=$QD_KEY/g" "$ENV_FILE"
fi
# Load staging variables for local execution context (needed for ports/migrations) # Load staging variables for local execution context (needed for ports/migrations)
# Clean up carriage returns just in case # Clean up carriage returns just in case
POSTGRES_USER=$(grep "^POSTGRES_USER=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r') POSTGRES_USER=$(grep "^POSTGRES_USER=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
@@ -52,13 +92,24 @@ POSTGRES_PORT=${POSTGRES_PORT:-5438}
WEB_PORT=${WEB_PORT:-5080} WEB_PORT=${WEB_PORT:-5080}
# 3. Stop any conflicting Docker Compose environments # 3. Stop any conflicting Docker Compose environments
echo "🧹 Stopping existing containers..." if [ "$NEXUS_ONLY" = true ]; then
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans || true echo "🧹 Stopping and removing only the web (nexus) container..."
docker compose down --remove-orphans 2>/dev/null || true docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" stop web || true
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" rm -f web || true
else
echo "🧹 Stopping existing containers..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans || true
docker compose down --remove-orphans 2>/dev/null || true
fi
# 4. Build and start containers # 4. Build and start containers
echo "🚀 Building and starting staging containers..." if [ "$NEXUS_ONLY" = true ]; then
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build echo "🚀 Building and restarting only the web (nexus) container..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build web
else
echo "🚀 Building and starting staging containers..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build
fi
# 5. Wait for Database to be healthy # 5. Wait for Database to be healthy
echo "⏳ Waiting for database (nexus-db-stage) to become healthy..." echo "⏳ Waiting for database (nexus-db-stage) to become healthy..."
@@ -81,14 +132,14 @@ export ConnectionStrings__PostgresConnection="Host=127.0.0.1;Port=$POSTGRES_PORT
dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web --no-build dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web --no-build
# 7. Wait for Web Application to respond # 7. Wait for Web Application to respond
echo "⏳ Waiting for Web Application to start on http://localhost:$WEB_PORT..." echo "⏳ Waiting for Web Application to start on http://localhost:$WEB_PORT/health..."
MAX_WEB_ATTEMPTS=20 MAX_WEB_ATTEMPTS=30
web_attempt=0 web_attempt=0
until curl -s -f "http://localhost:$WEB_PORT" >/dev/null; do until curl -s -f "http://localhost:$WEB_PORT/health" >/dev/null; do
sleep 2 sleep 2
web_attempt=$((web_attempt + 1)) web_attempt=$((web_attempt + 1))
if [ $web_attempt -ge $MAX_WEB_ATTEMPTS ]; then if [ $web_attempt -ge $MAX_WEB_ATTEMPTS ]; then
echo "⚠️ Warning: Web app is not responding yet on http://localhost:$WEB_PORT, but let's check logs..." echo "⚠️ Warning: Web app is not responding yet on http://localhost:$WEB_PORT/health, but let's check logs..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" logs web docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" logs web
break break
fi fi
@@ -38,7 +38,7 @@ public class PublishBookVersionCommandHandler : ICommandHandler<PublishBookVersi
if (book == null) if (book == null)
{ {
throw new BookNotFoundException(request.BookId); return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found."));
} }
var oldDraftRevision = book.CurrentDraftRevision; var oldDraftRevision = book.CurrentDraftRevision;
@@ -41,7 +41,7 @@ public class GetBookRevisionsQueryHandler : IQueryHandler<GetBookRevisionsQuery,
if (!bookExists) if (!bookExists)
{ {
throw new BookNotFoundException(request.BookId); return FluentResults.Result.Fail<List<CreatorBookRevisionDto>>(new FluentResults.Error($"Book with ID '{request.BookId}' was not found."));
} }
// Fetch all revisions sorted chronologically // Fetch all revisions sorted chronologically
@@ -55,7 +55,15 @@ public static class DependencyInjection
// Qdrant Client registration // Qdrant Client registration
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334"; var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
services.AddSingleton<QdrantClient>(sp => new QdrantClient(new Uri(qdrantUrl))); var qdrantApiKey = configuration["Qdrant:ApiKey"];
services.AddSingleton<QdrantClient>(sp =>
{
if (!string.IsNullOrEmpty(qdrantApiKey))
{
return new QdrantClient(new Uri(qdrantUrl), apiKey: qdrantApiKey);
}
return new QdrantClient(new Uri(qdrantUrl));
});
// Neo4j Driver registration (supports optional authentication) // Neo4j Driver registration (supports optional authentication)
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687"; var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
@@ -42,6 +42,8 @@
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 string? _lastInitializedEditorId;
private bool _disposed;
private enum SaveStatus private enum SaveStatus
{ {
@@ -136,9 +138,11 @@
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender || _reinitializeEditor) var shouldInit = (firstRender || _reinitializeEditor) && (EditorId != _lastInitializedEditorId);
if (shouldInit)
{ {
_reinitializeEditor = false; _reinitializeEditor = false;
_lastInitializedEditorId = EditorId; // Set immediately before any async yield to prevent concurrent triggers
if (firstRender) if (firstRender)
{ {
@@ -153,7 +157,7 @@
{ {
_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?v={Guid.NewGuid():N}"
); );
} }
await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown); await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown);
@@ -178,7 +182,7 @@
{ {
_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?v={Guid.NewGuid():N}"
); );
} }
@@ -347,6 +351,7 @@
// Cancel pending timers thread-safely // Cancel pending timers thread-safely
CancellationTokenSource? ctsToCancel = null; CancellationTokenSource? ctsToCancel = null;
CancellationToken token;
lock (_timerLock) lock (_timerLock)
{ {
if (_debounceCts != null) if (_debounceCts != null)
@@ -355,6 +360,7 @@
_debounceCts = null; _debounceCts = null;
} }
_debounceCts = new CancellationTokenSource(); _debounceCts = new CancellationTokenSource();
token = _debounceCts.Token; // Capture token synchronously under lock on UI thread
} }
if (ctsToCancel != null) if (ctsToCancel != null)
@@ -373,13 +379,6 @@
// Start 5-second idle debounce timer // Start 5-second idle debounce timer
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
CancellationToken token;
lock (_timerLock)
{
if (_debounceCts == null) return;
token = _debounceCts.Token;
}
try try
{ {
await Task.Delay(5000, token); await Task.Delay(5000, token);
@@ -398,7 +397,7 @@
private async Task TriggerAutosaveAsync(string markdown, CancellationToken token) private async Task TriggerAutosaveAsync(string markdown, CancellationToken token)
{ {
if (token.IsCancellationRequested) return; if (token.IsCancellationRequested || _disposed) return;
_status = SaveStatus.Saving; _status = SaveStatus.Saving;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
@@ -413,6 +412,8 @@
token token
); );
if (_disposed) return;
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
// Purge LocalStorage backup key on HTTP success // Purge LocalStorage backup key on HTTP success
@@ -428,10 +429,12 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
if (_disposed) return;
_status = SaveStatus.OfflineLocalBackup; _status = SaveStatus.OfflineLocalBackup;
Console.WriteLine($"[MarkdownEditor] Autosave HTTP exception: {ex.Message}"); Console.WriteLine($"[MarkdownEditor] Autosave HTTP exception: {ex.Message}");
} }
if (_disposed) return;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -474,6 +477,7 @@
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
_disposed = true;
try try
{ {
_cts.Cancel(); _cts.Cancel();
@@ -509,9 +513,29 @@
try 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) if (_module is not null)
{
try
{ {
await _module.InvokeVoidAsync("destroyEditor", EditorId); await _module.InvokeVoidAsync("destroyEditor", EditorId);
}
catch
{
// Fail silently
}
}
}
try
{
if (_module is not null)
{
await _module.DisposeAsync(); await _module.DisposeAsync();
} }
} }
@@ -183,10 +183,11 @@
InvokeAsync(StateHasChanged); InvokeAsync(StateHasChanged);
} }
protected override void OnAfterRender(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender)
{ {
await ThemeService.InitializeAsync();
_isFullyLoaded = true; _isFullyLoaded = true;
StateHasChanged(); StateHasChanged();
} }
@@ -354,6 +354,8 @@
/* --- Desktop Sidebar: warm paper shadow --- */ /* --- Desktop Sidebar: warm paper shadow --- */
.theme-light ::deep .hub-sidebar { .theme-light ::deep .hub-sidebar {
box-shadow: 4px 0 20px rgba(139, 130, 115, 0.08); box-shadow: 4px 0 20px rgba(139, 130, 115, 0.08);
background: var(--bg-surface) !important;
border-right: 1px solid var(--border) !important;
} }
/* --- Logo icon: remove neon glow --- */ /* --- Logo icon: remove neon glow --- */
@@ -1,218 +0,0 @@
@page "/creator/edit/{BookId:guid}"
@attribute [Authorize]
@using System.Net.Http.Json
@using Microsoft.Extensions.Logging
@using NexusReader.Application.DTOs.Creator
@inject HttpClient Http
@inject NavigationManager NavigationManager
@inject ILogger<Creator> Logger
<PageTitle>Workspace Autora | Nexus Reader</PageTitle>
<div class="workspace-container">
<!-- Left Sidebar for Chapter Selection -->
<aside class="workspace-sidebar glass-panel">
<div class="sidebar-header">
<button type="button" class="back-dashboard-btn" @onclick="NavigateToDashboard">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
<span>Dashboard</span>
</button>
<h3 class="sidebar-title">Rozdziały</h3>
</div>
<nav class="chapters-nav">
@if (_chaptersLoading)
{
<div class="sidebar-loading">
<div class="spinner-glow small"></div>
<span>Ładowanie spisu...</span>
</div>
}
else if (_chapters == null || !_chapters.Any())
{
<div class="sidebar-empty">Brak rozdziałów w tej wersji.</div>
}
else
{
<ul class="chapters-list">
@foreach (var ch in _chapters)
{
<li class="chapter-item @(ch.Id == _activeChapterId ? "active" : "")" @onclick="() => LoadChapterContentAsync(ch.Id)">
<span class="chapter-order">@ch.SortOrder.</span>
<span class="chapter-name" title="@ch.Title">@ch.Title</span>
</li>
}
</ul>
}
</nav>
</aside>
<!-- Right Workspace Area -->
<main class="workspace-content">
@if (_contentLoading)
{
<div class="editor-loading-placeholder glass-panel">
<div class="spinner-glow"></div>
<h3 class="loading-title">Wczytywanie treści rozdziału...</h3>
<p>Przygotowywanie edytora Zen Mode i sprawdzanie kopii zapasowych w LocalStorage...</p>
</div>
}
else if (_activeChapterId == Guid.Empty)
{
<div class="workspace-empty glass-panel">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4z"></path>
</svg>
<h3>Wybierz rozdział z listy</h3>
<p>Kliknij na dowolny tytuł w panelu bocznym, aby rozpocząć pisanie lub edycję.</p>
</div>
}
else
{
<div class="editor-workspace-card glass-panel" spellcheck="false">
<div class="editor-header-meta">
<h2 class="active-chapter-title">@_activeChapterTitle</h2>
<span class="chapter-id-badge">ID: @_activeChapterId.ToString().Substring(0, 8)...</span>
</div>
<div class="editor-growing-area">
<MarkdownEditor @ref="_editorRef"
InitialMarkdown="@_chapterMarkdown"
ChapterId="@_activeChapterId"
ServerTimestamp="@_serverTimestamp"
OnSave="HandleSave"
ShowFetchButton="true"
Height="100%" />
</div>
</div>
}
</main>
</div>
@code {
[Parameter]
public Guid? BookId { get; set; }
private MarkdownEditor? _editorRef;
private bool _chaptersLoading = true;
private bool _contentLoading = false;
private List<ChapterItemDto> _chapters = new();
private Guid _activeChapterId = Guid.Empty;
private string _activeChapterTitle = string.Empty;
private string _chapterMarkdown = string.Empty;
private DateTime _serverTimestamp = DateTime.UtcNow;
public class ChapterItemDto
{
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
public int SortOrder { get; set; }
}
public class ChapterDetailsDto
{
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
public string MarkdownContent { get; set; } = string.Empty;
}
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
if (BookId.HasValue && BookId.Value != Guid.Empty)
{
await LoadBookChaptersAsync();
}
else
{
_chaptersLoading = false;
_chapters.Clear();
_activeChapterId = Guid.Empty;
_chapterMarkdown = string.Empty;
}
}
private async Task LoadBookChaptersAsync()
{
_chaptersLoading = true;
StateHasChanged();
try
{
_chapters = await Http.GetFromJsonAsync<List<ChapterItemDto>>($"api/creator/books/{BookId}/chapters") ?? new();
// Extract the query parameter chapterId if available
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
Guid targetChapterId = Guid.Empty;
if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("chapterId", out var chapterValue))
{
Guid.TryParse(chapterValue, out targetChapterId);
}
if (targetChapterId != Guid.Empty && _chapters.Any(c => c.Id == targetChapterId))
{
await LoadChapterContentAsync(targetChapterId);
}
else if (_chapters.Any())
{
await LoadChapterContentAsync(_chapters.First().Id);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to load book chapters.");
}
finally
{
_chaptersLoading = false;
StateHasChanged();
}
}
private async Task LoadChapterContentAsync(Guid chapterId)
{
if (chapterId == Guid.Empty) return;
_contentLoading = true;
_activeChapterId = chapterId;
_activeChapterTitle = _chapters.FirstOrDefault(c => c.Id == chapterId)?.Title ?? "Rozdział";
StateHasChanged();
try
{
var details = await Http.GetFromJsonAsync<ChapterDetailsDto>($"api/chapters/{chapterId}");
if (details != null)
{
_chapterMarkdown = details.MarkdownContent;
_serverTimestamp = DateTime.UtcNow; // Used to check database sync freshness
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to load chapter content.");
_chapterMarkdown = string.Empty;
}
finally
{
_contentLoading = false;
StateHasChanged();
}
}
private void HandleSave(string markdown)
{
_chapterMarkdown = markdown;
Logger.LogInformation("Saved markdown content length: {Length}", markdown.Length);
}
private void NavigateToDashboard()
{
NavigationManager.NavigateTo("/creator");
}
}
@@ -1,275 +0,0 @@
.workspace-container {
display: flex;
min-height: calc(100vh - 64px); /* assuming top navbar is 64px */
width: 100%;
background: var(--bg-base);
animation: fade-in 0.4s ease-out;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* --- Left Sidebar --- */
.workspace-sidebar {
width: 280px;
flex-shrink: 0;
border-right: 1px solid var(--border);
background: var(--bg-surface);
display: flex;
flex-direction: column;
padding: 1.5rem 0;
z-index: 10;
}
.sidebar-header {
padding: 0 1.5rem 1.5rem;
border-bottom: 1px dashed var(--border);
display: flex;
flex-direction: column;
gap: 1rem;
}
.back-dashboard-btn {
background: transparent;
border: none;
color: var(--text-muted);
font-size: 0.85rem;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
padding: 0.25rem 0;
transition: color 0.2s;
width: fit-content;
}
.back-dashboard-btn:hover {
color: var(--text-main);
}
.sidebar-title {
font-family: var(--nexus-font-serif, serif);
font-size: 1.25rem;
font-weight: 700;
color: var(--text-main);
margin: 0;
}
.chapters-nav {
flex: 1;
overflow-y: auto;
padding: 1rem 0;
}
.sidebar-loading, .sidebar-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem 1.5rem;
color: var(--text-muted);
font-size: 0.85rem;
text-align: center;
}
.chapters-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.chapter-item {
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
border-left: 3px solid transparent;
color: var(--text-muted);
}
.chapter-item:hover {
background: rgba(255, 255, 255, 0.02);
color: var(--text-main);
}
.chapter-item.active {
background: rgba(16, 185, 129, 0.03);
border-left-color: var(--accent);
color: var(--text-main);
font-weight: 600;
}
.chapter-order {
font-size: 0.8rem;
opacity: 0.5;
}
.chapter-name {
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* --- Right Content Workspace --- */
.workspace-content {
flex: 1;
padding: 2.5rem;
display: flex;
flex-direction: column;
overflow-y: auto;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.workspace-empty, .editor-loading-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 4rem 2rem;
gap: 1.5rem;
height: 100%;
min-height: 400px;
}
.workspace-empty svg {
color: var(--text-muted);
opacity: 0.4;
}
.workspace-empty h3, .loading-title {
font-family: var(--nexus-font-serif, serif);
font-size: 1.5rem;
font-weight: 600;
color: var(--text-main);
margin: 0;
}
.workspace-empty p {
font-size: 0.95rem;
color: var(--text-muted);
max-width: 400px;
line-height: 1.5;
}
.editor-workspace-card {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 2rem;
height: 100%;
min-height: 500px;
}
.editor-header-meta {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
border-bottom: 1px dashed var(--border);
}
.active-chapter-title {
font-family: var(--nexus-font-serif, serif);
font-size: 1.75rem;
font-weight: 700;
color: var(--text-main);
margin: 0;
}
.chapter-id-badge {
font-size: 0.75rem;
color: var(--text-muted);
padding: 4px 10px;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: 6px;
text-transform: uppercase;
}
.editor-growing-area {
flex: 1;
display: flex;
flex-direction: column;
}
/* Glassmorphism Panel styles */
.glass-panel {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.03);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.spinner-glow {
width: 36px;
height: 36px;
border: 3px solid rgba(16, 185, 129, 0.1);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin-glow 1s linear infinite;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.2);
}
@keyframes spin-glow {
100% { transform: rotate(360deg); }
}
/* --- Mobile View Adjustments --- */
@media (max-width: 992px) {
.workspace-sidebar {
width: 220px;
}
.workspace-content {
padding: 1.5rem;
}
}
@media (max-width: 768px) {
.workspace-container {
flex-direction: column;
}
.workspace-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border);
padding: 1rem 0;
}
.chapters-list {
flex-direction: row;
overflow-x: auto;
padding: 0 1rem;
}
.chapter-item {
padding: 0.5rem 1rem;
border-left: none;
border-bottom: 3px solid transparent;
white-space: nowrap;
}
.chapter-item.active {
border-bottom-color: var(--accent);
}
.sidebar-header {
padding: 0 1rem 0.5rem;
border-bottom: none;
}
.workspace-content {
padding: 1rem;
}
.active-chapter-title {
font-size: 1.35rem;
}
}
@@ -372,7 +372,7 @@
{ {
if (book.FirstChapterId.HasValue) if (book.FirstChapterId.HasValue)
{ {
NavigationManager.NavigateTo($"/creator/edit/{book.Id}?chapterId={book.FirstChapterId.Value}"); NavigationManager.NavigateTo($"/creator/edit/{book.Id}/{book.FirstChapterId.Value}");
} }
else else
{ {
@@ -0,0 +1,186 @@
@page "/creator/edit/{BookId}"
@page "/creator/edit/{BookId}/{ChapterId}"
@layout MainHubLayout
@attribute [Authorize]
@using NexusReader.UI.Shared.Components
@if (_loadingChapters)
{
<div class="hub-loading" style="height: calc(100vh - 4rem); display: flex; flex-direction: column; justify-content: center; align-items: center; background-color: var(--bg-base);">
<div class="nexus-loader"></div>
<p style="margin-top: 1rem; color: var(--text-muted); font-family: var(--nexus-font-sans);">Ładowanie struktury książki...</p>
</div>
}
else
{
<div class="creator-edit-fullscreen-wrapper">
<div class="chapters-sidebar">
<div class="sidebar-meta-header">
<h2>Rozdziały</h2>
</div>
<div class="chapters-list-wrapper">
@foreach (var ch in _chapters)
{
var isActive = ch.Id == _activeChapterId;
<a class="chapter-item @(isActive ? "active" : "")" href="/creator/edit/@BookId/@ch.Id">
@if (isActive)
{
<div class="active-indicator"></div>
<i class="fa-solid fa-book-open chapter-icon"></i>
}
else
{
<i class="fa-solid fa-file-lines chapter-icon"></i>
}
<span class="chapter-title-text">@ch.Title</span>
</a>
}
</div>
</div>
<div class="editor-workspace-area">
<div class="editor-header-row">
<div class="title-zone">
<h1 class="chapter-title">@_activeChapterTitle</h1>
</div>
<div class="telemetry-zone">
<span class="chapter-id-badge">ID: @_activeChapterId</span>
</div>
</div>
<div class="editor-canvas-card">
@if (_loadingChapter)
{
<div class="hub-loading" style="height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center;">
<div class="nexus-loader"></div>
<p style="margin-top: 1rem; color: var(--text-muted); font-family: var(--nexus-font-sans);">Wczytywanie treści rozdziału...</p>
</div>
}
else if (_isChapterLoaded)
{
<div class="milkdown-premium-container" spellcheck="false">
<MarkdownEditor @key="_activeChapterId"
ChapterId="_activeChapterId"
InitialMarkdown="@_initialMarkdown"
ShowFetchButton="false" />
</div>
}
else
{
<div style="height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; color: var(--text-muted); font-family: var(--nexus-font-sans);">
<p>Wybierz lub utwórz rozdział, aby rozpocząć edycję.</p>
</div>
}
</div>
</div>
</div>
}
@code {
[Inject] private HttpClient Http { get; set; } = default!;
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
[Parameter] public string BookId { get; set; } = string.Empty;
[Parameter] public string ChapterId { get; set; } = string.Empty;
private List<ChapterListItem> _chapters = new();
private Guid _parsedBookId = Guid.Empty;
private Guid _activeChapterId = Guid.Empty;
private string _activeChapterTitle = string.Empty;
private string _initialMarkdown = string.Empty;
private bool _loadingChapters = true;
private bool _loadingChapter = false;
private bool _isChapterLoaded = false;
private class ChapterListItem
{
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
public int SortOrder { get; set; }
}
private class ChapterDetail
{
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
public string MarkdownContent { get; set; } = string.Empty;
}
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
if (!Guid.TryParse(BookId, out var parsedBookId))
{
NavigationManager.NavigateTo("/creator");
return;
}
_parsedBookId = parsedBookId;
// Fetch chapters list if empty or if book ID has changed
if (_chapters.Count == 0)
{
_loadingChapters = true;
try
{
var chapters = await Http.GetFromJsonAsync<List<ChapterListItem>>($"/api/creator/books/{_parsedBookId}/chapters");
_chapters = chapters ?? new();
}
catch (Exception ex)
{
Console.WriteLine($"[CreatorEdit] Error fetching chapters list: {ex.Message}");
}
finally
{
_loadingChapters = false;
}
}
// If ChapterId is empty/null, select the first chapter from list and navigate
if (string.IsNullOrEmpty(ChapterId))
{
if (_chapters.Any())
{
NavigationManager.NavigateTo($"/creator/edit/{BookId}/{_chapters.First().Id}");
}
return;
}
if (Guid.TryParse(ChapterId, out var parsedChapterId))
{
// If active chapter changed, fetch its details
if (parsedChapterId != _activeChapterId)
{
_activeChapterId = parsedChapterId;
var ch = _chapters.FirstOrDefault(c => c.Id == _activeChapterId);
_activeChapterTitle = ch?.Title ?? "Rozdział";
_loadingChapter = true;
_isChapterLoaded = false;
StateHasChanged();
try
{
var detail = await Http.GetFromJsonAsync<ChapterDetail>($"/api/chapters/{_activeChapterId}");
if (detail != null)
{
_initialMarkdown = detail.MarkdownContent;
_isChapterLoaded = true;
}
}
catch (Exception ex)
{
Console.WriteLine($"[CreatorEdit] Error fetching chapter content: {ex.Message}");
}
finally
{
_loadingChapter = false;
}
}
}
}
}
@@ -0,0 +1,365 @@
/* ==========================================================================
NEXUSREADER CREATOR EDIT MODE - HIGH-FIDELITY SAAS PREMIUM DESIGN OVERRIDE
========================================================================== */
/* 1. ARCHITECTURAL BOUNDARY CONTROL */
.creator-edit-fullscreen-wrapper {
width: 100% !important;
max-width: 100% !important;
height: calc(100vh - 4rem) !important;
margin: 0 !important;
padding: 0 !important;
display: flex !important;
overflow: hidden !important;
background-color: #121214;
box-sizing: border-box;
}
/* Dynamic theme bridge mapping for Warm Paper mode */
.theme-light .creator-edit-fullscreen-wrapper {
background-color: #f4f1ea;
}
/* 2. UNIFIED SIDEBAR DESIGN (Eliminating layout color fragmentation) */
.chapters-sidebar {
width: 300px !important;
flex-shrink: 0;
background-color: #16161a !important;
border-right: 1px solid rgba(255, 255, 255, 0.04) !important;
display: flex;
flex-direction: column;
padding: 2.5rem 1.5rem !important;
box-sizing: border-box;
}
.theme-light .chapters-sidebar {
background-color: #eae6db !important; /* Rich warm tone that remains fully cohesive with warm paper base */
border-right: 1px solid #dcd7cc !important;
}
.sidebar-meta-header h2 {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 2px;
color: #a1a1aa;
margin: 0 0 1.75rem 0;
}
.theme-light .sidebar-meta-header h2 {
color: #78716c;
}
.chapters-list-wrapper {
display: flex;
flex-direction: column;
gap: 6px;
}
/* Premium Navigation Links */
.chapter-item {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px !important;
border-radius: 10px;
color: #a1a1aa;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.theme-light .chapter-item {
color: #78716c;
}
.chapter-item i.chapter-icon {
font-size: 0.95rem;
color: #71717a;
transition: color 0.25s ease;
}
/* Active Indicator Node Alignment */
.chapter-item.active {
background-color: rgba(0, 255, 153, 0.05) !important;
color: #00ff99 !important;
font-weight: 600;
}
.theme-light .chapter-item.active {
background-color: rgba(16, 185, 129, 0.06) !important;
color: #10b981 !important;
}
.chapter-item.active i.chapter-icon {
color: inherit !important;
}
.chapter-item:hover:not(.active) {
background-color: rgba(255, 255, 255, 0.02);
color: #ffffff;
}
.theme-light .chapter-item:hover:not(.active) {
background-color: rgba(0, 0, 0, 0.02);
color: #2d2a26;
}
/* 3. WORKSPACE METRICS (Zen presentation spacing) */
.editor-workspace-area {
flex-grow: 1;
display: flex;
flex-direction: column;
height: 100%;
padding: 3rem 4rem 2.5rem 4rem !important; /* Generous padding context for premium scale */
box-sizing: border-box;
overflow: hidden;
}
.editor-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
flex-shrink: 0;
width: 100%;
}
.editor-workspace-area h1.chapter-title {
font-size: 2.4rem;
font-weight: 700;
color: #ffffff;
margin: 0;
letter-spacing: -0.75px;
}
.theme-light .editor-workspace-area h1.chapter-title {
color: #2d2a26;
}
.chapter-id-badge {
font-family: 'Azeret Mono', monospace;
font-size: 0.72rem;
color: #71717a;
background: #1a1a1e;
padding: 6px 14px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.05);
letter-spacing: 0.2px;
}
.theme-light .chapter-id-badge {
background: #ffffff;
color: #78716c;
border: 1px solid #dcd7cc;
}
/* 4. ELEVATED EDITOR CANVAS CARD (Introducing layered shadow mechanics) */
.editor-canvas-card {
background-color: #1a1a1e !important;
border: 1px solid rgba(255, 255, 255, 0.04) !important;
border-radius: 20px;
padding: 3rem !important;
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
box-sizing: border-box;
/* Soft diffuse structural shadows mimicking actual surface elevation */
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.2);
}
.theme-light .editor-canvas-card {
background-color: #ffffff !important;
border: 1px solid #dcd7cc !important;
box-shadow: 0 20px 50px rgba(45, 42, 38, 0.04), 0 4px 12px rgba(45, 42, 38, 0.02);
}
.milkdown-premium-container {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
}
/* DEEP MOUNTING COMPONENT INTEROP */
.milkdown-premium-container ::deep .milkdown {
background: transparent !important;
box-shadow: none !important;
border: none !important;
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
width: 100%;
}
.milkdown-premium-container ::deep .ProseMirror {
color: #e4e1d9 !important;
background-color: transparent !important;
font-size: 1.15rem !important;
line-height: 1.8 !important;
flex-grow: 1;
overflow-y: auto !important;
padding-right: 24px !important;
outline: none !important;
box-sizing: border-box;
width: 100%;
}
.theme-light .milkdown-premium-container ::deep .ProseMirror {
color: #2d2a26 !important;
}
/* Precise matching text selection token */
.milkdown-premium-container ::deep .ProseMirror ::selection {
background-color: rgba(0, 255, 153, 0.2) !important;
}
.theme-light .milkdown-premium-container ::deep .ProseMirror ::selection {
background-color: rgba(16, 185, 129, 0.18) !important;
}
/* Core webkit custom scrollbar mapping */
.milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar {
width: 6px;
}
.milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar-track {
background: transparent;
}
.milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
}
.theme-light .milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar-thumb {
background: #dcd7cc;
}
/* 5. SEAMLESS INTEGRATED ACTIONS FOOTER BAR (OVERWRITING FOR MARKDOWNEDITOR COMPONENT INTEGRATION) */
.milkdown-premium-container ::deep .markdown-editor-container {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
height: 100%;
}
.milkdown-premium-container ::deep .milkdown-editor-wrapper {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
flex-grow: 1;
overflow: hidden !important;
display: flex;
flex-direction: column;
}
.milkdown-premium-container ::deep .milkdown {
flex-grow: 1;
overflow: hidden !important;
}
.milkdown-premium-container ::deep .editor-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 2rem !important;
padding: 1.5rem 0 0 0 !important;
border: none !important;
border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
background: transparent !important;
border-radius: 0 !important;
flex-shrink: 0;
width: 100%;
}
.theme-light .milkdown-premium-container ::deep .editor-footer {
border-top: 1px solid #dcd7cc !important;
}
/* Telemetry cloud synchronization line mapping */
.milkdown-premium-container ::deep .status-indicator {
display: flex;
align-items: center;
gap: 12px;
font-family: 'Azeret Mono', monospace;
font-size: 0.82rem;
color: #71717a;
letter-spacing: 0.1px;
}
.theme-light .milkdown-premium-container ::deep .status-indicator {
color: #78716c;
}
.milkdown-premium-container ::deep .status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
}
.milkdown-premium-container ::deep .status-dot.saved {
background-color: #00ff99 !important;
box-shadow: 0 0 10px rgba(0, 255, 153, 0.8) !important;
color: #00ff99 !important;
}
.theme-light .milkdown-premium-container ::deep .status-dot.saved {
background-color: #10b981 !important;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.6) !important;
color: #10b981 !important;
}
.milkdown-premium-container ::deep .status-dot.saving {
background-color: #F59E0B !important;
box-shadow: 0 0 10px rgba(245, 158, 11, 0.8) !important;
color: #F59E0B !important;
}
.milkdown-premium-container ::deep .status-dot.offline {
background-color: #EF4444 !important;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.8) !important;
color: #EF4444 !important;
}
/* Premium Tactile Operational Button Trigger */
.milkdown-premium-container ::deep .nexus-btn {
background-color: #00ff99 !important;
color: #121214 !important;
font-weight: 700;
font-size: 0.9rem;
letter-spacing: -0.1px;
padding: 11px 24px !important;
border: none !important;
border-radius: 10px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 10px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 20px rgba(0, 255, 153, 0.15);
height: auto !important;
min-height: unset !important;
}
.theme-light .milkdown-premium-container ::deep .nexus-btn {
background-color: #10b981 !important;
color: #ffffff !important;
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.15);
}
.milkdown-premium-container ::deep .nexus-btn:hover {
transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(0, 255, 153, 0.3);
}
.theme-light .milkdown-premium-container ::deep .nexus-btn:hover {
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
}
@@ -1,5 +1,11 @@
// Map to keep track of active Crepe editor instances by elementId (container ID) // Initialize global stores on window to share state across dynamically imported module instances (preventing cache-buster isolation)
const editorCache = new Map(); if (typeof window !== 'undefined') {
if (!window.editorCache) window.editorCache = new Map();
if (!window.editorStates) window.editorStates = new Map();
}
const editorCache = typeof window !== 'undefined' ? window.editorCache : new Map();
const editorStates = typeof window !== 'undefined' ? window.editorStates : new Map();
/** /**
* Asynchronously injects a stylesheet link tag into the document head * Asynchronously injects a stylesheet link tag into the document head
@@ -23,19 +29,64 @@ async function ensureStylesheet(href) {
* Initializes a Milkdown Crepe editor on the specified element. * Initializes a Milkdown Crepe editor on the specified element.
*/ */
export async function initEditor(elementId, dotNetHelper, initialMarkdown) { export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
// Check if already destroyed or initializing
if (editorStates.get(elementId) === 'destroyed') {
console.warn(`[Milkdown] initEditor called on already destroyed element: ${elementId}. Aborting.`);
return;
}
if (editorStates.get(elementId) === 'initializing' || editorStates.get(elementId) === 'ready') {
console.warn(`[Milkdown] Editor is already initializing or ready for element: ${elementId}. Ignoring.`);
return;
}
editorStates.set(elementId, 'initializing');
// Guard 1: Destroy previous cached editor instance with the same ID if it exists
if (editorCache.has(elementId)) {
console.warn(`[Milkdown] Editor instance already exists in cache for: ${elementId}. Destroying first.`);
await destroyEditor(elementId);
}
const container = document.getElementById(elementId); const container = document.getElementById(elementId);
if (!container) { if (!container) {
console.error(`[Milkdown] Container with ID "${elementId}" not found.`); console.error(`[Milkdown] Container with ID "${elementId}" not found.`);
editorStates.delete(elementId);
return; return;
} }
// Guard 2: Clear container children to prevent double-initialization of crepe editor DOM
if (container.children.length > 0) {
console.warn(`[Milkdown] Container "${elementId}" is not empty. Clearing children before initialization.`);
container.innerHTML = '';
}
// Guard 3: Search the parent workspace card to purge any other leftover editor components
const parentCard = container.closest('.milkdown-premium-container') || container.parentElement;
if (parentCard) {
const existingEditors = parentCard.querySelectorAll('.milkdown, .crepe');
if (existingEditors.length > 0) {
console.warn(`[Milkdown] Found ${existingEditors.length} leftover editor DOM elements in the workspace card. Purging them.`);
existingEditors.forEach(el => el.remove());
}
}
try { try {
// Condition 2: Prevent FOUC by loading stylesheets before instantiating the editor // Condition 2: Prevent FOUC by loading stylesheets before instantiating the editor
await ensureStylesheet('/_content/NexusReader.UI.Shared/css/vendor/milkdown-crepe.css'); await ensureStylesheet('/_content/NexusReader.UI.Shared/css/vendor/milkdown-crepe.css');
if (editorStates.get(elementId) === 'destroyed') {
console.warn(`[Milkdown] Element ${elementId} destroyed during stylesheet loading. Aborting.`);
return;
}
// Dynamically import the local JS bundle // Dynamically import the local JS bundle
await import('/_content/NexusReader.UI.Shared/js/vendor/milkdown-crepe.js'); await import('/_content/NexusReader.UI.Shared/js/vendor/milkdown-crepe.js');
if (editorStates.get(elementId) === 'destroyed') {
console.warn(`[Milkdown] Element ${elementId} destroyed during crepe bundle loading. Aborting.`);
return;
}
// Get Crepe constructor from the global window.milkdownCrepe namespace // Get Crepe constructor from the global window.milkdownCrepe namespace
const Crepe = window.milkdownCrepe?.Crepe; const Crepe = window.milkdownCrepe?.Crepe;
if (!Crepe) { if (!Crepe) {
@@ -100,6 +151,7 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
clearTimeout(debounceTimeout); clearTimeout(debounceTimeout);
} }
debounceTimeout = setTimeout(() => { debounceTimeout = setTimeout(() => {
if (editorStates.get(elementId) === 'destroyed') return;
dotNetHelper.invokeMethodAsync('OnEditorContentChanged', markdown) dotNetHelper.invokeMethodAsync('OnEditorContentChanged', markdown)
.catch(err => console.error("[Milkdown] Failed to notify editor content changed:", err)); .catch(err => console.error("[Milkdown] Failed to notify editor content changed:", err));
}, 300); }, 300);
@@ -112,8 +164,17 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
// Create the editor view asynchronously // Create the editor view asynchronously
await crepe.create(); await crepe.create();
if (editorStates.get(elementId) === 'destroyed') {
console.warn(`[Milkdown] Element ${elementId} destroyed during crepe.create(). Cleaning up.`);
await crepe.destroy();
editorCache.delete(elementId);
return;
}
editorStates.set(elementId, 'ready');
console.log(`[Milkdown] Editor successfully initialized on element: ${elementId}`); console.log(`[Milkdown] Editor successfully initialized on element: ${elementId}`);
} catch (error) { } catch (error) {
editorStates.delete(elementId);
console.error(`[Milkdown] Failed to initialize editor on "${elementId}":`, error); console.error(`[Milkdown] Failed to initialize editor on "${elementId}":`, error);
} }
} }
@@ -134,6 +195,8 @@ export function getMarkdownContent(elementId) {
* Safely disposes of the editor instance to prevent memory leaks in WASM. * Safely disposes of the editor instance to prevent memory leaks in WASM.
*/ */
export async function destroyEditor(elementId) { export async function destroyEditor(elementId) {
editorStates.set(elementId, 'destroyed');
const crepe = editorCache.get(elementId); const crepe = editorCache.get(elementId);
if (crepe) { if (crepe) {
try { try {
@@ -144,6 +207,12 @@ export async function destroyEditor(elementId) {
} }
editorCache.delete(elementId); editorCache.delete(elementId);
} }
// Explicitly clean up container DOM children
const container = document.getElementById(elementId);
if (container) {
container.innerHTML = '';
}
} }
/** /**
@@ -163,3 +232,13 @@ export function getBackupKeys() {
} }
return keys; return keys;
} }
// Attach to window for global access (especially from DisposeAsync when module reference is null)
if (typeof window !== 'undefined') {
window.milkdownWrapper = {
initEditor,
getMarkdownContent,
destroyEditor,
getBackupKeys
};
}
+11 -12
View File
@@ -91,6 +91,10 @@ builder.Services.AddCascadingAuthenticationState();
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddHealthChecks()
.AddCheck<NexusReader.Web.Services.DatabaseHealthCheck>("Database")
.AddCheck<NexusReader.Web.Services.QdrantHealthCheck>("Qdrant")
.AddCheck<NexusReader.Web.Services.Neo4jHealthCheck>("Neo4j");
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies( builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
NexusReader.Application.DependencyInjection.Assembly, NexusReader.Application.DependencyInjection.Assembly,
@@ -295,6 +299,7 @@ if (!allowRegistration || !allowPasswordReset)
} }
app.MapStaticAssets(); app.MapStaticAssets();
app.MapHealthChecks("/health");
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub"); app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
// API endpoint for WASM client to fetch EPUB content // API endpoint for WASM client to fetch EPUB content
@@ -514,18 +519,15 @@ app.MapGet("/api/creator/books/{bookId:guid}/revisions", async (Guid bookId, Cla
var tenantId = user.FindFirstValue("TenantId") ?? "global"; var tenantId = user.FindFirstValue("TenantId") ?? "global";
try
{
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetBookRevisionsQuery(bookId, userId, tenantId)); var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetBookRevisionsQuery(bookId, userId, tenantId));
if (result.IsSuccess) return Results.Ok(result.Value); if (result.IsSuccess) return Results.Ok(result.Value);
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"; var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
return Results.BadRequest(errorMsg); if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
}
catch (NexusReader.Domain.Exceptions.BookNotFoundException)
{ {
return Results.NotFound($"Book with ID '{bookId}' was not found."); return Results.NotFound(errorMsg);
} }
return Results.BadRequest(errorMsg);
}).RequireAuthorization(); }).RequireAuthorization();
app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [FromQuery] string version, ClaimsPrincipal user, IMediator mediator) => app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [FromQuery] string version, ClaimsPrincipal user, IMediator mediator) =>
@@ -540,18 +542,15 @@ app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [Fro
return Results.BadRequest("Version string is required."); return Results.BadRequest("Version string is required.");
} }
try
{
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand(bookId, version, userId, tenantId)); var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand(bookId, version, userId, tenantId));
if (result.IsSuccess) return Results.Ok(); if (result.IsSuccess) return Results.Ok();
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"; var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
return Results.BadRequest(errorMsg); if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
}
catch (NexusReader.Domain.Exceptions.BookNotFoundException)
{ {
return Results.NotFound($"Book with ID '{bookId}' was not found."); return Results.NotFound(errorMsg);
} }
return Results.BadRequest(errorMsg);
}).RequireAuthorization(); }).RequireAuthorization();
app.MapPost("/api/creator/books", async ( app.MapPost("/api/creator/books", async (
@@ -0,0 +1,35 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using NexusReader.Data.Persistence;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace NexusReader.Web.Services;
public class DatabaseHealthCheck : IHealthCheck
{
private readonly AppDbContext _dbContext;
public DatabaseHealthCheck(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
var canConnect = await _dbContext.Database.CanConnectAsync(cancellationToken);
if (canConnect)
{
return HealthCheckResult.Healthy("Database is accessible.");
}
return HealthCheckResult.Unhealthy("Cannot connect to the database.");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Database health check failed with exception.", ex);
}
}
}
@@ -0,0 +1,31 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Neo4j.Driver;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace NexusReader.Web.Services;
public class Neo4jHealthCheck : IHealthCheck
{
private readonly IDriver _driver;
public Neo4jHealthCheck(IDriver driver)
{
_driver = driver;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
await _driver.VerifyConnectivityAsync();
return HealthCheckResult.Healthy("Neo4j database is accessible.");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Neo4j database connectivity check failed.", ex);
}
}
}
@@ -0,0 +1,32 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Qdrant.Client;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace NexusReader.Web.Services;
public class QdrantHealthCheck : IHealthCheck
{
private readonly QdrantClient _qdrantClient;
public QdrantHealthCheck(QdrantClient qdrantClient)
{
_qdrantClient = qdrantClient;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
// Simple check: query collection existence to verify connection is alive
_ = await _qdrantClient.CollectionExistsAsync("knowledge_units", cancellationToken);
return HealthCheckResult.Healthy("Qdrant database is accessible.");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Qdrant database health check failed.", ex);
}
}
}
@@ -169,7 +169,7 @@ public class PublishBookVersionTests : IDisposable
} }
[Fact] [Fact]
public async Task Handle_WithMismatchedTenantId_ThrowsBookNotFoundException() public async Task Handle_WithMismatchedTenantId_ReturnsFailure()
{ {
// Arrange // Arrange
var bookId = Guid.NewGuid(); var bookId = Guid.NewGuid();
@@ -210,13 +210,16 @@ public class PublishBookVersionTests : IDisposable
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object); var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act & Assert // Act
var action = () => handler.Handle(command, CancellationToken.None); var result = await handler.Handle(command, CancellationToken.None);
await action.Should().ThrowAsync<BookNotFoundException>();
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
} }
[Fact] [Fact]
public async Task Handle_WithMismatchedUserId_ThrowsBookNotFoundException() public async Task Handle_WithMismatchedUserId_ReturnsFailure()
{ {
// Arrange // Arrange
var bookId = Guid.NewGuid(); var bookId = Guid.NewGuid();
@@ -257,13 +260,16 @@ public class PublishBookVersionTests : IDisposable
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object); var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act & Assert // Act
var action = () => handler.Handle(command, CancellationToken.None); var result = await handler.Handle(command, CancellationToken.None);
await action.Should().ThrowAsync<BookNotFoundException>();
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
} }
[Fact] [Fact]
public async Task Handle_WithNonExistentBook_ThrowsBookNotFoundException() public async Task Handle_WithNonExistentBook_ReturnsFailure()
{ {
// Arrange // Arrange
var command = new PublishBookVersionCommand( var command = new PublishBookVersionCommand(
@@ -275,9 +281,12 @@ public class PublishBookVersionTests : IDisposable
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object); var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act & Assert // Act
var action = () => handler.Handle(command, CancellationToken.None); var result = await handler.Handle(command, CancellationToken.None);
await action.Should().ThrowAsync<BookNotFoundException>();
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
} }
public void Dispose() public void Dispose()
@@ -234,7 +234,7 @@ public class CreatorDashboardTests : IDisposable
} }
[Fact] [Fact]
public async Task GetBookRevisions_WithMismatchedUserOrTenant_ThrowsBookNotFoundException() public async Task GetBookRevisions_WithMismatchedUserOrTenant_ReturnsFailure()
{ {
// Arrange // Arrange
var userId = "creator-123"; var userId = "creator-123";
@@ -262,12 +262,14 @@ public class CreatorDashboardTests : IDisposable
// Act & Assert // Act & Assert
var queryMismatchedTenant = new GetBookRevisionsQuery(bookId, userId, "different-tenant"); var queryMismatchedTenant = new GetBookRevisionsQuery(bookId, userId, "different-tenant");
var actionTenant = () => handler.Handle(queryMismatchedTenant, CancellationToken.None); var resultTenant = await handler.Handle(queryMismatchedTenant, CancellationToken.None);
await actionTenant.Should().ThrowAsync<BookNotFoundException>(); resultTenant.IsSuccess.Should().BeFalse();
resultTenant.Errors.Should().Contain(e => e.Message.Contains("was not found"));
var queryMismatchedUser = new GetBookRevisionsQuery(bookId, "different-user", tenantId); var queryMismatchedUser = new GetBookRevisionsQuery(bookId, "different-user", tenantId);
var actionUser = () => handler.Handle(queryMismatchedUser, CancellationToken.None); var resultUser = await handler.Handle(queryMismatchedUser, CancellationToken.None);
await actionUser.Should().ThrowAsync<BookNotFoundException>(); resultUser.IsSuccess.Should().BeFalse();
resultUser.Errors.Should().Contain(e => e.Message.Contains("was not found"));
} }
public void Dispose() public void Dispose()