feat(creator): Refactor Creator flow, implement book creation pipeline & versioning, and setup Docker staging
- Relocate dashboard routing to /creator and editor workspace to /creator/edit/{BookId}
- Implement CreateBookCommand and handler with transactional default chapter seeding
- Implement PublishBookVersionCommand and GetCreatorDashboardDataQuery
- Build CreatorDashboard modal and UI components with customized dark input styles
- Add run-stage.sh script to automate staging environment setup, database migrations, and health checks
- Update developer workflow rules in GEMINI.md
This commit is contained in:
@@ -0,0 +1,542 @@
|
||||
@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">×</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">×</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}?chapterId={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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user