diff --git a/Directory.Packages.props b/Directory.Packages.props index c9be3b9..f7f6319 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/Dockerfile b/Dockerfile index b07de47..88400e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 . diff --git a/GEMINI.md b/GEMINI.md index 1eb465d..bb586a1 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -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`). + diff --git a/docker-compose.stage.yml b/docker-compose.stage.yml index 66b9864..03bcec1 100644 --- a/docker-compose.stage.yml +++ b/docker-compose.stage.yml @@ -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} diff --git a/run-stage.sh b/run-stage.sh new file mode 100755 index 0000000..c189c36 --- /dev/null +++ b/run-stage.sh @@ -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 "--------------------------------------------------------" diff --git a/src/NexusReader.Application/Common/AppJsonContext.cs b/src/NexusReader.Application/Common/AppJsonContext.cs index a8323a4..e4c1323 100644 --- a/src/NexusReader.Application/Common/AppJsonContext.cs +++ b/src/NexusReader.Application/Common/AppJsonContext.cs @@ -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))] +[JsonSerializable(typeof(List))] public partial class AppJsonContext : JsonSerializerContext { } diff --git a/src/NexusReader.Application/DTOs/Creator/CreatorDtos.cs b/src/NexusReader.Application/DTOs/Creator/CreatorDtos.cs new file mode 100644 index 0000000..7bfa949 --- /dev/null +++ b/src/NexusReader.Application/DTOs/Creator/CreatorDtos.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; + +namespace NexusReader.Application.DTOs.Creator; + +/// +/// Telemetry metrics for the Creator Dashboard. +/// +public record DashboardMetricsDto( + int TotalReads, + double AvgReadTimeMinutes, + int ActiveReaders, + decimal GrossRevenue +); + +/// +/// Lightweight revision details for the Creator Dashboard. +/// +public record CreatorBookRevisionDto( + Guid Id, + string VersionString, + bool IsPublished, + DateTime CreatedAt, + DateTime? PublishedAt +); + +/// +/// Lightweight book publication details for the Creator Dashboard. +/// +public record CreatorBookDto( + Guid Id, + string Title, + int WordCount, + int AggregatedReads, + Guid? FirstChapterId, + CreatorBookRevisionDto? LivePublishedRevision, + CreatorBookRevisionDto? CurrentDraftRevision +); + +/// +/// Root data envelope for Creator Dashboard loading. +/// +public record CreatorDashboardDataDto( + DashboardMetricsDto Metrics, + List Books +); + +/// +/// Request DTO for creating a new Book. +/// +public record CreateBookRequestDto( + string Title, + string? Description +); + +/// +/// Response DTO for creating a new Book. +/// +public record CreateBookResponseDto( + Guid BookId +); diff --git a/src/NexusReader.Application/DTOs/Media/MediaDtos.cs b/src/NexusReader.Application/DTOs/Media/MediaDtos.cs index 8553e3a..ce6f5c4 100644 --- a/src/NexusReader.Application/DTOs/Media/MediaDtos.cs +++ b/src/NexusReader.Application/DTOs/Media/MediaDtos.cs @@ -16,3 +16,18 @@ public record ValidateChapterResponse(string SanitizedContent); /// Response DTO containing the uploaded media file URL. /// public record UploadResultDto(string Url); + +/// +/// Represents a structured JSON backup envelope stored in LocalStorage. +/// +public class LocalBackupEnvelope +{ + public Guid ChapterId { get; set; } + public DateTime Timestamp { get; set; } + public string MarkdownContent { get; set; } = string.Empty; +} + +/// +/// Request DTO for chapter autosaving. +/// +public record AutosaveChapterRequest(string MarkdownContent); diff --git a/src/NexusReader.Application/Features/Books/Commands/CreateBookCommand.cs b/src/NexusReader.Application/Features/Books/Commands/CreateBookCommand.cs new file mode 100644 index 0000000..603caf1 --- /dev/null +++ b/src/NexusReader.Application/Features/Books/Commands/CreateBookCommand.cs @@ -0,0 +1,18 @@ +using System; +using NexusReader.Application.Abstractions.Messaging; + +namespace NexusReader.Application.Features.Books.Commands; + +/// +/// Command to create a new Book, initialize its first Working Draft revision, and seed it with a default Introduction chapter. +/// +/// The title of the new book. +/// An optional description of the book. +/// The ID of the creator user. +/// The tenant ID for multi-tenant isolation. +public record CreateBookCommand( + string Title, + string? Description, + string UserId, + string TenantId +) : ICommand; diff --git a/src/NexusReader.Application/Features/Books/Commands/CreateBookCommandHandler.cs b/src/NexusReader.Application/Features/Books/Commands/CreateBookCommandHandler.cs new file mode 100644 index 0000000..6ecb057 --- /dev/null +++ b/src/NexusReader.Application/Features/Books/Commands/CreateBookCommandHandler.cs @@ -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; + +/// +/// MediatR handler for creating a Book, creating its initial Working Draft revision, +/// and seeding a default first chapter ("Introduction") in an atomic database transaction. +/// +public class CreateBookCommandHandler : ICommandHandler +{ + private readonly IDbContextFactory _dbContextFactory; + + public CreateBookCommandHandler(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task> Handle(CreateBookCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Title)) + { + return Result.Fail(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(new Error($"Failed to create book: {ex.Message}").CausedBy(ex)); + } + } +} diff --git a/src/NexusReader.Application/Features/Books/Commands/PublishBookVersionCommand.cs b/src/NexusReader.Application/Features/Books/Commands/PublishBookVersionCommand.cs new file mode 100644 index 0000000..5400e73 --- /dev/null +++ b/src/NexusReader.Application/Features/Books/Commands/PublishBookVersionCommand.cs @@ -0,0 +1,12 @@ +using NexusReader.Application.Abstractions.Messaging; + +namespace NexusReader.Application.Features.Books.Commands; + +/// +/// Command to publish a new frozen version of a Book, and create a new Working Draft. +/// +/// The unique identifier of the Book to publish. +/// The custom version string to apply (e.g. "v1.0"). +/// The ID of the user requesting the action. +/// The tenant ID for multi-tenant isolation. +public record PublishBookVersionCommand(Guid BookId, string CustomVersionString, string UserId, string TenantId) : ICommand; diff --git a/src/NexusReader.Application/Features/Books/Commands/PublishBookVersionCommandHandler.cs b/src/NexusReader.Application/Features/Books/Commands/PublishBookVersionCommandHandler.cs new file mode 100644 index 0000000..bc4ceb9 --- /dev/null +++ b/src/NexusReader.Application/Features/Books/Commands/PublishBookVersionCommandHandler.cs @@ -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; + +/// +/// MediatR handler for publishing a Book version and setting up the next Working Draft. +/// +public class PublishBookVersionCommandHandler : ICommandHandler +{ + private readonly IDbContextFactory _dbContextFactory; + + public PublishBookVersionCommandHandler(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task 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.")); + } + + 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)); + } + } +} diff --git a/src/NexusReader.Application/Queries/Creator/GetBookRevisionsQuery.cs b/src/NexusReader.Application/Queries/Creator/GetBookRevisionsQuery.cs new file mode 100644 index 0000000..69aeac0 --- /dev/null +++ b/src/NexusReader.Application/Queries/Creator/GetBookRevisionsQuery.cs @@ -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; + +/// +/// Query to load all revisions for a specific Book, checking multi-tenant ownership boundaries. +/// +/// The unique identifier of the target Book. +/// The ID of the creator requesting revision data. +/// The tenant ID for multi-tenant isolation. +public record GetBookRevisionsQuery(Guid BookId, string UserId, string TenantId) : IQuery>; + +/// +/// Handler that lists past revisions of a Book, verifying ownership to prevent cross-tenant leakages. +/// +public class GetBookRevisionsQueryHandler : IQueryHandler> +{ + private readonly IDbContextFactory _dbContextFactory; + + public GetBookRevisionsQueryHandler(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task>> 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>(new FluentResults.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); + } +} diff --git a/src/NexusReader.Application/Queries/Creator/GetCreatorDashboardDataQuery.cs b/src/NexusReader.Application/Queries/Creator/GetCreatorDashboardDataQuery.cs new file mode 100644 index 0000000..84254a6 --- /dev/null +++ b/src/NexusReader.Application/Queries/Creator/GetCreatorDashboardDataQuery.cs @@ -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; + +/// +/// Query to load aggregated Creator Dashboard telemetry metrics and book listings. +/// +/// The ID of the creator requesting dashboard data. +/// The tenant ID for multi-tenant isolation. +public record GetCreatorDashboardDataQuery(string UserId, string TenantId) : IQuery; + +/// +/// 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. +/// +public class GetCreatorDashboardDataQueryHandler : IQueryHandler +{ + private readonly IDbContextFactory _dbContextFactory; + + public GetCreatorDashboardDataQueryHandler(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task> 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() + : b.CurrentDraftRevision.Chapters.Select(c => c.MarkdownContent.Length).ToList() + }) + .ToListAsync(cancellationToken); + + var booksList = new List(); + 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)); + } +} diff --git a/src/NexusReader.Data/Migrations/20260611183927_AddBookVersioningSupport.Designer.cs b/src/NexusReader.Data/Migrations/20260611183927_AddBookVersioningSupport.Designer.cs new file mode 100644 index 0000000..c2468fd --- /dev/null +++ b/src/NexusReader.Data/Migrations/20260611183927_AddBookVersioningSupport.Designer.cs @@ -0,0 +1,865 @@ +๏ปฟ// +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 + { + /// + 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("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CurrentDraftRevisionId") + .HasColumnType("uuid"); + + b.Property("LivePublishedRevisionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BookId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BookRevisionId") + .HasColumnType("uuid"); + + b.Property("MarkdownContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("CoverUrl") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsReadyForReading") + .HasColumnType("boolean"); + + b.Property("LastChapter") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LastChapterIndex") + .HasColumnType("integer"); + + b.Property("LastReadDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Progress") + .HasColumnType("double precision"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("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("Id") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EbookId") + .HasColumnType("uuid"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("RelationType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SourceUnitId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("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("Id") + .HasColumnType("text"); + + b.Property("AITokenLimit") + .HasColumnType("integer"); + + b.Property("AITokensUsed") + .HasColumnType("integer"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastAiActionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReadPageId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionPlanId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ThemePreference") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalQuestions") + .HasColumnType("integer"); + + b.Property("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("ContentHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JsonData") + .IsRequired() + .HasColumnType("text"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalText") + .IsRequired() + .HasColumnType("text"); + + b.Property("PromptVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AITokenLimit") + .HasColumnType("integer"); + + b.Property("IsUnlimitedTokens") + .HasColumnType("boolean"); + + b.Property("MonthlyPrice") + .HasColumnType("numeric"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("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", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", 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 + } + } +} diff --git a/src/NexusReader.Data/Migrations/20260611183927_AddBookVersioningSupport.cs b/src/NexusReader.Data/Migrations/20260611183927_AddBookVersioningSupport.cs new file mode 100644 index 0000000..ea5cd00 --- /dev/null +++ b/src/NexusReader.Data/Migrations/20260611183927_AddBookVersioningSupport.cs @@ -0,0 +1,141 @@ +๏ปฟusing System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusReader.Data.Migrations +{ + /// + public partial class AddBookVersioningSupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BookRevisions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + BookId = table.Column(type: "uuid", nullable: false), + VersionString = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + IsPublished = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + PublishedAt = table.Column(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(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + TenantId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + UserId = table.Column(type: "text", nullable: false), + CurrentDraftRevisionId = table.Column(type: "uuid", nullable: true), + LivePublishedRevisionId = table.Column(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(type: "uuid", nullable: false), + BookRevisionId = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + MarkdownContent = table.Column(type: "text", nullable: false), + SortOrder = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs index 05ee59d..3859fdc 100644 --- a/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs @@ -172,6 +172,103 @@ namespace NexusReader.Data.Migrations b.ToTable("Authors"); }); + modelBuilder.Entity("NexusReader.Domain.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CurrentDraftRevisionId") + .HasColumnType("uuid"); + + b.Property("LivePublishedRevisionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BookId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BookRevisionId") + .HasColumnType("uuid"); + + b.Property("MarkdownContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("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("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"); diff --git a/src/NexusReader.Data/Persistence/AppDbContext.cs b/src/NexusReader.Data/Persistence/AppDbContext.cs index 0f8b3b2..8bc4aff 100644 --- a/src/NexusReader.Data/Persistence/AppDbContext.cs +++ b/src/NexusReader.Data/Persistence/AppDbContext.cs @@ -25,6 +25,9 @@ public class AppDbContext : IdentityDbContext public DbSet QuizResults => Set(); public DbSet SubscriptionPlans => Set(); public DbSet Authors => Set(); + public DbSet Books => Set(); + public DbSet BookRevisions => Set(); + public DbSet Chapters => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -114,6 +117,48 @@ public class AppDbContext : IdentityDbContext entity.HasIndex(e => e.TenantId); }); + modelBuilder.Entity(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(entity => + { + entity.HasKey(r => r.Id); + + entity.HasMany(r => r.Chapters) + .WithOne(c => c.BookRevision) + .HasForeignKey(c => c.BookRevisionId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(c => c.Id); + }); + // Seed Subscription Plans with deterministic IDs modelBuilder.Entity().HasData( new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" }, diff --git a/src/NexusReader.Data/Persistence/DbInitializer.cs b/src/NexusReader.Data/Persistence/DbInitializer.cs index fc84c56..aecfaf1 100644 --- a/src/NexusReader.Data/Persistence/DbInitializer.cs +++ b/src/NexusReader.Data/Persistence/DbInitializer.cs @@ -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) { diff --git a/src/NexusReader.Domain/Entities/Book.cs b/src/NexusReader.Domain/Entities/Book.cs new file mode 100644 index 0000000..62fea35 --- /dev/null +++ b/src/NexusReader.Domain/Entities/Book.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace NexusReader.Domain.Entities; + +/// +/// Represents a Book metadata entry that references its decoupled revisions. +/// +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 Revisions { get; set; } = new List(); +} diff --git a/src/NexusReader.Domain/Entities/BookRevision.cs b/src/NexusReader.Domain/Entities/BookRevision.cs new file mode 100644 index 0000000..a9f6c98 --- /dev/null +++ b/src/NexusReader.Domain/Entities/BookRevision.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace NexusReader.Domain.Entities; + +/// +/// Encapsulates a snapshot or draft version of a Book's chapters. +/// +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 Chapters { get; set; } = new List(); +} diff --git a/src/NexusReader.Domain/Entities/Chapter.cs b/src/NexusReader.Domain/Entities/Chapter.cs new file mode 100644 index 0000000..9a0291c --- /dev/null +++ b/src/NexusReader.Domain/Entities/Chapter.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace NexusReader.Domain.Entities; + +/// +/// Represents a chapter belonging strictly to a specific BookRevision. +/// +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; } +} diff --git a/src/NexusReader.Domain/Exceptions/BookNotFoundException.cs b/src/NexusReader.Domain/Exceptions/BookNotFoundException.cs new file mode 100644 index 0000000..b0cd5c7 --- /dev/null +++ b/src/NexusReader.Domain/Exceptions/BookNotFoundException.cs @@ -0,0 +1,12 @@ +namespace NexusReader.Domain.Exceptions; + +/// +/// Custom domain exception thrown when a Book cannot be found by its ID. +/// +public class BookNotFoundException : Exception +{ + public BookNotFoundException(Guid bookId) + : base($"Book with ID '{bookId}' was not found.") + { + } +} diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 63cdc37..aa44423 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -55,7 +55,15 @@ public static class DependencyInjection // Qdrant Client registration var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334"; - services.AddSingleton(sp => new QdrantClient(new Uri(qdrantUrl))); + var qdrantApiKey = configuration["Qdrant:ApiKey"]; + services.AddSingleton(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"; diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj index b423dfb..47a463f 100644 --- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj +++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj @@ -29,6 +29,7 @@ + diff --git a/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs b/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs index a08c967..ee86725 100644 --- a/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs +++ b/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs @@ -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? 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(); } } diff --git a/src/NexusReader.Infrastructure/Services/LocalStorageService.cs b/src/NexusReader.Infrastructure/Services/LocalStorageService.cs index 6b61ec5..d8803f8 100644 --- a/src/NexusReader.Infrastructure/Services/LocalStorageService.cs +++ b/src/NexusReader.Infrastructure/Services/LocalStorageService.cs @@ -24,7 +24,7 @@ public class LocalStorageService : IStorageService public async Task 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}"; } } diff --git a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor index 01ce06b..9cdc5d4 100644 --- a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor +++ b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor @@ -2,24 +2,81 @@ @implements IAsyncDisposable @inject IJSRuntime JS @inject HttpClient Http +@inject NexusReader.Application.Abstractions.Services.INativeStorageService StorageService
-
- @if (ShowFetchButton) + @if (_showRestorationBanner) { -
- +
+ + +
+ } + else + { +
+ + }
@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? _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( - "import", - "./_content/NexusReader.UI.Shared/js/milkdownWrapper.js" - ); - - // Call the initialization function in the wrapper + if (_module == null) + { + _module = await JS.InvokeAsync( + "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( + "import", + $"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js?v={Guid.NewGuid():N}" + ); + } + + // Sweep and filter backup keys defensively + var keys = await _module.InvokeAsync>("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( + 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( + 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("getMarkdownContent", EditorId); if (OnSave.HasDelegate) @@ -82,12 +320,137 @@ } [JSInvokable] - public async Task UploadImageFromJs(string filename, string contentType, byte[] fileBytes) + public async Task OnEditorContentChanged(string currentMarkdown) + { + _currentMarkdown = currentMarkdown; + + // Structured JSON Envelope Pattern + var envelope = new NexusReader.Application.DTOs.Media.LocalBackupEnvelope + { + ChapterId = ChapterId, + Timestamp = DateTime.UtcNow, + MarkdownContent = currentMarkdown + }; + + try + { + var envelopeJson = System.Text.Json.JsonSerializer.Serialize( + envelope, + NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope + ); + await StorageService.SaveStringAsync($"nexus-bkp-{ChapterId}", envelopeJson); + } + catch (Exception ex) + { + Console.WriteLine($"[MarkdownEditor] Failed to save backup to LocalStorage: {ex.Message}"); + } + + // Status indicator to Offline - Local Backup Only + _status = SaveStatus.OfflineLocalBackup; + await InvokeAsync(StateHasChanged); + + // Cancel pending timers thread-safely + CancellationTokenSource? ctsToCancel = null; + CancellationToken token; + lock (_timerLock) + { + if (_debounceCts != null) + { + ctsToCancel = _debounceCts; + _debounceCts = null; + } + _debounceCts = new CancellationTokenSource(); + token = _debounceCts.Token; // Capture token synchronously under lock on UI thread + } + + if (ctsToCancel != null) + { + try + { + await ctsToCancel.CancelAsync(); + ctsToCancel.Dispose(); + } + catch (Exception ex) + { + Console.WriteLine($"[MarkdownEditor] Error cancelling debounce timer: {ex.Message}"); + } + } + + // Start 5-second idle debounce timer + _ = Task.Run(async () => + { + try + { + await Task.Delay(5000, token); + await TriggerAutosaveAsync(currentMarkdown, token); + } + catch (TaskCanceledException) + { + // Task cancelled on new keystroke + } + catch (Exception ex) + { + Console.WriteLine($"[MarkdownEditor] Debounce timer exception: {ex.Message}"); + } + }); + } + + private async Task TriggerAutosaveAsync(string markdown, CancellationToken token) + { + if (token.IsCancellationRequested || _disposed) return; + + _status = SaveStatus.Saving; + await InvokeAsync(StateHasChanged); + + try + { + var request = new NexusReader.Application.DTOs.Media.AutosaveChapterRequest(markdown); + var response = await Http.PutAsJsonAsync( + $"/api/chapters/{ChapterId}/autosave", + request, + NexusReader.Application.Common.AppJsonContext.Default.AutosaveChapterRequest, + token + ); + + if (_disposed) return; + + if (response.IsSuccessStatusCode) + { + // Purge LocalStorage backup key on HTTP success + await StorageService.RemoveAsync($"nexus-bkp-{ChapterId}"); + _status = SaveStatus.SavedToCloud; + } + else + { + _status = SaveStatus.OfflineLocalBackup; + var errorMsg = await response.Content.ReadAsStringAsync(token); + Console.WriteLine($"[MarkdownEditor] Autosave HTTP error: {response.StatusCode} - {errorMsg}"); + } + } + catch (Exception ex) + { + if (_disposed) return; + _status = SaveStatus.OfflineLocalBackup; + Console.WriteLine($"[MarkdownEditor] Autosave HTTP exception: {ex.Message}"); + } + + if (_disposed) return; + await InvokeAsync(StateHasChanged); + } + + [JSInvokable] + public async Task 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.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 diff --git a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css index 87e124d..3a2c96d 100644 --- a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css +++ b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css @@ -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); } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ContextualRecommendationsWidget.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/ContextualRecommendationsWidget.razor.css index a014fe9..b42632f 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ContextualRecommendationsWidget.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/ContextualRecommendationsWidget.razor.css @@ -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; diff --git a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor index a892ff7..15736fc 100644 --- a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor +++ b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor @@ -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(); } diff --git a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css index 0e0c4a7..d0c1519 100644 --- a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css +++ b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css @@ -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 --- */ diff --git a/src/NexusReader.UI.Shared/Pages/Creator.razor b/src/NexusReader.UI.Shared/Pages/Creator.razor deleted file mode 100644 index c191997..0000000 --- a/src/NexusReader.UI.Shared/Pages/Creator.razor +++ /dev/null @@ -1,76 +0,0 @@ -@page "/creator" -@using Microsoft.AspNetCore.Authorization -@attribute [Authorize] - -Kreator Treล›ci (Zen Mode) - -
-
-

Kreator Treล›ci

-

Zen publishing workspace mapping standard Markdown into clean visual blocks.

-
- -
-
- -
- -
- -
-
- - @if (!string.IsNullOrEmpty(_savedMarkdown)) - { -
-
-

Retrieved Markdown Preview

-
-
-
@_savedMarkdown
-
-
- } -
- -@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(); - } -} diff --git a/src/NexusReader.UI.Shared/Pages/Creator.razor.css b/src/NexusReader.UI.Shared/Pages/Creator.razor.css deleted file mode 100644 index 2801637..0000000 --- a/src/NexusReader.UI.Shared/Pages/Creator.razor.css +++ /dev/null @@ -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; -} diff --git a/src/NexusReader.UI.Shared/Pages/CreatorDashboard.razor b/src/NexusReader.UI.Shared/Pages/CreatorDashboard.razor new file mode 100644 index 0000000..dac264f --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/CreatorDashboard.razor @@ -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 Logger + +Creator Dashboard | Nexus Reader + +
+
+
+

Panel Autora

+

Monitoruj zaangaลผowanie czytelnikรณw i publikuj wersje zamroลผone z poziomu kontroli wersji.

+
+
+ +
+ +
+ @if (_isLoading) + { + @for (int i = 0; i < 4; i++) + { +
+
+
+
+ } + } + else if (_dashboardData != null) + { +
+ Caล‚kowite Odczyty +

@_dashboardData.Metrics.TotalReads

+
+ โ†‘ + System stabilny +
+
+ +
+ ลšredni Czas Czytania +

@_dashboardData.Metrics.AvgReadTimeMinutes min

+
+ โ†’ + Na rozdziaล‚ +
+
+ +
+
+ Aktywni Czytelnicy +
+ + Live Now +
+
+

@_dashboardData.Metrics.ActiveReaders

+
+ โ†‘ + Ruch w czasie rzeczywistym +
+
+ +
+ Przychรณd Gross +

@_dashboardData.Metrics.GrossRevenue.ToString("C2")

+
+ โ†‘ + Rozliczenia w toku +
+
+ } +
+ + +
+
+

Twoje Publikacje

+ +
+ + @if (_isLoading) + { +
+ @for (int i = 0; i < 3; i++) + { +
+
+
+ +
+
+ } +
+ } + else if (_dashboardData == null || !_dashboardData.Books.Any()) + { +
+
+ + + + +
+

Brak publikacji

+

Nie utworzyล‚eล› jeszcze ลผadnych ksiฤ…ลผek do autorskiej edycji.

+ +
+ } + else + { +
+ @foreach (var book in _dashboardData.Books) + { +
+
+
+

@book.Title

+
+ @if (book.LivePublishedRevision != null) + { + + Live @book.LivePublishedRevision.VersionString + + } + @if (book.CurrentDraftRevision != null) + { + + Szkic + + } +
+
+ +
+
+ Sล‚owa: + @book.WordCount.ToString("N0") +
+
+ Wyล›wietlenia: + @book.AggregatedReads.ToString("N0") +
+
+ +
+ + + +
+
+ } +
+ } +
+
+
+ + +@if (_isPublishModalOpen && _activePublishBookId.HasValue) +{ + +} + + +@if (_isRevisionsModalOpen && _activeRevisionsBookId.HasValue) +{ + +} + + +@if (_isCreateBookModalOpen) +{ + +} + +@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 _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("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>($"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(); + 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; } + } +} diff --git a/src/NexusReader.UI.Shared/Pages/CreatorDashboard.razor.css b/src/NexusReader.UI.Shared/Pages/CreatorDashboard.razor.css new file mode 100644 index 0000000..dfd1a6d --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/CreatorDashboard.razor.css @@ -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; +} + + diff --git a/src/NexusReader.UI.Shared/Pages/CreatorEdit.razor b/src/NexusReader.UI.Shared/Pages/CreatorEdit.razor new file mode 100644 index 0000000..1ecb0b3 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/CreatorEdit.razor @@ -0,0 +1,186 @@ +@page "/creator/edit/{BookId}" +@page "/creator/edit/{BookId}/{ChapterId}" +@layout MainHubLayout +@attribute [Authorize] +@using NexusReader.UI.Shared.Components + +@if (_loadingChapters) +{ +
+
+

ลadowanie struktury ksiฤ…ลผki...

+
+} +else +{ +
+ +
+ +
+ @foreach (var ch in _chapters) + { + var isActive = ch.Id == _activeChapterId; + + @if (isActive) + { +
+ + } + else + { + + } + @ch.Title +
+ } +
+
+ +
+ +
+
+

@_activeChapterTitle

+
+
+ ID: @_activeChapterId +
+
+ +
+ @if (_loadingChapter) + { +
+
+

Wczytywanie treล›ci rozdziaล‚u...

+
+ } + else if (_isChapterLoaded) + { +
+ +
+ } + else + { +
+

Wybierz lub utwรณrz rozdziaล‚, aby rozpoczฤ…ฤ‡ edycjฤ™.

+
+ } +
+
+
+} + +@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 _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>($"/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($"/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; + } + } + } + } +} + diff --git a/src/NexusReader.UI.Shared/Pages/CreatorEdit.razor.css b/src/NexusReader.UI.Shared/Pages/CreatorEdit.razor.css new file mode 100644 index 0000000..e18ccc2 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/CreatorEdit.razor.css @@ -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); +} + + diff --git a/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js b/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js index 2d89599..c8ecf95 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js @@ -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 + }; } diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index 24e54e5..d3484d3 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -91,6 +91,10 @@ builder.Services.AddCascadingAuthenticationState(); builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddHealthChecks() + .AddCheck("Database") + .AddCheck("Qdrant") + .AddCheck("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("/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 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 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 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() .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); diff --git a/src/NexusReader.Web/Services/DatabaseHealthCheck.cs b/src/NexusReader.Web/Services/DatabaseHealthCheck.cs new file mode 100644 index 0000000..a7df82e --- /dev/null +++ b/src/NexusReader.Web/Services/DatabaseHealthCheck.cs @@ -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 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); + } + } +} diff --git a/src/NexusReader.Web/Services/Neo4jHealthCheck.cs b/src/NexusReader.Web/Services/Neo4jHealthCheck.cs new file mode 100644 index 0000000..5412c18 --- /dev/null +++ b/src/NexusReader.Web/Services/Neo4jHealthCheck.cs @@ -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 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); + } + } +} diff --git a/src/NexusReader.Web/Services/QdrantHealthCheck.cs b/src/NexusReader.Web/Services/QdrantHealthCheck.cs new file mode 100644 index 0000000..76ee08d --- /dev/null +++ b/src/NexusReader.Web/Services/QdrantHealthCheck.cs @@ -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 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); + } + } +} diff --git a/tests/NexusReader.Application.Tests/Commands/CreateBookTests.cs b/tests/NexusReader.Application.Tests/Commands/CreateBookTests.cs new file mode 100644 index 0000000..e3a69f3 --- /dev/null +++ b/tests/NexusReader.Application.Tests/Commands/CreateBookTests.cs @@ -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 _contextOptions; + private readonly Mock> _dbContextFactoryMock; + + public CreateBookTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _contextOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + // Seed initial database schema + using var context = new AppDbContext(_contextOptions); + context.Database.EnsureCreated(); + + _dbContextFactoryMock = new Mock>(); + _dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .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(); + } +} diff --git a/tests/NexusReader.Application.Tests/Commands/PublishBookVersionTests.cs b/tests/NexusReader.Application.Tests/Commands/PublishBookVersionTests.cs new file mode 100644 index 0000000..7e40963 --- /dev/null +++ b/tests/NexusReader.Application.Tests/Commands/PublishBookVersionTests.cs @@ -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 _contextOptions; + private readonly Mock> _dbContextFactoryMock; + + public PublishBookVersionTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _contextOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + // Seed initial database schema + using var context = new AppDbContext(_contextOptions); + context.Database.EnsureCreated(); + + _dbContextFactoryMock = new Mock>(); + _dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .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); + + // 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(); + } +} diff --git a/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj b/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj index 9ed3835..7b764a7 100644 --- a/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj +++ b/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj @@ -17,5 +17,6 @@ + diff --git a/tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs b/tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs index e8afdf8..9871dd2 100644 --- a/tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs +++ b/tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs @@ -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"); diff --git a/tests/NexusReader.Application.Tests/Queries/CreatorDashboardTests.cs b/tests/NexusReader.Application.Tests/Queries/CreatorDashboardTests.cs new file mode 100644 index 0000000..2450d32 --- /dev/null +++ b/tests/NexusReader.Application.Tests/Queries/CreatorDashboardTests.cs @@ -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 _contextOptions; + private readonly Mock> _dbContextFactoryMock; + + public CreatorDashboardTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _contextOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + // Seed initial database schema + using var context = new AppDbContext(_contextOptions); + context.Database.EnsureCreated(); + + _dbContextFactoryMock = new Mock>(); + _dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .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(); + 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(); + } +} diff --git a/tests/NexusReader.Application.Tests/Services/AutosaveEngineTests.cs b/tests/NexusReader.Application.Tests/Services/AutosaveEngineTests.cs new file mode 100644 index 0000000..6a73079 --- /dev/null +++ b/tests/NexusReader.Application.Tests/Services/AutosaveEngineTests.cs @@ -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); + } +} diff --git a/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs b/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs index 013b78c..6f69ef4 100644 --- a/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs +++ b/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs @@ -51,4 +51,20 @@ public class HtmlSanitizerServiceTests result.Should().NotContain("alert"); result.Should().Contain(""); } + + [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("<"); + result.Should().Contain(">"); + result.Should().NotContain("