Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3f5d02a6c | |||
| de6cee06ad | |||
| 34c6f45a10 |
@@ -4,8 +4,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||||
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
|
|
||||||
<PackageVersion Include="Markdig" Version="0.38.0" />
|
|
||||||
<PackageVersion Include="Mapster" Version="10.0.7" />
|
<PackageVersion Include="Mapster" Version="10.0.7" />
|
||||||
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
|
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
|
||||||
<PackageVersion Include="MediatR" Version="12.1.1" />
|
<PackageVersion Include="MediatR" Version="12.1.1" />
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseApp
|
|||||||
|
|
||||||
# Stage 2: Runtime
|
# Stage 2: Runtime
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 && rm -rf /var/lib/apt/lists/*
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,4 @@ version: 1.0
|
|||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **Git Workflow & Integration**
|
> **Git Workflow & Integration**
|
||||||
> 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.
|
> All tasks originating from the repository must be performed on a separate 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`).
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ services:
|
|||||||
- ASPNETCORE_ENVIRONMENT=Staging
|
- ASPNETCORE_ENVIRONMENT=Staging
|
||||||
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_stage_db};Username=${POSTGRES_USER:-nexus_user_stage};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_stage_db};Username=${POSTGRES_USER:-nexus_user_stage};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
- ConnectionStrings__QdrantConnection=http://qdrant:6334
|
- ConnectionStrings__QdrantConnection=http://qdrant:6334
|
||||||
- Qdrant__ApiKey=${QDRANT_API_KEY:-}
|
|
||||||
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
|
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
|
||||||
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
|
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
|
||||||
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
||||||
|
|||||||
-154
@@ -1,154 +0,0 @@
|
|||||||
#!/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 "--------------------------------------------------------"
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace NexusReader.Application.Abstractions.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Service for sanitizing raw input text (e.g. Markdown/HTML) to protect against XSS injection.
|
|
||||||
/// Intended to have a Singleton lifetime.
|
|
||||||
/// </summary>
|
|
||||||
public interface ISanitizerService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Sanitizes the input string and returns a clean, safe version.
|
|
||||||
/// </summary>
|
|
||||||
string Sanitize(string input);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
namespace NexusReader.Application.Abstractions.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// General file storage service interface for handling media uploads.
|
|
||||||
/// Intended to have a Scoped lifetime.
|
|
||||||
/// </summary>
|
|
||||||
public interface IStorageService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Uploads a file stream and returns its public URL/path.
|
|
||||||
/// </summary>
|
|
||||||
Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Uploads file bytes and returns its public URL/path.
|
|
||||||
/// </summary>
|
|
||||||
Task<string> UploadFileAsync(byte[] fileBytes, string fileName, string contentType);
|
|
||||||
}
|
|
||||||
@@ -18,21 +18,6 @@ namespace NexusReader.Application.Common;
|
|||||||
[JsonSerializable(typeof(List<NexusReader.Application.Queries.Recommendations.RecommendationDto>))]
|
[JsonSerializable(typeof(List<NexusReader.Application.Queries.Recommendations.RecommendationDto>))]
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.User.UpdateThemeRequest))]
|
[JsonSerializable(typeof(NexusReader.Application.DTOs.User.UpdateThemeRequest))]
|
||||||
[JsonSerializable(typeof(NexusReader.Domain.Enums.ThemeMode))]
|
[JsonSerializable(typeof(NexusReader.Domain.Enums.ThemeMode))]
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterRequest))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterResponse))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.UploadResultDto))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.LocalBackupEnvelope))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.AutosaveChapterRequest))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.Features.Books.Commands.CreateBookCommand))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreateBookRequestDto))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreateBookResponseDto))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorDashboardDataDto))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.DashboardMetricsDto))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorBookDto))]
|
|
||||||
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorBookRevisionDto))]
|
|
||||||
[JsonSerializable(typeof(List<NexusReader.Application.DTOs.Creator.CreatorBookDto>))]
|
|
||||||
[JsonSerializable(typeof(List<NexusReader.Application.DTOs.Creator.CreatorBookRevisionDto>))]
|
|
||||||
public partial class AppJsonContext : JsonSerializerContext
|
public partial class AppJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.DTOs.Creator;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Telemetry metrics for the Creator Dashboard.
|
|
||||||
/// </summary>
|
|
||||||
public record DashboardMetricsDto(
|
|
||||||
int TotalReads,
|
|
||||||
double AvgReadTimeMinutes,
|
|
||||||
int ActiveReaders,
|
|
||||||
decimal GrossRevenue
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lightweight revision details for the Creator Dashboard.
|
|
||||||
/// </summary>
|
|
||||||
public record CreatorBookRevisionDto(
|
|
||||||
Guid Id,
|
|
||||||
string VersionString,
|
|
||||||
bool IsPublished,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime? PublishedAt
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lightweight book publication details for the Creator Dashboard.
|
|
||||||
/// </summary>
|
|
||||||
public record CreatorBookDto(
|
|
||||||
Guid Id,
|
|
||||||
string Title,
|
|
||||||
int WordCount,
|
|
||||||
int AggregatedReads,
|
|
||||||
Guid? FirstChapterId,
|
|
||||||
CreatorBookRevisionDto? LivePublishedRevision,
|
|
||||||
CreatorBookRevisionDto? CurrentDraftRevision
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Root data envelope for Creator Dashboard loading.
|
|
||||||
/// </summary>
|
|
||||||
public record CreatorDashboardDataDto(
|
|
||||||
DashboardMetricsDto Metrics,
|
|
||||||
List<CreatorBookDto> Books
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for creating a new Book.
|
|
||||||
/// </summary>
|
|
||||||
public record CreateBookRequestDto(
|
|
||||||
string Title,
|
|
||||||
string? Description
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO for creating a new Book.
|
|
||||||
/// </summary>
|
|
||||||
public record CreateBookResponseDto(
|
|
||||||
Guid BookId
|
|
||||||
);
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
namespace NexusReader.Application.DTOs.Media;
|
|
||||||
|
|
||||||
// Note: These DTOs are registered in AppJsonContext.cs for JSON source generation.
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for chapter validation/sanitization.
|
|
||||||
/// </summary>
|
|
||||||
public record ValidateChapterRequest(string Content);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO containing sanitized chapter content.
|
|
||||||
/// </summary>
|
|
||||||
public record ValidateChapterResponse(string SanitizedContent);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO containing the uploaded media file URL.
|
|
||||||
/// </summary>
|
|
||||||
public record UploadResultDto(string Url);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a structured JSON backup envelope stored in LocalStorage.
|
|
||||||
/// </summary>
|
|
||||||
public class LocalBackupEnvelope
|
|
||||||
{
|
|
||||||
public Guid ChapterId { get; set; }
|
|
||||||
public DateTime Timestamp { get; set; }
|
|
||||||
public string MarkdownContent { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for chapter autosaving.
|
|
||||||
/// </summary>
|
|
||||||
public record AutosaveChapterRequest(string MarkdownContent);
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
using System;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Features.Books.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command to create a new Book, initialize its first Working Draft revision, and seed it with a default Introduction chapter.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="Title">The title of the new book.</param>
|
|
||||||
/// <param name="Description">An optional description of the book.</param>
|
|
||||||
/// <param name="UserId">The ID of the creator user.</param>
|
|
||||||
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
|
||||||
public record CreateBookCommand(
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
string UserId,
|
|
||||||
string TenantId
|
|
||||||
) : ICommand<Guid>;
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FluentResults;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using NexusReader.Domain.Entities;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Features.Books.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// MediatR handler for creating a Book, creating its initial Working Draft revision,
|
|
||||||
/// and seeding a default first chapter ("Introduction") in an atomic database transaction.
|
|
||||||
/// </summary>
|
|
||||||
public class CreateBookCommandHandler : ICommandHandler<CreateBookCommand, Guid>
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
|
||||||
|
|
||||||
public CreateBookCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
|
||||||
{
|
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<Guid>> Handle(CreateBookCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Title))
|
|
||||||
{
|
|
||||||
return Result.Fail<Guid>(new Error("Book title is required."));
|
|
||||||
}
|
|
||||||
|
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 1. Instantiate the Book record mapping Title, UserId, and TenantId
|
|
||||||
var book = new Book
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Title = request.Title.Trim(),
|
|
||||||
UserId = request.UserId,
|
|
||||||
TenantId = request.TenantId,
|
|
||||||
CurrentDraftRevisionId = null,
|
|
||||||
LivePublishedRevisionId = null
|
|
||||||
};
|
|
||||||
|
|
||||||
dbContext.Books.Add(book);
|
|
||||||
|
|
||||||
// 2. Instantiate the initial BookRevision designated as "Working Draft"
|
|
||||||
var draftRevision = new BookRevision
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
BookId = book.Id,
|
|
||||||
VersionString = "Working Draft",
|
|
||||||
IsPublished = false,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
dbContext.BookRevisions.Add(draftRevision);
|
|
||||||
|
|
||||||
// 3. Automatically instantiate and append a default first Chapter to this new revision
|
|
||||||
var introChapter = new Chapter
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
BookRevisionId = draftRevision.Id,
|
|
||||||
Title = "Introduction",
|
|
||||||
MarkdownContent = "# Introduction\nStart writing here...",
|
|
||||||
SortOrder = 1
|
|
||||||
};
|
|
||||||
|
|
||||||
dbContext.Chapters.Add(introChapter);
|
|
||||||
|
|
||||||
// Save first to generate DB references
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 4. Inject the newly instantiated draft revision ID back into Book.CurrentDraftRevisionId
|
|
||||||
book.CurrentDraftRevisionId = draftRevision.Id;
|
|
||||||
|
|
||||||
// Save the updated Book link
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Commit transaction
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Ok(book.Id);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception rollbackEx)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[CreateBook] Transaction rollback failed: {rollbackEx.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.Fail<Guid>(new Error($"Failed to create book: {ex.Message}").CausedBy(ex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Features.Books.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command to publish a new frozen version of a Book, and create a new Working Draft.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="BookId">The unique identifier of the Book to publish.</param>
|
|
||||||
/// <param name="CustomVersionString">The custom version string to apply (e.g. "v1.0").</param>
|
|
||||||
/// <param name="UserId">The ID of the user requesting the action.</param>
|
|
||||||
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
|
||||||
public record PublishBookVersionCommand(Guid BookId, string CustomVersionString, string UserId, string TenantId) : ICommand;
|
|
||||||
-112
@@ -1,112 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FluentResults;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using NexusReader.Domain.Entities;
|
|
||||||
using NexusReader.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Features.Books.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// MediatR handler for publishing a Book version and setting up the next Working Draft.
|
|
||||||
/// </summary>
|
|
||||||
public class PublishBookVersionCommandHandler : ICommandHandler<PublishBookVersionCommand>
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
|
||||||
|
|
||||||
public PublishBookVersionCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
|
||||||
{
|
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result> Handle(PublishBookVersionCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Fetch the Book including its CurrentDraftRevision and all associated Chapters,
|
|
||||||
// enforcing that the book belongs to the requested TenantId and UserId to prevent cross-tenant data leaks.
|
|
||||||
var book = await dbContext.Books
|
|
||||||
.Include(b => b.CurrentDraftRevision)
|
|
||||||
.ThenInclude(r => r!.Chapters)
|
|
||||||
.FirstOrDefaultAsync(
|
|
||||||
b => b.Id == request.BookId && b.UserId == request.UserId && b.TenantId == request.TenantId,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (book == null)
|
|
||||||
{
|
|
||||||
return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found."));
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
using NexusReader.Application.DTOs.Creator;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using NexusReader.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Creator;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Query to load all revisions for a specific Book, checking multi-tenant ownership boundaries.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="BookId">The unique identifier of the target Book.</param>
|
|
||||||
/// <param name="UserId">The ID of the creator requesting revision data.</param>
|
|
||||||
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
|
||||||
public record GetBookRevisionsQuery(Guid BookId, string UserId, string TenantId) : IQuery<List<CreatorBookRevisionDto>>;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler that lists past revisions of a Book, verifying ownership to prevent cross-tenant leakages.
|
|
||||||
/// </summary>
|
|
||||||
public class GetBookRevisionsQueryHandler : IQueryHandler<GetBookRevisionsQuery, List<CreatorBookRevisionDto>>
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
|
||||||
|
|
||||||
public GetBookRevisionsQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
|
||||||
{
|
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<FluentResults.Result<List<CreatorBookRevisionDto>>> Handle(GetBookRevisionsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Verify the book exists and belongs to this tenant/user to prevent cross-tenant data leaks
|
|
||||||
var bookExists = await dbContext.Books
|
|
||||||
.AnyAsync(b => b.Id == request.BookId && b.UserId == request.UserId && b.TenantId == request.TenantId, cancellationToken);
|
|
||||||
|
|
||||||
if (!bookExists)
|
|
||||||
{
|
|
||||||
return FluentResults.Result.Fail<List<CreatorBookRevisionDto>>(new FluentResults.Error($"Book with ID '{request.BookId}' was not found."));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
using NexusReader.Application.DTOs.Creator;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Creator;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Query to load aggregated Creator Dashboard telemetry metrics and book listings.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="UserId">The ID of the creator requesting dashboard data.</param>
|
|
||||||
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
|
|
||||||
public record GetCreatorDashboardDataQuery(string UserId, string TenantId) : IQuery<CreatorDashboardDataDto>;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler that executes projection-only LINQ queries to aggregate metrics and compute word counts
|
|
||||||
/// without loading raw chapter content into memory or tracking them in the EF Core Change Tracker.
|
|
||||||
/// </summary>
|
|
||||||
public class GetCreatorDashboardDataQueryHandler : IQueryHandler<GetCreatorDashboardDataQuery, CreatorDashboardDataDto>
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
|
||||||
|
|
||||||
public GetCreatorDashboardDataQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
|
||||||
{
|
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<FluentResults.Result<CreatorDashboardDataDto>> Handle(GetCreatorDashboardDataQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Execute projection-only LINQ query. The heavy MarkdownContent is projected only as integer lengths.
|
|
||||||
var projectedBooks = await dbContext.Books
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(b => b.UserId == request.UserId && b.TenantId == request.TenantId)
|
|
||||||
.Select(b => new
|
|
||||||
{
|
|
||||||
b.Id,
|
|
||||||
b.Title,
|
|
||||||
LivePublishedRevision = b.LivePublishedRevision == null ? null : new CreatorBookRevisionDto(
|
|
||||||
b.LivePublishedRevision.Id,
|
|
||||||
b.LivePublishedRevision.VersionString,
|
|
||||||
b.LivePublishedRevision.IsPublished,
|
|
||||||
b.LivePublishedRevision.CreatedAt,
|
|
||||||
b.LivePublishedRevision.PublishedAt
|
|
||||||
),
|
|
||||||
CurrentDraftRevision = b.CurrentDraftRevision == null ? null : new CreatorBookRevisionDto(
|
|
||||||
b.CurrentDraftRevision.Id,
|
|
||||||
b.CurrentDraftRevision.VersionString,
|
|
||||||
b.CurrentDraftRevision.IsPublished,
|
|
||||||
b.CurrentDraftRevision.CreatedAt,
|
|
||||||
b.CurrentDraftRevision.PublishedAt
|
|
||||||
),
|
|
||||||
FirstChapterId = b.CurrentDraftRevision == null
|
|
||||||
? (Guid?)null
|
|
||||||
: b.CurrentDraftRevision.Chapters.OrderBy(c => c.SortOrder).Select(c => c.Id).FirstOrDefault(),
|
|
||||||
ChapterContentLengths = b.CurrentDraftRevision == null
|
|
||||||
? new List<int>()
|
|
||||||
: b.CurrentDraftRevision.Chapters.Select(c => c.MarkdownContent.Length).ToList()
|
|
||||||
})
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var booksList = new List<CreatorBookDto>();
|
|
||||||
int totalReads = 0;
|
|
||||||
int totalWords = 0;
|
|
||||||
|
|
||||||
foreach (var pBook in projectedBooks)
|
|
||||||
{
|
|
||||||
// Estimate word count (approx. 6 characters per word as a database-friendly standard length)
|
|
||||||
int wordCount = pBook.ChapterContentLengths.Sum(len => len / 6);
|
|
||||||
totalWords += wordCount;
|
|
||||||
|
|
||||||
// Generate deterministic simulated telemetry metrics scoped to this Book
|
|
||||||
int bookReads = Math.Abs(pBook.Id.GetHashCode() % 1000) + 120;
|
|
||||||
totalReads += bookReads;
|
|
||||||
|
|
||||||
var bookDto = new CreatorBookDto(
|
|
||||||
pBook.Id,
|
|
||||||
pBook.Title,
|
|
||||||
wordCount,
|
|
||||||
bookReads,
|
|
||||||
pBook.FirstChapterId,
|
|
||||||
pBook.LivePublishedRevision,
|
|
||||||
pBook.CurrentDraftRevision
|
|
||||||
);
|
|
||||||
|
|
||||||
booksList.Add(bookDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate aggregate dashboard metrics based on projected stats
|
|
||||||
int activeReaders = projectedBooks.Count == 0 ? 0 : Math.Abs(request.UserId.GetHashCode() % 15) + 3;
|
|
||||||
decimal grossRevenue = totalReads * 1.49m;
|
|
||||||
double avgReadTime = projectedBooks.Count == 0 ? 0 : Math.Round(totalWords / 250.0, 1); // standard 250 words per minute reading speed
|
|
||||||
|
|
||||||
var metrics = new DashboardMetricsDto(
|
|
||||||
totalReads,
|
|
||||||
avgReadTime,
|
|
||||||
activeReaders,
|
|
||||||
grossRevenue
|
|
||||||
);
|
|
||||||
|
|
||||||
return FluentResults.Result.Ok(new CreatorDashboardDataDto(metrics, booksList));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-865
@@ -1,865 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace NexusReader.Data.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AppDbContext))]
|
|
||||||
[Migration("20260611183927_AddBookVersioningSupport")]
|
|
||||||
partial class AddBookVersioningSupport
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.7")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ConcurrencyStamp")
|
|
||||||
.IsConcurrencyToken()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedName")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("RoleNameIndex");
|
|
||||||
|
|
||||||
b.ToTable("AspNetRoles", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("ClaimType")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ClaimValue")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("RoleId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("RoleId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetRoleClaims", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("ClaimType")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ClaimValue")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserClaims", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("LoginProvider")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ProviderKey")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ProviderDisplayName")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("LoginProvider", "ProviderKey");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserLogins", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("RoleId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("UserId", "RoleId");
|
|
||||||
|
|
||||||
b.HasIndex("RoleId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserRoles", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("LoginProvider")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Value")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("UserId", "LoginProvider", "Name");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserTokens", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("Authors");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid?>("CurrentDraftRevisionId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid?>("LivePublishedRevisionId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("CurrentDraftRevisionId");
|
|
||||||
|
|
||||||
b.HasIndex("LivePublishedRevisionId");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("Books");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid>("BookId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsPublished")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("PublishedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("VersionString")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("BookId");
|
|
||||||
|
|
||||||
b.ToTable("BookRevisions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid>("BookRevisionId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("MarkdownContent")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("SortOrder")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("BookRevisionId");
|
|
||||||
|
|
||||||
b.ToTable("Chapters");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<DateTime>("AddedDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("AuthorId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("CoverUrl")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("FilePath")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<bool>("IsReadyForReading")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("LastChapter")
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<int>("LastChapterIndex")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("LastReadDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<double>("Progress")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("AuthorId");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("Ebooks");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Content")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<Guid?>("EbookId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("MetadataJson")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<int>("Type")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Version")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("EbookId");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.ToTable("KnowledgeUnits");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("RelationType")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<string>("SourceUnitId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("TargetUnitId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("SourceUnitId");
|
|
||||||
|
|
||||||
b.HasIndex("TargetUnitId");
|
|
||||||
|
|
||||||
b.ToTable("KnowledgeUnitLinks");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("AITokenLimit")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("AITokensUsed")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("AccessFailedCount")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("ConcurrencyStamp")
|
|
||||||
.IsConcurrencyToken()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("DisplayName")
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<bool>("EmailConfirmed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("LastAiActionDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("LastReadAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("LastReadPageId")
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<bool>("LockoutEnabled")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedEmail")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedUserName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("PhoneNumber")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<bool>("PhoneNumberConfirmed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("SecurityStamp")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("SubscriptionPlanId")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<int>("ThemePreference")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<bool>("TwoFactorEnabled")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("UserName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedEmail")
|
|
||||||
.HasDatabaseName("EmailIndex");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedUserName")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("UserNameIndex");
|
|
||||||
|
|
||||||
b.HasIndex("SubscriptionPlanId");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUsers", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CompletedDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("Score")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Topic")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("TotalQuestions")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("QuizResults");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("ContentHash")
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("JsonData")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ModelId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<string>("OriginalText")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("PromptVersion")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(10)
|
|
||||||
.HasColumnType("character varying(10)");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.HasKey("ContentHash");
|
|
||||||
|
|
||||||
b.HasIndex("ContentHash")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.ToTable("SemanticKnowledgeCache");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<int>("AITokenLimit")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<bool>("IsUnlimitedTokens")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<decimal>("MonthlyPrice")
|
|
||||||
.HasColumnType("numeric");
|
|
||||||
|
|
||||||
b.Property<string>("PlanName")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.Property<string>("StripeProductId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("PlanName")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("SubscriptionPlans");
|
|
||||||
|
|
||||||
b.HasData(
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 1,
|
|
||||||
AITokenLimit = 5000,
|
|
||||||
IsUnlimitedTokens = false,
|
|
||||||
MonthlyPrice = 0m,
|
|
||||||
PlanName = "Free",
|
|
||||||
StripeProductId = "prod_Free789"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 2,
|
|
||||||
AITokenLimit = 10000,
|
|
||||||
IsUnlimitedTokens = false,
|
|
||||||
MonthlyPrice = 9.99m,
|
|
||||||
PlanName = "Basic",
|
|
||||||
StripeProductId = "prod_basic_placeholder"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 3,
|
|
||||||
AITokenLimit = 50000,
|
|
||||||
IsUnlimitedTokens = false,
|
|
||||||
MonthlyPrice = 19.99m,
|
|
||||||
PlanName = "Pro",
|
|
||||||
StripeProductId = "prod_pro_placeholder"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 4,
|
|
||||||
AITokenLimit = 1000000000,
|
|
||||||
IsUnlimitedTokens = true,
|
|
||||||
MonthlyPrice = 99.99m,
|
|
||||||
PlanName = "Enterprise",
|
|
||||||
StripeProductId = "prod_enterprise_placeholder"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("RoleId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("RoleId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "CurrentDraftRevision")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("CurrentDraftRevisionId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "LivePublishedRevision")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LivePublishedRevisionId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("CurrentDraftRevision");
|
|
||||||
|
|
||||||
b.Navigation("LivePublishedRevision");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.Book", "Book")
|
|
||||||
.WithMany("Revisions")
|
|
||||||
.HasForeignKey("BookId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Book");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.BookRevision", "BookRevision")
|
|
||||||
.WithMany("Chapters")
|
|
||||||
.HasForeignKey("BookRevisionId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("BookRevision");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
|
||||||
.WithMany("Ebooks")
|
|
||||||
.HasForeignKey("AuthorId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
|
||||||
.WithMany("Ebooks")
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Author");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("EbookId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
b.Navigation("Ebook");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
|
||||||
.WithMany("OutgoingLinks")
|
|
||||||
.HasForeignKey("SourceUnitId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
|
|
||||||
.WithMany("IncomingLinks")
|
|
||||||
.HasForeignKey("TargetUnitId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("SourceUnit");
|
|
||||||
|
|
||||||
b.Navigation("TargetUnit");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("SubscriptionPlanId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("SubscriptionPlan");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
|
||||||
.WithMany("QuizResults")
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Ebooks");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Revisions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Chapters");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("IncomingLinks");
|
|
||||||
|
|
||||||
b.Navigation("OutgoingLinks");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Ebooks");
|
|
||||||
|
|
||||||
b.Navigation("QuizResults");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace NexusReader.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddBookVersioningSupport : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "BookRevisions",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
BookId = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
VersionString = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
|
||||||
IsPublished = table.Column<bool>(type: "boolean", nullable: false),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
PublishedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_BookRevisions", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "Books",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
|
||||||
TenantId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
|
||||||
UserId = table.Column<string>(type: "text", nullable: false),
|
|
||||||
CurrentDraftRevisionId = table.Column<Guid>(type: "uuid", nullable: true),
|
|
||||||
LivePublishedRevisionId = table.Column<Guid>(type: "uuid", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_Books", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Books_AspNetUsers_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Books_BookRevisions_CurrentDraftRevisionId",
|
|
||||||
column: x => x.CurrentDraftRevisionId,
|
|
||||||
principalTable: "BookRevisions",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Books_BookRevisions_LivePublishedRevisionId",
|
|
||||||
column: x => x.LivePublishedRevisionId,
|
|
||||||
principalTable: "BookRevisions",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Restrict);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "Chapters",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
BookRevisionId = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
|
||||||
MarkdownContent = table.Column<string>(type: "text", nullable: false),
|
|
||||||
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_Chapters", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Chapters_BookRevisions_BookRevisionId",
|
|
||||||
column: x => x.BookRevisionId,
|
|
||||||
principalTable: "BookRevisions",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_BookRevisions_BookId",
|
|
||||||
table: "BookRevisions",
|
|
||||||
column: "BookId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Books_CurrentDraftRevisionId",
|
|
||||||
table: "Books",
|
|
||||||
column: "CurrentDraftRevisionId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Books_LivePublishedRevisionId",
|
|
||||||
table: "Books",
|
|
||||||
column: "LivePublishedRevisionId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Books_TenantId",
|
|
||||||
table: "Books",
|
|
||||||
column: "TenantId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Books_UserId",
|
|
||||||
table: "Books",
|
|
||||||
column: "UserId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Chapters_BookRevisionId",
|
|
||||||
table: "Chapters",
|
|
||||||
column: "BookRevisionId");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_BookRevisions_Books_BookId",
|
|
||||||
table: "BookRevisions",
|
|
||||||
column: "BookId",
|
|
||||||
principalTable: "Books",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_BookRevisions_Books_BookId",
|
|
||||||
table: "BookRevisions");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "Chapters");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "Books");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "BookRevisions");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -172,103 +172,6 @@ namespace NexusReader.Data.Migrations
|
|||||||
b.ToTable("Authors");
|
b.ToTable("Authors");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid?>("CurrentDraftRevisionId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid?>("LivePublishedRevisionId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("TenantId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("CurrentDraftRevisionId");
|
|
||||||
|
|
||||||
b.HasIndex("LivePublishedRevisionId");
|
|
||||||
|
|
||||||
b.HasIndex("TenantId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("Books");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid>("BookId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsPublished")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("PublishedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("VersionString")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("BookId");
|
|
||||||
|
|
||||||
b.ToTable("BookRevisions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid>("BookRevisionId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("MarkdownContent")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("SortOrder")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("BookRevisionId");
|
|
||||||
|
|
||||||
b.ToTable("Chapters");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -711,53 +614,6 @@ namespace NexusReader.Data.Migrations
|
|||||||
.IsRequired();
|
.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 =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||||
@@ -833,16 +689,6 @@ namespace NexusReader.Data.Migrations
|
|||||||
b.Navigation("Ebooks");
|
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 =>
|
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("IncomingLinks");
|
b.Navigation("IncomingLinks");
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
||||||
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
|
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
|
||||||
public DbSet<Author> Authors => Set<Author>();
|
public DbSet<Author> Authors => Set<Author>();
|
||||||
public DbSet<Book> Books => Set<Book>();
|
|
||||||
public DbSet<BookRevision> BookRevisions => Set<BookRevision>();
|
|
||||||
public DbSet<Chapter> Chapters => Set<Chapter>();
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -117,48 +114,6 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
entity.HasIndex(e => e.TenantId);
|
entity.HasIndex(e => e.TenantId);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Book>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasKey(b => b.Id);
|
|
||||||
entity.HasIndex(b => b.TenantId);
|
|
||||||
entity.HasIndex(b => b.UserId);
|
|
||||||
|
|
||||||
entity.HasOne(e => e.User)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(e => e.UserId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
entity.HasMany(b => b.Revisions)
|
|
||||||
.WithOne(r => r.Book)
|
|
||||||
.HasForeignKey(r => r.BookId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
entity.HasOne(b => b.CurrentDraftRevision)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(b => b.CurrentDraftRevisionId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
entity.HasOne(b => b.LivePublishedRevision)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(b => b.LivePublishedRevisionId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<BookRevision>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasKey(r => r.Id);
|
|
||||||
|
|
||||||
entity.HasMany(r => r.Chapters)
|
|
||||||
.WithOne(c => c.BookRevision)
|
|
||||||
.HasForeignKey(c => c.BookRevisionId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<Chapter>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasKey(c => c.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Seed Subscription Plans with deterministic IDs
|
// Seed Subscription Plans with deterministic IDs
|
||||||
modelBuilder.Entity<SubscriptionPlan>().HasData(
|
modelBuilder.Entity<SubscriptionPlan>().HasData(
|
||||||
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" },
|
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" },
|
||||||
|
|||||||
@@ -136,72 +136,6 @@ public static class DbInitializer
|
|||||||
{
|
{
|
||||||
Console.WriteLine("[Seeder] Admin user already exists.");
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace NexusReader.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a Book metadata entry that references its decoupled revisions.
|
|
||||||
/// </summary>
|
|
||||||
public class Book
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(255)]
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(128)]
|
|
||||||
public string TenantId { get; set; } = "global";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string UserId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[ForeignKey(nameof(UserId))]
|
|
||||||
public virtual NexusUser? User { get; set; }
|
|
||||||
|
|
||||||
public Guid? CurrentDraftRevisionId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(CurrentDraftRevisionId))]
|
|
||||||
public virtual BookRevision? CurrentDraftRevision { get; set; }
|
|
||||||
|
|
||||||
public Guid? LivePublishedRevisionId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(LivePublishedRevisionId))]
|
|
||||||
public virtual BookRevision? LivePublishedRevision { get; set; }
|
|
||||||
|
|
||||||
public virtual ICollection<BookRevision> Revisions { get; set; } = new List<BookRevision>();
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace NexusReader.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encapsulates a snapshot or draft version of a Book's chapters.
|
|
||||||
/// </summary>
|
|
||||||
public class BookRevision
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public Guid BookId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(BookId))]
|
|
||||||
public virtual Book Book { get; set; } = null!;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(100)]
|
|
||||||
public string VersionString { get; set; } = "Working Draft";
|
|
||||||
|
|
||||||
public bool IsPublished { get; set; } = false;
|
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
public DateTime? PublishedAt { get; set; }
|
|
||||||
|
|
||||||
public virtual ICollection<Chapter> Chapters { get; set; } = new List<Chapter>();
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace NexusReader.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a chapter belonging strictly to a specific BookRevision.
|
|
||||||
/// </summary>
|
|
||||||
public class Chapter
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public Guid BookRevisionId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(BookRevisionId))]
|
|
||||||
public virtual BookRevision BookRevision { get; set; } = null!;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MaxLength(255)]
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string MarkdownContent { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public int SortOrder { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace NexusReader.Domain.Exceptions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Custom domain exception thrown when a Book cannot be found by its ID.
|
|
||||||
/// </summary>
|
|
||||||
public class BookNotFoundException : Exception
|
|
||||||
{
|
|
||||||
public BookNotFoundException(Guid bookId)
|
|
||||||
: base($"Book with ID '{bookId}' was not found.")
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Configuration;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Settings for configuring allowed tags, attributes, CSS properties, and schemes in HtmlSanitizerService.
|
|
||||||
/// </summary>
|
|
||||||
public class HtmlSanitizerSettings
|
|
||||||
{
|
|
||||||
public const string SectionName = "HtmlSanitizer";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of HTML tags that are allowed.
|
|
||||||
/// If null or empty, the default allowed tags list is used.
|
|
||||||
/// </summary>
|
|
||||||
public List<string>? AllowedTags { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of HTML attributes that are allowed.
|
|
||||||
/// If null or empty, the default allowed attributes list is used.
|
|
||||||
/// </summary>
|
|
||||||
public List<string>? AllowedAttributes { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of CSS properties that are allowed.
|
|
||||||
/// If null or empty, the default allowed CSS properties list is used.
|
|
||||||
/// </summary>
|
|
||||||
public List<string>? AllowedCssProperties { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of URI schemes that are allowed (e.g. "http", "https").
|
|
||||||
/// If null or empty, the default allowed schemes list is used.
|
|
||||||
/// </summary>
|
|
||||||
public List<string>? AllowedSchemes { get; set; }
|
|
||||||
}
|
|
||||||
@@ -55,15 +55,7 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
// Qdrant Client registration
|
// Qdrant Client registration
|
||||||
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
||||||
var qdrantApiKey = configuration["Qdrant:ApiKey"];
|
services.AddSingleton<QdrantClient>(sp => new QdrantClient(new Uri(qdrantUrl)));
|
||||||
services.AddSingleton<QdrantClient>(sp =>
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(qdrantApiKey))
|
|
||||||
{
|
|
||||||
return new QdrantClient(new Uri(qdrantUrl), apiKey: qdrantApiKey);
|
|
||||||
}
|
|
||||||
return new QdrantClient(new Uri(qdrantUrl));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Neo4j Driver registration (supports optional authentication)
|
// Neo4j Driver registration (supports optional authentication)
|
||||||
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
|
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
|
||||||
@@ -86,7 +78,6 @@ public static class DependencyInjection
|
|||||||
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
||||||
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
||||||
services.Configure<RagMonetizationOptions>(configuration.GetSection(RagMonetizationOptions.SectionName));
|
services.Configure<RagMonetizationOptions>(configuration.GetSection(RagMonetizationOptions.SectionName));
|
||||||
services.Configure<HtmlSanitizerSettings>(configuration.GetSection(HtmlSanitizerSettings.SectionName));
|
|
||||||
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
||||||
@@ -133,8 +124,6 @@ public static class DependencyInjection
|
|||||||
// Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution
|
// Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution
|
||||||
// that is environment-specific and incompatible with Singleton lifetime in MAUI.
|
// that is environment-specific and incompatible with Singleton lifetime in MAUI.
|
||||||
services.AddScoped<IBookStorageService, BookStorageService>();
|
services.AddScoped<IBookStorageService, BookStorageService>();
|
||||||
services.AddScoped<IStorageService, LocalStorageService>();
|
|
||||||
services.AddSingleton<ISanitizerService, HtmlSanitizerService>();
|
|
||||||
|
|
||||||
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
|
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
|
||||||
services.AddScoped<IEbookRepository, EbookRepository>();
|
services.AddScoped<IEbookRepository, EbookRepository>();
|
||||||
|
|||||||
@@ -28,8 +28,6 @@
|
|||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
<PackageReference Include="Polly" />
|
<PackageReference Include="Polly" />
|
||||||
<PackageReference Include="Polly.Extensions.Http" />
|
<PackageReference Include="Polly.Extensions.Http" />
|
||||||
<PackageReference Include="HtmlSanitizer" />
|
|
||||||
<PackageReference Include="Markdig" />
|
|
||||||
<PackageReference Include="Qdrant.Client" />
|
<PackageReference Include="Qdrant.Client" />
|
||||||
<PackageReference Include="Stripe.net" />
|
<PackageReference Include="Stripe.net" />
|
||||||
<PackageReference Include="VersOne.Epub" />
|
<PackageReference Include="VersOne.Epub" />
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
using Ganss.Xss;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using NexusReader.Application.Abstractions.Services;
|
|
||||||
using NexusReader.Infrastructure.Configuration;
|
|
||||||
using Markdig;
|
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Infrastructure implementation of ISanitizerService using the Ganss.Xss HtmlSanitizer library.
|
|
||||||
/// </summary>
|
|
||||||
public class HtmlSanitizerService : ISanitizerService
|
|
||||||
{
|
|
||||||
private readonly HtmlSanitizer _sanitizer;
|
|
||||||
private readonly MarkdownPipeline _pipeline;
|
|
||||||
|
|
||||||
public HtmlSanitizerService(IOptions<HtmlSanitizerSettings>? options = null)
|
|
||||||
{
|
|
||||||
_sanitizer = new HtmlSanitizer();
|
|
||||||
_pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
|
|
||||||
|
|
||||||
if (options?.Value != null)
|
|
||||||
{
|
|
||||||
var settings = options.Value;
|
|
||||||
|
|
||||||
if (settings.AllowedTags != null && settings.AllowedTags.Count > 0)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedTags.Clear();
|
|
||||||
foreach (var tag in settings.AllowedTags)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedTags.Add(tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.AllowedAttributes != null && settings.AllowedAttributes.Count > 0)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedAttributes.Clear();
|
|
||||||
foreach (var attr in settings.AllowedAttributes)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedAttributes.Add(attr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.AllowedCssProperties != null && settings.AllowedCssProperties.Count > 0)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedCssProperties.Clear();
|
|
||||||
foreach (var prop in settings.AllowedCssProperties)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedCssProperties.Add(prop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.AllowedSchemes != null && settings.AllowedSchemes.Count > 0)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedSchemes.Clear();
|
|
||||||
foreach (var scheme in settings.AllowedSchemes)
|
|
||||||
{
|
|
||||||
_sanitizer.AllowedSchemes.Add(scheme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Sanitize(string input)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(input))
|
|
||||||
{
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Translate raw Markdown input to HTML strictly before running HtmlSanitizer
|
|
||||||
var html = Markdown.ToHtml(input, _pipeline);
|
|
||||||
|
|
||||||
return _sanitizer.Sanitize(html).Trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Hosting;
|
|
||||||
using NexusReader.Application.Abstractions.Services;
|
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Infrastructure implementation of general storage utilizing local filesystem.
|
|
||||||
/// Files are saved in wwwroot/uploads/media.
|
|
||||||
/// </summary>
|
|
||||||
public class LocalStorageService : IStorageService
|
|
||||||
{
|
|
||||||
private readonly IWebHostEnvironment _environment;
|
|
||||||
|
|
||||||
public LocalStorageService(IWebHostEnvironment environment)
|
|
||||||
{
|
|
||||||
_environment = environment;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> UploadFileAsync(byte[] fileBytes, string fileName, string contentType)
|
|
||||||
{
|
|
||||||
using var stream = new MemoryStream(fileBytes);
|
|
||||||
return await UploadFileAsync(stream, fileName, contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType)
|
|
||||||
{
|
|
||||||
var mediaFolder = Path.Combine(_environment.WebRootPath, "uploads");
|
|
||||||
var resolvedMediaFolder = Path.GetFullPath(mediaFolder);
|
|
||||||
var folderWithSeparator = resolvedMediaFolder.EndsWith(Path.DirectorySeparatorChar)
|
|
||||||
? resolvedMediaFolder
|
|
||||||
: resolvedMediaFolder + Path.DirectorySeparatorChar;
|
|
||||||
|
|
||||||
if (!Directory.Exists(resolvedMediaFolder))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(resolvedMediaFolder);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean file name to prevent path traversal issues
|
|
||||||
var safeFileName = Path.GetFileName(fileName);
|
|
||||||
var uniqueFileName = $"{Guid.NewGuid()}_{safeFileName}";
|
|
||||||
var filePath = Path.Combine(resolvedMediaFolder, uniqueFileName);
|
|
||||||
|
|
||||||
// Guard against path traversal
|
|
||||||
var fullPath = Path.GetFullPath(filePath);
|
|
||||||
if (!fullPath.StartsWith(folderWithSeparator, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Path traversal detected.");
|
|
||||||
}
|
|
||||||
|
|
||||||
using (var outputStream = new FileStream(fullPath, FileMode.Create))
|
|
||||||
{
|
|
||||||
await fileStream.CopyToAsync(outputStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the public web-relative URL
|
|
||||||
return $"/uploads/{uniqueFileName}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -101,11 +101,6 @@
|
|||||||
<line x1="5" y1="12" x2="19" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<line x1="5" y1="12" x2="19" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
<polyline points="12 5 19 12 12 19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<polyline points="12 5 19 12 12 19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
case "edit":
|
|
||||||
case "edit-2":
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
break;
|
|
||||||
case "log-out":
|
case "log-out":
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
break;
|
break;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,559 +0,0 @@
|
|||||||
@using Microsoft.JSInterop
|
|
||||||
@implements IAsyncDisposable
|
|
||||||
@inject IJSRuntime JS
|
|
||||||
@inject HttpClient Http
|
|
||||||
@inject NexusReader.Application.Abstractions.Services.INativeStorageService StorageService
|
|
||||||
|
|
||||||
<div class="markdown-editor-container" style="height: @Height; width: @Width;">
|
|
||||||
@if (_showRestorationBanner)
|
|
||||||
{
|
|
||||||
<div class="restoration-banner">
|
|
||||||
<span class="banner-text">You have unsaved changes from an interrupted session.</span>
|
|
||||||
<div class="banner-actions">
|
|
||||||
<button type="button" class="banner-btn restore-btn" @onclick="RestoreBackupAsync">Restore</button>
|
|
||||||
<button type="button" class="banner-btn dismiss-btn" @onclick="DismissBackupAsync">Dismiss</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div @key="_editorRenderKey" id="@EditorId" class="milkdown-editor-wrapper"></div>
|
|
||||||
|
|
||||||
<div class="editor-footer">
|
|
||||||
<div class="status-indicator">
|
|
||||||
<span class="status-dot @StatusClass"></span>
|
|
||||||
<span class="status-text">@StatusText</span>
|
|
||||||
</div>
|
|
||||||
@if (ShowFetchButton)
|
|
||||||
{
|
|
||||||
<div class="editor-actions">
|
|
||||||
<button type="button" @onclick="FetchContentAsync" class="nexus-btn">
|
|
||||||
Fetch Markdown Content
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private string EditorId { get; set; } = $"milkdown-editor-{Guid.NewGuid():N}";
|
|
||||||
private Guid _editorRenderKey = Guid.NewGuid();
|
|
||||||
private readonly CancellationTokenSource _cts = new();
|
|
||||||
private IJSObjectReference? _module;
|
|
||||||
private DotNetObjectReference<MarkdownEditor>? _dotNetHelper;
|
|
||||||
private string? _lastInitializedEditorId;
|
|
||||||
private bool _disposed;
|
|
||||||
|
|
||||||
private enum SaveStatus
|
|
||||||
{
|
|
||||||
SavedToCloud,
|
|
||||||
Saving,
|
|
||||||
OfflineLocalBackup
|
|
||||||
}
|
|
||||||
|
|
||||||
private SaveStatus _status = SaveStatus.SavedToCloud;
|
|
||||||
private string _currentMarkdown = string.Empty;
|
|
||||||
private CancellationTokenSource? _debounceCts;
|
|
||||||
private readonly object _timerLock = new();
|
|
||||||
|
|
||||||
private bool _showRestorationBanner = false;
|
|
||||||
private NexusReader.Application.DTOs.Media.LocalBackupEnvelope? _pendingBackup;
|
|
||||||
private bool _hasRunStorageInit = false;
|
|
||||||
private bool _reinitializeEditor = false;
|
|
||||||
|
|
||||||
private string StatusClass => _status switch
|
|
||||||
{
|
|
||||||
SaveStatus.SavedToCloud => "saved",
|
|
||||||
SaveStatus.Saving => "saving",
|
|
||||||
SaveStatus.OfflineLocalBackup => "offline",
|
|
||||||
_ => "saved"
|
|
||||||
};
|
|
||||||
|
|
||||||
private string StatusText => _status switch
|
|
||||||
{
|
|
||||||
SaveStatus.SavedToCloud => "Saved to Cloud",
|
|
||||||
SaveStatus.Saving => "Saving...",
|
|
||||||
SaveStatus.OfflineLocalBackup => "Offline - Local Backup Only",
|
|
||||||
_ => "Saved to Cloud"
|
|
||||||
};
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public bool ShowFetchButton { get; set; } = true;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string InitialMarkdown { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<string> OnSave { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string Height { get; set; } = "500px";
|
|
||||||
|
|
||||||
[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)
|
|
||||||
{
|
|
||||||
var shouldInit = (firstRender || _reinitializeEditor) && (EditorId != _lastInitializedEditorId);
|
|
||||||
if (shouldInit)
|
|
||||||
{
|
|
||||||
_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
|
|
||||||
{
|
|
||||||
if (_module == null)
|
|
||||||
{
|
|
||||||
_module = await JS.InvokeAsync<IJSObjectReference>(
|
|
||||||
"import",
|
|
||||||
$"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js?v={Guid.NewGuid():N}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Error initializing Milkdown editor: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RunStorageSweepAndRestorationCheckAsync()
|
|
||||||
{
|
|
||||||
if (_hasRunStorageInit) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_hasRunStorageInit = true;
|
|
||||||
|
|
||||||
// Import wrapper module if not already loaded to access helper
|
|
||||||
if (_module == null)
|
|
||||||
{
|
|
||||||
_module = await JS.InvokeAsync<IJSObjectReference>(
|
|
||||||
"import",
|
|
||||||
$"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js?v={Guid.NewGuid():N}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sweep and filter backup keys defensively
|
|
||||||
var keys = await _module.InvokeAsync<List<string>>("getBackupKeys");
|
|
||||||
if (keys != null)
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
foreach (var key in keys)
|
|
||||||
{
|
|
||||||
// Strict defensive check before doing any JSON deserialization
|
|
||||||
if (!key.StartsWith("nexus-bkp-")) continue;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var backupResult = await StorageService.GetStringAsync(key);
|
|
||||||
if (backupResult.IsSuccess && !string.IsNullOrEmpty(backupResult.Value))
|
|
||||||
{
|
|
||||||
var envelope = System.Text.Json.JsonSerializer.Deserialize<NexusReader.Application.DTOs.Media.LocalBackupEnvelope>(
|
|
||||||
backupResult.Value,
|
|
||||||
NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope
|
|
||||||
);
|
|
||||||
if (envelope != null)
|
|
||||||
{
|
|
||||||
// Remove expired backups
|
|
||||||
if ((now - envelope.Timestamp).TotalDays > 7)
|
|
||||||
{
|
|
||||||
await StorageService.RemoveAsync(key);
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Boot-up Eviction: Deleted expired backup key {key}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Error sweeping key {key}: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restoration guard for this specific Chapter ID
|
|
||||||
var currentBackupKey = $"nexus-bkp-{ChapterId}";
|
|
||||||
var currentBackupResult = await StorageService.GetStringAsync(currentBackupKey);
|
|
||||||
if (currentBackupResult.IsSuccess && !string.IsNullOrEmpty(currentBackupResult.Value))
|
|
||||||
{
|
|
||||||
var envelope = System.Text.Json.JsonSerializer.Deserialize<NexusReader.Application.DTOs.Media.LocalBackupEnvelope>(
|
|
||||||
currentBackupResult.Value,
|
|
||||||
NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope
|
|
||||||
);
|
|
||||||
if (envelope != null)
|
|
||||||
{
|
|
||||||
var serverTime = ServerTimestamp ?? DateTime.MinValue;
|
|
||||||
if (envelope.Timestamp > serverTime && envelope.MarkdownContent != InitialMarkdown)
|
|
||||||
{
|
|
||||||
_pendingBackup = envelope;
|
|
||||||
_showRestorationBanner = true;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_hasRunStorageInit = false; // Reset to allow retry on client render
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Storage initialization deferred/failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RestoreBackupAsync()
|
|
||||||
{
|
|
||||||
if (_pendingBackup != null)
|
|
||||||
{
|
|
||||||
if (_module != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Prevent memory leak by cleaning up old instance in JS
|
|
||||||
await _module.InvokeVoidAsync("destroyEditor", EditorId);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Error destroying old editor during restore: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
InitialMarkdown = _pendingBackup.MarkdownContent;
|
|
||||||
_showRestorationBanner = false;
|
|
||||||
_pendingBackup = null;
|
|
||||||
|
|
||||||
// Regenerate render key and ID to trigger clean Blazor element-level re-initialization
|
|
||||||
_reinitializeEditor = true;
|
|
||||||
EditorId = $"milkdown-editor-{Guid.NewGuid():N}";
|
|
||||||
_editorRenderKey = Guid.NewGuid();
|
|
||||||
|
|
||||||
// Trigger an immediate background API autosave to synchronize the database with the restored content
|
|
||||||
_ = TriggerAutosaveAsync(InitialMarkdown, _cts.Token);
|
|
||||||
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DismissBackupAsync()
|
|
||||||
{
|
|
||||||
_showRestorationBanner = false;
|
|
||||||
_pendingBackup = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await StorageService.RemoveAsync($"nexus-bkp-{ChapterId}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Failed to dismiss backup from LocalStorage: {ex.Message}");
|
|
||||||
}
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task FetchContentAsync()
|
|
||||||
{
|
|
||||||
if (_module is not null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var markdown = await _module.InvokeAsync<string>("getMarkdownContent", EditorId);
|
|
||||||
|
|
||||||
if (OnSave.HasDelegate)
|
|
||||||
{
|
|
||||||
await OnSave.InvokeAsync(markdown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Error fetching markdown content: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[JSInvokable]
|
|
||||||
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<string> UploadImageFromJs(string filename, string contentType, IJSStreamReference streamRef)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const long maxFileSize = 5 * 1024 * 1024; // 5MB limit
|
|
||||||
using var stream = await streamRef.OpenReadStreamAsync(maxFileSize, _cts.Token);
|
|
||||||
using var memoryStream = new MemoryStream();
|
|
||||||
await stream.CopyToAsync(memoryStream, _cts.Token);
|
|
||||||
var fileBytes = memoryStream.ToArray();
|
|
||||||
|
|
||||||
using var content = new MultipartFormDataContent();
|
|
||||||
using var fileContent = new ByteArrayContent(fileBytes);
|
|
||||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
|
|
||||||
content.Add(fileContent, "file", filename);
|
|
||||||
|
|
||||||
var response = await Http.PostAsync("/api/media/upload", content, _cts.Token);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<NexusReader.Application.DTOs.Media.UploadResultDto>(
|
|
||||||
NexusReader.Application.Common.AppJsonContext.Default.UploadResultDto, _cts.Token);
|
|
||||||
return result?.Url ?? "https://placehold.co/600x400?text=Upload+Failed";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var errorMsg = await response.Content.ReadAsStringAsync(_cts.Token);
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Image upload failed: {response.StatusCode} - {errorMsg}");
|
|
||||||
return "https://placehold.co/600x400?text=Upload+Failed";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Exception during image upload: {ex.Message}");
|
|
||||||
return "https://placehold.co/600x400?text=Upload+Failed";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
_disposed = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_cts.Cancel();
|
|
||||||
_cts.Dispose();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 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)
|
|
||||||
{
|
|
||||||
await _module.DisposeAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException)
|
|
||||||
{
|
|
||||||
// Fail silently during circuit disconnection
|
|
||||||
}
|
|
||||||
catch (ObjectDisposedException)
|
|
||||||
{
|
|
||||||
// Fail silently if JS runtime/module is already disposed
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[MarkdownEditor] Unexpected error during JS cleanup: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_dotNetHelper?.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
.markdown-editor-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-editor-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--bg-surface);
|
|
||||||
overflow: auto;
|
|
||||||
padding: 1.5rem;
|
|
||||||
position: relative;
|
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-editor-wrapper:focus-within {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 1px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 3. Bypassing Blazor CSS Isolation for Dynamic JS DOMs using ::deep */
|
|
||||||
::deep .milkdown-editor-wrapper .crepe {
|
|
||||||
max-width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
::deep .milkdown-editor-wrapper .milkdown {
|
|
||||||
background-color: var(--bg-surface) !important;
|
|
||||||
color: var(--text-main) !important;
|
|
||||||
font-family: var(--nexus-font-sans) !important;
|
|
||||||
border: none !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
|
|
||||||
/* Map Crepe's internal variables to our design tokens */
|
|
||||||
--crepe-color-background: var(--bg-surface);
|
|
||||||
--crepe-color-on-background: var(--text-main);
|
|
||||||
--crepe-color-surface: rgba(255, 255, 255, 0.03);
|
|
||||||
--crepe-color-surface-low: rgba(255, 255, 255, 0.01);
|
|
||||||
--crepe-color-primary: var(--accent);
|
|
||||||
--crepe-color-outline: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
::deep .milkdown-editor-wrapper .milkdown .editor {
|
|
||||||
color: var(--text-main) !important;
|
|
||||||
background: transparent !important;
|
|
||||||
outline: none !important;
|
|
||||||
padding: 0.5rem 0 !important;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style the buttons using variables from app.css */
|
|
||||||
.nexus-btn {
|
|
||||||
font-family: var(--nexus-font-sans);
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
border: none;
|
|
||||||
text-decoration: none;
|
|
||||||
background: var(--nexus-neon);
|
|
||||||
color: #000000;
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nexus-btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
filter: brightness(1.1);
|
|
||||||
box-shadow: 0 4px 15px var(--nexus-primary-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nexus-btn:focus-visible {
|
|
||||||
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); }
|
|
||||||
}
|
|
||||||
-1
@@ -5,7 +5,6 @@
|
|||||||
.recommendations-panel {
|
.recommendations-panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1.75rem;
|
padding: 1.75rem;
|
||||||
margin-top: 2.5rem;
|
|
||||||
background: var(--nexus-surface, #1a1a1e);
|
background: var(--nexus-surface, #1a1a1e);
|
||||||
border: 1px solid var(--nexus-border, rgba(255, 255, 255, 0.05));
|
border: 1px solid var(--nexus-border, rgba(255, 255, 255, 0.05));
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
{
|
{
|
||||||
<div @key='"current-reading-book"' class="card-layout">
|
<div @key='"current-reading-book"' class="card-layout">
|
||||||
<div class="book-cover">
|
<div class="book-cover">
|
||||||
<img src="@(string.IsNullOrEmpty(Book.CoverUrl) ? "https://via.placeholder.com/120x180?text=No+Cover" : Book.CoverUrl)" alt="@Book.Title" aria-describedby="book-title-@Book.Id" />
|
<img src="@(string.IsNullOrEmpty(Book.CoverUrl) ? "https://via.placeholder.com/120x180?text=No+Cover" : Book.CoverUrl)" alt="@Book.Title" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="book-details">
|
<div class="book-details">
|
||||||
<div class="header-info">
|
<div class="header-info">
|
||||||
<h3 id="book-title-@Book.Id" class="book-title">@Book.Title</h3>
|
<h3 class="book-title">@Book.Title</h3>
|
||||||
<span class="author-name">by @Book.Author.Name</span>
|
<span class="author-name">by @Book.Author.Name</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn-nexus outline" @onclick="HandleContinueReading" aria-label="Kontynuuj czytanie">
|
<button class="btn-nexus outline" @onclick="HandleContinueReading">
|
||||||
Kontynuuj czytanie
|
Kontynuuj czytanie
|
||||||
<NexusIcon Name="arrow-right" Size="16" />
|
<NexusIcon Name="arrow-right" Size="16" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -200,7 +200,7 @@
|
|||||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3); /* Ambient card shadow */
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3); /* Ambient card shadow */
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-layout {
|
.card-layout {
|
||||||
@@ -284,6 +284,7 @@
|
|||||||
|
|
||||||
.theme-light .current-reading-card {
|
.theme-light .current-reading-card {
|
||||||
background: #ffffff; /* Pure white card surface for light theme */
|
background: #ffffff; /* Pure white card surface for light theme */
|
||||||
|
border-color: rgba(45, 42, 38, 0.08);
|
||||||
box-shadow: 0 12px 24px rgba(139, 130, 115, 0.12);
|
box-shadow: 0 12px 24px rgba(139, 130, 115, 0.12);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,12 +95,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="nav-text">Koncentry</span>
|
<span class="nav-text">Koncentry</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink class="nav-item" href="/creator" @onclick="CloseMobileMenu" title="Kreator" aria-label="Kreator">
|
|
||||||
<div class="nav-icon">
|
|
||||||
<NexusIcon Name="edit" Size="20" />
|
|
||||||
</div>
|
|
||||||
<span class="nav-text">Kreator</span>
|
|
||||||
</NavLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
@@ -183,11 +177,10 @@
|
|||||||
InvokeAsync(StateHasChanged);
|
InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override void OnAfterRender(bool firstRender)
|
||||||
{
|
{
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
await ThemeService.InitializeAsync();
|
|
||||||
_isFullyLoaded = true;
|
_isFullyLoaded = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
@@ -213,6 +206,5 @@
|
|||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,7 +266,7 @@
|
|||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: linear-gradient(135deg, var(--nexus-neon) 0%, #0099ff 100%);
|
background: linear-gradient(135deg, var(--nexus-neon) 0%, #0099ff 100%);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -354,8 +354,6 @@
|
|||||||
/* --- Desktop Sidebar: warm paper shadow --- */
|
/* --- Desktop Sidebar: warm paper shadow --- */
|
||||||
.theme-light ::deep .hub-sidebar {
|
.theme-light ::deep .hub-sidebar {
|
||||||
box-shadow: 4px 0 20px rgba(139, 130, 115, 0.08);
|
box-shadow: 4px 0 20px rgba(139, 130, 115, 0.08);
|
||||||
background: var(--bg-surface) !important;
|
|
||||||
border-right: 1px solid var(--border) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Logo icon: remove neon glow --- */
|
/* --- Logo icon: remove neon glow --- */
|
||||||
@@ -394,6 +392,7 @@
|
|||||||
/* User avatar mini: solid accent, white text, no neon glow */
|
/* User avatar mini: solid accent, white text, no neon glow */
|
||||||
.theme-light .user-avatar-mini {
|
.theme-light .user-avatar-mini {
|
||||||
background: #10b981;
|
background: #10b981;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.15);
|
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.15);
|
||||||
}
|
}
|
||||||
@@ -419,6 +418,10 @@
|
|||||||
box-shadow: 10px 0 30px rgba(139, 130, 115, 0.2);
|
box-shadow: 10px 0 30px rgba(139, 130, 115, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile topbar: warm paper border */
|
||||||
|
.theme-light .nexus-mobile-topbar {
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
/* Content padding for bottom navigation dock */
|
/* Content padding for bottom navigation dock */
|
||||||
.hub-content {
|
.hub-content {
|
||||||
@@ -436,7 +439,7 @@
|
|||||||
background: rgba(26, 26, 30, 0.75); /* Translucent dark mode */
|
background: rgba(26, 26, 30, 0.75); /* Translucent dark mode */
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
border: 1px solid var(--border); /* Microscopic perimeter border */
|
border: 1px solid rgba(255, 255, 255, 0.08); /* Microscopic perimeter border */
|
||||||
border-radius: 30px; /* Floating capsule rounded borders */
|
border-radius: 30px; /* Floating capsule rounded borders */
|
||||||
z-index: 150;
|
z-index: 150;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
@@ -531,6 +534,7 @@
|
|||||||
/* Light Theme Overrides */
|
/* Light Theme Overrides */
|
||||||
.theme-light ::deep .reader-mobile-dock {
|
.theme-light ::deep .reader-mobile-dock {
|
||||||
background: rgba(244, 241, 234, 0.9); /* Translucent light mode warm paper background */
|
background: rgba(244, 241, 234, 0.9); /* Translucent light mode warm paper background */
|
||||||
|
border: 1px solid rgba(139, 130, 115, 0.18); /* Light theme sepia border */
|
||||||
box-shadow: 0 8px 30px rgba(139, 130, 115, 0.15);
|
box-shadow: 0 8px 30px rgba(139, 130, 115, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,542 +0,0 @@
|
|||||||
@page "/creator"
|
|
||||||
@attribute [Authorize]
|
|
||||||
@using System.Net.Http.Json
|
|
||||||
@using Microsoft.Extensions.Logging
|
|
||||||
@using System.ComponentModel.DataAnnotations
|
|
||||||
@using NexusReader.Application.DTOs.Creator
|
|
||||||
@inject HttpClient Http
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
@inject ILogger<CreatorDashboard> Logger
|
|
||||||
|
|
||||||
<PageTitle>Creator Dashboard | Nexus Reader</PageTitle>
|
|
||||||
|
|
||||||
<div class="dashboard-container">
|
|
||||||
<header class="dashboard-header">
|
|
||||||
<div class="header-visual">
|
|
||||||
<h1 class="dashboard-title">Panel Autora</h1>
|
|
||||||
<p class="subtitle">Monitoruj zaangażowanie czytelników i publikuj wersje zamrożone z poziomu kontroli wersji.</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="dashboard-content">
|
|
||||||
<!-- Metrics Section -->
|
|
||||||
<section class="metrics-grid">
|
|
||||||
@if (_isLoading)
|
|
||||||
{
|
|
||||||
@for (int i = 0; i < 4; i++)
|
|
||||||
{
|
|
||||||
<div class="metric-card skeleton-card">
|
|
||||||
<div class="skeleton-line label"></div>
|
|
||||||
<div class="skeleton-line value"></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (_dashboardData != null)
|
|
||||||
{
|
|
||||||
<div class="metric-card glass-panel">
|
|
||||||
<span class="metric-label">Całkowite Odczyty</span>
|
|
||||||
<h2 class="metric-value">@_dashboardData.Metrics.TotalReads</h2>
|
|
||||||
<div class="metric-trend positive">
|
|
||||||
<span class="trend-icon">↑</span>
|
|
||||||
<span class="trend-text">System stabilny</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metric-card glass-panel">
|
|
||||||
<span class="metric-label">Średni Czas Czytania</span>
|
|
||||||
<h2 class="metric-value">@_dashboardData.Metrics.AvgReadTimeMinutes min</h2>
|
|
||||||
<div class="metric-trend neutral">
|
|
||||||
<span class="trend-icon">→</span>
|
|
||||||
<span class="trend-text">Na rozdział</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metric-card glass-panel">
|
|
||||||
<div class="metric-label-container">
|
|
||||||
<span class="metric-label">Aktywni Czytelnicy</span>
|
|
||||||
<div class="pulse-indicator">
|
|
||||||
<span class="pulse-dot"></span>
|
|
||||||
<span class="pulse-text">Live Now</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h2 class="metric-value">@_dashboardData.Metrics.ActiveReaders</h2>
|
|
||||||
<div class="metric-trend positive">
|
|
||||||
<span class="trend-icon">↑</span>
|
|
||||||
<span class="trend-text">Ruch w czasie rzeczywistym</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metric-card glass-panel">
|
|
||||||
<span class="metric-label">Przychód Gross</span>
|
|
||||||
<h2 class="metric-value">@_dashboardData.Metrics.GrossRevenue.ToString("C2")</h2>
|
|
||||||
<div class="metric-trend positive">
|
|
||||||
<span class="trend-icon">↑</span>
|
|
||||||
<span class="trend-text">Rozliczenia w toku</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Publication Cards Grid Section -->
|
|
||||||
<section class="publications-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2>Twoje Publikacje</h2>
|
|
||||||
<button type="button" class="btn-nexus primary glow-btn" @onclick="OpenCreateBookModal">
|
|
||||||
[ + Nowa Publikacja ]
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (_isLoading)
|
|
||||||
{
|
|
||||||
<div class="books-grid">
|
|
||||||
@for (int i = 0; i < 3; i++)
|
|
||||||
{
|
|
||||||
<div class="book-card skeleton-card">
|
|
||||||
<div class="skeleton-card-header"></div>
|
|
||||||
<div class="skeleton-line title"></div>
|
|
||||||
<div class="skeleton-line metadata"></div>
|
|
||||||
<div class="skeleton-card-actions"></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (_dashboardData == null || !_dashboardData.Books.Any())
|
|
||||||
{
|
|
||||||
<div class="empty-state glass-panel">
|
|
||||||
<div class="empty-icon">
|
|
||||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
|
|
||||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3>Brak publikacji</h3>
|
|
||||||
<p>Nie utworzyłeś jeszcze żadnych książek do autorskiej edycji.</p>
|
|
||||||
<button type="button" class="btn-nexus primary glow-btn" style="margin-top: 1.5rem;" @onclick="OpenCreateBookModal">
|
|
||||||
[ + Nowa Publikacja ]
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="books-grid">
|
|
||||||
@foreach (var book in _dashboardData.Books)
|
|
||||||
{
|
|
||||||
<div class="book-card glass-panel">
|
|
||||||
<div class="card-glow"></div>
|
|
||||||
<div class="book-card-header">
|
|
||||||
<h3 class="book-title" title="@book.Title">@book.Title</h3>
|
|
||||||
<div class="badges-row">
|
|
||||||
@if (book.LivePublishedRevision != null)
|
|
||||||
{
|
|
||||||
<span class="badge badge-published" title="Opublikowana wersja dostępna dla czytelników">
|
|
||||||
Live @book.LivePublishedRevision.VersionString
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@if (book.CurrentDraftRevision != null)
|
|
||||||
{
|
|
||||||
<span class="badge badge-draft pulsing" title="Szkic roboczy z nieopublikowanymi zmianami">
|
|
||||||
Szkic
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="book-telemetry">
|
|
||||||
<div class="telemetry-item">
|
|
||||||
<span class="telemetry-label">Słowa:</span>
|
|
||||||
<span class="telemetry-value">@book.WordCount.ToString("N0")</span>
|
|
||||||
</div>
|
|
||||||
<div class="telemetry-item">
|
|
||||||
<span class="telemetry-label">Wyświetlenia:</span>
|
|
||||||
<span class="telemetry-value">@book.AggregatedReads.ToString("N0")</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="book-card-actions">
|
|
||||||
<button type="button" class="btn-nexus secondary" @onclick="() => NavigateToEditor(book)">
|
|
||||||
Edytuj szkic
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-nexus primary glow-btn" @onclick="() => OpenPublishModal(book)">
|
|
||||||
Publikuj
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-nexus link-btn" @onclick="() => OpenRevisionsModalAsync(book)">
|
|
||||||
Rejestr zmian
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Defensively-Scoped Version Publish Modal -->
|
|
||||||
@if (_isPublishModalOpen && _activePublishBookId.HasValue)
|
|
||||||
{
|
|
||||||
<div class="modal-backdrop" @onclick="ClosePublishModal">
|
|
||||||
<div class="modal-content glass-panel" @onclick:stopPropagation>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Publikowanie Nowej Wersji</h3>
|
|
||||||
<button class="close-btn" @onclick="ClosePublishModal">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p>Zamrażasz obecny szkic książki <strong>@_activePublishBookTitle</strong> jako nową wersję publiczną.</p>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="versionInput">Sygnatura Wersji (np. v1.0.0)</label>
|
|
||||||
<input id="versionInput" type="text" class="form-control" @bind="_customVersionString" @bind:event="oninput" placeholder="Wpisz tag wersji..." />
|
|
||||||
</div>
|
|
||||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
|
||||||
{
|
|
||||||
<div class="error-banner">@_errorMessage</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn-nexus secondary" @onclick="ClosePublishModal">Anuluj</button>
|
|
||||||
<button type="button" class="btn-nexus primary glow-btn" @onclick="SubmitPublishVersionAsync" disabled="@_isSubmitting">
|
|
||||||
@(_isSubmitting ? "Wysyłanie..." : "Zatwierdź wersję")
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Manage Revisions Modal -->
|
|
||||||
@if (_isRevisionsModalOpen && _activeRevisionsBookId.HasValue)
|
|
||||||
{
|
|
||||||
<div class="modal-backdrop" @onclick="CloseRevisionsModal">
|
|
||||||
<div class="modal-content glass-panel" @onclick:stopPropagation>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Rejestr Rewizji: @_activeRevisionsBookTitle</h3>
|
|
||||||
<button class="close-btn" @onclick="CloseRevisionsModal">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
@if (_revisionsLoading)
|
|
||||||
{
|
|
||||||
<div class="spinner-container">
|
|
||||||
<div class="spinner-glow small"></div>
|
|
||||||
<span>Wczytywanie historii...</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (_revisionsList == null || !_revisionsList.Any())
|
|
||||||
{
|
|
||||||
<p class="empty-revisions">Brak zarejestrowanych rewizji.</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="revisions-list">
|
|
||||||
@foreach (var revision in _revisionsList)
|
|
||||||
{
|
|
||||||
<div class="revision-item">
|
|
||||||
<div class="revision-header">
|
|
||||||
<span class="revision-tag @(revision.IsPublished ? "published" : "draft")">
|
|
||||||
@(revision.IsPublished ? revision.VersionString : "Szkic roboczy")
|
|
||||||
</span>
|
|
||||||
<span class="revision-date">
|
|
||||||
@(revision.PublishedAt.HasValue ? revision.PublishedAt.Value.ToString("g") : revision.CreatedAt.ToString("g"))
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="revision-meta">
|
|
||||||
<span>Utworzono: @revision.CreatedAt.ToString("yyyy-MM-dd HH:mm")</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn-nexus secondary" @onclick="CloseRevisionsModal">Zamknij</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Create Book Modal -->
|
|
||||||
@if (_isCreateBookModalOpen)
|
|
||||||
{
|
|
||||||
<div class="modal-backdrop" @onclick="CloseCreateBookModal">
|
|
||||||
<div class="modal-content glass-panel" @onclick:stopPropagation>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2>Nowa Publikacja</h2>
|
|
||||||
<button class="close-btn" @onclick="CloseCreateBookModal">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<EditForm Model="_createBookModel" OnValidSubmit="SubmitCreateBookAsync">
|
|
||||||
<DataAnnotationsValidator />
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="creator-layout">
|
|
||||||
<!-- Book Cover Preview -->
|
|
||||||
<div class="cover-preview creator-cover">
|
|
||||||
<div class="cover-mockup-design">
|
|
||||||
<div class="cover-accent-line"></div>
|
|
||||||
<div class="cover-main-content">
|
|
||||||
<span class="cover-title-text">
|
|
||||||
@(string.IsNullOrWhiteSpace(_createBookModel.Title) ? "Tytuł Książki" : _createBookModel.Title)
|
|
||||||
</span>
|
|
||||||
<span class="cover-author-text">
|
|
||||||
Szkic roboczy
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="cover-logo-container">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
|
||||||
<span class="cover-brand">Nexus</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form inputs -->
|
|
||||||
<div class="creator-form-inputs">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="newBookTitle">Tytuł Książki</label>
|
|
||||||
<InputText id="newBookTitle" class="form-input" @bind-Value="_createBookModel.Title" placeholder="Wpisz tytuł książki..." />
|
|
||||||
<ValidationMessage For="@(() => _createBookModel.Title)" class="validation-message" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="newBookDescription">Opis (opcjonalny)</label>
|
|
||||||
<InputTextArea id="newBookDescription" class="form-input" @bind-Value="_createBookModel.Description" rows="3" placeholder="Wpisz krótki opis książki..." />
|
|
||||||
<ValidationMessage For="@(() => _createBookModel.Description)" class="validation-message" />
|
|
||||||
</div>
|
|
||||||
@if (!string.IsNullOrEmpty(_createBookError))
|
|
||||||
{
|
|
||||||
<div class="error-banner">@_createBookError</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" class="btn-nexus secondary" @onclick="CloseCreateBookModal">Anuluj</button>
|
|
||||||
<button type="submit" class="btn-nexus primary glow-btn" disabled="@_isCreatingBook">
|
|
||||||
@(_isCreatingBook ? "Tworzenie..." : "Utwórz książkę")
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</EditForm>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private bool _isLoading = true;
|
|
||||||
private CreatorDashboardDataDto? _dashboardData;
|
|
||||||
|
|
||||||
// Create Book Model and state
|
|
||||||
private bool _isCreateBookModalOpen;
|
|
||||||
private CreateBookModel _createBookModel = new();
|
|
||||||
private bool _isCreatingBook;
|
|
||||||
private string? _createBookError;
|
|
||||||
|
|
||||||
// Defensively-scoped state variables for modal isolation
|
|
||||||
private bool _isPublishModalOpen;
|
|
||||||
private Guid? _activePublishBookId;
|
|
||||||
private string _activePublishBookTitle = string.Empty;
|
|
||||||
private string _customVersionString = string.Empty;
|
|
||||||
private bool _isSubmitting;
|
|
||||||
private string? _errorMessage;
|
|
||||||
|
|
||||||
// Revisions modal state variables
|
|
||||||
private bool _isRevisionsModalOpen;
|
|
||||||
private Guid? _activeRevisionsBookId;
|
|
||||||
private string _activeRevisionsBookTitle = string.Empty;
|
|
||||||
private bool _revisionsLoading;
|
|
||||||
private List<CreatorBookRevisionDto> _revisionsList = new();
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
|
||||||
if (firstRender)
|
|
||||||
{
|
|
||||||
await LoadDashboardDataAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadDashboardDataAsync()
|
|
||||||
{
|
|
||||||
_isLoading = true;
|
|
||||||
StateHasChanged();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_dashboardData = await Http.GetFromJsonAsync<CreatorDashboardDataDto>("api/creator/dashboard");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Error loading creator dashboard data.");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isLoading = false;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void NavigateToEditor(CreatorBookDto book)
|
|
||||||
{
|
|
||||||
if (book.FirstChapterId.HasValue)
|
|
||||||
{
|
|
||||||
NavigationManager.NavigateTo($"/creator/edit/{book.Id}/{book.FirstChapterId.Value}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
NavigationManager.NavigateTo($"/creator/edit/{book.Id}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OpenPublishModal(CreatorBookDto book)
|
|
||||||
{
|
|
||||||
// Explicitly lock context boundaries to the selected book
|
|
||||||
_activePublishBookId = book.Id;
|
|
||||||
_activePublishBookTitle = book.Title;
|
|
||||||
_customVersionString = "v1.0.0";
|
|
||||||
_errorMessage = null;
|
|
||||||
_isPublishModalOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClosePublishModal()
|
|
||||||
{
|
|
||||||
_isPublishModalOpen = false;
|
|
||||||
_activePublishBookId = null;
|
|
||||||
_activePublishBookTitle = string.Empty;
|
|
||||||
_customVersionString = string.Empty;
|
|
||||||
_errorMessage = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SubmitPublishVersionAsync()
|
|
||||||
{
|
|
||||||
if (!_activePublishBookId.HasValue || string.IsNullOrWhiteSpace(_customVersionString))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isSubmitting = true;
|
|
||||||
_errorMessage = null;
|
|
||||||
StateHasChanged();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Explicitly lock the parameters during sending execution
|
|
||||||
var bookId = _activePublishBookId.Value;
|
|
||||||
var response = await Http.PostAsync($"api/creator/books/{bookId}/publish?version={Uri.EscapeDataString(_customVersionString)}", null);
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
ClosePublishModal();
|
|
||||||
await LoadDashboardDataAsync();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_errorMessage = await response.Content.ReadAsStringAsync();
|
|
||||||
if (string.IsNullOrWhiteSpace(_errorMessage))
|
|
||||||
{
|
|
||||||
_errorMessage = "Publish version endpoint returned an error.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Exception thrown during publication.");
|
|
||||||
_errorMessage = $"Mutation failed: {ex.Message}";
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isSubmitting = false;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OpenRevisionsModalAsync(CreatorBookDto book)
|
|
||||||
{
|
|
||||||
_activeRevisionsBookId = book.Id;
|
|
||||||
_activeRevisionsBookTitle = book.Title;
|
|
||||||
_revisionsList = new();
|
|
||||||
_revisionsLoading = true;
|
|
||||||
_isRevisionsModalOpen = true;
|
|
||||||
StateHasChanged();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_revisionsList = await Http.GetFromJsonAsync<List<CreatorBookRevisionDto>>($"api/creator/books/{book.Id}/revisions") ?? new();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Failed to load revisions.");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_revisionsLoading = false;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CloseRevisionsModal()
|
|
||||||
{
|
|
||||||
_isRevisionsModalOpen = false;
|
|
||||||
_activeRevisionsBookId = null;
|
|
||||||
_activeRevisionsBookTitle = string.Empty;
|
|
||||||
_revisionsList.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OpenCreateBookModal()
|
|
||||||
{
|
|
||||||
_createBookModel = new CreateBookModel();
|
|
||||||
_createBookError = null;
|
|
||||||
_isCreateBookModalOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CloseCreateBookModal()
|
|
||||||
{
|
|
||||||
_isCreateBookModalOpen = false;
|
|
||||||
_createBookError = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SubmitCreateBookAsync()
|
|
||||||
{
|
|
||||||
_isCreatingBook = true;
|
|
||||||
_createBookError = null;
|
|
||||||
StateHasChanged();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await Http.PostAsJsonAsync("api/creator/books", _createBookModel);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<CreateBookResponseDto>();
|
|
||||||
if (result != null)
|
|
||||||
{
|
|
||||||
// Reset modal state BEFORE routing to prevent it lingering in the DOM tree
|
|
||||||
_isCreateBookModalOpen = false;
|
|
||||||
_createBookError = null;
|
|
||||||
StateHasChanged();
|
|
||||||
|
|
||||||
NavigationManager.NavigateTo($"/creator/edit/{result.BookId}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_createBookError = "Otrzymano nieprawidłową odpowiedź z serwera.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var errorMsg = await response.Content.ReadAsStringAsync();
|
|
||||||
_createBookError = !string.IsNullOrWhiteSpace(errorMsg) ? errorMsg : "Wystąpił błąd podczas tworzenia książki.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Błąd podczas tworzenia książki.");
|
|
||||||
_createBookError = $"Krytyczny błąd: {ex.Message}";
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isCreatingBook = false;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CreateBookModel
|
|
||||||
{
|
|
||||||
[Required(ErrorMessage = "Tytuł książki jest wymagany.")]
|
|
||||||
[StringLength(255, ErrorMessage = "Tytuł książki nie może przekraczać 255 znaków.")]
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string? Description { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,763 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
@page "/creator/edit/{BookId}"
|
|
||||||
@page "/creator/edit/{BookId}/{ChapterId}"
|
|
||||||
@layout MainHubLayout
|
|
||||||
@attribute [Authorize]
|
|
||||||
@using NexusReader.UI.Shared.Components
|
|
||||||
|
|
||||||
@if (_loadingChapters)
|
|
||||||
{
|
|
||||||
<div class="hub-loading" style="height: calc(100vh - 4rem); display: flex; flex-direction: column; justify-content: center; align-items: center; background-color: var(--bg-base);">
|
|
||||||
<div class="nexus-loader"></div>
|
|
||||||
<p style="margin-top: 1rem; color: var(--text-muted); font-family: var(--nexus-font-sans);">Ładowanie struktury książki...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="creator-edit-fullscreen-wrapper">
|
|
||||||
|
|
||||||
<div class="chapters-sidebar">
|
|
||||||
<div class="sidebar-meta-header">
|
|
||||||
<h2>Rozdziały</h2>
|
|
||||||
</div>
|
|
||||||
<div class="chapters-list-wrapper">
|
|
||||||
@foreach (var ch in _chapters)
|
|
||||||
{
|
|
||||||
var isActive = ch.Id == _activeChapterId;
|
|
||||||
<a class="chapter-item @(isActive ? "active" : "")" href="/creator/edit/@BookId/@ch.Id">
|
|
||||||
@if (isActive)
|
|
||||||
{
|
|
||||||
<div class="active-indicator"></div>
|
|
||||||
<i class="fa-solid fa-book-open chapter-icon"></i>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<i class="fa-solid fa-file-lines chapter-icon"></i>
|
|
||||||
}
|
|
||||||
<span class="chapter-title-text">@ch.Title</span>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="editor-workspace-area">
|
|
||||||
|
|
||||||
<div class="editor-header-row">
|
|
||||||
<div class="title-zone">
|
|
||||||
<h1 class="chapter-title">@_activeChapterTitle</h1>
|
|
||||||
</div>
|
|
||||||
<div class="telemetry-zone">
|
|
||||||
<span class="chapter-id-badge">ID: @_activeChapterId</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="editor-canvas-card">
|
|
||||||
@if (_loadingChapter)
|
|
||||||
{
|
|
||||||
<div class="hub-loading" style="height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center;">
|
|
||||||
<div class="nexus-loader"></div>
|
|
||||||
<p style="margin-top: 1rem; color: var(--text-muted); font-family: var(--nexus-font-sans);">Wczytywanie treści rozdziału...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (_isChapterLoaded)
|
|
||||||
{
|
|
||||||
<div class="milkdown-premium-container" spellcheck="false">
|
|
||||||
<MarkdownEditor @key="_activeChapterId"
|
|
||||||
ChapterId="_activeChapterId"
|
|
||||||
InitialMarkdown="@_initialMarkdown"
|
|
||||||
ShowFetchButton="false" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div style="height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; color: var(--text-muted); font-family: var(--nexus-font-sans);">
|
|
||||||
<p>Wybierz lub utwórz rozdział, aby rozpocząć edycję.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Inject] private HttpClient Http { get; set; } = default!;
|
|
||||||
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
|
|
||||||
|
|
||||||
[Parameter] public string BookId { get; set; } = string.Empty;
|
|
||||||
[Parameter] public string ChapterId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
private List<ChapterListItem> _chapters = new();
|
|
||||||
private Guid _parsedBookId = Guid.Empty;
|
|
||||||
private Guid _activeChapterId = Guid.Empty;
|
|
||||||
private string _activeChapterTitle = string.Empty;
|
|
||||||
private string _initialMarkdown = string.Empty;
|
|
||||||
private bool _loadingChapters = true;
|
|
||||||
private bool _loadingChapter = false;
|
|
||||||
private bool _isChapterLoaded = false;
|
|
||||||
|
|
||||||
private class ChapterListItem
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
public int SortOrder { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private class ChapterDetail
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
public string MarkdownContent { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
|
||||||
{
|
|
||||||
await base.OnParametersSetAsync();
|
|
||||||
|
|
||||||
if (!Guid.TryParse(BookId, out var parsedBookId))
|
|
||||||
{
|
|
||||||
NavigationManager.NavigateTo("/creator");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_parsedBookId = parsedBookId;
|
|
||||||
|
|
||||||
// Fetch chapters list if empty or if book ID has changed
|
|
||||||
if (_chapters.Count == 0)
|
|
||||||
{
|
|
||||||
_loadingChapters = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var chapters = await Http.GetFromJsonAsync<List<ChapterListItem>>($"/api/creator/books/{_parsedBookId}/chapters");
|
|
||||||
_chapters = chapters ?? new();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[CreatorEdit] Error fetching chapters list: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_loadingChapters = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If ChapterId is empty/null, select the first chapter from list and navigate
|
|
||||||
if (string.IsNullOrEmpty(ChapterId))
|
|
||||||
{
|
|
||||||
if (_chapters.Any())
|
|
||||||
{
|
|
||||||
NavigationManager.NavigateTo($"/creator/edit/{BookId}/{_chapters.First().Id}");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Guid.TryParse(ChapterId, out var parsedChapterId))
|
|
||||||
{
|
|
||||||
// If active chapter changed, fetch its details
|
|
||||||
if (parsedChapterId != _activeChapterId)
|
|
||||||
{
|
|
||||||
_activeChapterId = parsedChapterId;
|
|
||||||
var ch = _chapters.FirstOrDefault(c => c.Id == _activeChapterId);
|
|
||||||
_activeChapterTitle = ch?.Title ?? "Rozdział";
|
|
||||||
|
|
||||||
_loadingChapter = true;
|
|
||||||
_isChapterLoaded = false;
|
|
||||||
StateHasChanged();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var detail = await Http.GetFromJsonAsync<ChapterDetail>($"/api/chapters/{_activeChapterId}");
|
|
||||||
if (detail != null)
|
|
||||||
{
|
|
||||||
_initialMarkdown = detail.MarkdownContent;
|
|
||||||
_isChapterLoaded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[CreatorEdit] Error fetching chapter content: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_loadingChapter = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
/* ==========================================================================
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -794,7 +794,7 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
@@ -882,7 +882,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -901,6 +901,7 @@
|
|||||||
/* --- Light Theme Overrides for Concepts Stack --- */
|
/* --- Light Theme Overrides for Concepts Stack --- */
|
||||||
.theme-light .concept-linear-item {
|
.theme-light .concept-linear-item {
|
||||||
background: rgba(0, 0, 0, 0.02);
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-color: rgba(0, 0, 0, 0.05);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -947,6 +948,7 @@
|
|||||||
|
|
||||||
.theme-light .view-toggle-btn {
|
.theme-light .view-toggle-btn {
|
||||||
background: rgba(0, 0, 0, 0.02);
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-color: rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-light .view-toggle-btn:hover {
|
.theme-light .view-toggle-btn:hover {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&family=Azeret+Mono:wght@300;400;500;600&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Semantic design tokens - default to Modern Deep Dark (Dark Mode) */
|
/* Semantic design tokens - default to Modern Deep Dark (Dark Mode) */
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
--nexus-paper: #F9F9F9;
|
--nexus-paper: #F9F9F9;
|
||||||
--nexus-font-sans: 'Inter', sans-serif;
|
--nexus-font-sans: 'Inter', sans-serif;
|
||||||
--nexus-font-serif: 'Merriweather', serif;
|
--nexus-font-serif: 'Merriweather', serif;
|
||||||
--nexus-font-mono: 'Azeret Mono', monospace;
|
|
||||||
|
|
||||||
/* Global Selection Style Override */
|
/* Global Selection Style Override */
|
||||||
--nexus-selection: rgba(0, 255, 153, 0.25);
|
--nexus-selection: rgba(0, 255, 153, 0.25);
|
||||||
@@ -61,12 +60,9 @@
|
|||||||
--nexus-node-concept-text: #e0e0e0;
|
--nexus-node-concept-text: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection,
|
::selection {
|
||||||
.ProseMirror ::selection,
|
background-color: var(--nexus-selection);
|
||||||
.ProseMirror::selection,
|
color: inherit;
|
||||||
.ProseMirror *::selection {
|
|
||||||
background-color: var(--nexus-selection) !important;
|
|
||||||
color: inherit !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -96,7 +92,7 @@
|
|||||||
.glass-panel {
|
.glass-panel {
|
||||||
background: rgba(20, 20, 20, 0.85);
|
background: rgba(20, 20, 20, 0.85);
|
||||||
/* Darker fallback for readability */
|
/* Darker fallback for readability */
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@@ -449,546 +445,3 @@ h1:focus {
|
|||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================================================
|
|
||||||
Selection Pop-up Menu & Crepe Toolbar Unification (SelectionAiPanel Style)
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
/* 1. Pop-up Containers (Glassmorphism Capsule) */
|
|
||||||
.milkdown-popover,
|
|
||||||
.popover,
|
|
||||||
.prosemirror-bubble-menu,
|
|
||||||
.milkdown .popover,
|
|
||||||
.milkdown-popover.popover,
|
|
||||||
.milkdown-toolbar {
|
|
||||||
background: rgba(24, 24, 28, 0.85) !important;
|
|
||||||
backdrop-filter: blur(12px) !important;
|
|
||||||
-webkit-backdrop-filter: blur(12px) !important;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
padding: 4px 6px !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 12px 28px rgba(0, 0, 0, 0.4) !important;
|
|
||||||
display: inline-flex; /* Removed !important to allow Tippy.js to hide popovers via inline style */
|
|
||||||
align-items: center !important;
|
|
||||||
gap: 4px !important;
|
|
||||||
z-index: 10000 !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
animation: fadeInScaleGlobal 0.18s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light Theme (Warm Paper) Overrides for Container */
|
|
||||||
.theme-light .milkdown-popover,
|
|
||||||
.theme-light .popover,
|
|
||||||
.theme-light .prosemirror-bubble-menu,
|
|
||||||
.theme-light .milkdown .popover,
|
|
||||||
.theme-light .milkdown-popover.popover,
|
|
||||||
.theme-light .milkdown-toolbar {
|
|
||||||
background: rgba(254, 254, 254, 0.95) !important;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(0, 0, 0, 0.04) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 2. Button & Item Formatting (Reader Selection Toolbar Style) */
|
|
||||||
.milkdown-popover button,
|
|
||||||
.popover button,
|
|
||||||
.prosemirror-bubble-menu button,
|
|
||||||
.milkdown .popover button,
|
|
||||||
.milkdown-toolbar button {
|
|
||||||
text-transform: none !important;
|
|
||||||
font-size: 0.8rem !important;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
color: #e4e4e7 !important; /* zinc-200 */
|
|
||||||
background: transparent !important;
|
|
||||||
border: none !important;
|
|
||||||
padding: 6px 12px !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
|
||||||
display: inline-flex !important;
|
|
||||||
align-items: center !important;
|
|
||||||
justify-content: center !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
gap: 6px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Crepe's Specific Square Icon-Only Toolbar Items */
|
|
||||||
.milkdown-toolbar .toolbar-item {
|
|
||||||
text-transform: none !important;
|
|
||||||
color: #e4e4e7 !important; /* zinc-200 */
|
|
||||||
background: transparent !important;
|
|
||||||
border: none !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
width: 28px !important;
|
|
||||||
height: 28px !important;
|
|
||||||
padding: 6px !important;
|
|
||||||
display: inline-flex !important;
|
|
||||||
justify-content: center !important;
|
|
||||||
align-items: center !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Icon overrides inside buttons */
|
|
||||||
.milkdown-popover button svg,
|
|
||||||
.popover button svg,
|
|
||||||
.prosemirror-bubble-menu button svg,
|
|
||||||
.milkdown .popover button svg,
|
|
||||||
.milkdown-popover button i,
|
|
||||||
.popover button i,
|
|
||||||
.prosemirror-bubble-menu button i,
|
|
||||||
.milkdown .popover button i,
|
|
||||||
.milkdown-toolbar button svg,
|
|
||||||
.milkdown-toolbar .toolbar-item svg,
|
|
||||||
.milkdown-toolbar button i,
|
|
||||||
.milkdown-toolbar .toolbar-item i {
|
|
||||||
color: currentColor !important;
|
|
||||||
fill: currentColor !important;
|
|
||||||
width: 14px !important;
|
|
||||||
height: 14px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover effects (zinc-200 / zinc-100 highlight) */
|
|
||||||
.milkdown-popover button:hover:not(.active),
|
|
||||||
.popover button:hover:not(.active),
|
|
||||||
.prosemirror-bubble-menu button:hover:not(.active),
|
|
||||||
.milkdown-toolbar button:hover:not(.active),
|
|
||||||
.milkdown-toolbar .toolbar-item:hover:not(.active) {
|
|
||||||
background: rgba(255, 255, 255, 0.05) !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active formatting state colors (var(--accent, #00ff99)) */
|
|
||||||
.milkdown-popover button.active,
|
|
||||||
.popover button.active,
|
|
||||||
.prosemirror-bubble-menu button.active,
|
|
||||||
.milkdown-popover button[aria-pressed="true"],
|
|
||||||
.popover button[aria-pressed="true"],
|
|
||||||
.prosemirror-bubble-menu button[aria-pressed="true"],
|
|
||||||
.milkdown-toolbar button.active,
|
|
||||||
.milkdown-toolbar .toolbar-item.active,
|
|
||||||
.milkdown-toolbar button[aria-pressed="true"],
|
|
||||||
.milkdown-toolbar .toolbar-item[aria-pressed="true"] {
|
|
||||||
color: var(--accent, #00ff99) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-popover button.active:hover,
|
|
||||||
.popover button.active:hover,
|
|
||||||
.prosemirror-bubble-menu button.active:hover,
|
|
||||||
.milkdown-popover button[aria-pressed="true"]:hover,
|
|
||||||
.popover button[aria-pressed="true"]:hover,
|
|
||||||
.prosemirror-bubble-menu button[aria-pressed="true"]:hover,
|
|
||||||
.milkdown-toolbar button.active:hover,
|
|
||||||
.milkdown-toolbar .toolbar-item.active:hover,
|
|
||||||
.milkdown-toolbar button[aria-pressed="true"]:hover,
|
|
||||||
.milkdown-toolbar .toolbar-item[aria-pressed="true"]:hover {
|
|
||||||
background: rgba(0, 255, 153, 0.08) !important;
|
|
||||||
box-shadow: 0 0 12px rgba(0, 255, 153, 0.15) !important;
|
|
||||||
color: var(--accent, #00ff99) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 3. Light Theme Overrides for Buttons */
|
|
||||||
.theme-light .milkdown-popover button,
|
|
||||||
.theme-light .popover button,
|
|
||||||
.theme-light .prosemirror-bubble-menu button,
|
|
||||||
.theme-light .milkdown-toolbar button,
|
|
||||||
.theme-light .milkdown-toolbar .toolbar-item {
|
|
||||||
color: #57524e !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown-popover button:hover:not(.active),
|
|
||||||
.theme-light .popover button:hover:not(.active),
|
|
||||||
.theme-light .prosemirror-bubble-menu button:hover:not(.active),
|
|
||||||
.theme-light .milkdown-toolbar button:hover:not(.active),
|
|
||||||
.theme-light .milkdown-toolbar .toolbar-item:hover:not(.active) {
|
|
||||||
background: rgba(0, 0, 0, 0.04) !important;
|
|
||||||
color: #1c1917 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown-popover button.active,
|
|
||||||
.theme-light .popover button.active,
|
|
||||||
.theme-light .prosemirror-bubble-menu button.active,
|
|
||||||
.theme-light .milkdown-popover button[aria-pressed="true"],
|
|
||||||
.theme-light .popover button[aria-pressed="true"],
|
|
||||||
.theme-light .prosemirror-bubble-menu button[aria-pressed="true"],
|
|
||||||
.theme-light .milkdown-toolbar button.active,
|
|
||||||
.theme-light .milkdown-toolbar .toolbar-item.active,
|
|
||||||
.theme-light .milkdown-toolbar button[aria-pressed="true"],
|
|
||||||
.theme-light .milkdown-toolbar .toolbar-item[aria-pressed="true"] {
|
|
||||||
color: #10b981 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown-popover button.active:hover,
|
|
||||||
.theme-light .popover button.active:hover,
|
|
||||||
.theme-light .prosemirror-bubble-menu button.active:hover,
|
|
||||||
.theme-light .milkdown-popover button[aria-pressed="true"]:hover,
|
|
||||||
.theme-light .popover button[aria-pressed="true"]:hover,
|
|
||||||
.theme-light .prosemirror-bubble-menu button[aria-pressed="true"]:hover,
|
|
||||||
.theme-light .milkdown-toolbar button.active:hover,
|
|
||||||
.theme-light .milkdown-toolbar .toolbar-item.active:hover,
|
|
||||||
.theme-light .milkdown-toolbar button[aria-pressed="true"]:hover,
|
|
||||||
.theme-light .milkdown-toolbar .toolbar-item[aria-pressed="true"]:hover {
|
|
||||||
background: rgba(16, 185, 129, 0.06) !important;
|
|
||||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.1) !important;
|
|
||||||
color: #10b981 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 4. Dividers Alignment */
|
|
||||||
.milkdown-popover .divider,
|
|
||||||
.popover .divider,
|
|
||||||
.prosemirror-bubble-menu .divider,
|
|
||||||
.milkdown .popover .divider,
|
|
||||||
.milkdown-toolbar .divider {
|
|
||||||
width: 1px !important;
|
|
||||||
height: 16px !important;
|
|
||||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
|
||||||
margin: 0 4px !important;
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown-popover .divider,
|
|
||||||
.theme-light .popover .divider,
|
|
||||||
.theme-light .prosemirror-bubble-menu .divider,
|
|
||||||
.theme-light .milkdown-toolbar .divider {
|
|
||||||
background-color: rgba(0, 0, 0, 0.08) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==========================================================================
|
|
||||||
Editor Table & General Control Elements Theming (SelectionAiPanel Style)
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
/* 1. Cell Drag Handles (Column and Row Drag Handles) */
|
|
||||||
.milkdown .milkdown-table-block .cell-handle {
|
|
||||||
background-color: rgba(24, 24, 28, 0.85) !important;
|
|
||||||
backdrop-filter: blur(12px) !important;
|
|
||||||
-webkit-backdrop-filter: blur(12px) !important;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
|
||||||
color: #e4e4e7 !important;
|
|
||||||
transition: all 0.2s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-table-block .cell-handle {
|
|
||||||
background-color: rgba(254, 254, 254, 0.95) !important;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
|
||||||
color: #57524e !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-table-block .cell-handle:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.15) !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
border-color: var(--accent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-table-block .cell-handle:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.08) !important;
|
|
||||||
color: #1c1917 !important;
|
|
||||||
border-color: #10b981 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-table-block .cell-handle svg {
|
|
||||||
fill: currentColor !important;
|
|
||||||
color: currentColor !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 2. Drag Handle Options Popup Menu (.button-group) */
|
|
||||||
.milkdown .milkdown-table-block .cell-handle .button-group {
|
|
||||||
background: rgba(24, 24, 28, 0.85) !important;
|
|
||||||
backdrop-filter: blur(12px) !important;
|
|
||||||
-webkit-backdrop-filter: blur(12px) !important;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 12px 28px rgba(0, 0, 0, 0.4) !important;
|
|
||||||
padding: 4px !important;
|
|
||||||
gap: 4px !important;
|
|
||||||
display: flex; /* Removed !important to allow toggling via data-show attribute */
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-table-block .cell-handle .button-group {
|
|
||||||
background: rgba(254, 254, 254, 0.95) !important;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(0, 0, 0, 0.04) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-table-block .cell-handle .button-group button {
|
|
||||||
background: transparent !important;
|
|
||||||
border: none !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
width: 28px !important;
|
|
||||||
height: 28px !important;
|
|
||||||
padding: 6px !important;
|
|
||||||
display: inline-flex !important;
|
|
||||||
justify-content: center !important;
|
|
||||||
align-items: center !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
color: #e4e4e7 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-table-block .cell-handle .button-group button {
|
|
||||||
color: #57524e !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-table-block .cell-handle .button-group button:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.05) !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-table-block .cell-handle .button-group button:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.04) !important;
|
|
||||||
color: #1c1917 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-table-block .cell-handle .button-group button:active {
|
|
||||||
background: rgba(0, 255, 153, 0.08) !important;
|
|
||||||
color: var(--accent, #00ff99) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-table-block .cell-handle .button-group button:active {
|
|
||||||
background: rgba(16, 185, 129, 0.06) !important;
|
|
||||||
color: #10b981 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-table-block .cell-handle .button-group button svg {
|
|
||||||
color: currentColor !important;
|
|
||||||
fill: currentColor !important;
|
|
||||||
width: 14px !important;
|
|
||||||
height: 14px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 3. Table Column/Row Insertion Lines & Add Buttons */
|
|
||||||
.milkdown .milkdown-table-block .line-handle {
|
|
||||||
background-color: var(--accent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-table-block .line-handle {
|
|
||||||
background-color: #10b981 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-table-block .line-handle .add-button {
|
|
||||||
background-color: rgba(24, 24, 28, 0.85) !important;
|
|
||||||
backdrop-filter: blur(12px) !important;
|
|
||||||
-webkit-backdrop-filter: blur(12px) !important;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
|
||||||
color: #e4e4e7 !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-table-block .line-handle .add-button {
|
|
||||||
background-color: rgba(254, 254, 254, 0.95) !important;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
|
||||||
color: #57524e !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-table-block .line-handle .add-button:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.15) !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
border-color: var(--accent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-table-block .line-handle .add-button:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.08) !important;
|
|
||||||
color: #1c1917 !important;
|
|
||||||
border-color: #10b981 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-table-block .line-handle .add-button svg {
|
|
||||||
width: 12px !important;
|
|
||||||
height: 12px !important;
|
|
||||||
color: currentColor !important;
|
|
||||||
fill: currentColor !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 4. Paragraph Block Drag Handles */
|
|
||||||
.milkdown .milkdown-block-handle {
|
|
||||||
background-color: rgba(24, 24, 28, 0.85) !important;
|
|
||||||
backdrop-filter: blur(12px) !important;
|
|
||||||
-webkit-backdrop-filter: blur(12px) !important;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
padding: 2px !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
|
||||||
display: flex; /* Removed !important to allow toggling */
|
|
||||||
gap: 2px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-block-handle {
|
|
||||||
background-color: rgba(254, 254, 254, 0.95) !important;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-block-handle .operation-item {
|
|
||||||
border-radius: 4px !important;
|
|
||||||
width: 24px !important;
|
|
||||||
height: 24px !important;
|
|
||||||
padding: 4px !important;
|
|
||||||
display: inline-flex !important;
|
|
||||||
justify-content: center !important;
|
|
||||||
align-items: center !important;
|
|
||||||
color: #e4e4e7 !important;
|
|
||||||
transition: all 0.2s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-block-handle .operation-item {
|
|
||||||
color: #57524e !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-block-handle .operation-item:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.05) !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-block-handle .operation-item:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.04) !important;
|
|
||||||
color: #1c1917 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-block-handle .operation-item.active {
|
|
||||||
background: rgba(0, 255, 153, 0.08) !important;
|
|
||||||
color: var(--accent, #00ff99) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-block-handle .operation-item.active {
|
|
||||||
background: rgba(16, 185, 129, 0.06) !important;
|
|
||||||
color: #10b981 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-block-handle .operation-item svg {
|
|
||||||
width: 14px !important;
|
|
||||||
height: 14px !important;
|
|
||||||
fill: currentColor !important;
|
|
||||||
color: currentColor !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 5. Slash Commands Menu */
|
|
||||||
.milkdown .milkdown-slash-menu {
|
|
||||||
background: rgba(24, 24, 28, 0.85) !important;
|
|
||||||
backdrop-filter: blur(12px) !important;
|
|
||||||
-webkit-backdrop-filter: blur(12px) !important;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 12px 28px rgba(0, 0, 0, 0.4) !important;
|
|
||||||
font-family: var(--nexus-font-sans) !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-slash-menu {
|
|
||||||
background: rgba(254, 254, 254, 0.95) !important;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(0, 0, 0, 0.04) !important;
|
|
||||||
color: #2d2a26 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-slash-menu .tab-group ul li {
|
|
||||||
color: #a1a1aa !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
transition: all 0.2s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-slash-menu .tab-group ul li {
|
|
||||||
color: #78716c !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-slash-menu .tab-group ul li:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.05) !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-slash-menu .tab-group ul li:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.04) !important;
|
|
||||||
color: #1c1917 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-slash-menu .tab-group ul li.selected {
|
|
||||||
background: rgba(0, 255, 153, 0.08) !important;
|
|
||||||
color: var(--accent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-slash-menu .tab-group ul li.selected {
|
|
||||||
background: rgba(16, 185, 129, 0.06) !important;
|
|
||||||
color: #10b981 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-slash-menu .menu-groups .menu-group li {
|
|
||||||
color: #e4e4e7 !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
transition: all 0.2s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-slash-menu .menu-groups .menu-group li {
|
|
||||||
color: #57524e !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-slash-menu .menu-groups .menu-group li svg {
|
|
||||||
color: #a1a1aa !important;
|
|
||||||
fill: #a1a1aa !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-slash-menu .menu-groups .menu-group li svg {
|
|
||||||
color: #78716c !important;
|
|
||||||
fill: #78716c !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-slash-menu .menu-groups .menu-group li.hover {
|
|
||||||
background: rgba(255, 255, 255, 0.05) !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-slash-menu .menu-groups .menu-group li.hover {
|
|
||||||
background: rgba(0, 0, 0, 0.04) !important;
|
|
||||||
color: #1c1917 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-slash-menu .menu-groups .menu-group li.active {
|
|
||||||
background: rgba(0, 255, 153, 0.08) !important;
|
|
||||||
color: var(--accent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-slash-menu .menu-groups .menu-group li.active {
|
|
||||||
background: rgba(16, 185, 129, 0.06) !important;
|
|
||||||
color: #10b981 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .milkdown-slash-menu .menu-groups .menu-group li.active svg {
|
|
||||||
color: var(--accent) !important;
|
|
||||||
fill: var(--accent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-light .milkdown .milkdown-slash-menu .menu-groups .menu-group li.active svg {
|
|
||||||
color: #10b981 !important;
|
|
||||||
fill: #10b981 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 6. Explicit Visibility State Overrides */
|
|
||||||
.milkdown-popover[data-show="false"],
|
|
||||||
.popover[data-show="false"],
|
|
||||||
.prosemirror-bubble-menu[data-show="false"],
|
|
||||||
.milkdown-toolbar[data-show="false"],
|
|
||||||
.milkdown-slash-menu[data-show="false"],
|
|
||||||
.milkdown-table-block .cell-handle .button-group[data-show="false"],
|
|
||||||
.milkdown-link-preview[data-show="false"],
|
|
||||||
.milkdown-link-edit[data-show="false"] {
|
|
||||||
display: none !important;
|
|
||||||
opacity: 0 !important;
|
|
||||||
visibility: hidden !important;
|
|
||||||
pointer-events: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 7. Table Overflow Clipping Fix for Handles */
|
|
||||||
.milkdown .tableWrapper {
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,244 +0,0 @@
|
|||||||
// 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
|
|
||||||
* and returns a Promise that resolves when the stylesheet is fully loaded.
|
|
||||||
*/
|
|
||||||
async function ensureStylesheet(href) {
|
|
||||||
if (document.querySelector(`link[href="${href}"]`)) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const link = document.createElement('link');
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.href = href;
|
|
||||||
link.onload = () => resolve();
|
|
||||||
link.onerror = (err) => reject(new Error(`Failed to load stylesheet: ${href}. ${err}`));
|
|
||||||
document.head.appendChild(link);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
|
||||||
throw new Error("Crepe constructor not found on window.milkdownCrepe");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the Crepe editor instance with custom ImageBlock upload handler
|
|
||||||
const crepe = new Crepe({
|
|
||||||
root: container,
|
|
||||||
defaultValue: initialMarkdown || "",
|
|
||||||
featureConfigs: {
|
|
||||||
[Crepe.Feature.ImageBlock]: {
|
|
||||||
onUpload: async (file) => {
|
|
||||||
try {
|
|
||||||
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 (onUpload):", err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the current Markdown content from a specific editor instance.
|
|
||||||
*/
|
|
||||||
export function getMarkdownContent(elementId) {
|
|
||||||
const crepe = editorCache.get(elementId);
|
|
||||||
if (!crepe) {
|
|
||||||
console.warn(`[Milkdown] No editor instance found for element: ${elementId}`);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return crepe.getMarkdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
await crepe.destroy();
|
|
||||||
console.log(`[Milkdown] Editor instance successfully destroyed: ${elementId}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[Milkdown] Error destroying editor for element "${elementId}":`, error);
|
|
||||||
}
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -91,10 +91,6 @@ builder.Services.AddCascadingAuthenticationState();
|
|||||||
|
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
builder.Services.AddHealthChecks()
|
|
||||||
.AddCheck<NexusReader.Web.Services.DatabaseHealthCheck>("Database")
|
|
||||||
.AddCheck<NexusReader.Web.Services.QdrantHealthCheck>("Qdrant")
|
|
||||||
.AddCheck<NexusReader.Web.Services.Neo4jHealthCheck>("Neo4j");
|
|
||||||
|
|
||||||
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
||||||
NexusReader.Application.DependencyInjection.Assembly,
|
NexusReader.Application.DependencyInjection.Assembly,
|
||||||
@@ -299,7 +295,6 @@ if (!allowRegistration || !allowPasswordReset)
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
app.MapHealthChecks("/health");
|
|
||||||
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||||
|
|
||||||
// API endpoint for WASM client to fetch EPUB content
|
// API endpoint for WASM client to fetch EPUB content
|
||||||
@@ -498,132 +493,6 @@ app.MapGet("/api/library/books", async (ClaimsPrincipal user, IMediator mediator
|
|||||||
return Results.BadRequest(errorMsg);
|
return Results.BadRequest(errorMsg);
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/creator/dashboard", async (ClaimsPrincipal user, IMediator mediator) =>
|
|
||||||
{
|
|
||||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
|
||||||
|
|
||||||
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
|
||||||
|
|
||||||
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetCreatorDashboardDataQuery(userId, tenantId));
|
|
||||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
|
||||||
|
|
||||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
|
||||||
return Results.BadRequest(errorMsg);
|
|
||||||
}).RequireAuthorization();
|
|
||||||
|
|
||||||
app.MapGet("/api/creator/books/{bookId:guid}/revisions", async (Guid bookId, ClaimsPrincipal user, IMediator mediator) =>
|
|
||||||
{
|
|
||||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
|
||||||
|
|
||||||
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
|
||||||
|
|
||||||
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetBookRevisionsQuery(bookId, userId, tenantId));
|
|
||||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
|
||||||
|
|
||||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
|
||||||
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return Results.NotFound(errorMsg);
|
|
||||||
}
|
|
||||||
return Results.BadRequest(errorMsg);
|
|
||||||
}).RequireAuthorization();
|
|
||||||
|
|
||||||
app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [FromQuery] string version, ClaimsPrincipal user, IMediator mediator) =>
|
|
||||||
{
|
|
||||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
|
||||||
|
|
||||||
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(version))
|
|
||||||
{
|
|
||||||
return Results.BadRequest("Version string is required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand(bookId, version, userId, tenantId));
|
|
||||||
if (result.IsSuccess) return Results.Ok();
|
|
||||||
|
|
||||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
|
||||||
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return Results.NotFound(errorMsg);
|
|
||||||
}
|
|
||||||
return Results.BadRequest(errorMsg);
|
|
||||||
}).RequireAuthorization();
|
|
||||||
|
|
||||||
app.MapPost("/api/creator/books", async (
|
|
||||||
[FromBody] NexusReader.Application.DTOs.Creator.CreateBookRequestDto request,
|
|
||||||
ClaimsPrincipal user,
|
|
||||||
IMediator mediator) =>
|
|
||||||
{
|
|
||||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
|
||||||
|
|
||||||
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
|
||||||
|
|
||||||
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.CreateBookCommand(
|
|
||||||
request.Title,
|
|
||||||
request.Description,
|
|
||||||
userId,
|
|
||||||
tenantId
|
|
||||||
));
|
|
||||||
|
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
|
||||||
return Results.Ok(new NexusReader.Application.DTOs.Creator.CreateBookResponseDto(result.Value));
|
|
||||||
}
|
|
||||||
|
|
||||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
|
||||||
return Results.BadRequest(errorMsg);
|
|
||||||
}).RequireAuthorization();
|
|
||||||
|
|
||||||
app.MapGet("/api/creator/books/{bookId:guid}/chapters", async (Guid bookId, ClaimsPrincipal user, IDbContextFactory<AppDbContext> dbContextFactory) =>
|
|
||||||
{
|
|
||||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
|
||||||
|
|
||||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
|
||||||
var book = await dbContext.Books
|
|
||||||
.Include(b => b.CurrentDraftRevision)
|
|
||||||
.ThenInclude(r => r!.Chapters)
|
|
||||||
.FirstOrDefaultAsync(b => b.Id == bookId && b.UserId == userId);
|
|
||||||
|
|
||||||
if (book == null) return Results.NotFound();
|
|
||||||
if (book.CurrentDraftRevision == null) return Results.BadRequest("No active draft revision.");
|
|
||||||
|
|
||||||
var chapters = book.CurrentDraftRevision.Chapters
|
|
||||||
.OrderBy(c => c.SortOrder)
|
|
||||||
.Select(c => new { c.Id, c.Title, c.SortOrder })
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Results.Ok(chapters);
|
|
||||||
}).RequireAuthorization();
|
|
||||||
|
|
||||||
app.MapGet("/api/chapters/{id:guid}", async (Guid id, ClaimsPrincipal user, IDbContextFactory<AppDbContext> dbContextFactory) =>
|
|
||||||
{
|
|
||||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
|
||||||
|
|
||||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
|
||||||
|
|
||||||
var chapter = await dbContext.Chapters
|
|
||||||
.Include(c => c.BookRevision)
|
|
||||||
.ThenInclude(r => r.Book)
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == id);
|
|
||||||
|
|
||||||
if (chapter == null) return Results.NotFound();
|
|
||||||
|
|
||||||
// Verify ownership
|
|
||||||
if (chapter.BookRevision.Book.UserId != userId)
|
|
||||||
{
|
|
||||||
return Results.Forbid();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(new { chapter.Id, chapter.Title, chapter.MarkdownContent });
|
|
||||||
}).RequireAuthorization();
|
|
||||||
|
|
||||||
app.MapPost("/api/library/purchase", async (
|
app.MapPost("/api/library/purchase", async (
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
[FromBody] PurchaseBookRequest request,
|
[FromBody] PurchaseBookRequest request,
|
||||||
@@ -900,85 +769,6 @@ app.MapPost("/identity/theme", async (
|
|||||||
return Results.Ok();
|
return Results.Ok();
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapPost("/api/media/upload", async (
|
|
||||||
HttpRequest request,
|
|
||||||
NexusReader.Application.Abstractions.Services.IStorageService storageService,
|
|
||||||
ILogger<Program> logger) =>
|
|
||||||
{
|
|
||||||
if (!request.HasFormContentType)
|
|
||||||
{
|
|
||||||
return Results.BadRequest("Request must be a multipart form.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var form = await request.ReadFormAsync();
|
|
||||||
var file = form.Files.GetFile("file");
|
|
||||||
|
|
||||||
if (file == null || file.Length == 0)
|
|
||||||
{
|
|
||||||
return Results.BadRequest("No file uploaded.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Size limit check (max 5MB)
|
|
||||||
const long maxFileSize = 5 * 1024 * 1024;
|
|
||||||
if (file.Length > maxFileSize)
|
|
||||||
{
|
|
||||||
return Results.BadRequest("File size exceeds the 5MB limit.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read file bytes for signature check
|
|
||||||
byte[] fileBytes;
|
|
||||||
using (var memoryStream = new MemoryStream())
|
|
||||||
{
|
|
||||||
await file.CopyToAsync(memoryStream);
|
|
||||||
fileBytes = memoryStream.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 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 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();
|
|
||||||
|
|
||||||
app.MapPost("/api/chapters/validate", (
|
|
||||||
[Microsoft.AspNetCore.Mvc.FromBody] NexusReader.Application.DTOs.Media.ValidateChapterRequest request,
|
|
||||||
NexusReader.Application.Abstractions.Services.ISanitizerService sanitizerService) =>
|
|
||||||
{
|
|
||||||
if (request == null || string.IsNullOrEmpty(request.Content))
|
|
||||||
{
|
|
||||||
return Results.Ok(new NexusReader.Application.DTOs.Media.ValidateChapterResponse(string.Empty));
|
|
||||||
}
|
|
||||||
|
|
||||||
var sanitized = sanitizerService.Sanitize(request.Content);
|
|
||||||
return Results.Ok(new NexusReader.Application.DTOs.Media.ValidateChapterResponse(sanitized));
|
|
||||||
}).DisableAntiforgery();
|
|
||||||
|
|
||||||
app.MapPut("/api/chapters/{id:guid}/autosave", async (
|
|
||||||
Guid id,
|
|
||||||
[Microsoft.AspNetCore.Mvc.FromBody] NexusReader.Application.DTOs.Media.AutosaveChapterRequest request,
|
|
||||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
|
||||||
ILoggerFactory loggerFactory) =>
|
|
||||||
{
|
|
||||||
var logger = loggerFactory.CreateLogger("ChaptersApi");
|
|
||||||
logger.LogInformation("Autosaving chapter {ChapterId} with content length {Length}", id, request?.MarkdownContent?.Length ?? 0);
|
|
||||||
|
|
||||||
if (request == null) return Results.BadRequest("Request content cannot be null.");
|
|
||||||
|
|
||||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
|
||||||
var chapter = await dbContext.Chapters.FindAsync(id);
|
|
||||||
if (chapter == null) return Results.NotFound($"Chapter with ID '{id}' was not found.");
|
|
||||||
|
|
||||||
chapter.MarkdownContent = request.MarkdownContent;
|
|
||||||
await dbContext.SaveChangesAsync();
|
|
||||||
|
|
||||||
return Results.Ok(new { Success = true });
|
|
||||||
}).DisableAntiforgery();
|
|
||||||
|
|
||||||
app.MapRazorComponents<App>()
|
app.MapRazorComponents<App>()
|
||||||
.AddInteractiveServerRenderMode()
|
.AddInteractiveServerRenderMode()
|
||||||
.AddInteractiveWebAssemblyRenderMode()
|
.AddInteractiveWebAssemblyRenderMode()
|
||||||
@@ -1030,58 +820,6 @@ async Task TriggerBackgroundProcessingForUnindexedBooksAsync(IServiceProvider se
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ImageValidator
|
|
||||||
{
|
|
||||||
public static bool ValidateImageSignature(byte[] bytes, string fileName, out string detectedContentType)
|
|
||||||
{
|
|
||||||
detectedContentType = string.Empty;
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(detectedContentType))
|
|
||||||
{
|
|
||||||
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);
|
public record KnowledgeRequest(string Text, Guid? EbookId = null);
|
||||||
public record GroundednessRequest(string Answer, string Context);
|
public record GroundednessRequest(string Answer, string Context);
|
||||||
public record SemanticSearchRequest(string QueryText, int Limit = 5);
|
public record SemanticSearchRequest(string QueryText, int Limit = 5);
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace NexusReader.Web.Services;
|
|
||||||
|
|
||||||
public class DatabaseHealthCheck : IHealthCheck
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _dbContext;
|
|
||||||
|
|
||||||
public DatabaseHealthCheck(AppDbContext dbContext)
|
|
||||||
{
|
|
||||||
_dbContext = dbContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
|
||||||
HealthCheckContext context, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var canConnect = await _dbContext.Database.CanConnectAsync(cancellationToken);
|
|
||||||
if (canConnect)
|
|
||||||
{
|
|
||||||
return HealthCheckResult.Healthy("Database is accessible.");
|
|
||||||
}
|
|
||||||
return HealthCheckResult.Unhealthy("Cannot connect to the database.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return HealthCheckResult.Unhealthy("Database health check failed with exception.", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
||||||
using Neo4j.Driver;
|
|
||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace NexusReader.Web.Services;
|
|
||||||
|
|
||||||
public class Neo4jHealthCheck : IHealthCheck
|
|
||||||
{
|
|
||||||
private readonly IDriver _driver;
|
|
||||||
|
|
||||||
public Neo4jHealthCheck(IDriver driver)
|
|
||||||
{
|
|
||||||
_driver = driver;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
|
||||||
HealthCheckContext context, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _driver.VerifyConnectivityAsync();
|
|
||||||
return HealthCheckResult.Healthy("Neo4j database is accessible.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return HealthCheckResult.Unhealthy("Neo4j database connectivity check failed.", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
||||||
using Qdrant.Client;
|
|
||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace NexusReader.Web.Services;
|
|
||||||
|
|
||||||
public class QdrantHealthCheck : IHealthCheck
|
|
||||||
{
|
|
||||||
private readonly QdrantClient _qdrantClient;
|
|
||||||
|
|
||||||
public QdrantHealthCheck(QdrantClient qdrantClient)
|
|
||||||
{
|
|
||||||
_qdrantClient = qdrantClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
|
||||||
HealthCheckContext context, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Simple check: query collection existence to verify connection is alive
|
|
||||||
_ = await _qdrantClient.CollectionExistsAsync("knowledge_units", cancellationToken);
|
|
||||||
return HealthCheckResult.Healthy("Qdrant database is accessible.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return HealthCheckResult.Unhealthy("Qdrant database health check failed.", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FluentAssertions;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Moq;
|
|
||||||
using NexusReader.Application.Features.Books.Commands;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using NexusReader.Domain.Entities;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Tests.Commands;
|
|
||||||
|
|
||||||
public class CreateBookTests : IDisposable
|
|
||||||
{
|
|
||||||
private readonly SqliteConnection _connection;
|
|
||||||
private readonly DbContextOptions<AppDbContext> _contextOptions;
|
|
||||||
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
|
|
||||||
|
|
||||||
public CreateBookTests()
|
|
||||||
{
|
|
||||||
_connection = new SqliteConnection("DataSource=:memory:");
|
|
||||||
_connection.Open();
|
|
||||||
|
|
||||||
_contextOptions = new DbContextOptionsBuilder<AppDbContext>()
|
|
||||||
.UseSqlite(_connection)
|
|
||||||
.Options;
|
|
||||||
|
|
||||||
// Seed initial database schema
|
|
||||||
using var context = new AppDbContext(_contextOptions);
|
|
||||||
context.Database.EnsureCreated();
|
|
||||||
|
|
||||||
_dbContextFactoryMock = new Mock<IDbContextFactory<AppDbContext>>();
|
|
||||||
_dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(() => new AppDbContext(_contextOptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
private NexusUser SeedUser(string userId, string tenantId)
|
|
||||||
{
|
|
||||||
var user = new NexusUser
|
|
||||||
{
|
|
||||||
Id = userId,
|
|
||||||
UserName = $"user_{userId}",
|
|
||||||
Email = $"{userId}@example.com",
|
|
||||||
TenantId = tenantId,
|
|
||||||
SubscriptionPlanId = 1
|
|
||||||
};
|
|
||||||
|
|
||||||
using var context = new AppDbContext(_contextOptions);
|
|
||||||
context.Users.Add(user);
|
|
||||||
context.SaveChanges();
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_WithValidCommand_SuccessfullyCreatesBookRevisionAndIntroductionChapter()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var userId = "creator-123";
|
|
||||||
var tenantId = "tenant-abc";
|
|
||||||
SeedUser(userId, tenantId);
|
|
||||||
|
|
||||||
var command = new CreateBookCommand(
|
|
||||||
Title: "The Art of Agentic Systems",
|
|
||||||
Description: "A masterclass on building self-healing AI agents.",
|
|
||||||
UserId: userId,
|
|
||||||
TenantId: tenantId
|
|
||||||
);
|
|
||||||
|
|
||||||
var handler = new CreateBookCommandHandler(_dbContextFactoryMock.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await handler.Handle(command, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.IsSuccess.Should().BeTrue();
|
|
||||||
result.Value.Should().NotBeEmpty();
|
|
||||||
|
|
||||||
using (var context = new AppDbContext(_contextOptions))
|
|
||||||
{
|
|
||||||
var book = await context.Books
|
|
||||||
.Include(b => b.CurrentDraftRevision)
|
|
||||||
.ThenInclude(r => r!.Chapters)
|
|
||||||
.FirstOrDefaultAsync(b => b.Id == result.Value);
|
|
||||||
|
|
||||||
book.Should().NotBeNull();
|
|
||||||
book!.Title.Should().Be("The Art of Agentic Systems");
|
|
||||||
book.UserId.Should().Be(userId);
|
|
||||||
book.TenantId.Should().Be(tenantId);
|
|
||||||
book.CurrentDraftRevisionId.Should().NotBeNull();
|
|
||||||
|
|
||||||
var revision = book.CurrentDraftRevision;
|
|
||||||
revision.Should().NotBeNull();
|
|
||||||
revision!.VersionString.Should().Be("Working Draft");
|
|
||||||
revision.IsPublished.Should().BeFalse();
|
|
||||||
revision.BookId.Should().Be(book.Id);
|
|
||||||
|
|
||||||
revision.Chapters.Should().HaveCount(1);
|
|
||||||
var chapter = revision.Chapters.First();
|
|
||||||
chapter.Title.Should().Be("Introduction");
|
|
||||||
chapter.MarkdownContent.Should().Be("# Introduction\nStart writing here...");
|
|
||||||
chapter.SortOrder.Should().Be(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_WithEmptyTitle_ReturnsFailureResult()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var command = new CreateBookCommand(
|
|
||||||
Title: "",
|
|
||||||
Description: "No title",
|
|
||||||
UserId: "user-1",
|
|
||||||
TenantId: "tenant-1"
|
|
||||||
);
|
|
||||||
|
|
||||||
var handler = new CreateBookCommandHandler(_dbContextFactoryMock.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await handler.Handle(command, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.IsSuccess.Should().BeFalse();
|
|
||||||
result.Errors.Should().NotBeEmpty();
|
|
||||||
result.Errors.First().Message.Should().Contain("title is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_OnDatabaseViolation_RollsBackTransaction()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
// We trigger a database violation by not seeding the user 'missing-user'
|
|
||||||
// and letting the foreign key constraint fail (if SQLite enforces it).
|
|
||||||
// If foreign keys aren't strictly enforced on SQLite by default without PRAGMA,
|
|
||||||
// we can check if it rolls back upon other violations, or manually verify error handling.
|
|
||||||
var command = new CreateBookCommand(
|
|
||||||
Title: "Violating Book",
|
|
||||||
Description: "Triggering constraint failure",
|
|
||||||
UserId: "non-existent-user-id-constraint",
|
|
||||||
TenantId: "tenant-1"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Let's force foreign key constraints on SQLite to verify rollback
|
|
||||||
using (var context = new AppDbContext(_contextOptions))
|
|
||||||
{
|
|
||||||
context.Database.ExecuteSqlRaw("PRAGMA foreign_keys = ON;");
|
|
||||||
}
|
|
||||||
|
|
||||||
var handler = new CreateBookCommandHandler(_dbContextFactoryMock.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await handler.Handle(command, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.IsSuccess.Should().BeFalse();
|
|
||||||
result.Errors.Should().NotBeEmpty();
|
|
||||||
|
|
||||||
// Ensure nothing was committed to the DB
|
|
||||||
using (var context = new AppDbContext(_contextOptions))
|
|
||||||
{
|
|
||||||
var books = await context.Books.ToListAsync();
|
|
||||||
books.Should().BeEmpty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_connection.Close();
|
|
||||||
_connection.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FluentAssertions;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Moq;
|
|
||||||
using NexusReader.Application.Features.Books.Commands;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using NexusReader.Domain.Entities;
|
|
||||||
using NexusReader.Domain.Exceptions;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Tests.Commands;
|
|
||||||
|
|
||||||
public class PublishBookVersionTests : IDisposable
|
|
||||||
{
|
|
||||||
private readonly SqliteConnection _connection;
|
|
||||||
private readonly DbContextOptions<AppDbContext> _contextOptions;
|
|
||||||
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
|
|
||||||
|
|
||||||
public PublishBookVersionTests()
|
|
||||||
{
|
|
||||||
_connection = new SqliteConnection("DataSource=:memory:");
|
|
||||||
_connection.Open();
|
|
||||||
|
|
||||||
_contextOptions = new DbContextOptionsBuilder<AppDbContext>()
|
|
||||||
.UseSqlite(_connection)
|
|
||||||
.Options;
|
|
||||||
|
|
||||||
// Seed initial database schema
|
|
||||||
using var context = new AppDbContext(_contextOptions);
|
|
||||||
context.Database.EnsureCreated();
|
|
||||||
|
|
||||||
_dbContextFactoryMock = new Mock<IDbContextFactory<AppDbContext>>();
|
|
||||||
_dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(() => new AppDbContext(_contextOptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_WithValidBookAndChapters_CorrectlyPublishesAndClonesChaptersWithNewGuids()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var bookId = Guid.NewGuid();
|
|
||||||
var userId = "test-user-123";
|
|
||||||
var tenantId = "test-tenant-456";
|
|
||||||
|
|
||||||
var user = new NexusUser
|
|
||||||
{
|
|
||||||
Id = userId,
|
|
||||||
UserName = "testuser",
|
|
||||||
Email = "test@example.com",
|
|
||||||
TenantId = tenantId,
|
|
||||||
SubscriptionPlanId = 1
|
|
||||||
};
|
|
||||||
|
|
||||||
var book = new Book
|
|
||||||
{
|
|
||||||
Id = bookId,
|
|
||||||
Title = "My Epic Book",
|
|
||||||
UserId = userId,
|
|
||||||
TenantId = tenantId
|
|
||||||
};
|
|
||||||
|
|
||||||
var originalDraftRevision = new BookRevision
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
BookId = bookId,
|
|
||||||
VersionString = "Working Draft",
|
|
||||||
IsPublished = false,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
var oldChapterId1 = Guid.NewGuid();
|
|
||||||
var oldChapterId2 = Guid.NewGuid();
|
|
||||||
|
|
||||||
var chapter1 = new Chapter
|
|
||||||
{
|
|
||||||
Id = oldChapterId1,
|
|
||||||
BookRevisionId = originalDraftRevision.Id,
|
|
||||||
Title = "Chapter 1: The Beginning",
|
|
||||||
MarkdownContent = "Once upon a time...",
|
|
||||||
SortOrder = 1
|
|
||||||
};
|
|
||||||
|
|
||||||
var chapter2 = new Chapter
|
|
||||||
{
|
|
||||||
Id = oldChapterId2,
|
|
||||||
BookRevisionId = originalDraftRevision.Id,
|
|
||||||
Title = "Chapter 2: The Middle",
|
|
||||||
MarkdownContent = "Interesting things happened.",
|
|
||||||
SortOrder = 2
|
|
||||||
};
|
|
||||||
|
|
||||||
using (var context = new AppDbContext(_contextOptions))
|
|
||||||
{
|
|
||||||
context.Users.Add(user);
|
|
||||||
context.Books.Add(book);
|
|
||||||
context.BookRevisions.Add(originalDraftRevision);
|
|
||||||
context.Chapters.Add(chapter1);
|
|
||||||
context.Chapters.Add(chapter2);
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
|
|
||||||
// Link the book's draft revision
|
|
||||||
var dbBook = await context.Books.FindAsync(bookId);
|
|
||||||
dbBook!.CurrentDraftRevisionId = originalDraftRevision.Id;
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
var command = new PublishBookVersionCommand(
|
|
||||||
BookId: bookId,
|
|
||||||
CustomVersionString: "v1.0.0",
|
|
||||||
UserId: userId,
|
|
||||||
TenantId: tenantId
|
|
||||||
);
|
|
||||||
|
|
||||||
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await handler.Handle(command, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.IsSuccess.Should().BeTrue();
|
|
||||||
|
|
||||||
using (var context = new AppDbContext(_contextOptions))
|
|
||||||
{
|
|
||||||
var updatedBook = await context.Books
|
|
||||||
.Include(b => b.Revisions)
|
|
||||||
.ThenInclude(r => r.Chapters)
|
|
||||||
.FirstOrDefaultAsync(b => b.Id == bookId);
|
|
||||||
|
|
||||||
updatedBook.Should().NotBeNull();
|
|
||||||
updatedBook!.LivePublishedRevisionId.Should().Be(originalDraftRevision.Id);
|
|
||||||
updatedBook.CurrentDraftRevisionId.Should().NotBeNull();
|
|
||||||
updatedBook.CurrentDraftRevisionId.Should().NotBe(originalDraftRevision.Id);
|
|
||||||
|
|
||||||
// Fetch the old draft revision (now frozen / published)
|
|
||||||
var oldDraft = updatedBook.Revisions.FirstOrDefault(r => r.Id == originalDraftRevision.Id);
|
|
||||||
oldDraft.Should().NotBeNull();
|
|
||||||
oldDraft!.IsPublished.Should().BeTrue();
|
|
||||||
oldDraft.VersionString.Should().Be("v1.0.0");
|
|
||||||
oldDraft.PublishedAt.Should().NotBeNull();
|
|
||||||
|
|
||||||
// Fetch the new working draft revision
|
|
||||||
var newDraft = updatedBook.Revisions.FirstOrDefault(r => r.Id == updatedBook.CurrentDraftRevisionId);
|
|
||||||
newDraft.Should().NotBeNull();
|
|
||||||
newDraft!.IsPublished.Should().BeFalse();
|
|
||||||
newDraft.VersionString.Should().Be("Working Draft");
|
|
||||||
|
|
||||||
// Verify chapters were deep copied and received brand new GUIDs (Identity Reset)
|
|
||||||
newDraft.Chapters.Should().HaveCount(2);
|
|
||||||
|
|
||||||
var clonedChapter1 = newDraft.Chapters.FirstOrDefault(c => c.SortOrder == 1);
|
|
||||||
clonedChapter1.Should().NotBeNull();
|
|
||||||
clonedChapter1!.Title.Should().Be("Chapter 1: The Beginning");
|
|
||||||
clonedChapter1.MarkdownContent.Should().Be("Once upon a time...");
|
|
||||||
clonedChapter1.Id.Should().NotBe(oldChapterId1); // GUID must be regenerated
|
|
||||||
clonedChapter1.BookRevisionId.Should().Be(newDraft.Id);
|
|
||||||
|
|
||||||
var clonedChapter2 = newDraft.Chapters.FirstOrDefault(c => c.SortOrder == 2);
|
|
||||||
clonedChapter2.Should().NotBeNull();
|
|
||||||
clonedChapter2!.Title.Should().Be("Chapter 2: The Middle");
|
|
||||||
clonedChapter2.MarkdownContent.Should().Be("Interesting things happened.");
|
|
||||||
clonedChapter2.Id.Should().NotBe(oldChapterId2); // GUID must be regenerated
|
|
||||||
clonedChapter2.BookRevisionId.Should().Be(newDraft.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_WithMismatchedTenantId_ReturnsFailure()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var bookId = Guid.NewGuid();
|
|
||||||
var userId = "test-user-123";
|
|
||||||
var tenantId = "test-tenant-456";
|
|
||||||
|
|
||||||
var user = new NexusUser
|
|
||||||
{
|
|
||||||
Id = userId,
|
|
||||||
UserName = "testuser",
|
|
||||||
Email = "test@example.com",
|
|
||||||
TenantId = tenantId,
|
|
||||||
SubscriptionPlanId = 1
|
|
||||||
};
|
|
||||||
|
|
||||||
var book = new Book
|
|
||||||
{
|
|
||||||
Id = bookId,
|
|
||||||
Title = "My Epic Book",
|
|
||||||
UserId = userId,
|
|
||||||
TenantId = tenantId
|
|
||||||
};
|
|
||||||
|
|
||||||
using (var context = new AppDbContext(_contextOptions))
|
|
||||||
{
|
|
||||||
context.Users.Add(user);
|
|
||||||
context.Books.Add(book);
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send command with a different TenantId to check multi-tenancy isolation
|
|
||||||
var command = new PublishBookVersionCommand(
|
|
||||||
BookId: bookId,
|
|
||||||
CustomVersionString: "v1.0.0",
|
|
||||||
UserId: userId,
|
|
||||||
TenantId: "different-tenant-789"
|
|
||||||
);
|
|
||||||
|
|
||||||
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await handler.Handle(command, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.IsSuccess.Should().BeFalse();
|
|
||||||
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_WithMismatchedUserId_ReturnsFailure()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var bookId = Guid.NewGuid();
|
|
||||||
var userId = "test-user-123";
|
|
||||||
var tenantId = "test-tenant-456";
|
|
||||||
|
|
||||||
var user = new NexusUser
|
|
||||||
{
|
|
||||||
Id = userId,
|
|
||||||
UserName = "testuser",
|
|
||||||
Email = "test@example.com",
|
|
||||||
TenantId = tenantId,
|
|
||||||
SubscriptionPlanId = 1
|
|
||||||
};
|
|
||||||
|
|
||||||
var book = new Book
|
|
||||||
{
|
|
||||||
Id = bookId,
|
|
||||||
Title = "My Epic Book",
|
|
||||||
UserId = userId,
|
|
||||||
TenantId = tenantId
|
|
||||||
};
|
|
||||||
|
|
||||||
using (var context = new AppDbContext(_contextOptions))
|
|
||||||
{
|
|
||||||
context.Users.Add(user);
|
|
||||||
context.Books.Add(book);
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send command with a different UserId to check multi-tenancy isolation
|
|
||||||
var command = new PublishBookVersionCommand(
|
|
||||||
BookId: bookId,
|
|
||||||
CustomVersionString: "v1.0.0",
|
|
||||||
UserId: "different-user-789",
|
|
||||||
TenantId: tenantId
|
|
||||||
);
|
|
||||||
|
|
||||||
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await handler.Handle(command, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.IsSuccess.Should().BeFalse();
|
|
||||||
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_WithNonExistentBook_ReturnsFailure()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var command = new PublishBookVersionCommand(
|
|
||||||
BookId: Guid.NewGuid(),
|
|
||||||
CustomVersionString: "v1.0.0",
|
|
||||||
UserId: "user-1",
|
|
||||||
TenantId: "tenant-1"
|
|
||||||
);
|
|
||||||
|
|
||||||
var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await handler.Handle(command, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.IsSuccess.Should().BeFalse();
|
|
||||||
result.Errors.Should().Contain(e => e.Message.Contains("was not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_connection.Close();
|
|
||||||
_connection.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,5 @@
|
|||||||
<ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" />
|
<ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" />
|
||||||
<ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\src\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
<ProjectReference Include="..\..\src\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
||||||
<ProjectReference Include="..\..\src\NexusReader.Web\NexusReader.Web.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace NexusReader.Application.Tests.Queries;
|
|||||||
|
|
||||||
public class CheckDatabaseTest
|
public class CheckDatabaseTest
|
||||||
{
|
{
|
||||||
[Fact(Skip = "Requires live Postgres database in Docker")]
|
[Fact]
|
||||||
public async Task PrintDatabaseStats()
|
public async Task PrintDatabaseStats()
|
||||||
{
|
{
|
||||||
var configJson = await File.ReadAllTextAsync("../../../../../src/NexusReader.Web/appsettings.json");
|
var configJson = await File.ReadAllTextAsync("../../../../../src/NexusReader.Web/appsettings.json");
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FluentAssertions;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Moq;
|
|
||||||
using NexusReader.Application.Queries.Creator;
|
|
||||||
using NexusReader.Data.Persistence;
|
|
||||||
using NexusReader.Domain.Entities;
|
|
||||||
using NexusReader.Domain.Exceptions;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Tests.Queries;
|
|
||||||
|
|
||||||
public class CreatorDashboardTests : IDisposable
|
|
||||||
{
|
|
||||||
private readonly SqliteConnection _connection;
|
|
||||||
private readonly DbContextOptions<AppDbContext> _contextOptions;
|
|
||||||
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
|
|
||||||
|
|
||||||
public CreatorDashboardTests()
|
|
||||||
{
|
|
||||||
_connection = new SqliteConnection("DataSource=:memory:");
|
|
||||||
_connection.Open();
|
|
||||||
|
|
||||||
_contextOptions = new DbContextOptionsBuilder<AppDbContext>()
|
|
||||||
.UseSqlite(_connection)
|
|
||||||
.Options;
|
|
||||||
|
|
||||||
// Seed initial database schema
|
|
||||||
using var context = new AppDbContext(_contextOptions);
|
|
||||||
context.Database.EnsureCreated();
|
|
||||||
|
|
||||||
_dbContextFactoryMock = new Mock<IDbContextFactory<AppDbContext>>();
|
|
||||||
_dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(() => new AppDbContext(_contextOptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
private NexusUser CreateTestUser(string userId, string tenantId)
|
|
||||||
{
|
|
||||||
return new NexusUser
|
|
||||||
{
|
|
||||||
Id = userId,
|
|
||||||
UserName = $"user_{userId}",
|
|
||||||
Email = $"{userId}@example.com",
|
|
||||||
TenantId = tenantId,
|
|
||||||
SubscriptionPlanId = 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetCreatorDashboardData_WithValidUser_ProjectsCorrectlyAndNeverLoadsMarkdownToTracker()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var userId = "creator-123";
|
|
||||||
var tenantId = "tenant-abc";
|
|
||||||
var bookId = Guid.NewGuid();
|
|
||||||
|
|
||||||
var user = CreateTestUser(userId, tenantId);
|
|
||||||
|
|
||||||
var book = new Book
|
|
||||||
{
|
|
||||||
Id = bookId,
|
|
||||||
Title = "Authored Masterpiece",
|
|
||||||
UserId = userId,
|
|
||||||
TenantId = tenantId
|
|
||||||
};
|
|
||||||
|
|
||||||
var draft = new BookRevision
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
BookId = bookId,
|
|
||||||
VersionString = "Working Draft",
|
|
||||||
IsPublished = false,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
// Standard markdown content (length 58 characters -> estimated word count: 9 words)
|
|
||||||
var chapter = new Chapter
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
BookRevisionId = draft.Id,
|
|
||||||
Title = "Chapter One",
|
|
||||||
MarkdownContent = "This is a content snippet that contains exactly ten words.", // 58 chars
|
|
||||||
SortOrder = 1
|
|
||||||
};
|
|
||||||
|
|
||||||
using (var context = new AppDbContext(_contextOptions))
|
|
||||||
{
|
|
||||||
context.Users.Add(user);
|
|
||||||
context.Books.Add(book);
|
|
||||||
context.BookRevisions.Add(draft);
|
|
||||||
context.Chapters.Add(chapter);
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
|
|
||||||
// Link draft revision
|
|
||||||
var dbBook = await context.Books.FindAsync(bookId);
|
|
||||||
dbBook!.CurrentDraftRevisionId = draft.Id;
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
var query = new GetCreatorDashboardDataQuery(userId, tenantId);
|
|
||||||
var handler = new GetCreatorDashboardDataQueryHandler(_dbContextFactoryMock.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await handler.Handle(query, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.IsSuccess.Should().BeTrue();
|
|
||||||
result.Value.Should().NotBeNull();
|
|
||||||
result.Value.Books.Should().HaveCount(1);
|
|
||||||
|
|
||||||
var bookDto = result.Value.Books.First();
|
|
||||||
bookDto.Title.Should().Be("Authored Masterpiece");
|
|
||||||
bookDto.WordCount.Should().Be(58 / 6); // projected word count calculation check
|
|
||||||
bookDto.AggregatedReads.Should().Be(Math.Abs(bookId.GetHashCode() % 1000) + 120);
|
|
||||||
|
|
||||||
// Verify metrics are calculated
|
|
||||||
result.Value.Metrics.TotalReads.Should().Be(bookDto.AggregatedReads);
|
|
||||||
result.Value.Metrics.ActiveReaders.Should().BeGreaterThan(0);
|
|
||||||
result.Value.Metrics.GrossRevenue.Should().Be(bookDto.AggregatedReads * 1.49m);
|
|
||||||
result.Value.Metrics.AvgReadTimeMinutes.Should().Be(Math.Round((58 / 6) / 250.0, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetCreatorDashboardData_EnforcesTenantAndUserBoundaries()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var userId = "creator-123";
|
|
||||||
var tenantId = "tenant-abc";
|
|
||||||
var bookId = Guid.NewGuid();
|
|
||||||
|
|
||||||
var user = CreateTestUser(userId, tenantId);
|
|
||||||
|
|
||||||
var book = new Book
|
|
||||||
{
|
|
||||||
Id = bookId,
|
|
||||||
Title = "Authored Masterpiece",
|
|
||||||
UserId = userId,
|
|
||||||
TenantId = tenantId
|
|
||||||
};
|
|
||||||
|
|
||||||
using (var context = new AppDbContext(_contextOptions))
|
|
||||||
{
|
|
||||||
context.Users.Add(user);
|
|
||||||
context.Books.Add(book);
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query with mismatched tenant ID
|
|
||||||
var queryMismatchedTenant = new GetCreatorDashboardDataQuery(userId, "different-tenant");
|
|
||||||
var handler = new GetCreatorDashboardDataQueryHandler(_dbContextFactoryMock.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var resultMismatchedTenant = await handler.Handle(queryMismatchedTenant, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
resultMismatchedTenant.IsSuccess.Should().BeTrue();
|
|
||||||
resultMismatchedTenant.Value.Books.Should().BeEmpty();
|
|
||||||
resultMismatchedTenant.Value.Metrics.TotalReads.Should().Be(0);
|
|
||||||
|
|
||||||
// Query with mismatched user ID
|
|
||||||
var queryMismatchedUser = new GetCreatorDashboardDataQuery("different-user", tenantId);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var resultMismatchedUser = await handler.Handle(queryMismatchedUser, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
resultMismatchedUser.IsSuccess.Should().BeTrue();
|
|
||||||
resultMismatchedUser.Value.Books.Should().BeEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetBookRevisions_WithValidBook_ReturnsRevisionsOrderedByDate()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var userId = "creator-123";
|
|
||||||
var tenantId = "tenant-abc";
|
|
||||||
var bookId = Guid.NewGuid();
|
|
||||||
|
|
||||||
var user = CreateTestUser(userId, tenantId);
|
|
||||||
|
|
||||||
var book = new Book
|
|
||||||
{
|
|
||||||
Id = bookId,
|
|
||||||
Title = "Authored Masterpiece",
|
|
||||||
UserId = userId,
|
|
||||||
TenantId = tenantId
|
|
||||||
};
|
|
||||||
|
|
||||||
var revision1 = new BookRevision
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
BookId = bookId,
|
|
||||||
VersionString = "v1.0.0",
|
|
||||||
IsPublished = true,
|
|
||||||
CreatedAt = DateTime.UtcNow.AddMinutes(-5),
|
|
||||||
PublishedAt = DateTime.UtcNow.AddMinutes(-5)
|
|
||||||
};
|
|
||||||
|
|
||||||
var revision2 = new BookRevision
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
BookId = bookId,
|
|
||||||
VersionString = "Working Draft",
|
|
||||||
IsPublished = false,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
using (var context = new AppDbContext(_contextOptions))
|
|
||||||
{
|
|
||||||
context.Users.Add(user);
|
|
||||||
context.Books.Add(book);
|
|
||||||
context.BookRevisions.Add(revision1);
|
|
||||||
context.BookRevisions.Add(revision2);
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
var query = new GetBookRevisionsQuery(bookId, userId, tenantId);
|
|
||||||
var handler = new GetBookRevisionsQueryHandler(_dbContextFactoryMock.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await handler.Handle(query, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.IsSuccess.Should().BeTrue();
|
|
||||||
result.Value.Should().HaveCount(2);
|
|
||||||
// Ordered by CreatedAt descending
|
|
||||||
result.Value[0].VersionString.Should().Be("Working Draft");
|
|
||||||
result.Value[1].VersionString.Should().Be("v1.0.0");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetBookRevisions_WithMismatchedUserOrTenant_ReturnsFailure()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var userId = "creator-123";
|
|
||||||
var tenantId = "tenant-abc";
|
|
||||||
var bookId = Guid.NewGuid();
|
|
||||||
|
|
||||||
var user = CreateTestUser(userId, tenantId);
|
|
||||||
|
|
||||||
var book = new Book
|
|
||||||
{
|
|
||||||
Id = bookId,
|
|
||||||
Title = "Authored Masterpiece",
|
|
||||||
UserId = userId,
|
|
||||||
TenantId = tenantId
|
|
||||||
};
|
|
||||||
|
|
||||||
using (var context = new AppDbContext(_contextOptions))
|
|
||||||
{
|
|
||||||
context.Users.Add(user);
|
|
||||||
context.Books.Add(book);
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
var handler = new GetBookRevisionsQueryHandler(_dbContextFactoryMock.Object);
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
var queryMismatchedTenant = new GetBookRevisionsQuery(bookId, userId, "different-tenant");
|
|
||||||
var resultTenant = await handler.Handle(queryMismatchedTenant, CancellationToken.None);
|
|
||||||
resultTenant.IsSuccess.Should().BeFalse();
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
using FluentAssertions;
|
|
||||||
using NexusReader.Infrastructure.Services;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Tests.Services;
|
|
||||||
|
|
||||||
public class HtmlSanitizerServiceTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Sanitize_WithSafeInput_ReturnsSameInput()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new HtmlSanitizerService();
|
|
||||||
var input = "<p>This is a safe <strong>paragraph</strong>.</p>";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.Sanitize(input);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.Should().Be(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Sanitize_WithScriptTag_StripsScriptTag()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new HtmlSanitizerService();
|
|
||||||
var input = "<p>Hello</p><script>alert('xss')</script>";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.Sanitize(input);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.Should().NotContain("<script>");
|
|
||||||
result.Should().NotContain("alert");
|
|
||||||
result.Should().Be("<p>Hello</p>");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Sanitize_WithOnEventHandlerAttribute_StripsOnError()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new HtmlSanitizerService();
|
|
||||||
var input = "<img src=\"x\" onerror=\"alert(1)\" />";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.Sanitize(input);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.Should().NotContain("onerror");
|
|
||||||
result.Should().NotContain("alert");
|
|
||||||
result.Should().Contain("<img src=\"x\">");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Sanitize_WithMarkdownCodeBlockContainingAngleBrackets_DoesNotStripAngleBrackets()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new HtmlSanitizerService();
|
|
||||||
var input = "Here is some code:\n\n```csharp\nif (x < y && y > z) { Console.WriteLine(\"test\"); }\n```";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.Sanitize(input);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.Should().Contain("<");
|
|
||||||
result.Should().Contain(">");
|
|
||||||
result.Should().NotContain("<script>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
using FluentAssertions;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Tests.Services;
|
|
||||||
|
|
||||||
public class ValidateImageSignatureTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Validate_PNG_WithCorrectSignature_ReturnsTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
byte[] pngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
|
||||||
string fileName = "image.png";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
bool isValid = ImageValidator.ValidateImageSignature(pngBytes, fileName, out string contentType);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
isValid.Should().BeTrue();
|
|
||||||
contentType.Should().Be("image/png");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_JPEG_WithCorrectSignature_ReturnsTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
byte[] jpegBytes = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46];
|
|
||||||
string fileName = "photo.jpg";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
bool isValid = ImageValidator.ValidateImageSignature(jpegBytes, fileName, out string contentType);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
isValid.Should().BeTrue();
|
|
||||||
contentType.Should().Be("image/jpeg");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_WEBP_WithCorrectSignature_ReturnsTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
byte[] webpBytes = [
|
|
||||||
0x52, 0x49, 0x46, 0x46, // RIFF
|
|
||||||
0x00, 0x00, 0x00, 0x00, // length
|
|
||||||
0x57, 0x45, 0x42, 0x50 // WEBP
|
|
||||||
];
|
|
||||||
string fileName = "graphic.webp";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
bool isValid = ImageValidator.ValidateImageSignature(webpBytes, fileName, out string contentType);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
isValid.Should().BeTrue();
|
|
||||||
contentType.Should().Be("image/webp");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_GIF_WithCorrectSignature_ReturnsTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
byte[] gifBytes = [
|
|
||||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a
|
|
||||||
0x01, 0x00, 0x01, 0x00
|
|
||||||
];
|
|
||||||
string fileName = "animation.gif";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
bool isValid = ImageValidator.ValidateImageSignature(gifBytes, fileName, out string contentType);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
isValid.Should().BeTrue();
|
|
||||||
contentType.Should().Be("image/gif");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_WithMismatchingExtension_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange: Valid PNG bytes but JPEG extension
|
|
||||||
byte[] pngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
|
||||||
string fileName = "spoofed.jpg";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
bool isValid = ImageValidator.ValidateImageSignature(pngBytes, fileName, out string contentType);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
isValid.Should().BeFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_WithInvalidSignature_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange: Plain text bytes but PNG extension
|
|
||||||
byte[] txtBytes = [0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0x77, 0x6F]; // "Hello wo"
|
|
||||||
string fileName = "not_a_png.png";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
bool isValid = ImageValidator.ValidateImageSignature(txtBytes, fileName, out string contentType);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
isValid.Should().BeFalse();
|
|
||||||
contentType.Should().BeEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_WithShortBytes_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
byte[] shortBytes = [0x89, 0x50];
|
|
||||||
string fileName = "short.png";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
bool isValid = ImageValidator.ValidateImageSignature(shortBytes, fileName, out string contentType);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
isValid.Should().BeFalse();
|
|
||||||
contentType.Should().BeEmpty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user