Files
Nexus.Reader/src/NexusReader.UI.Shared/Pages/CreatorDashboard.razor
T
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

543 lines
22 KiB
Plaintext

@page "/creator"
@attribute [Authorize]
@using System.Net.Http.Json
@using Microsoft.Extensions.Logging
@using System.ComponentModel.DataAnnotations
@using NexusReader.Application.DTOs.Creator
@inject HttpClient Http
@inject NavigationManager NavigationManager
@inject ILogger<CreatorDashboard> Logger
<PageTitle>Creator Dashboard | Nexus Reader</PageTitle>
<div class="dashboard-container">
<header class="dashboard-header">
<div class="header-visual">
<h1 class="dashboard-title">Panel Autora</h1>
<p class="subtitle">Monitoruj zaangażowanie czytelników i publikuj wersje zamrożone z poziomu kontroli wersji.</p>
</div>
</header>
<main class="dashboard-content">
<!-- Metrics Section -->
<section class="metrics-grid">
@if (_isLoading)
{
@for (int i = 0; i < 4; i++)
{
<div class="metric-card skeleton-card">
<div class="skeleton-line label"></div>
<div class="skeleton-line value"></div>
</div>
}
}
else if (_dashboardData != null)
{
<div class="metric-card glass-panel">
<span class="metric-label">Całkowite Odczyty</span>
<h2 class="metric-value">@_dashboardData.Metrics.TotalReads</h2>
<div class="metric-trend positive">
<span class="trend-icon">↑</span>
<span class="trend-text">System stabilny</span>
</div>
</div>
<div class="metric-card glass-panel">
<span class="metric-label">Średni Czas Czytania</span>
<h2 class="metric-value">@_dashboardData.Metrics.AvgReadTimeMinutes min</h2>
<div class="metric-trend neutral">
<span class="trend-icon">→</span>
<span class="trend-text">Na rozdział</span>
</div>
</div>
<div class="metric-card glass-panel">
<div class="metric-label-container">
<span class="metric-label">Aktywni Czytelnicy</span>
<div class="pulse-indicator">
<span class="pulse-dot"></span>
<span class="pulse-text">Live Now</span>
</div>
</div>
<h2 class="metric-value">@_dashboardData.Metrics.ActiveReaders</h2>
<div class="metric-trend positive">
<span class="trend-icon">↑</span>
<span class="trend-text">Ruch w czasie rzeczywistym</span>
</div>
</div>
<div class="metric-card glass-panel">
<span class="metric-label">Przychód Gross</span>
<h2 class="metric-value">@_dashboardData.Metrics.GrossRevenue.ToString("C2")</h2>
<div class="metric-trend positive">
<span class="trend-icon">↑</span>
<span class="trend-text">Rozliczenia w toku</span>
</div>
</div>
}
</section>
<!-- Publication Cards Grid Section -->
<section class="publications-section">
<div class="section-header">
<h2>Twoje Publikacje</h2>
<button type="button" class="btn-nexus primary glow-btn" @onclick="OpenCreateBookModal">
[ + Nowa Publikacja ]
</button>
</div>
@if (_isLoading)
{
<div class="books-grid">
@for (int i = 0; i < 3; i++)
{
<div class="book-card skeleton-card">
<div class="skeleton-card-header"></div>
<div class="skeleton-line title"></div>
<div class="skeleton-line metadata"></div>
<div class="skeleton-card-actions"></div>
</div>
}
</div>
}
else if (_dashboardData == null || !_dashboardData.Books.Any())
{
<div class="empty-state glass-panel">
<div class="empty-icon">
<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="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
</div>
<h3>Brak publikacji</h3>
<p>Nie utworzyłeś jeszcze żadnych książek do autorskiej edycji.</p>
<button type="button" class="btn-nexus primary glow-btn" style="margin-top: 1.5rem;" @onclick="OpenCreateBookModal">
[ + Nowa Publikacja ]
</button>
</div>
}
else
{
<div class="books-grid">
@foreach (var book in _dashboardData.Books)
{
<div class="book-card glass-panel">
<div class="card-glow"></div>
<div class="book-card-header">
<h3 class="book-title" title="@book.Title">@book.Title</h3>
<div class="badges-row">
@if (book.LivePublishedRevision != null)
{
<span class="badge badge-published" title="Opublikowana wersja dostępna dla czytelników">
Live @book.LivePublishedRevision.VersionString
</span>
}
@if (book.CurrentDraftRevision != null)
{
<span class="badge badge-draft pulsing" title="Szkic roboczy z nieopublikowanymi zmianami">
Szkic
</span>
}
</div>
</div>
<div class="book-telemetry">
<div class="telemetry-item">
<span class="telemetry-label">Słowa:</span>
<span class="telemetry-value">@book.WordCount.ToString("N0")</span>
</div>
<div class="telemetry-item">
<span class="telemetry-label">Wyświetlenia:</span>
<span class="telemetry-value">@book.AggregatedReads.ToString("N0")</span>
</div>
</div>
<div class="book-card-actions">
<button type="button" class="btn-nexus secondary" @onclick="() => NavigateToEditor(book)">
Edytuj szkic
</button>
<button type="button" class="btn-nexus primary glow-btn" @onclick="() => OpenPublishModal(book)">
Publikuj
</button>
<button type="button" class="btn-nexus link-btn" @onclick="() => OpenRevisionsModalAsync(book)">
Rejestr zmian
</button>
</div>
</div>
}
</div>
}
</section>
</main>
</div>
<!-- Defensively-Scoped Version Publish Modal -->
@if (_isPublishModalOpen && _activePublishBookId.HasValue)
{
<div class="modal-backdrop" @onclick="ClosePublishModal">
<div class="modal-content glass-panel" @onclick:stopPropagation>
<div class="modal-header">
<h3>Publikowanie Nowej Wersji</h3>
<button class="close-btn" @onclick="ClosePublishModal">&times;</button>
</div>
<div class="modal-body">
<p>Zamrażasz obecny szkic książki <strong>@_activePublishBookTitle</strong> jako nową wersję publiczną.</p>
<div class="form-group">
<label for="versionInput">Sygnatura Wersji (np. v1.0.0)</label>
<input id="versionInput" type="text" class="form-control" @bind="_customVersionString" @bind:event="oninput" placeholder="Wpisz tag wersji..." />
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="error-banner">@_errorMessage</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn-nexus secondary" @onclick="ClosePublishModal">Anuluj</button>
<button type="button" class="btn-nexus primary glow-btn" @onclick="SubmitPublishVersionAsync" disabled="@_isSubmitting">
@(_isSubmitting ? "Wysyłanie..." : "Zatwierdź wersję")
</button>
</div>
</div>
</div>
}
<!-- Manage Revisions Modal -->
@if (_isRevisionsModalOpen && _activeRevisionsBookId.HasValue)
{
<div class="modal-backdrop" @onclick="CloseRevisionsModal">
<div class="modal-content glass-panel" @onclick:stopPropagation>
<div class="modal-header">
<h3>Rejestr Rewizji: @_activeRevisionsBookTitle</h3>
<button class="close-btn" @onclick="CloseRevisionsModal">&times;</button>
</div>
<div class="modal-body">
@if (_revisionsLoading)
{
<div class="spinner-container">
<div class="spinner-glow small"></div>
<span>Wczytywanie historii...</span>
</div>
}
else if (_revisionsList == null || !_revisionsList.Any())
{
<p class="empty-revisions">Brak zarejestrowanych rewizji.</p>
}
else
{
<div class="revisions-list">
@foreach (var revision in _revisionsList)
{
<div class="revision-item">
<div class="revision-header">
<span class="revision-tag @(revision.IsPublished ? "published" : "draft")">
@(revision.IsPublished ? revision.VersionString : "Szkic roboczy")
</span>
<span class="revision-date">
@(revision.PublishedAt.HasValue ? revision.PublishedAt.Value.ToString("g") : revision.CreatedAt.ToString("g"))
</span>
</div>
<div class="revision-meta">
<span>Utworzono: @revision.CreatedAt.ToString("yyyy-MM-dd HH:mm")</span>
</div>
</div>
}
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn-nexus secondary" @onclick="CloseRevisionsModal">Zamknij</button>
</div>
</div>
</div>
}
<!-- Create Book Modal -->
@if (_isCreateBookModalOpen)
{
<div class="modal-backdrop" @onclick="CloseCreateBookModal">
<div class="modal-content glass-panel" @onclick:stopPropagation>
<div class="modal-header">
<h2>Nowa Publikacja</h2>
<button class="close-btn" @onclick="CloseCreateBookModal">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<EditForm Model="_createBookModel" OnValidSubmit="SubmitCreateBookAsync">
<DataAnnotationsValidator />
<div class="modal-body">
<div class="creator-layout">
<!-- Book Cover Preview -->
<div class="cover-preview creator-cover">
<div class="cover-mockup-design">
<div class="cover-accent-line"></div>
<div class="cover-main-content">
<span class="cover-title-text">
@(string.IsNullOrWhiteSpace(_createBookModel.Title) ? "Tytuł Książki" : _createBookModel.Title)
</span>
<span class="cover-author-text">
Szkic roboczy
</span>
</div>
<div class="cover-logo-container">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
<span class="cover-brand">Nexus</span>
</div>
</div>
</div>
<!-- Form inputs -->
<div class="creator-form-inputs">
<div class="form-group">
<label for="newBookTitle">Tytuł Książki</label>
<InputText id="newBookTitle" class="form-input" @bind-Value="_createBookModel.Title" placeholder="Wpisz tytuł książki..." />
<ValidationMessage For="@(() => _createBookModel.Title)" class="validation-message" />
</div>
<div class="form-group">
<label for="newBookDescription">Opis (opcjonalny)</label>
<InputTextArea id="newBookDescription" class="form-input" @bind-Value="_createBookModel.Description" rows="3" placeholder="Wpisz krótki opis książki..." />
<ValidationMessage For="@(() => _createBookModel.Description)" class="validation-message" />
</div>
@if (!string.IsNullOrEmpty(_createBookError))
{
<div class="error-banner">@_createBookError</div>
}
</div>
</div>
</div>
<div class="actions">
<button type="button" class="btn-nexus secondary" @onclick="CloseCreateBookModal">Anuluj</button>
<button type="submit" class="btn-nexus primary glow-btn" disabled="@_isCreatingBook">
@(_isCreatingBook ? "Tworzenie..." : "Utwórz książkę")
</button>
</div>
</EditForm>
</div>
</div>
}
@code {
private bool _isLoading = true;
private CreatorDashboardDataDto? _dashboardData;
// Create Book Model and state
private bool _isCreateBookModalOpen;
private CreateBookModel _createBookModel = new();
private bool _isCreatingBook;
private string? _createBookError;
// Defensively-scoped state variables for modal isolation
private bool _isPublishModalOpen;
private Guid? _activePublishBookId;
private string _activePublishBookTitle = string.Empty;
private string _customVersionString = string.Empty;
private bool _isSubmitting;
private string? _errorMessage;
// Revisions modal state variables
private bool _isRevisionsModalOpen;
private Guid? _activeRevisionsBookId;
private string _activeRevisionsBookTitle = string.Empty;
private bool _revisionsLoading;
private List<CreatorBookRevisionDto> _revisionsList = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await LoadDashboardDataAsync();
}
}
private async Task LoadDashboardDataAsync()
{
_isLoading = true;
StateHasChanged();
try
{
_dashboardData = await Http.GetFromJsonAsync<CreatorDashboardDataDto>("api/creator/dashboard");
}
catch (Exception ex)
{
Logger.LogError(ex, "Error loading creator dashboard data.");
}
finally
{
_isLoading = false;
StateHasChanged();
}
}
private void NavigateToEditor(CreatorBookDto book)
{
if (book.FirstChapterId.HasValue)
{
NavigationManager.NavigateTo($"/creator/edit/{book.Id}/{book.FirstChapterId.Value}");
}
else
{
NavigationManager.NavigateTo($"/creator/edit/{book.Id}");
}
}
private void OpenPublishModal(CreatorBookDto book)
{
// Explicitly lock context boundaries to the selected book
_activePublishBookId = book.Id;
_activePublishBookTitle = book.Title;
_customVersionString = "v1.0.0";
_errorMessage = null;
_isPublishModalOpen = true;
}
private void ClosePublishModal()
{
_isPublishModalOpen = false;
_activePublishBookId = null;
_activePublishBookTitle = string.Empty;
_customVersionString = string.Empty;
_errorMessage = null;
}
private async Task SubmitPublishVersionAsync()
{
if (!_activePublishBookId.HasValue || string.IsNullOrWhiteSpace(_customVersionString))
{
return;
}
_isSubmitting = true;
_errorMessage = null;
StateHasChanged();
try
{
// Explicitly lock the parameters during sending execution
var bookId = _activePublishBookId.Value;
var response = await Http.PostAsync($"api/creator/books/{bookId}/publish?version={Uri.EscapeDataString(_customVersionString)}", null);
if (response.IsSuccessStatusCode)
{
ClosePublishModal();
await LoadDashboardDataAsync();
}
else
{
_errorMessage = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(_errorMessage))
{
_errorMessage = "Publish version endpoint returned an error.";
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Exception thrown during publication.");
_errorMessage = $"Mutation failed: {ex.Message}";
}
finally
{
_isSubmitting = false;
StateHasChanged();
}
}
private async Task OpenRevisionsModalAsync(CreatorBookDto book)
{
_activeRevisionsBookId = book.Id;
_activeRevisionsBookTitle = book.Title;
_revisionsList = new();
_revisionsLoading = true;
_isRevisionsModalOpen = true;
StateHasChanged();
try
{
_revisionsList = await Http.GetFromJsonAsync<List<CreatorBookRevisionDto>>($"api/creator/books/{book.Id}/revisions") ?? new();
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to load revisions.");
}
finally
{
_revisionsLoading = false;
StateHasChanged();
}
}
private void CloseRevisionsModal()
{
_isRevisionsModalOpen = false;
_activeRevisionsBookId = null;
_activeRevisionsBookTitle = string.Empty;
_revisionsList.Clear();
}
private void OpenCreateBookModal()
{
_createBookModel = new CreateBookModel();
_createBookError = null;
_isCreateBookModalOpen = true;
}
private void CloseCreateBookModal()
{
_isCreateBookModalOpen = false;
_createBookError = null;
}
private async Task SubmitCreateBookAsync()
{
_isCreatingBook = true;
_createBookError = null;
StateHasChanged();
try
{
var response = await Http.PostAsJsonAsync("api/creator/books", _createBookModel);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<CreateBookResponseDto>();
if (result != null)
{
// Reset modal state BEFORE routing to prevent it lingering in the DOM tree
_isCreateBookModalOpen = false;
_createBookError = null;
StateHasChanged();
NavigationManager.NavigateTo($"/creator/edit/{result.BookId}");
}
else
{
_createBookError = "Otrzymano nieprawidłową odpowiedź z serwera.";
}
}
else
{
var errorMsg = await response.Content.ReadAsStringAsync();
_createBookError = !string.IsNullOrWhiteSpace(errorMsg) ? errorMsg : "Wystąpił błąd podczas tworzenia książki.";
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Błąd podczas tworzenia książki.");
_createBookError = $"Krytyczny błąd: {ex.Message}";
}
finally
{
_isCreatingBook = false;
StateHasChanged();
}
}
public class CreateBookModel
{
[Required(ErrorMessage = "Tytuł książki jest wymagany.")]
[StringLength(255, ErrorMessage = "Tytuł książki nie może przekraczać 255 znaków.")]
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
}
}