feat(creator): overhaul Creator flow, editor duplication, and staging setup #83

Merged
mjasin merged 15 commits from feature/stage3-book-versioning into develop 2026-06-15 17:15:43 +00:00
51 changed files with 5868 additions and 493 deletions
+1
View File
@@ -5,6 +5,7 @@
<ItemGroup>
<PackageVersion Include="FluentResults" Version="4.0.0" />
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
<PackageVersion Include="Markdig" Version="0.38.0" />
<PackageVersion Include="Mapster" Version="10.0.7" />
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
<PackageVersion Include="MediatR" Version="12.1.1" />
+1
View File
@@ -26,6 +26,7 @@ RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseApp
# Stage 2: Runtime
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
COPY --from=build /app/publish .
+6 -1
View File
@@ -46,4 +46,9 @@ version: 1.0
> [!IMPORTANT]
> **Git Workflow & Integration**
> All tasks originating from the repository must be performed on a separate branch. To connect to the Git repository, use the `gitea` MCP server.
> All tasks originating from the repository must be performed on a separate branch. Every new chat must be launched from the `develop` branch. To connect to the Git repository, use the `gitea` MCP server.
> [!IMPORTANT]
> **Docker Lifecycle Management**
> 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
- 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
- Qdrant__ApiKey=${QDRANT_API_KEY:-}
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
Executable
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# -------------------------------------------------------------
# Staging Deploy & Orchestration Helper for NexusReader
# -------------------------------------------------------------
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"
TEMPLATE_FILE=".env.stage.template"
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..."
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
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$TEMPLATE_FILE" ]; then
echo "📄 Creating $ENV_FILE from $TEMPLATE_FILE..."
cp "$TEMPLATE_FILE" "$ENV_FILE"
else
echo "❌ Error: Template file $TEMPLATE_FILE not found."
exit 1
fi
fi
# 2. Check and generate secure random passwords for placeholders
if grep -q "CHANGE_ME_TO_STRONG_PASSWORD" "$ENV_FILE"; then
echo "🔐 Generating secure random passwords in $ENV_FILE..."
PG_PASS=$(openssl rand -hex 16)
NEO_PASS=$(openssl rand -hex 16)
# Use standard sed compatible with Linux
sed -i "s/POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD/POSTGRES_PASSWORD=$PG_PASS/g" "$ENV_FILE"
sed -i "s/NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD/NEO4J_PASSWORD=$NEO_PASS/g" "$ENV_FILE"
fi
if grep -q "CHANGE_ME_TO_SECURE_ADMIN_PASSWORD" "$ENV_FILE"; then
echo "🔐 Generating secure admin seed password in $ENV_FILE..."
ADMIN_PASS=$(openssl rand -hex 16)
sed -i "s/NEXUS_ADMIN_PASSWORD=CHANGE_ME_TO_SECURE_ADMIN_PASSWORD/NEXUS_ADMIN_PASSWORD=$ADMIN_PASS/g" "$ENV_FILE"
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)
# Clean up carriage returns just in case
POSTGRES_USER=$(grep "^POSTGRES_USER=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
POSTGRES_PASSWORD=$(grep "^POSTGRES_PASSWORD=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
POSTGRES_DB=$(grep "^POSTGRES_DB=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
POSTGRES_PORT=$(grep "^POSTGRES_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
WEB_PORT=$(grep "^WEB_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
QDRANT_HTTP_PORT=$(grep "^QDRANT_HTTP_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
NEO4J_HTTP_PORT=$(grep "^NEO4J_HTTP_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
# Fallbacks in case env parsing is empty
POSTGRES_PORT=${POSTGRES_PORT:-5438}
WEB_PORT=${WEB_PORT:-5080}
# 3. Stop any conflicting Docker Compose environments
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 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
if [ "$NEXUS_ONLY" = true ]; then
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
echo "⏳ Waiting for database (nexus-db-stage) to become healthy..."
MAX_ATTEMPTS=30
attempt=0
until [ "$(docker inspect --format='{{json .State.Health.Status}}' nexus-db-stage 2>/dev/null)" == "\"healthy\"" ]; do
sleep 2
attempt=$((attempt + 1))
if [ $attempt -ge $MAX_ATTEMPTS ]; then
echo "❌ Timeout: Database container never became healthy."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" logs db
exit 1
fi
done
echo "✅ Database is healthy!"
# 6. Apply Entity Framework migrations
echo "🔄 Applying EF Core migrations to staging database on port $POSTGRES_PORT..."
export ConnectionStrings__PostgresConnection="Host=127.0.0.1;Port=$POSTGRES_PORT;Database=$POSTGRES_DB;Username=$POSTGRES_USER;Password=$POSTGRES_PASSWORD"
dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web --no-build
# 7. Wait for Web Application to respond
echo "⏳ Waiting for Web Application to start on http://localhost:$WEB_PORT/health..."
MAX_WEB_ATTEMPTS=30
web_attempt=0
until curl -s -f "http://localhost:$WEB_PORT/health" >/dev/null; do
sleep 2
web_attempt=$((web_attempt + 1))
if [ $web_attempt -ge $MAX_WEB_ATTEMPTS ]; then
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
break
fi
done
echo "🎉 Staging environment is ready!"
echo "--------------------------------------------------------"
echo "🌐 Web Application: http://localhost:$WEB_PORT"
echo "🗄️ PostgreSQL Port: $POSTGRES_PORT"
echo "🔎 Neo4j Console: http://localhost:$NEO4J_HTTP_PORT"
echo "📊 Qdrant Service: http://localhost:$QDRANT_HTTP_PORT"
echo "--------------------------------------------------------"
@@ -21,6 +21,18 @@ namespace NexusReader.Application.Common;
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterRequest))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterResponse))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.UploadResultDto))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.LocalBackupEnvelope))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.AutosaveChapterRequest))]
[JsonSerializable(typeof(NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand))]
[JsonSerializable(typeof(NexusReader.Application.Features.Books.Commands.CreateBookCommand))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreateBookRequestDto))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreateBookResponseDto))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorDashboardDataDto))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.DashboardMetricsDto))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorBookDto))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorBookRevisionDto))]
[JsonSerializable(typeof(List<NexusReader.Application.DTOs.Creator.CreatorBookDto>))]
[JsonSerializable(typeof(List<NexusReader.Application.DTOs.Creator.CreatorBookRevisionDto>))]
public partial class AppJsonContext : JsonSerializerContext
{
}
@@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
namespace NexusReader.Application.DTOs.Creator;
/// <summary>
/// Telemetry metrics for the Creator Dashboard.
/// </summary>
public record DashboardMetricsDto(
int TotalReads,
double AvgReadTimeMinutes,
int ActiveReaders,
decimal GrossRevenue
);
/// <summary>
/// Lightweight revision details for the Creator Dashboard.
/// </summary>
public record CreatorBookRevisionDto(
Guid Id,
string VersionString,
bool IsPublished,
DateTime CreatedAt,
DateTime? PublishedAt
);
/// <summary>
/// Lightweight book publication details for the Creator Dashboard.
/// </summary>
public record CreatorBookDto(
Guid Id,
string Title,
int WordCount,
int AggregatedReads,
Guid? FirstChapterId,
CreatorBookRevisionDto? LivePublishedRevision,
CreatorBookRevisionDto? CurrentDraftRevision
);
/// <summary>
/// Root data envelope for Creator Dashboard loading.
/// </summary>
public record CreatorDashboardDataDto(
DashboardMetricsDto Metrics,
List<CreatorBookDto> Books
);
/// <summary>
/// Request DTO for creating a new Book.
/// </summary>
public record CreateBookRequestDto(
string Title,
string? Description
);
/// <summary>
/// Response DTO for creating a new Book.
/// </summary>
public record CreateBookResponseDto(
Guid BookId
);
@@ -16,3 +16,18 @@ public record ValidateChapterResponse(string SanitizedContent);
/// Response DTO containing the uploaded media file URL.
/// </summary>
public record UploadResultDto(string Url);
/// <summary>
/// Represents a structured JSON backup envelope stored in LocalStorage.
/// </summary>
public class LocalBackupEnvelope
{
public Guid ChapterId { get; set; }
public DateTime Timestamp { get; set; }
public string MarkdownContent { get; set; } = string.Empty;
}
/// <summary>
/// Request DTO for chapter autosaving.
/// </summary>
public record AutosaveChapterRequest(string MarkdownContent);
@@ -0,0 +1,18 @@
using System;
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Features.Books.Commands;
/// <summary>
/// Command to create a new Book, initialize its first Working Draft revision, and seed it with a default Introduction chapter.
/// </summary>
/// <param name="Title">The title of the new book.</param>
/// <param name="Description">An optional description of the book.</param>
/// <param name="UserId">The ID of the creator user.</param>
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
public record CreateBookCommand(
string Title,
string? Description,
string UserId,
string TenantId
) : ICommand<Guid>;
@@ -0,0 +1,103 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FluentResults;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Features.Books.Commands;
/// <summary>
/// MediatR handler for creating a Book, creating its initial Working Draft revision,
/// and seeding a default first chapter ("Introduction") in an atomic database transaction.
/// </summary>
public class CreateBookCommandHandler : ICommandHandler<CreateBookCommand, Guid>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public CreateBookCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<Result<Guid>> Handle(CreateBookCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Title))
{
return Result.Fail<Guid>(new Error("Book title is required."));
}
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
// 1. Instantiate the Book record mapping Title, UserId, and TenantId
var book = new Book
{
Id = Guid.NewGuid(),
Title = request.Title.Trim(),
UserId = request.UserId,
TenantId = request.TenantId,
CurrentDraftRevisionId = null,
LivePublishedRevisionId = null
};
dbContext.Books.Add(book);
// 2. Instantiate the initial BookRevision designated as "Working Draft"
var draftRevision = new BookRevision
{
Id = Guid.NewGuid(),
BookId = book.Id,
VersionString = "Working Draft",
IsPublished = false,
CreatedAt = DateTime.UtcNow
};
dbContext.BookRevisions.Add(draftRevision);
// 3. Automatically instantiate and append a default first Chapter to this new revision
var introChapter = new Chapter
{
Id = Guid.NewGuid(),
BookRevisionId = draftRevision.Id,
Title = "Introduction",
MarkdownContent = "# Introduction\nStart writing here...",
SortOrder = 1
};
dbContext.Chapters.Add(introChapter);
// Save first to generate DB references
await dbContext.SaveChangesAsync(cancellationToken);
// 4. Inject the newly instantiated draft revision ID back into Book.CurrentDraftRevisionId
book.CurrentDraftRevisionId = draftRevision.Id;
// Save the updated Book link
await dbContext.SaveChangesAsync(cancellationToken);
// Commit transaction
await transaction.CommitAsync(cancellationToken);
return Result.Ok(book.Id);
}
catch (Exception ex)
{
try
{
await transaction.RollbackAsync(cancellationToken);
}
catch (Exception rollbackEx)
{
Console.WriteLine($"[CreateBook] Transaction rollback failed: {rollbackEx.Message}");
}
return Result.Fail<Guid>(new Error($"Failed to create book: {ex.Message}").CausedBy(ex));
}
}
}
@@ -0,0 +1,12 @@
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Features.Books.Commands;
/// <summary>
/// Command to publish a new frozen version of a Book, and create a new Working Draft.
/// </summary>
/// <param name="BookId">The unique identifier of the Book to publish.</param>
/// <param name="CustomVersionString">The custom version string to apply (e.g. "v1.0").</param>
/// <param name="UserId">The ID of the user requesting the action.</param>
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
public record PublishBookVersionCommand(Guid BookId, string CustomVersionString, string UserId, string TenantId) : ICommand;
@@ -0,0 +1,112 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FluentResults;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
using NexusReader.Domain.Exceptions;
namespace NexusReader.Application.Features.Books.Commands;
/// <summary>
/// MediatR handler for publishing a Book version and setting up the next Working Draft.
/// </summary>
public class PublishBookVersionCommandHandler : ICommandHandler<PublishBookVersionCommand>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public PublishBookVersionCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<Result> Handle(PublishBookVersionCommand request, CancellationToken cancellationToken)
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
// Fetch the Book including its CurrentDraftRevision and all associated Chapters,
// enforcing that the book belongs to the requested TenantId and UserId to prevent cross-tenant data leaks.
var book = await dbContext.Books
.Include(b => b.CurrentDraftRevision)
.ThenInclude(r => r!.Chapters)
.FirstOrDefaultAsync(
b => b.Id == request.BookId && b.UserId == request.UserId && b.TenantId == request.TenantId,
cancellationToken);
if (book == null)
{
return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found."));
mjasin marked this conversation as resolved Outdated
Outdated
Review

🟡 Design/Architecture: Violation of Result Pattern

Throwing BookNotFoundException inside the handler violates the project's strict architecture rule: Result Pattern: Zero exceptions for flow control. All handlers return Result<T> via FluentResult.

Suggested Fix:
Instead of throwing, return Result.Fail(...) containing an error message or a dedicated error class:

if (book == null)
{
    return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found."));
}

Remember to update the MapPost endpoint in Program.cs and the tests as well.

🟡 Design/Architecture: Violation of Result Pattern Throwing `BookNotFoundException` inside the handler violates the project's strict architecture rule: `Result Pattern: Zero exceptions for flow control. All handlers return Result<T> via FluentResult.` **Suggested Fix:** Instead of throwing, return `Result.Fail(...)` containing an error message or a dedicated error class: ```csharp if (book == null) { return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found.")); } ``` Remember to update the MapPost endpoint in `Program.cs` and the tests as well.
}
var oldDraftRevision = book.CurrentDraftRevision;
if (oldDraftRevision == null)
{
return Result.Fail(new Error("The book does not have an active draft revision to publish."));
}
// Start ACID transaction
using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
// 1. Update the current draft revision: Set IsPublished = true, PublishedAt = now, VersionString = custom
oldDraftRevision.IsPublished = true;
oldDraftRevision.PublishedAt = DateTime.UtcNow;
oldDraftRevision.VersionString = request.CustomVersionString;
// 2. Point the Book.LivePublishedRevisionId to this newly frozen revision ID
book.LivePublishedRevisionId = oldDraftRevision.Id;
// 3. Execute Deep Snapshot: Instantiate a brand new BookRevision representing the next "Working Draft"
var newDraftRevision = new BookRevision
{
Id = Guid.NewGuid(),
BookId = book.Id,
VersionString = "Working Draft",
IsPublished = false,
CreatedAt = DateTime.UtcNow
};
dbContext.BookRevisions.Add(newDraftRevision);
// Replicate/clone chapters into new Chapter objects associated with the new draft revision.
// Reset identities by explicitly instantiating completely new Chapter objects with Guid.NewGuid().
foreach (var oldChapter in oldDraftRevision.Chapters)
{
var newChapter = new Chapter
{
Id = Guid.NewGuid(),
BookRevisionId = newDraftRevision.Id,
Title = oldChapter.Title,
MarkdownContent = oldChapter.MarkdownContent,
SortOrder = oldChapter.SortOrder
};
dbContext.Chapters.Add(newChapter);
}
// 4. Assign the new draft revision ID to Book.CurrentDraftRevisionId
book.CurrentDraftRevisionId = newDraftRevision.Id;
// Save changes and commit transaction
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Result.Ok();
}
catch (Exception ex)
{
try
{
await transaction.RollbackAsync(cancellationToken);
}
catch (Exception rollbackEx)
{
Console.WriteLine($"[PublishBookVersion] Transaction rollback failed: {rollbackEx.Message}");
}
return Result.Fail(new Error($"Failed to publish book version: {ex.Message}").CausedBy(ex));
}
}
}
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.DTOs.Creator;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Exceptions;
namespace NexusReader.Application.Queries.Creator;
/// <summary>
/// Query to load all revisions for a specific Book, checking multi-tenant ownership boundaries.
/// </summary>
/// <param name="BookId">The unique identifier of the target Book.</param>
/// <param name="UserId">The ID of the creator requesting revision data.</param>
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
public record GetBookRevisionsQuery(Guid BookId, string UserId, string TenantId) : IQuery<List<CreatorBookRevisionDto>>;
/// <summary>
/// Handler that lists past revisions of a Book, verifying ownership to prevent cross-tenant leakages.
/// </summary>
public class GetBookRevisionsQueryHandler : IQueryHandler<GetBookRevisionsQuery, List<CreatorBookRevisionDto>>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public GetBookRevisionsQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<FluentResults.Result<List<CreatorBookRevisionDto>>> Handle(GetBookRevisionsQuery request, CancellationToken cancellationToken)
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
// Verify the book exists and belongs to this tenant/user to prevent cross-tenant data leaks
var bookExists = await dbContext.Books
.AnyAsync(b => b.Id == request.BookId && b.UserId == request.UserId && b.TenantId == request.TenantId, cancellationToken);
if (!bookExists)
{
return FluentResults.Result.Fail<List<CreatorBookRevisionDto>>(new FluentResults.Error($"Book with ID '{request.BookId}' was not found."));
mjasin marked this conversation as resolved Outdated
Outdated
Review

🟡 Design/Architecture: Violation of Result Pattern

Throwing BookNotFoundException inside the query handler violates the project's Result Pattern rule.

Suggested Fix:
Instead of throwing, return Result.Fail(...):

if (!bookExists)
{
    return Result.Fail<List<CreatorBookRevisionDto>>(new Error($"Book with ID '{request.BookId}' was not found."));
}
🟡 Design/Architecture: Violation of Result Pattern Throwing `BookNotFoundException` inside the query handler violates the project's Result Pattern rule. **Suggested Fix:** Instead of throwing, return `Result.Fail(...)`: ```csharp if (!bookExists) { return Result.Fail<List<CreatorBookRevisionDto>>(new Error($"Book with ID '{request.BookId}' was not found.")); } ```
}
// Fetch all revisions sorted chronologically
var revisions = await dbContext.BookRevisions
.AsNoTracking()
.Where(r => r.BookId == request.BookId)
.OrderByDescending(r => r.CreatedAt)
.Select(r => new CreatorBookRevisionDto(
r.Id,
r.VersionString,
r.IsPublished,
r.CreatedAt,
r.PublishedAt
))
.ToListAsync(cancellationToken);
return FluentResults.Result.Ok(revisions);
}
}
@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.DTOs.Creator;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Queries.Creator;
/// <summary>
/// Query to load aggregated Creator Dashboard telemetry metrics and book listings.
/// </summary>
/// <param name="UserId">The ID of the creator requesting dashboard data.</param>
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
public record GetCreatorDashboardDataQuery(string UserId, string TenantId) : IQuery<CreatorDashboardDataDto>;
/// <summary>
/// Handler that executes projection-only LINQ queries to aggregate metrics and compute word counts
/// without loading raw chapter content into memory or tracking them in the EF Core Change Tracker.
/// </summary>
public class GetCreatorDashboardDataQueryHandler : IQueryHandler<GetCreatorDashboardDataQuery, CreatorDashboardDataDto>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public GetCreatorDashboardDataQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<FluentResults.Result<CreatorDashboardDataDto>> Handle(GetCreatorDashboardDataQuery request, CancellationToken cancellationToken)
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
// Execute projection-only LINQ query. The heavy MarkdownContent is projected only as integer lengths.
var projectedBooks = await dbContext.Books
.AsNoTracking()
.Where(b => b.UserId == request.UserId && b.TenantId == request.TenantId)
.Select(b => new
{
b.Id,
b.Title,
LivePublishedRevision = b.LivePublishedRevision == null ? null : new CreatorBookRevisionDto(
b.LivePublishedRevision.Id,
b.LivePublishedRevision.VersionString,
b.LivePublishedRevision.IsPublished,
b.LivePublishedRevision.CreatedAt,
b.LivePublishedRevision.PublishedAt
),
CurrentDraftRevision = b.CurrentDraftRevision == null ? null : new CreatorBookRevisionDto(
b.CurrentDraftRevision.Id,
b.CurrentDraftRevision.VersionString,
b.CurrentDraftRevision.IsPublished,
b.CurrentDraftRevision.CreatedAt,
b.CurrentDraftRevision.PublishedAt
),
FirstChapterId = b.CurrentDraftRevision == null
? (Guid?)null
: b.CurrentDraftRevision.Chapters.OrderBy(c => c.SortOrder).Select(c => c.Id).FirstOrDefault(),
ChapterContentLengths = b.CurrentDraftRevision == null
? new List<int>()
: b.CurrentDraftRevision.Chapters.Select(c => c.MarkdownContent.Length).ToList()
})
.ToListAsync(cancellationToken);
var booksList = new List<CreatorBookDto>();
int totalReads = 0;
int totalWords = 0;
foreach (var pBook in projectedBooks)
{
// Estimate word count (approx. 6 characters per word as a database-friendly standard length)
int wordCount = pBook.ChapterContentLengths.Sum(len => len / 6);
totalWords += wordCount;
// Generate deterministic simulated telemetry metrics scoped to this Book
int bookReads = Math.Abs(pBook.Id.GetHashCode() % 1000) + 120;
totalReads += bookReads;
var bookDto = new CreatorBookDto(
pBook.Id,
pBook.Title,
wordCount,
bookReads,
pBook.FirstChapterId,
pBook.LivePublishedRevision,
pBook.CurrentDraftRevision
);
booksList.Add(bookDto);
}
// Calculate aggregate dashboard metrics based on projected stats
int activeReaders = projectedBooks.Count == 0 ? 0 : Math.Abs(request.UserId.GetHashCode() % 15) + 3;
decimal grossRevenue = totalReads * 1.49m;
double avgReadTime = projectedBooks.Count == 0 ? 0 : Math.Round(totalWords / 250.0, 1); // standard 250 words per minute reading speed
var metrics = new DashboardMetricsDto(
totalReads,
avgReadTime,
activeReaders,
grossRevenue
);
return FluentResults.Result.Ok(new CreatorDashboardDataDto(metrics, booksList));
}
}
@@ -0,0 +1,865 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Data.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace NexusReader.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260611183927_AddBookVersioningSupport")]
partial class AddBookVersioningSupport
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Authors");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CurrentDraftRevisionId")
.HasColumnType("uuid");
b.Property<Guid?>("LivePublishedRevisionId")
.HasColumnType("uuid");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CurrentDraftRevisionId");
b.HasIndex("LivePublishedRevisionId");
b.HasIndex("TenantId");
b.HasIndex("UserId");
b.ToTable("Books");
});
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("BookId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsPublished")
.HasColumnType("boolean");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VersionString")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("BookId");
b.ToTable("BookRevisions");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("BookRevisionId")
.HasColumnType("uuid");
b.Property<string>("MarkdownContent")
.IsRequired()
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.HasIndex("BookRevisionId");
b.ToTable("Chapters");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("AuthorId")
.HasColumnType("integer");
b.Property<string>("CoverUrl")
.HasColumnType("text");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsReadyForReading")
.HasColumnType("boolean");
b.Property<string>("LastChapter")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<int>("LastChapterIndex")
.HasColumnType("integer");
b.Property<DateTime?>("LastReadDate")
.HasColumnType("timestamp with time zone");
b.Property<double>("Progress")
.HasColumnType("double precision");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("TenantId");
b.HasIndex("UserId");
b.ToTable("Ebooks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.Property<string>("Id")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("EbookId")
.HasColumnType("uuid");
b.Property<string>("MetadataJson")
.HasColumnType("text");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("EbookId");
b.HasIndex("TenantId");
b.ToTable("KnowledgeUnits");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("RelationType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("SourceUnitId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TargetUnitId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("SourceUnitId");
b.HasIndex("TargetUnitId");
b.ToTable("KnowledgeUnitLinks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AITokenLimit")
.HasColumnType("integer");
b.Property<int>("AITokensUsed")
.HasColumnType("integer");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<DateTime?>("LastAiActionDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("LastReadAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastReadPageId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<int>("SubscriptionPlanId")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("ThemePreference")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.HasIndex("SubscriptionPlanId");
b.HasIndex("TenantId");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CompletedDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("Score")
.HasColumnType("integer");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("text");
b.Property<int>("TotalQuestions")
.HasColumnType("integer");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("UserId");
b.ToTable("QuizResults");
});
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
{
b.Property<string>("ContentHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("JsonData")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ModelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalText")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PromptVersion")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("ContentHash");
b.HasIndex("ContentHash")
.IsUnique();
b.HasIndex("TenantId");
b.ToTable("SemanticKnowledgeCache");
});
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AITokenLimit")
.HasColumnType("integer");
b.Property<bool>("IsUnlimitedTokens")
.HasColumnType("boolean");
b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric");
b.Property<string>("PlanName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("PlanName")
.IsUnique();
b.ToTable("SubscriptionPlans");
b.HasData(
new
{
Id = 1,
AITokenLimit = 5000,
IsUnlimitedTokens = false,
MonthlyPrice = 0m,
PlanName = "Free",
StripeProductId = "prod_Free789"
},
new
{
Id = 2,
AITokenLimit = 10000,
IsUnlimitedTokens = false,
MonthlyPrice = 9.99m,
PlanName = "Basic",
StripeProductId = "prod_basic_placeholder"
},
new
{
Id = 3,
AITokenLimit = 50000,
IsUnlimitedTokens = false,
MonthlyPrice = 19.99m,
PlanName = "Pro",
StripeProductId = "prod_pro_placeholder"
},
new
{
Id = 4,
AITokenLimit = 1000000000,
IsUnlimitedTokens = true,
MonthlyPrice = 99.99m,
PlanName = "Enterprise",
StripeProductId = "prod_enterprise_placeholder"
});
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
{
b.HasOne("NexusReader.Domain.Entities.BookRevision", "CurrentDraftRevision")
.WithMany()
.HasForeignKey("CurrentDraftRevisionId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("NexusReader.Domain.Entities.BookRevision", "LivePublishedRevision")
.WithMany()
.HasForeignKey("LivePublishedRevisionId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CurrentDraftRevision");
b.Navigation("LivePublishedRevision");
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
{
b.HasOne("NexusReader.Domain.Entities.Book", "Book")
.WithMany("Revisions")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
{
b.HasOne("NexusReader.Domain.Entities.BookRevision", "BookRevision")
.WithMany("Chapters")
.HasForeignKey("BookRevisionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BookRevision");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
.WithMany("Ebooks")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany("Ebooks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook")
.WithMany()
.HasForeignKey("EbookId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Ebook");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
{
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
.WithMany("OutgoingLinks")
.HasForeignKey("SourceUnitId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
.WithMany("IncomingLinks")
.HasForeignKey("TargetUnitId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SourceUnit");
b.Navigation("TargetUnit");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
.WithMany()
.HasForeignKey("SubscriptionPlanId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("SubscriptionPlan");
});
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany("QuizResults")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
{
b.Navigation("Ebooks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
{
b.Navigation("Revisions");
});
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
{
b.Navigation("Chapters");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.Navigation("IncomingLinks");
b.Navigation("OutgoingLinks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Navigation("Ebooks");
b.Navigation("QuizResults");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,141 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NexusReader.Data.Migrations
{
/// <inheritdoc />
public partial class AddBookVersioningSupport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BookRevisions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
BookId = table.Column<Guid>(type: "uuid", nullable: false),
VersionString = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
IsPublished = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
PublishedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BookRevisions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Books",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
TenantId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
UserId = table.Column<string>(type: "text", nullable: false),
CurrentDraftRevisionId = table.Column<Guid>(type: "uuid", nullable: true),
LivePublishedRevisionId = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Books", x => x.Id);
table.ForeignKey(
name: "FK_Books_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Books_BookRevisions_CurrentDraftRevisionId",
column: x => x.CurrentDraftRevisionId,
principalTable: "BookRevisions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Books_BookRevisions_LivePublishedRevisionId",
column: x => x.LivePublishedRevisionId,
principalTable: "BookRevisions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Chapters",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
BookRevisionId = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
MarkdownContent = table.Column<string>(type: "text", nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Chapters", x => x.Id);
table.ForeignKey(
name: "FK_Chapters_BookRevisions_BookRevisionId",
column: x => x.BookRevisionId,
principalTable: "BookRevisions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_BookRevisions_BookId",
table: "BookRevisions",
column: "BookId");
migrationBuilder.CreateIndex(
name: "IX_Books_CurrentDraftRevisionId",
table: "Books",
column: "CurrentDraftRevisionId");
migrationBuilder.CreateIndex(
name: "IX_Books_LivePublishedRevisionId",
table: "Books",
column: "LivePublishedRevisionId");
migrationBuilder.CreateIndex(
name: "IX_Books_TenantId",
table: "Books",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_Books_UserId",
table: "Books",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Chapters_BookRevisionId",
table: "Chapters",
column: "BookRevisionId");
migrationBuilder.AddForeignKey(
name: "FK_BookRevisions_Books_BookId",
table: "BookRevisions",
column: "BookId",
principalTable: "Books",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_BookRevisions_Books_BookId",
table: "BookRevisions");
migrationBuilder.DropTable(
name: "Chapters");
migrationBuilder.DropTable(
name: "Books");
migrationBuilder.DropTable(
name: "BookRevisions");
}
}
}
@@ -172,6 +172,103 @@ namespace NexusReader.Data.Migrations
b.ToTable("Authors");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CurrentDraftRevisionId")
.HasColumnType("uuid");
b.Property<Guid?>("LivePublishedRevisionId")
.HasColumnType("uuid");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CurrentDraftRevisionId");
b.HasIndex("LivePublishedRevisionId");
b.HasIndex("TenantId");
b.HasIndex("UserId");
b.ToTable("Books");
});
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("BookId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsPublished")
.HasColumnType("boolean");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VersionString")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("BookId");
b.ToTable("BookRevisions");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("BookRevisionId")
.HasColumnType("uuid");
b.Property<string>("MarkdownContent")
.IsRequired()
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.HasIndex("BookRevisionId");
b.ToTable("Chapters");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.Property<Guid>("Id")
@@ -614,6 +711,53 @@ namespace NexusReader.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
{
b.HasOne("NexusReader.Domain.Entities.BookRevision", "CurrentDraftRevision")
.WithMany()
.HasForeignKey("CurrentDraftRevisionId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("NexusReader.Domain.Entities.BookRevision", "LivePublishedRevision")
.WithMany()
.HasForeignKey("LivePublishedRevisionId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CurrentDraftRevision");
b.Navigation("LivePublishedRevision");
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
{
b.HasOne("NexusReader.Domain.Entities.Book", "Book")
.WithMany("Revisions")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
{
b.HasOne("NexusReader.Domain.Entities.BookRevision", "BookRevision")
.WithMany("Chapters")
.HasForeignKey("BookRevisionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BookRevision");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
@@ -689,6 +833,16 @@ namespace NexusReader.Data.Migrations
b.Navigation("Ebooks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
{
b.Navigation("Revisions");
});
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
{
b.Navigation("Chapters");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.Navigation("IncomingLinks");
@@ -25,6 +25,9 @@ public class AppDbContext : IdentityDbContext<NexusUser>
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
public DbSet<Author> Authors => Set<Author>();
public DbSet<Book> Books => Set<Book>();
public DbSet<BookRevision> BookRevisions => Set<BookRevision>();
public DbSet<Chapter> Chapters => Set<Chapter>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -114,6 +117,48 @@ public class AppDbContext : IdentityDbContext<NexusUser>
entity.HasIndex(e => e.TenantId);
});
modelBuilder.Entity<Book>(entity =>
{
entity.HasKey(b => b.Id);
entity.HasIndex(b => b.TenantId);
entity.HasIndex(b => b.UserId);
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(b => b.Revisions)
.WithOne(r => r.Book)
.HasForeignKey(r => r.BookId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(b => b.CurrentDraftRevision)
.WithMany()
.HasForeignKey(b => b.CurrentDraftRevisionId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(b => b.LivePublishedRevision)
.WithMany()
.HasForeignKey(b => b.LivePublishedRevisionId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<BookRevision>(entity =>
{
entity.HasKey(r => r.Id);
entity.HasMany(r => r.Chapters)
.WithOne(c => c.BookRevision)
.HasForeignKey(c => c.BookRevisionId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Chapter>(entity =>
{
entity.HasKey(c => c.Id);
});
// Seed Subscription Plans with deterministic IDs
modelBuilder.Entity<SubscriptionPlan>().HasData(
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" },
@@ -136,6 +136,72 @@ public static class DbInitializer
{
Console.WriteLine("[Seeder] Admin user already exists.");
}
// Seed Sample Authored Book for Creator Dashboard
var activeAdmin = await dbContext.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail);
if (activeAdmin != null)
{
if (!dbContext.Books.Any(b => b.UserId == activeAdmin.Id))
{
var sampleBookId = Guid.NewGuid();
var sampleBook = new Book
{
Id = sampleBookId,
Title = "Przewodnik po platformie Nexus",
UserId = activeAdmin.Id,
TenantId = activeAdmin.TenantId ?? "global"
};
dbContext.Books.Add(sampleBook);
await dbContext.SaveChangesAsync();
var sampleRevisionId = Guid.NewGuid();
var sampleRevision = new BookRevision
{
Id = sampleRevisionId,
BookId = sampleBookId,
VersionString = "Working Draft",
IsPublished = false,
CreatedAt = DateTime.UtcNow
};
dbContext.BookRevisions.Add(sampleRevision);
await dbContext.SaveChangesAsync();
var sampleChapter1 = new Chapter
{
Id = Guid.NewGuid(),
BookRevisionId = sampleRevisionId,
Title = "Rozdział 1: Wprowadzenie do Zen Mode",
MarkdownContent = @"# Zen Mode Editor
Welcome to your dedicated workspace. This premium panel supports Notion-like WYSIWYG editing.
## Features:
- **Zero Distraction**: Simple elevation and border framing.
- **GFM Tables**: Consistent cell padding and hover striping.
- **Clean Code Blocks**: Pre-rendered base64 font-loaded code-preview blocks.",
SortOrder = 1
};
var sampleChapter2 = new Chapter
{
Id = Guid.NewGuid(),
BookRevisionId = sampleRevisionId,
Title = "Rozdział 2: Zabezpieczenia i XSS",
MarkdownContent = @"# Security Overview
This module provides Magic Number image signature checking and HtmlSanitizer filters.",
SortOrder = 2
};
dbContext.Chapters.Add(sampleChapter1);
dbContext.Chapters.Add(sampleChapter2);
await dbContext.SaveChangesAsync();
sampleBook.CurrentDraftRevisionId = sampleRevisionId;
await dbContext.SaveChangesAsync();
Console.WriteLine("[Seeder] Sample authored book and chapters seeded for admin.");
}
}
}
catch (Exception ex)
{
+39
View File
@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace NexusReader.Domain.Entities;
/// <summary>
/// Represents a Book metadata entry that references its decoupled revisions.
/// </summary>
public class Book
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(255)]
public string Title { get; set; } = string.Empty;
[Required]
[MaxLength(128)]
public string TenantId { get; set; } = "global";
[Required]
public string UserId { get; set; } = string.Empty;
[ForeignKey(nameof(UserId))]
public virtual NexusUser? User { get; set; }
public Guid? CurrentDraftRevisionId { get; set; }
[ForeignKey(nameof(CurrentDraftRevisionId))]
public virtual BookRevision? CurrentDraftRevision { get; set; }
public Guid? LivePublishedRevisionId { get; set; }
[ForeignKey(nameof(LivePublishedRevisionId))]
public virtual BookRevision? LivePublishedRevision { get; set; }
public virtual ICollection<BookRevision> Revisions { get; set; } = new List<BookRevision>();
}
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace NexusReader.Domain.Entities;
/// <summary>
/// Encapsulates a snapshot or draft version of a Book's chapters.
/// </summary>
public class BookRevision
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public Guid BookId { get; set; }
[ForeignKey(nameof(BookId))]
public virtual Book Book { get; set; } = null!;
[Required]
[MaxLength(100)]
public string VersionString { get; set; } = "Working Draft";
public bool IsPublished { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? PublishedAt { get; set; }
public virtual ICollection<Chapter> Chapters { get; set; } = new List<Chapter>();
}
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace NexusReader.Domain.Entities;
/// <summary>
/// Represents a chapter belonging strictly to a specific BookRevision.
/// </summary>
public class Chapter
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public Guid BookRevisionId { get; set; }
[ForeignKey(nameof(BookRevisionId))]
public virtual BookRevision BookRevision { get; set; } = null!;
[Required]
[MaxLength(255)]
public string Title { get; set; } = string.Empty;
[Required]
public string MarkdownContent { get; set; } = string.Empty;
[Required]
public int SortOrder { get; set; }
}
@@ -0,0 +1,12 @@
namespace NexusReader.Domain.Exceptions;
/// <summary>
/// Custom domain exception thrown when a Book cannot be found by its ID.
/// </summary>
public class BookNotFoundException : Exception
{
public BookNotFoundException(Guid bookId)
: base($"Book with ID '{bookId}' was not found.")
{
}
}
@@ -55,7 +55,15 @@ public static class DependencyInjection
// Qdrant Client registration
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)
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
@@ -29,6 +29,7 @@
<PackageReference Include="Polly" />
<PackageReference Include="Polly.Extensions.Http" />
<PackageReference Include="HtmlSanitizer" />
<PackageReference Include="Markdig" />
<PackageReference Include="Qdrant.Client" />
<PackageReference Include="Stripe.net" />
<PackageReference Include="VersOne.Epub" />
@@ -2,6 +2,7 @@ using Ganss.Xss;
using Microsoft.Extensions.Options;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Infrastructure.Configuration;
using Markdig;
namespace NexusReader.Infrastructure.Services;
@@ -11,10 +12,12 @@ namespace NexusReader.Infrastructure.Services;
public class HtmlSanitizerService : ISanitizerService
{
private readonly HtmlSanitizer _sanitizer;
private readonly MarkdownPipeline _pipeline;
public HtmlSanitizerService(IOptions<HtmlSanitizerSettings>? options = null)
{
_sanitizer = new HtmlSanitizer();
_pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
if (options?.Value != null)
{
@@ -65,6 +68,9 @@ public class HtmlSanitizerService : ISanitizerService
return input;
}
return _sanitizer.Sanitize(input);
// Translate raw Markdown input to HTML strictly before running HtmlSanitizer
var html = Markdown.ToHtml(input, _pipeline);
return _sanitizer.Sanitize(html).Trim();
}
}
@@ -24,7 +24,7 @@ public class LocalStorageService : IStorageService
public async Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType)
{
var mediaFolder = Path.Combine(_environment.WebRootPath, "uploads", "media");
var mediaFolder = Path.Combine(_environment.WebRootPath, "uploads");
var resolvedMediaFolder = Path.GetFullPath(mediaFolder);
var folderWithSeparator = resolvedMediaFolder.EndsWith(Path.DirectorySeparatorChar)
? resolvedMediaFolder
@@ -53,6 +53,6 @@ public class LocalStorageService : IStorageService
}
// Return the public web-relative URL
return $"/uploads/media/{uniqueFileName}";
return $"/uploads/{uniqueFileName}";
}
}
@@ -2,24 +2,81 @@
@implements IAsyncDisposable
@inject IJSRuntime JS
@inject HttpClient Http
@inject NexusReader.Application.Abstractions.Services.INativeStorageService StorageService
<div class="markdown-editor-container" style="height: @Height; width: @Width;">
<div id="@EditorId" class="milkdown-editor-wrapper"></div>
@if (ShowFetchButton)
@if (_showRestorationBanner)
{
<div class="editor-actions">
<button type="button" @onclick="FetchContentAsync" class="nexus-btn">
Fetch Markdown Content
</button>
<div class="restoration-banner">
<span class="banner-text">You have unsaved changes from an interrupted session.</span>
<div class="banner-actions">
<button type="button" class="banner-btn restore-btn" @onclick="RestoreBackupAsync">Restore</button>
<button type="button" class="banner-btn dismiss-btn" @onclick="DismissBackupAsync">Dismiss</button>
</div>
</div>
}
else
{
<div @key="_editorRenderKey" id="@EditorId" class="milkdown-editor-wrapper"></div>
<div class="editor-footer">
<div class="status-indicator">
<span class="status-dot @StatusClass"></span>
<span class="status-text">@StatusText</span>
</div>
@if (ShowFetchButton)
{
<div class="editor-actions">
<button type="button" @onclick="FetchContentAsync" class="nexus-btn">
Fetch Markdown Content
</button>
</div>
}
</div>
}
</div>
@code {
private readonly string EditorId = $"milkdown-editor-{Guid.NewGuid():N}";
private string EditorId { get; set; } = $"milkdown-editor-{Guid.NewGuid():N}";
private Guid _editorRenderKey = Guid.NewGuid();
private readonly CancellationTokenSource _cts = new();
private IJSObjectReference? _module;
private DotNetObjectReference<MarkdownEditor>? _dotNetHelper;
private string? _lastInitializedEditorId;
private bool _disposed;
private enum SaveStatus
{
SavedToCloud,
Saving,
OfflineLocalBackup
}
private SaveStatus _status = SaveStatus.SavedToCloud;
private string _currentMarkdown = string.Empty;
private CancellationTokenSource? _debounceCts;
private readonly object _timerLock = new();
private bool _showRestorationBanner = false;
private NexusReader.Application.DTOs.Media.LocalBackupEnvelope? _pendingBackup;
private bool _hasRunStorageInit = false;
private bool _reinitializeEditor = false;
private string StatusClass => _status switch
{
SaveStatus.SavedToCloud => "saved",
SaveStatus.Saving => "saving",
SaveStatus.OfflineLocalBackup => "offline",
_ => "saved"
};
private string StatusText => _status switch
{
SaveStatus.SavedToCloud => "Saved to Cloud",
SaveStatus.Saving => "Saving...",
SaveStatus.OfflineLocalBackup => "Offline - Local Backup Only",
_ => "Saved to Cloud"
};
[Parameter]
public bool ShowFetchButton { get; set; } = true;
@@ -36,37 +93,218 @@
[Parameter]
public string Width { get; set; } = "100%";
[Parameter]
public Guid ChapterId { get; set; } = Guid.Empty;
[Parameter]
public DateTime? ServerTimestamp { get; set; }
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Sweep keys and check restoration on init
await RunStorageSweepAndRestorationCheckAsync();
}
private Guid _prevChapterId = Guid.Empty;
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
if (ChapterId != Guid.Empty && ChapterId != _prevChapterId)
{
_prevChapterId = ChapterId;
_hasRunStorageInit = false;
if (_module != null)
{
try
{
await _module.InvokeVoidAsync("destroyEditor", EditorId);
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Error destroying old editor on chapter switch: {ex.Message}");
}
}
_reinitializeEditor = true;
EditorId = $"milkdown-editor-{Guid.NewGuid():N}";
_editorRenderKey = Guid.NewGuid();
await RunStorageSweepAndRestorationCheckAsync();
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
var shouldInit = (firstRender || _reinitializeEditor) && (EditorId != _lastInitializedEditorId);
if (shouldInit)
{
_dotNetHelper = DotNetObjectReference.Create(this);
_reinitializeEditor = false;
_lastInitializedEditorId = EditorId; // Set immediately before any async yield to prevent concurrent triggers
if (firstRender)
{
_dotNetHelper = DotNetObjectReference.Create(this);
// Retry if deferred during prerendering OnInitializedAsync
await RunStorageSweepAndRestorationCheckAsync();
}
try
{
// Import the isolated JavaScript module
_module = await JS.InvokeAsync<IJSObjectReference>(
"import",
"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js"
);
// Call the initialization function in the wrapper
if (_module == null)
{
_module = await JS.InvokeAsync<IJSObjectReference>(
"import",
$"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js?v={Guid.NewGuid():N}"
);
}
await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown);
}
catch (Exception ex)
{
// Log the exception gracefully and do not crash the component
Console.WriteLine($"[MarkdownEditor] Error initializing Milkdown editor: {ex.Message}");
}
}
}
private async Task RunStorageSweepAndRestorationCheckAsync()
{
if (_hasRunStorageInit) return;
try
{
_hasRunStorageInit = true;
// Import wrapper module if not already loaded to access helper
if (_module == null)
{
_module = await JS.InvokeAsync<IJSObjectReference>(
"import",
$"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js?v={Guid.NewGuid():N}"
);
}
// Sweep and filter backup keys defensively
var keys = await _module.InvokeAsync<List<string>>("getBackupKeys");
if (keys != null)
{
var now = DateTime.UtcNow;
foreach (var key in keys)
{
// Strict defensive check before doing any JSON deserialization
if (!key.StartsWith("nexus-bkp-")) continue;
try
{
var backupResult = await StorageService.GetStringAsync(key);
if (backupResult.IsSuccess && !string.IsNullOrEmpty(backupResult.Value))
{
var envelope = System.Text.Json.JsonSerializer.Deserialize<NexusReader.Application.DTOs.Media.LocalBackupEnvelope>(
backupResult.Value,
NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope
);
if (envelope != null)
{
// Remove expired backups
if ((now - envelope.Timestamp).TotalDays > 7)
{
await StorageService.RemoveAsync(key);
Console.WriteLine($"[MarkdownEditor] Boot-up Eviction: Deleted expired backup key {key}");
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Error sweeping key {key}: {ex.Message}");
}
}
}
// Restoration guard for this specific Chapter ID
var currentBackupKey = $"nexus-bkp-{ChapterId}";
var currentBackupResult = await StorageService.GetStringAsync(currentBackupKey);
if (currentBackupResult.IsSuccess && !string.IsNullOrEmpty(currentBackupResult.Value))
{
var envelope = System.Text.Json.JsonSerializer.Deserialize<NexusReader.Application.DTOs.Media.LocalBackupEnvelope>(
currentBackupResult.Value,
NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope
);
if (envelope != null)
{
var serverTime = ServerTimestamp ?? DateTime.MinValue;
if (envelope.Timestamp > serverTime && envelope.MarkdownContent != InitialMarkdown)
{
_pendingBackup = envelope;
_showRestorationBanner = true;
StateHasChanged();
}
}
}
}
catch (Exception ex)
{
_hasRunStorageInit = false; // Reset to allow retry on client render
Console.WriteLine($"[MarkdownEditor] Storage initialization deferred/failed: {ex.Message}");
}
}
private async Task RestoreBackupAsync()
{
if (_pendingBackup != null)
{
if (_module != null)
{
try
{
// Prevent memory leak by cleaning up old instance in JS
await _module.InvokeVoidAsync("destroyEditor", EditorId);
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Error destroying old editor during restore: {ex.Message}");
}
}
InitialMarkdown = _pendingBackup.MarkdownContent;
_showRestorationBanner = false;
_pendingBackup = null;
// Regenerate render key and ID to trigger clean Blazor element-level re-initialization
_reinitializeEditor = true;
EditorId = $"milkdown-editor-{Guid.NewGuid():N}";
_editorRenderKey = Guid.NewGuid();
// Trigger an immediate background API autosave to synchronize the database with the restored content
_ = TriggerAutosaveAsync(InitialMarkdown, _cts.Token);
StateHasChanged();
}
}
private async Task DismissBackupAsync()
{
_showRestorationBanner = false;
_pendingBackup = null;
try
{
await StorageService.RemoveAsync($"nexus-bkp-{ChapterId}");
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Failed to dismiss backup from LocalStorage: {ex.Message}");
}
StateHasChanged();
}
public async Task FetchContentAsync()
{
if (_module is not null)
{
try
{
// Retrieve the updated markdown from JS
var markdown = await _module.InvokeAsync<string>("getMarkdownContent", EditorId);
if (OnSave.HasDelegate)
@@ -82,12 +320,137 @@
}
[JSInvokable]
public async Task<string> UploadImageFromJs(string filename, string contentType, byte[] fileBytes)
public async Task OnEditorContentChanged(string currentMarkdown)
{
_currentMarkdown = currentMarkdown;
// Structured JSON Envelope Pattern
var envelope = new NexusReader.Application.DTOs.Media.LocalBackupEnvelope
{
ChapterId = ChapterId,
Timestamp = DateTime.UtcNow,
MarkdownContent = currentMarkdown
};
try
{
var envelopeJson = System.Text.Json.JsonSerializer.Serialize(
envelope,
NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope
);
await StorageService.SaveStringAsync($"nexus-bkp-{ChapterId}", envelopeJson);
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Failed to save backup to LocalStorage: {ex.Message}");
}
// Status indicator to Offline - Local Backup Only
_status = SaveStatus.OfflineLocalBackup;
await InvokeAsync(StateHasChanged);
// Cancel pending timers thread-safely
CancellationTokenSource? ctsToCancel = null;
CancellationToken token;
lock (_timerLock)
{
if (_debounceCts != null)
{
ctsToCancel = _debounceCts;
_debounceCts = null;
}
_debounceCts = new CancellationTokenSource();
token = _debounceCts.Token; // Capture token synchronously under lock on UI thread
}
if (ctsToCancel != null)
{
try
{
await ctsToCancel.CancelAsync();
ctsToCancel.Dispose();
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Error cancelling debounce timer: {ex.Message}");
}
}
mjasin marked this conversation as resolved
Review

🔴 Blocking: Race Condition in Debounce Timer

The background task reads _debounceCts.Token from the shared component state after the task starts executing on the thread pool. If another keystroke happens in the meantime, it will read the new token.

Suggested Fix:
Capture the token synchronously on the UI thread before calling Task.Run and use the captured token directly inside the task:

CancellationToken token;
lock (_timerLock)
{
    token = _debounceCts.Token;
}

// Start 5-second idle debounce timer
_ = Task.Run(async () =>
{
    try
    {
        await Task.Delay(5000, token);
        await TriggerAutosaveAsync(currentMarkdown, token);
    }
    catch (TaskCanceledException) { }
    catch (Exception ex)
    {
        Console.WriteLine($"[MarkdownEditor] Debounce timer exception: {ex.Message}");
    }
});
🔴 Blocking: Race Condition in Debounce Timer The background task reads `_debounceCts.Token` from the shared component state *after* the task starts executing on the thread pool. If another keystroke happens in the meantime, it will read the *new* token. **Suggested Fix:** Capture the token synchronously on the UI thread before calling `Task.Run` and use the captured token directly inside the task: ```csharp CancellationToken token; lock (_timerLock) { token = _debounceCts.Token; } // Start 5-second idle debounce timer _ = Task.Run(async () => { try { await Task.Delay(5000, token); await TriggerAutosaveAsync(currentMarkdown, token); } catch (TaskCanceledException) { } catch (Exception ex) { Console.WriteLine($"[MarkdownEditor] Debounce timer exception: {ex.Message}"); } }); ```
// Start 5-second idle debounce timer
_ = Task.Run(async () =>
{
try
{
await Task.Delay(5000, token);
await TriggerAutosaveAsync(currentMarkdown, token);
}
catch (TaskCanceledException)
{
// Task cancelled on new keystroke
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Debounce timer exception: {ex.Message}");
}
});
}
private async Task TriggerAutosaveAsync(string markdown, CancellationToken token)
{
if (token.IsCancellationRequested || _disposed) return;
_status = SaveStatus.Saving;
mjasin marked this conversation as resolved
Review

🟢 Minor/Suggestion: Missing component disposal check

TriggerAutosaveAsync runs on a background thread and calls await InvokeAsync(StateHasChanged). If the component has been disposed before or during the save operation, calling StateHasChanged may result in runtime warnings/exceptions.

Suggested Fix:
Define a _disposed boolean flag, set it to true in DisposeAsync, and check it before updating state:

private bool _disposed;

// In DisposeAsync:
_disposed = true;

// In TriggerAutosaveAsync:
if (_disposed) return;
_status = SaveStatus.Saving;
await InvokeAsync(StateHasChanged);
🟢 Minor/Suggestion: Missing component disposal check `TriggerAutosaveAsync` runs on a background thread and calls `await InvokeAsync(StateHasChanged)`. If the component has been disposed before or during the save operation, calling `StateHasChanged` may result in runtime warnings/exceptions. **Suggested Fix:** Define a `_disposed` boolean flag, set it to `true` in `DisposeAsync`, and check it before updating state: ```csharp private bool _disposed; // In DisposeAsync: _disposed = true; // In TriggerAutosaveAsync: if (_disposed) return; _status = SaveStatus.Saving; await InvokeAsync(StateHasChanged); ```
await InvokeAsync(StateHasChanged);
try
{
var request = new NexusReader.Application.DTOs.Media.AutosaveChapterRequest(markdown);
var response = await Http.PutAsJsonAsync(
$"/api/chapters/{ChapterId}/autosave",
request,
NexusReader.Application.Common.AppJsonContext.Default.AutosaveChapterRequest,
token
);
if (_disposed) return;
if (response.IsSuccessStatusCode)
{
// Purge LocalStorage backup key on HTTP success
await StorageService.RemoveAsync($"nexus-bkp-{ChapterId}");
_status = SaveStatus.SavedToCloud;
}
else
{
_status = SaveStatus.OfflineLocalBackup;
var errorMsg = await response.Content.ReadAsStringAsync(token);
Console.WriteLine($"[MarkdownEditor] Autosave HTTP error: {response.StatusCode} - {errorMsg}");
}
}
catch (Exception ex)
{
if (_disposed) return;
_status = SaveStatus.OfflineLocalBackup;
Console.WriteLine($"[MarkdownEditor] Autosave HTTP exception: {ex.Message}");
}
if (_disposed) return;
await InvokeAsync(StateHasChanged);
}
[JSInvokable]
public async Task<string> UploadImageFromJs(string filename, string contentType, IJSStreamReference streamRef)
{
try
{
const long maxFileSize = 5 * 1024 * 1024; // 5MB limit
using var stream = await streamRef.OpenReadStreamAsync(maxFileSize, _cts.Token);
using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream, _cts.Token);
var fileBytes = memoryStream.ToArray();
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(fileBytes);
using var fileContent = new ByteArrayContent(fileBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
content.Add(fileContent, "file", filename);
@@ -96,24 +459,25 @@
{
var result = await response.Content.ReadFromJsonAsync<NexusReader.Application.DTOs.Media.UploadResultDto>(
NexusReader.Application.Common.AppJsonContext.Default.UploadResultDto, _cts.Token);
return result?.Url ?? string.Empty;
return result?.Url ?? "https://placehold.co/600x400?text=Upload+Failed";
}
else
{
var errorMsg = await response.Content.ReadAsStringAsync();
var errorMsg = await response.Content.ReadAsStringAsync(_cts.Token);
Console.WriteLine($"[MarkdownEditor] Image upload failed: {response.StatusCode} - {errorMsg}");
return string.Empty;
return "https://placehold.co/600x400?text=Upload+Failed";
}
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Exception during image upload: {ex.Message}");
return string.Empty;
return "https://placehold.co/600x400?text=Upload+Failed";
}
}
public async ValueTask DisposeAsync()
{
_disposed = true;
try
{
_cts.Cancel();
@@ -121,15 +485,57 @@
}
catch
{
// Fail silently if cancellation token disposal fails
// Fail silently
}
CancellationTokenSource? ctsToCancel = null;
lock (_timerLock)
{
if (_debounceCts != null)
{
ctsToCancel = _debounceCts;
_debounceCts = null;
}
}
if (ctsToCancel != null)
{
try
{
ctsToCancel.Cancel();
ctsToCancel.Dispose();
}
catch
{
// Fail silently
}
}
try
{
// Always try to destroy via global window registration first to handle null _module
await JS.InvokeVoidAsync("milkdownWrapper.destroyEditor", EditorId);
}
catch
{
// Fallback to module if global is not set
if (_module is not null)
{
try
{
await _module.InvokeVoidAsync("destroyEditor", EditorId);
}
catch
{
// Fail silently
}
}
}
try
{
if (_module is not null)
{
// Clean up the JS editor instance to prevent memory leaks
await _module.InvokeVoidAsync("destroyEditor", EditorId);
await _module.DisposeAsync();
}
}
@@ -143,7 +549,6 @@
}
catch (Exception ex)
{
// Log other unexpected errors
Console.WriteLine($"[MarkdownEditor] Unexpected error during JS cleanup: {ex.Message}");
}
finally
@@ -84,3 +84,114 @@
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Stateful Status Indicator Footer */
.editor-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background: var(--bg-surface-low, rgba(255, 255, 255, 0.02));
border-radius: var(--radius-sm, 6px);
border: 1px solid var(--border);
margin-top: -0.5rem;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-muted, #888888);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
position: relative;
box-shadow: 0 0 8px currentColor;
}
.status-dot.saved {
color: #10B981; /* Green */
background-color: #10B981;
}
.status-dot.saving {
color: #F59E0B; /* Amber */
background-color: #F59E0B;
animation: status-pulse 1s infinite alternate;
}
.status-dot.offline {
color: #EF4444; /* Red */
background-color: #EF4444;
}
/* Orange Restoration Warning Banner */
.restoration-banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.25rem;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: var(--radius-md, 8px);
color: var(--text-main);
font-size: 0.9rem;
gap: 1rem;
margin-bottom: 0.75rem;
animation: banner-fadeIn 0.3s ease-out;
}
.banner-text {
font-weight: 500;
}
.banner-actions {
display: flex;
gap: 0.75rem;
}
.banner-btn {
padding: 6px 12px;
border-radius: var(--radius-sm, 4px);
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
.restore-btn {
background: #F59E0B;
color: #000;
}
.restore-btn:hover {
background: #D97706;
transform: translateY(-1px);
}
.dismiss-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-main);
}
.dismiss-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
@keyframes status-pulse {
0% { opacity: 0.4; transform: scale(0.9); }
100% { opacity: 1; transform: scale(1.1); }
}
@keyframes banner-fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
@@ -5,6 +5,7 @@
.recommendations-panel {
width: 100%;
padding: 1.75rem;
margin-top: 2.5rem;
background: var(--nexus-surface, #1a1a1e);
border: 1px solid var(--nexus-border, rgba(255, 255, 255, 0.05));
border-radius: 12px;
@@ -183,10 +183,11 @@
InvokeAsync(StateHasChanged);
}
protected override void OnAfterRender(bool firstRender)
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await ThemeService.InitializeAsync();
_isFullyLoaded = true;
StateHasChanged();
}
@@ -354,6 +354,8 @@
/* --- Desktop Sidebar: warm paper shadow --- */
.theme-light ::deep .hub-sidebar {
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 --- */
@@ -1,76 +0,0 @@
@page "/creator"
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
<PageTitle>Kreator Treści (Zen Mode)</PageTitle>
<div class="creator-fullscreen-wrapper">
<div class="creator-header">
<h1>Kreator Treści</h1>
<p class="subtitle">Zen publishing workspace mapping standard Markdown into clean visual blocks.</p>
</div>
<div class="milkdown-premium-container creator-workspace-card" spellcheck="false">
<div class="editor-growing-area">
<MarkdownEditor @ref="_editorRef" InitialMarkdown="@_initialMarkdown" OnSave="HandleSave" ShowFetchButton="false" Height="100%" />
</div>
<div class="creator-actions-bar">
<button type="button" @onclick="TriggerFetchAsync" class="nexus-btn premium-fetch-btn btn-nexus-premium">
<span>Fetch Markdown Content</span>
<svg class="arrow-icon" viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(_savedMarkdown))
{
<div class="creator-preview-card">
<div class="preview-header">
<h3>Retrieved Markdown Preview</h3>
</div>
<div class="pre-wrapper">
<pre class="code-preview-block"><code>@_savedMarkdown</code></pre>
</div>
</div>
}
</div>
@code {
private MarkdownEditor? _editorRef;
private string _savedMarkdown = string.Empty;
private readonly string _initialMarkdown = @"# Zen Mode Editor
Welcome to your dedicated workspace. This premium panel supports Notion-like WYSIWYG editing.
## Features:
- **Zero Distraction**: Simple elevation and border framing.
- **GFM Tables**: Consistent cell padding and hover striping.
- **Clean Code Blocks**: Pre-rendered base64 font-loaded code-preview blocks.
| Option | Active | Value |
| :--- | :---: | :--- |
| Zen Mode | Yes | High Focus |
| Responsive | Yes | 1200px Max |
| Theme Sync | Yes | Automatic |
Start writing your masterpiece...";
private async Task TriggerFetchAsync()
{
if (_editorRef is not null)
{
await _editorRef.FetchContentAsync();
}
}
private void HandleSave(string markdown)
{
_savedMarkdown = markdown;
StateHasChanged();
}
}
@@ -1,349 +0,0 @@
/* ==========================================================================
Creator.razor.css - Isolated Styles for Zen Mode Creator Workspace
========================================================================== */
/* 1. BOUNDARY & SCROLLING RE-ENGINEERING */
/* Strict flexbox layout context eliminating global browser scrollbars */
.creator-fullscreen-wrapper {
width: 100% !important;
max-width: 100% !important; /* Likwidujemy sztuczne ograniczenia szerokości */
margin: 0;
padding: 1.5rem; /* Elastyczny, bezpieczny margines od krawędzi bocznych menu i ekranu */
display: flex;
flex-direction: column;
height: calc(100vh - 4rem);
box-sizing: border-box;
overflow: hidden;
gap: 1.25rem;
}
.creator-header {
flex-shrink: 0;
padding-left: 0.5rem;
}
.creator-header h1 {
font-size: 1.75rem;
font-weight: 700;
color: var(--text-main);
margin: 0 0 0.25rem 0;
}
.creator-header .subtitle {
font-size: 0.9rem;
color: var(--text-muted);
margin: 0;
}
/* 2. Full Viewport Workspace Card stretching smoothly */
.creator-workspace-card {
background-color: var(--bg-surface) !important;
border: 1px solid var(--border) !important;
border-radius: 20px;
padding: 2rem;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
flex-grow: 1;
width: 100% !important;
box-sizing: border-box;
overflow: hidden;
}
.editor-growing-area {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 3. Deep Cascading Overrides to target dynamic editor components */
.creator-fullscreen-wrapper ::deep .markdown-editor-container {
height: 100% !important;
display: flex !important;
flex-direction: column !important;
flex-grow: 1 !important;
overflow: hidden !important;
}
.creator-fullscreen-wrapper ::deep .milkdown-editor-wrapper {
display: flex !important;
flex-direction: column !important;
flex-grow: 1 !important;
overflow: hidden !important;
}
/* Force crepe and milkdown inner wrappers to stretch */
.creator-fullscreen-wrapper ::deep .crepe,
.creator-fullscreen-wrapper ::deep .milkdown {
width: 100% !important;
max-width: 100% !important;
display: flex !important;
flex-direction: column !important;
flex-grow: 1 !important;
overflow: hidden !important;
background-color: transparent !important;
background: transparent !important;
}
/* Pin the toolbar at the top */
.creator-fullscreen-wrapper ::deep .crepe .toolbar,
.creator-fullscreen-wrapper ::deep .milkdown-menu,
.creator-fullscreen-wrapper ::deep .crepe-menu-wrapper {
flex-shrink: 0 !important;
background-color: var(--bg-base) !important;
border: 1px solid var(--border) !important;
border-radius: 12px !important;
padding: 0.5rem !important;
margin-bottom: 1rem !important;
}
.creator-fullscreen-wrapper ::deep .crepe .toolbar button:hover,
.creator-fullscreen-wrapper ::deep .milkdown-menu button:hover,
.creator-fullscreen-wrapper ::deep .crepe-menu-wrapper button:hover,
.creator-fullscreen-wrapper ::deep .crepe .toolbar .button:hover,
.creator-fullscreen-wrapper ::deep .milkdown-menu .button:hover {
color: var(--accent) !important;
background-color: rgba(16, 185, 129, 0.1) !important;
border-radius: var(--radius-sm, 4px) !important;
}
/* Relocate scrolling directly to ProseMirror editor layer and fix text clipping */
.creator-fullscreen-wrapper ::deep .ProseMirror,
.creator-fullscreen-wrapper ::deep .crepe .editor,
.creator-fullscreen-wrapper ::deep .milkdown .editor {
position: relative !important;
top: 0 !important;
transform: none !important;
flex-grow: 1 !important;
overflow-y: auto !important;
padding: 1.5rem !important;
padding-right: 15px !important;
background-color: var(--bg-surface) !important;
color: var(--text-main) !important;
font-size: 1.1rem !important;
line-height: 1.7 !important;
outline: none !important;
width: 100% !important;
max-width: 100% !important;
}
/* Custom narrow scrollbar mapped to var(--border) */
.creator-fullscreen-wrapper ::deep .ProseMirror::-webkit-scrollbar,
.creator-fullscreen-wrapper ::deep .crepe .editor::-webkit-scrollbar,
.creator-fullscreen-wrapper ::deep .milkdown .editor::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.creator-fullscreen-wrapper ::deep .ProseMirror::-webkit-scrollbar-track,
.creator-fullscreen-wrapper ::deep .crepe .editor::-webkit-scrollbar-track,
.creator-fullscreen-wrapper ::deep .milkdown .editor::-webkit-scrollbar-track {
background: transparent;
}
.creator-fullscreen-wrapper ::deep .ProseMirror::-webkit-scrollbar-thumb,
.creator-fullscreen-wrapper ::deep .crepe .editor::-webkit-scrollbar-thumb,
.creator-fullscreen-wrapper ::deep .milkdown .editor::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.creator-fullscreen-wrapper ::deep .ProseMirror::-webkit-scrollbar-thumb:hover,
.creator-fullscreen-wrapper ::deep .crepe .editor::-webkit-scrollbar-thumb:hover,
.creator-fullscreen-wrapper ::deep .milkdown .editor::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Editorial Typography */
.creator-fullscreen-wrapper ::deep .milkdown .editor h1,
.creator-fullscreen-wrapper ::deep .crepe h1,
.creator-fullscreen-wrapper ::deep .ProseMirror h1 {
margin-top: 1.8rem !important;
margin-bottom: 1rem !important;
font-size: 2.25rem !important;
font-weight: 700 !important;
color: var(--text-main) !important;
line-height: 1.25 !important;
}
.creator-fullscreen-wrapper ::deep .milkdown .editor h2,
.creator-fullscreen-wrapper ::deep .crepe h2,
.creator-fullscreen-wrapper ::deep .ProseMirror h2 {
margin-top: 1.5rem !important;
margin-bottom: 0.8rem !important;
font-size: 1.6rem !important;
font-weight: 700 !important;
color: var(--text-main) !important;
line-height: 1.3 !important;
}
.creator-fullscreen-wrapper ::deep .milkdown .editor h3,
.creator-fullscreen-wrapper ::deep .crepe h3,
.creator-fullscreen-wrapper ::deep .ProseMirror h3 {
margin-top: 1.3rem !important;
margin-bottom: 0.7rem !important;
font-size: 1.3rem !important;
font-weight: 700 !important;
color: var(--text-main) !important;
line-height: 1.35 !important;
}
.creator-fullscreen-wrapper ::deep .milkdown .editor code,
.creator-fullscreen-wrapper ::deep .crepe code,
.creator-fullscreen-wrapper ::deep .ProseMirror code {
background-color: rgba(16, 185, 129, 0.1) !important;
color: var(--accent) !important;
padding: 0.2rem 0.4rem !important;
border-radius: var(--radius-sm, 4px) !important;
font-family: var(--nexus-font-mono) !important;
font-size: 0.85em !important;
}
/* Premium GFM Table Layouts */
.creator-fullscreen-wrapper ::deep .milkdown-premium-container table,
.creator-fullscreen-wrapper ::deep .crepe table,
.creator-fullscreen-wrapper ::deep .milkdown table,
.creator-fullscreen-wrapper ::deep .ProseMirror table {
width: 100% !important;
max-width: 100% !important;
border-collapse: collapse !important;
margin: 1.5rem 0 !important;
}
.creator-fullscreen-wrapper ::deep .milkdown-premium-container th,
.creator-fullscreen-wrapper ::deep .crepe th,
.creator-fullscreen-wrapper ::deep .milkdown th,
.creator-fullscreen-wrapper ::deep .ProseMirror th,
.creator-fullscreen-wrapper ::deep .milkdown-premium-container td,
.creator-fullscreen-wrapper ::deep .crepe td,
.creator-fullscreen-wrapper ::deep .milkdown td,
.creator-fullscreen-wrapper ::deep .ProseMirror td {
padding: 14px 18px !important;
border: 1px solid var(--border) !important;
}
.creator-fullscreen-wrapper ::deep .milkdown-premium-container th,
.creator-fullscreen-wrapper ::deep .crepe th,
.creator-fullscreen-wrapper ::deep .milkdown th,
.creator-fullscreen-wrapper ::deep .ProseMirror th {
background-color: var(--bg-base) !important;
color: var(--text-main) !important;
font-weight: 700 !important;
text-align: left !important;
}
.creator-fullscreen-wrapper ::deep .milkdown-premium-container td,
.creator-fullscreen-wrapper ::deep .crepe td,
.creator-fullscreen-wrapper ::deep .milkdown td,
.creator-fullscreen-wrapper ::deep .ProseMirror td {
color: var(--text-main) !important;
}
/* Zebra row background tints (Dark Mode default) */
.creator-fullscreen-wrapper ::deep .milkdown-premium-container tr:nth-child(even),
.creator-fullscreen-wrapper ::deep .crepe tr:nth-child(even),
.creator-fullscreen-wrapper ::deep .milkdown tr:nth-child(even),
.creator-fullscreen-wrapper ::deep .ProseMirror tr:nth-child(even) {
background-color: rgba(255, 255, 255, 0.01) !important;
}
/* Zebra row background tints (Light Mode override) */
.theme-light .creator-fullscreen-wrapper ::deep .milkdown-premium-container tr:nth-child(even),
.theme-light .creator-fullscreen-wrapper ::deep .crepe tr:nth-child(even),
.theme-light .creator-fullscreen-wrapper ::deep .milkdown tr:nth-child(even),
.theme-light .creator-fullscreen-wrapper ::deep .ProseMirror tr:nth-child(even) {
background-color: rgba(0, 0, 0, 0.015) !important;
}
/* Lists and Task Lists */
.creator-fullscreen-wrapper ::deep .crepe ul,
.creator-fullscreen-wrapper ::deep .crepe ol,
.creator-fullscreen-wrapper ::deep .milkdown ul,
.creator-fullscreen-wrapper ::deep .milkdown ol,
.creator-fullscreen-wrapper ::deep .ProseMirror ul,
.creator-fullscreen-wrapper ::deep .ProseMirror ol {
line-height: 1.7 !important;
}
/* 4. Bottom Actions Panel locked at floor zone of the card structure */
.creator-actions-bar {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
padding: 1rem 0 0 0;
border-top: 1px solid var(--border);
flex-shrink: 0;
width: 100%;
}
.btn-nexus-premium {
display: inline-flex;
align-items: center;
gap: 0.5rem;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background-color: var(--accent) !important;
background: var(--accent) !important;
color: #000000 !important;
border: none;
border-radius: var(--radius-md);
padding: 8px 16px;
font-weight: 600;
cursor: pointer;
font-family: var(--nexus-font-sans);
font-size: 0.9rem;
min-height: 36px;
}
.btn-nexus-premium:hover {
transform: translateY(-2px);
filter: brightness(1.1);
box-shadow: 0 4px 15px var(--accent-glow);
}
.btn-nexus-premium:hover .arrow-icon {
transform: translateX(4px);
}
.arrow-icon {
transition: transform 0.2s ease;
}
/* 5. Dedicated Preview Card */
.creator-preview-card {
background: #121214;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-lg);
padding: 1.25rem;
flex-shrink: 0;
max-height: 180px;
display: flex;
flex-direction: column;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.25);
}
.preview-header h3 {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
font-weight: 600;
color: #ffffff;
font-family: var(--nexus-font-sans);
}
.pre-wrapper {
overflow-y: auto;
overflow-x: auto;
flex-grow: 1;
}
.code-preview-block {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
font-family: 'Azeret Mono', SFMono-Regular, Consolas, Menlo, monospace;
font-size: 0.85rem;
color: #e4e4e7;
line-height: 1.6;
}
@@ -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">&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; }
}
}
@@ -0,0 +1,763 @@
.dashboard-container {
min-height: 100%;
display: flex;
flex-direction: column;
animation: fade-in 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(15px); }
to { opacity: 1; transform: translateY(0); }
}
/* --- Dashboard Header --- */
.dashboard-header {
padding: 3rem 2rem 2rem;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
position: relative;
overflow: hidden;
}
.dashboard-title {
font-family: var(--nexus-font-serif, serif);
font-size: 2.25rem;
font-weight: 700;
color: var(--text-main);
margin-bottom: 0.5rem;
}
.subtitle {
font-size: 0.95rem;
color: var(--text-muted);
max-width: 600px;
line-height: 1.5;
}
/* --- Main Content Layout --- */
.dashboard-content {
padding: 2.5rem 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 3rem;
}
/* --- Metrics Grid --- */
.metrics-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 1.5rem;
}
@media (min-width: 768px) {
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.metrics-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.metric-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.5rem;
position: relative;
}
.metric-label-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.metric-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.metric-value {
font-size: 1.85rem;
font-weight: 700;
color: var(--text-main);
margin: 0.25rem 0;
}
.metric-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
}
.metric-trend.positive {
color: var(--nexus-neon);
}
.metric-trend.neutral {
color: var(--text-muted);
}
/* 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);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.2s ease, box-shadow 0.2s ease;
}
.glass-panel:hover {
transform: translateY(-4px);
border-color: var(--accent);
box-shadow: 0 10px 30px rgba(16, 185, 129, 0.06);
}
/* --- Pulsing indicator --- */
.pulse-indicator {
display: flex;
align-items: center;
gap: 0.4rem;
background: rgba(255, 68, 68, 0.1);
border: 1px solid rgba(255, 68, 68, 0.3);
border-radius: 100px;
padding: 2px 8px;
}
.pulse-dot {
width: 6px;
height: 6px;
background-color: #ff4444;
border-radius: 50%;
animation: beacon-pulse 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.pulse-text {
font-size: 0.65rem;
font-weight: 700;
color: #ff4444;
text-transform: uppercase;
letter-spacing: 0.05em;
}
@keyframes beacon-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(1.3); }
}
/* --- Publications Section --- */
.publications-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section-header h2 {
font-family: var(--nexus-font-serif, serif);
font-size: 1.5rem;
font-weight: 600;
color: var(--text-main);
margin: 0;
}
/* --- Publication Grid & Cards --- */
.books-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 1.5rem;
}
@media (min-width: 768px) {
.books-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.books-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.book-card {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 1.5rem;
padding: 1.5rem;
position: relative;
overflow: hidden;
}
.card-glow {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: linear-gradient(90deg, var(--border), var(--accent), var(--border));
opacity: 0.4;
}
.book-card:hover .card-glow {
opacity: 1;
background: linear-gradient(90deg, var(--accent), var(--nexus-neon), var(--accent));
}
.book-card-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.book-title {
font-size: 1.15rem;
font-weight: 600;
color: var(--text-main);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badges-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.badge {
padding: 0.25rem 0.6rem;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
border: 1px solid transparent;
}
.badge-published {
background: rgba(16, 185, 129, 0.1);
color: var(--nexus-neon);
border-color: rgba(16, 185, 129, 0.3);
}
.badge-draft {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
border-color: rgba(245, 158, 11, 0.3);
}
.badge-draft.pulsing {
animation: draft-pulse 2s infinite ease-in-out;
}
@keyframes draft-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.2); }
50% { box-shadow: 0 0 8px 2px rgba(245, 158, 11, 0.4); }
}
.book-telemetry {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-top: 1px dashed var(--border);
border-bottom: 1px dashed var(--border);
}
.telemetry-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.telemetry-label {
font-size: 0.75rem;
color: var(--text-muted);
}
.telemetry-value {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-main);
}
.book-card-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.5rem;
}
.book-card-actions .btn-nexus {
flex: 1;
min-width: 90px;
text-align: center;
justify-content: center;
}
.book-card-actions .link-btn {
flex: unset;
width: 100%;
}
/* --- Buttons --- */
.btn-nexus {
padding: 0.6rem 1.1rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: none;
display: inline-flex;
align-items: center;
}
.btn-nexus.primary {
background: #10b981;
color: #000;
}
.btn-nexus.secondary {
background: var(--bg-base);
color: var(--text-main);
border: 1px solid var(--border);
}
.btn-nexus.link-btn {
background: transparent;
color: var(--text-muted);
border: none;
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
text-decoration: underline;
}
.btn-nexus:hover {
transform: translateY(-2px);
}
.btn-nexus.primary:hover {
filter: brightness(1.15);
}
.btn-nexus.secondary:hover {
border-color: var(--accent);
background: var(--bg-surface);
}
.btn-nexus.link-btn:hover {
color: var(--text-main);
}
.btn-nexus.glow-btn {
position: relative;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.2);
}
.btn-nexus.glow-btn:hover {
box-shadow: 0 0 20px rgba(16, 185, 129, 0.4);
}
/* --- Modal styles --- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(5px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fade-in-backdrop 0.3s ease;
}
/* --- Modal Content & Header --- */
.modal-content {
width: 90%;
max-width: 620px; /* Wider split layout */
max-height: 90vh;
display: flex;
flex-direction: column;
padding: 2rem !important;
animation: modal-slide 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
}
@keyframes fade-in-backdrop {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modal-slide {
from { opacity: 0; transform: translateY(-30px); }
to { opacity: 1; transform: translateY(0); }
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.modal-header h2, .modal-header h3 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-main);
}
.close-btn {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.close-btn:hover {
color: #10b981;
transform: rotate(90deg);
}
.modal-body {
flex: 1;
overflow-y: auto;
margin-bottom: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.form-group label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
}
.form-control, .form-input, ::deep .form-control, ::deep .form-input {
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-radius: 8px;
color: #ffffff !important;
font-size: 0.95rem;
outline: none;
transition: all 0.3s;
width: 100%;
box-sizing: border-box;
}
.form-control:focus, .form-input:focus, ::deep .form-control:focus, ::deep .form-input:focus {
outline: none;
border-color: #10b981 !important;
background: rgba(255, 255, 255, 0.06) !important;
box-shadow: 0 0 15px rgba(16, 185, 129, 0.2) !important;
}
.error-banner {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: rgba(255, 68, 68, 0.1);
border: 1px solid rgba(255, 68, 68, 0.3);
border-radius: 8px;
color: #ff4444;
font-size: 0.85rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
/* --- Revisions list --- */
.spinner-container {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 2rem;
justify-content: center;
color: var(--text-muted);
}
.revisions-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 250px;
overflow-y: auto;
padding-right: 0.25rem;
}
.revision-item {
padding: 0.75rem 1rem;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.revision-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.revision-tag {
font-size: 0.75rem;
font-weight: 700;
}
.revision-tag.published {
color: var(--nexus-neon);
}
.revision-tag.draft {
color: #f59e0b;
}
.revision-date {
font-size: 0.75rem;
color: var(--text-muted);
}
.revision-meta {
font-size: 0.7rem;
color: var(--text-muted);
}
.empty-revisions {
text-align: center;
color: var(--text-muted);
padding: 2rem;
}
/* --- Loading skeleton animations --- */
.skeleton-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
position: relative;
overflow: hidden;
}
.skeleton-card::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent);
animation: skeleton-glow 1.5s infinite;
}
@keyframes skeleton-glow {
100% { transform: translateX(100%); }
}
.skeleton-line {
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.skeleton-line.label {
width: 60%;
height: 12px;
margin-bottom: 0.75rem;
}
.skeleton-line.value {
width: 40%;
height: 24px;
}
.skeleton-card-header {
height: 4px;
background: rgba(255, 255, 255, 0.05);
}
.skeleton-line.title {
width: 70%;
height: 16px;
margin: 1.5rem 1.5rem 0.5rem;
}
.skeleton-line.metadata {
width: 40%;
height: 12px;
margin: 0 1.5rem 1.5rem;
}
.skeleton-card-actions {
height: 38px;
background: rgba(255, 255, 255, 0.03);
margin-top: auto;
}
/* --- Mobile View Adjustments --- */
@media (max-width: 767px) {
.dashboard-header {
padding: 1.5rem 1rem 1rem;
}
.dashboard-title {
font-size: 1.75rem;
}
.dashboard-content {
padding: 1.5rem 1rem;
gap: 2rem;
}
.book-card-actions {
flex-direction: column;
}
.book-card-actions .btn-nexus {
width: 100%;
justify-content: center;
}
}
.validation-message {
color: #ff4444;
font-size: 0.8rem;
margin-top: 0.25rem;
}
/* Split Layout for Creator Modal */
.creator-layout {
display: grid;
grid-template-columns: 140px 1fr;
gap: 2rem;
align-items: start;
margin-top: 0.5rem;
}
.creator-cover {
width: 140px;
height: 200px;
border-radius: 12px;
overflow: hidden;
background: linear-gradient(135deg, #1f2937 0%, #111827 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.4);
position: relative;
user-select: none;
box-sizing: border-box;
}
.cover-mockup-design {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 1.5rem 1rem;
box-sizing: border-box;
position: relative;
}
.cover-accent-line {
position: absolute;
top: 0;
left: 10px;
width: 2px;
height: 100%;
background: rgba(16, 185, 129, 0.2); /* green accent line */
box-shadow: 0 0 8px rgba(16, 185, 129, 0.15);
}
.cover-main-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 2;
margin-top: 1rem;
text-align: center;
}
.cover-title-text {
font-size: 0.8rem;
font-weight: 700;
color: #ffffff;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.cover-author-text {
font-size: 0.6rem;
color: #9ca3af;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cover-logo-container {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
z-index: 2;
color: #10b981;
opacity: 0.9;
}
.cover-logo-container svg {
width: 12px;
height: 12px;
fill: currentColor;
}
.cover-brand {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
.creator-form-inputs {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.actions {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
@@ -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)
const editorCache = new Map();
// Initialize global stores on window to share state across dynamically imported module instances (preventing cache-buster isolation)
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
@@ -23,19 +29,64 @@ async function ensureStylesheet(href) {
* Initializes a Milkdown Crepe editor on the specified element.
*/
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);
if (!container) {
console.error(`[Milkdown] Container with ID "${elementId}" not found.`);
editorStates.delete(elementId);
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 {
// Condition 2: Prevent FOUC by loading stylesheets before instantiating the editor
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
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
const Crepe = window.milkdownCrepe?.Crepe;
if (!Crepe) {
@@ -50,12 +101,11 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
[Crepe.Feature.ImageBlock]: {
onUpload: async (file) => {
try {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const url = await dotNetHelper.invokeMethodAsync('UploadImageFromJs', file.name, file.type, uint8Array);
const streamRef = DotNet.createJSStreamReference(file);
const url = await dotNetHelper.invokeMethodAsync('UploadImageFromJs', file.name, file.type, streamRef);
return url;
} catch (err) {
console.error("[Milkdown] Failed to upload image from JS:", err);
console.error("[Milkdown] Failed to upload image from JS (onUpload):", err);
throw err;
}
}
@@ -63,14 +113,68 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
}
});
// Configure custom uploader using the uploadConfig context slice
crepe.editor.config((ctx) => {
try {
ctx.update('uploadConfig', (prev) => ({
...prev,
uploader: async (files, schema) => {
const nodes = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith('image/')) {
try {
const streamRef = DotNet.createJSStreamReference(file);
const uploadedUrl = await dotNetHelper.invokeMethodAsync('UploadImageFromJs', file.name, file.type, streamRef);
if (uploadedUrl) {
const node = schema.nodes.image.create({ src: uploadedUrl, alt: file.name });
nodes.push(node);
}
} catch (err) {
console.error("[Milkdown] Failed to upload image in custom uploader:", err);
}
}
}
return nodes;
}
}));
} catch (err) {
console.error("[Milkdown] Failed to configure uploadConfig uploader:", err);
}
});
// Hook into the Crepe content update listener system with 300ms JS debounce
let debounceTimeout = null;
crepe.on((listener) => {
listener.markdownUpdated((ctx, markdown, prevMarkdown) => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
debounceTimeout = setTimeout(() => {
if (editorStates.get(elementId) === 'destroyed') return;
dotNetHelper.invokeMethodAsync('OnEditorContentChanged', markdown)
.catch(err => console.error("[Milkdown] Failed to notify editor content changed:", err));
}, 300);
});
});
// Store the editor instance in the map
editorCache.set(elementId, crepe);
// Create the editor view asynchronously
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}`);
} catch (error) {
editorStates.delete(elementId);
console.error(`[Milkdown] Failed to initialize editor on "${elementId}":`, error);
}
}
@@ -91,6 +195,8 @@ export function getMarkdownContent(elementId) {
* Safely disposes of the editor instance to prevent memory leaks in WASM.
*/
export async function destroyEditor(elementId) {
editorStates.set(elementId, 'destroyed');
const crepe = editorCache.get(elementId);
if (crepe) {
try {
@@ -101,4 +207,38 @@ export async function destroyEditor(elementId) {
}
editorCache.delete(elementId);
}
// Explicitly clean up container DOM children
const container = document.getElementById(elementId);
if (container) {
container.innerHTML = '';
}
}
/**
* Safely retrieves all localStorage keys starting with the "nexus-bkp-" prefix.
*/
export function getBackupKeys() {
const keys = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('nexus-bkp-')) {
keys.push(key);
}
}
} catch (err) {
console.error("[Milkdown] Error listing localStorage keys:", err);
}
return keys;
}
// Attach to window for global access (especially from DisposeAsync when module reference is null)
if (typeof window !== 'undefined') {
window.milkdownWrapper = {
initEditor,
getMarkdownContent,
destroyEditor,
getBackupKeys
};
}
+203 -27
View File
@@ -91,6 +91,10 @@ builder.Services.AddCascadingAuthenticationState();
builder.Services.AddApplication();
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(
NexusReader.Application.DependencyInjection.Assembly,
@@ -295,6 +299,7 @@ if (!allowRegistration || !allowPasswordReset)
}
app.MapStaticAssets();
app.MapHealthChecks("/health");
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
// API endpoint for WASM client to fetch EPUB content
@@ -493,6 +498,132 @@ app.MapGet("/api/library/books", async (ClaimsPrincipal user, IMediator mediator
return Results.BadRequest(errorMsg);
}).RequireAuthorization();
app.MapGet("/api/creator/dashboard", async (ClaimsPrincipal user, IMediator mediator) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetCreatorDashboardDataQuery(userId, tenantId));
if (result.IsSuccess) return Results.Ok(result.Value);
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
return Results.BadRequest(errorMsg);
}).RequireAuthorization();
app.MapGet("/api/creator/books/{bookId:guid}/revisions", async (Guid bookId, ClaimsPrincipal user, IMediator mediator) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetBookRevisionsQuery(bookId, userId, tenantId));
if (result.IsSuccess) return Results.Ok(result.Value);
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
{
return Results.NotFound(errorMsg);
}
return Results.BadRequest(errorMsg);
}).RequireAuthorization();
app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [FromQuery] string version, ClaimsPrincipal user, IMediator mediator) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
var tenantId = user.FindFirstValue("TenantId") ?? "global";
if (string.IsNullOrWhiteSpace(version))
{
return Results.BadRequest("Version string is required.");
}
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand(bookId, version, userId, tenantId));
if (result.IsSuccess) return Results.Ok();
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
{
return Results.NotFound(errorMsg);
}
return Results.BadRequest(errorMsg);
}).RequireAuthorization();
app.MapPost("/api/creator/books", async (
[FromBody] NexusReader.Application.DTOs.Creator.CreateBookRequestDto request,
ClaimsPrincipal user,
IMediator mediator) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.CreateBookCommand(
request.Title,
request.Description,
userId,
tenantId
));
if (result.IsSuccess)
{
return Results.Ok(new NexusReader.Application.DTOs.Creator.CreateBookResponseDto(result.Value));
}
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
return Results.BadRequest(errorMsg);
}).RequireAuthorization();
app.MapGet("/api/creator/books/{bookId:guid}/chapters", async (Guid bookId, ClaimsPrincipal user, IDbContextFactory<AppDbContext> dbContextFactory) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
using var dbContext = await dbContextFactory.CreateDbContextAsync();
var book = await dbContext.Books
.Include(b => b.CurrentDraftRevision)
.ThenInclude(r => r!.Chapters)
.FirstOrDefaultAsync(b => b.Id == bookId && b.UserId == userId);
if (book == null) return Results.NotFound();
if (book.CurrentDraftRevision == null) return Results.BadRequest("No active draft revision.");
var chapters = book.CurrentDraftRevision.Chapters
.OrderBy(c => c.SortOrder)
.Select(c => new { c.Id, c.Title, c.SortOrder })
.ToList();
return Results.Ok(chapters);
}).RequireAuthorization();
app.MapGet("/api/chapters/{id:guid}", async (Guid id, ClaimsPrincipal user, IDbContextFactory<AppDbContext> dbContextFactory) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
using var dbContext = await dbContextFactory.CreateDbContextAsync();
var chapter = await dbContext.Chapters
.Include(c => c.BookRevision)
.ThenInclude(r => r.Book)
.FirstOrDefaultAsync(c => c.Id == id);
if (chapter == null) return Results.NotFound();
// Verify ownership
if (chapter.BookRevision.Book.UserId != userId)
{
return Results.Forbid();
}
return Results.Ok(new { chapter.Id, chapter.Title, chapter.MarkdownContent });
}).RequireAuthorization();
app.MapPost("/api/library/purchase", async (
ClaimsPrincipal user,
[FromBody] PurchaseBookRequest request,
@@ -802,15 +933,15 @@ app.MapPost("/api/media/upload", async (
fileBytes = memoryStream.ToArray();
}
// Validate signature
if (!ValidateImageSignature(fileBytes, file.ContentType))
// Validate signature without trusting browser content-type, enforcing extension matching
if (!ImageValidator.ValidateImageSignature(fileBytes, file.FileName, out var detectedContentType))
{
logger.LogWarning("File signature validation failed for file {FileName} with content type {ContentType}.", file.FileName, file.ContentType);
return Results.BadRequest("Invalid image signature. Legitimate JPEG, PNG, or WEBP images only.");
logger.LogWarning("File signature validation failed for file {FileName} with browser content type {ContentType}.", file.FileName, file.ContentType);
return Results.BadRequest("Invalid file signature or extension mismatch. Legitimate JPEG, PNG, WEBP, or GIF images only.");
}
// Save using IStorageService
var fileUrl = await storageService.UploadFileAsync(fileBytes, file.FileName, file.ContentType);
// Save using IStorageService with the verified content type
var fileUrl = await storageService.UploadFileAsync(fileBytes, file.FileName, detectedContentType);
return Results.Ok(new NexusReader.Application.DTOs.Media.UploadResultDto(fileUrl));
}).DisableAntiforgery();
@@ -827,6 +958,27 @@ app.MapPost("/api/chapters/validate", (
return Results.Ok(new NexusReader.Application.DTOs.Media.ValidateChapterResponse(sanitized));
}).DisableAntiforgery();
app.MapPut("/api/chapters/{id:guid}/autosave", async (
Guid id,
[Microsoft.AspNetCore.Mvc.FromBody] NexusReader.Application.DTOs.Media.AutosaveChapterRequest request,
IDbContextFactory<AppDbContext> dbContextFactory,
ILoggerFactory loggerFactory) =>
{
var logger = loggerFactory.CreateLogger("ChaptersApi");
logger.LogInformation("Autosaving chapter {ChapterId} with content length {Length}", id, request?.MarkdownContent?.Length ?? 0);
if (request == null) return Results.BadRequest("Request content cannot be null.");
using var dbContext = await dbContextFactory.CreateDbContextAsync();
var chapter = await dbContext.Chapters.FindAsync(id);
if (chapter == null) return Results.NotFound($"Chapter with ID '{id}' was not found.");
chapter.MarkdownContent = request.MarkdownContent;
await dbContext.SaveChangesAsync();
return Results.Ok(new { Success = true });
}).DisableAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
@@ -878,32 +1030,56 @@ async Task TriggerBackgroundProcessingForUnindexedBooksAsync(IServiceProvider se
}
}
static bool ValidateImageSignature(byte[] bytes, string contentType)
public static class ImageValidator
{
if (bytes.Length < 4) return false;
// Check PNG signature: 89 50 4E 47
if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
public static bool ValidateImageSignature(byte[] bytes, string fileName, out string detectedContentType)
{
return contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase);
}
detectedContentType = string.Empty;
if (bytes.Length < 4) return false;
// Check JPEG signature: FF D8 FF
if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
{
return contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) ||
contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase);
}
// Check PNG signature: 89 50 4E 47
if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
{
detectedContentType = "image/png";
}
// Check JPEG signature: FF D8 FF
else if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
{
detectedContentType = "image/jpeg";
}
// Check WEBP signature: RIFF ... WEBP
else if (bytes.Length >= 12 &&
bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && // RIFF
bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) // WEBP
{
detectedContentType = "image/webp";
}
// Check GIF signature: GIF87a or GIF89a
else if (bytes.Length >= 6 &&
bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38 &&
(bytes[4] == 0x37 || bytes[4] == 0x39) && bytes[5] == 0x61)
{
detectedContentType = "image/gif";
}
// Check WEBP signature: RIFF ... WEBP
if (bytes.Length >= 12 &&
bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && // RIFF
bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) // WEBP
{
return contentType.Equals("image/webp", StringComparison.OrdinalIgnoreCase);
}
if (string.IsNullOrEmpty(detectedContentType))
{
return false;
}
return false;
// Verify that the file extension matches the detected content type (extension-spoofing guard)
var ext = Path.GetExtension(fileName).ToLowerInvariant();
var isMatch = detectedContentType switch
{
"image/png" => ext == ".png",
"image/jpeg" => ext == ".jpg" || ext == ".jpeg",
"image/webp" => ext == ".webp",
"image/gif" => ext == ".gif",
_ => false
};
return isMatch;
}
}
public record KnowledgeRequest(string Text, Guid? EbookId = null);
@@ -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);
}
}
}
@@ -0,0 +1,173 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Moq;
using NexusReader.Application.Features.Books.Commands;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
using Xunit;
namespace NexusReader.Application.Tests.Commands;
public class CreateBookTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<AppDbContext> _contextOptions;
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
public CreateBookTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
_contextOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
// Seed initial database schema
using var context = new AppDbContext(_contextOptions);
context.Database.EnsureCreated();
_dbContextFactoryMock = new Mock<IDbContextFactory<AppDbContext>>();
_dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new AppDbContext(_contextOptions));
}
private NexusUser SeedUser(string userId, string tenantId)
{
var user = new NexusUser
{
Id = userId,
UserName = $"user_{userId}",
Email = $"{userId}@example.com",
TenantId = tenantId,
SubscriptionPlanId = 1
};
using var context = new AppDbContext(_contextOptions);
context.Users.Add(user);
context.SaveChanges();
return user;
}
[Fact]
public async Task Handle_WithValidCommand_SuccessfullyCreatesBookRevisionAndIntroductionChapter()
{
// Arrange
var userId = "creator-123";
var tenantId = "tenant-abc";
SeedUser(userId, tenantId);
var command = new CreateBookCommand(
Title: "The Art of Agentic Systems",
Description: "A masterclass on building self-healing AI agents.",
UserId: userId,
TenantId: tenantId
);
var handler = new CreateBookCommandHandler(_dbContextFactoryMock.Object);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeEmpty();
using (var context = new AppDbContext(_contextOptions))
{
var book = await context.Books
.Include(b => b.CurrentDraftRevision)
.ThenInclude(r => r!.Chapters)
.FirstOrDefaultAsync(b => b.Id == result.Value);
book.Should().NotBeNull();
book!.Title.Should().Be("The Art of Agentic Systems");
book.UserId.Should().Be(userId);
book.TenantId.Should().Be(tenantId);
book.CurrentDraftRevisionId.Should().NotBeNull();
var revision = book.CurrentDraftRevision;
revision.Should().NotBeNull();
revision!.VersionString.Should().Be("Working Draft");
revision.IsPublished.Should().BeFalse();
revision.BookId.Should().Be(book.Id);
revision.Chapters.Should().HaveCount(1);
var chapter = revision.Chapters.First();
chapter.Title.Should().Be("Introduction");
chapter.MarkdownContent.Should().Be("# Introduction\nStart writing here...");
chapter.SortOrder.Should().Be(1);
}
}
[Fact]
public async Task Handle_WithEmptyTitle_ReturnsFailureResult()
{
// Arrange
var command = new CreateBookCommand(
Title: "",
Description: "No title",
UserId: "user-1",
TenantId: "tenant-1"
);
var handler = new CreateBookCommandHandler(_dbContextFactoryMock.Object);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().NotBeEmpty();
result.Errors.First().Message.Should().Contain("title is required");
}
[Fact]
public async Task Handle_OnDatabaseViolation_RollsBackTransaction()
{
// Arrange
// We trigger a database violation by not seeding the user 'missing-user'
// and letting the foreign key constraint fail (if SQLite enforces it).
// If foreign keys aren't strictly enforced on SQLite by default without PRAGMA,
// we can check if it rolls back upon other violations, or manually verify error handling.
var command = new CreateBookCommand(
Title: "Violating Book",
Description: "Triggering constraint failure",
UserId: "non-existent-user-id-constraint",
TenantId: "tenant-1"
);
// Let's force foreign key constraints on SQLite to verify rollback
using (var context = new AppDbContext(_contextOptions))
{
context.Database.ExecuteSqlRaw("PRAGMA foreign_keys = ON;");
}
var handler = new CreateBookCommandHandler(_dbContextFactoryMock.Object);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().NotBeEmpty();
// Ensure nothing was committed to the DB
using (var context = new AppDbContext(_contextOptions))
{
var books = await context.Books.ToListAsync();
books.Should().BeEmpty();
}
}
public void Dispose()
{
_connection.Close();
_connection.Dispose();
}
}
@@ -0,0 +1,297 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Moq;
using NexusReader.Application.Features.Books.Commands;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
using NexusReader.Domain.Exceptions;
using Xunit;
namespace NexusReader.Application.Tests.Commands;
public class PublishBookVersionTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<AppDbContext> _contextOptions;
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
public PublishBookVersionTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
_contextOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
// Seed initial database schema
using var context = new AppDbContext(_contextOptions);
context.Database.EnsureCreated();
_dbContextFactoryMock = new Mock<IDbContextFactory<AppDbContext>>();
_dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new AppDbContext(_contextOptions));
}
[Fact]
public async Task Handle_WithValidBookAndChapters_CorrectlyPublishesAndClonesChaptersWithNewGuids()
{
// Arrange
var bookId = Guid.NewGuid();
var userId = "test-user-123";
var tenantId = "test-tenant-456";
var user = new NexusUser
{
Id = userId,
UserName = "testuser",
Email = "test@example.com",
TenantId = tenantId,
SubscriptionPlanId = 1
};
var book = new Book
{
Id = bookId,
Title = "My Epic Book",
UserId = userId,
TenantId = tenantId
};
var originalDraftRevision = new BookRevision
{
Id = Guid.NewGuid(),
BookId = bookId,
VersionString = "Working Draft",
IsPublished = false,
CreatedAt = DateTime.UtcNow
};
var oldChapterId1 = Guid.NewGuid();
var oldChapterId2 = Guid.NewGuid();
var chapter1 = new Chapter
{
Id = oldChapterId1,
BookRevisionId = originalDraftRevision.Id,
Title = "Chapter 1: The Beginning",
MarkdownContent = "Once upon a time...",
SortOrder = 1
};
var chapter2 = new Chapter
{
Id = oldChapterId2,
BookRevisionId = originalDraftRevision.Id,
Title = "Chapter 2: The Middle",
MarkdownContent = "Interesting things happened.",
SortOrder = 2
};
using (var context = new AppDbContext(_contextOptions))
{
context.Users.Add(user);
context.Books.Add(book);
context.BookRevisions.Add(originalDraftRevision);
context.Chapters.Add(chapter1);
context.Chapters.Add(chapter2);
await context.SaveChangesAsync();
// Link the book's draft revision
var dbBook = await context.Books.FindAsync(bookId);
dbBook!.CurrentDraftRevisionId = originalDraftRevision.Id;
await context.SaveChangesAsync();
}
var command = new PublishBookVersionCommand(
BookId: bookId,
CustomVersionString: "v1.0.0",
UserId: userId,
TenantId: tenantId
);
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
using (var context = new AppDbContext(_contextOptions))
{
var updatedBook = await context.Books
.Include(b => b.Revisions)
.ThenInclude(r => r.Chapters)
.FirstOrDefaultAsync(b => b.Id == bookId);
updatedBook.Should().NotBeNull();
updatedBook!.LivePublishedRevisionId.Should().Be(originalDraftRevision.Id);
updatedBook.CurrentDraftRevisionId.Should().NotBeNull();
updatedBook.CurrentDraftRevisionId.Should().NotBe(originalDraftRevision.Id);
// Fetch the old draft revision (now frozen / published)
var oldDraft = updatedBook.Revisions.FirstOrDefault(r => r.Id == originalDraftRevision.Id);
oldDraft.Should().NotBeNull();
oldDraft!.IsPublished.Should().BeTrue();
oldDraft.VersionString.Should().Be("v1.0.0");
oldDraft.PublishedAt.Should().NotBeNull();
// Fetch the new working draft revision
var newDraft = updatedBook.Revisions.FirstOrDefault(r => r.Id == updatedBook.CurrentDraftRevisionId);
newDraft.Should().NotBeNull();
newDraft!.IsPublished.Should().BeFalse();
newDraft.VersionString.Should().Be("Working Draft");
// Verify chapters were deep copied and received brand new GUIDs (Identity Reset)
newDraft.Chapters.Should().HaveCount(2);
var clonedChapter1 = newDraft.Chapters.FirstOrDefault(c => c.SortOrder == 1);
clonedChapter1.Should().NotBeNull();
clonedChapter1!.Title.Should().Be("Chapter 1: The Beginning");
clonedChapter1.MarkdownContent.Should().Be("Once upon a time...");
clonedChapter1.Id.Should().NotBe(oldChapterId1); // GUID must be regenerated
clonedChapter1.BookRevisionId.Should().Be(newDraft.Id);
var clonedChapter2 = newDraft.Chapters.FirstOrDefault(c => c.SortOrder == 2);
clonedChapter2.Should().NotBeNull();
clonedChapter2!.Title.Should().Be("Chapter 2: The Middle");
clonedChapter2.MarkdownContent.Should().Be("Interesting things happened.");
clonedChapter2.Id.Should().NotBe(oldChapterId2); // GUID must be regenerated
clonedChapter2.BookRevisionId.Should().Be(newDraft.Id);
}
}
[Fact]
public async Task Handle_WithMismatchedTenantId_ReturnsFailure()
{
// Arrange
var bookId = Guid.NewGuid();
var userId = "test-user-123";
var tenantId = "test-tenant-456";
var user = new NexusUser
{
Id = userId,
UserName = "testuser",
Email = "test@example.com",
TenantId = tenantId,
SubscriptionPlanId = 1
};
var book = new Book
{
Id = bookId,
Title = "My Epic Book",
UserId = userId,
TenantId = tenantId
};
using (var context = new AppDbContext(_contextOptions))
{
context.Users.Add(user);
context.Books.Add(book);
await context.SaveChangesAsync();
}
// Send command with a different TenantId to check multi-tenancy isolation
var command = new PublishBookVersionCommand(
BookId: bookId,
CustomVersionString: "v1.0.0",
UserId: userId,
TenantId: "different-tenant-789"
);
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act
var result = await handler.Handle(command, CancellationToken.None);
mjasin marked this conversation as resolved Outdated
Outdated
Review

🟡 Design/Architecture: Update test for Result Pattern alignment

Once the handler is refactored to return Result.Fail instead of throwing BookNotFoundException, update this test to assert failure on the returned result rather than expecting an exception.

Suggested Fix:

var result = await handler.Handle(command, CancellationToken.None);
result.IsSuccess.Should().BeFalse();
🟡 Design/Architecture: Update test for Result Pattern alignment Once the handler is refactored to return `Result.Fail` instead of throwing `BookNotFoundException`, update this test to assert failure on the returned result rather than expecting an exception. **Suggested Fix:** ```csharp var result = await handler.Handle(command, CancellationToken.None); result.IsSuccess.Should().BeFalse(); ```
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
}
[Fact]
public async Task Handle_WithMismatchedUserId_ReturnsFailure()
{
// Arrange
var bookId = Guid.NewGuid();
var userId = "test-user-123";
var tenantId = "test-tenant-456";
var user = new NexusUser
{
Id = userId,
UserName = "testuser",
Email = "test@example.com",
TenantId = tenantId,
SubscriptionPlanId = 1
};
var book = new Book
{
Id = bookId,
Title = "My Epic Book",
UserId = userId,
TenantId = tenantId
};
using (var context = new AppDbContext(_contextOptions))
{
context.Users.Add(user);
context.Books.Add(book);
await context.SaveChangesAsync();
}
// Send command with a different UserId to check multi-tenancy isolation
var command = new PublishBookVersionCommand(
BookId: bookId,
CustomVersionString: "v1.0.0",
UserId: "different-user-789",
TenantId: tenantId
);
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
}
[Fact]
public async Task Handle_WithNonExistentBook_ReturnsFailure()
{
// Arrange
var command = new PublishBookVersionCommand(
BookId: Guid.NewGuid(),
CustomVersionString: "v1.0.0",
UserId: "user-1",
TenantId: "tenant-1"
);
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
}
public void Dispose()
{
_connection.Close();
_connection.Dispose();
}
}
@@ -17,5 +17,6 @@
<ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" />
<ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
<ProjectReference Include="..\..\src\NexusReader.Web\NexusReader.Web.csproj" />
</ItemGroup>
</Project>
@@ -10,7 +10,7 @@ namespace NexusReader.Application.Tests.Queries;
public class CheckDatabaseTest
{
[Fact]
[Fact(Skip = "Requires live Postgres database in Docker")]
public async Task PrintDatabaseStats()
{
var configJson = await File.ReadAllTextAsync("../../../../../src/NexusReader.Web/appsettings.json");
@@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Moq;
using NexusReader.Application.Queries.Creator;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
using NexusReader.Domain.Exceptions;
using Xunit;
namespace NexusReader.Application.Tests.Queries;
public class CreatorDashboardTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<AppDbContext> _contextOptions;
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
public CreatorDashboardTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
_contextOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
// Seed initial database schema
using var context = new AppDbContext(_contextOptions);
context.Database.EnsureCreated();
_dbContextFactoryMock = new Mock<IDbContextFactory<AppDbContext>>();
_dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new AppDbContext(_contextOptions));
}
private NexusUser CreateTestUser(string userId, string tenantId)
{
return new NexusUser
{
Id = userId,
UserName = $"user_{userId}",
Email = $"{userId}@example.com",
TenantId = tenantId,
SubscriptionPlanId = 1
};
}
[Fact]
public async Task GetCreatorDashboardData_WithValidUser_ProjectsCorrectlyAndNeverLoadsMarkdownToTracker()
{
// Arrange
var userId = "creator-123";
var tenantId = "tenant-abc";
var bookId = Guid.NewGuid();
var user = CreateTestUser(userId, tenantId);
var book = new Book
{
Id = bookId,
Title = "Authored Masterpiece",
UserId = userId,
TenantId = tenantId
};
var draft = new BookRevision
{
Id = Guid.NewGuid(),
BookId = bookId,
VersionString = "Working Draft",
IsPublished = false,
CreatedAt = DateTime.UtcNow
};
// Standard markdown content (length 58 characters -> estimated word count: 9 words)
var chapter = new Chapter
{
Id = Guid.NewGuid(),
BookRevisionId = draft.Id,
Title = "Chapter One",
MarkdownContent = "This is a content snippet that contains exactly ten words.", // 58 chars
SortOrder = 1
};
using (var context = new AppDbContext(_contextOptions))
{
context.Users.Add(user);
context.Books.Add(book);
context.BookRevisions.Add(draft);
context.Chapters.Add(chapter);
await context.SaveChangesAsync();
// Link draft revision
var dbBook = await context.Books.FindAsync(bookId);
dbBook!.CurrentDraftRevisionId = draft.Id;
await context.SaveChangesAsync();
}
var query = new GetCreatorDashboardDataQuery(userId, tenantId);
var handler = new GetCreatorDashboardDataQueryHandler(_dbContextFactoryMock.Object);
// Act
var result = await handler.Handle(query, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();
result.Value.Books.Should().HaveCount(1);
var bookDto = result.Value.Books.First();
bookDto.Title.Should().Be("Authored Masterpiece");
bookDto.WordCount.Should().Be(58 / 6); // projected word count calculation check
bookDto.AggregatedReads.Should().Be(Math.Abs(bookId.GetHashCode() % 1000) + 120);
// Verify metrics are calculated
result.Value.Metrics.TotalReads.Should().Be(bookDto.AggregatedReads);
result.Value.Metrics.ActiveReaders.Should().BeGreaterThan(0);
result.Value.Metrics.GrossRevenue.Should().Be(bookDto.AggregatedReads * 1.49m);
result.Value.Metrics.AvgReadTimeMinutes.Should().Be(Math.Round((58 / 6) / 250.0, 1));
}
[Fact]
public async Task GetCreatorDashboardData_EnforcesTenantAndUserBoundaries()
{
// Arrange
var userId = "creator-123";
var tenantId = "tenant-abc";
var bookId = Guid.NewGuid();
var user = CreateTestUser(userId, tenantId);
var book = new Book
{
Id = bookId,
Title = "Authored Masterpiece",
UserId = userId,
TenantId = tenantId
};
using (var context = new AppDbContext(_contextOptions))
{
context.Users.Add(user);
context.Books.Add(book);
await context.SaveChangesAsync();
}
// Query with mismatched tenant ID
var queryMismatchedTenant = new GetCreatorDashboardDataQuery(userId, "different-tenant");
var handler = new GetCreatorDashboardDataQueryHandler(_dbContextFactoryMock.Object);
// Act
var resultMismatchedTenant = await handler.Handle(queryMismatchedTenant, CancellationToken.None);
// Assert
resultMismatchedTenant.IsSuccess.Should().BeTrue();
resultMismatchedTenant.Value.Books.Should().BeEmpty();
resultMismatchedTenant.Value.Metrics.TotalReads.Should().Be(0);
// Query with mismatched user ID
var queryMismatchedUser = new GetCreatorDashboardDataQuery("different-user", tenantId);
// Act
var resultMismatchedUser = await handler.Handle(queryMismatchedUser, CancellationToken.None);
// Assert
resultMismatchedUser.IsSuccess.Should().BeTrue();
resultMismatchedUser.Value.Books.Should().BeEmpty();
}
[Fact]
public async Task GetBookRevisions_WithValidBook_ReturnsRevisionsOrderedByDate()
{
// Arrange
var userId = "creator-123";
var tenantId = "tenant-abc";
var bookId = Guid.NewGuid();
var user = CreateTestUser(userId, tenantId);
var book = new Book
{
Id = bookId,
Title = "Authored Masterpiece",
UserId = userId,
TenantId = tenantId
};
var revision1 = new BookRevision
{
Id = Guid.NewGuid(),
BookId = bookId,
VersionString = "v1.0.0",
IsPublished = true,
CreatedAt = DateTime.UtcNow.AddMinutes(-5),
PublishedAt = DateTime.UtcNow.AddMinutes(-5)
};
var revision2 = new BookRevision
{
Id = Guid.NewGuid(),
BookId = bookId,
VersionString = "Working Draft",
IsPublished = false,
CreatedAt = DateTime.UtcNow
};
using (var context = new AppDbContext(_contextOptions))
{
context.Users.Add(user);
context.Books.Add(book);
context.BookRevisions.Add(revision1);
context.BookRevisions.Add(revision2);
await context.SaveChangesAsync();
}
var query = new GetBookRevisionsQuery(bookId, userId, tenantId);
var handler = new GetBookRevisionsQueryHandler(_dbContextFactoryMock.Object);
// Act
var result = await handler.Handle(query, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(2);
// Ordered by CreatedAt descending
result.Value[0].VersionString.Should().Be("Working Draft");
result.Value[1].VersionString.Should().Be("v1.0.0");
}
[Fact]
public async Task GetBookRevisions_WithMismatchedUserOrTenant_ReturnsFailure()
{
// Arrange
var userId = "creator-123";
var tenantId = "tenant-abc";
var bookId = Guid.NewGuid();
var user = CreateTestUser(userId, tenantId);
var book = new Book
{
Id = bookId,
Title = "Authored Masterpiece",
UserId = userId,
TenantId = tenantId
};
using (var context = new AppDbContext(_contextOptions))
{
context.Users.Add(user);
context.Books.Add(book);
await context.SaveChangesAsync();
}
var handler = new GetBookRevisionsQueryHandler(_dbContextFactoryMock.Object);
// Act & Assert
var queryMismatchedTenant = new GetBookRevisionsQuery(bookId, userId, "different-tenant");
var resultTenant = await handler.Handle(queryMismatchedTenant, CancellationToken.None);
resultTenant.IsSuccess.Should().BeFalse();
mjasin marked this conversation as resolved Outdated
Outdated
Review

🟡 Design/Architecture: Update test for Result Pattern alignment

Update this test to assert failure on the returned result rather than expecting an exception once the query handler is refactored.

Suggested Fix:

var result = await handler.Handle(queryMismatchedTenant, CancellationToken.None);
result.IsSuccess.Should().BeFalse();
🟡 Design/Architecture: Update test for Result Pattern alignment Update this test to assert failure on the returned result rather than expecting an exception once the query handler is refactored. **Suggested Fix:** ```csharp var result = await handler.Handle(queryMismatchedTenant, CancellationToken.None); result.IsSuccess.Should().BeFalse(); ```
resultTenant.Errors.Should().Contain(e => e.Message.Contains("was not found"));
var queryMismatchedUser = new GetBookRevisionsQuery(bookId, "different-user", tenantId);
var resultUser = await handler.Handle(queryMismatchedUser, CancellationToken.None);
resultUser.IsSuccess.Should().BeFalse();
resultUser.Errors.Should().Contain(e => e.Message.Contains("was not found"));
}
public void Dispose()
{
_connection.Close();
_connection.Dispose();
}
}
@@ -0,0 +1,61 @@
using System.Text.Json;
using FluentAssertions;
using NexusReader.Application.Common;
using NexusReader.Application.DTOs.Media;
using Xunit;
namespace NexusReader.Application.Tests.Services;
public class AutosaveEngineTests
{
[Fact]
public void SerializeAndDeserialize_LocalBackupEnvelope_Succeeds()
{
// Arrange
var envelope = new LocalBackupEnvelope
{
ChapterId = Guid.NewGuid(),
Timestamp = DateTime.UtcNow.AddMinutes(-10),
MarkdownContent = "# Hello Autosave"
};
// Act
var json = JsonSerializer.Serialize(envelope, AppJsonContext.Default.LocalBackupEnvelope);
var deserialized = JsonSerializer.Deserialize(json, AppJsonContext.Default.LocalBackupEnvelope);
// Assert
deserialized.Should().NotBeNull();
deserialized!.ChapterId.Should().Be(envelope.ChapterId);
deserialized.MarkdownContent.Should().Be(envelope.MarkdownContent);
// Truncate milliseconds to avoid precision discrepancies in text representation
deserialized.Timestamp.ToUniversalTime().Date.Should().Be(envelope.Timestamp.ToUniversalTime().Date);
}
[Fact]
public void SerializeAndDeserialize_AutosaveChapterRequest_Succeeds()
{
// Arrange
var request = new AutosaveChapterRequest("# Content to Autosave");
// Act
var json = JsonSerializer.Serialize(request, AppJsonContext.Default.AutosaveChapterRequest);
var deserialized = JsonSerializer.Deserialize(json, AppJsonContext.Default.AutosaveChapterRequest);
// Assert
deserialized.Should().NotBeNull();
deserialized!.MarkdownContent.Should().Be(request.MarkdownContent);
}
[Fact]
public void BackupEviction_CheckAgeLogic_EvictsCorrectly()
{
// Arrange
var now = DateTime.UtcNow;
var freshTimestamp = now.AddDays(-6);
var expiredTimestamp = now.AddDays(-8);
// Act & Assert
(now - freshTimestamp).TotalDays.Should().BeLessThanOrEqualTo(7.0);
(now - expiredTimestamp).TotalDays.Should().BeGreaterThan(7.0);
}
}
@@ -51,4 +51,20 @@ public class HtmlSanitizerServiceTests
result.Should().NotContain("alert");
result.Should().Contain("<img src=\"x\">");
}
[Fact]
public void Sanitize_WithMarkdownCodeBlockContainingAngleBrackets_DoesNotStripAngleBrackets()
{
// Arrange
var service = new HtmlSanitizerService();
var input = "Here is some code:\n\n```csharp\nif (x < y && y > z) { Console.WriteLine(\"test\"); }\n```";
// Act
var result = service.Sanitize(input);
// Assert
result.Should().Contain("&lt;");
result.Should().Contain("&gt;");
result.Should().NotContain("<script>");
}
}
@@ -0,0 +1,118 @@
using FluentAssertions;
using Xunit;
namespace NexusReader.Application.Tests.Services;
public class ValidateImageSignatureTests
{
[Fact]
public void Validate_PNG_WithCorrectSignature_ReturnsTrue()
{
// Arrange
byte[] pngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
string fileName = "image.png";
// Act
bool isValid = ImageValidator.ValidateImageSignature(pngBytes, fileName, out string contentType);
// Assert
isValid.Should().BeTrue();
contentType.Should().Be("image/png");
}
[Fact]
public void Validate_JPEG_WithCorrectSignature_ReturnsTrue()
{
// Arrange
byte[] jpegBytes = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46];
string fileName = "photo.jpg";
// Act
bool isValid = ImageValidator.ValidateImageSignature(jpegBytes, fileName, out string contentType);
// Assert
isValid.Should().BeTrue();
contentType.Should().Be("image/jpeg");
}
[Fact]
public void Validate_WEBP_WithCorrectSignature_ReturnsTrue()
{
// Arrange
byte[] webpBytes = [
0x52, 0x49, 0x46, 0x46, // RIFF
0x00, 0x00, 0x00, 0x00, // length
0x57, 0x45, 0x42, 0x50 // WEBP
];
string fileName = "graphic.webp";
// Act
bool isValid = ImageValidator.ValidateImageSignature(webpBytes, fileName, out string contentType);
// Assert
isValid.Should().BeTrue();
contentType.Should().Be("image/webp");
}
[Fact]
public void Validate_GIF_WithCorrectSignature_ReturnsTrue()
{
// Arrange
byte[] gifBytes = [
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a
0x01, 0x00, 0x01, 0x00
];
string fileName = "animation.gif";
// Act
bool isValid = ImageValidator.ValidateImageSignature(gifBytes, fileName, out string contentType);
// Assert
isValid.Should().BeTrue();
contentType.Should().Be("image/gif");
}
[Fact]
public void Validate_WithMismatchingExtension_ReturnsFalse()
{
// Arrange: Valid PNG bytes but JPEG extension
byte[] pngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
string fileName = "spoofed.jpg";
// Act
bool isValid = ImageValidator.ValidateImageSignature(pngBytes, fileName, out string contentType);
// Assert
isValid.Should().BeFalse();
}
[Fact]
public void Validate_WithInvalidSignature_ReturnsFalse()
{
// Arrange: Plain text bytes but PNG extension
byte[] txtBytes = [0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0x77, 0x6F]; // "Hello wo"
string fileName = "not_a_png.png";
// Act
bool isValid = ImageValidator.ValidateImageSignature(txtBytes, fileName, out string contentType);
// Assert
isValid.Should().BeFalse();
contentType.Should().BeEmpty();
}
[Fact]
public void Validate_WithShortBytes_ReturnsFalse()
{
// Arrange
byte[] shortBytes = [0x89, 0x50];
string fileName = "short.png";
// Act
bool isValid = ImageValidator.ValidateImageSignature(shortBytes, fileName, out string contentType);
// Assert
isValid.Should().BeFalse();
contentType.Should().BeEmpty();
}
}